Skip to content

Commit 1810364

Browse files
adds tests to JWT/Token validators (#5)
OKTA-725158 adds tests to JWT/Token validators
1 parent a9cfd6d commit 1810364

6 files changed

Lines changed: 251 additions & 4 deletions

File tree

packages/auth-foundation/src/jwt/IDTokenValidator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export const DefaultIDTokenValidator: IDTokenValidator = {
110110
case 'maxAge':
111111
if (context.maxAge) {
112112
const authTime = jwt.payload['auth_time'];
113-
if (!authTime || !Number.isFinite(authTime) || typeof authTime !== 'number') {
113+
if (!authTime || typeof authTime !== 'number' || !Number.isFinite(authTime)) {
114114
throw new JWTError('Invalid Authentication Time');
115115
}
116116

packages/auth-foundation/src/oauth2/client.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,6 @@ export class OAuth2Client extends APIClient {
295295
}
296296
}
297297

298-
// TODO: valid accesstoken at_hash
299-
300298
return token;
301299
}
302300

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { DefaultIDTokenValidator, IDTokenValidator, JWT } from 'src/jwt';
2+
import { JWTError } from 'src/errors';
3+
import { b64u, buf } from 'src/crypto';
4+
5+
const writeJWT = (header: Record<string, unknown>, claims: Record<string, unknown>) => {
6+
const head = b64u(buf(JSON.stringify(header)));
7+
const body = b64u(buf(JSON.stringify(claims)));
8+
return `${head}.${body}.fakesignature`;
9+
};
10+
11+
// wrapper around `DefaultIDTokenValidator.validate` with default param values
12+
const defaultParams = {
13+
issuer: new URL('https://fake.okta.com'),
14+
clientId: 'someclientid',
15+
context: { supportedAlgs: ['RS256'] }
16+
};
17+
const validate = (
18+
jwt: JWT,
19+
params: { issuer?: URL, clientId?: string, context?: Record<string, unknown> } = {}
20+
): void => {
21+
const { issuer, clientId, context } = {...defaultParams, ...params};
22+
return DefaultIDTokenValidator.validate(jwt, issuer, clientId, context);
23+
};
24+
25+
describe('DefaultIDTokenValidator', () => {
26+
const context: any = {};
27+
28+
beforeEach(() => {
29+
const header = {
30+
kid: 'somerandomid',
31+
alg: 'RS256'
32+
};
33+
34+
const now = Date.now() / 1000;
35+
36+
const payload = {
37+
sub: 'someclientid',
38+
aud: 'someclientid',
39+
ver: 1,
40+
iss: 'https://fake.okta.com',
41+
iat: now,
42+
exp: now + 300,
43+
nonce: 'nonceuponatime'
44+
};
45+
46+
context.header = header;
47+
context.payload = payload;
48+
});
49+
50+
it('validates a valid ID Token', () => {
51+
const { header, payload } = context;
52+
const jwt = new JWT(writeJWT(header, payload));
53+
54+
expect(() => validate(jwt)).not.toThrow();
55+
});
56+
57+
it('will run a subset of validation checks when configured', () => {
58+
const { header, payload } = context;
59+
const jwt = new JWT(writeJWT(header, payload));
60+
61+
const allChecks = DefaultIDTokenValidator.checks;
62+
const [_, ...otherChecks] = allChecks;
63+
DefaultIDTokenValidator.checks = otherChecks;
64+
65+
expect(() => validate(jwt, { issuer: new URL('https://foo.okta.com') }))
66+
.not.toThrow(new JWTError('Invalid issuer (iss) claim'));
67+
68+
DefaultIDTokenValidator.checks = allChecks; // resets validation checks
69+
expect(() => validate(jwt, { issuer: new URL('https://foo.okta.com') }))
70+
.toThrow(new JWTError('Invalid issuer (iss) claim'));
71+
});
72+
73+
describe('throws when ID Token contains inconsistent claim', () => {
74+
it('issuer (iss)', () => {
75+
const { header, payload } = context;
76+
const jwt1 = new JWT(writeJWT(header, payload));
77+
expect(() => validate(jwt1, { issuer: new URL('https://foo.okta.com') }))
78+
.toThrow(new JWTError('Invalid issuer (iss) claim'));
79+
80+
payload.iss = 'https://foo.okta.com';
81+
const jwt2 = new JWT(writeJWT(header, payload));
82+
expect(() => validate(jwt2)).toThrow(new JWTError('Invalid issuer (iss) claim'));
83+
});
84+
85+
it('audience (aud)', () => {
86+
const { header, payload } = context;
87+
const jwt1 = new JWT(writeJWT(header, payload));
88+
expect(() => validate(jwt1, { clientId: 'foobar' }))
89+
.toThrow(new JWTError('invalid audience (aud) claim'));
90+
91+
payload.aud = 'foobar';
92+
const jwt2 = new JWT(writeJWT(header, payload));
93+
expect(() => validate(jwt2)).toThrow(new JWTError('invalid audience (aud) claim'));
94+
});
95+
96+
it('scheme (iss)', () => {
97+
const { header, payload } = context;
98+
payload.iss = 'http://fake.okta.com';
99+
const jwt = new JWT(writeJWT(header, payload));
100+
expect(() => validate(jwt, { issuer: new URL('http://fake.okta.com') }))
101+
.toThrow(new JWTError('issuer (iss) claim requires HTTPS'));
102+
});
103+
104+
it('algorithm (header.alg)', () => {
105+
const { header, payload } = context;
106+
const jwt1 = new JWT(writeJWT(header, payload));
107+
expect(() => validate(jwt1, { context: { supportedAlgs: [] } })).not.toThrow();
108+
expect(() => validate(jwt1, { context: {} })).not.toThrow();
109+
expect(() => validate(jwt1, { context: undefined })).not.toThrow();
110+
111+
expect(() => validate(jwt1, { context: { supportedAlgs: ['HS256'] } }))
112+
.toThrow(new JWTError('Unsupported jwt signing algorithm'));
113+
114+
header.alg = 'HS256';
115+
const jwt2 = new JWT(writeJWT(header, payload));
116+
expect(() => validate(jwt2)).toThrow(new JWTError('Unsupported jwt signing algorithm'));
117+
expect(() => validate(jwt2, { context: { supportedAlgs: ['RS256', 'HS256'] } })).not.toThrow();
118+
});
119+
120+
it('expirationTime (exp)', () => {
121+
const { header, payload } = context;
122+
payload.iat -= 600; // required to avoid different validation error
123+
payload.exp -= 600;
124+
const jwt = new JWT(writeJWT(header, payload));
125+
expect(() => validate(jwt)).toThrow(new JWTError('jwt has expired'));
126+
});
127+
128+
it('issuedAtTime', () => {
129+
const { header, payload } = context;
130+
payload.iat -= 600;
131+
const jwt = new JWT(writeJWT(header, payload));
132+
expect(() => validate(jwt)).toThrow(new JWTError('issuedAtTime (iat) exceeds grace interval'));
133+
134+
const graceInterval = DefaultIDTokenValidator.issuedAtGraceInterval;
135+
DefaultIDTokenValidator.issuedAtGraceInterval = 1000;
136+
expect(() => validate(jwt)).not.toThrow(new JWTError('issuedAtTime (iat) exceeds grace interval'));
137+
DefaultIDTokenValidator.issuedAtGraceInterval = graceInterval; // restore graceInterval
138+
});
139+
140+
it('nonce', () => {
141+
const { header, payload } = context;
142+
const jwt = new JWT(writeJWT(header, payload));
143+
expect(() => validate(jwt)).not.toThrow(new JWTError('nonce mismatch'));
144+
expect(() => validate(jwt, { context: { nonce: 'foo' } })).toThrow(new JWTError('nonce mismatch'));
145+
expect(() => validate(jwt, { context: { nonce: 'nonceuponatime' } })).not.toThrow(new JWTError('nonce mismatch'));
146+
});
147+
148+
it('maxAge', () => {
149+
const { header, payload } = context;
150+
const jwt1 = new JWT(writeJWT(header, payload));
151+
expect(() => validate(jwt1)).not.toThrow();
152+
expect(() => validate(jwt1, { context: { maxAge: 300 } })).toThrow(new JWTError('Invalid Authentication Time'));
153+
154+
payload.auth_time = 1 / 0;
155+
const jwt2 = new JWT(writeJWT(header, payload));
156+
expect(() => validate(jwt2, { context: { maxAge: 300 } })).toThrow(new JWTError('Invalid Authentication Time'));
157+
158+
payload.auth_time = 'foobar';
159+
const jwt3 = new JWT(writeJWT(header, payload));
160+
expect(() => validate(jwt3, { context: { maxAge: 300 } })).toThrow(new JWTError('Invalid Authentication Time'));
161+
162+
payload.auth_time = payload.iat - 300;
163+
const jwt4 = new JWT(writeJWT(header, payload));
164+
expect(() => validate(jwt4, { context: { maxAge: 300 } })).toThrow(new JWTError('exceeds maxAge'));
165+
166+
delete payload.iat;
167+
DefaultIDTokenValidator.checks = ['maxAge']; // isolates `maxAge` for test to avoid failing other validations
168+
const jwt5 = new JWT(writeJWT(header, payload));
169+
expect(() => validate(jwt5, { context: { maxAge: 300 } })).toThrow(new JWTError('exceeds maxAge'));
170+
DefaultIDTokenValidator.checks = [...IDTokenValidator.allValidationChecks]; // resets validation checks
171+
});
172+
173+
it('subject', () => {
174+
const { header, payload } = context;
175+
payload.sub = '';
176+
const jwt1 = new JWT(writeJWT(header, payload));
177+
expect(() => validate(jwt1)).toThrow(new JWTError('Invalid subject (sub) claim'));
178+
179+
delete payload.sub;
180+
const jwt2 = new JWT(writeJWT(header, payload));
181+
expect(() => validate(jwt2)).not.toThrow(new JWTError('Invalid subject (sub) claim'));
182+
});
183+
});
184+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { DefaultTokenHashValidator, JWT } from 'src/jwt';
2+
import { JWTError } from 'src/errors';
3+
import { b64u, buf } from 'src/crypto';
4+
5+
const writeJWT = (header: Record<string, unknown>, claims: Record<string, unknown>) => {
6+
const head = b64u(buf(JSON.stringify(header)));
7+
const body = b64u(buf(JSON.stringify(claims)));
8+
return `${head}.${body}.fakesignature`;
9+
};
10+
11+
describe('TokenHashValidator', () => {
12+
const context: any = {};
13+
14+
describe('accessToken', () => {
15+
16+
beforeEach(() => {
17+
context.token = 'eyJraWQiOiJSTWtOZWNkaGd2awdawdTmQ0Q3VWcTM2QmpBNDVJZ3VsY2NawdaTN1pWUkVEb0VXcFZVIiwiYWxnIjoiUlMyNTYifQaaaaaaaaaaaa';
18+
context.header = {
19+
kid: 'somerandomid',
20+
alg: 'RS256'
21+
};
22+
context.payload = {
23+
at_hash: 'JhDrJ4NqbCAkRdWmStLwuQ'
24+
};
25+
context.validator = DefaultTokenHashValidator('accessToken');
26+
});
27+
28+
it('should validate a valid `at_hash` value', async () => {
29+
const { header, payload, token, validator } = context;
30+
const idToken = new JWT(writeJWT(header, payload));
31+
32+
await expect(validator.validate(token, idToken)).resolves.toBe(undefined);
33+
});
34+
35+
it('should throw if `at_hash` is not valid', async () => {
36+
const { header, payload, token, validator } = context;
37+
38+
const idToken1 = new JWT(writeJWT(header, payload));
39+
await expect(validator.validate('foobar', idToken1)).rejects.toThrow(new JWTError('Signature Invalid'));
40+
41+
payload.at_hash = 'foobar';
42+
const idToken2 = new JWT(writeJWT(header, payload));
43+
await expect(validator.validate(token, idToken2)).rejects.toThrow(new JWTError('Signature Invalid'));
44+
});
45+
46+
it('should throw if token is an empty string', async () => {
47+
const { header, payload, validator } = context;
48+
const idToken = new JWT(writeJWT(header, payload));
49+
50+
await expect(validator.validate('', idToken)).rejects.toThrow(new TypeError('"token" cannot be an empty string'));
51+
});
52+
53+
it('should throw if IDToken signing algorithm is not supported', async () => {
54+
const { header, payload, token, validator } = context;
55+
header.alg = 'HS256';
56+
const idToken = new JWT(writeJWT(header, payload));
57+
58+
await expect(validator.validate(token, idToken)).rejects.toThrow(new JWTError('Unsupported Algorithm'));
59+
});
60+
});
61+
});

tooling/eslint-config/sdk.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ module.exports = {
2626
// https://typescript-eslint.io/docs/linting/troubleshooting/#i-am-using-a-rule-from-eslint-core-and-it-doesnt-work-correctly-with-typescript-code
2727
"no-undef": "off",
2828
"no-unused-vars": "off",
29-
"@typescript-eslint/no-unused-vars": "error",
29+
"@typescript-eslint/no-unused-vars": [2, {
30+
"destructuredArrayIgnorePattern": "^_",
31+
"ignoreRestSiblings": true
32+
}]
3033
}
3134
},
3235
{

tooling/jest-helpers/browser/jest.setup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Object.defineProperty(global, 'crypto', {
99
randomUUID: () => randStr(15), // do not use actual crypto alg for testing to for speed
1010
// getRandomValues: () => new Uint8Array(8)
1111
getRandomValues: arr => crypto.randomBytes(arr.length),
12+
subtle: crypto.subtle
1213
}
1314
});
1415

0 commit comments

Comments
 (0)