UNPKG

5.85 kBPlain TextView Raw
1import { urlDecodeB64 } from './utils';
2import { IdToken, JWTVerifyOptions } from './global';
3
4const isNumber = (n: any) => typeof n === 'number';
5
6const idTokendecoded = [
7 'iss',
8 'aud',
9 'exp',
10 'nbf',
11 'iat',
12 'jti',
13 'azp',
14 'nonce',
15 'auth_time',
16 'at_hash',
17 'c_hash',
18 'acr',
19 'amr',
20 'sub_jwk',
21 'cnf',
22 'sip_from_tag',
23 'sip_date',
24 'sip_callid',
25 'sip_cseq_num',
26 'sip_via_branch',
27 'orig',
28 'dest',
29 'mky',
30 'events',
31 'toe',
32 'txn',
33 'rph',
34 'sid',
35 'vot',
36 'vtm'
37];
38
39export const decode = (token: string) => {
40 const parts = token.split('.');
41 const [header, payload, signature] = parts;
42
43 if (parts.length !== 3 || !header || !payload || !signature) {
44 throw new Error('ID token could not be decoded');
45 }
46 const payloadJSON = JSON.parse(urlDecodeB64(payload));
47 const claims: IdToken = { __raw: token };
48 const user: any = {};
49 Object.keys(payloadJSON).forEach(k => {
50 claims[k] = payloadJSON[k];
51 if (!idTokendecoded.includes(k)) {
52 user[k] = payloadJSON[k];
53 }
54 });
55 return {
56 encoded: { header, payload, signature },
57 header: JSON.parse(urlDecodeB64(header)),
58 claims,
59 user
60 };
61};
62
63export const verify = (options: JWTVerifyOptions) => {
64 if (!options.id_token) {
65 throw new Error('ID token is required but missing');
66 }
67
68 const decoded = decode(options.id_token);
69
70 if (!decoded.claims.iss) {
71 throw new Error(
72 'Issuer (iss) claim must be a string present in the ID token'
73 );
74 }
75
76 if (decoded.claims.iss !== options.iss) {
77 throw new Error(
78 `Issuer (iss) claim mismatch in the ID token; expected "${options.iss}", found "${decoded.claims.iss}"`
79 );
80 }
81
82 if (!decoded.user.sub) {
83 throw new Error(
84 'Subject (sub) claim must be a string present in the ID token'
85 );
86 }
87
88 if (decoded.header.alg !== 'RS256') {
89 throw new Error(
90 `Signature algorithm of "${decoded.header.alg}" is not supported. Expected the ID token to be signed with "RS256".`
91 );
92 }
93
94 if (
95 !decoded.claims.aud ||
96 !(
97 typeof decoded.claims.aud === 'string' ||
98 Array.isArray(decoded.claims.aud)
99 )
100 ) {
101 throw new Error(
102 'Audience (aud) claim must be a string or array of strings present in the ID token'
103 );
104 }
105 if (Array.isArray(decoded.claims.aud)) {
106 if (!decoded.claims.aud.includes(options.aud)) {
107 throw new Error(
108 `Audience (aud) claim mismatch in the ID token; expected "${
109 options.aud
110 }" but was not one of "${decoded.claims.aud.join(', ')}"`
111 );
112 }
113 if (decoded.claims.aud.length > 1) {
114 if (!decoded.claims.azp) {
115 throw new Error(
116 'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values'
117 );
118 }
119 if (decoded.claims.azp !== options.aud) {
120 throw new Error(
121 `Authorized Party (azp) claim mismatch in the ID token; expected "${options.aud}", found "${decoded.claims.azp}"`
122 );
123 }
124 }
125 } else if (decoded.claims.aud !== options.aud) {
126 throw new Error(
127 `Audience (aud) claim mismatch in the ID token; expected "${options.aud}" but found "${decoded.claims.aud}"`
128 );
129 }
130 if (options.nonce) {
131 if (!decoded.claims.nonce) {
132 throw new Error(
133 'Nonce (nonce) claim must be a string present in the ID token'
134 );
135 }
136 if (decoded.claims.nonce !== options.nonce) {
137 throw new Error(
138 `Nonce (nonce) claim mismatch in the ID token; expected "${options.nonce}", found "${decoded.claims.nonce}"`
139 );
140 }
141 }
142
143 if (options.max_age && !isNumber(decoded.claims.auth_time)) {
144 throw new Error(
145 'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified'
146 );
147 }
148
149 /* c8 ignore next 5 */
150 if (decoded.claims.exp == null || !isNumber(decoded.claims.exp)) {
151 throw new Error(
152 'Expiration Time (exp) claim must be a number present in the ID token'
153 );
154 }
155 if (!isNumber(decoded.claims.iat)) {
156 throw new Error(
157 'Issued At (iat) claim must be a number present in the ID token'
158 );
159 }
160
161 const leeway = options.leeway || 60;
162 const now = new Date(options.now || Date.now());
163 const expDate = new Date(0);
164
165 expDate.setUTCSeconds(decoded.claims.exp + leeway);
166
167 if (now > expDate) {
168 throw new Error(
169 `Expiration Time (exp) claim error in the ID token; current time (${now}) is after expiration time (${expDate})`
170 );
171 }
172
173 if (decoded.claims.nbf != null && isNumber(decoded.claims.nbf)) {
174 const nbfDate = new Date(0);
175 nbfDate.setUTCSeconds(decoded.claims.nbf - leeway);
176 if (now < nbfDate) {
177 throw new Error(
178 `Not Before time (nbf) claim in the ID token indicates that this token can't be used just yet. Current time (${now}) is before ${nbfDate}`
179 );
180 }
181 }
182
183 if (decoded.claims.auth_time != null && isNumber(decoded.claims.auth_time)) {
184 const authTimeDate = new Date(0);
185 authTimeDate.setUTCSeconds(
186 parseInt(decoded.claims.auth_time) + (options.max_age as number) + leeway
187 );
188
189 if (now > authTimeDate) {
190 throw new Error(
191 `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (${now}) is after last auth at ${authTimeDate}`
192 );
193 }
194 }
195
196 if (options.organizationId) {
197 if (!decoded.claims.org_id) {
198 throw new Error(
199 'Organization ID (org_id) claim must be a string present in the ID token'
200 );
201 } else if (options.organizationId !== decoded.claims.org_id) {
202 throw new Error(
203 `Organization ID (org_id) claim mismatch in the ID token; expected "${options.organizationId}", found "${decoded.claims.org_id}"`
204 );
205 }
206 }
207
208 return decoded;
209};