fix(webui): route missing API bootstrap tokens to auth
maintainer edit: handle review feedback by treating bootstrap responses without api_token as auth-required, and remove the obsolete issue_token(api_token=...) compatibility path now that API tokens are issued separately.
This commit is contained in:
@@ -42,12 +42,10 @@ class GatewayTokenStore:
|
|||||||
return False
|
return False
|
||||||
return True
|
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)}"
|
token_value = f"nbwt_{secrets.token_urlsafe(32)}"
|
||||||
expiry = time.monotonic() + float(ttl_s)
|
expiry = time.monotonic() + float(ttl_s)
|
||||||
self.issued_tokens[token_value] = expiry
|
self.issued_tokens[token_value] = expiry
|
||||||
if api_token:
|
|
||||||
self.api_tokens[token_value] = expiry
|
|
||||||
return token_value
|
return token_value
|
||||||
|
|
||||||
def issue_api_token(self, ttl_s: int | float) -> str:
|
def issue_api_token(self, ttl_s: int | float) -> str:
|
||||||
|
|||||||
@@ -660,7 +660,7 @@ async def test_nanobot_feature_remote_install_requires_opt_in(
|
|||||||
install_calls=install_calls,
|
install_calls=install_calls,
|
||||||
)
|
)
|
||||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
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"
|
path = "/api/settings/nanobot-features/enable?name=matrix"
|
||||||
request = _FakeReq({"Authorization": f"Bearer {token}"}, path=path)
|
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,
|
install_calls=install_calls,
|
||||||
)
|
)
|
||||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
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(
|
request = _FakeReq(
|
||||||
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
|
{"Authorization": f"Bearer {token}", "Host": "127.0.0.1:8765"},
|
||||||
path="/api/settings/nanobot-features/enable?name=matrix",
|
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,
|
install_calls=install_calls,
|
||||||
)
|
)
|
||||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
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(
|
request = _FakeReq(
|
||||||
{
|
{
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -795,7 +795,7 @@ async def test_nanobot_feature_remote_enable_without_install_is_allowed(
|
|||||||
install_calls=install_calls,
|
install_calls=install_calls,
|
||||||
)
|
)
|
||||||
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=_free_port())
|
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(
|
request = _FakeReq(
|
||||||
{"Authorization": f"Bearer {token}"},
|
{"Authorization": f"Bearer {token}"},
|
||||||
path="/api/settings/nanobot-features/enable?name=matrix",
|
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)
|
_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())
|
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(
|
request = _FakeReq(
|
||||||
{"Authorization": f"Bearer {token}"},
|
{"Authorization": f"Bearer {token}"},
|
||||||
path="/api/settings/nanobot-features/disable?name=matrix",
|
path="/api/settings/nanobot-features/disable?name=matrix",
|
||||||
|
|||||||
+13
-5
@@ -23,6 +23,7 @@ import { useSkills } from "@/hooks/useSkills";
|
|||||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
BootstrapAuthRequiredError,
|
||||||
clearSavedSecret,
|
clearSavedSecret,
|
||||||
consumeUrlBootstrapSecret,
|
consumeUrlBootstrapSecret,
|
||||||
deriveWsUrl,
|
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({
|
function HostChrome({
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onSidebarPreviewEnter,
|
onSidebarPreviewEnter,
|
||||||
@@ -410,11 +417,13 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const msg = (e as Error).message;
|
if (isBootstrapAuthRequired(e)) {
|
||||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
|
||||||
setState({ status: "auth", failed: !!secret });
|
setState({ status: "auth", failed: !!secret });
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
await refreshReadyClient(client, state.runtimeSurface);
|
await refreshReadyClient(client, state.runtimeSurface);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = (e as Error).message;
|
if (isBootstrapAuthRequired(e)) {
|
||||||
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
|
|
||||||
setState({ status: "auth", failed: !!bootstrapSecretRef.current });
|
setState({ status: "auth", failed: !!bootstrapSecretRef.current });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { fetchWithTimeout } from "./http";
|
|||||||
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
const SECRET_STORAGE_KEY = "nanobot-webui.bootstrap-secret";
|
||||||
const URL_SECRET_PARAM = "bootstrapSecret";
|
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. */
|
/** Read a previously saved bootstrap secret from localStorage. */
|
||||||
export function loadSavedSecret(): string {
|
export function loadSavedSecret(): string {
|
||||||
if (typeof window === "undefined") return "";
|
if (typeof window === "undefined") return "";
|
||||||
@@ -74,6 +81,9 @@ export async function fetchBootstrap(
|
|||||||
headers,
|
headers,
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
if (!res.ok) {
|
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}`);
|
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const body = (await res.json()) as BootstrapResponse;
|
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");
|
throw new Error("bootstrap response missing token or ws_path");
|
||||||
}
|
}
|
||||||
if (!body.api_token) {
|
if (!body.api_token) {
|
||||||
throw new Error("bootstrap response missing api_token");
|
throw new BootstrapAuthRequiredError(
|
||||||
|
"bootstrap authentication required: missing api_token",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ vi.mock("@/hooks/useTheme", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("@/lib/bootstrap", () => ({
|
vi.mock("@/lib/bootstrap", () => ({
|
||||||
|
BootstrapAuthRequiredError: class BootstrapAuthRequiredError extends Error {
|
||||||
|
constructor(message = "bootstrap authentication required") {
|
||||||
|
super(message);
|
||||||
|
this.name = "BootstrapAuthRequiredError";
|
||||||
|
}
|
||||||
|
},
|
||||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||||
token: "tok",
|
token: "tok",
|
||||||
api_token: "api-tok",
|
api_token: "api-tok",
|
||||||
@@ -217,7 +223,11 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
return { NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
|
import {
|
||||||
|
BootstrapAuthRequiredError,
|
||||||
|
deriveWsUrl,
|
||||||
|
fetchBootstrap,
|
||||||
|
} from "@/lib/bootstrap";
|
||||||
import App from "@/App";
|
import App from "@/App";
|
||||||
|
|
||||||
describe("App layout", () => {
|
describe("App layout", () => {
|
||||||
@@ -271,6 +281,20 @@ describe("App layout", () => {
|
|||||||
expect(connectSpy).not.toHaveBeenCalled();
|
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(<App />);
|
||||||
|
|
||||||
|
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 () => {
|
it("shows an invalid-password error after a submitted password is rejected", async () => {
|
||||||
vi.mocked(fetchBootstrap).mockRejectedValue(
|
vi.mocked(fetchBootstrap).mockRejectedValue(
|
||||||
new Error("bootstrap failed: HTTP 401"),
|
new Error("bootstrap failed: HTTP 401"),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("bootstrap helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -51,7 +56,7 @@ describe("bootstrap helpers", () => {
|
|||||||
await pending;
|
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(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async () => ({
|
vi.fn(async () => ({
|
||||||
@@ -60,9 +65,12 @@ describe("bootstrap helpers", () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(fetchBootstrap()).rejects.toThrow(
|
const promise = fetchBootstrap();
|
||||||
"bootstrap response missing api_token",
|
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", () => {
|
it("consumes bootstrap secrets from the URL fragment", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user