-
Notifications
You must be signed in to change notification settings - Fork 8
/
index.js
235 lines (191 loc) · 7.59 KB
/
index.js
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
231
232
233
234
235
'use strict'
const { Unauthorized, InternalServerError } = require('http-errors')
const fastifyPlugin = require('fastify-plugin')
const fastifyJwt = require('@fastify/jwt')
const NodeCache = require('node-cache')
const { createPublicKey } = require('node:crypto')
const forbiddenOptions = ['algorithms']
const errorMessages = {
badHeaderFormat: 'Authorization header should be in format: Bearer [token].',
expiredToken: 'Expired token.',
invalidAlgorithm: 'Unsupported token.',
invalidToken: 'Invalid token.',
jwksHttpError: 'Unable to get the JWS due to a HTTP error',
missingHeader: 'Missing Authorization HTTP header.',
missingKey: 'Missing Key: Public key must be provided',
missingOptions: 'Please provide at least one of the "jwksUrl" or "secret" options.'
}
const fastifyJwtErrors = [
['Format is Authorization: Bearer \\[token\\]', errorMessages.badHeaderFormat],
['No Authorization was found in request\\.headers', errorMessages.missingHeader],
['token expired', errorMessages.expiredToken],
['invalid algorithm', errorMessages.invalidAlgorithm],
[/(?:jwt malformed)|(?:invalid signature)|(?:jwt (?:audience|issuer) invalid)/, errorMessages.invalidToken]
]
function verifyOptions(options) {
let { jwksUrl, audience, secret, issuer } = options
// Do not allow some options to be overidden by original user provided
for (const key of forbiddenOptions) {
if (key in options) {
throw new Error(`Option "${key}" is not supported.`)
}
}
// Prepare verification options
const verify = Object.assign({}, options, { algorithms: [] })
let jwksUrlObject
let jwksUrlOrigin
if (jwksUrl) {
jwksUrl = jwksUrl.toString()
// Normalize to get a complete URL for JWKS fetching
if (!jwksUrl.match(/^http(?:s?)/)) {
jwksUrlObject = new URL(`https://${jwksUrl}`)
jwksUrl = jwksUrlObject.toString()
} else {
// adds missing trailing slash if it's not been provided in the config
jwksUrlObject = new URL(jwksUrl)
jwksUrl = jwksUrlObject.toString()
}
jwksUrlOrigin = jwksUrlObject.origin + '/'
verify.algorithms.push('RS256')
// @TODO normalize issuer url like done for jwksUrl
verify.allowedIss = issuer || jwksUrlOrigin
if (audience) {
verify.allowedAud = jwksUrlOrigin
}
}
if (audience) {
verify.allowedAud = audience === true ? jwksUrlOrigin : audience
}
if (secret) {
secret = secret.toString()
verify.algorithms.push('HS256')
}
if (!jwksUrl && !secret) {
// If there is no jwksUrl and no secret no verifications are possible, throw an error
throw new Error(errorMessages.missingOptions)
}
return { jwksUrl, audience, secret, verify }
}
async function getRemoteSecret(jwksUrl, alg, kid, cache) {
try {
const cacheKey = `${alg}:${kid}:${jwksUrl}`
const cached = cache.get(cacheKey)
if (cached) {
return cached
} else if (cached === null) {
// null is returned when a previous attempt resulted in the key missing in the JWKs - Do not attempt to fetch again
throw new Unauthorized(errorMessages.missingKey)
}
// Hit the well-known URL in order to get the key
const response = await fetch(jwksUrl, { signal: AbortSignal.timeout(5000) })
const body = await response.json()
if (!response.ok) {
const error = new Error(response.statusText)
error.response = response
error.body = body
throw error
}
// Find the key with ID and algorithm matching the JWT token header
const key = body.keys.find(
k => k.kid === kid && ((k.alg && k.alg === alg) || (k.kty && k.kty === 'RSA' && k.use === 'sig'))
)
if (!key) {
// Mark the key as missing
cache.set(cacheKey, null)
throw new Unauthorized(errorMessages.missingKey)
}
let secret
if (key.x5c) {
// @TODO This comes from a previous implementation: check whether this condition is still necessary
// certToPEM extracted from https://github.com/auth0/node-jwks-rsa/blob/master/src/utils.js
secret = `-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----\n`
} else {
const publicKey = await createPublicKey({ key, format: 'jwk' })
secret = publicKey.export({ type: 'spki', format: 'pem' })
}
// Save the key in the cache
cache.set(cacheKey, secret)
return secret
} catch (e) {
if (e.response) {
throw InternalServerError(`${errorMessages.jwksHttpError}: [HTTP ${e.response.status}] ${JSON.stringify(e.body)}`)
}
e.statusCode = e.statusCode || 500
throw e
}
}
function fastifyJwtJwks(instance, options, done) {
try {
// Construct the JWT function names and this plugin's decorator names using the same rules as @fastify/jwt
const { namespace } = options
const decodeFunctionName = namespace ? `${namespace}JwtDecode` : 'jwtDecode'
const verifyFunctionName = namespace ? `${namespace}JwtVerify` : 'jwtVerify'
const authenticateMethodName = namespace ? `${namespace}Authenticate` : 'authenticate'
const jwksOptionsName = namespace ? `${namespace}JwtJwks` : 'jwtJwks'
const secretsCacheName = namespace ? `${namespace}JwtJwksSecretsCache` : 'jwtJwksSecretsCache'
function getSecret(request, reply, cb) {
request[decodeFunctionName]({ decode: { complete: true } })
.then(decoded => {
const { header } = decoded
// If the algorithm is not using RS256, the encryption key is jwt client secret
if (header.alg.startsWith('HS')) {
if (!request[jwksOptionsName].secret) {
throw new Unauthorized(errorMessages.invalidAlgorithm)
}
return cb(null, request[jwksOptionsName].secret)
}
// If the algorithm is RS256, get the key remotely using a well-known URL containing a JWK set
getRemoteSecret(request[jwksOptionsName].jwksUrl, header.alg, header.kid, request[secretsCacheName])
.then(key => cb(null, key))
.catch(cb)
})
.catch(cb)
}
async function authenticate(request) {
try {
await request[verifyFunctionName]()
} catch (e) {
for (const [jwtMessage, errorMessage] of fastifyJwtErrors) {
if (e.message.match(jwtMessage)) {
throw new Unauthorized(errorMessage, { a: 1 })
}
}
if (e.statusCode) {
throw e
}
throw new Unauthorized(e.message)
}
}
// Check if secrets cache is wanted - Convert milliseconds to seconds and cache for a week by default
const ttl = parseFloat('secretsTtl' in options ? options.secretsTtl : '604800000', 10) / 1e3
delete options.secretsTtl
const jwtJwksOptions = verifyOptions(options)
// Setup @fastify/jwt
instance.register(fastifyJwt, {
verify: jwtJwksOptions.verify,
cookie: options.cookie,
secret: getSecret,
formatUser: options.formatUser,
namespace
})
// Setup our decorators
instance.decorate(authenticateMethodName, authenticate)
instance.decorate(jwksOptionsName, jwtJwksOptions)
instance.decorateRequest(jwksOptionsName, {
getter: () => jwtJwksOptions
})
const cache =
ttl > 0 ? new NodeCache({ stdTTL: ttl }) : { get: () => undefined, set: () => false, close: () => undefined }
// Create a cache or a fake cache
instance.decorateRequest(secretsCacheName, {
getter: () => cache
})
instance.addHook('onClose', () => cache.close())
done()
} catch (e) {
done(e)
}
}
module.exports = fastifyPlugin(fastifyJwtJwks, { name: 'fastify-jwt-jwks', fastify: '5.x' })
module.exports.default = fastifyJwtJwks
module.exports.fastifyJwtJwks = fastifyJwtJwks