ada-agent 0.7.0 → 0.9.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.
package/README.md CHANGED
@@ -252,6 +252,16 @@ model allowlist + tool rules pushed to every client), per-user usage metering, a
252
252
  activated only when you create seats, file-backed, self-hosted in your own network. See
253
253
  **[docs/enterprise.md](docs/enterprise.md)** for the 2-minute bootstrap.
254
254
 
255
+ **SSO (OIDC)** — federate login to your IdP (Okta, Entra single-tenant, Auth0, Keycloak, Google
256
+ Workspace): `ADA_OIDC_ISSUER=… ada-server`, then developers run `ada login oidc`. The backend
257
+ JIT-provisions a seat for the verified identity and offboarding is immediate. Stdlib-only RS256/JWKS
258
+ verification, no new dependency; fail-closed by construction. See
259
+ **[docs/enterprise-stage2-oidc.md](docs/enterprise-stage2-oidc.md)**.
260
+
261
+ **Deploy** — a `Dockerfile` + `docker-compose.yml` run the backend anywhere (`docker compose up`);
262
+ point clients at it with `ADA_BACKEND_URL` / `ada.backendUrl`. Use Cloudflare Workers AI models
263
+ (`@cf/*`) or an AI Gateway with zero code change. See **[docs/deploy.md](docs/deploy.md)**.
264
+
255
265
  ## Benchmarks
256
266
 
257
267
  ada can run **SWE-bench Verified** — it generates patches for real GitHub issues (one isolated repo
package/docs/deploy.md ADDED
@@ -0,0 +1,84 @@
1
+ # Deploying ada-server
2
+
3
+ `ada-server` is the routing backend — it holds provider keys and speaks the OpenAI-compatible API
4
+ that every ada client (the `ada` CLI **and** the ada IDE) points at. It's a small Node HTTP server;
5
+ this guide runs it in a container. Clients then set `ADA_BACKEND_URL` (CLI) or `ada.backendUrl` (IDE)
6
+ to its URL.
7
+
8
+ ## Quick start (container)
9
+
10
+ ```bash
11
+ cp .env.example .env # add at least one provider key (see below)
12
+ docker compose up --build # → http://localhost:8787
13
+
14
+ # point a client at it
15
+ ADA_BACKEND_URL=http://localhost:8787/v1 ada
16
+ ```
17
+
18
+ Or without compose:
19
+
20
+ ```bash
21
+ docker build -t ada-server .
22
+ docker run -p 8787:8787 -v ada-data:/data --env-file .env ada-server
23
+ ```
24
+
25
+ The image is **server-only** (~small, `node:22-slim`, no native build) — it drops the `node-pty`
26
+ client tool and the `skills/` bundle it doesn't need.
27
+
28
+ ## Configuration (env)
29
+
30
+ | Var | Purpose |
31
+ |---|---|
32
+ | provider keys | e.g. `CLOUDFLARE_ACCOUNT_ID`+`CLOUDFLARE_API_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, … — set what you use. Every provider + its key env is in [`src/server/config.ts`](../src/server/config.ts). |
33
+ | `ADA_PORT` | listen port (default `8787`). |
34
+ | `ADA_DATA_DIR` | where seats/policy/usage/audit live (default `/data` in the image). **Mount a volume here** — see persistence. |
35
+ | `ADA_ADMIN_KEY` | bootstrap admin key → enables the enterprise control plane ([enterprise.md](enterprise.md)). |
36
+ | `ADA_OIDC_*` | OIDC SSO ([enterprise-stage2-oidc.md](enterprise-stage2-oidc.md)). |
37
+
38
+ ## Persistence — mount `/data`
39
+
40
+ The stores are flat JSON/JSONL under `ADA_DATA_DIR`. In a container that directory is **ephemeral**
41
+ unless you mount a volume — without one, seats, usage, and the audit log are lost on restart. The
42
+ compose file and the `docker run -v ada-data:/data` above handle this. On a PaaS, attach a persistent
43
+ disk/volume mounted at `/data`.
44
+
45
+ > If your platform has no persistent volume (e.g. plain Cloudflare Workers), that's the signal to move
46
+ > to the Workers + D1/KV port below rather than the container.
47
+
48
+ ## Cloudflare
49
+
50
+ Two independent things, don't conflate them:
51
+
52
+ **1. Using Cloudflare's models (no code, works today).** Set `CLOUDFLARE_ACCOUNT_ID` +
53
+ `CLOUDFLARE_API_TOKEN` and request `@cf/*` model ids (e.g. `@cf/meta/llama-3.3-70b-instruct`) — the
54
+ router sends them to Workers AI. To route *all* providers through a **Cloudflare AI Gateway** (for
55
+ unified logging, caching, rate-limiting), point `CLOUDFLARE_BASE_URL` at your gateway URL.
56
+
57
+ **2. Hosting the container.**
58
+ - **Easiest durable path (recommended now):** any container host with a persistent volume — **Fly.io**,
59
+ **Render**, **Railway**. Deploy the Dockerfile, attach a volume at `/data`, set env, done. Put TLS
60
+ in front (the platform usually does this for you).
61
+ - **Cloudflare Containers** (beta): runnable via a Worker + container binding, but Cloudflare's model
62
+ is stateless-leaning — durable seat/usage/audit state wants **R2/D1**, not a container disk. For a
63
+ Cloudflare-native, stateful deploy, prefer the port below.
64
+
65
+ ### Phase 2 — Workers-native port (planned)
66
+
67
+ The container is deliberately step one. The edge-native version swaps:
68
+
69
+ - the Node HTTP server → a Workers `fetch` handler,
70
+ - the file-backed stores (already isolated behind `dataDir()` in `enterprise.ts`) → **D1** (seats /
71
+ policy / usage / audit) or **KV**,
72
+ - streaming metering (currently a `res.write` tee) → **AI Gateway** request logs,
73
+ - and uses **Workers AI** directly for `@cf/*`.
74
+
75
+ Tracked as a follow-up; the container unblocks distribution first.
76
+
77
+ ## Hardening
78
+
79
+ - Terminate **TLS** in front (Caddy/nginx, or the PaaS) — seat keys travel as bearer tokens.
80
+ - Back up the `/data` volume (four small files).
81
+ - Lock it down: set `ADA_ADMIN_KEY` or `ADA_OIDC_*` so the backend isn't dev-open (see
82
+ [enterprise.md](enterprise.md)).
83
+ - To run non-root, add `USER node` to the Dockerfile and ensure the mounted volume is writable by
84
+ uid 1000.
@@ -0,0 +1,135 @@
1
+ # Enterprise Stage 2 — OIDC SSO + JIT seat provisioning
2
+
3
+ Status: **shipped in 0.8.0**. This is the design record; the operator runbook is in
4
+ [enterprise.md](enterprise.md#sso-oidc--federate-login-to-your-idp).
5
+
6
+ ## Why this, and why not SAML
7
+
8
+ After Stage 1 (seats, model allowlist, provider pinning, metering, audit), the #1 enterprise
9
+ security-review gate for a tool that **holds provider API keys** and **reads source code** is
10
+ centralized identity with provable, timely deprovisioning — *"when Alice is offboarded, is her access
11
+ gone in minutes?"* Static `ada_sk_` seat keys can't answer that.
12
+
13
+ OIDC beat the alternatives on unblock-value ÷ cost, grounded in the real code:
14
+
15
+ - **vs SAML** — a terminal has no device grant, so SAML needs a brand-new loopback auth-code+PKCE
16
+ flow *and* an XML-DSig / exclusive-C14N assertion verifier that Node's stdlib can't provide (forcing
17
+ a dependency or a hand-rolled, XSW-prone verifier). OIDC reuses the existing RFC 8628 device flow
18
+ (`oauth.ts`) and needs only stdlib RS256/JWKS verification. Most SAML-mandating IdPs speak OIDC too.
19
+ SAML is deferred to a fast-follow for a contractually SAML-only account.
20
+ - **vs a seatless verify-only login** — real buyers gate on *demonstrable* deprovisioning (SOC 2
21
+ CC6.3 / ISO 27001 A.9.2.6). Verify-only bounds revocation by IdP token lifetime with no kill switch.
22
+ We take exactly one rung more: a JIT seat + an immediate `disable-by-externalId` admin endpoint.
23
+
24
+ Ranking: **oidc-sso (build now) > SCIM (Stage 3) > audit→SIEM export (Stage 4) > SAML (Stage 5a) >
25
+ durable-store/HA (Stage 5c)**.
26
+
27
+ ## Goals / non-goals
28
+
29
+ **Goals:** federate login to the org's single-tenant OIDC IdP via the device flow; JIT-provision a
30
+ seat keyed to a stable, non-secret, issuer-scoped `externalId` (`iss#sub`); fail closed everywhere;
31
+ RS256/JWKS verification with **zero new dependencies**; an immediate admin deprovision path.
32
+
33
+ **Non-goals (sequenced later, and honest about it):** SCIM auto-provisioning (Stage 3); SIEM/audit
34
+ **export** (Stage 4 — Stage 2 only *appends* login events, it makes no SIEM-readiness claim); SAML
35
+ (5a); secrets-at-rest encryption of `credentials.json`/`users.json` (5b); Postgres/HA (5c);
36
+ multi-tenant issuers (rejected at load).
37
+
38
+ ## Architecture — model B (seat key as the durable bearer)
39
+
40
+ The ID token is a **one-time provisioning artifact**, not a per-request credential. This is what makes
41
+ headless work and keeps the long-lived replayable secret a *server-minted, disableable* seat key
42
+ rather than a stealable `id_token`.
43
+
44
+ ```
45
+ 1. Client startup: GET /v1/whoami → 401 (backend locked because ADA_OIDC_ISSUER is set).
46
+ 2. Client GET /v1/auth/methods (unauth) → { methods:["oidc"], oidc:{ issuer, clientId,
47
+ deviceAuthEndpoint, tokenEndpoint, scope, exchangePath } }. ← client needs NO OIDC env of its own
48
+ 3. `ada login oidc` runs the device flow against the IdP (browser; MFA/conditional-access enforced
49
+ BY the IdP). Gets { id_token, access_token, ... }.
50
+ 4. Client POST {exchangePath} Authorization: Bearer <id_token>
51
+ backend: verifyOidcToken → isProvisionAllowed (group/domain) → upsertSeatForSSO(iss#sub)
52
+ → audit sso_login → 200 { seat_key: "ada_sk_…", user, role }
53
+ client: store the SEAT KEY under the "oidc" credential (not the id_token).
54
+ 5. Every later request (chat/embeddings/models/whoami, and ALL of -p / serve / acp) sends the
55
+ ada_sk_ seat key through the hardened identifySeat hot path. No per-request JWKS/RSA, no network.
56
+ 6. Offboarding: admin POST /v1/users/disable-by-external { externalId } → next request 401; re-login
57
+ refused (disabled seat is never resurrected).
58
+ ```
59
+
60
+ ## Files, endpoints, env
61
+
62
+ **New** `src/server/oidc.ts` (stdlib-only): `oidcEnabled()`, `oidcConfig()` (validates + fails closed),
63
+ `assertOidcConfig()` (startup abort), `discover()` (cached openid-configuration), `verifyOidcToken()`
64
+ (RS256/JWKS with a `getKey`/`now` seam for tests), `isProvisionAllowed()`, `mapIdentityToSeatFields()`.
65
+
66
+ **Changed** `src/server/enterprise.ts`: `Seat` gains `externalId?`/`iss?`; `seatByExternalId()`,
67
+ `upsertSeatForSSO()`, `disableSeatByExternalId()`. `index.ts`: `locked() += oidcEnabled()`; `identify()`
68
+ skips GitHub/Google when OIDC is on; pre-auth `GET /v1/auth/methods` + `POST /v1/auth/oidc/exchange`;
69
+ admin `POST /v1/users/disable-by-external`; startup `assertOidcConfig()`. `oauth.ts`: `deviceGrant()`
70
+ (returns the raw token response incl. `id_token`); `deviceLogin()` builds on it. `cli.ts`:
71
+ `identityToken()` returns the OIDC seat key; `oidcLogin()` + `ensureAuth()` self-configure from
72
+ `/v1/auth/methods`.
73
+
74
+ | Endpoint | Auth | Purpose |
75
+ |---|---|---|
76
+ | `GET /v1/auth/methods` | none | advertise enabled login methods + public OIDC endpoints |
77
+ | `POST /v1/auth/oidc/exchange` | Bearer = `id_token` | verify → provision → return a seat key (once). The ONLY JWT entry point. |
78
+ | `POST /v1/users/disable-by-external` | admin | immediate offboarding by `iss#sub` |
79
+
80
+ | Env | Purpose | Gate |
81
+ |---|---|---|
82
+ | `ADA_OIDC_ISSUER` | master opt-in; `iss` + discovery. **https, single-tenant only.** | unset ⇒ inert; multi-tenant ⇒ refuse start |
83
+ | `ADA_OIDC_CLIENT_ID` | device-flow app id; expected `aud`/`azp` | required with issuer |
84
+ | `ADA_OIDC_ALLOWED_GROUPS` | groups permitted to provision | **unset + no domains ⇒ refuse start** |
85
+ | `ADA_OIDC_ALLOWED_DOMAINS` | alt allow-surface (email domains) | — |
86
+ | `ADA_OIDC_ADMIN_GROUP` | group ⇒ `role:"admin"` | unset ⇒ all `dev` |
87
+ | `ADA_OIDC_GROUP_CLAIM` / `ADA_OIDC_NAME_CLAIM` | claim names | `groups` / email→preferred_username→sub |
88
+ | `ADA_OIDC_JWKS_URI` / `ADA_OIDC_AUDIENCE` / `ADA_OIDC_CLOCK_SKEW_MS` / `ADA_OIDC_SCOPE` | overrides | discovered / =client id / 120000 / `openid profile email` |
89
+
90
+ ## Security model — the fail-closed points (each resolves a red-team finding)
91
+
92
+ - **Backend locks on OIDC.** `locked()` includes `oidcEnabled()`, so a fresh SSO deployment with zero
93
+ seats never falls to the dev-open `{user:"dev"}` branch. *(Fails closed at `locked()`.)*
94
+ - **No open provisioning.** `isProvisionAllowed()` requires a **positive** group/domain match, and the
95
+ server **refuses to start** without an allow-surface — a public issuer can't JIT a seat for every
96
+ account it will sign a token for. Domain matches require a **verified** email (`email_verified:true`
97
+ in the token) so a self-service IdP can't provision an unverified `attacker@corp.com`; IdPs that omit
98
+ the claim must use a group allowlist. *(Fails closed at startup + per exchange.)*
99
+ - **Legacy shared keys refused under SSO.** `ADA_CLIENT_KEYS` is ignored whenever OIDC is on (single
100
+ identity authority), so a still-configured shared key can't bypass verification before the first
101
+ seat is minted.
102
+ - **Issuer-scoped identity.** `externalId = iss#sub`; multi-tenant issuers rejected at load — a reused
103
+ `sub` across tenants can't collapse two people onto one seat. *(Fails closed at config load.)*
104
+ - **Immediate, demonstrable deprovisioning.** Model B + `disableSeatByExternalId` +
105
+ `/v1/users/disable-by-external`: a disabled seat 401s on the next request and re-login is refused,
106
+ independent of token lifetime. *(Fails closed in `identifySeat`/`upsertSeatForSSO`.)*
107
+ - **One JWT entry point.** The `id_token` is accepted only at `/v1/auth/oidc/exchange`; it never
108
+ reaches the per-request identity path, so it can't be forwarded to GitHub/Google verification.
109
+ - **Verifier hardening.** `alg` allowlisted to RS256 (rejects `none`/`HS*` key-confusion); `aud` must
110
+ include the client id; `azp` (when present) must equal the client id, and a multi-audience token
111
+ without `azp` is rejected; `exp`/`nbf`/`iat` checked with bounded skew. JWKS fetches are rate-capped
112
+ (≤1 refetch/60s) so an attacker-chosen `kid` can't amplify fetches; `jwks_uri` must be https and is
113
+ blocked from loopback/private hosts (a lightweight SSRF guard — origin is not pinned to the issuer
114
+ because Google Workspace serves JWKS from a different origin).
115
+ - **One identity authority.** GitHub/Google login is disabled while OIDC is on, so a person disabled
116
+ in the IdP can't re-enter via a still-allowed GitHub account. Admin role is downgraded to dev on a
117
+ login that no longer carries the admin group (privilege revocation); escalation stays an explicit
118
+ admin action.
119
+
120
+ ## Verification
121
+
122
+ `npm run typecheck` + `npm run selfcheck` (hermetic: JIT mint/reuse/no-escalation, immediate
123
+ deprovision-by-externalId, prototype-safe scan, and a full RS256 verify test with a local keypair +
124
+ injected JWKS covering tamper/wrong-aud/`alg=none`/expiry → all `null`). Live-verified: OIDC-locked
125
+ backend 401s a tokenless request, fail-closed startup on missing allow-surface and multi-tenant
126
+ issuer, real-Google discovery + JWKS guard, exchange rejects a bogus token.
127
+
128
+ ## Roadmap
129
+
130
+ - **Stage 3 — SCIM 2.0**: admin-gated `/scim/v2/Users` driving `createSeat`/`disableSeatByExternalId`
131
+ on the `iss#sub` index shipped here (no migration). Automates joiner/mover/leaver.
132
+ - **Stage 4 — audit/usage SIEM export**: tee `appendAudit`/`appendUsage` to syslog/webhook/OTLP-JSON;
133
+ audit hash-chain + `GET /v1/audit/verify`. Carries the Stage 2/3 `sso_*`/`scim_*` events.
134
+ - **Stage 5** — SAML (named account only); secrets-at-rest encryption; Postgres/HA at the `dataDir()`
135
+ seam.
@@ -41,6 +41,57 @@ DELETE /v1/users/<keyPrefix> # disable a seat (≥12 chars of its key; kept fo
41
41
  Full keys are never listed after creation — only a 14-char prefix. `ADA_ADMIN_KEY` is the
42
42
  break-glass admin; create an admin *seat* for day-to-day and keep the env key in a vault.
43
43
 
44
+ ## SSO (OIDC) — federate login to your IdP
45
+
46
+ Instead of handing out static seat keys, connect your OIDC IdP (Okta, Entra **single-tenant**, Auth0,
47
+ Keycloak, Google Workspace). Developers sign in through the browser via the device flow, and the
48
+ backend **JIT-provisions a seat** for the verified identity. Seats are keyed to a stable, non-secret
49
+ `iss#sub`, so offboarding has a target.
50
+
51
+ ```bash
52
+ # On the backend (all values are non-secret except any confidential-client secret):
53
+ export ADA_OIDC_ISSUER=https://your-tenant.okta.com # single-tenant issuer URL (https)
54
+ export ADA_OIDC_CLIENT_ID=<device-flow app client id>
55
+ export ADA_OIDC_ALLOWED_GROUPS=ada-users # OR ADA_OIDC_ALLOWED_DOMAINS=yourco.com
56
+ export ADA_OIDC_ADMIN_GROUP=ada-admins # optional: this group → role "admin"
57
+ ada-server # banner: [OIDC SSO (0 seats — awaiting first login)]
58
+ ```
59
+
60
+ **Setting `ADA_OIDC_ISSUER` locks the backend immediately** (before any seat exists) — a request
61
+ that doesn't authenticate gets `401`, never dev-open. Two fail-closed guards refuse to start on unsafe
62
+ config: no `ALLOWED_GROUPS`/`ALLOWED_DOMAINS` (would provision every IdP user), or a **multi-tenant**
63
+ issuer (`.../common` or `.../organizations`, where `sub` isn't unique). When OIDC is on, the
64
+ GitHub/Google login path is disabled — the IdP is the single identity authority.
65
+
66
+ The developer needs **no OIDC config** — the client learns it from the backend:
67
+
68
+ ```bash
69
+ export ADA_BACKEND_URL=https://ada.yourcompany.com/v1
70
+ ada login oidc # opens the IdP in a browser; on success stores a seat key. Then just `ada`.
71
+ ```
72
+
73
+ Under the hood the ID token is a **one-time provisioning artifact**: `ada login oidc` exchanges it at
74
+ `POST /v1/auth/oidc/exchange` for a durable `ada_sk_` seat key, and every later request (including
75
+ `-p`, `serve`, `acp`) carries that seat key — so long headless runs never expire mid-job, and
76
+ revocation is a seat-disable, not a token-lifetime wait.
77
+
78
+ **Offboarding (immediate):** disable the seat by its `iss#sub`. The next request 401s and re-login is
79
+ refused (a disabled seat is never resurrected).
80
+
81
+ ```bash
82
+ curl -X POST -H "Authorization: Bearer $ADA_ADMIN_KEY" \
83
+ -d '{"externalId":"https://your-tenant.okta.com#00u1a2b3c4"}' \
84
+ http://localhost:8787/v1/users/disable-by-external
85
+ ```
86
+
87
+ `GET /v1/auth/methods` (unauthenticated) advertises the enabled methods. ID-token verification is
88
+ stdlib-only (RS256 + JWKS, `alg` allowlisted, `iss`/`aud`/`azp`/`exp`/`nbf` checked) — no new
89
+ dependency. Full env reference and the security model: [enterprise-stage2-oidc.md](enterprise-stage2-oidc.md).
90
+
91
+ Automated joiner/mover/leaver (**SCIM**) and audit **export to a SIEM** are the sequenced next stages;
92
+ today login events (`sso_login`, `sso_login_denied`, `seat_created`, `seat_disabled`) are appended to
93
+ the audit log but not yet streamed out.
94
+
44
95
  ## Org policy
45
96
 
46
97
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ada-agent",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A from-zero terminal coding agent with a Cursor-style routing backend, ~285 skills, MCP connectors, and ask/plan/auto modes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,10 +56,12 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@anthropic-ai/sdk": "^0.106.0",
59
- "node-pty": "^1.1.0",
60
59
  "openai": "^6.45.0",
61
60
  "tsx": "^4.22.4"
62
61
  },
62
+ "optionalDependencies": {
63
+ "node-pty": "^1.1.0"
64
+ },
63
65
  "devDependencies": {
64
66
  "@types/node": "^26.0.1",
65
67
  "typescript": "^6.0.3"
package/src/client/cli.ts CHANGED
@@ -12,8 +12,8 @@ import { Agent, type AgentEvent, type ApprovalDecision, type OnApprove } from ".
12
12
  import { ApprovalRegistry, newId, sseFrame } from "./agent-server.ts";
13
13
  import { expandPrompt, loadPrompts } from "./prompts.ts";
14
14
  import { Session, list, type SessionMeta } from "./session.ts";
15
- import { deleteCredential, getCredential, listCredentials } from "../server/credentials.ts";
16
- import { deviceLogin, oauthConfig } from "../server/oauth.ts";
15
+ import { deleteCredential, getCredential, listCredentials, setCredential } from "../server/credentials.ts";
16
+ import { deviceGrant, deviceLogin, oauthConfig } from "../server/oauth.ts";
17
17
  import { addTrust, isTrusted, loadSettings, setActiveAgentPermissions, setOrgPermissions, type PermRule, type Settings } from "./settings.ts";
18
18
  import { getCommands, loadExtensions } from "./extensions.ts";
19
19
  import { registerTool, setAsker } from "./tools.ts";
@@ -36,8 +36,12 @@ type RL = ReturnType<typeof createInterface>;
36
36
 
37
37
  const BACKEND = process.env.ADA_BACKEND_URL ?? "http://localhost:8787/v1";
38
38
 
39
- /** A stored GitHub/Google login token, sent as the bearer so the backend can identify us. */
39
+ /** A stored login credential, sent as the bearer so the backend can identify us. An OIDC SSO seat
40
+ * key (ada_sk_…) wins — it's a durable, server-minted, disableable bearer (see oidcLogin). A seat
41
+ * key is only honored by the backend that minted it, so a stray one sent elsewhere just 401s. */
40
42
  function identityToken(): string | undefined {
43
+ const oidc = getCredential("oidc");
44
+ if (oidc?.type === "oauth" && oidc.key) return oidc.key;
41
45
  for (const p of ["github", "google"]) {
42
46
  const c = getCredential(p);
43
47
  if (c?.type === "oauth" && c.access) return c.access;
@@ -353,8 +357,52 @@ function makeClient(): OpenAI {
353
357
  return new OpenAI({ baseURL: BACKEND, apiKey: clientKey(), maxRetries: 0 });
354
358
  }
355
359
 
356
- /** Run the device flow for `provider`; returns true on success (token stored). */
360
+ interface AuthMethods {
361
+ methods: string[];
362
+ oidc?: { issuer: string; clientId: string; deviceAuthEndpoint: string; tokenEndpoint: string; scope: string; exchangePath: string };
363
+ oidcError?: string;
364
+ }
365
+
366
+ /** Ask the backend which login methods it offers (so the client needs no OIDC env of its own). */
367
+ async function fetchAuthMethods(): Promise<AuthMethods | null> {
368
+ try {
369
+ const r = await fetch(`${BACKEND}/auth/methods`, { signal: AbortSignal.timeout(4000) });
370
+ if (!r.ok) return null;
371
+ return (await r.json()) as AuthMethods;
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+
377
+ /** OIDC SSO login (model B): device-flow against the org IdP using the backend-advertised config,
378
+ * then exchange the id_token for a durable seat key stored under the "oidc" credential. */
379
+ async function oidcLogin(): Promise<boolean> {
380
+ const m = await fetchAuthMethods();
381
+ if (!m?.oidc) {
382
+ console.log(m?.oidcError ? `SSO unavailable: ${m.oidcError}` : "this backend does not offer OIDC SSO.");
383
+ return false;
384
+ }
385
+ const { clientId, deviceAuthEndpoint, tokenEndpoint, scope, exchangePath } = m.oidc;
386
+ try {
387
+ const tok = await deviceGrant("SSO", { clientId, deviceUrl: deviceAuthEndpoint, tokenUrl: tokenEndpoint, scope }, (s) => console.log(s));
388
+ const idToken = tok.id_token as string | undefined;
389
+ if (!idToken) throw new Error("IdP returned no id_token — ensure the 'openid' scope is granted");
390
+ const r = await fetch(`${BACKEND}${exchangePath}`, { method: "POST", headers: { authorization: `Bearer ${idToken}` } });
391
+ if (!r.ok) throw new Error(`exchange failed: HTTP ${r.status} ${await r.text().catch(() => "")}`);
392
+ const { seat_key, user, role } = (await r.json()) as { seat_key?: string; user?: string; role?: string };
393
+ if (!seat_key) throw new Error("backend returned no seat_key");
394
+ await setCredential("oidc", { type: "oauth", key: seat_key });
395
+ console.log(`Logged in via SSO as ${user} (${role}).`);
396
+ return true;
397
+ } catch (e) {
398
+ console.error(`SSO login failed: ${e instanceof Error ? e.message : e}`);
399
+ return false;
400
+ }
401
+ }
402
+
403
+ /** Run the device flow for `provider`; returns true on success (credential stored). */
357
404
  async function loginFlow(provider: string): Promise<boolean> {
405
+ if (provider === "oidc" || provider === "sso") return oidcLogin();
358
406
  const cfg = oauthConfig(provider);
359
407
  if (!cfg) {
360
408
  console.log(`No OAuth config for ${provider}. Set ADA_OAUTH_${provider.toUpperCase()}_{CLIENT_ID,DEVICE_URL,TOKEN_URL}.`);
@@ -411,12 +459,16 @@ async function ensureAuth(rl: RL, client: OpenAI): Promise<OpenAI> {
411
459
  return client; // backend unreachable — the model fetch will report it
412
460
  }
413
461
  if (status !== 401) return client; // 200 = already authorized, or backend is open (dev)
414
- const provider = ["github", "google"].find((p) => oauthConfig(p));
462
+ // Prefer the org IdP if the backend advertises OIDC SSO; else fall back to a locally-configured
463
+ // GitHub/Google OAuth app.
464
+ const methods = await fetchAuthMethods();
465
+ const provider = methods?.methods.includes("oidc") ? "oidc" : ["github", "google"].find((p) => oauthConfig(p));
415
466
  if (!provider) {
416
- console.log("\x1b[33mthis backend requires login, but no OAuth provider is configured (set ADA_OAUTH_*).\x1b[0m");
467
+ console.log("\x1b[33mthis backend requires login, but no login method is available (backend offers no SSO and no ADA_OAUTH_* is set).\x1b[0m");
417
468
  return client;
418
469
  }
419
- const ans = (await rl.question(`\x1b[33mnot logged in sign in with ${provider}? [Y/n] \x1b[0m`)).trim().toLowerCase();
470
+ const label = provider === "oidc" ? `your org (${methods?.oidc?.issuer ?? "SSO"})` : provider;
471
+ const ans = (await rl.question(`\x1b[33mnot logged in — sign in with ${label}? [Y/n] \x1b[0m`)).trim().toLowerCase();
420
472
  if (ans === "n" || ans === "no") return client;
421
473
  return (await loginFlow(provider)) ? makeClient() : client;
422
474
  }
package/src/selfcheck.ts CHANGED
@@ -349,6 +349,86 @@ async function main(): Promise<void> {
349
349
  }
350
350
  }
351
351
 
352
+ // --- OIDC SSO (Stage 2): JIT seat invariants + hermetic RS256 id-token verification ---
353
+ {
354
+ const dir = join(tmpdir(), `ada-oidc-${Date.now()}`);
355
+ const ent = await import("./server/enterprise.ts");
356
+ const oidc = await import("./server/oidc.ts");
357
+ const { generateKeyPairSync, sign } = await import("node:crypto");
358
+ const savedEnv = { ...process.env };
359
+ const iss = "https://idp.example.com";
360
+ process.env.ADA_DATA_DIR = dir;
361
+ process.env.ADA_OIDC_ISSUER = iss;
362
+ process.env.ADA_OIDC_CLIENT_ID = "ada-client";
363
+ process.env.ADA_OIDC_ALLOWED_GROUPS = "engineering";
364
+ process.env.ADA_OIDC_ADMIN_GROUP = "admins";
365
+ try {
366
+ // JIT seat provisioning invariants (the load-bearing new behavior).
367
+ const ext = `${iss}#sub-123`;
368
+ const k1 = ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir);
369
+ assert.ok(k1 && k1.startsWith("ada_sk_") && k1.length > 40, "OIDC JIT mints a valid seat key");
370
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), k1, "same iss#sub reuses one seat (no key rotation)");
371
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "admin", dir), k1, "existing seat is NOT auto-escalated to admin on login");
372
+ assert.deepEqual(ent.identifySeat(k1!, dir), { user: "sso-user", role: "dev" }, "SSO seat key authenticates like any seat");
373
+ assert.equal(ent.disableSeatByExternalId(ext, dir), "sso-user", "disable-by-externalId offboards");
374
+ assert.equal(ent.upsertSeatForSSO(ext, iss, "sso-user", "dev", dir), null, "disabled externalId denies re-login (fail-closed deprovision, no resurrect)");
375
+ assert.equal(ent.identifySeat(k1!, dir), null, "disabled SSO seat no longer authenticates");
376
+ assert.equal(ent.seatByExternalId("__proto__", dir), null, "externalId scan is prototype-safe");
377
+ // admin→dev downgrade when the admin group drops off a later login.
378
+ const ext2 = `${iss}#boss`;
379
+ const kb = ent.upsertSeatForSSO(ext2, iss, "boss", "admin", dir);
380
+ assert.equal(ent.identifySeat(kb!, dir)!.role, "admin", "admin seat provisioned");
381
+ assert.equal(ent.upsertSeatForSSO(ext2, iss, "boss", "dev", dir), kb, "downgrade reuses the same key");
382
+ assert.equal(ent.identifySeat(kb!, dir)!.role, "dev", "admin→dev downgrade on group removal");
383
+
384
+ // group/domain gate.
385
+ assert.ok(oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["engineering"] }), "allowed group provisions");
386
+ assert.ok(!oidc.isProvisionAllowed({ iss, sub: "s", name: "n", groups: ["other"], email: "x@evil.com" }), "non-allowed group/domain refused");
387
+ assert.equal(oidc.mapIdentityToSeatFields({ iss, sub: "z", name: "z", groups: ["admins"] }).role, "admin", "admin group → admin role");
388
+
389
+ // Hermetic RS256 verification: sign a token locally, verify via an injected JWKS key.
390
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
391
+ const pubJwk = { ...(publicKey.export({ format: "jwk" }) as Record<string, unknown>), kid: "test", kty: "RSA" };
392
+ const getKey = (kid: string) => (kid === "test" ? pubJwk : null);
393
+ const now = 1_800_000_000_000;
394
+ const sec = Math.floor(now / 1000);
395
+ const b64u = (o: unknown): string => Buffer.from(typeof o === "string" ? o : JSON.stringify(o)).toString("base64url");
396
+ const mkToken = (payload: Record<string, unknown>, alg = "RS256"): string => {
397
+ const head = b64u({ alg, kid: "test", typ: "JWT" });
398
+ const body = b64u(payload);
399
+ if (alg === "none") return `${head}.${body}.`;
400
+ return `${head}.${body}.${sign("RSA-SHA256", Buffer.from(`${head}.${body}`), privateKey).toString("base64url")}`;
401
+ };
402
+ const good = { iss, aud: "ada-client", sub: "sub-123", exp: sec + 3600, iat: sec, groups: ["engineering"], email: "dev@corp.com" };
403
+ const id = await oidc.verifyOidcToken(mkToken(good), { getKey, now });
404
+ assert.ok(id && id.sub === "sub-123" && id.iss === iss, "valid RS256 id_token verifies");
405
+ const validTok = mkToken(good);
406
+ assert.equal(await oidc.verifyOidcToken(`${validTok.slice(0, -4)}AAAA`, { getKey, now }), null, "tampered signature → null");
407
+ assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, aud: "someone-else" }), { getKey, now }), null, "wrong audience → null");
408
+ assert.equal(await oidc.verifyOidcToken(mkToken(good, "none"), { getKey, now }), null, "alg=none → null (no key confusion)");
409
+ assert.equal(await oidc.verifyOidcToken(mkToken({ ...good, exp: sec - 7200 }), { getKey, now }), null, "expired token → null");
410
+ // email is trusted only when the IdP marks it verified (domain-provisioning fail-open fix).
411
+ const idU = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: false }), { getKey, now });
412
+ assert.ok(idU && idU.email === undefined, "unverified email dropped from identity");
413
+ const idV = await oidc.verifyOidcToken(mkToken({ ...good, email: "x@corp.com", email_verified: true }), { getKey, now });
414
+ assert.equal(idV!.email, "x@corp.com", "verified email kept");
415
+
416
+ // SSRF guard classifies against a parsed IP (net.isIP), not a string prefix.
417
+ for (const bad of ["https://[::1]/keys", "https://[fe80::1]/keys", "https://[fc00::1]/keys", "https://[::ffff:127.0.0.1]/keys", "https://127.0.0.1/keys", "https://10.1.2.3/keys", "http://idp.okta.com/keys"]) {
418
+ assert.throws(() => oidc.assertSafeJwksUri(bad), `jwks_uri rejected: ${bad}`);
419
+ }
420
+ for (const ok of ["https://fcm.googleapis.com/keys", "https://fd-idp.corp.com/keys", "https://your-tenant.okta.com/oauth2/v1/keys"]) {
421
+ assert.doesNotThrow(() => oidc.assertSafeJwksUri(ok), `jwks_uri allowed: ${ok}`);
422
+ }
423
+ } finally {
424
+ for (const k of ["ADA_DATA_DIR", "ADA_OIDC_ISSUER", "ADA_OIDC_CLIENT_ID", "ADA_OIDC_ALLOWED_GROUPS", "ADA_OIDC_ADMIN_GROUP"]) {
425
+ if (savedEnv[k] === undefined) delete process.env[k];
426
+ else process.env[k] = savedEnv[k];
427
+ }
428
+ rmSync(dir, { recursive: true, force: true });
429
+ }
430
+ }
431
+
352
432
  // --- org policy merge: restrictive wins, org can tighten but never loosen ---
353
433
  {
354
434
  const { permissionFor, setActiveAgentPermissions, setOrgPermissions } = await import("./client/settings.ts");
@@ -35,6 +35,8 @@ export interface Seat {
35
35
  role: "admin" | "dev";
36
36
  created: string;
37
37
  disabled?: boolean;
38
+ externalId?: string; // OIDC SSO: issuer-scoped stable id (`iss#sub`) — non-secret, the deprovision target
39
+ iss?: string; // OIDC issuer the seat was provisioned from (so an issuer change is detectable)
38
40
  }
39
41
  export interface PolicyRule {
40
42
  tool?: string;
@@ -137,6 +139,66 @@ export function createSeat(name: string, role: "admin" | "dev" = "dev", dir = da
137
139
  return key;
138
140
  }
139
141
 
142
+ /** Find a seat by its OIDC externalId (`iss#sub`). Scans the (small) seat map by VALUE — externalId
143
+ * is compared, never used as a lookup key, so it's inherently prototype-safe. Inherits CorruptStore
144
+ * from loadSeats (callers fail closed). */
145
+ export function seatByExternalId(externalId: string, dir = dataDir()): { key: string; seat: Seat } | null {
146
+ const seats = loadSeats(dir);
147
+ for (const key of Object.keys(seats)) {
148
+ const seat = seats[key]!;
149
+ if (seat.externalId === externalId) return { key, seat };
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /** JIT-provision (or reuse) a seat for a verified OIDC identity. Returns the seat's `ada_sk_` key, or
155
+ * null if the seat exists but is DISABLED (a deprovisioned user must not be resurrected by re-login).
156
+ * - existing enabled seat → reuse its key (NO rotation); if it was admin and the login no longer
157
+ * carries the admin group, downgrade admin→dev (privilege revocation). Never auto-ESCALATE here.
158
+ * - new identity → mint one key, stamp externalId+iss, one `seat_created` audit row. */
159
+ export function upsertSeatForSSO(externalId: string, iss: string, name: string, role: "admin" | "dev", dir = dataDir()): string | null {
160
+ const seats = loadSeats(dir);
161
+ let foundKey: string | null = null;
162
+ for (const key of Object.keys(seats)) {
163
+ if (seats[key]!.externalId === externalId) {
164
+ foundKey = key;
165
+ break;
166
+ }
167
+ }
168
+ if (foundKey) {
169
+ const seat = seats[foundKey]!;
170
+ if (seat.disabled) return null; // deprovisioned — do NOT resurrect
171
+ if (seat.role === "admin" && role === "dev") {
172
+ seat.role = "dev";
173
+ saveSeats(seats, dir);
174
+ appendAudit({ ts: Date.now(), user: name, event: "role_changed", detail: `${name} admin→dev (SSO group removed)` }, dir);
175
+ }
176
+ return foundKey;
177
+ }
178
+ const key = `ada_sk_${randomBytes(24).toString("hex")}`;
179
+ seats[key] = { name, role, created: new Date().toISOString(), externalId, iss };
180
+ saveSeats(seats, dir);
181
+ appendAudit({ ts: Date.now(), user: name, event: "seat_created", detail: `${name} (${role}) via OIDC ${externalId}` }, dir);
182
+ return key;
183
+ }
184
+
185
+ /** Immediate offboarding: disable the seat for an OIDC externalId. The admin endpoint (and, later,
186
+ * SCIM DELETE) call this — the next identifySeat for that key returns null (401). */
187
+ export function disableSeatByExternalId(externalId: string, dir = dataDir()): string | null {
188
+ const seats = loadSeats(dir);
189
+ for (const key of Object.keys(seats)) {
190
+ const seat = seats[key]!;
191
+ if (seat.externalId === externalId) {
192
+ if (seat.disabled) return seat.name; // idempotent
193
+ seat.disabled = true;
194
+ saveSeats(seats, dir);
195
+ appendAudit({ ts: Date.now(), user: seat.name, event: "seat_disabled", detail: `${seat.name} (SSO ${externalId})` }, dir);
196
+ return seat.name;
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
140
202
  /** Disable (not delete — the audit trail keeps the history) the seat whose key starts with prefix. */
141
203
  export function disableSeat(prefix: string, dir = dataDir()): string | null {
142
204
  if (prefix.length < 12) return null; // too short to be safely unique
@@ -5,8 +5,9 @@
5
5
  import { createServer } from "node:http";
6
6
  import type { IncomingMessage, ServerResponse } from "node:http";
7
7
  import { PORT, PROVIDERS, clientKeys, configuredProviders, isConfigured } from "./config.ts";
8
- import { CorruptStore, type Identity, appendAudit, appendUsage, auditTail, createSeat, disableSeat, enterpriseMode, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, usageSummary, validatePolicy } from "./enterprise.ts";
8
+ import { CorruptStore, type Identity, appendAudit, appendUsage, auditTail, createSeat, disableSeat, disableSeatByExternalId, enterpriseMode, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, upsertSeatForSSO, usageSummary, validatePolicy } from "./enterprise.ts";
9
9
  import { allowedUsers, isAllowed, verifyIdentity } from "./identity.ts";
10
+ import { assertOidcConfig, discover, isProvisionAllowed, mapIdentityToSeatFields, oidcConfig, oidcEnabled, verifyOidcToken } from "./oidc.ts";
10
11
  import { adapterFor } from "./providers/registry.ts";
11
12
  import { route } from "./router.ts";
12
13
 
@@ -20,7 +21,9 @@ function readBody(req: IncomingMessage): Promise<string> {
20
21
  }
21
22
 
22
23
  function locked(): boolean {
23
- return enterpriseMode() || clientKeys() !== null || allowedUsers() !== null || !!process.env.ADA_REQUIRE_LOGIN;
24
+ // OIDC must lock the backend the instant ADA_OIDC_ISSUER is set — BEFORE any seat exists — else a
25
+ // fresh SSO deployment with zero seats would fall through identify() to dev-open.
26
+ return enterpriseMode() || clientKeys() !== null || allowedUsers() !== null || oidcEnabled() || !!process.env.ADA_REQUIRE_LOGIN;
24
27
  }
25
28
 
26
29
  /** Resolve a request to WHO is making it. Order: seat key / ADA_ADMIN_KEY (enterprise), legacy
@@ -40,10 +43,17 @@ async function identify(req: IncomingMessage): Promise<Identity | "corrupt" | nu
40
43
  }
41
44
  if (seat) return seat;
42
45
  // Legacy ADA_CLIENT_KEYS are NOT honored once seats/admin-key exist — enterprise supersedes them,
43
- // so a disabled seat can't be resurrected via a still-configured shared key.
44
- if (!enterpriseMode() && clientKeys()?.includes(token)) return { user: "team", role: "dev" };
45
- const id = await verifyIdentity(token); // GitHub / Google login
46
- if (id && isAllowed(id.user)) return { user: id.user, role: "dev" };
46
+ // so a disabled seat can't be resurrected via a still-configured shared key. They're ALSO refused
47
+ // whenever OIDC is the org's IdP (single identity authority) else a still-set shared key would
48
+ // bypass SSO verification during the window before the first seat is minted.
49
+ if (!oidcEnabled() && !enterpriseMode() && clientKeys()?.includes(token)) return { user: "team", role: "dev" };
50
+ // One identity authority: when OIDC is the org's IdP, the GitHub/Google login path is disabled so
51
+ // a person disabled in the IdP can't re-enter via a still-allowed GitHub account. (SSO users
52
+ // authenticate at /v1/auth/oidc/exchange and then carry a seat key, not an id_token, per request.)
53
+ if (!oidcEnabled()) {
54
+ const id = await verifyIdentity(token); // GitHub / Google login
55
+ if (id && isAllowed(id.user)) return { user: id.user, role: "dev" };
56
+ }
47
57
  }
48
58
  return locked() ? null : { user: "dev", role: "dev" }; // dev mode: open
49
59
  }
@@ -162,6 +172,74 @@ async function handleEmbeddings(req: IncomingMessage, res: ServerResponse, who:
162
172
  res.end(text);
163
173
  }
164
174
 
175
+ /** Public: advertise enabled login methods so the terminal client can self-configure (no OIDC env on
176
+ * the client). For OIDC it returns the issuer + client id + device/token endpoints (all public
177
+ * discovery values) plus the exchange path. Unauthenticated by design. */
178
+ async function handleAuthMethods(res: ServerResponse): Promise<void> {
179
+ const methods: string[] = [];
180
+ const out: Record<string, unknown> = {};
181
+ if (oidcEnabled()) {
182
+ try {
183
+ const cfg = oidcConfig();
184
+ const d = await discover();
185
+ if (d.device_authorization_endpoint && d.token_endpoint) {
186
+ methods.push("oidc");
187
+ out.oidc = {
188
+ issuer: cfg.issuer,
189
+ clientId: cfg.clientId,
190
+ deviceAuthEndpoint: d.device_authorization_endpoint,
191
+ tokenEndpoint: d.token_endpoint,
192
+ scope: cfg.scope,
193
+ exchangePath: "/v1/auth/oidc/exchange",
194
+ };
195
+ } else {
196
+ out.oidcError = "IdP does not advertise a device_authorization_endpoint (device flow unavailable)";
197
+ }
198
+ } catch (e) {
199
+ out.oidcError = e instanceof Error ? e.message : String(e);
200
+ }
201
+ }
202
+ return json(res, 200, { methods, ...out });
203
+ }
204
+
205
+ /** Public: exchange a verified OIDC id_token for a seat key (model B — the id_token is a one-time
206
+ * provisioning artifact; every later request carries the returned ada_sk_ seat key). This is the
207
+ * ONLY endpoint that accepts a JWT, so an id_token never reaches the per-request seat/identity path. */
208
+ async function handleOidcExchange(req: IncomingMessage, res: ServerResponse): Promise<void> {
209
+ if (!oidcEnabled()) return json(res, 404, { error: { message: "OIDC not enabled" } });
210
+ const h = req.headers["authorization"];
211
+ const idToken = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : "";
212
+ if (!idToken) return json(res, 401, { error: { message: "missing id_token bearer" } });
213
+
214
+ let identity: Awaited<ReturnType<typeof verifyOidcToken>>;
215
+ try {
216
+ identity = await verifyOidcToken(idToken);
217
+ } catch {
218
+ identity = null;
219
+ }
220
+ if (!identity) return json(res, 401, { error: { message: "invalid or unverifiable id_token" } });
221
+
222
+ if (!isProvisionAllowed(identity)) {
223
+ appendAudit({ ts: Date.now(), user: identity.email ?? identity.sub, event: "sso_login_denied", detail: `not in allowed group/domain: ${identity.iss}#${identity.sub}` });
224
+ return json(res, 403, { error: { message: "not authorized by org group/domain policy" } });
225
+ }
226
+
227
+ const { externalId, iss, name, role } = mapIdentityToSeatFields(identity);
228
+ let key: string | null;
229
+ try {
230
+ key = upsertSeatForSSO(externalId, iss, name, role);
231
+ } catch (e) {
232
+ if (e instanceof CorruptStore) return json(res, 503, { error: { message: "seat store unreadable — refusing to provision (fail-closed)" } });
233
+ throw e;
234
+ }
235
+ if (!key) {
236
+ appendAudit({ ts: Date.now(), user: name, event: "sso_login_denied", detail: `seat disabled: ${externalId}` });
237
+ return json(res, 403, { error: { message: "your seat has been disabled — contact your admin" } });
238
+ }
239
+ appendAudit({ ts: Date.now(), user: name, event: "sso_login", detail: externalId });
240
+ return json(res, 200, { seat_key: key, user: name, role });
241
+ }
242
+
165
243
  const server = createServer(async (req, res) => {
166
244
  try {
167
245
  const url = new URL(req.url ?? "/", "http://localhost");
@@ -169,6 +247,10 @@ const server = createServer(async (req, res) => {
169
247
  res.writeHead(200, { "content-type": "text/plain" });
170
248
  return res.end("ada backend ok");
171
249
  }
250
+ // Pre-auth login routes (a locked backend must still let a new user authenticate).
251
+ if (req.method === "GET" && url.pathname === "/v1/auth/methods") return await handleAuthMethods(res);
252
+ if (req.method === "POST" && url.pathname === "/v1/auth/oidc/exchange") return await handleOidcExchange(req, res);
253
+
172
254
  const who = await identify(req);
173
255
  if (who === "corrupt") return json(res, 503, { error: { message: "auth store unreadable — refusing all requests (fail-closed). Fix ~/.ada/server/users.json." } });
174
256
  if (!who) return json(res, 401, { error: { message: "unauthorized — invalid client key, seat key, or login" } });
@@ -239,6 +321,26 @@ const server = createServer(async (req, res) => {
239
321
  return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } });
240
322
  }
241
323
  }
324
+ // Immediate offboarding by OIDC externalId (`iss#sub`) — the entry point an admin (or Stage-3
325
+ // SCIM) uses to kill a leaver's access without waiting for the id_token to expire.
326
+ if (req.method === "POST" && url.pathname === "/v1/users/disable-by-external") {
327
+ if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
328
+ let externalId = "";
329
+ try {
330
+ externalId = String((JSON.parse(await readBody(req)) as { externalId?: string }).externalId ?? "").trim();
331
+ } catch {
332
+ /* falls through to the empty check */
333
+ }
334
+ if (!externalId) return json(res, 400, { error: { message: "missing 'externalId' (iss#sub)" } });
335
+ let name: string | null;
336
+ try {
337
+ name = disableSeatByExternalId(externalId);
338
+ } catch (e) {
339
+ if (e instanceof CorruptStore) return json(res, 503, { error: { message: "seat store unreadable" } });
340
+ throw e;
341
+ }
342
+ return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "no seat for that externalId" } });
343
+ }
242
344
  if (req.method === "GET" && url.pathname === "/v1/usage") {
243
345
  if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
244
346
  return json(res, 200, usageSummary(Math.min(Number(url.searchParams.get("days")) || 30, 365)));
@@ -260,14 +362,26 @@ const server = createServer(async (req, res) => {
260
362
  }
261
363
  });
262
364
 
365
+ // Fail fast on bad OIDC config (multi-tenant issuer, missing allow-surface, …) — never boot into a
366
+ // state where SSO would provision seats unsafely.
367
+ try {
368
+ assertOidcConfig();
369
+ } catch (e) {
370
+ console.error(`\x1b[31m[fatal] OIDC misconfigured: ${e instanceof Error ? e.message : e}\x1b[0m`);
371
+ process.exit(1);
372
+ }
373
+
263
374
  server.listen(PORT, () => {
264
- if (enterpriseMode() && clientKeys()) console.warn("\x1b[33m[warn] ADA_CLIENT_KEYS is set but ignored in enterprise mode (seats supersede it) — unset it to avoid confusion.\x1b[0m");
375
+ if ((enterpriseMode() || oidcEnabled()) && clientKeys()) console.warn("\x1b[33m[warn] ADA_CLIENT_KEYS is set but ignored in enterprise/OIDC mode (seats/SSO supersede it) — unset it to avoid confusion.\x1b[0m");
265
376
  const seats = listSeats().filter((s) => !s.disabled).length;
377
+ const sso = oidcEnabled() ? " + OIDC SSO" : "";
266
378
  const auth = enterpriseMode()
267
- ? `ENTERPRISE (${seats} seat${seats === 1 ? "" : "s"}${process.env.ADA_ADMIN_KEY ? " + admin key" : ""})`
268
- : locked()
269
- ? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
270
- : "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ADMIN_KEY to lock down";
379
+ ? `ENTERPRISE (${seats} seat${seats === 1 ? "" : "s"}${process.env.ADA_ADMIN_KEY ? " + admin key" : ""}${sso})`
380
+ : oidcEnabled()
381
+ ? `OIDC SSO (0 seats awaiting first login)`
382
+ : locked()
383
+ ? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
384
+ : "AUTH DISABLED (dev) — set ADA_CLIENT_KEYS or ADA_ADMIN_KEY to lock down";
271
385
  const provs = configuredProviders();
272
386
  console.log(`ada backend → http://localhost:${PORT} [${auth}]`);
273
387
  console.log(`providers: ${provs.length ? provs.join(", ") : "(none configured — set provider API keys)"}`);
@@ -55,8 +55,10 @@ async function postForm(url: string, body: Record<string, string>): Promise<Reco
55
55
  return (await res.json()) as Record<string, unknown>;
56
56
  }
57
57
 
58
- /** Run the device flow: print the user code, poll for the token, store it on success. */
59
- export async function deviceLogin(provider: string, cfg: OAuthConfig, print: (s: string) => void): Promise<void> {
58
+ /** Run the device flow: print the user code, poll, and RETURN the raw token response (access_token,
59
+ * id_token, refresh_token, …) without storing anything. Callers decide what to persist GitHub/Google
60
+ * store the access token (deviceLogin below); OIDC SSO exchanges the id_token for a seat key. */
61
+ export async function deviceGrant(provider: string, cfg: OAuthConfig, print: (s: string) => void): Promise<Record<string, unknown>> {
60
62
  const secret: Record<string, string> = cfg.clientSecret ? { client_secret: cfg.clientSecret } : {};
61
63
  const dev = await postForm(cfg.deviceUrl, { client_id: cfg.clientId, scope: cfg.scope ?? "", ...secret });
62
64
  const deviceCode = dev.device_code as string;
@@ -74,16 +76,7 @@ export async function deviceLogin(provider: string, cfg: OAuthConfig, print: (s:
74
76
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
75
77
  ...secret,
76
78
  });
77
- if (tok.access_token) {
78
- await setCredential(provider, {
79
- type: "oauth",
80
- access: tok.access_token as string,
81
- refresh: tok.refresh_token as string | undefined,
82
- expires: tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : undefined,
83
- });
84
- print(`Logged in to ${provider}.`);
85
- return;
86
- }
79
+ if (tok.access_token || tok.id_token) return tok;
87
80
  const err = tok.error as string | undefined;
88
81
  if (err && err !== "authorization_pending" && err !== "slow_down") {
89
82
  throw new Error((tok.error_description as string) ?? err);
@@ -91,3 +84,15 @@ export async function deviceLogin(provider: string, cfg: OAuthConfig, print: (s:
91
84
  }
92
85
  throw new Error("device login timed out");
93
86
  }
87
+
88
+ /** Device flow for identity providers (GitHub/Google): grant, then store the access token. */
89
+ export async function deviceLogin(provider: string, cfg: OAuthConfig, print: (s: string) => void): Promise<void> {
90
+ const tok = await deviceGrant(provider, cfg, print);
91
+ await setCredential(provider, {
92
+ type: "oauth",
93
+ access: tok.access_token as string,
94
+ refresh: tok.refresh_token as string | undefined,
95
+ expires: tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : undefined,
96
+ });
97
+ print(`Logged in to ${provider}.`);
98
+ }
@@ -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
+ }