Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/good-numbers-act.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 21 additions & 24 deletions interface_mapping.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 |
46 changes: 46 additions & 0 deletions packages/journey-client/src/lib/client.store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
23 changes: 21 additions & 2 deletions packages/journey-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,17 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
resume: async (url: string, options?: ResumeOptions): Promise<JourneyResult> => {
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;

Expand Down Expand Up @@ -247,12 +255,23 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
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) {
Expand Down
Loading