d7be2e0c by Adam Heath

Initial pass of code.

1 parent 52c693a0
1 {
2 "name": "astro-oidc-middleware",
3 "type": "module",
4 "exports": {
5 ".": "./src/index.mjs"
6 },
7 "dependencies": {
8 "cors": "^2.8.5",
9 "express-session": "^1.18.0",
10 "openid-client": "^5.6.5",
11 "passport": "^0.7.0",
12 "read-env": "^2.0.0",
13 "redirecter": "^0.2.3",
14 "session-file-store": "^1.5.0"
15 }
16 }
1 import 'dotenv/config'
2 import oidcMiddleware, { fileStoreOptions } from './oidc-middleware.mjs'
3 import { oidcParams } from './options.mjs'
4
5 const clientLocalsSymbol = Symbol.for("astro.locals")
6
7 export default (options) => {
8 return {
9 name: 'session',
10 hooks: {
11 'astro:server:setup': async ({ server }) => {
12 server.middlewares.use(await oidcMiddleware({ oidc: oidcParams() }, fileStoreOptions))
13 server.middlewares.use((req, res, next) => {
14 req[ clientLocalsSymbol ] = res.locals
15 next()
16 })
17 },
18 },
19 }
20 }
1 import { EventEmitter } from 'node:events'
2 import { isEqual } from 'lodash-es'
3
4 const READ = Symbol('READ')
5 const TOUCH = Symbol('TOUCH')
6 const WRITE = Symbol('WRITE')
7
8 export default function (session) {
9 var Store = session.Store
10
11 function FixRacyStore(options = {}) {
12 var self = this
13
14 Store.call(this, options)
15 this.options = options
16 this.readEvents = new EventEmitter()
17 this.writeEvents = new EventEmitter()
18 this.cache = {}
19 this.clearCache = {}
20 }
21
22 FixRacyStore.prototype.__proto__ = Store.prototype
23
24 FixRacyStore.prototype._setCache = function(sessionId, session, newMode, downgrade) {
25 const { [ sessionId ]: { timerId, writes, mode = READ } = {} } = this.cache
26 //console.log(Date.now() + ':setCache', {sessionId, mode, newMode, downgrade})
27 const newEntry = {timerId, writes, session, mode: (mode === READ || downgrade) ? newMode : mode}
28 this.cache[ sessionId ] = newEntry
29 if (mode === newMode && newMode === TOUCH) {
30 return newEntry
31 }
32 clearTimeout(timerId)
33 if (newEntry.mode !== WRITE) {
34 newEntry.timerId = setTimeout(() => {
35 try {
36 const { [ sessionId ]: { mode } } = this.cache
37 if (mode === TOUCH) {
38 //console.log(Date.now() + ':flushing cache', sessionId)
39 this._flush(sessionId)
40 } else {
41 //console.log(Date.now() + ':clearing cache', sessionId)
42 delete this.cache[ sessionId ]
43 }
44 } catch (e) {
45 console.error(e)
46 }
47 }, 1000)
48 }
49 return newEntry
50 }
51
52 FixRacyStore.prototype.get = function(sessionId, callback = (err, session) => {}) {
53 const { [ sessionId ]: { session } = {} } = this.cache
54 if (session) {
55 //console.log('read from memory', {sessionId})
56 callback(null, session)
57 return
58 }
59 this.readEvents.once(sessionId, callback)
60 if (this.readEvents.listenerCount(sessionId) === 1) {
61 //console.log('issuing get', {sessionId})
62 this.options.store.get(sessionId, (err, session) => {
63 //console.log('got session', {sessionId})
64 if (!this.readEvents.listenerCount(sessionId)) {
65 //console.log('race condition fixed!')
66 }
67 this._setCache(sessionId, session, READ)
68 this.readEvents.emit(sessionId, err, session)
69 })
70 } else {
71 //console.log('get already issued', {sessionId})
72 }
73 }
74
75 FixRacyStore.prototype._flush = function (sessionId, ...callbacks) {
76 const { session } = this.cache[ sessionId ]
77 //console.log(Date.now() + ':writing', {sessionId, callbacks: callbacks.length})
78 this.options.store.set(sessionId, session, (err, session) => {
79 callbacks.forEach(callback => callback(err, session))
80 const { [ sessionId ]: cacheEntry } = this.cache
81 const { writes } = cacheEntry
82 cacheEntry.writes = undefined
83 if (writes === undefined || writes.length === 0) {
84 this._setCache(sessionId, session, READ, true)
85 } else {
86 this._flush(sessionId, ...writes)
87 }
88 })
89 }
90
91 FixRacyStore.prototype.set = function (sessionId, session, callback) {
92 if (isEqual(this.cache[ sessionId ], session)) {
93 //console.log('set:isEqual', {sessionId})
94 callback(null, session)
95 return
96 }
97 const cacheEntry = this._setCache(sessionId, session, WRITE)
98 //console.log(Date.now() + ':set', {sessionId, cacheEntry})
99 this.readEvents.emit(sessionId, null, session)
100 if (cacheEntry.writes === undefined) {
101 cacheEntry.writes = []
102 this._flush(sessionId, callback)
103 } else {
104 cacheEntry.writes.push(callback)
105 }
106 }
107
108 FixRacyStore.prototype.touch = function (sessionId, session, callback) {
109 //console.log(Date.now() + ':touch', {sessionId})
110 const cacheEntry = this._setCache(sessionId, session, TOUCH)
111 callback(null, session)
112 return
113 }
114
115 FixRacyStore.prototype.destroy = function (sessionId, callback) {
116 this.readEvents.removeAllListeners(sessionId)
117 this.options.store.destroy(sessionId, callback)
118 }
119
120 FixRacyStore.prototype.clear = function (callback) {
121 this.readEvents.removeAllListeners()
122 this.options.store.clear(callback)
123 }
124
125 FixRacyStore.prototype.length = function (callback) {
126 this.options.store.length(callback)
127 }
128
129 FixRacyStore.prototype.list = function (callback) {
130 this.options.store.list(callback)
131 }
132
133 FixRacyStore.prototype.expired = function (sessionId, callback) {
134 this.options.store.expired(sessionId, callback)
135 }
136
137 return FixRacyStore
138 }
139
1 export { default as FixRacyStore } from './fix-racy-store.mjs'
2 export { default as oidcMiddleware, fileStoreOptions } from './oidc-middleware.mjs'
3 export { readEnvOptions, oidcParams } from './options.mjs'
4 export { default as AstroIntegration } from './astro-integration.mjs'
1 import express from 'express'
2 import ExpressSession from 'express-session'
3 import fileStore from 'session-file-store'
4 import { merge } from 'lodash-es'
5 import path from 'path'
6 import { URL, fileURLToPath, parse, format } from 'url'
7 import { Issuer, Strategy } from 'openid-client'
8 import passport from 'passport'
9 import cors from 'cors'
10 import qs from 'qs'
11 import redirect from 'redirecter'
12
13 import FixRacyStore from './fix-racy-store.mjs'
14
15 const defaultOptions = {
16 auth: {
17 prefix: '/',
18 login: '/login',
19 loginReturnTo: '/',
20 callback: '/login/callback',
21 logout: '/logout',
22 logoutReturnTo: '/',
23 refreshToken: '/login/refresh/token',
24 tokens: '/login/tokens',
25 },
26 session: {
27 secret: 'some-secret',
28 store: async (options) => new ExpressSession.MemoryStore(),
29 },
30 oidc: {
31 enabled: true,
32 },
33 }
34
35 export const fileStoreOptions = {
36 session: {
37 fileStore: {
38 path: path.join(path.dirname(fileURLToPath(import.meta.url)), '../node_modules/.sessions'),
39 },
40 store: async (options) => {
41 const racyStoreOptions = {
42 store: new (fileStore(ExpressSession))(options.session.fileStore),
43 }
44 return new (FixRacyStore(ExpressSession))(racyStoreOptions)
45 },
46 },
47 }
48
49 const getQuery = (req) => {
50 const { originalUrl } = req
51 const url = new URL(originalUrl, 'http://localhost')
52 const result = {}
53 for(const [ key, value ] of url.searchParams) {
54 result[ key ] = value;
55 }
56 return result;
57 }
58
59 const parseRawToken = (rawToken, def) => {
60 return rawToken ? JSON.parse(Buffer.from(rawToken.split('.')[1], 'base64').toString()) : def
61 }
62
63 export default async (...options) => {
64 options = merge({}, defaultOptions, ...options)
65 console.log('options', options.oidc)
66 const app = express.Router()
67
68 const sessionStore = await options.session.store(options)
69
70 app.use(cors({
71 allowedHeaders: ['authorization', 'content-type'],
72 }))
73 app.use(ExpressSession({
74 secret: options.session.secret,
75 resave: false,
76 saveUninitialized: true,
77 store: sessionStore,
78 }))
79
80 const fetchOpenidClient = async () => {
81 const issuer = await Issuer.discover(options.oidc.issuer)
82 return new issuer.Client({
83 client_id: options.oidc.clientId,
84 client_secret: options.oidc.clientSecret,
85 redirect_uris: [ options.oidc.clientUrlBase + (options.auth.prefix === '/' ? '' : options.auth.prefix) + '/login/callback' ],
86 token_endpoint_auth_method: 'client_secret_post',
87 });
88 }
89
90 let oidcClient
91 const getOpenidClient = async () => {
92 if (!oidcClient) {
93 oidcClient = await fetchOpenidClient()
94 setTimeout(() => oidcClient = null, 60000)
95 }
96 return oidcClient
97 }
98
99 const createPassportUser = (tokenSet, userInfo) => ({ claims: tokenSet.claims(), userInfo, tokenSet })
100 const refreshToken = async (req, force = false) => {
101 const { session } = req
102 const { passport: { user: { tokenSet } = {} } = {} } = session
103 try {
104 if (tokenSet) {
105 const now = Date.now() / 1000
106 const expires_at = tokenSet.expires_at
107 const { exp } = parseRawToken(tokenSet.refresh_token)
108 //console.log('checking refresh', {now, expires_at, exp, force})
109 if (force || now + 60 > expires_at) {
110 //console.log('trying for token refresh', {refresh_token: tokenSet.refresh_token})
111 const oidcClient = await getOpenidClient()
112 const newTokenSet = await oidcClient.refresh(tokenSet.refresh_token)
113 const { exp: newExp } = parseRawToken(newTokenSet.refresh_token)
114 //console.log('new refresh_token', {newTokenSet, newExp})
115 if (newTokenSet) {
116 const newUserInfo = await oidcClient.userinfo(newTokenSet.access_token)
117 delete session.passport
118 session.passport = { user: createPassportUser(newTokenSet, newUserInfo) }
119 }
120 }
121 return session.passport
122 }
123 } catch (e) {
124 console.error(e)
125 await new Promise((resolve, reject) => {
126 req.logout(err => {
127 if (err) {
128 reject(err)
129 } else {
130 resolve()
131 }
132 })
133 })
134 }
135 }
136
137 if (options.oidc.enabled) {
138 const auth = express.Router()
139 if (options.auth.prefix !== '/') {
140 app.use(options.auth.prefix, auth)
141 } else {
142 app.use(auth)
143 }
144 auth.use(passport.initialize())
145 auth.use(passport.session())
146
147 passport.use('oidc', new Strategy({ client: await getOpenidClient()}, (tokenSet, userInfo, done) => {
148 done(null, createPassportUser(tokenSet, userInfo))
149 }))
150 passport.serializeUser((user, done) => {
151 //console.log('serializeUser', user)
152 done(null, user)
153 })
154 passport.deserializeUser((user, done) => {
155 //console.log('deserializeUser', user)
156 done(null, user)
157 })
158 auth.get(options.auth.login, (req, res, next) => {
159 const { returnTo = options.auth.loginReturnTo } = getQuery(req)
160 const state = Buffer.from(JSON.stringify({ returnTo })).toString('base64')
161 passport.authenticate('oidc', { state })(req, res, next)
162 })
163 auth.get(options.auth.callback, passport.authenticate('oidc', { failureRedirect: '/' }), (req, res) => {
164 try {
165 const { state } = getQuery(req)
166 const { returnTo } = state ? JSON.parse(Buffer.from(state, 'base64').toString()) : {}
167 if (typeof returnTo === 'string' && returnTo.startsWith('/')) {
168 return redirect(req, res, returnTo)
169 }
170 } catch (e) {
171 console.error(e)
172 }
173 console.log('callback:redirect to /')
174 redirect(req, res, '/')
175 }, (err, req, res, next) => {
176 if (err) {
177 console.error('callback error', err)
178 return redirect(req, res, '/')
179 }
180 next()
181 })
182 auth.get(options.auth.logout, (req, res, next) => {
183 const { session } = req
184 const { passport: { user: { tokenSet } = {} } = {} } = session
185 const { returnTo = options.auth.logoutReturnTo } = getQuery(req)
186 req.logout(async err => {
187 if (!tokenSet) {
188 return redirect(req, res, returnTo)
189 }
190 const oidcClient = await getOpenidClient()
191 const originalUrl = `http${req.socket.encrypted ? 's' : ''}://${req.headers.host}${req.originalUrl}`
192 const returnToURL = new URL(returnTo, originalUrl)
193 const target = oidcClient.endSessionUrl({ id_token_hint: tokenSet.id_token, post_logout_redirect_uri: returnToURL.toString() })
194 return redirect(req, res, target)
195 })
196 })
197 auth.get(options.auth.refreshToken, async (req, res, next) => {
198 await refreshToken(req, true)
199 const { session } = req
200 const { passport = {} } = session
201 const { user: { tokenSet: { access_token, expires_at } = {} } = {} } = passport
202 res.end(JSON.stringify({ access_token, expires_at }))
203 })
204 auth.get(options.auth.tokens, async (req, res, next) => {
205 const passport = await refreshToken(req, true)
206 if (passport) {
207 const { user: { tokenSet } } = passport
208 const { access_token, expires_at, token_type, id_token } = tokenSet
209 res.writeHead(200)
210 res.end(JSON.stringify({ access_token, expires_at, token_type, id_token }))
211 } else {
212 res.writeHead(401)
213 res.end('')
214 }
215 })
216 }
217 app.use(async (req, res, next) => {
218 await refreshToken(req, false)
219 const { session } = req
220 const { passport: { user: { tokenSet } = {} } = {} } = session
221 res.locals = Object.assign(res.locals || {}, {
222 session,
223 async refreshToken() {
224 return refreshToken(req, true)
225 },
226 })
227 await next()
228 })
229 return app
230 }
1 import { default as _ } from 'lodash'
2 import { readEnv } from 'read-env'
3
4 export const readEnvOptions = {
5 source: process.env,
6 separator: '__',
7 format: 'camelcase',
8 sanitize: {
9 object: false,
10 array: false,
11 bool: true,
12 'int': true,
13 'float': true,
14 },
15 }
16
17 export const oidcParams = () => _.pickBy(_.merge({
18 //bearerOnly: true,
19 }, readEnv('OIDC', readEnvOptions)), (value, key) => {
20 return !_.startsWith(key, 'http')
21 })
22
23