Skip to content

Commit

Permalink
Merge pull request #76 from qHedge/master
Browse files Browse the repository at this point in the history
[FILEZCAD-2016] Implement get authorisation info
  • Loading branch information
Gyunikuchan authored Jan 25, 2024
2 parents 5cdcd7b + 886d4b7 commit 09fe937
Show file tree
Hide file tree
Showing 7 changed files with 331 additions and 23 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ Corppass.NdiOidcHelper
- `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
- `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
- `getAuthorisationInfoTokenPayload(tokens: TokenResponse) => Promise<AuthInfoTokenPayload>` - get authorisation info JWT from authorisation info endpoint. Outputs AuthInfoTokenPayload, which contain roles and params information of user
- `extractActiveAuthResultFromAuthInfoToken(authInfoToken: AuthInfoTokenPayload): Record<string, EserviceAuthResultRow[]>` - get the currently active authorisation information of the user from the authorisation information token payload

---

Expand Down
167 changes: 167 additions & 0 deletions src/corppass/__tests__/corppass-helper-ndi.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: []
});

});
});
});
74 changes: 73 additions & 1 deletion src/corppass/corppass-helper-ndi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { EntityInfo, TokenResponse, 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;
Expand All @@ -30,6 +31,15 @@ export interface NDIIdTokenPayload {
userInfo: UserInfo;
}

export interface AuthInfoTokenPayload {
aud: string;
iat: number;
iss: string;
exp: number;
AuthInfo: AuthInfo;
TPAuthInfo?: TPAccessInfo;
}

export interface NdiOidcHelperConstructor {
oidcConfigUrl: string;
clientID: string;
Expand All @@ -55,6 +65,7 @@ interface OidcConfig {
authorization_endpoint: string;
token_endpoint: string;
jwks_uri: string;
'authorization-info_endpoint': string;
}

export class NdiOidcHelper {
Expand Down Expand Up @@ -219,6 +230,67 @@ export class NdiOidcHelper {
throw Error("Token payload sub property is not defined");
}

/**
* Get authorisation information from Corppass Endpoint
*/
public async getAuthorisationInfoTokenPayload(tokens: TokenResponse): Promise<AuthInfoTokenPayload> {
try {
const {
data: { 'authorization-info_endpoint': authorisationInfoEndpoint, jwks_uri, issuer },
} = await this.axiosClient.get<OidcConfig>(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);

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;
} catch (e) {
logger.error("Failed to get authorisation info", e);
throw e;
}
}

public extractActiveAuthResultFromAuthInfoToken(authInfoTokenPayload: AuthInfoTokenPayload): Record<string, EserviceAuthResultRow[]> {
const { AuthInfo: { Result_Set: authInfoResultSet } } = authInfoTokenPayload;
const { ESrvc_Row_Count: resultCount, ESrvc_Result: results } = authInfoResultSet;

if (!resultCount) {
return {};
}
const filteredResult = {} as Record<string, EserviceAuthResultRow[]>;

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);
}
Expand Down
46 changes: 25 additions & 21 deletions src/corppass/shared-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,29 +16,33 @@ export interface EntityInfo {
CPNonUEN_Name: string;
}

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;
}[];
}[];
};
}[];
};
export interface AuthInfo {
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[];
};
}[];
}

interface TPAccessInfo {
export interface TPAccessInfo {
Result_Set: {
ESrvc_Row_Count: number,
ESrvc_Result: {
Expand Down
15 changes: 14 additions & 1 deletion src/util/DateUtils.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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);
}
}
7 changes: 7 additions & 0 deletions src/util/JweUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading

0 comments on commit 09fe937

Please sign in to comment.