Skip to content

Commit 17bed5c

Browse files
committed
Update login for deployment to saas
1 parent 29ef21e commit 17bed5c

14 files changed

Lines changed: 489 additions & 57 deletions
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Docker Branch Build
2+
3+
on:
4+
push:
5+
branches:
6+
- new-login-2
7+
workflow_dispatch:
8+
9+
permissions:
10+
packages: write
11+
12+
jobs:
13+
docker:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- name: Set lowercase image name
19+
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV
20+
21+
- name: Log in to GHCR
22+
uses: docker/login-action@v3
23+
with:
24+
registry: ghcr.io
25+
username: ${{ github.actor }}
26+
password: ${{ secrets.GITHUB_TOKEN }}
27+
28+
- name: Set up Docker Buildx
29+
uses: docker/setup-buildx-action@v3
30+
31+
- name: Build and push
32+
uses: docker/build-push-action@v6
33+
with:
34+
context: .
35+
target: deploy
36+
push: true
37+
tags: ${{ env.IMAGE }}:new-login-2
38+
cache-from: type=gha
39+
cache-to: type=gha,mode=max
40+
build-args: |
41+
NODE_ENV=production

Dockerfile

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
FROM node:22-slim AS builder
1+
# BASE_IMAGE is overridable so the image can be built behind a firewall that
2+
# blocks docker.io: the saas login2 deploy script passes a reachable debian-slim
3+
# node base (e.g. mirror.gcr.io/library/node:22-slim). Default is the upstream
4+
# tag so CI and normal local builds are unchanged. Must remain debian-based —
5+
# the deploy stage uses groupadd/useradd and @swc/core+esbuild native deps.
6+
ARG BASE_IMAGE=node:22-slim
7+
FROM ${BASE_IMAGE} AS builder
28

39
ARG NODE_ENV=development
410
ENV NODE_ENV=$NODE_ENV
@@ -30,10 +36,14 @@ ENV PREVIEW="true"
3036

3137
RUN ["pnpm", "run", "build"]
3238

33-
# Run as the built-in non-root user from the node base image
39+
# Create forgerock user (UID 11111) to match saas Kubernetes security standards.
40+
# The node base image provides UID 1000, but AIC workloads must run as 11111.
41+
RUN groupadd --gid 11111 forgerock && \
42+
useradd --uid 11111 --gid 11111 --no-create-home --shell /bin/bash forgerock
43+
3444
# Only chown the build output — avoids duplicating the entire node_modules layer
35-
RUN chown -R node:node apps/login-app/build
36-
USER node
45+
RUN chown -R forgerock:forgerock apps/login-app/build
46+
USER forgerock
3747

3848
EXPOSE 3000
3949

apps/login-app/src/hooks.server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { env } from '$env/dynamic/private';
2+
import { buildLoginCspHeaders, isHtmlResponse } from '$server/csp.utilities';
3+
4+
import type { Handle } from '@sveltejs/kit';
5+
6+
export const handle: Handle = async ({ event, resolve }) => {
7+
const response = await resolve(event);
8+
9+
if (!isHtmlResponse(response)) {
10+
return response;
11+
}
12+
13+
response.headers.set('Cache-Control', 'no-store');
14+
response.headers.set('Pragma', 'no-cache');
15+
response.headers.set('Referrer-Policy', 'origin');
16+
response.headers.set('X-Content-Type-Options', 'nosniff');
17+
18+
const cspHeaders = buildLoginCspHeaders(
19+
event.url,
20+
{
21+
enforced: env.CSP_ENFORCED,
22+
reportOnly: env.CSP_REPORT_ONLY,
23+
},
24+
{
25+
amUrl: env.FR_AM_URL,
26+
currentHost: event.request.headers.get('host'),
27+
},
28+
);
29+
30+
cspHeaders.forEach((value, header) => {
31+
response.headers.set(header, value);
32+
});
33+
34+
return response;
35+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import {
4+
buildLoginCspHeaders,
5+
DEFAULT_CSP_ENFORCED,
6+
DEFAULT_CSP_REPORT_ONLY,
7+
getRealmFromLoginUrl,
8+
isHtmlResponse,
9+
} from './csp.utilities';
10+
11+
describe('Login CSP utilities', () => {
12+
it('uses root as the default realm', () => {
13+
const realm = getRealmFromLoginUrl(new URL('https://example.com/login'));
14+
15+
expect(realm).toBe('root');
16+
});
17+
18+
it('uses customer CSP values only for alpha and bravo realm requests', () => {
19+
const alphaHeaders = buildLoginCspHeaders(new URL('https://example.com/login?realm=/alpha'), {
20+
enforced: "frame-ancestors 'self' https://alpha.example.com",
21+
reportOnly: "default-src 'self'; report-uri https://reports.example.com",
22+
});
23+
const rootHeaders = buildLoginCspHeaders(new URL('https://example.com/login?realm=/'), {
24+
enforced: "frame-ancestors 'self' https://alpha.example.com",
25+
reportOnly: "default-src 'self'; report-uri https://reports.example.com",
26+
});
27+
28+
expect(alphaHeaders.get('Content-Security-Policy')).toBe(
29+
"frame-ancestors 'self' https://alpha.example.com",
30+
);
31+
expect(alphaHeaders.get('Content-Security-Policy-Report-Only')).toBe(
32+
"default-src 'self'; report-uri https://reports.example.com",
33+
);
34+
expect(rootHeaders.get('Content-Security-Policy')).toBe(DEFAULT_CSP_ENFORCED);
35+
expect(rootHeaders.get('Content-Security-Policy-Report-Only')).toBe(DEFAULT_CSP_REPORT_ONLY);
36+
});
37+
38+
it('uses customer CSP values for custom hosts', () => {
39+
const headers = buildLoginCspHeaders(
40+
new URL('https://login.example.com/login?realm=/'),
41+
{
42+
enforced: "frame-ancestors 'self' https://custom.example.com",
43+
reportOnly: "default-src 'self'; report-uri https://reports.example.com",
44+
},
45+
{
46+
amUrl: 'https://openam.example.com/am',
47+
currentHost: 'login.example.com',
48+
},
49+
);
50+
51+
expect(headers.get('Content-Security-Policy')).toBe(
52+
"frame-ancestors 'self' https://custom.example.com",
53+
);
54+
expect(headers.get('Content-Security-Policy-Report-Only')).toBe(
55+
"default-src 'self'; report-uri https://reports.example.com",
56+
);
57+
});
58+
59+
it('uses default CSP values for the AM host root realm', () => {
60+
const headers = buildLoginCspHeaders(
61+
new URL('https://openam.example.com/login?realm=/'),
62+
{
63+
enforced: "frame-ancestors 'self' https://custom.example.com",
64+
reportOnly: "default-src 'self'; report-uri https://reports.example.com",
65+
},
66+
{
67+
amUrl: 'https://openam.example.com/am',
68+
currentHost: 'openam.example.com:443',
69+
},
70+
);
71+
72+
expect(headers.get('Content-Security-Policy')).toBe(DEFAULT_CSP_ENFORCED);
73+
expect(headers.get('Content-Security-Policy-Report-Only')).toBe(DEFAULT_CSP_REPORT_ONLY);
74+
});
75+
76+
it('detects HTML responses', () => {
77+
const response = new Response('<main></main>', {
78+
headers: {
79+
'content-type': 'text/html; charset=utf-8',
80+
},
81+
});
82+
83+
expect(isHtmlResponse(response)).toBe(true);
84+
});
85+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
type CspEnvironment = {
2+
enforced?: string;
3+
reportOnly?: string;
4+
};
5+
6+
type CspRequestContext = {
7+
amUrl?: string;
8+
currentHost?: string | null;
9+
};
10+
11+
export const DEFAULT_CSP_ENFORCED = "frame-ancestors 'self'";
12+
export const DEFAULT_CSP_REPORT_ONLY =
13+
"frame-ancestors 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'";
14+
15+
const CUSTOMER_CSP_REALMS = new Set(['alpha', 'bravo']);
16+
17+
export function getRealmFromLoginUrl(url: URL): string {
18+
const realm = url.searchParams.get('realm');
19+
20+
if (!realm) {
21+
return 'root';
22+
}
23+
24+
return realm.replace(/^\/+/, '') || 'root';
25+
}
26+
27+
function hostWithoutPort(host: string): string {
28+
return host.toLowerCase().replace(/:\d+$/, '');
29+
}
30+
31+
function getAMHost(amUrl?: string): string | null {
32+
if (!amUrl) {
33+
return null;
34+
}
35+
36+
try {
37+
return new URL(amUrl).host;
38+
} catch {
39+
return null;
40+
}
41+
}
42+
43+
function isCustomHost(context: CspRequestContext): boolean {
44+
const amHost = getAMHost(context.amUrl);
45+
46+
if (!context.currentHost || !amHost) {
47+
return false;
48+
}
49+
50+
return hostWithoutPort(context.currentHost) !== hostWithoutPort(amHost);
51+
}
52+
53+
export function buildLoginCspHeaders(
54+
url: URL,
55+
cspEnvironment: CspEnvironment,
56+
context: CspRequestContext = {},
57+
): Headers {
58+
const realm = getRealmFromLoginUrl(url);
59+
const headers = new Headers();
60+
61+
if (CUSTOMER_CSP_REALMS.has(realm) || isCustomHost(context)) {
62+
headers.set('Content-Security-Policy', cspEnvironment.enforced || DEFAULT_CSP_ENFORCED);
63+
headers.set(
64+
'Content-Security-Policy-Report-Only',
65+
cspEnvironment.reportOnly || DEFAULT_CSP_REPORT_ONLY,
66+
);
67+
68+
return headers;
69+
}
70+
71+
headers.set('Content-Security-Policy', DEFAULT_CSP_ENFORCED);
72+
headers.set('Content-Security-Policy-Report-Only', DEFAULT_CSP_REPORT_ONLY);
73+
74+
return headers;
75+
}
76+
77+
export function isHtmlResponse(response: Response): boolean {
78+
const contentType = response.headers.get('content-type') || '';
79+
80+
return contentType.includes('text/html');
81+
}

apps/login-app/src/lib/server/redirect/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,33 @@ In these flows:
8787
3. Login app passes the `goto` and `gotoOnFail` params to AM and AM links this parameter with the active auth session in memory. This happens through the SDK options (`StepOptions.query.goto` and `StepOptions.query.gotoOnFail`).
8888
4. AM stores all of these relevant state params in the `suspendedId`, so the magic link sent to the user contains the `goto` param within this `suspendedId` in the URL.
8989
5. AM then restores the goto URL, and AM is able to send the user to this URL upon completion of the journey (turned into the successUrl).
90+
91+
## Deployment notes: same-host deployment (standard)
92+
93+
In the standard PingOne AIC deployment the login app and platform-ui share the same FQDN. HAProxy routes traffic based on path prefix:
94+
95+
- `/login/*``be_login2` (Login2)
96+
- `/am/XUI/*` → 301 redirect to `/login/?[query]` when `ENABLE_LOGIN2=true`
97+
- `/enduser/*``be_platform-ui` (platform-ui SPA, never touches Login2)
98+
- `/platform/*``be_platform-ui`
99+
100+
`/enduser/` bypasses Login2 entirely, so post-login role-based redirects to `https://<fqdn>/enduser/?realm=/<realm>#/` are safe and route directly to the platform-ui SPA.
101+
102+
### OAuth token refresh (SPA iframe PKCE)
103+
104+
The enduser SPA refreshes tokens by opening a hidden iframe to `/am/oauth2/<realm>/authorize`. When the session is valid, AM completes this silently and redirects the iframe to `appAuthHelperRedirect.html`, which posts the token back to the parent frame.
105+
106+
Without a session, AM redirects to `/am/UI/Login``/am/XUI/` → (HAProxy 301) → `/login/?goto=<oauth-authorize-url>`. The `+page.server.ts` load function handles this in two ways:
107+
108+
1. **Valid session + OAuth goto**: validates the session with AM (`getUserIdFromSession`), then passes the `goto` directly to AM which completes the OAuth code exchange without another login prompt.
109+
2. **No/expired session + OAuth goto**: `getUserIdFromSession` returns null, session check is skipped, and the login form is served normally.
110+
111+
OAuth authorize URLs are also blocked as top-level redirect targets (`isOAuthAuthorizePath`) so a full-page POST-login flow never redirects the browser directly to an authorize endpoint.
112+
113+
### Loop prevention
114+
115+
`isLoginAppPath(url, loginAppOrigin)` guards against redirect loops caused by AM returning `/login/` paths as the default `validateGoto` successURL. It checks both **host** and **pathname**.
116+
117+
### Session detection on load
118+
119+
`+page.server.ts` runs pre-flight session detection: if the user has a valid AM session cookie and no intentional journey (`?journey=` / `?authIndexValue=`) is in the URL, they are redirected immediately to their portal without seeing the login form.

apps/login-app/src/lib/server/redirect/redirect.effects.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,19 +35,20 @@ const REDIRECT_QUERY_PARAMS = 'redirect_query_params';
3535
export function storeRedirectParams(event: RequestEvent): RedirectParams {
3636
const goto = event.url.searchParams.get('goto') || undefined;
3737
const gotoOnFail = event.url.searchParams.get('gotoOnFail') || undefined;
38-
39-
if (goto || gotoOnFail) {
40-
setHttpCookie(
41-
event.cookies,
42-
REDIRECT_QUERY_PARAMS,
43-
JSON.stringify({
44-
...(goto ? { goto } : {}),
45-
...(gotoOnFail ? { gotoOnFail } : {}),
46-
}),
47-
);
48-
}
49-
50-
return { goto, gotoOnFail };
38+
const realmParam = event.url.searchParams.get('realm');
39+
const realm = realmParam != null ? realmParam.replace(/^\/+/, '') || 'root' : 'root';
40+
41+
setHttpCookie(
42+
event.cookies,
43+
REDIRECT_QUERY_PARAMS,
44+
JSON.stringify({
45+
...(goto ? { goto } : {}),
46+
...(gotoOnFail ? { gotoOnFail } : {}),
47+
realm,
48+
}),
49+
);
50+
51+
return { goto, gotoOnFail, realm };
5152
}
5253

5354
/**
@@ -65,8 +66,8 @@ export async function createRedirectContext(
6566
const isGotoOnFail = loginResult !== 'success';
6667
const gotoUrl = isGotoOnFail ? cookie.gotoOnFail ?? '' : cookie.goto ?? journeyStepUrl;
6768
const successUrl = tokenId && gotoUrl ? await validateUrl(tokenId, gotoUrl) : null;
68-
const roles = tokenId ? await getUserRolesFromSession(tokenId) : [];
69-
const realm = env.FR_REALM_PATH;
69+
const realm = cookie.realm ?? env.FR_REALM_PATH ?? 'alpha';
70+
const roles = tokenId ? await getUserRolesFromSession(tokenId, realm) : [];
7071
const amOrigin = new URL(AM_DOMAIN_PATH).origin;
7172

7273
return {
@@ -93,6 +94,7 @@ export function readAndClearRedirectCookie(event: RequestEvent): RedirectParams
9394
const cookieSchema = z.object({
9495
goto: z.string().optional(),
9596
gotoOnFail: z.string().optional(),
97+
realm: z.string().optional(),
9698
});
9799

98100
if (!cookieValue) {

apps/login-app/src/lib/server/redirect/redirect.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type RedirectData = {
2323
export type RedirectParams = {
2424
goto?: string;
2525
gotoOnFail?: string;
26+
realm?: string;
2627
};
2728

2829
export type RedirectFormValue = {

apps/login-app/src/lib/server/redirect/redirect.utilities.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,8 @@ describe('resolveRedirect', () => {
213213
expect(url).toBe('/failure-redirect');
214214
});
215215

216-
it('returns a success fallback redirect when nothing matches', () => {
216+
it('returns an enduser fallback redirect when nothing matches', () => {
217217
const url = resolveRedirect(makeContext());
218-
expect(url).toBe('/success-redirect');
218+
expect(url).toBe('https://openam.example.com/enduser/?realm=/#/');
219219
});
220220
});

0 commit comments

Comments
 (0)