@openparachute/agent 0.2.3-rc.3 → 0.2.3-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.ts +103 -14
- package/src/daemon.ts +30 -8
- package/src/transports/http-ui.ts +10 -8
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-CqIi6rPT.js +60 -0
- package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-5KEwEhfi.js +0 -60
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
3
|
+
"version": "0.2.3-rc.5",
|
|
4
4
|
"description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
|
|
5
5
|
"license": "AGPL-3.0",
|
|
6
6
|
"type": "module",
|
package/src/auth.ts
CHANGED
|
@@ -7,13 +7,26 @@
|
|
|
7
7
|
* - Layer 1 (bridge / session↔channel): the bridge presents the token as an
|
|
8
8
|
* `Authorization: Bearer` header on `/events` + `/api/*`.
|
|
9
9
|
* - Layer 2 (human / chat UI): the page fetches a short-lived token from the
|
|
10
|
-
* hub (`/admin/agent-token`) and attaches it
|
|
11
|
-
* `send` POST
|
|
12
|
-
*
|
|
10
|
+
* hub (`/admin/agent-token`) and attaches it as a Bearer header on the
|
|
11
|
+
* `send` POST. For the browser SSE streams (`/ui/events`,
|
|
12
|
+
* `/api/channels/<ch>/turn-events`) — which an `EventSource` can't set a
|
|
13
|
+
* header on — the page does NOT put the JWT in the URL. Instead it mints a
|
|
14
|
+
* one-time SSE TICKET (`POST /api/ui/sse-ticket`, Bearer-authenticated) and
|
|
15
|
+
* opens `…?ticket=<nonce>`. See `requireSseTicket` below + `ui-ticket.ts`.
|
|
13
16
|
*
|
|
14
|
-
* `requireScope` accepts the token from
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* `requireScope` accepts the token from a Bearer header (and, for the
|
|
18
|
+
* agent:admin terminal WebSocket only, a `?token=` query param). The no-token
|
|
19
|
+
* path short-circuits before any JWKS fetch, keeping it unit-testable without a
|
|
20
|
+
* live hub (same approach Layer 1 used).
|
|
21
|
+
*
|
|
22
|
+
* WHY THE TICKET (agent#25). A full hub JWT in a `?token=` URL lands in any
|
|
23
|
+
* access/proxy log, browser history, or network trace — a credential leak
|
|
24
|
+
* mitigated before only by the token's short TTL. The browser SSE endpoints now
|
|
25
|
+
* trade the JWT for an opaque, single-use, ≤60s ticket (`requireSseTicket`); the
|
|
26
|
+
* JWT only ever travels in a `fetch` Bearer header. The legacy `?token=` SSE
|
|
27
|
+
* path was REMOVED (pre-1.0, no deprecation window). The terminal WebSocket
|
|
28
|
+
* (`agent:admin`) still uses `?token=` — a separate, operator-gated mechanism
|
|
29
|
+
* out of this change's scope.
|
|
17
30
|
*
|
|
18
31
|
* DUAL-ACCEPT (channel→agent rename transition,
|
|
19
32
|
* `parachute-patterns/migrations/2026-06-17-channel-to-agent.md` rule 1). New
|
|
@@ -26,6 +39,7 @@
|
|
|
26
39
|
|
|
27
40
|
import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
|
|
28
41
|
import { extractBearer } from "@openparachute/scope-guard";
|
|
42
|
+
import { consumeTicket } from "./ui-ticket.ts";
|
|
29
43
|
|
|
30
44
|
/** Agent scopes, declared here so callers share one spelling. */
|
|
31
45
|
export const SCOPE_READ = "agent:read" as const;
|
|
@@ -79,15 +93,18 @@ export function json(data: unknown, status = 200): Response {
|
|
|
79
93
|
|
|
80
94
|
/**
|
|
81
95
|
* Extract a presented token from a request: the `Authorization: Bearer` header
|
|
82
|
-
* first (the bridge
|
|
83
|
-
*
|
|
84
|
-
*
|
|
96
|
+
* first (the bridge, the UI's POST, the SSE-ticket mint), falling back to a
|
|
97
|
+
* `?token=` query param only when `allowQueryParam` is set. The ONLY caller that
|
|
98
|
+
* opts into the query param is the agent:admin terminal WebSocket
|
|
99
|
+
* (`new WebSocket()` can't set headers); the browser SSE streams moved to the
|
|
100
|
+
* one-time-ticket path (`requireSseTicket`) so a JWT never rides in a URL. Returns
|
|
101
|
+
* null if neither source is present.
|
|
85
102
|
*/
|
|
86
103
|
export function extractToken(req: Request, url: URL, allowQueryParam = false): string | null {
|
|
87
104
|
const bearer = extractBearer(req.headers.get("authorization"));
|
|
88
105
|
if (bearer) return bearer;
|
|
89
|
-
// `?token=` is opt-in (the
|
|
90
|
-
// Bearer header, so they
|
|
106
|
+
// `?token=` is opt-in (the terminal WebSocket only). Every other caller presents
|
|
107
|
+
// a Bearer header, so they leave it false — keeping query-param JWTs off every
|
|
91
108
|
// endpoint that doesn't strictly need them (and out of those access logs).
|
|
92
109
|
if (allowQueryParam) {
|
|
93
110
|
const q = url.searchParams.get("token");
|
|
@@ -99,9 +116,10 @@ export function extractToken(req: Request, url: URL, allowQueryParam = false): s
|
|
|
99
116
|
/**
|
|
100
117
|
* Guard an HTTP endpoint on a hub-issued JWT carrying `scope`. The token arrives
|
|
101
118
|
* as an `Authorization: Bearer` header; pass `allowQueryParam: true` to also
|
|
102
|
-
* accept a `?token=` query param (the
|
|
103
|
-
* headers).
|
|
104
|
-
* confined to
|
|
119
|
+
* accept a `?token=` query param (the agent:admin terminal WebSocket only —
|
|
120
|
+
* `new WebSocket()` can't set headers). All other callers leave it false, so
|
|
121
|
+
* query-param JWTs are confined to that one endpoint. Browser SSE streams use
|
|
122
|
+
* `requireSseTicket` (the one-time ticket), not this.
|
|
105
123
|
*
|
|
106
124
|
* Returns `null` when the request is authorized (caller proceeds), or a
|
|
107
125
|
* `Response` (401/403) the caller must return as-is.
|
|
@@ -138,3 +156,74 @@ export async function requireScope(
|
|
|
138
156
|
);
|
|
139
157
|
}
|
|
140
158
|
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mint endpoint for a one-time SSE ticket (agent#25). Authenticate the presented
|
|
162
|
+
* Bearer JWT for `scope` (the SAME validation `requireScope` runs — no-token →
|
|
163
|
+
* 401 pre-JWKS, bad/insufficient → 401/403), then issue a single-use, ≤60s
|
|
164
|
+
* opaque ticket carrying ONLY the token's validated scopes. The ticket — never
|
|
165
|
+
* the JWT — goes in the SSE URL. Returns the mint `Response` (200 `{ ticket,
|
|
166
|
+
* expires_at }`, or the gate's 401/403) for the caller to return as-is.
|
|
167
|
+
*
|
|
168
|
+
* `mintTicket` is injected (defaults to the real `ui-ticket.ts` store) so unit
|
|
169
|
+
* tests can assert what scopes get carried without reaching into the singleton.
|
|
170
|
+
* Critically, an UNAUTHENTICATED mint is impossible: the scope gate runs first
|
|
171
|
+
* and short-circuits before any ticket is created — minting without a valid
|
|
172
|
+
* bearer would be an auth bypass.
|
|
173
|
+
*/
|
|
174
|
+
export async function mintSseTicket(
|
|
175
|
+
req: Request,
|
|
176
|
+
url: URL,
|
|
177
|
+
scope: string,
|
|
178
|
+
mint: (scopes: readonly string[]) => { ticket: string; expiresAt: number },
|
|
179
|
+
): Promise<Response> {
|
|
180
|
+
const token = extractToken(req, url); // Bearer header ONLY — never a query param.
|
|
181
|
+
if (!token) {
|
|
182
|
+
return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
183
|
+
}
|
|
184
|
+
let scopes: string[];
|
|
185
|
+
try {
|
|
186
|
+
const claims = await validateHubJwt(token);
|
|
187
|
+
if (!grantsScope(claims.scopes, scope)) {
|
|
188
|
+
return json(
|
|
189
|
+
{ error: "insufficient_scope", message: `requires ${scope}`, granted: claims.scopes },
|
|
190
|
+
403,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// Carry the token's OWN validated scopes — never widen beyond what it holds.
|
|
194
|
+
scopes = claims.scopes;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return json(
|
|
197
|
+
{ error: "unauthorized", message: err instanceof HubJwtError ? err.message : "invalid token" },
|
|
198
|
+
401,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const { ticket, expiresAt } = mint(scopes);
|
|
202
|
+
return json({ ticket, expires_at: new Date(expiresAt).toISOString() });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Guard a browser SSE endpoint on a one-time `?ticket=<nonce>` (agent#25 — the
|
|
207
|
+
* EventSource auth path that replaced the leaky `?token=<JWT>`). Look up + CONSUME
|
|
208
|
+
* the ticket (single-use: a second connect 401s), then assert the ticket's carried
|
|
209
|
+
* scopes include `scope` (the ticket can never authorize more than the JWT that
|
|
210
|
+
* minted it — `mintSseTicket` stored exactly that JWT's scopes). Returns `null`
|
|
211
|
+
* when authorized (caller opens the stream) or a 401 `Response` to return as-is.
|
|
212
|
+
*
|
|
213
|
+
* No JWKS fetch on this path — the JWT was validated at MINT time and its scopes
|
|
214
|
+
* captured in the ticket; consume is a pure in-memory lookup. So an absent /
|
|
215
|
+
* expired / already-used / under-scoped ticket all map to 401 with no network I/O.
|
|
216
|
+
*/
|
|
217
|
+
export function requireSseTicket(url: URL, scope: string): Response | null {
|
|
218
|
+
const consumed = consumeTicket(url.searchParams.get("ticket"));
|
|
219
|
+
if (!consumed) {
|
|
220
|
+
return json({ error: "unauthorized", message: "valid one-time SSE ticket required" }, 401);
|
|
221
|
+
}
|
|
222
|
+
if (!grantsScope(consumed.scopes, scope)) {
|
|
223
|
+
return json(
|
|
224
|
+
{ error: "insufficient_scope", message: `ticket lacks ${scope}`, granted: consumed.scopes },
|
|
225
|
+
403,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
package/src/daemon.ts
CHANGED
|
@@ -85,6 +85,8 @@ import { ClientRegistry, sseFrame } from "./routing.ts";
|
|
|
85
85
|
import { DeliveryState } from "./delivery-state.ts";
|
|
86
86
|
import {
|
|
87
87
|
requireScope,
|
|
88
|
+
mintSseTicket,
|
|
89
|
+
requireSseTicket,
|
|
88
90
|
extractToken,
|
|
89
91
|
json as authJson,
|
|
90
92
|
SCOPE_READ,
|
|
@@ -93,6 +95,7 @@ import {
|
|
|
93
95
|
SCOPE_ADMIN,
|
|
94
96
|
SCOPE_TERMINAL,
|
|
95
97
|
} from "./auth.ts";
|
|
98
|
+
import { mintTicket } from "./ui-ticket.ts";
|
|
96
99
|
import {
|
|
97
100
|
createTerminalWsHandlers,
|
|
98
101
|
type TerminalWsData,
|
|
@@ -1124,8 +1127,13 @@ function redirect(location: string): Response {
|
|
|
1124
1127
|
// is `agent:write`.
|
|
1125
1128
|
//
|
|
1126
1129
|
// Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
|
|
1127
|
-
// `agent:send
|
|
1128
|
-
//
|
|
1130
|
+
// `agent:send`, Bearer) with `requireScope`. The browser SSE streams
|
|
1131
|
+
// (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
|
|
1132
|
+
// ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
|
|
1133
|
+
// `EventSource` can't set a header, and a JWT in a URL leaks into access logs
|
|
1134
|
+
// (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
|
|
1135
|
+
// agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
|
|
1136
|
+
// carries only the minting token's scopes.
|
|
1129
1137
|
//
|
|
1130
1138
|
// Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
|
|
1131
1139
|
// OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
|
|
@@ -2745,8 +2753,21 @@ export function createFetchHandler(
|
|
|
2745
2753
|
return json({ ok: true, reloaded: result });
|
|
2746
2754
|
}
|
|
2747
2755
|
|
|
2756
|
+
// One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
|
|
2757
|
+
// page can't put its hub JWT in an EventSource URL without leaking it into
|
|
2758
|
+
// access logs, so it trades the JWT (presented HERE as a Bearer header — no
|
|
2759
|
+
// leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
|
|
2760
|
+
// Bearer-gated on `agent:read` (the scope both browser SSE streams require);
|
|
2761
|
+
// the minted ticket carries ONLY the token's own validated scopes, so it can
|
|
2762
|
+
// never authorize more than the JWT did. An unauthenticated mint is impossible
|
|
2763
|
+
// — `mintSseTicket` runs the scope gate before issuing anything. Returns
|
|
2764
|
+
// `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
|
|
2765
|
+
if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
|
|
2766
|
+
return mintSseTicket(req, url, SCOPE_READ, mintTicket);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2748
2769
|
// Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
|
|
2749
|
-
//
|
|
2770
|
+
// a one-time SSE ticket carrying `agent:read`). The streaming
|
|
2750
2771
|
// view (design 2026-06-16 build item #1): the chat subscribes here to watch a
|
|
2751
2772
|
// PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
|
|
2752
2773
|
// done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
|
|
@@ -2758,11 +2779,12 @@ export function createFetchHandler(
|
|
|
2758
2779
|
{
|
|
2759
2780
|
const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
|
|
2760
2781
|
if (req.method === "GET" && turnMatch) {
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
// stdio-bridge /events SSE uses a
|
|
2765
|
-
|
|
2782
|
+
// Browser EventSource can't set an Authorization header, so this SSE
|
|
2783
|
+
// authenticates via a one-time `?ticket=<nonce>` (agent#25) — minted by
|
|
2784
|
+
// POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
|
|
2785
|
+
// hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
|
|
2786
|
+
// Bearer header, so it never needed a query credential at all.)
|
|
2787
|
+
const denied = requireSseTicket(url, SCOPE_READ);
|
|
2766
2788
|
if (denied) return denied;
|
|
2767
2789
|
const channelName = decodeURIComponent(turnMatch[1]!);
|
|
2768
2790
|
const clientId = crypto.randomUUID();
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* to them (mirroring the daemon's `/events` SSE pattern for bridges).
|
|
17
17
|
*
|
|
18
18
|
* It owns two HTTP surfaces via `ingestHttp` (scoped to ITS OWN channel name):
|
|
19
|
-
* 1. POST /api/channels/<name>/send
|
|
20
|
-
* 2. GET /ui/events?channel=<name
|
|
19
|
+
* 1. POST /api/channels/<name>/send — body {text} → ctx.emit(...) → {ok:true}
|
|
20
|
+
* 2. GET /ui/events?channel=<name>&ticket= — SSE stream the browser subscribes to
|
|
21
|
+
* (one-time ticket auth — agent#25; the JWT never rides in this URL)
|
|
21
22
|
* The static `/ui` chat page itself is global and served by the daemon, since
|
|
22
23
|
* it's a channel picker across all http-ui channels.
|
|
23
24
|
*/
|
|
@@ -30,7 +31,7 @@ import type {
|
|
|
30
31
|
PermissionArgs,
|
|
31
32
|
} from "../transport.ts";
|
|
32
33
|
import { sseFrame } from "../routing.ts";
|
|
33
|
-
import { requireScope, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
|
|
34
|
+
import { requireScope, requireSseTicket, json, SCOPE_SEND, SCOPE_READ } from "../auth.ts";
|
|
34
35
|
|
|
35
36
|
/** A connected browser SSE client (one per open chat page on this channel). */
|
|
36
37
|
interface UiClient {
|
|
@@ -168,11 +169,12 @@ export class HttpUiTransport implements Transport {
|
|
|
168
169
|
url.pathname === "/ui/events" &&
|
|
169
170
|
url.searchParams.get("channel") === channel
|
|
170
171
|
) {
|
|
171
|
-
// Layer 2:
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
172
|
+
// Layer 2: EventSource can't set headers, so this gates on a one-time
|
|
173
|
+
// `?ticket=<nonce>` (agent#25) — minted by POST /api/ui/sse-ticket
|
|
174
|
+
// (Bearer-gated) and consumed single-use here, carrying `agent:read`. The
|
|
175
|
+
// hub JWT never appears in this URL (the leak the ticket closes). Absent /
|
|
176
|
+
// expired / already-used ticket → 401 before the stream opens.
|
|
177
|
+
const denied = requireSseTicket(url, SCOPE_READ);
|
|
176
178
|
if (denied) return denied;
|
|
177
179
|
const clientId = crypto.randomUUID();
|
|
178
180
|
const clients = this.uiClients;
|
package/src/ui-kit.ts
CHANGED
|
@@ -376,9 +376,12 @@ export const SHELL_JS = `
|
|
|
376
376
|
el.className = "app-status" + (kind ? " " + kind : "");
|
|
377
377
|
}
|
|
378
378
|
// Hub-minted agent token (cookie-gated to the logged-in operator). Cached on
|
|
379
|
-
// window.__token; pages attach it as a Bearer header
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// window.__token; pages attach it as a Bearer header. (Browser SSE auth moved
|
|
380
|
+
// off the query-param token to a one-time ticket in agent#25, so the JWT never
|
|
381
|
+
// lands in a URL; this template is the retired server-rendered shell, superseded
|
|
382
|
+
// by the SPA.) Endpoint renamed /admin/channel-token to /admin/agent-token in
|
|
383
|
+
// the channel-to-agent rename; the hub 301-redirects the old path for old
|
|
384
|
+
// bookmarks.
|
|
382
385
|
function fetchToken() {
|
|
383
386
|
return fetch(window.location.origin + "/admin/agent-token", { credentials: "include" })
|
|
384
387
|
.then(function (r) { if (!r.ok) throw new Error("token " + r.status); return r.json(); })
|
package/src/ui-ticket.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time SSE tickets for the browser EventSource auth path (Layer 2, human↔UI).
|
|
3
|
+
*
|
|
4
|
+
* THE LEAK THIS CLOSES. A browser `EventSource` can't set an `Authorization`
|
|
5
|
+
* header, so the agent SPA used to put the hub JWT directly in the SSE URL
|
|
6
|
+
* (`/ui/events?token=<JWT>`, `/api/channels/<ch>/turn-events?token=<JWT>`). A
|
|
7
|
+
* full bearer JWT in a URL lands in any access log, proxy log, browser history,
|
|
8
|
+
* or network trace — mitigated before only by the token's ~10min TTL. That's a
|
|
9
|
+
* credential-in-a-URL leak.
|
|
10
|
+
*
|
|
11
|
+
* THE FIX. Trade the JWT for an opaque, single-use, very-short-lived TICKET that
|
|
12
|
+
* goes in the URL instead. The SPA presents its bearer JWT to a normal
|
|
13
|
+
* authenticated endpoint (a Bearer header on a `fetch`, no leak), which mints a
|
|
14
|
+
* ticket: a crypto-random 256-bit nonce (base64url) stored ONLY server-side in
|
|
15
|
+
* this TTL'd map, carrying the validated scope(s)/audience of the presenting
|
|
16
|
+
* token. The SPA opens `/ui/events?ticket=<nonce>`; the SSE consume path looks
|
|
17
|
+
* the nonce up, CONSUMES it (deletes immediately — single-use), and establishes
|
|
18
|
+
* the stream with the ticket's scopes. The JWT never appears in a URL or log.
|
|
19
|
+
*
|
|
20
|
+
* SECURITY PROPERTIES (all load-bearing):
|
|
21
|
+
* - Unguessable: 32 random bytes (256 bits) from `crypto.getRandomValues`,
|
|
22
|
+
* base64url-encoded. Far above the issue's 128-bit floor.
|
|
23
|
+
* - Single-use: `consume` DELETES the entry before returning, so a replayed
|
|
24
|
+
* ticket (a second connect, or a stolen URL) finds nothing → 401.
|
|
25
|
+
* - Short TTL: default 60s — just long enough to open the connection. An
|
|
26
|
+
* expired entry is treated as absent (and lazily pruned).
|
|
27
|
+
* - No scope widening: the ticket stores EXACTLY the scopes the minting token
|
|
28
|
+
* presented (validated upstream by `requireScope` before `mint` is called).
|
|
29
|
+
* The consume path asserts the required scope against the stored set, so a
|
|
30
|
+
* ticket can never authorize more than the JWT that minted it.
|
|
31
|
+
*
|
|
32
|
+
* This is process-local in-memory state by design (mirrors the daemon's other
|
|
33
|
+
* in-process registries). The daemon is single-instance per machine; tickets
|
|
34
|
+
* live ≤60s and are cheap to lose on restart (the SPA just re-mints). A
|
|
35
|
+
* module-level singleton is used because the two consume paths live in different
|
|
36
|
+
* modules (`http-ui.ts`'s `ingestHttp` and the daemon's turn-events route) and
|
|
37
|
+
* both must hit the SAME store — `ingestHttp(req, url)` has no place to thread an
|
|
38
|
+
* instance through. `_resetTicketsForTest` isolates unit tests.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A minted ticket's server-side record. Never leaves the process. */
|
|
42
|
+
interface TicketRecord {
|
|
43
|
+
/** The validated scopes carried from the minting JWT (the ceiling — never widened). */
|
|
44
|
+
scopes: string[];
|
|
45
|
+
/** Epoch ms after which the ticket is expired (treated as absent). */
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default ticket lifetime — long enough to open an EventSource, no longer. */
|
|
50
|
+
export const TICKET_TTL_MS = 60_000;
|
|
51
|
+
|
|
52
|
+
/** Nonce entropy: 32 bytes = 256 bits, well above the 128-bit floor. */
|
|
53
|
+
const TICKET_BYTES = 32;
|
|
54
|
+
|
|
55
|
+
/** The process-local ticket store. nonce → record. */
|
|
56
|
+
const tickets = new Map<string, TicketRecord>();
|
|
57
|
+
|
|
58
|
+
/** base64url-encode bytes (no padding) — URL-safe, no `+`/`/`/`=`. */
|
|
59
|
+
function base64url(bytes: Uint8Array): string {
|
|
60
|
+
let bin = "";
|
|
61
|
+
for (const b of bytes) bin += String.fromCharCode(b);
|
|
62
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mint a single-use ticket carrying `scopes` (a COPY of the validated scopes from
|
|
67
|
+
* the presenting JWT — the caller must have already authenticated + scope-checked
|
|
68
|
+
* the token, so this never widens authority). Returns the opaque nonce + its
|
|
69
|
+
* absolute expiry. TTL defaults to {@link TICKET_TTL_MS}.
|
|
70
|
+
*/
|
|
71
|
+
export function mintTicket(scopes: readonly string[], ttlMs = TICKET_TTL_MS): {
|
|
72
|
+
ticket: string;
|
|
73
|
+
expiresAt: number;
|
|
74
|
+
} {
|
|
75
|
+
pruneExpiredTickets();
|
|
76
|
+
const bytes = new Uint8Array(TICKET_BYTES);
|
|
77
|
+
crypto.getRandomValues(bytes);
|
|
78
|
+
const ticket = base64url(bytes);
|
|
79
|
+
const expiresAt = Date.now() + ttlMs;
|
|
80
|
+
tickets.set(ticket, { scopes: [...scopes], expiresAt });
|
|
81
|
+
return { ticket, expiresAt };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Consume a ticket: look it up, and if present + unexpired, DELETE it (single-use)
|
|
86
|
+
* and return its scopes. Returns `null` for an absent / expired / already-consumed
|
|
87
|
+
* ticket — the caller maps that to a 401. Deletion happens before return, so two
|
|
88
|
+
* concurrent consumes of the same nonce can't both succeed.
|
|
89
|
+
*/
|
|
90
|
+
export function consumeTicket(ticket: string | null | undefined): { scopes: string[] } | null {
|
|
91
|
+
if (!ticket) return null;
|
|
92
|
+
const rec = tickets.get(ticket);
|
|
93
|
+
if (!rec) return null;
|
|
94
|
+
// Single-use: remove FIRST, so even an expired hit can't be retried and a
|
|
95
|
+
// concurrent second consume finds nothing.
|
|
96
|
+
tickets.delete(ticket);
|
|
97
|
+
if (Date.now() >= rec.expiresAt) return null;
|
|
98
|
+
return { scopes: rec.scopes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Drop every expired ticket. Called opportunistically on each mint so the map
|
|
103
|
+
* can't grow unbounded if some tickets are never consumed; not on a timer (no
|
|
104
|
+
* background work in a possibly-idle daemon). O(n) over a map that's tiny in
|
|
105
|
+
* practice (≤ a handful of live tickets at 60s TTL).
|
|
106
|
+
*/
|
|
107
|
+
export function pruneExpiredTickets(now = Date.now()): void {
|
|
108
|
+
for (const [k, rec] of tickets) {
|
|
109
|
+
if (now >= rec.expiresAt) tickets.delete(k);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Test seam: clear all tickets so unit tests start from a clean store. */
|
|
114
|
+
export function _resetTicketsForTest(): void {
|
|
115
|
+
tickets.clear();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test seam: the current live ticket count (asserts single-use deletion). */
|
|
119
|
+
export function _ticketCountForTest(): number {
|
|
120
|
+
return tickets.size;
|
|
121
|
+
}
|