ada-agent 0.6.1 → 0.8.0

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.
@@ -0,0 +1,300 @@
1
+ // OIDC SSO for the enterprise control plane (Stage 2). The org connects its IdP (Okta, Entra
2
+ // single-tenant, Auth0, Keycloak, Google Workspace) via ADA_OIDC_ISSUER; a terminal user runs the
3
+ // device flow in a browser and the backend maps the verified ID token to a seat (see enterprise.ts
4
+ // upsertSeatForSSO). ID-token verification is stdlib-only (node:crypto RS256 + JWKS) — no new dep.
5
+ //
6
+ // Fail-closed contract (hardened after an adversarial red-team):
7
+ // - setting ADA_OIDC_ISSUER LOCKS the backend (see index.ts locked()), before any seat exists;
8
+ // - provisioning requires a POSITIVE group/domain match — an empty allowlist refuses to start,
9
+ // so a public/multi-tenant issuer can't JIT a seat for every account it will sign a token for;
10
+ // - multi-tenant issuers (Entra common/organizations) are rejected at config load;
11
+ // - seats key on `iss#sub` (issuer-scoped), so a reused `sub` across IdPs can't collide.
12
+
13
+ import { createPublicKey, verify as cryptoVerify } from "node:crypto";
14
+ import { isIP } from "node:net";
15
+
16
+ export interface OidcConfig {
17
+ issuer: string;
18
+ clientId: string;
19
+ audience: string;
20
+ allowedGroups: string[];
21
+ allowedDomains: string[];
22
+ adminGroup?: string;
23
+ groupClaim: string;
24
+ nameClaim: string;
25
+ jwksUri?: string;
26
+ clockSkewMs: number;
27
+ scope: string;
28
+ }
29
+
30
+ export interface OidcIdentity {
31
+ iss: string;
32
+ sub: string;
33
+ name: string;
34
+ email?: string;
35
+ groups: string[];
36
+ }
37
+
38
+ /** OIDC is the master opt-in. Gates locked() in index.ts so the backend never falls to dev-open. */
39
+ export function oidcEnabled(): boolean {
40
+ return !!process.env.ADA_OIDC_ISSUER;
41
+ }
42
+
43
+ function list(v: string | undefined): string[] {
44
+ return (v ?? "").split(",").map((s) => s.trim()).filter(Boolean);
45
+ }
46
+
47
+ function stripSlash(u: string): string {
48
+ return u.replace(/\/+$/, "");
49
+ }
50
+
51
+ /** Parse + VALIDATE the OIDC config from env. Throws (fail-closed) on any unsafe/incomplete config;
52
+ * call assertOidcConfig() once at startup so misconfiguration aborts the process instead of
53
+ * surfacing per-request. */
54
+ export function oidcConfig(): OidcConfig {
55
+ const issuer = process.env.ADA_OIDC_ISSUER;
56
+ if (!issuer) throw new Error("oidcConfig() called without ADA_OIDC_ISSUER");
57
+ if (!/^https:\/\//i.test(issuer)) throw new Error("ADA_OIDC_ISSUER must be an https URL");
58
+ // Multi-tenant issuers make `sub`/`iss` non-unique across tenants → reject (single-tenant only).
59
+ if (/login\.microsoftonline\.com\/(common|organizations)(\/|$)/i.test(issuer) || /\{tenant\}|\{tenantid\}/i.test(issuer)) {
60
+ throw new Error("multi-tenant OIDC issuer rejected — configure a concrete single-tenant issuer URL");
61
+ }
62
+ const clientId = process.env.ADA_OIDC_CLIENT_ID;
63
+ if (!clientId) throw new Error("ADA_OIDC_CLIENT_ID is required when ADA_OIDC_ISSUER is set");
64
+ const allowedGroups = list(process.env.ADA_OIDC_ALLOWED_GROUPS);
65
+ const allowedDomains = list(process.env.ADA_OIDC_ALLOWED_DOMAINS).map((d) => d.toLowerCase());
66
+ // Positive allow-surface is mandatory: without it JIT would provision a seat for every identity
67
+ // the IdP will sign a token for (esp. a public issuer). Refuse to start.
68
+ if (!allowedGroups.length && !allowedDomains.length) {
69
+ throw new Error("set ADA_OIDC_ALLOWED_GROUPS or ADA_OIDC_ALLOWED_DOMAINS — refusing to provision every IdP user (fail-closed)");
70
+ }
71
+ const jwksUri = process.env.ADA_OIDC_JWKS_URI;
72
+ if (jwksUri) assertSafeJwksUri(jwksUri);
73
+ return {
74
+ issuer: stripSlash(issuer),
75
+ clientId,
76
+ audience: process.env.ADA_OIDC_AUDIENCE ?? clientId,
77
+ allowedGroups,
78
+ allowedDomains,
79
+ adminGroup: process.env.ADA_OIDC_ADMIN_GROUP || undefined,
80
+ groupClaim: process.env.ADA_OIDC_GROUP_CLAIM ?? "groups",
81
+ nameClaim: process.env.ADA_OIDC_NAME_CLAIM ?? "",
82
+ jwksUri,
83
+ clockSkewMs: Number(process.env.ADA_OIDC_CLOCK_SKEW_MS) || 120_000,
84
+ scope: process.env.ADA_OIDC_SCOPE ?? "openid profile email",
85
+ };
86
+ }
87
+
88
+ /** Abort startup on bad OIDC config (called from index.ts). Returns true if OIDC is on and valid. */
89
+ export function assertOidcConfig(): boolean {
90
+ if (!oidcEnabled()) return false;
91
+ oidcConfig(); // throws on any problem
92
+ return true;
93
+ }
94
+
95
+ function isPrivateV4(host: string): boolean {
96
+ const p = host.split(".").map(Number);
97
+ if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true; // malformed → refuse
98
+ const [a, b] = p as [number, number, number, number];
99
+ return a === 0 || a === 127 || a === 10 || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31);
100
+ }
101
+
102
+ function isPrivateV6(host: string): boolean {
103
+ const h = host.toLowerCase();
104
+ if (h === "::1" || h === "::") return true; // loopback / unspecified
105
+ const mapped = /((?:\d{1,3}\.){3}\d{1,3})$/.exec(h);
106
+ if (h.startsWith("::") && mapped) return isPrivateV4(mapped[1]!); // IPv4-mapped/compat → classify embedded v4
107
+ const g = h.split(":")[0];
108
+ if (!g) return true; // other ::-prefixed low addresses — refuse conservatively
109
+ return /^fe[89ab]/.test(g) || /^f[cd]/.test(g); // fe80::/10 link-local, fc00::/7 ULA
110
+ }
111
+
112
+ // A jwks_uri must be https and must not point at loopback/link-local/private hosts — a lightweight
113
+ // SSRF guard for a typo'd/compromised issuer. Classification is against a PARSED IP (net.isIP),
114
+ // never a string prefix: WHATWG URL keeps IPv6 in brackets, and prefix tests both miss `[::1]` and
115
+ // falsely reject DNS names like `fcm.googleapis.com`. (We deliberately do NOT pin origin to the
116
+ // issuer: Google Workspace serves its JWKS from googleapis.com, a different origin than the issuer.)
117
+ export function assertSafeJwksUri(uri: string): void {
118
+ let u: URL;
119
+ try {
120
+ u = new URL(uri);
121
+ } catch {
122
+ throw new Error(`invalid jwks_uri: ${uri}`);
123
+ }
124
+ if (u.protocol !== "https:") throw new Error(`jwks_uri must be https: ${uri}`);
125
+ let host = u.hostname.toLowerCase();
126
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // unwrap IPv6 literal
127
+ const fam = isIP(host); // 4, 6, or 0 (DNS name)
128
+ if (fam === 0) {
129
+ if (host === "localhost" || host.endsWith(".local") || host.endsWith(".internal")) {
130
+ throw new Error(`jwks_uri host not allowed (loopback/internal): ${host}`);
131
+ }
132
+ return; // ordinary DNS name — resolves at fetch; the issuer is deployer-controlled
133
+ }
134
+ if (fam === 4 ? isPrivateV4(host) : isPrivateV6(host)) throw new Error(`jwks_uri host not allowed (private/loopback IP): ${host}`);
135
+ }
136
+
137
+ // ---- OIDC discovery (device/token/jwks endpoints), cached for the process ----
138
+ interface Discovery {
139
+ device_authorization_endpoint?: string;
140
+ token_endpoint?: string;
141
+ jwks_uri?: string;
142
+ }
143
+ let discoveryCache: { at: number; doc: Discovery } | null = null;
144
+ let discoveryInflight: Promise<Discovery> | null = null;
145
+ let discoveryFailUntil = 0;
146
+ const DISCOVERY_TTL = 3_600_000; // endpoints are stable; refresh hourly
147
+
148
+ /** Cached OIDC discovery. Reached from the UNAUTHENTICATED /v1/auth/methods, so concurrent callers
149
+ * share one in-flight fetch and a failure is negative-cached briefly — a cold cache during an IdP
150
+ * outage can't fan out to one outbound request per anonymous caller. */
151
+ export async function discover(): Promise<Discovery> {
152
+ if (discoveryCache && Date.now() - discoveryCache.at < DISCOVERY_TTL) return discoveryCache.doc;
153
+ if (Date.now() < discoveryFailUntil) throw new Error("OIDC discovery temporarily unavailable (cached failure)");
154
+ if (discoveryInflight) return discoveryInflight;
155
+ discoveryInflight = (async () => {
156
+ try {
157
+ const { issuer } = oidcConfig();
158
+ const r = await fetch(`${issuer}/.well-known/openid-configuration`, { signal: AbortSignal.timeout(8000) });
159
+ if (!r.ok) throw new Error(`OIDC discovery failed: HTTP ${r.status} at ${issuer}/.well-known/openid-configuration`);
160
+ const doc = (await r.json()) as Discovery;
161
+ if (doc.jwks_uri) assertSafeJwksUri(doc.jwks_uri);
162
+ discoveryCache = { at: Date.now(), doc };
163
+ return doc;
164
+ } catch (e) {
165
+ discoveryFailUntil = Date.now() + 10_000; // negative-cache 10s to stop pre-auth fan-out
166
+ throw e;
167
+ } finally {
168
+ discoveryInflight = null;
169
+ }
170
+ })();
171
+ return discoveryInflight;
172
+ }
173
+
174
+ // ---- JWKS cache with a fetch rate-cap (an attacker-chosen `kid` can't force unbounded refetches) ----
175
+ type Jwk = Record<string, unknown> & { kid?: string; kty?: string; use?: string; alg?: string };
176
+ const jwksCache = new Map<string, Jwk>();
177
+ let lastJwksFetch = 0;
178
+ const JWKS_MIN_REFETCH_MS = 60_000;
179
+
180
+ async function refreshJwks(): Promise<void> {
181
+ lastJwksFetch = Date.now();
182
+ const uri = oidcConfig().jwksUri ?? (await discover()).jwks_uri;
183
+ if (!uri) throw new Error("no jwks_uri (set ADA_OIDC_JWKS_URI or fix discovery)");
184
+ assertSafeJwksUri(uri);
185
+ const r = await fetch(uri, { signal: AbortSignal.timeout(8000) });
186
+ if (!r.ok) throw new Error(`JWKS fetch failed: HTTP ${r.status}`);
187
+ const { keys } = (await r.json()) as { keys?: Jwk[] };
188
+ for (const k of keys ?? []) {
189
+ if (k.kty === "RSA" && k.use !== "enc" && k.kid) jwksCache.set(k.kid, k);
190
+ }
191
+ }
192
+
193
+ async function defaultGetKey(kid: string): Promise<Jwk | null> {
194
+ const hit = jwksCache.get(kid);
195
+ if (hit) return hit;
196
+ if (Date.now() - lastJwksFetch > JWKS_MIN_REFETCH_MS) {
197
+ await refreshJwks();
198
+ return jwksCache.get(kid) ?? null;
199
+ }
200
+ return null; // rate-capped: unknown kid, refetched too recently
201
+ }
202
+
203
+ function b64urlJson(part: string): Record<string, unknown> | null {
204
+ try {
205
+ return JSON.parse(Buffer.from(part, "base64url").toString("utf8")) as Record<string, unknown>;
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
210
+
211
+ /** Verify an OIDC ID token → identity, or null on ANY failure (bad alg/sig/claims). `opts.getKey`
212
+ * and `opts.now` are injectable for hermetic tests; production uses the JWKS cache + wall clock. */
213
+ export async function verifyOidcToken(
214
+ idToken: string,
215
+ opts: { getKey?: (kid: string) => Promise<Jwk | null> | Jwk | null; now?: number } = {},
216
+ ): Promise<OidcIdentity | null> {
217
+ const cfg = oidcConfig();
218
+ const now = opts.now ?? Date.now();
219
+ const getKey = opts.getKey ?? defaultGetKey;
220
+
221
+ const parts = idToken.split(".");
222
+ if (parts.length !== 3) return null;
223
+ const [h, p, s] = parts as [string, string, string];
224
+ const header = b64urlJson(h);
225
+ const payload = b64urlJson(p);
226
+ if (!header || !payload) return null;
227
+ if (header.alg !== "RS256") return null; // allowlist RS256 only — reject "none" and HS* (key confusion)
228
+
229
+ const kid = typeof header.kid === "string" ? header.kid : "";
230
+ if (!kid) return null;
231
+ let jwk: Jwk | null;
232
+ try {
233
+ jwk = await getKey(kid);
234
+ } catch {
235
+ return null; // JWKS/network error ⇒ deny
236
+ }
237
+ if (!jwk) return null;
238
+
239
+ // Verify the RS256 signature over `header.payload`.
240
+ let ok = false;
241
+ try {
242
+ const pub = createPublicKey({ key: jwk, format: "jwk" } as unknown as import("node:crypto").JsonWebKeyInput);
243
+ ok = cryptoVerify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, Buffer.from(s, "base64url"));
244
+ } catch {
245
+ return null;
246
+ }
247
+ if (!ok) return null;
248
+
249
+ // Claims.
250
+ const skew = cfg.clockSkewMs;
251
+ if (payload.iss !== cfg.issuer) return null;
252
+ const aud = payload.aud;
253
+ const audArr = Array.isArray(aud) ? aud.map(String) : typeof aud === "string" ? [aud] : [];
254
+ if (!audArr.includes(cfg.audience)) return null;
255
+ // If aud carries extra resource ids we don't own AND azp is absent, reject (token minted for a
256
+ // different client). When azp is present it must be our client id.
257
+ if (typeof payload.azp === "string") {
258
+ if (payload.azp !== cfg.clientId) return null;
259
+ } else if (audArr.length > 1) {
260
+ return null;
261
+ }
262
+ if (typeof payload.exp !== "number" || payload.exp * 1000 <= now - skew) return null;
263
+ if (typeof payload.nbf === "number" && payload.nbf * 1000 > now + skew) return null;
264
+ if (typeof payload.iat === "number" && payload.iat * 1000 > now + skew) return null;
265
+ if (typeof payload.sub !== "string" || !payload.sub) return null;
266
+
267
+ const groupsRaw = payload[cfg.groupClaim];
268
+ const groups = Array.isArray(groupsRaw) ? groupsRaw.map(String) : typeof groupsRaw === "string" ? [groupsRaw] : [];
269
+ // Only trust `email` when the IdP marks it verified — domain-based provisioning (isProvisionAllowed)
270
+ // matches on the email domain, and a self-service IdP will happily sign a token for an unverified
271
+ // attacker@corp.com. IdPs that omit email_verified simply can't use the domain allowlist (use groups).
272
+ const email = payload.email_verified === true && typeof payload.email === "string" ? payload.email : undefined;
273
+ const name =
274
+ (cfg.nameClaim && typeof payload[cfg.nameClaim] === "string" && (payload[cfg.nameClaim] as string)) ||
275
+ email ||
276
+ (typeof payload.preferred_username === "string" ? payload.preferred_username : undefined) ||
277
+ (payload.sub as string);
278
+
279
+ return { iss: cfg.issuer, sub: payload.sub, name, email, groups };
280
+ }
281
+
282
+ /** Positive, fail-closed membership: true iff the identity is in an allowed group OR email domain.
283
+ * oidcConfig() already refuses to start with an empty allow-surface, so this is never vacuously true. */
284
+ export function isProvisionAllowed(id: OidcIdentity): boolean {
285
+ const cfg = oidcConfig();
286
+ if (cfg.allowedGroups.length && id.groups.some((g) => cfg.allowedGroups.includes(g))) return true;
287
+ if (cfg.allowedDomains.length && id.email) {
288
+ const domain = id.email.split("@")[1]?.toLowerCase();
289
+ if (domain && cfg.allowedDomains.includes(domain)) return true;
290
+ }
291
+ return false;
292
+ }
293
+
294
+ /** Map a verified identity to seat fields. externalId is issuer-scoped (`iss#sub`); admin role only
295
+ * when the configured admin group is present in the token. */
296
+ export function mapIdentityToSeatFields(id: OidcIdentity): { externalId: string; iss: string; name: string; role: "admin" | "dev" } {
297
+ const cfg = oidcConfig();
298
+ const role: "admin" | "dev" = cfg.adminGroup && id.groups.includes(cfg.adminGroup) ? "admin" : "dev";
299
+ return { externalId: `${id.iss}#${id.sub}`, iss: id.iss, name: id.name, role };
300
+ }
@@ -114,6 +114,8 @@ export const anthropicAdapter: Adapter = {
114
114
 
115
115
  let stop = "stop";
116
116
  let toolIndex = -1;
117
+ let inTokens = 0; // Anthropic reports input on message_start, cumulative output on message_delta
118
+ let outTokens = 0;
117
119
 
118
120
  try {
119
121
  const client = await getClient();
@@ -148,7 +150,9 @@ export const anthropicAdapter: Adapter = {
148
150
  );
149
151
 
150
152
  for await (const event of stream) {
151
- if (event.type === "content_block_start") {
153
+ if (event.type === "message_start") {
154
+ inTokens = (event.message as { usage?: { input_tokens?: number } }).usage?.input_tokens ?? 0;
155
+ } else if (event.type === "content_block_start") {
152
156
  const cb = event.content_block as { type: string; id?: string; name?: string };
153
157
  if (cb.type === "tool_use") {
154
158
  toolIndex++;
@@ -161,10 +165,15 @@ export const anthropicAdapter: Adapter = {
161
165
  } else if (event.type === "message_delta") {
162
166
  const reason = (event.delta as { stop_reason?: string | null }).stop_reason;
163
167
  if (reason) stop = mapStop(reason);
168
+ const ot = (event as { usage?: { output_tokens?: number } }).usage?.output_tokens;
169
+ if (typeof ot === "number") outTokens = ot; // cumulative — take the latest
164
170
  }
165
171
  }
166
172
 
167
173
  chunk({}, stop);
174
+ // Emit an OpenAI-shaped usage chunk so the backend's metering (and the client's own token
175
+ // counters) work for Claude too — Anthropic doesn't send one in this wire format.
176
+ writeChunk(res, { id, object: "chat.completion.chunk", created, model, choices: [], usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } });
168
177
  endStream(res);
169
178
  } catch (err) {
170
179
  chunk({ content: `\n[backend: anthropic error: ${err instanceof Error ? err.message : String(err)}]` }, "stop");
@@ -42,14 +42,25 @@ export const openAICompatAdapter: Adapter = {
42
42
  // endpoint wants the bare id. (Cloudflare's "@cf/…" ids aren't "cloudflare/…", so they pass through.)
43
43
  const prefix = `${provider}/`;
44
44
  const outBody = typeof body.model === "string" && body.model.startsWith(prefix) ? { ...body, model: body.model.slice(prefix.length) } : body;
45
+ // If the client goes away, abort the upstream too — else the full completion is generated,
46
+ // billed, and (for enterprise) metered against a request nobody is reading.
47
+ const ac = new AbortController();
48
+ res.on("close", () => {
49
+ if (!res.writableEnded) ac.abort();
50
+ });
45
51
  let upstream: Awaited<ReturnType<typeof fetch>>;
46
52
  try {
47
53
  upstream = await fetch(`${def.baseURL}/chat/completions`, {
48
54
  method: "POST",
49
55
  headers: { "content-type": "application/json", ...(await authHeaders(provider)) },
50
56
  body: JSON.stringify(outBody),
57
+ signal: ac.signal,
51
58
  });
52
59
  } catch (e) {
60
+ if (ac.signal.aborted) {
61
+ res.end();
62
+ return;
63
+ }
53
64
  res.writeHead(502, { "content-type": "application/json" });
54
65
  res.end(
55
66
  JSON.stringify({
@@ -71,10 +82,18 @@ export const openAICompatAdapter: Adapter = {
71
82
  if (body.stream) {
72
83
  res.writeHead(200, SSE_HEADERS);
73
84
  const reader = upstream.body.getReader();
74
- for (;;) {
75
- const { done, value } = await reader.read();
76
- if (done) break;
77
- if (value) res.write(Buffer.from(value));
85
+ try {
86
+ for (;;) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ if (res.destroyed) {
90
+ await reader.cancel(); // client gone → stop pulling tokens from upstream
91
+ break;
92
+ }
93
+ if (value) res.write(Buffer.from(value));
94
+ }
95
+ } catch {
96
+ /* aborted mid-stream (client closed) — nothing more to do */
78
97
  }
79
98
  res.end();
80
99
  } else {