feat(thalos-web): harden production auth experience

This commit is contained in:
José René White Enciso 2026-03-31 16:05:38 -06:00
parent 24bcf71048
commit 39f5af23e4
12 changed files with 180 additions and 39 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"
};

View File

@ -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(<App />);
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(<App />);
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();
});
});

View File

@ -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<IdentityProvider>(0);
@ -129,7 +130,7 @@ function LoginRoute() {
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Typography.Title level={3}>Thalos Authentication</Typography.Title>
<Typography.Paragraph type="secondary">
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.
</Typography.Paragraph>
<Card>
@ -145,36 +146,49 @@ function LoginRoute() {
/>
</Card>
<Card title="Manual Session Login (Dev/Test)">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Form layout="vertical" onFinish={() => void onSubmit()}>
<Form.Item label="Subject Id" required>
<Input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
</Form.Item>
<Form.Item label="Tenant Id" required>
<Input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
</Form.Item>
<Form.Item label="Provider" required>
<Select
value={provider}
onChange={(value) => setProvider(value as IdentityProvider)}
options={[
{ value: 0, label: 'Internal JWT' },
{ value: 1, label: 'Azure AD (simulated)' },
{ value: 2, label: 'Google (simulated)' }
]}
/>
</Form.Item>
<Form.Item label="External Token (optional)">
<Input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
Sign In
</Button>
</Form>
{error && <Alert type="error" showIcon message={error} />}
</Space>
</Card>
{manualLoginEnabled ? (
<Card title="Manual Session Login (Dev/Test)">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="This fallback is intended for local development, simulated-provider testing, and operator troubleshooting only."
/>
<Form layout="vertical" onFinish={() => void onSubmit()}>
<Form.Item label="Subject Id" required>
<Input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
</Form.Item>
<Form.Item label="Tenant Id" required>
<Input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
</Form.Item>
<Form.Item label="Provider" required>
<Select
value={provider}
onChange={(value) => setProvider(value as IdentityProvider)}
options={[
{ value: 0, label: 'Internal JWT' },
{ value: 1, label: 'Azure AD (simulated)' },
{ value: 2, label: 'Google (simulated)' }
]}
/>
</Form.Item>
<Form.Item label="External Token (optional)">
<Input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={submitting}>
Sign In
</Button>
</Form>
{error && <Alert type="error" showIcon message={error} />}
</Space>
</Card>
) : (
<Alert
type="info"
showIcon
message="Manual session login is disabled in this environment. Use the Google sign-in path above."
/>
)}
</Space>
);
}
@ -237,6 +251,9 @@ function CallbackRoute() {
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Alert type="error" showIcon message={message ?? 'Callback failed.'} />
{callback.kind === 'error' && callback.correlationId ? (
<Typography.Text type="secondary">Correlation ID: {callback.correlationId}</Typography.Text>
) : null}
<Button type="primary" onClick={() => navigate('/login', { replace: true })}>
Back to login
</Button>

View File

@ -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);
});
});

View File

@ -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<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> {
return requestJson<T>(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;
}

View File

@ -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`)}`);

View File

@ -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';

View File

@ -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"}
{"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"}