Skip to content

Commit

Permalink
feat(js): verifyIdToken (#127)
Browse files Browse the repository at this point in the history
  • Loading branch information
IceHe authored Jan 21, 2022
1 parent 998b541 commit 954dc6d
Show file tree
Hide file tree
Showing 5 changed files with 267 additions and 7 deletions.
2 changes: 1 addition & 1 deletion packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"dependencies": {
"@silverhand/essentials": "^1.1.3",
"jose": "^4.1.4",
"jose": "^4.3.8",
"js-base64": "^3.7.2",
"superstruct": "^0.15.3"
},
Expand Down
31 changes: 31 additions & 0 deletions packages/js/src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* Copied from react-i18next/ts4.1/index.d.ts */
// Normalize single namespace
type AppendKeys<K1, K2> = `${K1 & string}.${K2 & string}`;
type AppendKeys2<K1, K2> = `${K1 & string}.${Exclude<K2, keyof any[]> & string}`;
type Normalize2<T, K = keyof T> = K extends keyof T
? T[K] extends Record<string, any>
? T[K] extends readonly any[]
? AppendKeys2<K, keyof T[K]> | AppendKeys2<K, Normalize2<T[K]>>
: AppendKeys<K, keyof T[K]> | AppendKeys<K, Normalize2<T[K]>>
: never
: never;
type Normalize<T> = keyof T | Normalize2<T>;

const logtoErrorCodes = Object.freeze({
idToken: {
verification: {
invalidIat: 'Invalid issued at time',
},
},
});

export type LogtoErrorCode = Normalize<typeof logtoErrorCodes>;

export class LogtoError extends Error {
code: LogtoErrorCode;

constructor(code: LogtoErrorCode, message?: string) {
super(message);
this.code = code;
}
}
214 changes: 211 additions & 3 deletions packages/js/src/utils/id-token.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,215 @@
import { SignJWT, generateKeyPair } from 'jose';
import { KeyObject } from 'crypto';

import { createRemoteJWKSet, generateKeyPair, SignJWT } from 'jose';
import nock from 'nock';
import { StructError } from 'superstruct';

import { decodeIdToken } from './id-token';
import { LogtoError } from './errors';
import { decodeIdToken, verifyIdToken } from './id-token';

const createDefaultJwks = () => createRemoteJWKSet(new URL('https://logto.dev/oidc/jwks'));

describe('verifyIdToken', () => {
test('valid ID Token, signed by RS256 algorithm, should not throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).resolves.not.toThrow();
});

test('valid ID Token, signed by ES512 algorithm, should not throw', async () => {
const alg = 'ES512';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).resolves.not.toThrow();
});

test('mismatched signature should throw', async () => {
const alg = 'RS256';
const { privateKey } = await generateKeyPair(alg);
const { publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check envirionment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('foz')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'foo', 'baz', jwks)).rejects.toThrowError(
'signature verification failed'
);
});

test('mismatched issuer should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'xxx', jwks)).rejects.toThrowError(
'unexpected "iss" claim value'
);
});

test('mismatched audience should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'xxx', 'foo', jwks)).rejects.toThrowError(
'unexpected "aud" claim value'
);
});

test('expired ID Token should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime(Date.now() / 1000 - 1)
.setIssuedAt()
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).rejects.toThrowError(
'"exp" claim timestamp check failed'
);
});

test('issued at time, too far away from current time, should throw', async () => {
const alg = 'RS256';
const { privateKey, publicKey } = await generateKeyPair(alg);

if (!(publicKey instanceof KeyObject)) {
throw new TypeError('key is not instanceof KeyObject, check environment');
}

const key = publicKey.export({ format: 'jwk' });
nock('https://logto.dev', { allowUnmocked: true })
.get('/oidc/jwks')
.reply(200, { keys: [key] });

const idToken = await new SignJWT({})
.setProtectedHeader({ alg })
.setIssuer('foo')
.setSubject('bar')
.setAudience('qux')
.setExpirationTime('2h')
.setIssuedAt(Date.now() / 1000 - 180)
.sign(privateKey);

const jwks = createDefaultJwks();

await expect(verifyIdToken(idToken, 'qux', 'foo', jwks)).rejects.toThrowError(
new LogtoError('idToken.verification.invalidIat')
);
});
});

describe('decodeIdToken', () => {
test('decoding valid JWT should return claims', async () => {
Expand Down Expand Up @@ -98,7 +306,7 @@ describe('decodeIdToken', () => {
expect(() => decodeIdToken(jwt)).toThrowError(StructError);
});

test('decoding valid JWT without issued time should throw StructError', async () => {
test('decoding valid JWT without issued at time should throw StructError', async () => {
const { privateKey } = await generateKeyPair('RS256');
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'RS256' })
Expand Down
17 changes: 17 additions & 0 deletions packages/js/src/utils/id-token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { UrlSafeBase64 } from '@silverhand/essentials';
import { jwtVerify, JWTVerifyGetKey } from 'jose';
import * as s from 'superstruct';

import { LogtoError } from './errors';

const issuedAtTimeTolerance = 60;

/**
* @link [ID Token](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)
*/
Expand All @@ -15,6 +20,18 @@ const IdTokenClaimsSchema = s.type({

export type IdTokenClaims = s.Infer<typeof IdTokenClaimsSchema>;

export const verifyIdToken = async (
idToken: string,
clientId: string,
issuer: string,
jwks: JWTVerifyGetKey
) => {
const result = await jwtVerify(idToken, jwks, { audience: clientId, issuer });
if (Math.abs((result?.payload?.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
throw new LogtoError('idToken.verification.invalidIat');
}
};

export const decodeIdToken = (token: string): IdTokenClaims => {
const { 1: encodedPayload } = token.split('.');
if (!encodedPayload) {
Expand Down
10 changes: 7 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 954dc6d

Please sign in to comment.