From ec4efd41c09ffd5bff65ffed23013c1b6ea29f49 Mon Sep 17 00:00:00 2001 From: qh Date: Mon, 29 Jan 2024 14:10:56 +0800 Subject: [PATCH 1/7] feat: add optional overrideDecryptKey to facilitate key rotation without downtime ref: FILEZCAD-2080 --- README.md | 4 ++-- src/corppass/corppass-helper-ndi.ts | 6 ++++-- src/singpass/singpass-helper-ndi.ts | 14 ++++++++------ src/util/JweUtil.ts | 7 +++++++ src/util/__tests__/JweUtil.spec.ts | 12 ++++++++++++ 5 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 src/util/__tests__/JweUtil.spec.ts diff --git a/README.md b/README.md index 9fa47467..661a6c73 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Singpass.NdiOidcHelper - nonce (later returned inside the JWT from token endpoint) - `getTokens (authCode: string, axiosRequestConfig?: AxiosRequestConfig) => Promise` - get back the tokens from SP token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload -- `getIdTokenPayload(tokens: TokenResponse) => Promise` - decrypt and verify the JWT. Outputs TokenPayload, which is the input for extractNricAndUuidFromPayload +- `getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key) => Promise` - decrypt and verify the JWT. Outputs TokenPayload, which is the input for extractNricAndUuidFromPayload - `extractNricAndUuidFromPayload(payload: TokenPayload) => { nric: string, uuid: string }` - finally, get the nric and WOG (Whole-of-government) UUID of the user from the ID Token TokenPayload --- @@ -281,7 +281,7 @@ Corppass.OidcHelper - `getTokens (authCode: string, axiosRequestConfig?: AxiosRequestConfig) => Promise` - get back the tokens from token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload - `refreshTokens (refreshToken: string, axiosRequestConfig?: AxiosRequestConfig) => Promise` - get fresh tokens from SP token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload - `getAccessTokenPayload(tokens: TokenResponse) => Promise` - decode and verify the JWT. Outputs AccessTokenPayload, which contains the `EntityInfo`, `AuthInfo` and `TPAccessInfo` claims -- `getIdTokenPayload(tokens: TokenResponse) => Promise` - decrypt and verify the JWT. Outputs IdTokenPayload, which is the input for extractInfoFromIdTokenSubject +- `getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key) => Promise` - decrypt and verify the JWT. Outputs IdTokenPayload, which is the input for extractInfoFromIdTokenSubject - `extractInfoFromIdTokenSubject(payload: TokenPayload) => { nric: string, uuid: string, countryCode: string }` - finally, get the nric, system defined UUID and country code of the user from the ID Token TokenPayload --- diff --git a/src/corppass/corppass-helper-ndi.ts b/src/corppass/corppass-helper-ndi.ts index 88cb98d3..a0d15dba 100644 --- a/src/corppass/corppass-helper-ndi.ts +++ b/src/corppass/corppass-helper-ndi.ts @@ -178,7 +178,7 @@ export class NdiOidcHelper { * Decrypts the ID Token JWT inside the TokenResponse to get the payload * Use extractInfoFromIdTokenSubject on the returned Token Payload to get the NRIC, system defined ID and country code */ - public async getIdTokenPayload(tokens: TokenResponse): Promise { + public async getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key): Promise { try { const { data: { jwks_uri, issuer }, @@ -191,7 +191,9 @@ export class NdiOidcHelper { const jwsVerifyKey = JSON.stringify(keys[0]); const { id_token } = tokens; - const decryptedJwe = await JweUtil.decryptJWE(id_token, this.jweDecryptKey.key, this.jweDecryptKey.format); + + const finalDecryptionKey = overrideDecryptKey ?? this.jweDecryptKey; + const decryptedJwe = await JweUtil.decryptJWE(id_token, finalDecryptionKey.key, finalDecryptionKey.format); const jwsPayload = decryptedJwe.payload.toString(); const verifiedJws = await JweUtil.verifyJWS(jwsPayload, jwsVerifyKey, "json"); return JSON.parse(verifiedJws.payload.toString()) as NDIIdTokenPayload; diff --git a/src/singpass/singpass-helper-ndi.ts b/src/singpass/singpass-helper-ndi.ts index dbda0b39..c7bc372b 100644 --- a/src/singpass/singpass-helper-ndi.ts +++ b/src/singpass/singpass-helper-ndi.ts @@ -5,8 +5,8 @@ import { JweUtil } from "../util"; import { SingpassMyInfoError } from "../util/error/SingpassMyinfoError"; import { logger } from "../util/Logger"; import { TokenPayload, TokenResponse } from './shared-constants'; -import { Key } from'../util/KeyUtil'; -import { createClientAssertion } from'../util/SigningUtil'; +import { Key } from '../util/KeyUtil'; +import { createClientAssertion } from '../util/SigningUtil'; export interface NdiOidcHelperConstructor { oidcConfigUrl: string; @@ -49,7 +49,7 @@ export class NdiOidcHelper { state: string, nonce?: string ): Promise => { - const {data: {authorization_endpoint}} = await this.axiosClient.get(this.oidcConfigUrl); + const { data: { authorization_endpoint } } = await this.axiosClient.get(this.oidcConfigUrl); const queryParams = { state, @@ -103,14 +103,16 @@ export class NdiOidcHelper { * Decrypts the ID Token JWT inside the TokenResponse to get the payload * Use extractNricAndUuidFromPayload on the returned Token Payload to get the NRIC and UUID */ - public async getIdTokenPayload(tokens: TokenResponse): Promise { + public async getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key): Promise { try { const { data: { jwks_uri } } = await this.axiosClient.get(this.oidcConfigUrl); - const { data: { keys } } = await this.axiosClient.get<{keys: Object[]}>(jwks_uri); + const { data: { keys } } = await this.axiosClient.get<{ keys: Object[] }>(jwks_uri); const jwsVerifyKey = JSON.stringify(keys[0]); const { id_token } = tokens; - const decryptedJwe = await JweUtil.decryptJWE(id_token, this.jweDecryptKey.key, this.jweDecryptKey.format); + + const finalDecryptionKey = overrideDecryptKey ?? this.jweDecryptKey; + const decryptedJwe = await JweUtil.decryptJWE(id_token, finalDecryptionKey.key, finalDecryptionKey.format); const jwsPayload = decryptedJwe.payload.toString(); const verifiedJws = await JweUtil.verifyJWS(jwsPayload, jwsVerifyKey, 'json'); return JSON.parse(verifiedJws.payload.toString()) as TokenPayload; diff --git a/src/util/JweUtil.ts b/src/util/JweUtil.ts index 8f3bf61b..44f44aa4 100644 --- a/src/util/JweUtil.ts +++ b/src/util/JweUtil.ts @@ -28,3 +28,10 @@ export async function verifyJwsUsingKeyStore(jws: string, keys: string | object) const keyStore = await jose.JWK.asKeyStore(keys); return jose.JWS.createVerify(keyStore).verify(jws); } + +export function extractJwtHeader(jwt: string): object { + const jwtComponents = jwt.split("."); + const header = jose.util.base64url.decode(jwtComponents[0]); + return JSON.parse(header.toString()); + +} diff --git a/src/util/__tests__/JweUtil.spec.ts b/src/util/__tests__/JweUtil.spec.ts new file mode 100644 index 00000000..87e14652 --- /dev/null +++ b/src/util/__tests__/JweUtil.spec.ts @@ -0,0 +1,12 @@ +import { extractJwtHeader } from "../JweUtil"; + + +describe("extractJwtHeader", () => { + it("should extract JWT header", () => { + const SAMPLE_JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + expect(extractJwtHeader(SAMPLE_JWT)).toStrictEqual({ + "alg": "HS256", + "typ": "JWT" + }); + }); +}); From 6342ab97329d6b98682ce8b90fe1935df8d697db Mon Sep 17 00:00:00 2001 From: qh Date: Mon, 29 Jan 2024 14:45:29 +0800 Subject: [PATCH 2/7] feat: add helper function to extract kid from singpass or corppass token response --- src/util/JweUtil.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/util/JweUtil.ts b/src/util/JweUtil.ts index 44f44aa4..470d6ee1 100644 --- a/src/util/JweUtil.ts +++ b/src/util/JweUtil.ts @@ -1,6 +1,8 @@ import * as jose from "node-jose"; import { SingpassMyInfoError } from "./error/SingpassMyinfoError"; import { KeyFormat } from './KeyUtil'; +import { TokenResponse as SingpassTokenResponse } from "../singpass/shared-constants"; +import { TokenResponse as CorppassTokenResponse } from "../corppass/shared-constants"; export async function decryptJWE(jwe: string, decryptKey: string, format: KeyFormat = 'pem'): Promise { if (!jwe) throw new SingpassMyInfoError("Missing JWE data."); @@ -29,9 +31,14 @@ export async function verifyJwsUsingKeyStore(jws: string, keys: string | object) return jose.JWS.createVerify(keyStore).verify(jws); } -export function extractJwtHeader(jwt: string): object { +export function extractJwtHeader(jwt: string): Record { const jwtComponents = jwt.split("."); const header = jose.util.base64url.decode(jwtComponents[0]); return JSON.parse(header.toString()); +} +export function extractKidFromIdToken(tokens: SingpassTokenResponse | CorppassTokenResponse): string { + const { id_token: idToken } = tokens; + const { kid } = extractJwtHeader(idToken); + return kid; } From 18516108c94b17a17d16bd882444764ab9f72bbf Mon Sep 17 00:00:00 2001 From: qh Date: Tue, 30 Jan 2024 14:20:44 +0800 Subject: [PATCH 3/7] test: add test for extrackKidFromIdToken ref: FILEZCAD-2080 --- src/util/JweUtil.ts | 3 ++- src/util/__tests__/JweUtil.spec.ts | 31 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/util/JweUtil.ts b/src/util/JweUtil.ts index 470d6ee1..5e563c83 100644 --- a/src/util/JweUtil.ts +++ b/src/util/JweUtil.ts @@ -26,7 +26,7 @@ export async function verifyJWS(jws: string, verifyCert: string, format: KeyForm export async function verifyJwsUsingKeyStore(jws: string, keys: string | object) { if (!jws) throw new SingpassMyInfoError("Missing JWT data."); - if (!keys) throw new SingpassMyInfoError("Missing key set"); + if (!keys) throw new SingpassMyInfoError("Missing key set."); const keyStore = await jose.JWK.asKeyStore(keys); return jose.JWS.createVerify(keyStore).verify(jws); } @@ -40,5 +40,6 @@ export function extractJwtHeader(jwt: string): Record { export function extractKidFromIdToken(tokens: SingpassTokenResponse | CorppassTokenResponse): string { const { id_token: idToken } = tokens; const { kid } = extractJwtHeader(idToken); + if (!kid) throw new SingpassMyInfoError("Missing kid."); return kid; } diff --git a/src/util/__tests__/JweUtil.spec.ts b/src/util/__tests__/JweUtil.spec.ts index 87e14652..f562e104 100644 --- a/src/util/__tests__/JweUtil.spec.ts +++ b/src/util/__tests__/JweUtil.spec.ts @@ -1,4 +1,6 @@ -import { extractJwtHeader } from "../JweUtil"; +import { TokenResponse } from "../../singpass/shared-constants"; +import { extractJwtHeader, extractKidFromIdToken } from "../JweUtil"; +import { SingpassMyInfoError } from "../error/SingpassMyinfoError"; describe("extractJwtHeader", () => { @@ -10,3 +12,30 @@ describe("extractJwtHeader", () => { }); }); }); + +describe("extractKidFromIdToken", () => { + const SAMPLE_JWT_WITHOUT_KID = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const SAMPLE_JWT_WITH_KID = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2lkIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.dcwcwIMbXPoifMvEnN_SlEEYOoErMH7CchiTQ8m9oy8'; + it("should throw error when id_token header does not contain kid", () => { + const SAMPLE_TOKEN: TokenResponse = { + access_token: "", + refresh_token: "", + id_token: SAMPLE_JWT_WITHOUT_KID, + token_type: "", + expires_in: 0, + scope: "" + }; + expect(() => extractKidFromIdToken(SAMPLE_TOKEN)).toThrow(SingpassMyInfoError); + }); + it("should return kid in id_token", () => { + const SAMPLE_TOKEN: TokenResponse = { + access_token: "", + refresh_token: "", + id_token: SAMPLE_JWT_WITH_KID, + token_type: "", + expires_in: 0, + scope: "" + }; + expect(extractKidFromIdToken(SAMPLE_TOKEN)).toEqual('test-kid'); + }); +}); From bc7e984ebfe23c60bc2c5dc098078a2b414291a9 Mon Sep 17 00:00:00 2001 From: qh Date: Thu, 1 Feb 2024 11:40:58 +0800 Subject: [PATCH 4/7] chore: update changelog ref: FILEZCAD-2080 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a1f126..ca6df670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,20 @@ # Changelogs +## Unreleased + +- Add optional `overrideDecryptKey` to corppass and singpass helper's `getIdTokenPayload` method +- Add `extractJwtHeader` and `extractKidFromIdToken` method + ## 8.3.4 + - Add all new profiles for book facilities UAT ## 8.3.3 + - Add new profiles for book facilities UAT ## 8.3.1 + - Add new profiles for book facilities UAT ## 8.2.0 From 0235f0845b0d18cb5439912c48423f884f139ef4 Mon Sep 17 00:00:00 2001 From: qh Date: Thu, 1 Feb 2024 14:41:51 +0800 Subject: [PATCH 5/7] test: add test for getIdTokenPayload '/Users/qh/dev/commons/singpass-myinfo-oidc-helper' --- .../__tests__/corppass-helper-ndi.spec.ts | 160 ++++++++++++++++-- .../__tests__/singpass-helper-ndi.spec.ts | 83 ++++++++- 2 files changed, 223 insertions(+), 20 deletions(-) diff --git a/src/corppass/__tests__/corppass-helper-ndi.spec.ts b/src/corppass/__tests__/corppass-helper-ndi.spec.ts index f8b7df0f..e40af3e4 100644 --- a/src/corppass/__tests__/corppass-helper-ndi.spec.ts +++ b/src/corppass/__tests__/corppass-helper-ndi.spec.ts @@ -1,15 +1,24 @@ import { NDIIdTokenPayload, NdiOidcHelper, NdiOidcHelperConstructor } from "../corppass-helper-ndi"; import { TokenResponse } from "../shared-constants"; import * as JweUtils from "../../util/JweUtil"; -import { JWS } from "node-jose"; +import { JWE, JWS } from "node-jose"; const mockOidcConfigUrl = "https://mockcorppass.sg/authorize"; const mockClientId = "CLIENT-ID"; const mockRedirectUri = "http://mockme.sg/callback"; +const mockAdditionalHeaders = { "x-api-token": "TOKEN" }; const mockDecryptKey = '{"kty": "EC","d": "AA1YtF2O779tiuJ4Rs3UVItxgX3GFOgQ-aycS-n-lFU","use": "enc","crv": "P-256","kid": "odRFtcGZYAwsS4WtQWdbwdVXuAdHt4VoqFX6VwAXrmQ","x": "MFqQFZrB74cDhiBHhIBg9iCB-qj86vU45dj2iA-RAjs","y": "yUOsmZh4rd3qwqXRgRCIaAyRcOj4S0mD6tEsd-aTlL0","alg": "ECDH-ES+A256KW"}'; const mockSignKey = '{"kty": "EC","d": "QMS1DAh9RHzH7Oqj2FL5FW1j7FeQWqNjIfoaSfV14x8","use": "sig","crv": "P-256","kid": "jqjQh6u7LHFFxCPf12PqBzbDfpnqL9I0qR8Gqllq6vU","x": "17aNA7ePDntFNM0hKfTFcFoXhHK0nJ7n4zDwXfwi22s","y": "fGJn6q2zQitVVJY91Fr1oe4bErqy5SL3V4AC4e_4dmQ","alg": "ES256"}'; +const mockTokenResponse: TokenResponse = { + access_token: 'MOCK_ACCESS_TOKEN', + refresh_token: "MOCK_REFRESH_TOKEN", + id_token: "MOCK_ID_TOKEN", + token_type: "bearer", + expires_in: 599, + scope: "openid" +}; const createMockIdTokenPayload = (overrideProps?: Partial): NDIIdTokenPayload => ({ userInfo: { @@ -138,14 +147,6 @@ describe("NDI Corppass Helper", () => { }); describe("Authorisation info api", () => { - const MOCK_TOKEN: TokenResponse = { - access_token: 'MOCK_ACCESS_TOKEN', - refresh_token: "MOCK_REFRESH_TOKEN", - id_token: "MOCK_ID_TOKEN", - token_type: "bearer", - expires_in: 599, - scope: "openid" - }; const MOCK_AUTH_INFO = { "Result_Set": { @@ -204,12 +205,11 @@ describe("NDI Corppass Helper", () => { }; const MOCK_AUTH_PAYLOAD = { ...MOCK_RAW_AUTH_PAYLOAD, AuthInfo: MOCK_AUTH_INFO }; - const MOCK_ADDITIONAL_HEADERS = { "x-api-token": "TOKEN" }; - it("should use proxy url when specific", async () => { + it("should use proxy url when specified", async () => { const corppassHelper = new NdiOidcHelper({ ...props, proxyBaseUrl: "https://www.proxy.gov.sg", - additionalHeaders: MOCK_ADDITIONAL_HEADERS, + additionalHeaders: mockAdditionalHeaders, }); const mockVerifyJwsUsingKeyStore = jest.spyOn(JweUtils, "verifyJwsUsingKeyStore").mockResolvedValueOnce({ payload: JSON.stringify(MOCK_RAW_AUTH_PAYLOAD) } as unknown as JWS.VerificationResult); @@ -251,18 +251,18 @@ describe("NDI Corppass Helper", () => { corppassHelper._testExports.getCorppassClient().get = axiosMock; corppassHelper._testExports.getCorppassClient().post = axiosPostMock; - expect(await corppassHelper.getAuthorisationInfoTokenPayload(MOCK_TOKEN)).toStrictEqual(MOCK_AUTH_PAYLOAD); + expect(await corppassHelper.getAuthorisationInfoTokenPayload(mockTokenResponse)).toStrictEqual(MOCK_AUTH_PAYLOAD); expect(axiosMock.mock.calls[0]).toEqual( expect.arrayContaining([ mockOidcConfigUrl, - { headers: MOCK_ADDITIONAL_HEADERS }, + { headers: mockAdditionalHeaders }, ]), ); expect(axiosMock.mock.calls[1]).toEqual( expect.arrayContaining([ 'https://www.proxy.gov.sg/.well-known/keys', - { headers: MOCK_ADDITIONAL_HEADERS }, + { headers: mockAdditionalHeaders }, ]), ); @@ -273,7 +273,7 @@ describe("NDI Corppass Helper", () => { expect.arrayContaining([ "https://www.proxy.gov.sg/authorization-info", null, - { headers: { "Authorization": `Bearer ${MOCK_TOKEN.access_token}`, ...MOCK_ADDITIONAL_HEADERS } }, + { headers: { "Authorization": `Bearer ${mockTokenResponse.access_token}`, ...mockAdditionalHeaders } }, ]), ); }); @@ -282,7 +282,7 @@ describe("NDI Corppass Helper", () => { const corppassHelper = new NdiOidcHelper({ ...props, proxyBaseUrl: "https://www.proxy.gov.sg", - additionalHeaders: MOCK_ADDITIONAL_HEADERS, + additionalHeaders: mockAdditionalHeaders, }); expect(corppassHelper.extractActiveAuthResultFromAuthInfoToken(MOCK_AUTH_PAYLOAD)).toStrictEqual({ EserviceId: [{ @@ -300,4 +300,130 @@ describe("NDI Corppass Helper", () => { }); }); + + describe("getIdTokenPayload()", () => { + const mockOverrideDecryptKey = + '{"kty": "EC","d": "AA1YtF2O779tiuJ4Rs3UVItxgX3GFOgQ-aycS-n-lFU","use": "enc","crv": "P-256","kid": "MOCK-OVERRIDE-DECRYPT-KEY-ID","x": "MFqQFZrB74cDhiBHhIBg9iCB-qj86vU45dj2iA-RAjs","y": "yUOsmZh4rd3qwqXRgRCIaAyRcOj4S0mD6tEsd-aTlL0","alg": "ECDH-ES+A256KW"}'; + + const mockVerifiedJws = { payload: JSON.stringify({ mockResults: 'VERIFIED_JWS' }) }; + it("should use proxy url when specified", async () => { + const corppassHelper = new NdiOidcHelper({ + ...props, + proxyBaseUrl: "https://www.proxy.gov.sg", + additionalHeaders: mockAdditionalHeaders, + }); + + const mockDecryptJwe = jest.spyOn(JweUtils, "decryptJWE").mockResolvedValueOnce({ payload: 'DECRYPT_RESULTS' } as unknown as JWE.DecryptResult); + const mockVerifyJWS = jest.spyOn(JweUtils, "verifyJWS").mockResolvedValueOnce(mockVerifiedJws as unknown as JWS.VerificationResult); + + const axiosMock = jest.fn(); + // First get is to get OIDC Config + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + token_endpoint: "https://mockcorppass.sg/mga/sps/oauth/oauth20/token", + issuer: "https://mockcorppass.sg", + 'authorization-info_endpoint': "https://mockcorppass.sg/authorization-info", + jwks_uri: "https://mockcorppass.sg/.well-known/keys", + + }, + }; + }); + + // Second get is to get JWKS + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + keys: ["MOCK_KEY"], + + }, + }; + }); + + + corppassHelper._testExports.getCorppassClient().get = axiosMock; + + await corppassHelper.getIdTokenPayload(mockTokenResponse); + + expect(axiosMock.mock.calls[0]).toEqual( + expect.arrayContaining([ + mockOidcConfigUrl, + { headers: mockAdditionalHeaders }, + ]), + ); + expect(axiosMock.mock.calls[1]).toEqual( + expect.arrayContaining([ + 'https://www.proxy.gov.sg/.well-known/keys', + { headers: mockAdditionalHeaders }, + ]), + ); + + expect(mockDecryptJwe).toHaveBeenCalledWith(mockTokenResponse.id_token, mockDecryptKey, 'json',); + expect(mockVerifyJWS).toHaveBeenCalledWith('DECRYPT_RESULTS', JSON.stringify("MOCK_KEY"), 'json'); + expect(axiosMock).toHaveBeenCalledTimes(2); + + }); + + it("should use overrideDecryptKey when specified", async () => { + const corppassHelper = new NdiOidcHelper({ + ...props, + proxyBaseUrl: "https://www.proxy.gov.sg", + additionalHeaders: mockAdditionalHeaders, + }); + + const mockDecryptJwe = jest.spyOn(JweUtils, "decryptJWE").mockResolvedValueOnce({ payload: 'DECRYPT_RESULTS' } as unknown as JWE.DecryptResult); + const mockVerifyJWS = jest.spyOn(JweUtils, "verifyJWS").mockResolvedValueOnce(mockVerifiedJws as unknown as JWS.VerificationResult); + + const axiosMock = jest.fn(); + // First get is to get OIDC Config + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + token_endpoint: "https://mockcorppass.sg/mga/sps/oauth/oauth20/token", + issuer: "https://mockcorppass.sg", + 'authorization-info_endpoint': "https://mockcorppass.sg/authorization-info", + jwks_uri: "https://mockcorppass.sg/.well-known/keys", + + }, + }; + }); + + // Second get is to get JWKS + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + keys: ["MOCK_KEY"], + + }, + }; + }); + + + corppassHelper._testExports.getCorppassClient().get = axiosMock; + + await corppassHelper.getIdTokenPayload(mockTokenResponse, { key: mockOverrideDecryptKey, format: "json" }); + + expect(axiosMock.mock.calls[0]).toEqual( + expect.arrayContaining([ + mockOidcConfigUrl, + { headers: mockAdditionalHeaders }, + ]), + ); + expect(axiosMock.mock.calls[1]).toEqual( + expect.arrayContaining([ + 'https://www.proxy.gov.sg/.well-known/keys', + { headers: mockAdditionalHeaders }, + ]), + ); + + expect(mockDecryptJwe).toHaveBeenCalledWith(mockTokenResponse.id_token, mockOverrideDecryptKey, 'json',); + expect(mockVerifyJWS).toHaveBeenCalledWith('DECRYPT_RESULTS', JSON.stringify("MOCK_KEY"), 'json'); + expect(axiosMock).toHaveBeenCalledTimes(2); + + }); + }); }); diff --git a/src/singpass/__tests__/singpass-helper-ndi.spec.ts b/src/singpass/__tests__/singpass-helper-ndi.spec.ts index 1d4c59c4..142449bd 100644 --- a/src/singpass/__tests__/singpass-helper-ndi.spec.ts +++ b/src/singpass/__tests__/singpass-helper-ndi.spec.ts @@ -1,11 +1,21 @@ import { NdiOidcHelper, NdiOidcHelperConstructor } from "../singpass-helper-ndi"; -import { TokenPayload } from '../shared-constants'; +import { TokenPayload, TokenResponse } from '../shared-constants'; +import * as JweUtils from "../../util/JweUtil"; +import { JWE, JWS } from "node-jose"; const mockOidcConfigUrl = "https://mocksingpass.sg/authorize"; const mockClientId = "CLIENT-ID"; const mockRedirectUri = "http://mockme.sg/callback"; const mockDecryptKey = "sshh-secret"; const mockSignKey = "sshh-secret"; +const mockTokenResponse: TokenResponse = { + access_token: 'MOCK_ACCESS_TOKEN', + refresh_token: "MOCK_REFRESH_TOKEN", + id_token: "MOCK_ID_TOKEN", + token_type: "bearer", + expires_in: 599, + scope: "openid" +}; const createMockTokenPayload = (overrideProps?: Partial): TokenPayload => ({ rt_hash: "TJXzQKancNCg3f3YQcZhzg", @@ -25,8 +35,8 @@ describe("NDI Singpass Helper", () => { oidcConfigUrl: mockOidcConfigUrl, clientID: mockClientId, redirectUri: mockRedirectUri, - jweDecryptKey: {key: mockDecryptKey}, - clientAssertionSignKey: {key:mockSignKey}, + jweDecryptKey: { key: mockDecryptKey }, + clientAssertionSignKey: { key: mockSignKey }, }; const helper = new NdiOidcHelper(props); @@ -83,4 +93,71 @@ describe("NDI Singpass Helper", () => { expect(() => helper.extractNricAndUuidFromPayload(mockPayload)).toThrowError("Token payload sub property is invalid, does not contain valid NRIC and uuid string"); }); }); + + describe("getIdTokenPayload()", () => { + const mockOverrideDecryptKey = + '{"kty": "EC","d": "AA1YtF2O779tiuJ4Rs3UVItxgX3GFOgQ-aycS-n-lFU","use": "enc","crv": "P-256","kid": "MOCK-OVERRIDE-DECRYPT-KEY-ID","x": "MFqQFZrB74cDhiBHhIBg9iCB-qj86vU45dj2iA-RAjs","y": "yUOsmZh4rd3qwqXRgRCIaAyRcOj4S0mD6tEsd-aTlL0","alg": "ECDH-ES+A256KW"}'; + + const mockVerifiedJws = { payload: JSON.stringify({ mockResults: 'VERIFIED_JWS' }) }; + + it("should use overrideDecryptKey when specified", async () => { + const corppassHelper = new NdiOidcHelper({ + ...props, + }); + + const mockDecryptJwe = jest.spyOn(JweUtils, "decryptJWE").mockResolvedValueOnce({ payload: 'DECRYPT_RESULTS' } as unknown as JWE.DecryptResult); + const mockVerifyJWS = jest.spyOn(JweUtils, "verifyJWS").mockResolvedValueOnce(mockVerifiedJws as unknown as JWS.VerificationResult); + + const mockJwksUrl = "https://www.mocksingpass.gov.sg/.well-known/keys"; + const mockTokenEndpoint = "https://www.mocksingpass.gov.sg/mga/sps/oauth/oauth20/token"; + const mockIssuer = "https://www.mocksingpass.gov.sg"; + const mockAuthorizationInfoEndpoint = "https://www.mocksingpass.gov.sg/authorization-info"; + const axiosMock = jest.fn(); + // First get is to get OIDC Config + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + token_endpoint: mockTokenEndpoint, + issuer: mockIssuer, + 'authorization-info_endpoint': mockAuthorizationInfoEndpoint, + jwks_uri: mockJwksUrl, + + }, + }; + }); + + // Second get is to get JWKS + axiosMock.mockImplementationOnce(() => { + return { + status: 200, + data: { + keys: ["MOCK_KEY"], + + }, + }; + }); + + + corppassHelper._testExports.getSingpassClient().get = axiosMock; + + await corppassHelper.getIdTokenPayload(mockTokenResponse, { key: mockOverrideDecryptKey, format: "json" }); + + expect(axiosMock.mock.calls[0]).toEqual( + expect.arrayContaining([ + mockOidcConfigUrl, + ]), + ); + expect(axiosMock.mock.calls[1]).toEqual( + expect.arrayContaining([ + mockJwksUrl, + ]), + ); + + expect(mockDecryptJwe).toHaveBeenCalledWith(mockTokenResponse.id_token, mockOverrideDecryptKey, 'json',); + expect(mockVerifyJWS).toHaveBeenCalledWith('DECRYPT_RESULTS', JSON.stringify("MOCK_KEY"), 'json'); + expect(axiosMock).toHaveBeenCalledTimes(2); + + }); + }); }); From f6dabd15287a3af78003d4af7e975d86f2380229 Mon Sep 17 00:00:00 2001 From: qh Date: Thu, 1 Feb 2024 16:55:30 +0800 Subject: [PATCH 6/7] chore: bump version for publishing --- CHANGELOG.md | 2 +- package.json | 122 +++++++++++++++++++++++++-------------------------- 2 files changed, 62 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6df670..cb0428e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelogs -## Unreleased +## 8.3.7 - Add optional `overrideDecryptKey` to corppass and singpass helper's `getIdTokenPayload` method - Add `extractJwtHeader` and `extractKidFromIdToken` method diff --git a/package.json b/package.json index 803b3f54..b588f159 100644 --- a/package.json +++ b/package.json @@ -1,63 +1,63 @@ { - "name": "@govtechsg/singpass-myinfo-oidc-helper", - "version": "8.3.6", - "description": "Helper for building a Relying Party to integrate with Singpass OIDC and MyInfo person basic API", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "repository": "github:GovTechSG/singpass-myinfo-oidc-helper", - "bugs": "https://github.com/GovTechSG/singpass-myinfo-oidc-helper/issues", - "license": "MIT", - "scripts": { - "prepare": "husky", - "prepublishOnly": "npm run build", - "test": "./shared-scripts/jest-test-unit-integration.sh", - "test:ext": "./shared-scripts/jest-test-external.sh", - "lint": "./shared-scripts/ts-lint.sh", - "build": "./scripts/build.sh", - "generate-myinfo-typings": "ts-node --project ./shared-config/script.tsconfig.json ./scripts/generate-myinfo-typings" - }, - "lint-staged": { - "*.ts": [ - "npm run lint --" - ] - }, - "dependencies": { - "@js-joda/core": "^5.6.1", - "@js-joda/timezone": "^2.18.2", - "axios": "~1.6.7", - "https-proxy-agent": "^7.0.2", - "is-base64": "^1.1.0", - "jsonwebtoken": "^9.0.2", - "lodash": "^4.17.21", - "node-jose": "^2.2.0", - "nonce": "^1.0.4", - "proxy-agent": "^6.3.1", - "rosie": "^2.1.1" - }, - "devDependencies": { - "@types/jest": "^29.5.11", - "@types/jsonwebtoken": "^9.0.5", - "@types/lodash": "^4.14.202", - "@types/node": "^18.19.9", - "@types/node-jose": "^1.1.13", - "@types/rosie": "0.0.45", - "dotenv": "^16.4.1", - "dtsgenerator": "^3.19.1", - "handlebars": "^4.7.8", - "husky": "^9.0.7", - "jest": "^29.7.0", - "jest-bamboo-reporter": "^1.3.0", - "jest-junit": "^16.0.0", - "lint-staged": "^15.2.1", - "nock": "^13.5.1", - "shelljs": "~0.8.5", - "ts-jest": "^29.1.2", - "ts-node": "^10.9.2", - "tslint": "^6.1.3", - "tslint-config-security": "^1.16.0", - "tslint-no-circular-imports": "~0.7.0", - "tslint-sonarts": "^1.9.0", - "typescript": "^5.3.3", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz" - } + "name": "@govtechsg/singpass-myinfo-oidc-helper", + "version": "8.3.7", + "description": "Helper for building a Relying Party to integrate with Singpass OIDC and MyInfo person basic API", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": "github:GovTechSG/singpass-myinfo-oidc-helper", + "bugs": "https://github.com/GovTechSG/singpass-myinfo-oidc-helper/issues", + "license": "MIT", + "scripts": { + "prepare": "husky", + "prepublishOnly": "npm run build", + "test": "./shared-scripts/jest-test-unit-integration.sh", + "test:ext": "./shared-scripts/jest-test-external.sh", + "lint": "./shared-scripts/ts-lint.sh", + "build": "./scripts/build.sh", + "generate-myinfo-typings": "ts-node --project ./shared-config/script.tsconfig.json ./scripts/generate-myinfo-typings" + }, + "lint-staged": { + "*.ts": [ + "npm run lint --" + ] + }, + "dependencies": { + "@js-joda/core": "^5.6.1", + "@js-joda/timezone": "^2.18.2", + "axios": "~1.6.7", + "https-proxy-agent": "^7.0.2", + "is-base64": "^1.1.0", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", + "node-jose": "^2.2.0", + "nonce": "^1.0.4", + "proxy-agent": "^6.3.1", + "rosie": "^2.1.1" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/lodash": "^4.14.202", + "@types/node": "^18.19.9", + "@types/node-jose": "^1.1.13", + "@types/rosie": "0.0.45", + "dotenv": "^16.4.1", + "dtsgenerator": "^3.19.1", + "handlebars": "^4.7.8", + "husky": "^9.0.7", + "jest": "^29.7.0", + "jest-bamboo-reporter": "^1.3.0", + "jest-junit": "^16.0.0", + "lint-staged": "^15.2.1", + "nock": "^13.5.1", + "shelljs": "~0.8.5", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "tslint": "^6.1.3", + "tslint-config-security": "^1.16.0", + "tslint-no-circular-imports": "~0.7.0", + "tslint-sonarts": "^1.9.0", + "typescript": "^5.3.3", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz" + } } From 370b23672810a585901a5476e27cc22cdefb793f Mon Sep 17 00:00:00 2001 From: qh Date: Thu, 1 Feb 2024 17:14:10 +0800 Subject: [PATCH 7/7] chore: revert linting --- package.json | 122 +++++++++++++++++++++++++-------------------------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index b588f159..76ff67e1 100644 --- a/package.json +++ b/package.json @@ -1,63 +1,63 @@ { - "name": "@govtechsg/singpass-myinfo-oidc-helper", - "version": "8.3.7", - "description": "Helper for building a Relying Party to integrate with Singpass OIDC and MyInfo person basic API", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "repository": "github:GovTechSG/singpass-myinfo-oidc-helper", - "bugs": "https://github.com/GovTechSG/singpass-myinfo-oidc-helper/issues", - "license": "MIT", - "scripts": { - "prepare": "husky", - "prepublishOnly": "npm run build", - "test": "./shared-scripts/jest-test-unit-integration.sh", - "test:ext": "./shared-scripts/jest-test-external.sh", - "lint": "./shared-scripts/ts-lint.sh", - "build": "./scripts/build.sh", - "generate-myinfo-typings": "ts-node --project ./shared-config/script.tsconfig.json ./scripts/generate-myinfo-typings" - }, - "lint-staged": { - "*.ts": [ - "npm run lint --" - ] - }, - "dependencies": { - "@js-joda/core": "^5.6.1", - "@js-joda/timezone": "^2.18.2", - "axios": "~1.6.7", - "https-proxy-agent": "^7.0.2", - "is-base64": "^1.1.0", - "jsonwebtoken": "^9.0.2", - "lodash": "^4.17.21", - "node-jose": "^2.2.0", - "nonce": "^1.0.4", - "proxy-agent": "^6.3.1", - "rosie": "^2.1.1" - }, - "devDependencies": { - "@types/jest": "^29.5.11", - "@types/jsonwebtoken": "^9.0.5", - "@types/lodash": "^4.14.202", - "@types/node": "^18.19.9", - "@types/node-jose": "^1.1.13", - "@types/rosie": "0.0.45", - "dotenv": "^16.4.1", - "dtsgenerator": "^3.19.1", - "handlebars": "^4.7.8", - "husky": "^9.0.7", - "jest": "^29.7.0", - "jest-bamboo-reporter": "^1.3.0", - "jest-junit": "^16.0.0", - "lint-staged": "^15.2.1", - "nock": "^13.5.1", - "shelljs": "~0.8.5", - "ts-jest": "^29.1.2", - "ts-node": "^10.9.2", - "tslint": "^6.1.3", - "tslint-config-security": "^1.16.0", - "tslint-no-circular-imports": "~0.7.0", - "tslint-sonarts": "^1.9.0", - "typescript": "^5.3.3", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz" - } + "name": "@govtechsg/singpass-myinfo-oidc-helper", + "version": "8.3.7", + "description": "Helper for building a Relying Party to integrate with Singpass OIDC and MyInfo person basic API", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "repository": "github:GovTechSG/singpass-myinfo-oidc-helper", + "bugs": "https://github.com/GovTechSG/singpass-myinfo-oidc-helper/issues", + "license": "MIT", + "scripts": { + "prepare": "husky", + "prepublishOnly": "npm run build", + "test": "./shared-scripts/jest-test-unit-integration.sh", + "test:ext": "./shared-scripts/jest-test-external.sh", + "lint": "./shared-scripts/ts-lint.sh", + "build": "./scripts/build.sh", + "generate-myinfo-typings": "ts-node --project ./shared-config/script.tsconfig.json ./scripts/generate-myinfo-typings" + }, + "lint-staged": { + "*.ts": [ + "npm run lint --" + ] + }, + "dependencies": { + "@js-joda/core": "^5.6.1", + "@js-joda/timezone": "^2.18.2", + "axios": "~1.6.7", + "https-proxy-agent": "^7.0.2", + "is-base64": "^1.1.0", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", + "node-jose": "^2.2.0", + "nonce": "^1.0.4", + "proxy-agent": "^6.3.1", + "rosie": "^2.1.1" + }, + "devDependencies": { + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/lodash": "^4.14.202", + "@types/node": "^18.19.9", + "@types/node-jose": "^1.1.13", + "@types/rosie": "0.0.45", + "dotenv": "^16.4.1", + "dtsgenerator": "^3.19.1", + "handlebars": "^4.7.8", + "husky": "^9.0.7", + "jest": "^29.7.0", + "jest-bamboo-reporter": "^1.3.0", + "jest-junit": "^16.0.0", + "lint-staged": "^15.2.1", + "nock": "^13.5.1", + "shelljs": "~0.8.5", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "tslint": "^6.1.3", + "tslint-config-security": "^1.16.0", + "tslint-no-circular-imports": "~0.7.0", + "tslint-sonarts": "^1.9.0", + "typescript": "^5.3.3", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz" + } }