diff --git a/CHANGELOG.md b/CHANGELOG.md index 34a1f126..cb0428e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,20 @@ # Changelogs +## 8.3.7 + +- 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 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/package.json b/package.json index 803b3f54..76ff67e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govtechsg/singpass-myinfo-oidc-helper", - "version": "8.3.6", + "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", 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/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/__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); + + }); + }); }); 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..5e563c83 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."); @@ -24,7 +26,20 @@ 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); } + +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); + 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 new file mode 100644 index 00000000..f562e104 --- /dev/null +++ b/src/util/__tests__/JweUtil.spec.ts @@ -0,0 +1,41 @@ +import { TokenResponse } from "../../singpass/shared-constants"; +import { extractJwtHeader, extractKidFromIdToken } from "../JweUtil"; +import { SingpassMyInfoError } from "../error/SingpassMyinfoError"; + + +describe("extractJwtHeader", () => { + it("should extract JWT header", () => { + const SAMPLE_JWT = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + expect(extractJwtHeader(SAMPLE_JWT)).toStrictEqual({ + "alg": "HS256", + "typ": "JWT" + }); + }); +}); + +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'); + }); +});