d7be2e0c by Adam Heath

Initial pass of code.

1 parent 52c693a0
{
"name": "astro-oidc-middleware",
"type": "module",
"exports": {
".": "./src/index.mjs"
},
"dependencies": {
"cors": "^2.8.5",
"express-session": "^1.18.0",
"openid-client": "^5.6.5",
"passport": "^0.7.0",
"read-env": "^2.0.0",
"redirecter": "^0.2.3",
"session-file-store": "^1.5.0"
}
}
import 'dotenv/config'
import oidcMiddleware, { fileStoreOptions } from './oidc-middleware.mjs'
import { oidcParams } from './options.mjs'
const clientLocalsSymbol = Symbol.for("astro.locals")
export default (options) => {
return {
name: 'session',
hooks: {
'astro:server:setup': async ({ server }) => {
server.middlewares.use(await oidcMiddleware({ oidc: oidcParams() }, fileStoreOptions))
server.middlewares.use((req, res, next) => {
req[ clientLocalsSymbol ] = res.locals
next()
})
},
},
}
}
import { EventEmitter } from 'node:events'
import { isEqual } from 'lodash-es'
const READ = Symbol('READ')
const TOUCH = Symbol('TOUCH')
const WRITE = Symbol('WRITE')
export default function (session) {
var Store = session.Store
function FixRacyStore(options = {}) {
var self = this
Store.call(this, options)
this.options = options
this.readEvents = new EventEmitter()
this.writeEvents = new EventEmitter()
this.cache = {}
this.clearCache = {}
}
FixRacyStore.prototype.__proto__ = Store.prototype
FixRacyStore.prototype._setCache = function(sessionId, session, newMode, downgrade) {
const { [ sessionId ]: { timerId, writes, mode = READ } = {} } = this.cache
//console.log(Date.now() + ':setCache', {sessionId, mode, newMode, downgrade})
const newEntry = {timerId, writes, session, mode: (mode === READ || downgrade) ? newMode : mode}
this.cache[ sessionId ] = newEntry
if (mode === newMode && newMode === TOUCH) {
return newEntry
}
clearTimeout(timerId)
if (newEntry.mode !== WRITE) {
newEntry.timerId = setTimeout(() => {
try {
const { [ sessionId ]: { mode } } = this.cache
if (mode === TOUCH) {
//console.log(Date.now() + ':flushing cache', sessionId)
this._flush(sessionId)
} else {
//console.log(Date.now() + ':clearing cache', sessionId)
delete this.cache[ sessionId ]
}
} catch (e) {
console.error(e)
}
}, 1000)
}
return newEntry
}
FixRacyStore.prototype.get = function(sessionId, callback = (err, session) => {}) {
const { [ sessionId ]: { session } = {} } = this.cache
if (session) {
//console.log('read from memory', {sessionId})
callback(null, session)
return
}
this.readEvents.once(sessionId, callback)
if (this.readEvents.listenerCount(sessionId) === 1) {
//console.log('issuing get', {sessionId})
this.options.store.get(sessionId, (err, session) => {
//console.log('got session', {sessionId})
if (!this.readEvents.listenerCount(sessionId)) {
//console.log('race condition fixed!')
}
this._setCache(sessionId, session, READ)
this.readEvents.emit(sessionId, err, session)
})
} else {
//console.log('get already issued', {sessionId})
}
}
FixRacyStore.prototype._flush = function (sessionId, ...callbacks) {
const { session } = this.cache[ sessionId ]
//console.log(Date.now() + ':writing', {sessionId, callbacks: callbacks.length})
this.options.store.set(sessionId, session, (err, session) => {
callbacks.forEach(callback => callback(err, session))
const { [ sessionId ]: cacheEntry } = this.cache
const { writes } = cacheEntry
cacheEntry.writes = undefined
if (writes === undefined || writes.length === 0) {
this._setCache(sessionId, session, READ, true)
} else {
this._flush(sessionId, ...writes)
}
})
}
FixRacyStore.prototype.set = function (sessionId, session, callback) {
if (isEqual(this.cache[ sessionId ], session)) {
//console.log('set:isEqual', {sessionId})
callback(null, session)
return
}
const cacheEntry = this._setCache(sessionId, session, WRITE)
//console.log(Date.now() + ':set', {sessionId, cacheEntry})
this.readEvents.emit(sessionId, null, session)
if (cacheEntry.writes === undefined) {
cacheEntry.writes = []
this._flush(sessionId, callback)
} else {
cacheEntry.writes.push(callback)
}
}
FixRacyStore.prototype.touch = function (sessionId, session, callback) {
//console.log(Date.now() + ':touch', {sessionId})
const cacheEntry = this._setCache(sessionId, session, TOUCH)
callback(null, session)
return
}
FixRacyStore.prototype.destroy = function (sessionId, callback) {
this.readEvents.removeAllListeners(sessionId)
this.options.store.destroy(sessionId, callback)
}
FixRacyStore.prototype.clear = function (callback) {
this.readEvents.removeAllListeners()
this.options.store.clear(callback)
}
FixRacyStore.prototype.length = function (callback) {
this.options.store.length(callback)
}
FixRacyStore.prototype.list = function (callback) {
this.options.store.list(callback)
}
FixRacyStore.prototype.expired = function (sessionId, callback) {
this.options.store.expired(sessionId, callback)
}
return FixRacyStore
}
export { default as FixRacyStore } from './fix-racy-store.mjs'
export { default as oidcMiddleware, fileStoreOptions } from './oidc-middleware.mjs'
export { readEnvOptions, oidcParams } from './options.mjs'
export { default as AstroIntegration } from './astro-integration.mjs'
import express from 'express'
import ExpressSession from 'express-session'
import fileStore from 'session-file-store'
import { merge } from 'lodash-es'
import path from 'path'
import { URL, fileURLToPath, parse, format } from 'url'
import { Issuer, Strategy } from 'openid-client'
import passport from 'passport'
import cors from 'cors'
import qs from 'qs'
import redirect from 'redirecter'
import FixRacyStore from './fix-racy-store.mjs'
const defaultOptions = {
auth: {
prefix: '/',
login: '/login',
loginReturnTo: '/',
callback: '/login/callback',
logout: '/logout',
logoutReturnTo: '/',
refreshToken: '/login/refresh/token',
tokens: '/login/tokens',
},
session: {
secret: 'some-secret',
store: async (options) => new ExpressSession.MemoryStore(),
},
oidc: {
enabled: true,
},
}
export const fileStoreOptions = {
session: {
fileStore: {
path: path.join(path.dirname(fileURLToPath(import.meta.url)), '../node_modules/.sessions'),
},
store: async (options) => {
const racyStoreOptions = {
store: new (fileStore(ExpressSession))(options.session.fileStore),
}
return new (FixRacyStore(ExpressSession))(racyStoreOptions)
},
},
}
const getQuery = (req) => {
const { originalUrl } = req
const url = new URL(originalUrl, 'http://localhost')
const result = {}
for(const [ key, value ] of url.searchParams) {
result[ key ] = value;
}
return result;
}
const parseRawToken = (rawToken, def) => {
return rawToken ? JSON.parse(Buffer.from(rawToken.split('.')[1], 'base64').toString()) : def
}
export default async (...options) => {
options = merge({}, defaultOptions, ...options)
console.log('options', options.oidc)
const app = express.Router()
const sessionStore = await options.session.store(options)
app.use(cors({
allowedHeaders: ['authorization', 'content-type'],
}))
app.use(ExpressSession({
secret: options.session.secret,
resave: false,
saveUninitialized: true,
store: sessionStore,
}))
const fetchOpenidClient = async () => {
const issuer = await Issuer.discover(options.oidc.issuer)
return new issuer.Client({
client_id: options.oidc.clientId,
client_secret: options.oidc.clientSecret,
redirect_uris: [ options.oidc.clientUrlBase + (options.auth.prefix === '/' ? '' : options.auth.prefix) + '/login/callback' ],
token_endpoint_auth_method: 'client_secret_post',
});
}
let oidcClient
const getOpenidClient = async () => {
if (!oidcClient) {
oidcClient = await fetchOpenidClient()
setTimeout(() => oidcClient = null, 60000)
}
return oidcClient
}
const createPassportUser = (tokenSet, userInfo) => ({ claims: tokenSet.claims(), userInfo, tokenSet })
const refreshToken = async (req, force = false) => {
const { session } = req
const { passport: { user: { tokenSet } = {} } = {} } = session
try {
if (tokenSet) {
const now = Date.now() / 1000
const expires_at = tokenSet.expires_at
const { exp } = parseRawToken(tokenSet.refresh_token)
//console.log('checking refresh', {now, expires_at, exp, force})
if (force || now + 60 > expires_at) {
//console.log('trying for token refresh', {refresh_token: tokenSet.refresh_token})
const oidcClient = await getOpenidClient()
const newTokenSet = await oidcClient.refresh(tokenSet.refresh_token)
const { exp: newExp } = parseRawToken(newTokenSet.refresh_token)
//console.log('new refresh_token', {newTokenSet, newExp})
if (newTokenSet) {
const newUserInfo = await oidcClient.userinfo(newTokenSet.access_token)
delete session.passport
session.passport = { user: createPassportUser(newTokenSet, newUserInfo) }
}
}
return session.passport
}
} catch (e) {
console.error(e)
await new Promise((resolve, reject) => {
req.logout(err => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
}
if (options.oidc.enabled) {
const auth = express.Router()
if (options.auth.prefix !== '/') {
app.use(options.auth.prefix, auth)
} else {
app.use(auth)
}
auth.use(passport.initialize())
auth.use(passport.session())
passport.use('oidc', new Strategy({ client: await getOpenidClient()}, (tokenSet, userInfo, done) => {
done(null, createPassportUser(tokenSet, userInfo))
}))
passport.serializeUser((user, done) => {
//console.log('serializeUser', user)
done(null, user)
})
passport.deserializeUser((user, done) => {
//console.log('deserializeUser', user)
done(null, user)
})
auth.get(options.auth.login, (req, res, next) => {
const { returnTo = options.auth.loginReturnTo } = getQuery(req)
const state = Buffer.from(JSON.stringify({ returnTo })).toString('base64')
passport.authenticate('oidc', { state })(req, res, next)
})
auth.get(options.auth.callback, passport.authenticate('oidc', { failureRedirect: '/' }), (req, res) => {
try {
const { state } = getQuery(req)
const { returnTo } = state ? JSON.parse(Buffer.from(state, 'base64').toString()) : {}
if (typeof returnTo === 'string' && returnTo.startsWith('/')) {
return redirect(req, res, returnTo)
}
} catch (e) {
console.error(e)
}
console.log('callback:redirect to /')
redirect(req, res, '/')
}, (err, req, res, next) => {
if (err) {
console.error('callback error', err)
return redirect(req, res, '/')
}
next()
})
auth.get(options.auth.logout, (req, res, next) => {
const { session } = req
const { passport: { user: { tokenSet } = {} } = {} } = session
const { returnTo = options.auth.logoutReturnTo } = getQuery(req)
req.logout(async err => {
if (!tokenSet) {
return redirect(req, res, returnTo)
}
const oidcClient = await getOpenidClient()
const originalUrl = `http${req.socket.encrypted ? 's' : ''}://${req.headers.host}${req.originalUrl}`
const returnToURL = new URL(returnTo, originalUrl)
const target = oidcClient.endSessionUrl({ id_token_hint: tokenSet.id_token, post_logout_redirect_uri: returnToURL.toString() })
return redirect(req, res, target)
})
})
auth.get(options.auth.refreshToken, async (req, res, next) => {
await refreshToken(req, true)
const { session } = req
const { passport = {} } = session
const { user: { tokenSet: { access_token, expires_at } = {} } = {} } = passport
res.end(JSON.stringify({ access_token, expires_at }))
})
auth.get(options.auth.tokens, async (req, res, next) => {
const passport = await refreshToken(req, true)
if (passport) {
const { user: { tokenSet } } = passport
const { access_token, expires_at, token_type, id_token } = tokenSet
res.writeHead(200)
res.end(JSON.stringify({ access_token, expires_at, token_type, id_token }))
} else {
res.writeHead(401)
res.end('')
}
})
}
app.use(async (req, res, next) => {
await refreshToken(req, false)
const { session } = req
const { passport: { user: { tokenSet } = {} } = {} } = session
res.locals = Object.assign(res.locals || {}, {
session,
async refreshToken() {
return refreshToken(req, true)
},
})
await next()
})
return app
}
import { default as _ } from 'lodash'
import { readEnv } from 'read-env'
export const readEnvOptions = {
source: process.env,
separator: '__',
format: 'camelcase',
sanitize: {
object: false,
array: false,
bool: true,
'int': true,
'float': true,
},
}
export const oidcParams = () => _.pickBy(_.merge({
//bearerOnly: true,
}, readEnv('OIDC', readEnvOptions)), (value, key) => {
return !_.startsWith(key, 'http')
})