feat(thalos-web): harden production auth experience
This commit is contained in:
parent
24bcf71048
commit
39f5af23e4
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
};
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
81
src/App.tsx
81
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<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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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`)}`);
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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"}
|
||||
Loading…
Reference in New Issue
Block a user