From 1a5b3dec6630776b5d236c31830ef1b58a7c2ced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Wed, 11 Mar 2026 10:25:38 -0600 Subject: [PATCH] feat(thalos-web): add central OIDC login shell --- docker/40-runtime-config.sh | 5 +- docs/architecture/frontend-boundary.md | 7 +- docs/runbooks/containerization.md | 5 ++ docs/runbooks/local-development.md | 4 ++ docs/runbooks/testing.md | 3 +- public/runtime-config.js | 5 +- src/App.test.tsx | 51 ++++++++++---- src/App.tsx | 98 ++++++++------------------ src/api/client.test.ts | 29 +++++++- src/api/client.ts | 42 +++++++++++ src/auth/oidcLogin.test.ts | 34 +++++++++ src/auth/oidcLogin.ts | 59 ++++++++++++++++ 12 files changed, 252 insertions(+), 90 deletions(-) create mode 100644 src/auth/oidcLogin.test.ts create mode 100644 src/auth/oidcLogin.ts diff --git a/docker/40-runtime-config.sh b/docker/40-runtime-config.sh index 8c2cc72..6b7280e 100755 --- a/docker/40-runtime-config.sh +++ b/docker/40-runtime-config.sh @@ -4,6 +4,9 @@ set -eu cat > /usr/share/nginx/html/runtime-config.js < ({ })); import { loadDashboard } from './api/dashboardApi'; -import { getSessionMe, loginSession } from './api/sessionApi'; +import { getSessionMe } from './api/sessionApi'; import App from './App'; describe('Thalos App', () => { beforeEach(() => { vi.mocked(loadDashboard).mockReset(); vi.mocked(getSessionMe).mockReset(); - vi.mocked(loginSession).mockReset(); + window.history.pushState({}, '', '/'); + window.__APP_CONFIG__ = { + API_BASE_URL: 'http://localhost:8080', + THALOS_AUTH_BASE_URL: 'http://localhost:20080', + THALOS_DEFAULT_RETURN_URL: 'https://auth.dream-views.com/', + THALOS_ALLOWED_RETURN_HOSTS: 'auth.dream-views.com,furniture-display-demo.dream-views.com', + THALOS_DEFAULT_TENANT_ID: 'demo-tenant' + }; }); - it('renders login gate and signs in through Thalos session endpoint', async () => { - vi.mocked(getSessionMe) - .mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }) - .mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 }); + it('renders central login link with safe return URL context', async () => { + window.history.pushState( + {}, + '', + '/?returnUrl=https%3A%2F%2Ffurniture-display-demo.dream-views.com%2Fdashboard&tenantId=tenant-a' + ); + vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Sign In' })).toBeInTheDocument()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument()); + expect(screen.getByText(/tenant: tenant-a/i)).toBeInTheDocument(); + expect(screen.getByText(/return: https:\/\/furniture-display-demo\.dream-views\.com\/dashboard/i)).toBeInTheDocument(); - fireEvent.change(screen.getByLabelText('Subject Id'), { target: { value: 'alice' } }); - fireEvent.click(screen.getByRole('button', { name: 'Sign In' })); - - await waitFor(() => { - expect(loginSession).toHaveBeenCalledTimes(1); - expect(screen.getByText(/subject: demo-user/i)).toBeInTheDocument(); - }); + const link = screen.getByRole('link') as HTMLAnchorElement; + expect(link.href).toContain('/api/identity/oidc/google/start'); + expect(link.href).toContain('tenantId=tenant-a'); + expect(link.href).toContain( + encodeURIComponent('https://furniture-display-demo.dream-views.com/dashboard') + ); }); - it('loads protected session snapshot through dashboard adapter', async () => { + it('falls back to default return URL when requested host is not allowed', async () => { + window.history.pushState({}, '', '/?returnUrl=https%3A%2F%2Fevil.example.com%2Fsteal'); + vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Continue with Google' })).toBeInTheDocument()); + expect(screen.getByText(/return: https:\/\/auth\.dream-views\.com\//i)).toBeInTheDocument(); + }); + + it('loads protected session snapshot for authenticated users', async () => { vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: true, subjectId: 'demo-user', diff --git a/src/App.tsx b/src/App.tsx index b34803b..54093a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { loadDashboard } from './api/dashboardApi'; import { SessionProvider, useSessionContext } from './auth/sessionContext'; +import { resolveOidcLoginContext } from './auth/oidcLogin'; import type { IdentityProvider } from './api/sessionApi'; function App() { @@ -15,6 +16,7 @@ type RouteKey = 'overview' | 'session'; function ThalosShell() { const session = useSessionContext(); + const oidcLogin = useMemo(() => resolveOidcLoginContext(window.location.search), []); const [route, setRoute] = useState('overview'); const [snapshot, setSnapshot] = useState(null); const [error, setError] = useState(null); @@ -46,9 +48,17 @@ function ThalosShell() { return (

Thalos Web

-

Login against Thalos session endpoints to access protected routes.

+

Central login entrypoint for `auth.dream-views.com`.

+

After successful authentication, you will return to the requested allowed destination.

{session.error &&
{session.error}
} - +
+ Login Context + tenant: {oidcLogin.tenantId} + return: {oidcLogin.returnUrl} + + + +
); } @@ -56,16 +66,21 @@ function ThalosShell() { return (

Thalos Web

-

Session management MVP for login, me, refresh, and logout.

+

Central session management shell for auth callback and profile checks.

subject: {session.profile.subjectId} tenant: {session.profile.tenantId} provider: {providerLabel(session.profile.provider)} - + + + @@ -84,7 +99,7 @@ function ThalosShell() { className={route === 'session' ? 'active' : undefined} onClick={() => setRoute('session')} > - Session Actions + Session Routing
@@ -104,74 +119,17 @@ function ThalosShell() { )} - {route === 'session' && } + {route === 'session' && ( +
+ Central Redirect Target + {oidcLogin.returnUrl} +

Use this shell to confirm session state before returning to the requested app.

+
+ )}
); } -function LoginCard() { - const session = useSessionContext(); - const [subjectId, setSubjectId] = useState('demo-user'); - const [tenantId, setTenantId] = useState('demo-tenant'); - const [provider, setProvider] = useState(0); - const [externalToken, setExternalToken] = useState(''); - const [error, setError] = useState(null); - const [submitting, setSubmitting] = useState(false); - - const onSubmit = async () => { - setSubmitting(true); - setError(null); - try { - await session.login({ - subjectId, - tenantId, - provider, - externalToken - }); - } catch (err) { - setError(err instanceof Error ? err.message : 'Login failed.'); - } finally { - setSubmitting(false); - } - }; - - return ( -
-
- - - -
- -
- -
- {error &&
{error}
} -
- ); -} - function providerLabel(provider: IdentityProvider): string { if (provider === 0 || provider === '0' || provider === 'InternalJwt') { return 'Internal JWT'; diff --git a/src/api/client.test.ts b/src/api/client.test.ts index d2b0966..59cfd4f 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getApiBaseUrl, getThalosAuthBaseUrl } from './client'; +import { + getApiBaseUrl, + getThalosAllowedReturnHosts, + getThalosAuthBaseUrl, + getThalosDefaultReturnUrl, + getThalosDefaultTenantId +} from './client'; describe('client runtime base URLs', () => { afterEach(() => { @@ -38,4 +44,25 @@ describe('client runtime base URLs', () => { it('falls back to localhost default when both runtime and env are missing', () => { expect(getApiBaseUrl()).toBe('http://localhost:8080'); }); + + it('reads default return URL and tenant from runtime config when available', () => { + window.__APP_CONFIG__ = { + THALOS_DEFAULT_RETURN_URL: 'https://auth.dream-views.com/', + THALOS_DEFAULT_TENANT_ID: 'tenant-1' + }; + + expect(getThalosDefaultReturnUrl()).toBe('https://auth.dream-views.com/'); + expect(getThalosDefaultTenantId()).toBe('tenant-1'); + }); + + it('parses allowed return hosts from runtime config', () => { + window.__APP_CONFIG__ = { + THALOS_ALLOWED_RETURN_HOSTS: 'auth.dream-views.com, furniture-display-demo.dream-views.com' + }; + + expect(getThalosAllowedReturnHosts()).toEqual([ + 'auth.dream-views.com', + 'furniture-display-demo.dream-views.com' + ]); + }); }); diff --git a/src/api/client.ts b/src/api/client.ts index 4ead4eb..832cf7a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -3,6 +3,9 @@ declare global { __APP_CONFIG__?: { API_BASE_URL?: string; THALOS_AUTH_BASE_URL?: string; + THALOS_DEFAULT_RETURN_URL?: string; + THALOS_ALLOWED_RETURN_HOSTS?: string; + THALOS_DEFAULT_TENANT_ID?: string; }; } } @@ -47,6 +50,38 @@ export function getThalosAuthBaseUrl(): string { return import.meta.env.VITE_THALOS_AUTH_BASE_URL ?? getApiBaseUrl(); } +export function getThalosDefaultReturnUrl(): string { + const runtimeValue = window.__APP_CONFIG__?.THALOS_DEFAULT_RETURN_URL; + if (runtimeValue && runtimeValue.length > 0) { + return runtimeValue; + } + + return import.meta.env.VITE_THALOS_DEFAULT_RETURN_URL ?? `${window.location.origin}/`; +} + +export function getThalosAllowedReturnHosts(): string[] { + const runtimeValue = window.__APP_CONFIG__?.THALOS_ALLOWED_RETURN_HOSTS; + if (runtimeValue && runtimeValue.length > 0) { + return parseHosts(runtimeValue); + } + + const envValue = import.meta.env.VITE_THALOS_ALLOWED_RETURN_HOSTS; + if (envValue && envValue.length > 0) { + return parseHosts(envValue); + } + + return [window.location.hostname]; +} + +export function getThalosDefaultTenantId(): string { + const runtimeValue = window.__APP_CONFIG__?.THALOS_DEFAULT_TENANT_ID; + if (runtimeValue && runtimeValue.length > 0) { + return runtimeValue; + } + + return import.meta.env.VITE_THALOS_DEFAULT_TENANT_ID ?? 'demo-tenant'; +} + export async function getJson(path: string, baseUrl = getApiBaseUrl()): Promise { return requestJson(baseUrl, path, { method: 'GET' }); } @@ -122,3 +157,10 @@ function createCorrelationId(): string { return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`; } + +function parseHosts(rawValue: string): string[] { + return rawValue + .split(',') + .map((host) => host.trim()) + .filter((host) => host.length > 0); +} diff --git a/src/auth/oidcLogin.test.ts b/src/auth/oidcLogin.test.ts new file mode 100644 index 0000000..c56fb59 --- /dev/null +++ b/src/auth/oidcLogin.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeReturnUrl } from './oidcLogin'; + +describe('oidc return url handling', () => { + it('keeps return URL when host is allowed', () => { + const value = sanitizeReturnUrl( + 'https://furniture-display-demo.dream-views.com/app', + 'https://auth.dream-views.com/', + ['auth.dream-views.com', 'furniture-display-demo.dream-views.com'] + ); + + expect(value).toBe('https://furniture-display-demo.dream-views.com/app'); + }); + + it('falls back to default return URL when host is not allowed', () => { + const value = sanitizeReturnUrl( + 'https://evil.example.com/callback', + 'https://auth.dream-views.com/', + ['auth.dream-views.com', 'furniture-display-demo.dream-views.com'] + ); + + expect(value).toBe('https://auth.dream-views.com/'); + }); + + it('falls back to default return URL when protocol is not http/https', () => { + const value = sanitizeReturnUrl( + 'javascript:alert(1)', + 'https://auth.dream-views.com/', + ['auth.dream-views.com'] + ); + + expect(value).toBe('https://auth.dream-views.com/'); + }); +}); diff --git a/src/auth/oidcLogin.ts b/src/auth/oidcLogin.ts new file mode 100644 index 0000000..711c611 --- /dev/null +++ b/src/auth/oidcLogin.ts @@ -0,0 +1,59 @@ +import { + getThalosAllowedReturnHosts, + getThalosAuthBaseUrl, + getThalosDefaultReturnUrl, + getThalosDefaultTenantId +} from '../api/client'; + +export type OidcLoginContext = { + returnUrl: string; + tenantId: string; + startUrl: string; +}; + +export function resolveOidcLoginContext(search: string): OidcLoginContext { + const query = new URLSearchParams(search); + const returnUrl = sanitizeReturnUrl(query.get('returnUrl'), getThalosDefaultReturnUrl(), getThalosAllowedReturnHosts()); + const tenantId = sanitizeTenantId(query.get('tenantId')); + const startUrl = buildGoogleOidcStartUrl(returnUrl, tenantId); + return { returnUrl, tenantId, startUrl }; +} + +export function sanitizeReturnUrl(rawReturnUrl: string | null, defaultReturnUrl: string, allowedHosts: string[]): string { + if (!rawReturnUrl || rawReturnUrl.length === 0) { + return defaultReturnUrl; + } + + let parsed: URL; + try { + parsed = new URL(rawReturnUrl); + } catch { + return defaultReturnUrl; + } + + // Prevent protocol abuse and open-redirect fallback to non-browser schemes. + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return defaultReturnUrl; + } + + const isAllowed = allowedHosts.some((host) => host.toLowerCase() === parsed.hostname.toLowerCase()); + return isAllowed ? parsed.toString() : defaultReturnUrl; +} + +export function sanitizeTenantId(rawTenantId: string | null): string { + if (!rawTenantId || rawTenantId.length === 0) { + return getThalosDefaultTenantId(); + } + + const candidate = rawTenantId.trim(); + return candidate.length === 0 ? getThalosDefaultTenantId() : candidate; +} + +export function buildGoogleOidcStartUrl(returnUrl: string, tenantId: string): string { + const authBase = getThalosAuthBaseUrl().replace(/\/+$/, ''); + const query = new URLSearchParams({ + returnUrl, + tenantId + }); + return `${authBase}/api/identity/oidc/google/start?${query.toString()}`; +}