diff --git a/.changeset/good-numbers-act.md b/.changeset/good-numbers-act.md new file mode 100644 index 0000000000..da96730781 --- /dev/null +++ b/.changeset/good-numbers-act.md @@ -0,0 +1,7 @@ +--- +'@forgerock/journey-client': patch +--- + +Restore legacy resume() redirect query-param handling. + +resume() now parses and forwards additional URL params (error, errorCode, errorMessage, nonce, RelayState, scope, suspendedId) and uses authIndexValue as a fallback journey value. diff --git a/interface_mapping.md b/interface_mapping.md index a683abb66b..ea6d87135c 100644 --- a/interface_mapping.md +++ b/interface_mapping.md @@ -1,7 +1,7 @@ --- document: interface_mapping version: '1.0' -last_updated: '2026-04-06' +last_updated: '2026-04-24' legacy_sdk: '@forgerock/javascript-sdk' legacy_source: '.opensource/forgerock-javascript-sdk/packages/javascript-sdk/src' new_packages: @@ -312,21 +312,19 @@ const oidcClient = await oidc({ config }); ### resume() URL Parameter Parsing -The legacy `FRAuth.resume()` automatically parses 10+ URL parameters from the redirect URL and conditionally adjusts behavior. The new `journeyClient.resume()` handles a subset of these: +The legacy `FRAuth.resume()` automatically parses 10+ URL parameters from the redirect URL and conditionally adjusts behavior. The new `journeyClient.resume()` continues to parse these URL parameters and forwards them through as `options.query` values. -| URL Parameter | Legacy Behavior | New Behavior | -| ------------------------------------ | -------------------------------------------- | ------------------------------------------------------ | -| `code` | Extracted, passed as query param to `next()` | Same — extracted and passed through | -| `state` | Extracted, passed as query param | Same | -| `form_post_entry` | Extracted, triggers previous step retrieval | Same | -| `responsekey` | Extracted, triggers previous step retrieval | Same | -| `error`, `errorCode`, `errorMessage` | Extracted, passed as query params | **Not parsed** — check return value for `GenericError` | -| `suspendedId` | Extracted; skips previous step retrieval | **Not parsed** — handle suspended flows manually | -| `RelayState` | Extracted for SAML flows | **Not parsed** | -| `nonce`, `scope` | Extracted, passed as query params | **Not parsed** | -| `authIndexValue` | Used as fallback tree name | **Not parsed** — pass tree via `options.journey` | - -> **Migration note:** If your app relies on `suspendedId`, `RelayState`, or `authIndexValue` URL parameters being auto-parsed, you must extract them manually from the URL and pass them via `options.query` in the new SDK. +| URL Parameter | Legacy Behavior | New Behavior | +| ------------------------------------ | -------------------------------------------- | ----------------------------------- | +| `code` | Extracted, passed as query param to `next()` | Same — extracted and passed through | +| `state` | Extracted, passed as query param | Same | +| `form_post_entry` | Extracted, triggers previous step retrieval | Same | +| `responsekey` | Extracted, triggers previous step retrieval | Same | +| `error`, `errorCode`, `errorMessage` | Extracted, passed as query params | Same | +| `suspendedId` | Extracted, passed as query params | Same | +| `RelayState` | Extracted for SAML flows | Same | +| `nonce`, `scope` | Extracted, passed as query params | Same | +| `authIndexValue` | Extracted, used as fallback journey name | Same | ### Before/After: Start and Next @@ -1133,12 +1131,11 @@ The legacy `@forgerock/token-vault` package provided advanced token security via ### Key Behavioral Removals -| Legacy Behavior | New Approach | -| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Global config via `Config.set()` | Each client manages its own config independently | -| Automatic PKCE challenge generation in `OAuth2Client` | `@forgerock/oidc-client` handles PKCE internally | -| `HttpClient` auto-injecting bearer tokens and refreshing on 401 | Manually get tokens, add `Authorization` header, handle 401 yourself | -| Token stored in `localStorage` by default | OIDC client uses `localStorage` by default; journey client step storage uses `sessionStorage` | -| Per-call config overrides via `StepOptions` | **Major change:** Config is fixed at client creation time. Legacy apps that passed different `tree`, `serverConfig`, or `middleware` per-call must create separate client instances. Only `query` params can vary per-call | -| `FRUser.logout()` silently swallows errors per-step | `oidcClient.user.logout()` returns structured `LogoutErrorResult` with per-operation error details | -| `FRAuth.resume()` auto-parses 10+ URL params (suspendedId, RelayState, etc.) | `journeyClient.resume()` only parses `code`, `state`, `form_post_entry`, `responsekey`. Other params must be extracted manually | +| Legacy Behavior | New Approach | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Global config via `Config.set()` | Each client manages its own config independently | +| Automatic PKCE challenge generation in `OAuth2Client` | `@forgerock/oidc-client` handles PKCE internally | +| `HttpClient` auto-injecting bearer tokens and refreshing on 401 | Manually get tokens, add `Authorization` header, handle 401 yourself | +| Token stored in `localStorage` by default | OIDC client uses `localStorage` by default; journey client step storage uses `sessionStorage` | +| Per-call config overrides via `StepOptions` | **Major change:** Config is fixed at client creation time. Legacy apps that passed different `tree`, `serverConfig`, or `middleware` per-call must create separate client instances. Only `query` params can vary per-call | +| `FRUser.logout()` silently swallows errors per-step | `oidcClient.user.logout()` returns structured `LogoutErrorResult` with per-operation error details | diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index f57a1c0f7c..69b12553fb 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -249,6 +249,52 @@ describe('journey-client', () => { } }); + test('resume_WithPreviousStepInStorage_ForwardsLegacyUrlParams', async () => { + const previousStepPayload: Step = { + callbacks: [{ type: callbackType.RedirectCallback, input: [], output: [] }], + }; + mockStorageInstance.get.mockResolvedValue({ step: previousStepPayload }); + const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] }; + setupMockFetch(nextStepPayload); + + const client = await journey({ config: mockConfig }); + const resumeUrl = + 'https://app.com/callback?code=123&state=abc&error=access_denied&errorCode=E1&errorMessage=oops&form_post_entry=fp&nonce=n1&RelayState=rs&responsekey=rk&scope=openid&suspendedId=s1'; + await client.resume(resumeUrl, {}); + + const request = mockFetch.mock.calls[1][0] as Request; + const url = new URL(request.url); + expect(url.searchParams.get('code')).toBe('123'); + expect(url.searchParams.get('state')).toBe('abc'); + expect(url.searchParams.get('error')).toBe('access_denied'); + expect(url.searchParams.get('errorCode')).toBe('E1'); + expect(url.searchParams.get('errorMessage')).toBe('oops'); + expect(url.searchParams.get('form_post_entry')).toBe('fp'); + expect(url.searchParams.get('nonce')).toBe('n1'); + expect(url.searchParams.get('RelayState')).toBe('rs'); + expect(url.searchParams.get('responsekey')).toBe('rk'); + expect(url.searchParams.get('scope')).toBe('openid'); + expect(url.searchParams.get('suspendedId')).toBe('s1'); + }); + + test('resume_WithPreviousStepInStorage_AllowsOptionsQueryToOverrideUrlParams', async () => { + const previousStepPayload: Step = { + callbacks: [{ type: callbackType.RedirectCallback, input: [], output: [] }], + }; + mockStorageInstance.get.mockResolvedValue({ step: previousStepPayload }); + const nextStepPayload: Step = { authId: 'test-auth-id', callbacks: [] }; + setupMockFetch(nextStepPayload); + + const client = await journey({ config: mockConfig }); + const resumeUrl = 'https://app.com/callback?code=123&state=abc'; + await client.resume(resumeUrl, { query: { code: 'override' } }); + + const request = mockFetch.mock.calls[1][0] as Request; + const url = new URL(request.url); + expect(url.searchParams.get('code')).toBe('override'); + expect(url.searchParams.get('state')).toBe('abc'); + }); + test('resume_WithPlainStepObjectInStorage_CorrectlyResumes', async () => { const plainStepPayload: Step = { callbacks: [ diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 7cdb2a97e1..129386a864 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -206,9 +206,17 @@ export async function journey({ resume: async (url: string, options?: ResumeOptions): Promise => { const parsedUrl = new URL(url); const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + const errorCode = parsedUrl.searchParams.get('errorCode'); + const errorMessage = parsedUrl.searchParams.get('errorMessage'); const state = parsedUrl.searchParams.get('state'); const form_post_entry = parsedUrl.searchParams.get('form_post_entry'); + const nonce = parsedUrl.searchParams.get('nonce'); + const RelayState = parsedUrl.searchParams.get('RelayState'); const responsekey = parsedUrl.searchParams.get('responsekey'); + const scope = parsedUrl.searchParams.get('scope'); + const suspendedId = parsedUrl.searchParams.get('suspendedId'); + const authIndexValue = parsedUrl.searchParams.get('authIndexValue') ?? undefined; let previousStep: JourneyStep | undefined; @@ -247,12 +255,23 @@ export async function journey({ const resumeOptions = { ...options, query: { - ...(options && options.query), ...(code && { code }), - ...(state && { state }), + ...(error && { error }), + ...(errorCode && { errorCode }), + ...(errorMessage && { errorMessage }), ...(form_post_entry && { form_post_entry }), + ...(nonce && { nonce }), + ...(RelayState && { RelayState }), ...(responsekey && { responsekey }), + ...(scope && { scope }), + ...(state && { state }), + ...(suspendedId && { suspendedId }), + + ...(options && options.query), }, + ...((options?.journey ?? authIndexValue) && { + journey: options?.journey ?? authIndexValue, + }), }; if (previousStep) {