diff --git a/nanobot/webui/gateway_tokens.py b/nanobot/webui/gateway_tokens.py index 925ae81a..19f60694 100644 --- a/nanobot/webui/gateway_tokens.py +++ b/nanobot/webui/gateway_tokens.py @@ -42,12 +42,10 @@ class GatewayTokenStore: return False return True - def issue_token(self, ttl_s: int | float, *, api_token: bool = False) -> str: + def issue_token(self, ttl_s: int | float) -> str: token_value = f"nbwt_{secrets.token_urlsafe(32)}" expiry = time.monotonic() + float(ttl_s) self.issued_tokens[token_value] = expiry - if api_token: - self.api_tokens[token_value] = expiry return token_value def issue_api_token(self, ttl_s: int | float) -> str: diff --git a/tests/channels/test_websocket_http_routes.py b/tests/channels/test_websocket_http_routes.py index a0500468..b019f8de 100644 --- a/tests/channels/test_websocket_http_routes.py +++ b/tests/channels/test_websocket_http_routes.py @@ -660,7 +660,7 @@ async def test_nanobot_feature_remote_install_requires_opt_in( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_token(300, api_token=True) + token = channel.gateway.tokens.issue_api_token(300) path = "/api/settings/nanobot-features/enable?name=matrix" request = _FakeReq({"Authorization": f"Bearer {token}"}, path=path) @@ -707,7 +707,7 @@ async def test_nanobot_feature_local_install_allowed_by_default( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_token(300, api_token=True) + token = channel.gateway.tokens.issue_api_token(300) request = _FakeReq( {"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"}, path="/api/settings/nanobot-features/enable?name=matrix", @@ -743,7 +743,7 @@ async def test_nanobot_feature_loopback_reverse_proxy_install_requires_opt_in( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_token(300, api_token=True) + token = channel.gateway.tokens.issue_api_token(300) request = _FakeReq( { "Authorization": f"Bearer {token}", @@ -795,7 +795,7 @@ async def test_nanobot_feature_remote_enable_without_install_is_allowed( install_calls=install_calls, ) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_token(300, api_token=True) + token = channel.gateway.tokens.issue_api_token(300) request = _FakeReq( {"Authorization": f"Bearer {token}"}, path="/api/settings/nanobot-features/enable?name=matrix", @@ -829,7 +829,7 @@ async def test_nanobot_feature_remote_disable_does_not_need_install_policy( _stub_matrix_feature(monkeypatch, config_path, deps=["matrix-nio>=0.25.2"], installed=False) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port()) - token = channel.gateway.tokens.issue_token(300, api_token=True) + token = channel.gateway.tokens.issue_api_token(300) request = _FakeReq( {"Authorization": f"Bearer {token}"}, path="/api/settings/nanobot-features/disable?name=matrix", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 724a74ca..c8434f30 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -23,6 +23,7 @@ import { useSkills } from "@/hooks/useSkills"; import { ThemeProvider, useTheme } from "@/hooks/useTheme"; import { cn } from "@/lib/utils"; import { + BootstrapAuthRequiredError, clearSavedSecret, consumeUrlBootstrapSecret, deriveWsUrl, @@ -296,6 +297,12 @@ function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePa }; } +function isBootstrapAuthRequired(error: unknown): boolean { + if (error instanceof BootstrapAuthRequiredError) return true; + const msg = error instanceof Error ? error.message : String(error); + return msg.includes("HTTP 401") || msg.includes("HTTP 403"); +} + function HostChrome({ onToggleSidebar, onSidebarPreviewEnter, @@ -410,11 +417,13 @@ export default function App() { }); } catch (e) { if (cancelled) return; - const msg = (e as Error).message; - if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + if (isBootstrapAuthRequired(e)) { setState({ status: "auth", failed: !!secret }); } else { - setState({ status: "error", message: msg }); + setState({ + status: "error", + message: e instanceof Error ? e.message : String(e), + }); } } })(); @@ -432,8 +441,7 @@ export default function App() { try { await refreshReadyClient(client, state.runtimeSurface); } catch (e) { - const msg = (e as Error).message; - if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { + if (isBootstrapAuthRequired(e)) { setState({ status: "auth", failed: !!bootstrapSecretRef.current }); } } diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index 5a9053fb..b7fafef9 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -4,6 +4,13 @@ import { fetchWithTimeout } from "./http"; const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret"; const URL_SECRET_PARAM = "bootstrapSecret"; +export class BootstrapAuthRequiredError extends Error { + constructor(message = "bootstrap authentication required") { + super(message); + this.name = "BootstrapAuthRequiredError"; + } +} + /** Read a previously saved bootstrap secret from localStorage. */ export function loadSavedSecret(): string { if (typeof window === "undefined") return ""; @@ -74,6 +81,9 @@ export async function fetchBootstrap( headers, }, timeoutMs); if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new BootstrapAuthRequiredError(`bootstrap failed: HTTP ${res.status}`); + } throw new Error(`bootstrap failed: HTTP ${res.status}`); } const body = (await res.json()) as BootstrapResponse; @@ -81,7 +91,9 @@ export async function fetchBootstrap( throw new Error("bootstrap response missing token or ws_path"); } if (!body.api_token) { - throw new Error("bootstrap response missing api_token"); + throw new BootstrapAuthRequiredError( + "bootstrap authentication required: missing api_token", + ); } return body; } diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index f4940daa..4a38e8ba 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -175,6 +175,12 @@ vi.mock("@/hooks/useTheme", async () => { }); vi.mock("@/lib/bootstrap", () => ({ + BootstrapAuthRequiredError: class BootstrapAuthRequiredError extends Error { + constructor(message = "bootstrap authentication required") { + super(message); + this.name = "BootstrapAuthRequiredError"; + } + }, fetchBootstrap: vi.fn().mockResolvedValue({ token: "tok", api_token: "api-tok", @@ -217,7 +223,11 @@ vi.mock("@/lib/nanobot-client", () => { return { NanobotClient: MockClient }; }); -import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { + BootstrapAuthRequiredError, + deriveWsUrl, + fetchBootstrap, +} from "@/lib/bootstrap"; import App from "@/App"; describe("App layout", () => { @@ -271,6 +281,20 @@ describe("App layout", () => { expect(connectSpy).not.toHaveBeenCalled(); }); + it("shows the auth form when bootstrap does not issue an API token", async () => { + vi.mocked(fetchBootstrap).mockRejectedValueOnce( + new BootstrapAuthRequiredError( + "bootstrap authentication required: missing api_token", + ), + ); + + render(); + + expect(await screen.findByText("Authentication required")).toBeInTheDocument(); + expect(screen.queryByText("Invalid password. Try again.")).not.toBeInTheDocument(); + expect(connectSpy).not.toHaveBeenCalled(); + }); + it("shows an invalid-password error after a submitted password is rejected", async () => { vi.mocked(fetchBootstrap).mockRejectedValue( new Error("bootstrap failed: HTTP 401"), diff --git a/webui/src/tests/bootstrap.test.ts b/webui/src/tests/bootstrap.test.ts index c2240583..1378ff50 100644 --- a/webui/src/tests/bootstrap.test.ts +++ b/webui/src/tests/bootstrap.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap"; +import { + BootstrapAuthRequiredError, + consumeUrlBootstrapSecret, + deriveWsUrl, + fetchBootstrap, +} from "@/lib/bootstrap"; describe("bootstrap helpers", () => { afterEach(() => { @@ -51,7 +56,7 @@ describe("bootstrap helpers", () => { await pending; }); - it("rejects bootstrap responses without an API token", async () => { + it("treats bootstrap responses without an API token as auth-required", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ @@ -60,9 +65,12 @@ describe("bootstrap helpers", () => { })), ); - await expect(fetchBootstrap()).rejects.toThrow( - "bootstrap response missing api_token", - ); + const promise = fetchBootstrap(); + await expect(promise).rejects.toMatchObject({ + name: "BootstrapAuthRequiredError", + message: "bootstrap authentication required: missing api_token", + }); + await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError); }); it("consumes bootstrap secrets from the URL fragment", () => {