UNPKG

10.7 kBJavaScriptView Raw
1const isObject = require('../help/is_object')
2const epoch = require('../help/epoch')
3const secs = require('../help/secs')
4const getKey = require('../help/get_key')
5const { bare: verify } = require('../jws/verify')
6const { JWTClaimInvalid, JWTExpired } = require('../errors')
7
8const { isString, isNotString } = require('./shared_validations')
9const decode = require('./decode')
10
11const isPayloadString = isString.bind(undefined, JWTClaimInvalid)
12const isOptionString = isString.bind(undefined, TypeError)
13
14const IDTOKEN = 'id_token'
15const LOGOUTTOKEN = 'logout_token'
16const ATJWT = 'at+JWT'
17
18const isTimestamp = (value, label, required = false) => {
19 if (required && value === undefined) {
20 throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing')
21 }
22
23 if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value))) {
24 throw new JWTClaimInvalid(`"${label}" claim must be a unix timestamp`, label, 'invalid')
25 }
26}
27
28const isStringOrArrayOfStrings = (value, label, required = false) => {
29 if (required && value === undefined) {
30 throw new JWTClaimInvalid(`"${label}" claim is missing`, label, 'missing')
31 }
32
33 if (value !== undefined && (isNotString(value) && isNotArrayOfStrings(value))) {
34 throw new JWTClaimInvalid(`"${label}" claim must be a string or array of strings`, label, 'invalid')
35 }
36}
37
38const isNotArrayOfStrings = val => !Array.isArray(val) || val.length === 0 || val.some(isNotString)
39const normalizeTyp = (value) => value.toLowerCase().replace(/^application\//, '')
40
41const validateOptions = ({
42 algorithms, audience, clockTolerance, complete = false, crit, ignoreExp = false,
43 ignoreIat = false, ignoreNbf = false, issuer, jti, maxAuthAge, maxTokenAge, nonce, now = new Date(),
44 profile, subject, typ
45}) => {
46 isOptionString(profile, 'options.profile')
47
48 if (typeof complete !== 'boolean') {
49 throw new TypeError('options.complete must be a boolean')
50 }
51
52 if (typeof ignoreExp !== 'boolean') {
53 throw new TypeError('options.ignoreExp must be a boolean')
54 }
55
56 if (typeof ignoreNbf !== 'boolean') {
57 throw new TypeError('options.ignoreNbf must be a boolean')
58 }
59
60 if (typeof ignoreIat !== 'boolean') {
61 throw new TypeError('options.ignoreIat must be a boolean')
62 }
63
64 isOptionString(maxTokenAge, 'options.maxTokenAge')
65 isOptionString(subject, 'options.subject')
66 isOptionString(issuer, 'options.issuer')
67 isOptionString(maxAuthAge, 'options.maxAuthAge')
68 isOptionString(jti, 'options.jti')
69 isOptionString(clockTolerance, 'options.clockTolerance')
70 isOptionString(typ, 'options.typ')
71
72 if (audience !== undefined && (isNotString(audience) && isNotArrayOfStrings(audience))) {
73 throw new TypeError('options.audience must be a string or an array of strings')
74 }
75
76 if (algorithms !== undefined && isNotArrayOfStrings(algorithms)) {
77 throw new TypeError('options.algorithms must be an array of strings')
78 }
79
80 isOptionString(nonce, 'options.nonce')
81
82 if (!(now instanceof Date) || !now.getTime()) {
83 throw new TypeError('options.now must be a valid Date object')
84 }
85
86 if (ignoreIat && maxTokenAge !== undefined) {
87 throw new TypeError('options.ignoreIat and options.maxTokenAge cannot used together')
88 }
89
90 if (crit !== undefined && isNotArrayOfStrings(crit)) {
91 throw new TypeError('options.crit must be an array of strings')
92 }
93
94 switch (profile) {
95 case IDTOKEN:
96 if (!issuer) {
97 throw new TypeError('"issuer" option is required to validate an ID Token')
98 }
99
100 if (!audience) {
101 throw new TypeError('"audience" option is required to validate an ID Token')
102 }
103
104 break
105 case ATJWT:
106 if (!issuer) {
107 throw new TypeError('"issuer" option is required to validate a JWT Access Token')
108 }
109
110 if (!audience) {
111 throw new TypeError('"audience" option is required to validate a JWT Access Token')
112 }
113
114 typ = ATJWT
115
116 break
117 case LOGOUTTOKEN:
118 if (!issuer) {
119 throw new TypeError('"issuer" option is required to validate a Logout Token')
120 }
121
122 if (!audience) {
123 throw new TypeError('"audience" option is required to validate a Logout Token')
124 }
125
126 break
127 case undefined:
128 break
129 default:
130 throw new TypeError(`unsupported options.profile value "${profile}"`)
131 }
132
133 return {
134 algorithms,
135 audience,
136 clockTolerance,
137 complete,
138 crit,
139 ignoreExp,
140 ignoreIat,
141 ignoreNbf,
142 issuer,
143 jti,
144 maxAuthAge,
145 maxTokenAge,
146 nonce,
147 now,
148 profile,
149 subject,
150 typ
151 }
152}
153
154const validateTypes = ({ header, payload }, profile, options) => {
155 isPayloadString(header.alg, '"alg" header parameter', 'alg', true)
156
157 isTimestamp(payload.iat, 'iat', profile === IDTOKEN || profile === LOGOUTTOKEN || profile === ATJWT || !!options.maxTokenAge)
158 isTimestamp(payload.exp, 'exp', profile === IDTOKEN || profile === ATJWT)
159 isTimestamp(payload.auth_time, 'auth_time', !!options.maxAuthAge)
160 isTimestamp(payload.nbf, 'nbf')
161 isPayloadString(payload.jti, '"jti" claim', 'jti', profile === LOGOUTTOKEN || profile === ATJWT || !!options.jti)
162 isPayloadString(payload.acr, '"acr" claim', 'acr')
163 isPayloadString(payload.nonce, '"nonce" claim', 'nonce', !!options.nonce)
164 isPayloadString(payload.iss, '"iss" claim', 'iss', !!options.issuer)
165 isPayloadString(payload.sub, '"sub" claim', 'sub', profile === IDTOKEN || profile === ATJWT || !!options.subject)
166 isStringOrArrayOfStrings(payload.aud, 'aud', !!options.audience)
167 isPayloadString(payload.azp, '"azp" claim', 'azp', profile === IDTOKEN && Array.isArray(payload.aud) && payload.aud.length > 1)
168 isStringOrArrayOfStrings(payload.amr, 'amr')
169 isPayloadString(header.typ, '"typ" header parameter', 'typ', !!options.typ)
170
171 if (profile === ATJWT) {
172 isPayloadString(payload.client_id, '"client_id" claim', 'client_id', true)
173 }
174
175 if (profile === LOGOUTTOKEN) {
176 isPayloadString(payload.sid, '"sid" claim', 'sid')
177
178 if (!('sid' in payload) && !('sub' in payload)) {
179 throw new JWTClaimInvalid('either "sid" or "sub" (or both) claims must be present')
180 }
181
182 if ('nonce' in payload) {
183 throw new JWTClaimInvalid('"nonce" claim is prohibited', 'nonce', 'prohibited')
184 }
185
186 if (!('events' in payload)) {
187 throw new JWTClaimInvalid('"events" claim is missing', 'events', 'missing')
188 }
189
190 if (!isObject(payload.events)) {
191 throw new JWTClaimInvalid('"events" claim must be an object', 'events', 'invalid')
192 }
193
194 if (!('http://schemas.openid.net/event/backchannel-logout' in payload.events)) {
195 throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member is missing in the "events" claim', 'events', 'invalid')
196 }
197
198 if (!isObject(payload.events['http://schemas.openid.net/event/backchannel-logout'])) {
199 throw new JWTClaimInvalid('"http://schemas.openid.net/event/backchannel-logout" member in the "events" claim must be an object', 'events', 'invalid')
200 }
201 }
202}
203
204const checkAudiencePresence = (audPayload, audOption, profile) => {
205 if (typeof audPayload === 'string') {
206 return audOption.includes(audPayload)
207 }
208
209 // Each principal intended to process the JWT MUST
210 // identify itself with a value in the audience claim
211 audPayload = new Set(audPayload)
212 return audOption.some(Set.prototype.has.bind(audPayload))
213}
214
215module.exports = (token, key, options = {}) => {
216 if (!isObject(options)) {
217 throw new TypeError('options must be an object')
218 }
219
220 const {
221 algorithms, audience, clockTolerance, complete, crit, ignoreExp, ignoreIat, ignoreNbf, issuer,
222 jti, maxAuthAge, maxTokenAge, nonce, now, profile, subject, typ
223 } = options = validateOptions(options)
224
225 const decoded = decode(token, { complete: true })
226 key = getKey(key, true)
227
228 if (complete) {
229 ({ key } = verify(true, 'preparsed', { decoded, token }, key, { crit, algorithms, complete: true }))
230 decoded.key = key
231 } else {
232 verify(true, 'preparsed', { decoded, token }, key, { crit, algorithms })
233 }
234
235 const unix = epoch(now)
236 validateTypes(decoded, profile, options)
237
238 if (issuer && decoded.payload.iss !== issuer) {
239 throw new JWTClaimInvalid('unexpected "iss" claim value', 'iss', 'check_failed')
240 }
241
242 if (nonce && decoded.payload.nonce !== nonce) {
243 throw new JWTClaimInvalid('unexpected "nonce" claim value', 'nonce', 'check_failed')
244 }
245
246 if (subject && decoded.payload.sub !== subject) {
247 throw new JWTClaimInvalid('unexpected "sub" claim value', 'sub', 'check_failed')
248 }
249
250 if (jti && decoded.payload.jti !== jti) {
251 throw new JWTClaimInvalid('unexpected "jti" claim value', 'jti', 'check_failed')
252 }
253
254 if (audience && !checkAudiencePresence(decoded.payload.aud, typeof audience === 'string' ? [audience] : audience, profile)) {
255 throw new JWTClaimInvalid('unexpected "aud" claim value', 'aud', 'check_failed')
256 }
257
258 if (typ && normalizeTyp(decoded.header.typ) !== normalizeTyp(typ)) {
259 throw new JWTClaimInvalid('unexpected "typ" JWT header value', 'typ', 'check_failed')
260 }
261
262 const tolerance = clockTolerance ? secs(clockTolerance) : 0
263
264 if (maxAuthAge) {
265 const maxAuthAgeSeconds = secs(maxAuthAge)
266 if (decoded.payload.auth_time + maxAuthAgeSeconds < unix - tolerance) {
267 throw new JWTClaimInvalid('"auth_time" claim timestamp check failed (too much time has elapsed since the last End-User authentication)', 'auth_time', 'check_failed')
268 }
269 }
270
271 if (!ignoreIat && !('exp' in decoded.payload) && 'iat' in decoded.payload && decoded.payload.iat > unix + tolerance) {
272 throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed')
273 }
274
275 if (!ignoreNbf && 'nbf' in decoded.payload && decoded.payload.nbf > unix + tolerance) {
276 throw new JWTClaimInvalid('"nbf" claim timestamp check failed', 'nbf', 'check_failed')
277 }
278
279 if (!ignoreExp && 'exp' in decoded.payload && decoded.payload.exp <= unix - tolerance) {
280 throw new JWTExpired('"exp" claim timestamp check failed', 'exp', 'check_failed')
281 }
282
283 if (maxTokenAge) {
284 const age = unix - decoded.payload.iat
285 const max = secs(maxTokenAge)
286
287 if (age - tolerance > max) {
288 throw new JWTExpired('"iat" claim timestamp check failed (too far in the past)', 'iat', 'check_failed')
289 }
290
291 if (age < 0 - tolerance) {
292 throw new JWTClaimInvalid('"iat" claim timestamp check failed (it should be in the past)', 'iat', 'check_failed')
293 }
294 }
295
296 if (profile === IDTOKEN && Array.isArray(decoded.payload.aud) && decoded.payload.aud.length > 1 && decoded.payload.azp !== audience) {
297 throw new JWTClaimInvalid('unexpected "azp" claim value', 'azp', 'check_failed')
298 }
299
300 return complete ? decoded : decoded.payload
301}