1 | import { urlDecodeB64 } from './utils';
|
2 | import { IdToken, JWTVerifyOptions } from './global';
|
3 |
|
4 | const isNumber = (n: any) => typeof n === 'number';
|
5 |
|
6 | const 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 |
|
39 | export 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 |
|
63 | export 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 |
|
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 | };
|