|
| 1 | +var urlDecodeB64 = function(data) { |
| 2 | + return Buffer.from(data, 'base64').toString('utf8'); |
| 3 | +}; |
| 4 | + |
| 5 | +/** |
| 6 | + * Decodes a string token into the 3 parts, throws if the format is invalid |
| 7 | + * @param token |
| 8 | + */ |
| 9 | +var decode = function(token) { |
| 10 | + var parts = token.split('.'); |
| 11 | + |
| 12 | + if (parts.length !== 3) { |
| 13 | + throw new Error('ID token could not be decoded'); |
| 14 | + } |
| 15 | + |
| 16 | + return { |
| 17 | + _raw: token, |
| 18 | + header: JSON.parse(urlDecodeB64(parts[0])), |
| 19 | + payload: JSON.parse(urlDecodeB64(parts[1])), |
| 20 | + signature: parts[2] |
| 21 | + }; |
| 22 | +}; |
| 23 | + |
| 24 | +var DEFAULT_LEEWAY = 60; //default clock-skew, in seconds |
| 25 | + |
| 26 | +/** |
| 27 | + * Validator for ID Tokens following OIDC spec. |
| 28 | + * @param token the string token to verify |
| 29 | + * @param options the options required to run this verification |
| 30 | + * @returns A promise containing the decoded token payload, or throws an exception if validation failed |
| 31 | + */ |
| 32 | +var validate = function(token, options) { |
| 33 | + if (!token) { |
| 34 | + throw new Error('ID token is required but missing'); |
| 35 | + } |
| 36 | + |
| 37 | + var decodedToken = decode(token); |
| 38 | + |
| 39 | + // Check algorithm |
| 40 | + var header = decodedToken.header; |
| 41 | + if (header.alg !== 'RS256' && header.alg !== 'HS256') { |
| 42 | + throw new Error( |
| 43 | + `Signature algorithm of "${header.alg}" is not supported. Expected the ID token to be signed with "RS256" or "HS256".` |
| 44 | + ); |
| 45 | + } |
| 46 | + |
| 47 | + var payload = decodedToken.payload; |
| 48 | + |
| 49 | + // Issuer |
| 50 | + if (!payload.iss || typeof payload.iss !== 'string') { |
| 51 | + throw new Error('Issuer (iss) claim must be a string present in the ID token'); |
| 52 | + } |
| 53 | + if (payload.iss !== options.issuer) { |
| 54 | + throw new Error( |
| 55 | + `Issuer (iss) claim mismatch in the ID token; expected "${options.issuer}", found "${payload.iss}"` |
| 56 | + ); |
| 57 | + } |
| 58 | + |
| 59 | + // Subject |
| 60 | + if (!payload.sub || typeof payload.sub !== 'string') { |
| 61 | + throw new Error('Subject (sub) claim must be a string present in the ID token'); |
| 62 | + } |
| 63 | + |
| 64 | + // Audience |
| 65 | + if (!payload.aud || !(typeof payload.aud === 'string' || Array.isArray(payload.aud))) { |
| 66 | + throw new Error( |
| 67 | + 'Audience (aud) claim must be a string or array of strings present in the ID token' |
| 68 | + ); |
| 69 | + } |
| 70 | + if (Array.isArray(payload.aud) && !payload.aud.includes(options.audience)) { |
| 71 | + throw new Error( |
| 72 | + `Audience (aud) claim mismatch in the ID token; expected "${ |
| 73 | + options.audience |
| 74 | + }" but was not one of "${payload.aud.join(', ')}"` |
| 75 | + ); |
| 76 | + } else if (typeof payload.aud === 'string' && payload.aud !== options.audience) { |
| 77 | + throw new Error( |
| 78 | + `Audience (aud) claim mismatch in the ID token; expected "${options.audience}" but found "${payload.aud}"` |
| 79 | + ); |
| 80 | + } |
| 81 | + |
| 82 | + // --Time validation (epoch)-- |
| 83 | + var now = Math.floor(Date.now() / 1000); |
| 84 | + var leeway = options.leeway || DEFAULT_LEEWAY; |
| 85 | + |
| 86 | + // Expires at |
| 87 | + if (!payload.exp || typeof payload.exp !== 'number') { |
| 88 | + throw new Error('Expiration Time (exp) claim must be a number present in the ID token'); |
| 89 | + } |
| 90 | + var expTime = payload.exp + leeway; |
| 91 | + |
| 92 | + if (now > expTime) { |
| 93 | + throw new Error( |
| 94 | + `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expTime})` |
| 95 | + ); |
| 96 | + } |
| 97 | + |
| 98 | + // Issued at |
| 99 | + if (!payload.iat || typeof payload.iat !== 'number') { |
| 100 | + throw new Error('Issued At (iat) claim must be a number present in the ID token'); |
| 101 | + } |
| 102 | + |
| 103 | + // Nonce |
| 104 | + if (options.nonce) { |
| 105 | + if (!payload.nonce || typeof payload.nonce !== 'string') { |
| 106 | + throw new Error('Nonce (nonce) claim must be a string present in the ID token'); |
| 107 | + } |
| 108 | + if (payload.nonce !== options.nonce) { |
| 109 | + throw new Error( |
| 110 | + `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${payload.nonce}"` |
| 111 | + ); |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + // Authorized party |
| 116 | + if (Array.isArray(payload.aud) && payload.aud.length > 1) { |
| 117 | + if (!payload.azp || typeof payload.azp !== 'string') { |
| 118 | + throw new Error( |
| 119 | + 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values' |
| 120 | + ); |
| 121 | + } |
| 122 | + if (payload.azp !== options.audience) { |
| 123 | + throw new Error( |
| 124 | + `Authorized Party (azp) claim mismatch in the ID token; expected "${options.audience}", found "${payload.azp}"` |
| 125 | + ); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + // Authentication time |
| 130 | + if (options.maxAge) { |
| 131 | + if (!payload.auth_time || typeof payload.auth_time !== 'number') { |
| 132 | + throw new Error( |
| 133 | + 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified' |
| 134 | + ); |
| 135 | + } |
| 136 | + |
| 137 | + var authValidUntil = payload.auth_time + options.maxAge + leeway; |
| 138 | + if (now > authValidUntil) { |
| 139 | + throw new Error( |
| 140 | + `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Currrent time (${now}) is after last auth at ${authValidUntil}` |
| 141 | + ); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + return decodedToken; |
| 146 | +}; |
| 147 | + |
| 148 | +module.exports = { |
| 149 | + decode: decode, |
| 150 | + validate: validate |
| 151 | +}; |
0 commit comments