Skip to content

Commit

Permalink
Merge pull request #80 from qHedge/master
Browse files Browse the repository at this point in the history
[FILEZCAD-2080] Providing option to override decrypt key
  • Loading branch information
Gyunikuchan authored Feb 1, 2024
2 parents 92712e5 + 370b236 commit 69c3b3e
Show file tree
Hide file tree
Showing 9 changed files with 303 additions and 32 deletions.
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

0 comments on commit 69c3b3e

Please sign in to comment.