fix(web): simplify first-time token sign-in
This commit is contained in:
@@ -23,6 +23,8 @@ Chat upload limits:
|
|||||||
|
|
||||||
### `GET /v1/auth/session`
|
### `GET /v1/auth/session`
|
||||||
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
|
- 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
|
## Models
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Transport:
|
|||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
- Same as REST endpoints (`Authorization: Bearer <token>` 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
|
## Chat Completion Request Body
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
<p className="mt-1 text-sm text-muted-foreground">Enter your admin token.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
|||||||
className="space-y-3"
|
className="space-y-3"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void onSignIn(authTokenInput.trim() || null);
|
const token = authTokenInput.trim();
|
||||||
|
if (token) void onSignIn(token);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
|
aria-label="Admin token"
|
||||||
placeholder="ADMIN_TOKEN"
|
placeholder="ADMIN_TOKEN"
|
||||||
|
required
|
||||||
value={authTokenInput}
|
value={authTokenInput}
|
||||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||||
disabled={isSigningIn}
|
disabled={isSigningIn}
|
||||||
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
||||||
/>
|
/>
|
||||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
<Button className="w-full" type="submit" disabled={isSigningIn || !authTokenInput.trim()}>
|
||||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
|
|
||||||
Continue without token
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
{authError ? <p role="alert" className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||||
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ function persistToken(token: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAuthError(message: string) {
|
function normalizeAuthError(message: string) {
|
||||||
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
|
if (message.includes("invalid bearer token")) {
|
||||||
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
|
return "Incorrect token. Please try again.";
|
||||||
|
}
|
||||||
|
if (message.includes("missing bearer token")) {
|
||||||
|
return "Enter your admin token.";
|
||||||
}
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -43,10 +46,10 @@ export function useSessionAuth() {
|
|||||||
persistToken(tokenCandidate);
|
persistToken(tokenCandidate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuthFailure = (message: string) => {
|
const handleAuthFailure = (message: string | null) => {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setAuthMode(null);
|
setAuthMode(null);
|
||||||
setAuthError(normalizeAuthError(message));
|
setAuthError(message ? normalizeAuthError(message) : null);
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
persistToken(null);
|
persistToken(null);
|
||||||
};
|
};
|
||||||
@@ -81,7 +84,8 @@ export function useSessionAuth() {
|
|||||||
await completeSessionCheck(token);
|
await completeSessionCheck(token);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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 {
|
} finally {
|
||||||
setIsCheckingSession(false);
|
setIsCheckingSession(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,29 @@ const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-c
|
|||||||
const root = fileURLToPath(new URL('../dist/', import.meta.url));
|
const root = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||||
await readFile(resolve(root, 'sw.js'));
|
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' };
|
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 server = createServer(async (request, response) => {
|
||||||
const path = new URL(request.url, 'http://localhost').pathname;
|
const path = new URL(request.url, 'http://localhost').pathname;
|
||||||
if (path.startsWith('/api/')) {
|
if (path.startsWith('/api/')) {
|
||||||
response.writeHead(401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
let status = 200;
|
||||||
response.end(JSON.stringify({ error: true, message: 'missing bearer token', statusCode: 401 }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path));
|
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path));
|
||||||
@@ -40,14 +58,43 @@ try {
|
|||||||
// Activation must settle before a worker-controlled navigation can finish.
|
// Activation must settle before a worker-controlled navigation can finish.
|
||||||
await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 });
|
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').waitFor({ timeout: 5_000 });
|
||||||
await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture');
|
assert.equal(await page.getByRole('alert').count(), 0);
|
||||||
assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').inputValue(), 'local-login-fixture');
|
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);
|
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.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 });
|
||||||
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_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');
|
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, []);
|
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 {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
server.closeAllConnections();
|
server.closeAllConnections();
|
||||||
|
|||||||
Reference in New Issue
Block a user