Skip to content

Commit 26cc196

Browse files
committed
feat(login-app): redirect uri
1 parent f390dac commit 26cc196

File tree

8 files changed

+271
-53
lines changed

8 files changed

+271
-53
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
/**
11+
* Shared constants for redirect handling in the login-app.
12+
*/
13+
14+
export const REDIRECT_FALLBACK = 'https://www.pingidentity.com/en.html';
15+
16+
export const REDIRECT_QUERY_PARAMS = 'redirect_query_params';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
import type { z } from 'zod';
11+
12+
import { getLocale } from '$core/_utilities/i18n.utilities';
13+
14+
import type { stringsSchema } from '$core/locale.store';
15+
16+
export async function loadLocaleContent(userLocale: string) {
17+
const locale = getLocale(userLocale, '/');
18+
const [country, lang] = locale.split('/');
19+
20+
let localeContent: { default: z.infer<typeof stringsSchema> };
21+
22+
try {
23+
localeContent = await import(`$locales/${country}/${lang}/index.json`);
24+
} catch (err) {
25+
console.error(`User locale content for ${userLocale} was not found.`);
26+
27+
// TODO: Reevaluate use of JS versus JSON without breaking type generation for lib
28+
// eslint-disable-next-line
29+
// @ts-ignore
30+
localeContent = await import(`$locales/us/en/index.json`);
31+
}
32+
33+
return localeContent.default;
34+
}
Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,56 @@
11
/**
22
*
3-
* Copyright © 2025 Ping Identity Corporation. All right reserved.
3+
* Copyright © 2025-2026 Ping Identity Corporation. All right reserved.
44
*
55
* This software may be modified and distributed under the terms
66
* of the MIT license. See the LICENSE file for details.
77
*
88
**/
99

10-
import type { RequestEvent } from '@sveltejs/kit';
1110
import type { PageServerLoad } from './$types';
12-
import type { z } from 'zod';
13-
14-
import { getLocale } from '$core/_utilities/i18n.utilities';
15-
16-
import type { stringsSchema } from '$core/locale.store';
17-
18-
export const load: PageServerLoad = async (event: RequestEvent) => {
19-
const userLocale = event.request.headers.get('accept-language') || 'en-US';
20-
const locale = getLocale(userLocale, '/');
21-
const [country, lang] = locale.split('/');
22-
23-
let localeContent: { default: z.infer<typeof stringsSchema> };
24-
25-
try {
26-
localeContent = await import(`$locales/${country}/${lang}/index.json`);
27-
} catch (err) {
28-
console.error(`User locale content for ${userLocale} was not found.`);
29-
30-
// TODO: Reevaluate use of JS versus JSON without breaking type generation for lib
31-
// eslint-disable-next-line
32-
// @ts-ignore
33-
localeContent = await import(`$locales/us/en/index.json`);
11+
import { loadLocaleContent } from '$server/locale';
12+
13+
import { REDIRECT_QUERY_PARAMS } from '$lib/redirect.constants';
14+
15+
export const load: PageServerLoad = async ({ request, url, cookies }) => {
16+
// https://docs.pingidentity.com/pingam/8/am-oauth2/oauth2-parameters.html#redirect-uri
17+
const redirectUri = url.searchParams.get('goto');
18+
19+
const userLocale = request.headers.get('accept-language') || 'en-US';
20+
const content = await loadLocaleContent(userLocale);
21+
22+
if (redirectUri) {
23+
try {
24+
// Normalize common inputs into something URL parsing + AM validateGoto can consistently handle.
25+
// Examples:
26+
// - '/path' stays relative (will be resolved against this app's origin below)
27+
// - 'example.com/path' becomes 'https://example.com/path'
28+
// - 'https://…' stays as-is
29+
const normalizedRedirectUri =
30+
redirectUri.startsWith('http://') ||
31+
redirectUri.startsWith('https://') ||
32+
redirectUri.startsWith('/')
33+
? redirectUri
34+
: `https://${redirectUri}`;
35+
36+
// Parse into a real URL object. If the input is relative (e.g. '/path'), use this app's origin
37+
// as the base so we end up with a full absolute URL in `parsed.href`.
38+
const parsed = new URL(normalizedRedirectUri, url.origin);
39+
40+
// Only persist http(s) redirects. Other schemes like 'javascript:' are ignored.
41+
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
42+
cookies.set(REDIRECT_QUERY_PARAMS, parsed.href, {
43+
httpOnly: true,
44+
sameSite: 'lax',
45+
secure: url.protocol === 'https:',
46+
maxAge: 300,
47+
path: '/',
48+
});
49+
}
50+
} catch {
51+
// Ignore invalid redirectUri values
52+
}
3453
}
3554

36-
return { content: localeContent.default };
55+
return { content };
3756
};

apps/login-app/src/routes/(app)/+page.svelte

Lines changed: 39 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
<!--
22
3-
Copyright © 2025 Ping Identity Corporation. All right reserved.
3+
Copyright © 2025-2026 Ping Identity Corporation. All right reserved.
44
55
This software may be modified and distributed under the terms
66
of the MIT license. See the LICENSE file for details.
77
88
-->
99

1010
<script lang="ts">
11-
import { Config, FRUser, SessionManager } from '@forgerock/javascript-sdk';
1211
import { goto } from '$app/navigation';
12+
import { browser } from '$app/environment';
1313
import { page } from '$app/stores';
1414
import { onMount } from 'svelte';
1515
16+
import { REDIRECT_FALLBACK } from '$lib/redirect.constants';
17+
1618
import Box from '$components/primitives/box/centered.svelte';
1719
import Journey from '$journey/journey.svelte';
1820
import { initialize as initializeJourney } from '$journey/journey.store';
@@ -38,29 +40,39 @@
3840
3941
let name = '';
4042
41-
async function logout() {
42-
const { clientId } = Config.get();
43+
// Ensures we only trigger the post-login redirect once
44+
// to avoid re-running multiple times as journey/oauth/user stores update.
45+
let hasRedirected = false;
4346
44-
/**
45-
* If configuration has a clientId, then use FRUser to logout to ensure
46-
* token revoking and removal; else, just end the session.
47-
*/
48-
if (clientId) {
49-
// Call SDK logout
50-
await FRUser.logout();
51-
} else {
52-
await SessionManager.logout();
47+
async function redirectAfterLogin() {
48+
const accessToken = $oauthStore.response?.accessToken;
49+
50+
if (!accessToken) {
51+
window.location.assign(REDIRECT_FALLBACK);
52+
return;
5353
}
5454
55-
// Reset stores
56-
journeyStore.reset();
57-
oauthStore.reset();
58-
userStore.reset();
59-
// Fetch fresh journey step
60-
journeyStore.start({
61-
tree: journeyParam || authIndexValue || undefined,
62-
});
55+
try {
56+
const response = await fetch('/api/redirect', {
57+
method: 'GET',
58+
headers: {
59+
accept: 'application/json',
60+
authorization: `Bearer ${accessToken}`,
61+
},
62+
});
63+
64+
if (!response.ok) {
65+
window.location.assign(REDIRECT_FALLBACK);
66+
return;
67+
}
68+
69+
const body = (await response.json()) as { redirectUri?: string };
70+
window.location.assign(body.redirectUri || REDIRECT_FALLBACK);
71+
} catch {
72+
window.location.assign(REDIRECT_FALLBACK);
73+
}
6374
}
75+
6476
/**
6577
* Sets up locale store with appropriate content
6678
*/
@@ -87,19 +99,18 @@
8799
userStore.get();
88100
}
89101
name = ($userStore.response as { name: string })?.name;
102+
103+
if (browser && $userStore?.successful && !hasRedirected) {
104+
hasRedirected = true;
105+
redirectAfterLogin();
106+
}
90107
}
91108
</script>
92109

93110
<Box>
94111
{#if !$userStore.successful}
95112
<Journey componentStyle="app" displayIcon={true} {journeyStore} />
96113
{:else}
97-
<p class="tw_mb-6">User: {name}</p>
98-
<button
99-
class="tw_button-base tw_focusable-element dark:tw_focusable-element_dark tw_button-secondary dark:tw_button-secondary_dark"
100-
on:click={logout}
101-
>
102-
Logout
103-
</button>
114+
<p class="tw_mb-6">{name}, you are being redirected...</p>
104115
{/if}
105116
</Box>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
// Reference: https://docs.pingidentity.com/pingoneaic/am-authentication/redirection-url-precedence.html
11+
12+
import { json } from '@sveltejs/kit';
13+
import type { RequestHandler } from './$types';
14+
15+
import { AM_DOMAIN_PATH, JSON_REALM_PATH } from '$core/constants';
16+
import { REDIRECT_FALLBACK, REDIRECT_QUERY_PARAMS } from '$lib/redirect.constants';
17+
18+
async function validateGoto(
19+
fetchFn: typeof fetch,
20+
authorization: string,
21+
gotoUri: string,
22+
): Promise<string | null> {
23+
const response = await fetchFn(`${AM_DOMAIN_PATH}${JSON_REALM_PATH}/users?_action=validateGoto`, {
24+
method: 'POST',
25+
headers: {
26+
'Accept-API-Version': 'protocol=2.1,resource=3.0',
27+
'Content-Type': 'application/json',
28+
Authorization: authorization,
29+
},
30+
body: JSON.stringify({ goto: gotoUri }),
31+
});
32+
33+
if (!response.ok) {
34+
console.warn('validateGoto failed', {
35+
status: response.status,
36+
statusText: response.statusText,
37+
hasAuthorization: authorization.toLowerCase().startsWith('bearer '),
38+
});
39+
return null;
40+
}
41+
42+
// AM returns { successURL: string } even when the input goto is invalid
43+
// It falls back to the default success URL
44+
try {
45+
const bodyText = await response.text();
46+
const data = JSON.parse(bodyText) as { successURL?: string };
47+
if (!data?.successURL) return null;
48+
49+
// successURL may be absolute or relative (e.g. '/enduser/?realm=/alpha').
50+
// Resolve relative URLs against the AM base URL.
51+
return new URL(data.successURL, AM_DOMAIN_PATH).href;
52+
} catch {
53+
return null;
54+
}
55+
}
56+
57+
export const GET: RequestHandler = async ({ cookies, url, request, fetch }) => {
58+
const redirectUri = cookies.get(REDIRECT_QUERY_PARAMS);
59+
60+
const authorization = request.headers.get('authorization') || '';
61+
62+
if (!redirectUri) {
63+
return json({ redirectUri: REDIRECT_FALLBACK }, { status: 400 });
64+
}
65+
66+
if (!authorization) {
67+
return json({ redirectUri: REDIRECT_FALLBACK }, { status: 401 });
68+
}
69+
70+
// Cookie value is expected to be an absolute URL (normalized at write time)
71+
const validated = await validateGoto(fetch, authorization, redirectUri);
72+
73+
// Clear cookie after successful validation
74+
if (validated) {
75+
cookies.delete(REDIRECT_QUERY_PARAMS, {
76+
httpOnly: true,
77+
sameSite: 'lax',
78+
secure: url.protocol === 'https:',
79+
path: '/',
80+
});
81+
}
82+
83+
return json({ redirectUri: validated ?? REDIRECT_FALLBACK }, { status: validated ? 200 : 400 });
84+
};

apps/login-app/tsconfig.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"extends": "./.svelte-kit/tsconfig.json",
33
"compilerOptions": {
44
"paths": {
5+
"$lib": ["./src/lib"],
6+
"$lib/*": ["./src/lib/*"],
57
"$core": ["../../core"],
68
"$core/*": ["../../core/*"],
79
"$components": ["../../core/components"],
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
*
3+
* Copyright © 2025 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
import { test, expect } from '@playwright/test';
11+
import { username, password } from '../utilities/demo-user.js';
12+
13+
test('Successful redirect', async ({ page }) => {
14+
await page.goto('/?goto=https://forgerock.github.io/');
15+
16+
await page.getByLabel('Username').fill(username);
17+
await page.getByLabel('Password').fill(password);
18+
await page.getByRole('button', { name: 'Next' }).click();
19+
await expect(page).toHaveURL('https://forgerock.github.io/');
20+
});
21+
22+
test('Invalid URL redirects to end user UI', async ({ page }) => {
23+
await page.goto('/?goto=https://invalidurl.com');
24+
25+
await page.getByLabel('Username').fill(username);
26+
await page.getByLabel('Password').fill(password);
27+
await page.getByRole('button', { name: 'Next' }).click();
28+
29+
// https://invalidurl.com does not exist in validation service
30+
// so it should redirect to end user UI
31+
await expect(page).toHaveURL('https://openam-sdks.forgeblocks.com/enduser/?realm=/alpha#/');
32+
});
33+
34+
test('Empty URL redirects to fallback', async ({ page }) => {
35+
await page.goto('/');
36+
37+
await page.getByLabel('Username').fill(username);
38+
await page.getByLabel('Password').fill(password);
39+
await page.getByRole('button', { name: 'Next' }).click();
40+
41+
await expect(page).toHaveURL('https://www.pingidentity.com/en.html');
42+
});

e2e/tests/utilities/demo-user.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
*
3+
* Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*
7+
*/
8+
export const username = 'JSAmLoginFrameworkE2E@user.com';
9+
export const password = 'Demo_12345!';
10+
export const displayName = 'Demo User';

0 commit comments

Comments
 (0)