Skip to content

Commit 3088dc6

Browse files
committed
feat: SSR compatibility for journey-client and oidc-client (PoC)
Make journey-client and oidc-client importable and usable in Node.js/SSR environments by eliminating eager browser global references and decoupling PKCE generation from sessionStorage. Storage: Replace eager sessionStorage/localStorage references with lazy globalThis access via getBrowserStorage(). Add configurable storage option to JourneyClientConfig so SSR callers can provide a custom noop adapter. PKCE: Decouple generation from storage — createAuthorizeUrl now returns { url, verifier, state } instead of writing to sessionStorage. Callers persist PKCE values however they choose (cookies, server session, etc.). Token exchange accepts optional pkceValues parameter to skip sessionStorage. Guard redirect() with typeof window check for server environments. Export createJourneyObject for client-side step reconstitution. SvelteKit PoC in e2e/svelte-app demonstrates the full flow: server-side journey start, client-side credential submission, server-side PKCE authorize URL generation with cookie-based verifier persistence, and server-side token exchange against the AM mock API.
1 parent 916e21c commit 3088dc6

28 files changed

Lines changed: 1011 additions & 88 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ test-output
8282
.cursor/rules/nx-rules.mdc
8383
.github/instructions/nx.instructions.md
8484

85+
# SvelteKit
86+
.svelte-kit
87+
8588
# Gemini local knowledge base files
8689
GEMINI.md
8790
**/GEMINI.md

e2e/oidc-app/src/utils/oidc-app.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,14 @@ export async function oidcApp({ config, urlParams }) {
8888
});
8989

9090
document.getElementById('login-redirect').addEventListener('click', async () => {
91-
const authorizeUrl = await oidcClient.authorize.url();
92-
if (typeof authorizeUrl !== 'string' && 'error' in authorizeUrl) {
93-
console.error('Authorization URL Error:', authorizeUrl);
94-
displayError(authorizeUrl);
91+
const authorizeResult = await oidcClient.authorize.url();
92+
if ('error' in authorizeResult) {
93+
console.error('Authorization URL Error:', authorizeResult);
94+
displayError(authorizeResult);
9595
return;
9696
} else {
97-
console.log('Authorization URL:', authorizeUrl);
98-
window.location.assign(authorizeUrl);
97+
console.log('Authorization URL:', authorizeResult.url);
98+
window.location.assign(authorizeResult.url);
9999
}
100100
});
101101

e2e/svelte-app/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@forgerock/svelte-app",
3+
"version": "1.0.0",
4+
"private": true,
5+
"description": "SvelteKit SSR proof of concept for Journey Client",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite dev",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"@forgerock/journey-client": "workspace:*",
14+
"@forgerock/oidc-client": "workspace:*"
15+
},
16+
"devDependencies": {
17+
"@sveltejs/adapter-auto": "^6.0.0",
18+
"@sveltejs/kit": "^2.21.0",
19+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
20+
"svelte": "^5.0.0",
21+
"vite": "catalog:vite"
22+
},
23+
"nx": {
24+
"tags": ["scope:e2e"]
25+
}
26+
}

e2e/svelte-app/src/app.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Journey Client SSR PoC</title>
7+
%sveltekit.head%
8+
</head>
9+
<body>
10+
<div id="app">%sveltekit.body%</div>
11+
</body>
12+
</html>

e2e/svelte-app/src/lib/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Server configuration for the SSR proof of concept.
3+
* Points at the AM mock API running on localhost:9443.
4+
*/
5+
export const WELLKNOWN_URL =
6+
'http://localhost:9443/am/oauth2/realms/root/.well-known/openid-configuration';
7+
8+
export const CLIENT_ID = 'SvelteSSRClient';
9+
export const REDIRECT_URI = 'http://localhost:5174/callback';
10+
export const SCOPE = 'openid profile';
11+
12+
/** No-op storage adapter for server-side usage where browser storage is unavailable. */
13+
export const noopStorage = {
14+
get: async () => null,
15+
set: async () => {},
16+
remove: async () => {},
17+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script>
2+
let { children } = $props();
3+
</script>
4+
5+
<main>
6+
{@render children()}
7+
</main>
8+
9+
<style>
10+
main {
11+
max-width: 480px;
12+
margin: 2rem auto;
13+
font-family: system-ui, sans-serif;
14+
}
15+
</style>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { redirect } from '@sveltejs/kit';
2+
import { journey } from '@forgerock/journey-client';
3+
import { oidc } from '@forgerock/oidc-client';
4+
import { WELLKNOWN_URL, CLIENT_ID, REDIRECT_URI, SCOPE, noopStorage } from '$lib/config.js';
5+
import type { PageServerLoad, Actions } from './$types';
6+
7+
/**
8+
* Server-side load function.
9+
*
10+
* Initializes the journey client with noop storage (no sessionStorage on server)
11+
* and calls start() to fetch the first authentication step. The raw step payload
12+
* is serialized and passed to the client for SSR rendering.
13+
*/
14+
export const load: PageServerLoad = async () => {
15+
try {
16+
const client = await journey({
17+
config: {
18+
serverConfig: { wellknown: WELLKNOWN_URL },
19+
storage: { type: 'custom', name: 'journey-step', custom: noopStorage },
20+
},
21+
});
22+
23+
const result = await client.start();
24+
25+
if ('payload' in result) {
26+
return {
27+
stepPayload: result.payload,
28+
error: null,
29+
};
30+
}
31+
32+
return {
33+
stepPayload: null,
34+
error: 'error' in result ? result : { error: 'unexpected', message: 'Unexpected result' },
35+
};
36+
} catch (e) {
37+
return {
38+
stepPayload: null,
39+
error: {
40+
error: 'server_init_failed',
41+
message: e instanceof Error ? e.message : 'Failed to initialize journey client on server',
42+
},
43+
};
44+
}
45+
};
46+
47+
/**
48+
* Form actions — the authorize action generates a PKCE authorize URL on the server,
49+
* stores the verifier in a cookie, and redirects the browser to the authorize endpoint.
50+
*/
51+
export const actions: Actions = {
52+
authorize: async ({ cookies }) => {
53+
const client = await oidc({
54+
config: {
55+
serverConfig: { wellknown: WELLKNOWN_URL },
56+
clientId: CLIENT_ID,
57+
redirectUri: REDIRECT_URI,
58+
scope: SCOPE,
59+
responseType: 'code',
60+
},
61+
storage: { type: 'custom', name: CLIENT_ID, custom: noopStorage },
62+
});
63+
64+
if (!client || 'error' in client) {
65+
return { error: 'Failed to initialize OIDC client' };
66+
}
67+
68+
// Generate authorize URL with PKCE — returns { url, verifier, state }
69+
const result = await client.authorize.url();
70+
71+
if ('error' in result) {
72+
return { error: result.error };
73+
}
74+
75+
// Store PKCE verifier + state in an httpOnly cookie for the callback route
76+
cookies.set('pkce_verifier', result.verifier, {
77+
path: '/',
78+
httpOnly: true,
79+
sameSite: 'lax',
80+
maxAge: 300, // 5 minutes
81+
});
82+
cookies.set('pkce_state', result.state, {
83+
path: '/',
84+
httpOnly: true,
85+
sameSite: 'lax',
86+
maxAge: 300,
87+
});
88+
89+
// Redirect browser to authorization endpoint
90+
redirect(303, result.url);
91+
},
92+
};

0 commit comments

Comments
 (0)