mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 17:08:33 +00:00
feat(webui): bypass tokens for trusted proxy auth
This commit is contained in:
parent
5cd14a42df
commit
465a918cf8
@ -82,7 +82,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token`, `tokenIssueSecret`, or a fully configured `trustedProxyAuth` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
|
||||
> The gateway health route itself is intentionally minimal and unauthenticated. When the
|
||||
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
|
||||
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
|
||||
@ -93,7 +93,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
||||
|
||||
For a local `cloudflared` process in front of nanobot, Cloudflare Access can
|
||||
authenticate the user before forwarding the request and add
|
||||
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy bootstrap only when the
|
||||
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy no-token mode only when the
|
||||
direct TCP peer is the tunnel process and the assertion is non-empty:
|
||||
|
||||
```json
|
||||
@ -113,11 +113,14 @@ direct TCP peer is the tunnel process and the assertion is non-empty:
|
||||
```
|
||||
|
||||
This is two-part authorization: a trusted direct loopback peer **and** a
|
||||
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bootstrap
|
||||
bypass. Nanobot trusts the assertion but does not cryptographically validate
|
||||
the JWT, so configure the tunnel and Access policy carefully and do not expose
|
||||
the nanobot listener directly to untrusted clients. Forwarded client headers
|
||||
such as `X-Forwarded-For` do not establish proxy trust.
|
||||
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
|
||||
For this flow `/webui/bootstrap` returns connection metadata without a
|
||||
bootstrap token or REST API token; the proxy assertion authorizes the WebSocket
|
||||
handshake and REST requests directly. Nanobot trusts the assertion but does
|
||||
not cryptographically validate the JWT, so configure the tunnel and Access
|
||||
policy carefully and do not expose the nanobot listener directly to untrusted
|
||||
clients. Forwarded client headers such as `X-Forwarded-For` do not establish
|
||||
proxy trust.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
|
||||
@ -76,7 +76,7 @@ ws://{host}:{port}{path}?client_id={id}&token={token}
|
||||
| Parameter | Required | Description |
|
||||
|-----------|----------|-------------|
|
||||
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
|
||||
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. |
|
||||
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured, unless the request comes through an authenticated `trustedProxyAuth` peer. |
|
||||
|
||||
## Wire Protocol
|
||||
|
||||
@ -222,11 +222,11 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. A trusted proxy assertion bypasses this requirement. |
|
||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token, unless `trustedProxyAuth` authenticates the direct proxy peer. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret`, `token`, or a fully configured `trustedProxyAuth`. |
|
||||
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part bootstrap authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap. |
|
||||
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` issues tokens for local/secret-authenticated requests; trusted-proxy requests intentionally receive no bootstrap or API token. |
|
||||
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part no-token authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap or WebSocket/API access. |
|
||||
| `trustedProxyAuth.trustedPeerCidrs` | list of CIDR strings | — | Direct TCP peer networks that may present the assertion. IPv4, IPv6, and IPv4-mapped IPv6 peers are supported; universal CIDRs (`0.0.0.0/0`, `::/0`) are rejected. |
|
||||
| `trustedProxyAuth.assertionHeader` | string | — | HTTP header whose non-empty value proves the upstream proxy authenticated the request. Nanobot trusts this value but does not cryptographically validate it. |
|
||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||
@ -273,16 +273,18 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
||||
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
|
||||
4. The token is consumed (single use) and cannot be reused.
|
||||
|
||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||
The embedded WebUI's `/webui/bootstrap` route returns a WebSocket token and
|
||||
REST `api_token` for local or secret-authenticated requests. When
|
||||
`trustedProxyAuth` authenticates the direct proxy peer, it returns connection
|
||||
metadata only: no bootstrap token, no REST API token, and no token query
|
||||
parameter is required for the WebSocket handshake or subsequent REST requests.
|
||||
|
||||
It returns a separate `api_token` for REST routes to same-machine localhost
|
||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`,
|
||||
the static `token`, or the configured trusted proxy assertion.
|
||||
|
||||
### Trusted proxy bootstrap
|
||||
### Trusted proxy no-token bootstrap
|
||||
|
||||
`trustedProxyAuth` is an opt-in alternative for deployments where an
|
||||
identity-aware reverse proxy authenticates the user before connecting to nanobot.
|
||||
The proxy assertion becomes the authentication boundary for the entire WebUI
|
||||
surface: `/webui/bootstrap`, the WebSocket handshake, and REST API routes.
|
||||
Bootstrap is accepted only when **both** the direct TCP peer matches one of
|
||||
`trustedPeerCidrs` and the configured assertion header is present and non-empty.
|
||||
A trusted address by itself is never sufficient.
|
||||
|
||||
@ -61,6 +61,9 @@ from nanobot.session.webui_turns import (
|
||||
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||
from nanobot.webui.forking import handle_webui_fork_chat
|
||||
from nanobot.webui.gateway_services import GatewayServices
|
||||
from nanobot.webui.http_utils import (
|
||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
normalize_config_path as _normalize_config_path,
|
||||
)
|
||||
@ -220,11 +223,11 @@ class WebSocketConfig(Base):
|
||||
def wildcard_host_requires_auth(self) -> Self:
|
||||
if self.host not in ("0.0.0.0", "::"):
|
||||
return self
|
||||
if self.token.strip() or self.token_issue_secret.strip():
|
||||
if self.token.strip() or self.token_issue_secret.strip() or self.trusted_proxy_auth is not None:
|
||||
return self
|
||||
raise ValueError(
|
||||
"host is 0.0.0.0 (all interfaces) but neither token nor "
|
||||
"token_issue_secret is set — set one to prevent unauthenticated access"
|
||||
"host is 0.0.0.0 (all interfaces) but neither token, token_issue_secret, "
|
||||
"nor trusted_proxy_auth is set — set one to prevent unauthenticated access"
|
||||
)
|
||||
|
||||
|
||||
@ -480,16 +483,16 @@ class WebSocketChannel(BaseChannel):
|
||||
async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
|
||||
"""Route an inbound HTTP request to the HTTP handler or WS upgrade."""
|
||||
got, query = _parse_request_path(request.path)
|
||||
expected_ws = self._expected_path()
|
||||
|
||||
# WebSocket upgrade — channel handles this itself
|
||||
expected_ws = self._expected_path()
|
||||
if got == expected_ws and _is_websocket_upgrade(request):
|
||||
client_id = _query_first(query, "client_id") or ""
|
||||
if len(client_id) > 128:
|
||||
client_id = client_id[:128]
|
||||
if not self.is_allowed(client_id):
|
||||
return connection.respond(403, "Forbidden")
|
||||
return self._authorize_websocket_handshake(connection, query)
|
||||
return self._authorize_websocket_handshake(connection, query, request.headers)
|
||||
|
||||
# Everything else goes to the HTTP handler
|
||||
return await self._http_router.dispatch(connection, request)
|
||||
@ -498,7 +501,12 @@ class WebSocketChannel(BaseChannel):
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
query: dict[str, list[str]],
|
||||
headers: Any = None,
|
||||
) -> Any:
|
||||
if _is_trusted_proxy_authenticated_request(connection, headers or {}, self.config):
|
||||
self._webui_connections.add(connection)
|
||||
return None
|
||||
|
||||
supplied = _query_first(query, "token")
|
||||
static_token = self.config.token.strip()
|
||||
|
||||
|
||||
@ -3351,7 +3351,7 @@ def test_trusted_proxy_rejects_untrusted_peer_spoof(bus: MagicMock) -> None:
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust(
|
||||
def test_trusted_proxy_bootstrap_has_no_tokens(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
assertion = "opaque-upstream-assertion"
|
||||
@ -3376,9 +3376,36 @@ def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust(
|
||||
assert assertion not in body
|
||||
assert assertion not in repr(log.mock_calls)
|
||||
payload = json.loads(body)
|
||||
assert payload["token"].startswith("nbwt_")
|
||||
assert payload["api_token"].startswith("nbwt_")
|
||||
assert payload["api_token"] != payload["token"]
|
||||
assert "token" not in payload
|
||||
assert "api_token" not in payload
|
||||
assert payload["ws_path"] == "/"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_proxy_authorizes_rest_without_api_token(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, **_trusted_proxy_config())
|
||||
response = await channel.gateway.http.dispatch(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Host": "nanobot.example",
|
||||
"Cf-Access-Jwt-Assertion": "present",
|
||||
},
|
||||
path="/api/sessions",
|
||||
),
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_trusted_proxy_authorizes_websocket_without_token(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, **_trusted_proxy_config())
|
||||
response = channel._authorize_websocket_handshake(
|
||||
_LOCAL,
|
||||
{},
|
||||
{"Cf-Access-Jwt-Assertion": "present"},
|
||||
)
|
||||
assert response is None
|
||||
assert _LOCAL in channel._webui_connections
|
||||
|
||||
|
||||
def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> None:
|
||||
@ -3397,7 +3424,7 @@ def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> N
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> None:
|
||||
def test_trusted_proxy_bypasses_bootstrap_secret_and_tokens(bus: MagicMock) -> None:
|
||||
channel = _ch(
|
||||
bus,
|
||||
tokenIssueSecret="route-secret",
|
||||
@ -3407,7 +3434,10 @@ def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> Non
|
||||
_LOCAL,
|
||||
_FakeReq({"Cf-Access-Jwt-Assertion": "present"}),
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
assert resp.status_code == 200
|
||||
payload = json.loads(resp.body)
|
||||
assert "token" not in payload
|
||||
assert "api_token" not in payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -3461,6 +3491,11 @@ def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None:
|
||||
assert channel.config.host == "0.0.0.0"
|
||||
|
||||
|
||||
def test_wildcard_host_with_trusted_proxy_auth_is_valid(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, host="0.0.0.0", **_trusted_proxy_config())
|
||||
assert channel.config.host == "0.0.0.0"
|
||||
|
||||
|
||||
def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
|
||||
import pytest
|
||||
from pydantic_core import ValidationError
|
||||
|
||||
@ -266,6 +266,8 @@ class GatewayHTTPHandler:
|
||||
# -- Token management ---------------------------------------------------
|
||||
|
||||
def check_api_token(self, request: WsRequest) -> bool:
|
||||
if getattr(request, "_nanobot_trusted_proxy_authenticated", False):
|
||||
return True
|
||||
return self.tokens.check_api_token(request)
|
||||
|
||||
# -- Main dispatch ------------------------------------------------------
|
||||
@ -275,6 +277,11 @@ class GatewayHTTPHandler:
|
||||
got, _ = _parse_request_path(request.path)
|
||||
started = time.perf_counter()
|
||||
response: Any | None = None
|
||||
setattr(
|
||||
request,
|
||||
"_nanobot_trusted_proxy_authenticated",
|
||||
_is_trusted_proxy_authenticated_request(connection, request.headers, self.config),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._dispatch_resolved(connection, request, got)
|
||||
@ -380,13 +387,27 @@ class GatewayHTTPHandler:
|
||||
request.headers,
|
||||
self.config,
|
||||
)
|
||||
if secret:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not (is_local_browser or is_proxy_authenticated):
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
if not is_proxy_authenticated:
|
||||
if secret:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not is_local_browser:
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
|
||||
api_token_allowed = bool(secret) or is_local_browser or is_proxy_authenticated
|
||||
if is_proxy_authenticated:
|
||||
payload = {
|
||||
"ws_path": _normalize_config_path(self.config.path),
|
||||
"ws_url": self._bootstrap_ws_url(request),
|
||||
"limits": self.ingress.bootstrap_limits(
|
||||
max_frame_bytes=self.config.max_message_bytes,
|
||||
),
|
||||
"model_name": _resolve_bootstrap_model_name(self.runtime_model_name),
|
||||
"runtime_surface": self._runtime_surface,
|
||||
"runtime_capabilities": self._capabilities,
|
||||
}
|
||||
return _http_json_response(payload)
|
||||
|
||||
api_token_allowed = bool(secret) or is_local_browser
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
|
||||
@ -70,7 +70,7 @@ type BootState =
|
||||
status: "ready";
|
||||
client: NanobotClient;
|
||||
token: string;
|
||||
tokenExpiresAt: number;
|
||||
tokenExpiresAt: number | null;
|
||||
modelName: string | null;
|
||||
ingressLimits: BootstrapResponse["limits"] | null;
|
||||
runtimeSurface: RuntimeSurface;
|
||||
@ -733,7 +733,9 @@ export default function App() {
|
||||
? toRuntimeSurface(boot.runtime_surface)
|
||||
: fallbackSurface;
|
||||
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
||||
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
|
||||
const tokenExpiresAt = boot.expires_in
|
||||
? bootstrapTokenExpiresAt(boot.expires_in)
|
||||
: null;
|
||||
if (runtimeHost.socketFactory) {
|
||||
client.updateUrl(url, runtimeHost.socketFactory);
|
||||
} else {
|
||||
@ -744,7 +746,7 @@ export default function App() {
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
...current,
|
||||
token: boot.api_token,
|
||||
token: boot.api_token ?? "",
|
||||
tokenExpiresAt,
|
||||
modelName: boot.model_name ?? current.modelName,
|
||||
ingressLimits: boot.limits ?? current.ingressLimits,
|
||||
@ -752,7 +754,7 @@ export default function App() {
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return { token: boot.api_token, url };
|
||||
return { token: boot.api_token ?? "", url };
|
||||
},
|
||||
[],
|
||||
);
|
||||
@ -787,8 +789,10 @@ export default function App() {
|
||||
setState({
|
||||
status: "ready",
|
||||
client,
|
||||
token: boot.api_token,
|
||||
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in),
|
||||
token: boot.api_token ?? "",
|
||||
tokenExpiresAt: boot.expires_in
|
||||
? bootstrapTokenExpiresAt(boot.expires_in)
|
||||
: null,
|
||||
modelName: boot.model_name ?? null,
|
||||
ingressLimits: boot.limits ?? null,
|
||||
runtimeSurface,
|
||||
@ -813,7 +817,7 @@ export default function App() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "ready") return;
|
||||
if (state.status !== "ready" || state.tokenExpiresAt === null) return;
|
||||
const client = state.client;
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
|
||||
@ -87,13 +87,8 @@ export async function fetchBootstrap(
|
||||
throw new Error(`bootstrap failed: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as BootstrapResponse;
|
||||
if (!body.token || !body.ws_path) {
|
||||
throw new Error("bootstrap response missing token or ws_path");
|
||||
}
|
||||
if (!body.api_token) {
|
||||
throw new BootstrapAuthRequiredError(
|
||||
"bootstrap authentication required: missing api_token",
|
||||
);
|
||||
if (!body.ws_path) {
|
||||
throw new Error("bootstrap response missing ws_path");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
@ -107,10 +102,10 @@ export async function fetchBootstrap(
|
||||
*/
|
||||
export function deriveWsUrl(
|
||||
wsPath: string,
|
||||
token: string,
|
||||
token: string | null | undefined,
|
||||
wsUrl?: string | null,
|
||||
): string {
|
||||
const query = `?token=${encodeURIComponent(token)}`;
|
||||
const query = token ? `?token=${encodeURIComponent(token)}` : "";
|
||||
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
|
||||
if (typeof window !== "undefined" && window.location.port === "5173") {
|
||||
const host = window.location.hostname.includes(":")
|
||||
@ -127,6 +122,7 @@ export function deriveWsUrl(
|
||||
return `${scheme}://${authority}${path}${query}`;
|
||||
}
|
||||
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
|
||||
if (!token) return wsUrl;
|
||||
const join = wsUrl.includes("?") ? "&" : "?";
|
||||
return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
@ -390,11 +390,11 @@ export interface SidebarStatePayload {
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
token: string;
|
||||
api_token: string;
|
||||
token?: string;
|
||||
api_token?: string;
|
||||
ws_path: string;
|
||||
ws_url?: string | null;
|
||||
expires_in: number;
|
||||
expires_in?: number;
|
||||
limits?: WebUIIngressLimits;
|
||||
model_name?: string | null;
|
||||
runtime_surface?: RuntimeSurface;
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
BootstrapAuthRequiredError,
|
||||
consumeUrlBootstrapSecret,
|
||||
deriveWsUrl,
|
||||
fetchBootstrap,
|
||||
@ -57,6 +56,12 @@ describe("bootstrap helpers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not append a token for trusted-proxy websocket URLs", () => {
|
||||
expect(deriveWsUrl("/", undefined, "wss://proxy.example/")).toBe(
|
||||
"wss://proxy.example/",
|
||||
);
|
||||
});
|
||||
|
||||
it("times out when the bootstrap endpoint never responds", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
|
||||
@ -69,21 +74,19 @@ describe("bootstrap helpers", () => {
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("treats bootstrap responses without an API token as auth-required", async () => {
|
||||
it("accepts tokenless trusted-proxy bootstrap responses", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }),
|
||||
json: async () => ({ ws_path: "/", ws_url: "wss://proxy.example/" }),
|
||||
})),
|
||||
);
|
||||
|
||||
const promise = fetchBootstrap();
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
name: "BootstrapAuthRequiredError",
|
||||
message: "bootstrap authentication required: missing api_token",
|
||||
await expect(fetchBootstrap()).resolves.toMatchObject({
|
||||
ws_path: "/",
|
||||
ws_url: "wss://proxy.example/",
|
||||
});
|
||||
await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError);
|
||||
});
|
||||
|
||||
it("consumes bootstrap secrets from the URL fragment", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user