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() {
/>
-
-
-
- setSubjectId(event.target.value)} />
-
-
- setTenantId(event.target.value)} />
-
-
-
-
- setExternalToken(event.target.value)} />
-
-
-
- {error && }
-
-
+ {manualLoginEnabled ? (
+
+
+
+
+ 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