UNPKG

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