Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FILEZCAD-2080] Providing option to override decrypt key #80

Merged
merged 10 commits into from
Feb 1, 2024
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ Singpass.NdiOidcHelper
- nonce (later returned inside the JWT from token endpoint)

- `getTokens (authCode: string, axiosRequestConfig?: AxiosRequestConfig) => Promise<TokenResponse>` - get back the tokens from SP token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload
- `getIdTokenPayload(tokens: TokenResponse) => Promise<TokenPayload>` - decrypt and verify the JWT. Outputs TokenPayload, which is the input for extractNricAndUuidFromPayload
- `getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key) => Promise<TokenPayload>` - 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

---
Expand Down Expand Up @@ -281,7 +281,7 @@ Corppass.OidcHelper
- `getTokens (authCode: string, axiosRequestConfig?: AxiosRequestConfig) => Promise<TokenResponse>` - get back the tokens from token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload
- `refreshTokens (refreshToken: string, axiosRequestConfig?: AxiosRequestConfig) => Promise<TokenResponse>` - get fresh tokens from SP token endpoint. Outputs TokenResponse, which is the input for getIdTokenPayload
- `getAccessTokenPayload(tokens: TokenResponse) => Promise<AccessTokenPayload>` - decode and verify the JWT. Outputs AccessTokenPayload, which contains the `EntityInfo`, `AuthInfo` and `TPAccessInfo` claims
- `getIdTokenPayload(tokens: TokenResponse) => Promise<IdTokenPayload>` - decrypt and verify the JWT. Outputs IdTokenPayload, which is the input for extractInfoFromIdTokenSubject
- `getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key) => Promise<IdTokenPayload>` - 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

---
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
160 changes: 143 additions & 17 deletions src/corppass/__tests__/corppass-helper-ndi.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): NDIIdTokenPayload => ({
userInfo: {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 },
]),
);

Expand All @@ -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 } },
]),
);
});
Expand All @@ -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: [{
Expand All @@ -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);

});
});
});
6 changes: 4 additions & 2 deletions src/corppass/corppass-helper-ndi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NDIIdTokenPayload> {
public async getIdTokenPayload(tokens: TokenResponse, overrideDecryptKey?: Key): Promise<NDIIdTokenPayload> {
try {
const {
data: { jwks_uri, issuer },
Expand All @@ -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;
Expand Down
83 changes: 80 additions & 3 deletions src/singpass/__tests__/singpass-helper-ndi.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): TokenPayload => ({
rt_hash: "TJXzQKancNCg3f3YQcZhzg",
Expand All @@ -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);

Expand Down Expand Up @@ -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);

});
});
});
Loading
Loading