ada-agent 0.7.0 → 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.
- package/README.md +6 -0
- package/docs/enterprise-stage2-oidc.md +135 -0
- package/docs/enterprise.md +51 -0
- package/package.json +1 -1
- package/src/client/cli.ts +59 -7
- package/src/selfcheck.ts +80 -0
- package/src/server/enterprise.ts +62 -0
- package/src/server/index.ts +125 -11
- package/src/server/oauth.ts +17 -12
- package/src/server/oidc.ts +300 -0
package/README.md
CHANGED
|
@@ -252,6 +252,12 @@ 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
|
+
|
|
255
261
|
## Benchmarks
|
|
256
262
|
|
|
257
263
|
ada can run **SWE-bench Verified** — it generates patches for real GitHub issues (one isolated repo
|
|
@@ -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.
|
package/docs/enterprise.md
CHANGED
|
@@ -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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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");
|
package/src/server/enterprise.ts
CHANGED
|
@@ -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
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
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
|
-
:
|
|
269
|
-
? `
|
|
270
|
-
:
|
|
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)"}`);
|
package/src/server/oauth.ts
CHANGED
|
@@ -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
|
|
59
|
-
|
|
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
|
+
}
|