diff --git a/docs/api/rest.md b/docs/api/rest.md index 2c86783..ac792c0 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -23,6 +23,8 @@ Chat upload limits: ### `GET /v1/auth/session` - Response: `{ "authenticated": true, "mode": "open" | "token" }` +- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`). +- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass. ## Models diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 3d295d9..9fd5795 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -15,6 +15,7 @@ Transport: Authentication: - Same as REST endpoints (`Authorization: Bearer ` when token mode is enabled) +- The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged. ## Chat Completion Request Body diff --git a/web/src/components/auth/auth-screen.tsx b/web/src/components/auth/auth-screen.tsx index 0a0fa53..d3300dd 100644 --- a/web/src/components/auth/auth-screen.tsx +++ b/web/src/components/auth/auth-screen.tsx @@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut

Sign in to Sybil

-

Use your backend admin token.

+

Enter your admin token.

@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut className="space-y-3" onSubmit={(event) => { event.preventDefault(); - void onSignIn(authTokenInput.trim() || null); + const token = authTokenInput.trim(); + if (token) void onSignIn(token); }} > setAuthTokenInput(event.currentTarget.value)} disabled={isSigningIn} className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50" /> - - - {authError ?

{authError}

: null} -

If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.

+ {authError ?

{authError}

: null} ); diff --git a/web/src/hooks/use-session-auth.ts b/web/src/hooks/use-session-auth.ts index 022605c..ac05a18 100644 --- a/web/src/hooks/use-session-auth.ts +++ b/web/src/hooks/use-session-auth.ts @@ -18,8 +18,11 @@ function persistToken(token: string | null) { } function normalizeAuthError(message: string) { - if (message.includes("missing bearer token") || message.includes("invalid bearer token")) { - return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env."; + if (message.includes("invalid bearer token")) { + return "Incorrect token. Please try again."; + } + if (message.includes("missing bearer token")) { + return "Enter your admin token."; } return message; } @@ -43,10 +46,10 @@ export function useSessionAuth() { persistToken(tokenCandidate); }; - const handleAuthFailure = (message: string) => { + const handleAuthFailure = (message: string | null) => { setIsAuthenticated(false); setAuthMode(null); - setAuthError(normalizeAuthError(message)); + setAuthError(message ? normalizeAuthError(message) : null); setAuthToken(null); persistToken(null); }; @@ -81,7 +84,8 @@ export function useSessionAuth() { await completeSessionCheck(token); } catch (err) { const message = err instanceof Error ? err.message : String(err); - handleAuthFailure(message); + // A first visit without a token is an invitation to sign in, not a failed attempt. + handleAuthFailure(!token && message.includes("missing bearer token") ? null : message); } finally { setIsCheckingSession(false); } diff --git a/web/tests/pwa.browser.mjs b/web/tests/pwa.browser.mjs index 257e7e8..016bdc8 100644 --- a/web/tests/pwa.browser.mjs +++ b/web/tests/pwa.browser.mjs @@ -12,11 +12,29 @@ const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-c const root = fileURLToPath(new URL('../dist/', import.meta.url)); await readFile(resolve(root, 'sw.js')); const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.ttf': 'font/ttf' }; +let sessionMode = 'token'; const server = createServer(async (request, response) => { const path = new URL(request.url, 'http://localhost').pathname; if (path.startsWith('/api/')) { - response.writeHead(401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - response.end(JSON.stringify({ error: true, message: 'missing bearer token', statusCode: 401 })); + let status = 200; + let body; + if (sessionMode === 'unavailable') { + status = 503; + body = { message: 'Service temporarily unavailable' }; + } else if (sessionMode !== 'open' && request.headers.authorization !== 'Bearer local-login-fixture') { + status = request.headers.authorization ? 403 : 401; + body = { message: status === 401 ? 'missing bearer token' : 'invalid bearer token' }; + } else { + body = { + '/api/v1/auth/session': { authenticated: true, mode: sessionMode }, + '/api/v1/workspace-items': { items: [] }, + '/api/v1/models': { providers: {} }, + '/api/v1/chat-tools': { tools: [] }, + '/api/v1/active-runs': { chats: [], searches: [] }, + }[path] ?? {}; + } + response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify(body)); return; } const file = resolve(root, '.' + (path === '/' ? '/index.html' : path)); @@ -40,14 +58,43 @@ try { // Activation must settle before a worker-controlled navigation can finish. await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 }); await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 }); - await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture'); - assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').inputValue(), 'local-login-fixture'); + assert.equal(await page.getByRole('alert').count(), 0); + assert.equal(await page.getByRole('button', { name: 'Continue without token' }).count(), 0); + assert.equal(await page.getByText(/If.*ADMIN_TOKEN/).count(), 0); + assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isDisabled(), true); + await page.getByPlaceholder('ADMIN_TOKEN').fill('incorrect-login-fixture'); assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isEnabled(), true); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await page.getByRole('alert').waitFor(); + assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.'); await page.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 }); await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 }); + assert.equal(await page.getByRole('alert').count(), 0); assert.equal(await page.evaluate(() => navigator.serviceWorker.controller?.state), 'activated'); + + // Invalid saved credentials are a real failure, unlike a first visit without credentials. + await page.evaluate(() => localStorage.setItem('sybil_admin_token', 'outdated-login-fixture')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.getByRole('alert').waitFor(); + assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.'); + await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture'); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await page.locator('.workspace-shell').waitFor(); + assert.equal(await page.evaluate(() => localStorage.getItem('sybil_admin_token')), 'local-login-fixture'); + + await page.evaluate(() => localStorage.removeItem('sybil_admin_token')); + sessionMode = 'open'; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.locator('.workspace-shell').waitFor(); + assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').count(), 0); + + // Suppress only the expected missing-token response, never connectivity/server failures. + sessionMode = 'unavailable'; + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.getByRole('alert').waitFor(); + assert.equal(await page.getByRole('alert').textContent(), 'Service temporarily unavailable'); assert.deepEqual(errors, []); - console.log('PASS first-visit service-worker activation, stable sign-in form, and controlled reload'); + console.log('PASS service-worker activation/reload, neutral first sign-in, rejected tokens, valid sign-in, automatic open-mode entry, and visible server failures'); } finally { await browser.close(); server.closeAllConnections();