Initial pass of code.
Showing
7 changed files
with
432 additions
and
0 deletions
package-lock.json
0 → 100644
This diff is collapsed.
Click to expand it.
package.json
0 → 100644
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 | } |
src/astro-integration.mjs
0 → 100644
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 | } |
src/fix-racy-store.mjs
0 → 100644
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 |
src/index.mjs
0 → 100644
src/oidc-middleware.mjs
0 → 100644
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 | } |
src/options.mjs
0 → 100644
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 |
-
Please register or sign in to post a comment