feat(thalos-web): add central OIDC login shell
This commit is contained in:
parent
d867b966da
commit
1a5b3dec66
@ -4,6 +4,9 @@ set -eu
|
|||||||
cat > /usr/share/nginx/html/runtime-config.js <<EOT
|
cat > /usr/share/nginx/html/runtime-config.js <<EOT
|
||||||
window.__APP_CONFIG__ = {
|
window.__APP_CONFIG__ = {
|
||||||
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}",
|
API_BASE_URL: "${API_BASE_URL:-http://localhost:8080}",
|
||||||
THALOS_AUTH_BASE_URL: "${THALOS_AUTH_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:-https://auth.dream-views.com/}",
|
||||||
|
THALOS_ALLOWED_RETURN_HOSTS: "${THALOS_ALLOWED_RETURN_HOSTS:-auth.dream-views.com,furniture-display-demo.dream-views.com,furniture-admin-demo.dream-views.com,kitchen-ops-demo.dream-views.com,waiter-floor-demo.dream-views.com,customer-orders-demo.dream-views.com,pos-transactions-demo.dream-views.com,restaurant-admin-demo.dream-views.com,localhost}",
|
||||||
|
THALOS_DEFAULT_TENANT_ID: "${THALOS_DEFAULT_TENANT_ID:-demo-tenant}"
|
||||||
};
|
};
|
||||||
EOT
|
EOT
|
||||||
|
|||||||
@ -4,11 +4,15 @@
|
|||||||
- Frontend data access flows through `src/api/*` adapter modules.
|
- Frontend data access flows through `src/api/*` adapter modules.
|
||||||
- The UI does not access DAL or internal services directly.
|
- The UI does not access DAL or internal services directly.
|
||||||
- Route shell and protected sections are session-aware via Thalos session endpoints.
|
- Route shell and protected sections are session-aware via Thalos session endpoints.
|
||||||
|
- The app is the central login shell for `auth.dream-views.com` and only redirects to allowlisted return hosts.
|
||||||
|
|
||||||
## Runtime Base URLs
|
## Runtime Base URLs
|
||||||
|
|
||||||
- `API_BASE_URL` for business BFF calls.
|
- `API_BASE_URL` for business BFF calls.
|
||||||
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
|
- `THALOS_AUTH_BASE_URL` for session login/refresh/logout/me.
|
||||||
|
- `THALOS_DEFAULT_RETURN_URL` as the fallback post-login destination.
|
||||||
|
- `THALOS_ALLOWED_RETURN_HOSTS` as the return-url allowlist.
|
||||||
|
- `THALOS_DEFAULT_TENANT_ID` as login context default.
|
||||||
|
|
||||||
## Protected Workflow Endpoints
|
## Protected Workflow Endpoints
|
||||||
|
|
||||||
@ -19,7 +23,8 @@
|
|||||||
|
|
||||||
## UI Workflow Coverage
|
## UI Workflow Coverage
|
||||||
|
|
||||||
- Session login
|
- Google OIDC start-link generation with `returnUrl` and `tenantId`
|
||||||
- Session me/profile inspection
|
- Session me/profile inspection
|
||||||
- Session refresh
|
- Session refresh
|
||||||
- Session logout
|
- Session logout
|
||||||
|
- Safe fallback when request return host is not allowlisted
|
||||||
|
|||||||
@ -11,6 +11,10 @@ docker build -t agilewebs/thalos-web:dev .
|
|||||||
```bash
|
```bash
|
||||||
docker run --rm -p 8080:8080 \
|
docker run --rm -p 8080:8080 \
|
||||||
-e API_BASE_URL=http://host.docker.internal:8080 \
|
-e API_BASE_URL=http://host.docker.internal:8080 \
|
||||||
|
-e THALOS_AUTH_BASE_URL=http://host.docker.internal:20080 \
|
||||||
|
-e THALOS_DEFAULT_RETURN_URL=https://auth.dream-views.com/ \
|
||||||
|
-e THALOS_ALLOWED_RETURN_HOSTS=auth.dream-views.com,localhost \
|
||||||
|
-e THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||||
--name thalos-web agilewebs/thalos-web:dev
|
--name thalos-web agilewebs/thalos-web:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -19,6 +23,7 @@ docker run --rm -p 8080:8080 \
|
|||||||
- Build-time fallback: `VITE_API_BASE_URL`
|
- Build-time fallback: `VITE_API_BASE_URL`
|
||||||
- Runtime override: container env `API_BASE_URL`
|
- Runtime override: container env `API_BASE_URL`
|
||||||
- Runtime file generated at startup: `/runtime-config.js`
|
- Runtime file generated at startup: `/runtime-config.js`
|
||||||
|
- OIDC login context is configured via runtime env, not build-time hardcoding.
|
||||||
|
|
||||||
## Health Check
|
## Health Check
|
||||||
|
|
||||||
|
|||||||
@ -11,12 +11,16 @@ npm install
|
|||||||
```bash
|
```bash
|
||||||
VITE_API_BASE_URL=http://localhost:8080 \
|
VITE_API_BASE_URL=http://localhost:8080 \
|
||||||
VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \
|
VITE_THALOS_AUTH_BASE_URL=http://localhost:20080 \
|
||||||
|
VITE_THALOS_DEFAULT_RETURN_URL=https://auth.dream-views.com/ \
|
||||||
|
VITE_THALOS_ALLOWED_RETURN_HOSTS=auth.dream-views.com,localhost \
|
||||||
|
VITE_THALOS_DEFAULT_TENANT_ID=demo-tenant \
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Auth Model
|
## Auth Model
|
||||||
|
|
||||||
- Login is executed against Thalos session endpoints.
|
- Login is executed against Thalos session endpoints.
|
||||||
|
- Google OIDC login start URL is generated from query params with return-url allowlist validation.
|
||||||
- Business calls are gated behind session checks.
|
- Business calls are gated behind session checks.
|
||||||
- Session cookies are sent with `credentials: include`.
|
- Session cookies are sent with `credentials: include`.
|
||||||
|
|
||||||
|
|||||||
@ -16,7 +16,8 @@ npm run test:ci
|
|||||||
|
|
||||||
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
- `src/api/client.test.ts`: runtime-config precedence and fallback behavior.
|
||||||
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
|
- `src/api/dashboardApi.test.ts`: endpoint path/query composition and payload mapping.
|
||||||
- `src/App.test.tsx`: protected-route render and workflow trigger behavior.
|
- `src/auth/oidcLogin.test.ts`: return-url sanitization and allowlist behavior.
|
||||||
|
- `src/App.test.tsx`: central login render, safe return-url fallback, and authenticated session workflow.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
window.__APP_CONFIG__ = {
|
window.__APP_CONFIG__ = {
|
||||||
API_BASE_URL: "http://localhost:8080",
|
API_BASE_URL: "http://localhost:8080",
|
||||||
THALOS_AUTH_BASE_URL: "http://localhost:20080"
|
THALOS_AUTH_BASE_URL: "http://localhost:20080",
|
||||||
|
THALOS_DEFAULT_RETURN_URL: "https://auth.dream-views.com/",
|
||||||
|
THALOS_ALLOWED_RETURN_HOSTS: "auth.dream-views.com,localhost",
|
||||||
|
THALOS_DEFAULT_TENANT_ID: "demo-tenant"
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,35 +13,56 @@ vi.mock('./api/dashboardApi', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
import { getSessionMe, loginSession } from './api/sessionApi';
|
import { getSessionMe } from './api/sessionApi';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
describe('Thalos App', () => {
|
describe('Thalos App', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(loadDashboard).mockReset();
|
vi.mocked(loadDashboard).mockReset();
|
||||||
vi.mocked(getSessionMe).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 () => {
|
it('renders central login link with safe return URL context', async () => {
|
||||||
vi.mocked(getSessionMe)
|
window.history.pushState(
|
||||||
.mockResolvedValueOnce({ isAuthenticated: false, subjectId: '', tenantId: '', provider: 0 })
|
{},
|
||||||
.mockResolvedValueOnce({ isAuthenticated: true, subjectId: 'demo-user', tenantId: 'demo-tenant', provider: 0 });
|
'',
|
||||||
|
'/?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(<App />);
|
render(<App />);
|
||||||
|
|
||||||
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' } });
|
const link = screen.getByRole('link') as HTMLAnchorElement;
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Sign In' }));
|
expect(link.href).toContain('/api/identity/oidc/google/start');
|
||||||
|
expect(link.href).toContain('tenantId=tenant-a');
|
||||||
await waitFor(() => {
|
expect(link.href).toContain(
|
||||||
expect(loginSession).toHaveBeenCalledTimes(1);
|
encodeURIComponent('https://furniture-display-demo.dream-views.com/dashboard')
|
||||||
expect(screen.getByText(/subject: demo-user/i)).toBeInTheDocument();
|
);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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(<App />);
|
||||||
|
|
||||||
|
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({
|
vi.mocked(getSessionMe).mockResolvedValue({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
subjectId: 'demo-user',
|
subjectId: 'demo-user',
|
||||||
|
|||||||
98
src/App.tsx
98
src/App.tsx
@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { loadDashboard } from './api/dashboardApi';
|
import { loadDashboard } from './api/dashboardApi';
|
||||||
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
import { SessionProvider, useSessionContext } from './auth/sessionContext';
|
||||||
|
import { resolveOidcLoginContext } from './auth/oidcLogin';
|
||||||
import type { IdentityProvider } from './api/sessionApi';
|
import type { IdentityProvider } from './api/sessionApi';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -15,6 +16,7 @@ type RouteKey = 'overview' | 'session';
|
|||||||
|
|
||||||
function ThalosShell() {
|
function ThalosShell() {
|
||||||
const session = useSessionContext();
|
const session = useSessionContext();
|
||||||
|
const oidcLogin = useMemo(() => resolveOidcLoginContext(window.location.search), []);
|
||||||
const [route, setRoute] = useState<RouteKey>('overview');
|
const [route, setRoute] = useState<RouteKey>('overview');
|
||||||
const [snapshot, setSnapshot] = useState<unknown>(null);
|
const [snapshot, setSnapshot] = useState<unknown>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -46,9 +48,17 @@ function ThalosShell() {
|
|||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<h1>Thalos Web</h1>
|
<h1>Thalos Web</h1>
|
||||||
<p className="muted">Login against Thalos session endpoints to access protected routes.</p>
|
<p className="muted">Central login entrypoint for `auth.dream-views.com`.</p>
|
||||||
|
<p className="muted">After successful authentication, you will return to the requested allowed destination.</p>
|
||||||
{session.error && <div className="alert">{session.error}</div>}
|
{session.error && <div className="alert">{session.error}</div>}
|
||||||
<LoginCard />
|
<section className="card col">
|
||||||
|
<strong>Login Context</strong>
|
||||||
|
<span className="badge">tenant: {oidcLogin.tenantId}</span>
|
||||||
|
<span className="badge">return: {oidcLogin.returnUrl}</span>
|
||||||
|
<a href={oidcLogin.startUrl}>
|
||||||
|
<button type="button">Continue with Google</button>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -56,16 +66,21 @@ function ThalosShell() {
|
|||||||
return (
|
return (
|
||||||
<main className="app">
|
<main className="app">
|
||||||
<h1>Thalos Web</h1>
|
<h1>Thalos Web</h1>
|
||||||
<p className="muted">Session management MVP for login, me, refresh, and logout.</p>
|
<p className="muted">Central session management shell for auth callback and profile checks.</p>
|
||||||
|
|
||||||
<section className="card row">
|
<section className="card row">
|
||||||
<span className="badge">subject: {session.profile.subjectId}</span>
|
<span className="badge">subject: {session.profile.subjectId}</span>
|
||||||
<span className="badge">tenant: {session.profile.tenantId}</span>
|
<span className="badge">tenant: {session.profile.tenantId}</span>
|
||||||
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
<span className="badge">provider: {providerLabel(session.profile.provider)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<button type="button" className="secondary" onClick={() => void session.refresh()}>
|
<button type="button" onClick={() => void session.refresh()}>
|
||||||
Refresh Session
|
Refresh Session
|
||||||
</button>
|
</button>
|
||||||
|
<a href={oidcLogin.returnUrl}>
|
||||||
|
<button type="button" className="secondary">
|
||||||
|
Continue to App
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
<button type="button" className="warn" onClick={() => void session.logout()}>
|
<button type="button" className="warn" onClick={() => void session.logout()}>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
@ -84,7 +99,7 @@ function ThalosShell() {
|
|||||||
className={route === 'session' ? 'active' : undefined}
|
className={route === 'session' ? 'active' : undefined}
|
||||||
onClick={() => setRoute('session')}
|
onClick={() => setRoute('session')}
|
||||||
>
|
>
|
||||||
Session Actions
|
Session Routing
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -104,74 +119,17 @@ function ThalosShell() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{route === 'session' && <LoginCard />}
|
{route === 'session' && (
|
||||||
|
<section className="card col">
|
||||||
|
<strong>Central Redirect Target</strong>
|
||||||
|
<span className="badge">{oidcLogin.returnUrl}</span>
|
||||||
|
<p className="muted">Use this shell to confirm session state before returning to the requested app.</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginCard() {
|
|
||||||
const session = useSessionContext();
|
|
||||||
const [subjectId, setSubjectId] = useState('demo-user');
|
|
||||||
const [tenantId, setTenantId] = useState('demo-tenant');
|
|
||||||
const [provider, setProvider] = useState<IdentityProvider>(0);
|
|
||||||
const [externalToken, setExternalToken] = useState('');
|
|
||||||
const [error, setError] = useState<string | null>(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 (
|
|
||||||
<section className="card col">
|
|
||||||
<div className="grid">
|
|
||||||
<label className="col">
|
|
||||||
Subject Id
|
|
||||||
<input value={subjectId} onChange={(event) => setSubjectId(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="col">
|
|
||||||
Tenant Id
|
|
||||||
<input value={tenantId} onChange={(event) => setTenantId(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label className="col">
|
|
||||||
Provider
|
|
||||||
<select
|
|
||||||
value={String(provider)}
|
|
||||||
onChange={(event) => setProvider(Number(event.target.value) as IdentityProvider)}
|
|
||||||
>
|
|
||||||
<option value="0">Internal JWT</option>
|
|
||||||
<option value="1">Azure AD (simulated)</option>
|
|
||||||
<option value="2">Google (simulated)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label className="col">
|
|
||||||
External Token (optional for simulated external providers)
|
|
||||||
<input value={externalToken} onChange={(event) => setExternalToken(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="row">
|
|
||||||
<button type="button" onClick={() => void onSubmit()} disabled={submitting}>
|
|
||||||
{submitting ? 'Signing In...' : 'Sign In'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <div className="alert">{error}</div>}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerLabel(provider: IdentityProvider): string {
|
function providerLabel(provider: IdentityProvider): string {
|
||||||
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
if (provider === 0 || provider === '0' || provider === 'InternalJwt') {
|
||||||
return 'Internal JWT';
|
return 'Internal JWT';
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('client runtime base URLs', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -38,4 +44,25 @@ describe('client runtime base URLs', () => {
|
|||||||
it('falls back to localhost default when both runtime and env are missing', () => {
|
it('falls back to localhost default when both runtime and env are missing', () => {
|
||||||
expect(getApiBaseUrl()).toBe('http://localhost:8080');
|
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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,9 @@ declare global {
|
|||||||
__APP_CONFIG__?: {
|
__APP_CONFIG__?: {
|
||||||
API_BASE_URL?: string;
|
API_BASE_URL?: string;
|
||||||
THALOS_AUTH_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();
|
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<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> {
|
export async function getJson<T>(path: string, baseUrl = getApiBaseUrl()): Promise<T> {
|
||||||
return requestJson<T>(baseUrl, path, { method: 'GET' });
|
return requestJson<T>(baseUrl, path, { method: 'GET' });
|
||||||
}
|
}
|
||||||
@ -122,3 +157,10 @@ function createCorrelationId(): string {
|
|||||||
|
|
||||||
return `corr-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
34
src/auth/oidcLogin.test.ts
Normal file
34
src/auth/oidcLogin.test.ts
Normal file
@ -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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/auth/oidcLogin.ts
Normal file
59
src/auth/oidcLogin.ts
Normal file
@ -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()}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user