oidc-middleware.mjs
7.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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
}