From 7210bde0bd5482de6d231278beb5d835bacb5eeb Mon Sep 17 00:00:00 2001 From: qh Date: Tue, 16 Jan 2024 17:42:13 +0800 Subject: [PATCH 1/3] feat: implement get auth info ref: FILEZCAD-2016 --- README.md | 2 ++ src/corppass/corppass-helper-ndi.ts | 43 ++++++++++++++++++++++++++++- src/corppass/shared-constants.ts | 4 +-- src/util/JweUtil.ts | 7 +++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0cf5d976..87dcdc86 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,8 @@ Corppass.NdiOidcHelper - `getIdTokenPayload(tokens: TokenResponse) => 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 +- `getAuthorisationInfo(tokens: TokenResponse) => Promise` - get authorisation info JWT from authorisation info endpoint. Outputs AuthInfoTokenPayload, which contain roles and params information of user + --- ## Key Object diff --git a/src/corppass/corppass-helper-ndi.ts b/src/corppass/corppass-helper-ndi.ts index 340ac687..b0c22c26 100644 --- a/src/corppass/corppass-helper-ndi.ts +++ b/src/corppass/corppass-helper-ndi.ts @@ -4,7 +4,7 @@ import { createClient } from "../client/axios-client"; import { JweUtil } from "../util"; import { SingpassMyInfoError } from "../util/error/SingpassMyinfoError"; import { logger } from "../util/Logger"; -import { EntityInfo, TokenResponse, UserInfo } from "./shared-constants"; +import { AuthInfo, EntityInfo, TokenResponse, TPAccessInfo, UserInfo } from "./shared-constants"; import { Key } from "../util/KeyUtil"; import { createClientAssertion } from "../util/SigningUtil"; @@ -30,6 +30,15 @@ export interface NDIIdTokenPayload { userInfo: UserInfo; } +export interface AuthInfoTokenPayload { + aud: string; + iat: number; + iss: string; + exp: number; + AuthInfo: AuthInfo | string; + TpAuthInfo: TPAccessInfo; +} + export interface NdiOidcHelperConstructor { oidcConfigUrl: string; clientID: string; @@ -55,6 +64,7 @@ interface OidcConfig { authorization_endpoint: string; token_endpoint: string; jwks_uri: string; + 'authorization-info_endpoint': string; } export class NdiOidcHelper { @@ -219,6 +229,37 @@ export class NdiOidcHelper { throw Error("Token payload sub property is not defined"); } + /** + * Get authorisation information from Corppass Endpoint + */ + public async getAuthorisationInfo(tokens: TokenResponse): Promise { + try { + const { + data: { 'authorization-info_endpoint': authorisationInfoEndpoint, jwks_uri, issuer }, + } = await this.axiosClient.get(this.oidcConfigUrl, { headers: this.additionalHeaders }); + + const finalAuthorisationInfoUri = this.proxyBaseUrl ? authorisationInfoEndpoint.replace(issuer, this.proxyBaseUrl) : authorisationInfoEndpoint; + const { access_token: accessToken } = tokens; + const config = { + headers: { + ...this.additionalHeaders, + Authorization: `Bearer ${accessToken}`, + }, + }; + const { data: authorisationInfoJws } = await this.axiosClient.post(finalAuthorisationInfoUri, null, config); + + const finalJwksUri = this.proxyBaseUrl ? jwks_uri.replace(issuer, this.proxyBaseUrl) : jwks_uri; + const { data: { keys }, } = await this.axiosClient.get(finalJwksUri, { headers: this.additionalHeaders }); + + const verifiedJws = await JweUtil.verifyJwsUsingKeyStore(authorisationInfoJws, keys); + + return JSON.parse(verifiedJws.payload.toString()) as AuthInfoTokenPayload; + } catch (e) { + logger.error("Failed to get authorisation info", e); + throw e; + } + } + private validateStatus(status: number) { return status === 302 || (status >= 200 && status < 300); } diff --git a/src/corppass/shared-constants.ts b/src/corppass/shared-constants.ts index e1972818..dc892ff9 100644 --- a/src/corppass/shared-constants.ts +++ b/src/corppass/shared-constants.ts @@ -16,7 +16,7 @@ export interface EntityInfo { CPNonUEN_Name: string; } -interface AuthInfo { +export interface AuthInfo { Result_Set: { ESrvc_Row_Count: number; ESrvc_Result: { @@ -38,7 +38,7 @@ interface AuthInfo { }; } -interface TPAccessInfo { +export interface TPAccessInfo { Result_Set: { ESrvc_Row_Count: number, ESrvc_Result: { diff --git a/src/util/JweUtil.ts b/src/util/JweUtil.ts index 0e3cb0d2..8f3bf61b 100644 --- a/src/util/JweUtil.ts +++ b/src/util/JweUtil.ts @@ -21,3 +21,10 @@ export async function verifyJWS(jws: string, verifyCert: string, format: KeyForm return jose.JWS.createVerify(jwsKey).verify(jws); } + +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"); + const keyStore = await jose.JWK.asKeyStore(keys); + return jose.JWS.createVerify(keyStore).verify(jws); +} From dc02fb40ac0167c33d990c3e9dffdd28c1427a5a Mon Sep 17 00:00:00 2001 From: qh Date: Fri, 19 Jan 2024 18:14:46 +0800 Subject: [PATCH 2/3] feat: implement extract active auth info ref: FILEZCAD-2016 --- README.md | 4 +- .../__tests__/corppass-helper-ndi.spec.ts | 167 ++++++++++++++++++ src/corppass/corppass-helper-ndi.ts | 44 ++++- src/corppass/shared-constants.ts | 42 +++-- src/util/DateUtils.ts | 15 +- src/util/__tests__/DateUtil.spec.ts | 43 +++++ 6 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 src/util/__tests__/DateUtil.spec.ts diff --git a/README.md b/README.md index 87dcdc86..9fa47467 100644 --- a/README.md +++ b/README.md @@ -315,8 +315,8 @@ Corppass.NdiOidcHelper - `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 - `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 - -- `getAuthorisationInfo(tokens: TokenResponse) => Promise` - get authorisation info JWT from authorisation info endpoint. Outputs AuthInfoTokenPayload, which contain roles and params information of user +- `getAuthorisationInfoTokenPayload(tokens: TokenResponse) => Promise` - get authorisation info JWT from authorisation info endpoint. Outputs AuthInfoTokenPayload, which contain roles and params information of user +- `extractActiveAuthResultFromAuthInfoToken(authInfoToken: AuthInfoTokenPayload): Record` - get the currently active authorisation information of the user from the authorisation information token payload --- diff --git a/src/corppass/__tests__/corppass-helper-ndi.spec.ts b/src/corppass/__tests__/corppass-helper-ndi.spec.ts index 02cdc661..f8b7df0f 100644 --- a/src/corppass/__tests__/corppass-helper-ndi.spec.ts +++ b/src/corppass/__tests__/corppass-helper-ndi.spec.ts @@ -1,4 +1,7 @@ import { NDIIdTokenPayload, NdiOidcHelper, NdiOidcHelperConstructor } from "../corppass-helper-ndi"; +import { TokenResponse } from "../shared-constants"; +import * as JweUtils from "../../util/JweUtil"; +import { JWS } from "node-jose"; const mockOidcConfigUrl = "https://mockcorppass.sg/authorize"; const mockClientId = "CLIENT-ID"; @@ -133,4 +136,168 @@ 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": { + "ESrvc_Row_Count": 1, + "ESrvc_Result": [{ + "CPESrvcID": "EserviceId", + "Auth_Result_Set": + { + "Row_Count": 2, + "Row": [{ + "CPEntID_SUB": "", + "CPRole": "CorppassRole", + "StartDate": "2024-01-16", + "EndDate": "9999-12-31", + "Parameter": [{ + "name": "Agencies", + "value": "AGY" + }] + }, { + "CPEntID_SUB": "", + "CPRole": "CorppassRole", + "StartDate": "2000-01-16", + "EndDate": "2000-12-31", + "Parameter": [{ + "name": "Agencies", + "value": "AGY" + }] + }] + } + }, { + "CPESrvcID": "EserviceId2", + "Auth_Result_Set": + { + "Row_Count": 1, + "Row": [{ + "CPEntID_SUB": "", + "CPRole": "CorppassRole", + "StartDate": "2000-01-16", + "EndDate": "2000-12-31", + "Parameter": [{ + "name": "Agencies", + "value": "AGY" + }] + }] + } + }] + } + }; + + const MOCK_RAW_AUTH_PAYLOAD = { + aud: "", + iat: 0, + iss: "", + exp: 0, + AuthInfo: JSON.stringify(MOCK_AUTH_INFO), + }; + 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 () => { + const corppassHelper = new NdiOidcHelper({ + ...props, + proxyBaseUrl: "https://www.proxy.gov.sg", + additionalHeaders: MOCK_ADDITIONAL_HEADERS, + }); + + const mockVerifyJwsUsingKeyStore = jest.spyOn(JweUtils, "verifyJwsUsingKeyStore").mockResolvedValueOnce({ payload: JSON.stringify(MOCK_RAW_AUTH_PAYLOAD) } 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_KEYS", + + }, + }; + }); + + const axiosPostMock = jest.fn((): any => + Promise.resolve({ + status: 200, + data: "TEST_AUTH_INFO_TOKEN" + , + }), + ); + + corppassHelper._testExports.getCorppassClient().get = axiosMock; + corppassHelper._testExports.getCorppassClient().post = axiosPostMock; + + expect(await corppassHelper.getAuthorisationInfoTokenPayload(MOCK_TOKEN)).toStrictEqual(MOCK_AUTH_PAYLOAD); + + expect(axiosMock.mock.calls[0]).toEqual( + expect.arrayContaining([ + mockOidcConfigUrl, + { headers: MOCK_ADDITIONAL_HEADERS }, + ]), + ); + expect(axiosMock.mock.calls[1]).toEqual( + expect.arrayContaining([ + 'https://www.proxy.gov.sg/.well-known/keys', + { headers: MOCK_ADDITIONAL_HEADERS }, + ]), + ); + + expect(mockVerifyJwsUsingKeyStore).toHaveBeenCalledWith('TEST_AUTH_INFO_TOKEN', 'MOCK_KEYS'); + expect(axiosMock).toHaveBeenCalledTimes(2); + expect(axiosPostMock).toHaveBeenCalledTimes(1); + expect(axiosPostMock.mock.calls[0]).toEqual( + expect.arrayContaining([ + "https://www.proxy.gov.sg/authorization-info", + null, + { headers: { "Authorization": `Bearer ${MOCK_TOKEN.access_token}`, ...MOCK_ADDITIONAL_HEADERS } }, + ]), + ); + }); + + it("should extract active auth result", async () => { + const corppassHelper = new NdiOidcHelper({ + ...props, + proxyBaseUrl: "https://www.proxy.gov.sg", + additionalHeaders: MOCK_ADDITIONAL_HEADERS, + }); + expect(corppassHelper.extractActiveAuthResultFromAuthInfoToken(MOCK_AUTH_PAYLOAD)).toStrictEqual({ + EserviceId: [{ + "CPEntID_SUB": "", + "CPRole": "CorppassRole", + "StartDate": "2024-01-16", + "EndDate": "9999-12-31", + "Parameter": [{ + "name": "Agencies", + "value": "AGY" + }] + }], + EserviceId2: [] + }); + + }); + }); }); diff --git a/src/corppass/corppass-helper-ndi.ts b/src/corppass/corppass-helper-ndi.ts index b0c22c26..c330eb58 100644 --- a/src/corppass/corppass-helper-ndi.ts +++ b/src/corppass/corppass-helper-ndi.ts @@ -4,9 +4,10 @@ import { createClient } from "../client/axios-client"; import { JweUtil } from "../util"; import { SingpassMyInfoError } from "../util/error/SingpassMyinfoError"; import { logger } from "../util/Logger"; -import { AuthInfo, EntityInfo, TokenResponse, TPAccessInfo, UserInfo } from "./shared-constants"; +import { AuthInfo, EntityInfo, EserviceAuthResultRow, TokenResponse, TPAccessInfo, UserInfo } from "./shared-constants"; import { Key } from "../util/KeyUtil"; import { createClientAssertion } from "../util/SigningUtil"; +import { DateUtils } from "../util/DateUtils"; interface AccessTokenPayload { exp: number; @@ -35,8 +36,8 @@ export interface AuthInfoTokenPayload { iat: number; iss: string; exp: number; - AuthInfo: AuthInfo | string; - TpAuthInfo: TPAccessInfo; + AuthInfo: AuthInfo; + TpAuthInfo?: TPAccessInfo; } export interface NdiOidcHelperConstructor { @@ -232,7 +233,7 @@ export class NdiOidcHelper { /** * Get authorisation information from Corppass Endpoint */ - public async getAuthorisationInfo(tokens: TokenResponse): Promise { + public async getAuthorisationInfoTokenPayload(tokens: TokenResponse): Promise { try { const { data: { 'authorization-info_endpoint': authorisationInfoEndpoint, jwks_uri, issuer }, @@ -253,13 +254,46 @@ export class NdiOidcHelper { const verifiedJws = await JweUtil.verifyJwsUsingKeyStore(authorisationInfoJws, keys); - return JSON.parse(verifiedJws.payload.toString()) as AuthInfoTokenPayload; + const authorisationInfoTokenPayload = JSON.parse(verifiedJws.payload.toString()); + if (typeof authorisationInfoTokenPayload.AuthInfo === 'string') { + authorisationInfoTokenPayload.AuthInfo = JSON.parse(authorisationInfoTokenPayload.AuthInfo); + } + + if (typeof authorisationInfoTokenPayload.TP_Auth === 'string') { + authorisationInfoTokenPayload.TP_Auth = JSON.parse(authorisationInfoTokenPayload.TP_Auth); + } + + return authorisationInfoTokenPayload; } catch (e) { logger.error("Failed to get authorisation info", e); throw e; } } + public extractActiveAuthResultFromAuthInfoToken(authInfoTokenPayload: AuthInfoTokenPayload): Record { + const { AuthInfo: { Result_Set: authInfoResultSet } } = authInfoTokenPayload; + const { ESrvc_Row_Count: resultCount, ESrvc_Result: results } = authInfoResultSet; + + if (!resultCount) { + return {}; + } + const filteredResult = {} as Record; + + results.forEach((result) => { + const { + Auth_Result_Set: { Row: resultRows }, + CPESrvcID: serviceId, + } = result; + const filteredAuthResultSet = resultRows.filter((resultRow) => { + const { StartDate, EndDate } = resultRow; + return DateUtils.isWithinPeriod(StartDate, EndDate); + }); + filteredResult[serviceId] = filteredAuthResultSet; + }); + + return filteredResult; + } + private validateStatus(status: number) { return status === 302 || (status >= 200 && status < 300); } diff --git a/src/corppass/shared-constants.ts b/src/corppass/shared-constants.ts index dc892ff9..1543279d 100644 --- a/src/corppass/shared-constants.ts +++ b/src/corppass/shared-constants.ts @@ -17,25 +17,29 @@ export interface EntityInfo { } export interface AuthInfo { - Result_Set: { - ESrvc_Row_Count: number; - ESrvc_Result: { - CPESrvcID: string; - Auth_Result_Set: { - Row_Count: number; - Row: { - CPEntID_SUB: string; - CPRole: string; - StartDate: string; - EndDate: string; - Parameter?: { - name: string; - value: string; - }[]; - }[]; - }; - }[]; - }; + Result_Set: AuthInfoResultSet; +} + +export interface EserviceAuthResultRow { + CPEntID_SUB: string; + CPRole: string; + StartDate: string; + EndDate: string; + Parameter?: { + name: string; + value: string; + }[]; +} + +export interface AuthInfoResultSet { + ESrvc_Row_Count: number; + ESrvc_Result: { + CPESrvcID: string; + Auth_Result_Set: { + Row_Count: number; + Row: EserviceAuthResultRow[]; + }; + }[]; } export interface TPAccessInfo { diff --git a/src/util/DateUtils.ts b/src/util/DateUtils.ts index e167d270..a027bc51 100644 --- a/src/util/DateUtils.ts +++ b/src/util/DateUtils.ts @@ -1,6 +1,7 @@ -import { convert, Instant, LocalDate, LocalDateTime, LocalTime, TemporalAdjusters, ZonedDateTime, ZoneId } from "@js-joda/core"; +import { convert, DateTimeFormatterBuilder, Instant, LocalDate, LocalDateTime, LocalTime, ResolverStyle, TemporalAdjusters, ZonedDateTime, ZoneId } from "@js-joda/core"; import "@js-joda/timezone"; import * as _ from "lodash"; +import { logger } from "../util/Logger"; export namespace DateUtils { export const SG_TZ = ZoneId.of("Asia/Singapore"); @@ -238,4 +239,16 @@ export namespace DateUtils { return zdt.with(TemporalAdjusters.lastDayOfYear()).withHour(23).withMinute(59).withSecond(59).withNano(999999999); } + + /** + * Check if time is within period, if no input is given, current date (local time) will be used + * NOTE: startOfPeriod and endOfPeriod is currently in the format 'uuuu-MM-dd', example: 2015-01-05 + */ + export function isWithinPeriod(startOfPeriod: string, endOfPeriod: string, input = LocalDate.now()): boolean { + const CORPPASS_DATE_FORMAT = "uuuu-MM-dd"; + const formatter = new DateTimeFormatterBuilder().appendPattern(CORPPASS_DATE_FORMAT).toFormatter(ResolverStyle.STRICT); + const start = LocalDate.parse(startOfPeriod, formatter); + const end = LocalDate.parse(endOfPeriod, formatter); + return !(input.compareTo(start) < 0 || input.compareTo(end) > 0); + } } diff --git a/src/util/__tests__/DateUtil.spec.ts b/src/util/__tests__/DateUtil.spec.ts new file mode 100644 index 00000000..aa365a81 --- /dev/null +++ b/src/util/__tests__/DateUtil.spec.ts @@ -0,0 +1,43 @@ +import { LocalDate } from "@js-joda/core"; +import { DateUtils } from "../DateUtils"; + + +describe("isWithinPeriod", () => { + it("should return false when checking if tmr is between 2 years ago and today", () => { + const current = LocalDate.now(); + const input = current.plusDays(1); + const start = current.minusYears(2).toString(); + const end = current.toString(); + expect(DateUtils.isWithinPeriod(start, end, input)).toBe(false); + }); + it("should return true when checking if today is between 2 years ago and today", () => { + const current = LocalDate.now(); + const start = current.minusYears(2).toString(); + const end = current.toString(); + expect(DateUtils.isWithinPeriod(start, end)).toBe(true); + }); + it("should return true when checking if today is between today and tomorrow", () => { + const current = LocalDate.now(); + const start = current.toString(); + const end = current.plusDays(1).toString(); + expect(DateUtils.isWithinPeriod(start, end)).toBe(true); + }); + it("should return false when checking if today is between tomorrow and the day after", () => { + const current = LocalDate.now(); + const start = current.plusDays(1).toString(); + const end = current.plusDays(2).toString(); + expect(DateUtils.isWithinPeriod(start, end)).toBe(false); + }); + it("should return true when checking if today is between today and 9999-12-31", () => { + const current = LocalDate.now(); + const start = current.toString(); + const end = '9999-12-31'; + expect(DateUtils.isWithinPeriod(start, end)).toBe(true); + }); + it("should return false when checking if yesterday is between today and 9999-12-31", () => { + const current = LocalDate.now(); + const start = current.toString(); + const end = '9999-12-31'; + expect(DateUtils.isWithinPeriod(start, end, current.minusDays(1))).toBe(false); + }); +}); From 7e630ba37f7ed1abc4cbf4fc7e6978faea774c8d Mon Sep 17 00:00:00 2001 From: qh Date: Mon, 22 Jan 2024 18:13:39 +0800 Subject: [PATCH 3/3] chore: update typing and interface ref: FILEZCAD-2016 --- src/corppass/corppass-helper-ndi.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/corppass/corppass-helper-ndi.ts b/src/corppass/corppass-helper-ndi.ts index c330eb58..88cb98d3 100644 --- a/src/corppass/corppass-helper-ndi.ts +++ b/src/corppass/corppass-helper-ndi.ts @@ -37,7 +37,7 @@ export interface AuthInfoTokenPayload { iss: string; exp: number; AuthInfo: AuthInfo; - TpAuthInfo?: TPAccessInfo; + TPAuthInfo?: TPAccessInfo; } export interface NdiOidcHelperConstructor { @@ -254,13 +254,10 @@ export class NdiOidcHelper { const verifiedJws = await JweUtil.verifyJwsUsingKeyStore(authorisationInfoJws, keys); - const authorisationInfoTokenPayload = JSON.parse(verifiedJws.payload.toString()); - if (typeof authorisationInfoTokenPayload.AuthInfo === 'string') { - authorisationInfoTokenPayload.AuthInfo = JSON.parse(authorisationInfoTokenPayload.AuthInfo); - } - - if (typeof authorisationInfoTokenPayload.TP_Auth === 'string') { - authorisationInfoTokenPayload.TP_Auth = JSON.parse(authorisationInfoTokenPayload.TP_Auth); + const authorisationInfoTokenPayload = JSON.parse(verifiedJws.payload.toString()) as AuthInfoTokenPayload; + authorisationInfoTokenPayload.AuthInfo = JSON.parse(authorisationInfoTokenPayload.AuthInfo as unknown as string); + if (authorisationInfoTokenPayload.TPAuthInfo) { + authorisationInfoTokenPayload.TPAuthInfo = JSON.parse(authorisationInfoTokenPayload.TPAuthInfo as unknown as string); } return authorisationInfoTokenPayload;