Skip to content

Commit e16b198

Browse files
committed
Add Corppass PAR (Pushed Authorization Request) feature with feature flag
1 parent 716dd1d commit e16b198

File tree

10 files changed

+398
-4
lines changed

10 files changed

+398
-4
lines changed

shared/constants/feature-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,5 @@ export const featureFlags = {
2828
singpassMrf: 'singpass-mrf' as const,
2929
enableSaveDraftButtonFloating: 'enable-save-draft-button-floating' as const,
3030
enableSaveDraftButtonHeader: 'enable-save-draft-button-header' as const,
31+
enableCorppassPar: 'enable-corppass-par' as const,
3132
}

src/app/config/features/spcp-myinfo.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type ISpcpConfig = {
2929
spOidcRpJwksSecretPath: string
3030
cpOidcNdiDiscoveryEndpoint: string
3131
cpOidcNdiJwksEndpoint: string
32+
cpOidcNdiParEndpoint: string
3233
cpOidcRpClientId: string
3334
cpOidcRpRedirectUrl: string
3435
cpOidcRpJwksPublic: string
@@ -215,6 +216,12 @@ const spcpMyInfoSchema: Schema<ISpcpMyInfo> = {
215216
default: null,
216217
env: 'CP_OIDC_NDI_JWKS_ENDPOINT',
217218
},
219+
cpOidcNdiParEndpoint: {
220+
doc: "NDI's Corppass OIDC PAR (Pushed Authorization Request) Endpoint",
221+
format: String,
222+
default: '',
223+
env: 'CP_OIDC_NDI_PAR_ENDPOINT',
224+
},
218225
cpOidcRpClientId: {
219226
doc: "The Relying Party's Corppass Client ID as registered with NDI",
220227
format: String,

src/app/modules/core/core.errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export enum ErrorCodes {
127127
SPCP_OIDC_MISSING_ID_TOKEN = 110207,
128128
SPCP_OIDC_INVALID_VERIFICATION_KEY = 110208,
129129
SPCP_OIDC_EXCHANGE_AUTH_TOKEN = 110209,
130+
SPCP_OIDC_CREATE_PAR_REQUEST = 110210,
130131
// [110300 - 110399] MyInfo Errors (/modules/myinfo)
131132
MYINFO_CIRCUIT_BREAKER = 110300,
132133
MYINFO_FETCH = 110301,

src/app/modules/form/public-form/public-form.controller.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { SgidService } from '../../sgid/sgid.service'
5454
import { validateSgidForm } from '../../sgid/sgid.util'
5555
import { InvalidJwtError, VerifyJwtError } from '../../spcp/spcp.errors'
5656
import { getOidcService } from '../../spcp/spcp.oidc.service'
57+
import { CpOidcServiceClass } from '../../spcp/spcp.oidc.service/spcp.oidc.service.cp'
5758
import {
5859
getRedirectTargetSpcpOidc,
5960
validateSpcpForm,
@@ -701,10 +702,20 @@ export const _handleFormAuthRedirect: ControllerHandler<
701702
isPersistentLogin,
702703
encodedQuery,
703704
)
704-
return getOidcService(FormAuthType.CP).createRedirectUrl(
705-
target,
706-
form.esrvcId,
705+
const cpOidcService = getOidcService(
706+
FormAuthType.CP,
707+
) as CpOidcServiceClass
708+
// Use PAR-based redirect when the feature flag is enabled
709+
const useCorppassPar = req.growthbook?.isOn(
710+
featureFlags.enableCorppassPar,
707711
)
712+
if (useCorppassPar) {
713+
return cpOidcService.createRedirectUrlWithPar(
714+
target,
715+
form.esrvcId,
716+
)
717+
}
718+
return cpOidcService.createRedirectUrl(target, form.esrvcId)
708719
})
709720
}
710721
case FormAuthType.SGID:

src/app/modules/spcp/__tests__/spcp.oidc.client.spec.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SpcpOidcBaseClientCache } from '../spcp.oidc.client.cache'
1313
import {
1414
CreateAuthorisationUrlError,
1515
CreateJwtError,
16+
CreateParRequestError,
1617
ExchangeAuthTokenError,
1718
GetDecryptionKeyError,
1819
GetSigningKeyError,
@@ -32,9 +33,11 @@ import {
3233
import {
3334
CP_OIDC_NDI_DISCOVERY_ENDPOINT,
3435
CP_OIDC_NDI_JWKS_ENDPOINT,
36+
CP_OIDC_NDI_PAR_ENDPOINT,
3537
CP_OIDC_RP_CLIENT_ID,
3638
CP_OIDC_RP_REDIRECT_URL,
3739
cpOidcClientConfig,
40+
cpOidcClientConfigWithPar,
3841
SP_OIDC_NDI_DISCOVERY_ENDPOINT,
3942
SP_OIDC_NDI_JWKS_ENDPOINT,
4043
SP_OIDC_RP_CLIENT_ID,
@@ -2203,6 +2206,215 @@ describe('CpOidcClient', () => {
22032206
})
22042207
})
22052208

2209+
describe('createAuthorisationUrlWithPar', () => {
2210+
it('should throw CreateAuthorisationUrlError if state parameter is empty', async () => {
2211+
// Arrange
2212+
jest
2213+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2214+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2215+
2216+
const MOCK_EMPTY_STATE = ''
2217+
const MOCK_ESRVCID = 'esrvcId'
2218+
2219+
// Act
2220+
2221+
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
2222+
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
2223+
MOCK_EMPTY_STATE,
2224+
MOCK_ESRVCID,
2225+
)
2226+
2227+
// Assert
2228+
await expect(tryCreateUrl).rejects.toThrow(CreateAuthorisationUrlError)
2229+
await expect(tryCreateUrl).rejects.toThrow('Empty state')
2230+
})
2231+
2232+
it('should throw CreateAuthorisationUrlError if esrvcId parameter is empty', async () => {
2233+
// Arrange
2234+
jest
2235+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2236+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2237+
2238+
const MOCK_STATE = 'state'
2239+
const MOCK_EMPTY_ESRVCID = ''
2240+
2241+
// Act
2242+
2243+
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
2244+
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
2245+
MOCK_STATE,
2246+
MOCK_EMPTY_ESRVCID,
2247+
)
2248+
2249+
// Assert
2250+
await expect(tryCreateUrl).rejects.toThrow(CreateAuthorisationUrlError)
2251+
await expect(tryCreateUrl).rejects.toThrow('Empty esrvcId')
2252+
})
2253+
2254+
it('should throw CreateParRequestError if PAR endpoint is not configured', async () => {
2255+
// Arrange
2256+
jest
2257+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2258+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2259+
2260+
const MOCK_STATE = 'state'
2261+
const MOCK_ESRVCID = 'esrvcId'
2262+
2263+
// Act
2264+
// Use the config without PAR endpoint
2265+
const cpOidcClient = new CpOidcClient(cpOidcClientConfig)
2266+
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
2267+
MOCK_STATE,
2268+
MOCK_ESRVCID,
2269+
)
2270+
2271+
// Assert
2272+
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
2273+
await expect(tryCreateUrl).rejects.toThrow('PAR endpoint not configured')
2274+
})
2275+
2276+
it('should correctly POST to PAR endpoint and return authorisation URL with request_uri', async () => {
2277+
// Arrange
2278+
jest
2279+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2280+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2281+
2282+
const MOCK_STATE = 'state'
2283+
const MOCK_ESRVCID = 'esrvcId'
2284+
const MOCK_REQUEST_URI = 'urn:ietf:params:oauth:request_uri:12345'
2285+
const MOCK_AUTH_ENDPOINT = 'https://corppass.example.com/authorize'
2286+
2287+
jest
2288+
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
2289+
.mockResolvedValueOnce({
2290+
issuer: {
2291+
metadata: {
2292+
authorization_endpoint: MOCK_AUTH_ENDPOINT,
2293+
issuer: 'https://corppass.example.com',
2294+
},
2295+
},
2296+
} as unknown as BaseClient)
2297+
2298+
jest
2299+
.spyOn(CpOidcClient.prototype, 'createJWT')
2300+
.mockResolvedValueOnce('mockJwt')
2301+
2302+
const axiosSpy = jest.spyOn(axios, 'post').mockResolvedValueOnce({
2303+
data: {
2304+
request_uri: MOCK_REQUEST_URI,
2305+
expires_in: 90,
2306+
},
2307+
})
2308+
2309+
// Act
2310+
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
2311+
const result = await cpOidcClient.createAuthorisationUrlWithPar(
2312+
MOCK_STATE,
2313+
MOCK_ESRVCID,
2314+
)
2315+
2316+
// Assert
2317+
expect(axiosSpy).toHaveBeenCalledOnce()
2318+
expect(axiosSpy).toHaveBeenCalledWith(
2319+
CP_OIDC_NDI_PAR_ENDPOINT,
2320+
expect.stringContaining('esrvcID'),
2321+
expect.objectContaining({
2322+
headers: {
2323+
'Content-Type': 'application/x-www-form-urlencoded',
2324+
},
2325+
}),
2326+
)
2327+
expect(result).toContain(MOCK_AUTH_ENDPOINT)
2328+
expect(result).toContain('request_uri=')
2329+
expect(result).toContain(encodeURIComponent(MOCK_REQUEST_URI))
2330+
})
2331+
2332+
it('should throw CreateParRequestError if PAR response is missing request_uri', async () => {
2333+
// Arrange
2334+
jest
2335+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2336+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2337+
2338+
const MOCK_STATE = 'state'
2339+
const MOCK_ESRVCID = 'esrvcId'
2340+
2341+
jest
2342+
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
2343+
.mockResolvedValueOnce({
2344+
issuer: {
2345+
metadata: {
2346+
authorization_endpoint: 'https://corppass.example.com/authorize',
2347+
issuer: 'https://corppass.example.com',
2348+
},
2349+
},
2350+
} as unknown as BaseClient)
2351+
2352+
jest
2353+
.spyOn(CpOidcClient.prototype, 'createJWT')
2354+
.mockResolvedValueOnce('mockJwt')
2355+
2356+
jest.spyOn(axios, 'post').mockResolvedValueOnce({
2357+
data: {
2358+
expires_in: 90,
2359+
// Missing request_uri
2360+
},
2361+
})
2362+
2363+
// Act
2364+
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
2365+
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
2366+
MOCK_STATE,
2367+
MOCK_ESRVCID,
2368+
)
2369+
2370+
// Assert
2371+
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
2372+
await expect(tryCreateUrl).rejects.toThrow(
2373+
'PAR response missing request_uri',
2374+
)
2375+
})
2376+
2377+
it('should throw CreateParRequestError if PAR request fails', async () => {
2378+
// Arrange
2379+
jest
2380+
.spyOn(SpcpOidcBaseClientCache.prototype, 'refresh')
2381+
.mockResolvedValueOnce('ok' as unknown as Refresh)
2382+
2383+
const MOCK_STATE = 'state'
2384+
const MOCK_ESRVCID = 'esrvcId'
2385+
2386+
jest
2387+
.spyOn(CpOidcClient.prototype, 'getBaseClientFromCache')
2388+
.mockResolvedValueOnce({
2389+
issuer: {
2390+
metadata: {
2391+
authorization_endpoint: 'https://corppass.example.com/authorize',
2392+
issuer: 'https://corppass.example.com',
2393+
},
2394+
},
2395+
} as unknown as BaseClient)
2396+
2397+
jest
2398+
.spyOn(CpOidcClient.prototype, 'createJWT')
2399+
.mockResolvedValueOnce('mockJwt')
2400+
2401+
jest
2402+
.spyOn(axios, 'post')
2403+
.mockRejectedValueOnce(new Error('Network error'))
2404+
2405+
// Act
2406+
const cpOidcClient = new CpOidcClient(cpOidcClientConfigWithPar)
2407+
const tryCreateUrl = cpOidcClient.createAuthorisationUrlWithPar(
2408+
MOCK_STATE,
2409+
MOCK_ESRVCID,
2410+
)
2411+
2412+
// Assert
2413+
await expect(tryCreateUrl).rejects.toThrow(CreateParRequestError)
2414+
await expect(tryCreateUrl).rejects.toThrow('PAR request failed')
2415+
})
2416+
})
2417+
22062418
describe('getDecryptionKey', () => {
22072419
it('should return GetDecryptionKeyError if jwe is empty', () => {
22082420
// Arrange

src/app/modules/spcp/__tests__/spcp.test.constants.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const SP_OIDC_RP_REDIRECT_URL = 'spOidcRpRedirectUrl'
148148

149149
export const CP_OIDC_NDI_DISCOVERY_ENDPOINT = 'cpOidcNdiDiscoveryEndpoint'
150150
export const CP_OIDC_NDI_JWKS_ENDPOINT = 'cpOidcNdiJwksEndpoint'
151+
export const CP_OIDC_NDI_PAR_ENDPOINT = 'https://corppass.example.com/par'
151152
export const CP_OIDC_RP_CLIENT_ID = 'cpOidcRpClientId'
152153
export const CP_OIDC_RP_REDIRECT_URL = 'cpOidcRpRedirectUrl'
153154

@@ -198,3 +199,15 @@ export const cpOidcClientConfig: SpcpOidcClientConstructorParams = {
198199
rpSecretJwks: TEST_CP_RP_SECRET_JWKS,
199200
rpPublicJwks: TEST_CP_RP_PUBLIC_JWKS,
200201
}
202+
203+
export const cpOidcClientConfigWithPar: SpcpOidcClientConstructorParams & {
204+
parEndpoint: string
205+
} = {
206+
ndiDiscoveryEndpoint: CP_OIDC_NDI_DISCOVERY_ENDPOINT,
207+
ndiJwksEndpoint: CP_OIDC_NDI_JWKS_ENDPOINT,
208+
rpClientId: CP_OIDC_RP_CLIENT_ID,
209+
rpRedirectUrl: CP_OIDC_RP_REDIRECT_URL,
210+
rpSecretJwks: TEST_CP_RP_SECRET_JWKS,
211+
rpPublicJwks: TEST_CP_RP_PUBLIC_JWKS,
212+
parEndpoint: CP_OIDC_NDI_PAR_ENDPOINT,
213+
}

src/app/modules/spcp/spcp.oidc.client.errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ export class CreateAuthorisationUrlError extends ApplicationError {
99
}
1010
}
1111

12+
/**
13+
* Error while creating Pushed Authorisation Request
14+
*/
15+
export class CreateParRequestError extends ApplicationError {
16+
constructor(message = 'Error while creating Pushed Authorisation Request') {
17+
super(message, undefined, ErrorCodes.SPCP_OIDC_CREATE_PAR_REQUEST)
18+
}
19+
}
20+
1221
/**
1322
* Failed to create JWT
1423
*/

0 commit comments

Comments
 (0)