From 39f5af23e47e7b6bda30d74620b25f9372c7f54a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 16:05:38 -0600 Subject: [PATCH] feat(thalos-web): harden production auth experience --- docker/40-runtime-config.sh | 3 +- docs/architecture/frontend-boundary.md | 3 +- docs/runbooks/containerization.md | 2 + docs/runbooks/local-development.md | 2 + public/runtime-config.js | 3 +- src/App.test.tsx | 24 +++++++- src/App.tsx | 81 ++++++++++++++++---------- src/api/client.test.ts | 22 ++++++- src/api/client.ts | 36 ++++++++++++ src/auth/callbackState.test.ts | 10 ++++ src/auth/callbackState.ts | 31 +++++++++- tsconfig.app.tsbuildinfo | 2 +- 12 files changed, 180 insertions(+), 39 deletions(-) diff --git a/docker/40-runtime-config.sh b/docker/40-runtime-config.sh index 56a2b82..1104633 100755 --- a/docker/40-runtime-config.sh +++ b/docker/40-runtime-config.sh @@ -6,6 +6,7 @@ window.__APP_CONFIG__ = { API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}", THALOS_AUTH_BASE_URL: "${THALOS_AUTH_BASE_URL:-${API_BASE_URL:-http://localhost:8080}}", THALOS_DEFAULT_RETURN_URL: "${THALOS_DEFAULT_RETURN_URL:-http://localhost:22080/callback}", - THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}" + THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}", + THALOS_ENABLE_MANUAL_LOGIN: "${THALOS_ENABLE_MANUAL_LOGIN:-false}" }; EOT diff --git a/docs/architecture/frontend-boundary.md b/docs/architecture/frontend-boundary.md index a3457de..5d8b4a7 100644 --- a/docs/architecture/frontend-boundary.md +++ b/docs/architecture/frontend-boundary.md @@ -12,6 +12,7 @@ - `THALOS_AUTH_BASE_URL` for session and OIDC endpoints. - `THALOS_DEFAULT_RETURN_URL` for callback fallback. - `THALOS_DEFAULT_TENANT_ID` for OIDC tenant defaults. +- `THALOS_ENABLE_MANUAL_LOGIN` for explicitly enabling the dev/test fallback form. ## Protected Workflow Endpoints @@ -27,4 +28,4 @@ - Central login launch (Google OIDC start) - Callback processing and error rendering - Session workspace verification and snapshot reload -- Manual dev/test session login fallback +- Manual dev/test session login fallback gated by environment/runtime config diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index 610216e..0bbdb29 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -14,6 +14,7 @@ docker run --rm -p 8080:8080 \ -e THALOS_AUTH_BASE_URL=http://host.docker.internal:22080 \ -e THALOS_DEFAULT_RETURN_URL=http://localhost:22080/callback \ -e THALOS_DEFAULT_TENANT_ID=demo-tenant \ + -e THALOS_ENABLE_MANUAL_LOGIN=false \ --name thalos-web agilewebs/thalos-web:dev ``` @@ -23,6 +24,7 @@ docker run --rm -p 8080:8080 \ - Runtime override: container env `API_BASE_URL` - Runtime file generated at startup: `/runtime-config.js` - OIDC callback defaults are injected through runtime env vars, not hardcoded per build. +- Manual fallback login should remain disabled for deployed auth hosts unless an explicit dev/test override is required. ## Health Check diff --git a/docs/runbooks/local-development.md b/docs/runbooks/local-development.md index 74b692b..8c5af03 100644 --- a/docs/runbooks/local-development.md +++ b/docs/runbooks/local-development.md @@ -13,6 +13,7 @@ VITE_API_BASE_URL=http://localhost:8080 \ VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \ VITE_THALOS_DEFAULT_RETURN_URL=http://localhost:22080/callback \ VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \ +VITE_THALOS_ENABLE_MANUAL_LOGIN=true \ npm run dev ``` @@ -21,6 +22,7 @@ npm run dev - Central login starts via `GET /api/identity/oidc/google/start`. - Callback route validates query parameters and resolves session by calling refresh/me endpoints. - Session cookies are sent with `credentials: include`. +- Manual session login is available locally by setting `VITE_THALOS_ENABLE_MANUAL_LOGIN=true`. ## Build diff --git a/public/runtime-config.js b/public/runtime-config.js index 027362b..103602a 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -2,5 +2,6 @@ window.__APP_CONFIG__ = { API_BASE_URL: "http://localhost:8080", THALOS_AUTH_BASE_URL: "http://localhost:20080", THALOS_DEFAULT_RETURN_URL: "http://localhost:22080/callback", - THALOS_DEFAULT_TENANT_ID: "demo-tenant" + THALOS_DEFAULT_TENANT_ID: "demo-tenant", + THALOS_ENABLE_MANUAL_LOGIN: "true" }; diff --git a/src/App.test.tsx b/src/App.test.tsx index b5e943d..31fc32d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -25,7 +25,8 @@ describe('Thalos App', () => { API_BASE_URL: 'http://localhost:8080', THALOS_AUTH_BASE_URL: 'https://auth.dream-views.com', THALOS_DEFAULT_RETURN_URL: `${window.location.origin}/callback`, - THALOS_DEFAULT_TENANT_ID: 'demo-tenant' + THALOS_DEFAULT_TENANT_ID: 'demo-tenant', + THALOS_ENABLE_MANUAL_LOGIN: 'false' }; }); @@ -96,4 +97,25 @@ describe('Thalos App', () => { await waitFor(() => expect(screen.getByText('User cancelled')).toBeInTheDocument()); expect(refreshSession).not.toHaveBeenCalled(); }); + + it('hides manual fallback on production-style auth host config', async () => { + vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); + window.history.pushState({}, '', '/login'); + + render(); + + await waitFor(() => expect(screen.getByText('Sign in required')).toBeInTheDocument()); + expect(screen.queryByText('Manual Session Login (Dev/Test)')).not.toBeInTheDocument(); + expect(screen.getByText('Manual session login is disabled in this environment. Use the Google sign-in path above.')).toBeInTheDocument(); + }); + + it('shows correlation id when callback returns bff auth error context', async () => { + vi.mocked(getSessionMe).mockResolvedValue({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 }); + window.history.pushState({}, '', '/callback?authError=oidc_exchange_failed&correlationId=corr-123'); + + render(); + + await waitFor(() => expect(screen.getByText('The sign-in code could not be exchanged. Please retry the login flow.')).toBeInTheDocument()); + expect(screen.getByText('Correlation ID: corr-123')).toBeInTheDocument(); + }); }); diff --git a/src/App.tsx b/src/App.tsx index 2646f1e..30ddb99 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ import { import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { loadDashboard } from './api/dashboardApi'; -import { getThalosDefaultReturnUrl } from './api/client'; +import { getThalosDefaultReturnUrl, isThalosManualLoginEnabled } from './api/client'; import type { IdentityProvider } from './api/sessionApi'; import { parseOidcCallbackState } from './auth/callbackState'; import { buildGoogleOidcStartUrl } from './auth/oidcLogin'; @@ -99,6 +99,7 @@ function ThalosShell() { function LoginRoute() { const session = useSessionContext(); + const manualLoginEnabled = useMemo(() => isThalosManualLoginEnabled(), []); const [subjectId, setSubjectId] = useState('demo-user'); const [tenantId, setTenantId] = useState('demo-tenant'); const [provider, setProvider] = useState(0); @@ -129,7 +130,7 @@ function LoginRoute() { Thalos Authentication - Use the central OIDC journey for browser login. Manual form login remains available for local simulated-provider testing. + Use the central OIDC journey for browser login. Manual form login is only exposed for local and explicitly enabled dev/test environments. @@ -145,36 +146,49 @@ function LoginRoute() { /> - - -
void onSubmit()}> - - setSubjectId(event.target.value)} /> - - - setTenantId(event.target.value)} /> - - - setExternalToken(event.target.value)} /> - - -
- {error && } -
-
+ {manualLoginEnabled ? ( + + + +
void onSubmit()}> + + setSubjectId(event.target.value)} /> + + + setTenantId(event.target.value)} /> + + + setExternalToken(event.target.value)} /> + + +
+ {error && } +
+
+ ) : ( + + )}
); } @@ -237,6 +251,9 @@ function CallbackRoute() { return ( + {callback.kind === 'error' && callback.correlationId ? ( + Correlation ID: {callback.correlationId} + ) : null} diff --git a/src/api/client.test.ts b/src/api/client.test.ts index e1cfe56..2f952ba 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, getThalosDefaultReturnUrl, getThalosDefaultTenantId } from './client'; +import { + getApiBaseUrl, + getThalosAuthBaseUrl, + getThalosDefaultReturnUrl, + getThalosDefaultTenantId, + isThalosManualLoginEnabled +} from './client'; describe('client runtime base URLs', () => { afterEach(() => { @@ -62,4 +68,18 @@ describe('client runtime base URLs', () => { it('falls back to demo tenant when no tenant is configured', () => { expect(getThalosDefaultTenantId()).toBe('demo-tenant'); }); + + it('uses runtime-config manual-login toggle when present', () => { + window.__APP_CONFIG__ = { + THALOS_ENABLE_MANUAL_LOGIN: 'true' + }; + + expect(isThalosManualLoginEnabled()).toBe(true); + }); + + it('falls back to env manual-login toggle when runtime config is missing', () => { + vi.stubEnv('VITE_THALOS_ENABLE_MANUAL_LOGIN', 'false'); + + expect(isThalosManualLoginEnabled()).toBe(false); + }); }); diff --git a/src/api/client.ts b/src/api/client.ts index 2d0540a..a12a290 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,6 +5,7 @@ declare global { THALOS_AUTH_BASE_URL?: string; THALOS_DEFAULT_RETURN_URL?: string; THALOS_DEFAULT_TENANT_ID?: string; + THALOS_ENABLE_MANUAL_LOGIN?: string; }; } } @@ -67,6 +68,20 @@ export function getThalosDefaultTenantId(): string { return import.meta.env.VITE_THALOS_DEFAULT_TENANT_ID ?? 'demo-tenant'; } +export function isThalosManualLoginEnabled(): boolean { + const runtimeValue = parseBoolean(window.__APP_CONFIG__?.THALOS_ENABLE_MANUAL_LOGIN); + if (runtimeValue !== undefined) { + return runtimeValue; + } + + const envValue = parseBoolean(import.meta.env.VITE_THALOS_ENABLE_MANUAL_LOGIN); + if (envValue !== undefined) { + return envValue; + } + + return window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; +} + export async function getJson(path: string, baseUrl = getApiBaseUrl()): Promise { return requestJson(baseUrl, path, { method: 'GET' }); } @@ -142,3 +157,24 @@ function createCorrelationId(): string { return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`; } + +function parseBoolean(value: string | boolean | undefined): boolean | undefined { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + + if (normalized === 'false') { + return false; + } + + return undefined; +} diff --git a/src/auth/callbackState.test.ts b/src/auth/callbackState.test.ts index 4aff312..de31179 100644 --- a/src/auth/callbackState.test.ts +++ b/src/auth/callbackState.test.ts @@ -16,6 +16,16 @@ describe('callback state parser', () => { } }); + it('returns auth error state when bff redirects with authError query values', () => { + const result = parseOidcCallbackState('?authError=oidc_exchange_failed&correlationId=corr-123'); + + expect(result.kind).toBe('error'); + if (result.kind === 'error') { + expect(result.message).toBe('The sign-in code could not be exchanged. Please retry the login flow.'); + expect(result.correlationId).toBe('corr-123'); + } + }); + it('returns success state with sanitized same-origin return path', () => { const result = parseOidcCallbackState(`?returnUrl=${encodeURIComponent(`${window.location.origin}/session?tab=profile`)}`); diff --git a/src/auth/callbackState.ts b/src/auth/callbackState.ts index dcf8852..7f0dcac 100644 --- a/src/auth/callbackState.ts +++ b/src/auth/callbackState.ts @@ -8,6 +8,7 @@ type OidcCallbackSuccess = { type OidcCallbackError = { kind: 'error'; message: string; + correlationId?: string; }; export type OidcCallbackState = OidcCallbackSuccess | OidcCallbackError; @@ -16,11 +17,22 @@ export function parseOidcCallbackState(search: string): OidcCallbackState { const query = new URLSearchParams(search); const error = query.get('error'); const errorDescription = query.get('error_description'); + const authError = query.get('authError'); + const correlationId = query.get('correlationId') ?? undefined; if (error && error.length > 0) { return { kind: 'error', - message: errorDescription && errorDescription.length > 0 ? errorDescription : `OIDC callback failed: ${error}` + message: errorDescription && errorDescription.length > 0 ? errorDescription : `OIDC callback failed: ${error}`, + correlationId + }; + } + + if (authError && authError.length > 0) { + return { + kind: 'error', + message: getAuthErrorMessage(authError), + correlationId }; } @@ -31,6 +43,23 @@ export function parseOidcCallbackState(search: string): OidcCallbackState { }; } +function getAuthErrorMessage(authError: string): string { + switch (authError) { + case 'oidc_provider_error': + return 'The identity provider rejected the sign-in request.'; + case 'oidc_state_invalid': + return 'The sign-in session is no longer valid. Please start again.'; + case 'oidc_code_missing': + return 'The identity provider did not return an authorization code.'; + case 'oidc_exchange_failed': + return 'The sign-in code could not be exchanged. Please retry the login flow.'; + case 'session_login_failed': + return 'The session could not be created after authentication.'; + default: + return `Authentication failed: ${authError}`; + } +} + function sanitizeReturnPath(rawReturnUrl: string | null): string { if (!rawReturnUrl || rawReturnUrl.length === 0) { return '/session'; diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 363bae3..3b825a8 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.test.tsx","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/api/dashboardApi.test.ts","./src/api/dashboardApi.ts","./src/api/sessionApi.ts","./src/auth/callbackState.test.ts","./src/auth/callbackState.ts","./src/auth/oidcLogin.test.ts","./src/auth/oidcLogin.ts","./src/auth/sessionContext.tsx","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file