From 954dc6d9f046e752f635248bcd87e15cf02fb63e Mon Sep 17 00:00:00 2001 From: "IceHe.xyz" Date: Fri, 21 Jan 2022 15:34:59 +0800 Subject: [PATCH] feat(js): verifyIdToken (#127) --- packages/js/package.json | 2 +- packages/js/src/utils/errors.ts | 31 ++++ packages/js/src/utils/id-token.test.ts | 214 ++++++++++++++++++++++++- packages/js/src/utils/id-token.ts | 17 ++ pnpm-lock.yaml | 10 +- 5 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 packages/js/src/utils/errors.ts diff --git a/packages/js/package.json b/packages/js/package.json index d87a5fd89..55cf632ae 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -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" }, diff --git a/packages/js/src/utils/errors.ts b/packages/js/src/utils/errors.ts new file mode 100644 index 000000000..01cbfc9d0 --- /dev/null +++ b/packages/js/src/utils/errors.ts @@ -0,0 +1,31 @@ +/* Copied from react-i18next/ts4.1/index.d.ts */ +// Normalize single namespace +type AppendKeys = `${K1 & string}.${K2 & string}`; +type AppendKeys2 = `${K1 & string}.${Exclude & string}`; +type Normalize2 = K extends keyof T + ? T[K] extends Record + ? T[K] extends readonly any[] + ? AppendKeys2 | AppendKeys2> + : AppendKeys | AppendKeys> + : never + : never; +type Normalize = keyof T | Normalize2; + +const logtoErrorCodes = Object.freeze({ + idToken: { + verification: { + invalidIat: 'Invalid issued at time', + }, + }, +}); + +export type LogtoErrorCode = Normalize; + +export class LogtoError extends Error { + code: LogtoErrorCode; + + constructor(code: LogtoErrorCode, message?: string) { + super(message); + this.code = code; + } +} diff --git a/packages/js/src/utils/id-token.test.ts b/packages/js/src/utils/id-token.test.ts index a3c544a53..e1441b634 100644 --- a/packages/js/src/utils/id-token.test.ts +++ b/packages/js/src/utils/id-token.test.ts @@ -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 () => { @@ -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' }) diff --git a/packages/js/src/utils/id-token.ts b/packages/js/src/utils/id-token.ts index 8d1a01cf5..ef816c1dc 100644 --- a/packages/js/src/utils/id-token.ts +++ b/packages/js/src/utils/id-token.ts @@ -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) */ @@ -15,6 +20,18 @@ const IdTokenClaimsSchema = s.type({ export type IdTokenClaims = s.Infer; +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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d07356298..98ab6e3ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,7 +84,7 @@ importers: codecov: ^3.8.3 eslint: ^8.1.0 jest: ^27.0.6 - jose: ^4.1.4 + jose: ^4.3.8 js-base64: ^3.7.2 lint-staged: ^11.1.2 nock: ^13.1.3 @@ -100,7 +100,7 @@ importers: webpack-cli: ^4.9.1 dependencies: '@silverhand/essentials': 1.1.3 - jose: 4.1.4 + jose: 4.3.8 js-base64: 3.7.2 superstruct: 0.15.3 devDependencies: @@ -7205,7 +7205,7 @@ packages: '@typescript-eslint/eslint-plugin': '>=4.28.1' eslint: '>=7.30.0' dependencies: - '@typescript-eslint/eslint-plugin': 5.6.0_cfa0f0550d794f774b69ded00bc577ac + '@typescript-eslint/eslint-plugin': 5.6.0_85c6ab1406c522bb26958d9e32219a86 eslint: 8.2.0 typescript: 4.4.4 dev: true @@ -10545,6 +10545,10 @@ packages: resolution: {integrity: sha512-iCCOrCZuG+BjP3SsjTmJtgFZey1hwHMUqv9nBqFkdJtJ/jd193QgCl/u339IdyS+2opUklON+9VzGzmKaudsfg==} dev: false + /jose/4.3.8: + resolution: {integrity: sha512-dFiqN5FPLNWa/v+J3ShFjV/9sRGickxMbGUbqBrYr+BkrqLOieACaavSi9XmLJXe0Uzd7Cgs1oYtDvDrOyWLgw==} + dev: false + /js-base64/3.7.2: resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==} dev: false