Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions shared/constants/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ export const featureFlags = {
singpassMrf: 'singpass-mrf' as const,
enableSaveDraftButtonFloating: 'enable-save-draft-button-floating' as const,
enableSaveDraftButtonHeader: 'enable-save-draft-button-header' as const,
enableCorppassPar: 'enable-corppass-par' as const,
}
7 changes: 7 additions & 0 deletions src/app/config/features/spcp-myinfo.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type ISpcpConfig = {
spOidcRpJwksSecretPath: string
cpOidcNdiDiscoveryEndpoint: string
cpOidcNdiJwksEndpoint: string
cpOidcNdiParEndpoint: string
cpOidcRpClientId: string
cpOidcRpRedirectUrl: string
cpOidcRpJwksPublic: string
Expand Down Expand Up @@ -215,6 +216,12 @@ const spcpMyInfoSchema: Schema<ISpcpMyInfo> = {
default: null,
env: 'CP_OIDC_NDI_JWKS_ENDPOINT',
},
cpOidcNdiParEndpoint: {
doc: "NDI's Corppass OIDC PAR (Pushed Authorization Request) Endpoint",
format: String,
default: '',
env: 'CP_OIDC_NDI_PAR_ENDPOINT',
},
cpOidcRpClientId: {
doc: "The Relying Party's Corppass Client ID as registered with NDI",
format: String,
Expand Down
1 change: 1 addition & 0 deletions src/app/modules/core/core.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export enum ErrorCodes {
SPCP_OIDC_MISSING_ID_TOKEN = 110207,
SPCP_OIDC_INVALID_VERIFICATION_KEY = 110208,
SPCP_OIDC_EXCHANGE_AUTH_TOKEN = 110209,
SPCP_OIDC_CREATE_PAR_REQUEST = 110210,
// [110300 - 110399] MyInfo Errors (/modules/myinfo)
MYINFO_CIRCUIT_BREAKER = 110300,
MYINFO_FETCH = 110301,
Expand Down
17 changes: 14 additions & 3 deletions src/app/modules/form/public-form/public-form.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import { SgidService } from '../../sgid/sgid.service'
import { validateSgidForm } from '../../sgid/sgid.util'
import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors'
import { getOidcService } from '../../spcp/spcp.oidc.service'
import { CpOidcServiceClass } from '../../spcp/spcp.oidc.service/spcp.oidc.service.cp'
import {
getRedirectTargetSpcpOidc,
validateSpcpForm,
Expand Down Expand Up @@ -701,10 +702,20 @@ export const _handleFormAuthRedirect: ControllerHandler<
isPersistentLogin,
encodedQuery,
)
return getOidcService(FormAuthType.CP).createRedirectUrl(
target,
form.esrvcId,
const cpOidcService = getOidcService(
FormAuthType.CP,
) as CpOidcServiceClass
// Use PAR-based redirect when the feature flag is enabled
const useCorppassPar = req.growthbook?.isOn(
featureFlags.enableCorppassPar,
)
if (useCorppassPar) {
return cpOidcService.createRedirectUrlWithPar(
target,
form.esrvcId,
)
}
return cpOidcService.createRedirectUrl(target, form.esrvcId)
})
}
case FormAuthType.SGID:
Expand Down
212 changes: 212 additions & 0 deletions src/app/modules/spcp/__tests__/spcp.oidc.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { SpcpOidcBaseClientCache } from '../spcp.oidc.client.cache'
import {
CreateAuthorisationUrlError,
CreateJwtError,
CreateParRequestError,
ExchangeAuthTokenError,
GetDecryptionKeyError,
GetSigningKeyError,
Expand All @@ -32,9 +33,11 @@ import {
import {
CP_OIDC_NDI_DISCOVERY_ENDPOINT,
CP_OIDC_NDI_JWKS_ENDPOINT,
CP_OIDC_NDI_PAR_ENDPOINT,
CP_OIDC_RP_CLIENT_ID,
CP_OIDC_RP_REDIRECT_URL,
cpOidcClientConfig,
cpOidcClientConfigWithPar,
SP_OIDC_NDI_DISCOVERY_ENDPOINT,
SP_OIDC_NDI_JWKS_ENDPOINT,
SP_OIDC_RP_CLIENT_ID,
Expand Down Expand Up @@ -2203,6 +2206,215 @@ describe('CpOidcClient', () => {
})
})

describe('createAuthorisationUrlWithPar', () => {
it('should throw CreateAuthorisationUrlError if state parameter is empty', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_EMPTY_STATE = ''
const MOCK_ESRVCID = 'esrvcId'

// Act

const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
MOCK_EMPTY_STATE,
MOCK_ESRVCID,
)

// Assert
await expect(tryCreateUrl).rejects.toThrow(CreateAuthorisationUrlError)
await expect(tryCreateUrl).rejects.toThrow('Empty state')
})

it('should throw CreateAuthorisationUrlError if esrvcId parameter is empty', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_STATE = 'state'
const MOCK_EMPTY_ESRVCID = ''

// Act

const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
MOCK_STATE,
MOCK_EMPTY_ESRVCID,
)

// Assert
await expect(tryCreateUrl).rejects.toThrow(CreateAuthorisationUrlError)
await expect(tryCreateUrl).rejects.toThrow('Empty esrvcId')
})

it('should throw CreateParRequestError if PAR endpoint is not configured', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_STATE = 'state'
const MOCK_ESRVCID = 'esrvcId'

// Act
// Use the config without PAR endpoint
const cpOidcClient = new CpOidcClient(cpOidcClientConfig)
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
MOCK_STATE,
MOCK_ESRVCID,
)

// Assert
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
await expect(tryCreateUrl).rejects.toThrow('PAR endpoint not configured')
})

it('should correctly POST to PAR endpoint and return authorisation URL with request_uri', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_STATE = 'state'
const MOCK_ESRVCID = 'esrvcId'
const MOCK_REQUEST_URI = 'urn:ietf:params:oauth:request_uri:12345'
const MOCK_AUTH_ENDPOINT = 'https://corppass.example.com/authorize'

jest
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
.mockResolvedValueOnce({
issuer: {
metadata: {
authorization_endpoint: MOCK_AUTH_ENDPOINT,
issuer: 'https://corppass.example.com',
},
},
} as unknown as BaseClient)

jest
.spyOn(CpOidcClient.prototype, 'createJWT')
.mockResolvedValueOnce('mockJwt')

const axiosSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
request_uri: MOCK_REQUEST_URI,
expires_in: 90,
},
})

// Act
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
const result = await cpOidcClient.createAuthorisationUrlWithPar(
MOCK_STATE,
MOCK_ESRVCID,
)

// Assert
expect(axiosSpy).toHaveBeenCalledOnce()
expect(axiosSpy).toHaveBeenCalledWith(
CP_OIDC_NDI_PAR_ENDPOINT,
expect.stringContaining('esrvcID'),
expect.objectContaining({
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}),
)
expect(result).toContain(MOCK_AUTH_ENDPOINT)
expect(result).toContain('request_uri=')
expect(result).toContain(encodeURIComponent(MOCK_REQUEST_URI))
})

it('should throw CreateParRequestError if PAR response is missing request_uri', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_STATE = 'state'
const MOCK_ESRVCID = 'esrvcId'

jest
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
.mockResolvedValueOnce({
issuer: {
metadata: {
authorization_endpoint: 'https://corppass.example.com/authorize',
issuer: 'https://corppass.example.com',
},
},
} as unknown as BaseClient)

jest
.spyOn(CpOidcClient.prototype, 'createJWT')
.mockResolvedValueOnce('mockJwt')

jest.spyOn(axios, 'post').mockResolvedValueOnce({
data: {
expires_in: 90,
// Missing request_uri
},
})

// Act
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
MOCK_STATE,
MOCK_ESRVCID,
)

// Assert
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
await expect(tryCreateUrl).rejects.toThrow(
'PAR response missing request_uri',
)
})

it('should throw CreateParRequestError if PAR request fails', async () => {
// Arrange
jest
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
.mockResolvedValueOnce('ok' as unknown as Refresh)

const MOCK_STATE = 'state'
const MOCK_ESRVCID = 'esrvcId'

jest
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
.mockResolvedValueOnce({
issuer: {
metadata: {
authorization_endpoint: 'https://corppass.example.com/authorize',
issuer: 'https://corppass.example.com',
},
},
} as unknown as BaseClient)

jest
.spyOn(CpOidcClient.prototype, 'createJWT')
.mockResolvedValueOnce('mockJwt')

jest
.spyOn(axios, 'post')
.mockRejectedValueOnce(new Error('Network error'))

// Act
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
MOCK_STATE,
MOCK_ESRVCID,
)

// Assert
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
await expect(tryCreateUrl).rejects.toThrow('PAR request failed')
})
})

describe('getDecryptionKey', () => {
it('should return GetDecryptionKeyError if jwe is empty', () => {
// Arrange
Expand Down
13 changes: 13 additions & 0 deletions src/app/modules/spcp/__tests__/spcp.test.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export const SP_OIDC_RP_REDIRECT_URL = 'spOidcRpRedirectUrl'

export const CP_OIDC_NDI_DISCOVERY_ENDPOINT = 'cpOidcNdiDiscoveryEndpoint'
export const CP_OIDC_NDI_JWKS_ENDPOINT = 'cpOidcNdiJwksEndpoint'
export const CP_OIDC_NDI_PAR_ENDPOINT = 'https://corppass.example.com/par'
export const CP_OIDC_RP_CLIENT_ID = 'cpOidcRpClientId'
export const CP_OIDC_RP_REDIRECT_URL = 'cpOidcRpRedirectUrl'

Expand Down Expand Up @@ -198,3 +199,15 @@ export const cpOidcClientConfig: SpcpOidcClientConstructorParams = {
rpSecretJwks: TEST_CP_RP_SECRET_JWKS,
rpPublicJwks: TEST_CP_RP_PUBLIC_JWKS,
}

export const cpOidcClientConfigWithPar: SpcpOidcClientConstructorParams & {
parEndpoint: string
} = {
ndiDiscoveryEndpoint: CP_OIDC_NDI_DISCOVERY_ENDPOINT,
ndiJwksEndpoint: CP_OIDC_NDI_JWKS_ENDPOINT,
rpClientId: CP_OIDC_RP_CLIENT_ID,
rpRedirectUrl: CP_OIDC_RP_REDIRECT_URL,
rpSecretJwks: TEST_CP_RP_SECRET_JWKS,
rpPublicJwks: TEST_CP_RP_PUBLIC_JWKS,
parEndpoint: CP_OIDC_NDI_PAR_ENDPOINT,
}
9 changes: 9 additions & 0 deletions src/app/modules/spcp/spcp.oidc.client.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export class CreateAuthorisationUrlError extends ApplicationError {
}
}

/**
* Error while creating Pushed Authorisation Request
*/
export class CreateParRequestError extends ApplicationError {
constructor(message = 'Error while creating Pushed Authorisation Request') {
super(message, undefined, ErrorCodes.SPCP_OIDC_CREATE_PAR_REQUEST)
}
}

/**
* Failed to create JWT
*/
Expand Down
Loading