ada-agent 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/docs/enterprise-stage2-oidc.md +135 -0
- package/docs/enterprise.md +134 -0
- package/package.json +1 -1
- package/src/client/cli.ts +98 -9
- package/src/client/settings.ts +21 -3
- package/src/selfcheck.ts +153 -0
- package/src/server/enterprise.ts +408 -0
- package/src/server/index.ts +277 -25
- package/src/server/oauth.ts +17 -12
- package/src/server/oidc.ts +300 -0
- package/src/server/providers/anthropic.ts +10 -1
- package/src/server/providers/openai-compat.ts +23 -4
package/src/server/index.ts
CHANGED
|
@@ -5,7 +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, disableSeatByExternalId, enterpriseMode, extractLastUsage, identifySeat, listSeats, loadPolicy, modelAllowed, savePolicy, upsertSeatForSSO, usageSummary, validatePolicy } from "./enterprise.ts";
|
|
8
9
|
import { allowedUsers, isAllowed, verifyIdentity } from "./identity.ts";
|
|
10
|
+
import { assertOidcConfig, discover, isProvisionAllowed, mapIdentityToSeatFields, oidcConfig, oidcEnabled, verifyOidcToken } from "./oidc.ts";
|
|
9
11
|
import { adapterFor } from "./providers/registry.ts";
|
|
10
12
|
import { route } from "./router.ts";
|
|
11
13
|
|
|
@@ -19,20 +21,41 @@ function readBody(req: IncomingMessage): Promise<string> {
|
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
function locked(): boolean {
|
|
22
|
-
|
|
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;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/** Resolve a request to WHO is making it. Order: seat key / ADA_ADMIN_KEY (enterprise), legacy
|
|
30
|
+
* static client key, GitHub/Google login. With no auth configured, the backend is open (dev mode).
|
|
31
|
+
* Returns "corrupt" if the seat store can't be read — the caller MUST 503, never fall through to
|
|
32
|
+
* dev-open. Null = unauthorized. */
|
|
33
|
+
async function identify(req: IncomingMessage): Promise<Identity | "corrupt" | null> {
|
|
29
34
|
const h = req.headers["authorization"];
|
|
30
35
|
const token = typeof h === "string" && h.startsWith("Bearer ") ? h.slice(7) : "";
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
if (token) {
|
|
37
|
+
let seat: Identity | null;
|
|
38
|
+
try {
|
|
39
|
+
seat = identifySeat(token);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e instanceof CorruptStore) return "corrupt";
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
if (seat) return seat;
|
|
45
|
+
// Legacy ADA_CLIENT_KEYS are NOT honored once seats/admin-key exist — enterprise supersedes them,
|
|
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
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return locked() ? null : { user: "dev", role: "dev" }; // dev mode: open
|
|
36
59
|
}
|
|
37
60
|
|
|
38
61
|
function json(res: ServerResponse, status: number, obj: unknown): void {
|
|
@@ -49,7 +72,7 @@ async function handleModels(res: ServerResponse): Promise<void> {
|
|
|
49
72
|
json(res, 200, { object: "list", data });
|
|
50
73
|
}
|
|
51
74
|
|
|
52
|
-
async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
75
|
+
async function handleChat(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
|
|
53
76
|
const raw = await readBody(req);
|
|
54
77
|
let body: Record<string, unknown>;
|
|
55
78
|
try {
|
|
@@ -61,36 +84,162 @@ async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<vo
|
|
|
61
84
|
const model = String(body.model ?? "");
|
|
62
85
|
if (!model) return json(res, 400, { error: { message: "missing 'model'" } });
|
|
63
86
|
|
|
64
|
-
|
|
87
|
+
// Org policy: model allowlist (enterprise). Enforced server-side so a modified client can't skip it.
|
|
88
|
+
let policy: import("./enterprise.ts").Policy;
|
|
89
|
+
try {
|
|
90
|
+
policy = loadPolicy();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests (fail-closed)" } });
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
if (!modelAllowed(model, policy)) {
|
|
96
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: model });
|
|
97
|
+
return json(res, 403, { error: { message: `model '${model}' is not allowed by org policy (allowed: ${policy.models!.join(", ")})` } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// When an allowlist is active, IGNORE the client's `provider` hint — else a seat holder could
|
|
101
|
+
// send an allowlisted model id with a different provider and leak the body to it before the
|
|
102
|
+
// upstream rejects the id. Route by the model id only.
|
|
103
|
+
const explicit = policy.models?.length ? undefined : typeof body.provider === "string" ? body.provider : undefined;
|
|
104
|
+
const provider = route(model, explicit);
|
|
65
105
|
if (!isConfigured(provider)) {
|
|
66
106
|
return json(res, 400, {
|
|
67
107
|
error: { message: `provider '${provider}' not configured — set ${PROVIDERS[provider].keyEnv} on the backend` },
|
|
68
108
|
});
|
|
69
109
|
}
|
|
70
110
|
|
|
111
|
+
// Metering must not be client-suppressible: force the upstream to emit a usage object on streams
|
|
112
|
+
// (OpenAI-compat only sends it when include_usage is set). Harmless for providers that ignore it.
|
|
113
|
+
if (body.stream) body.stream_options = { ...((body.stream_options as Record<string, unknown>) ?? {}), include_usage: true };
|
|
114
|
+
|
|
115
|
+
// Usage metering: tee the response (streamed or not) and record the last usage object the
|
|
116
|
+
// upstream reported. Wrapping res keeps this in ONE place for every adapter.
|
|
117
|
+
{
|
|
118
|
+
let tail = "";
|
|
119
|
+
const scan = (c: unknown): void => {
|
|
120
|
+
if (typeof c === "string" || Buffer.isBuffer(c)) tail = (tail + c.toString()).slice(-16_384);
|
|
121
|
+
};
|
|
122
|
+
const write = res.write.bind(res);
|
|
123
|
+
const end = res.end.bind(res);
|
|
124
|
+
res.write = ((c: never, ...a: never[]) => {
|
|
125
|
+
scan(c);
|
|
126
|
+
return write(c, ...a);
|
|
127
|
+
}) as typeof res.write;
|
|
128
|
+
res.end = ((c?: never, ...a: never[]) => {
|
|
129
|
+
scan(c);
|
|
130
|
+
const u = extractLastUsage(tail);
|
|
131
|
+
if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider, promptTokens: u.promptTokens, completionTokens: u.completionTokens });
|
|
132
|
+
return end(c, ...a);
|
|
133
|
+
}) as typeof res.end;
|
|
134
|
+
}
|
|
135
|
+
|
|
71
136
|
delete body.provider; // our routing hint; never forward it upstream
|
|
72
137
|
await adapterFor(provider).chat({ provider, model, body, res });
|
|
73
138
|
}
|
|
74
139
|
|
|
75
140
|
/** Embeddings for @codebase semantic search — forwarded to the ollama provider's
|
|
76
|
-
* OpenAI-compatible endpoint (embedding models only live there for now).
|
|
77
|
-
|
|
141
|
+
* OpenAI-compatible endpoint (embedding models only live there for now). Subject to the same org
|
|
142
|
+
* model allowlist as chat, and metered/attributed. */
|
|
143
|
+
async function handleEmbeddings(req: IncomingMessage, res: ServerResponse, who: Identity): Promise<void> {
|
|
78
144
|
const raw = await readBody(req);
|
|
145
|
+
let body: Record<string, unknown>;
|
|
79
146
|
try {
|
|
80
|
-
JSON.parse(raw);
|
|
147
|
+
body = JSON.parse(raw);
|
|
81
148
|
} catch {
|
|
82
149
|
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
83
150
|
}
|
|
151
|
+
const model = String(body.model ?? "");
|
|
152
|
+
let policy: import("./enterprise.ts").Policy;
|
|
153
|
+
try {
|
|
154
|
+
policy = loadPolicy();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable — refusing requests" } });
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
if (model && !modelAllowed(model, policy)) {
|
|
160
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_denied_model", detail: `embeddings:${model}` });
|
|
161
|
+
return json(res, 403, { error: { message: `embedding model '${model}' is not allowed by org policy` } });
|
|
162
|
+
}
|
|
84
163
|
const upstream = await fetch(`${PROVIDERS.ollama.baseURL}/embeddings`, {
|
|
85
164
|
method: "POST",
|
|
86
165
|
headers: { "content-type": "application/json" },
|
|
87
166
|
body: raw,
|
|
88
167
|
});
|
|
89
168
|
const text = await upstream.text();
|
|
169
|
+
const u = extractLastUsage(text); // embedding responses report prompt_tokens
|
|
170
|
+
if (u) appendUsage({ ts: Date.now(), user: who.user, model, provider: "ollama", promptTokens: u.promptTokens, completionTokens: 0 });
|
|
90
171
|
res.writeHead(upstream.status, { "content-type": "application/json" });
|
|
91
172
|
res.end(text);
|
|
92
173
|
}
|
|
93
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
|
+
|
|
94
243
|
const server = createServer(async (req, res) => {
|
|
95
244
|
try {
|
|
96
245
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -98,22 +247,109 @@ const server = createServer(async (req, res) => {
|
|
|
98
247
|
res.writeHead(200, { "content-type": "text/plain" });
|
|
99
248
|
return res.end("ada backend ok");
|
|
100
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
|
+
|
|
254
|
+
const who = await identify(req);
|
|
255
|
+
if (who === "corrupt") return json(res, 503, { error: { message: "auth store unreadable — refusing all requests (fail-closed). Fix ~/.ada/server/users.json." } });
|
|
256
|
+
if (!who) return json(res, 401, { error: { message: "unauthorized — invalid client key, seat key, or login" } });
|
|
257
|
+
|
|
101
258
|
if (req.method === "GET" && url.pathname === "/v1/whoami") {
|
|
102
|
-
|
|
103
|
-
return json(res, 200, { ok: true });
|
|
259
|
+
return json(res, 200, { ok: true, user: who.user, role: who.role });
|
|
104
260
|
}
|
|
105
261
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
106
|
-
if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
|
|
107
262
|
return await handleModels(res);
|
|
108
263
|
}
|
|
109
264
|
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
110
|
-
|
|
111
|
-
return await handleChat(req, res);
|
|
265
|
+
return await handleChat(req, res, who);
|
|
112
266
|
}
|
|
113
267
|
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
|
|
114
|
-
|
|
115
|
-
|
|
268
|
+
return await handleEmbeddings(req, res, who);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---- enterprise control plane ----
|
|
272
|
+
if (url.pathname === "/v1/policy") {
|
|
273
|
+
if (req.method === "GET") {
|
|
274
|
+
// any seat — clients fetch this and apply the tool rules locally
|
|
275
|
+
let policy: unknown;
|
|
276
|
+
try {
|
|
277
|
+
policy = loadPolicy();
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (e instanceof CorruptStore) return json(res, 503, { error: { message: "org policy unreadable" } });
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
appendAudit({ ts: Date.now(), user: who.user, event: "policy_fetched", detail: "" }); // spot seats that never fetch
|
|
283
|
+
return json(res, 200, policy);
|
|
284
|
+
}
|
|
285
|
+
if (req.method === "PUT") {
|
|
286
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
287
|
+
let parsed: unknown;
|
|
288
|
+
try {
|
|
289
|
+
parsed = JSON.parse(await readBody(req));
|
|
290
|
+
} catch {
|
|
291
|
+
return json(res, 400, { error: { message: "invalid JSON body" } });
|
|
292
|
+
}
|
|
293
|
+
const v = validatePolicy(parsed);
|
|
294
|
+
if ("error" in v) return json(res, 400, { error: { message: v.error } });
|
|
295
|
+
savePolicy(v.policy);
|
|
296
|
+
return json(res, 200, { ok: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (url.pathname === "/v1/users") {
|
|
300
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
301
|
+
if (req.method === "GET") return json(res, 200, { users: listSeats() });
|
|
302
|
+
if (req.method === "POST") {
|
|
303
|
+
let name = "";
|
|
304
|
+
let role: "admin" | "dev" = "dev";
|
|
305
|
+
try {
|
|
306
|
+
const b = JSON.parse(await readBody(req)) as { name?: string; role?: string };
|
|
307
|
+
name = String(b.name ?? "").trim();
|
|
308
|
+
if (b.role === "admin") role = "admin";
|
|
309
|
+
} catch {
|
|
310
|
+
/* falls through to the name check */
|
|
311
|
+
}
|
|
312
|
+
if (!name) return json(res, 400, { error: { message: "missing 'name'" } });
|
|
313
|
+
return json(res, 200, { key: createSeat(name, role), name, role, note: "shown once — store it now" });
|
|
314
|
+
}
|
|
116
315
|
}
|
|
316
|
+
{
|
|
317
|
+
const m = req.method === "DELETE" && url.pathname.match(/^\/v1\/users\/([\w]+)$/);
|
|
318
|
+
if (m) {
|
|
319
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
320
|
+
const name = disableSeat(m[1]!);
|
|
321
|
+
return json(res, name ? 200 : 404, name ? { ok: true, disabled: name } : { error: { message: "unknown or ambiguous key prefix (send ≥12 chars)" } });
|
|
322
|
+
}
|
|
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
|
+
}
|
|
344
|
+
if (req.method === "GET" && url.pathname === "/v1/usage") {
|
|
345
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
346
|
+
return json(res, 200, usageSummary(Math.min(Number(url.searchParams.get("days")) || 30, 365)));
|
|
347
|
+
}
|
|
348
|
+
if (req.method === "GET" && url.pathname === "/v1/audit") {
|
|
349
|
+
if (who.role !== "admin") return json(res, 403, { error: { message: "admin only" } });
|
|
350
|
+
return json(res, 200, { events: auditTail(Math.min(Number(url.searchParams.get("limit")) || 200, 2000)) });
|
|
351
|
+
}
|
|
352
|
+
|
|
117
353
|
return json(res, 404, { error: { message: "not found" } });
|
|
118
354
|
} catch (err) {
|
|
119
355
|
if (!res.headersSent) json(res, 500, { error: { message: err instanceof Error ? err.message : String(err) } });
|
|
@@ -126,10 +362,26 @@ const server = createServer(async (req, res) => {
|
|
|
126
362
|
}
|
|
127
363
|
});
|
|
128
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
|
+
|
|
129
374
|
server.listen(PORT, () => {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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");
|
|
376
|
+
const seats = listSeats().filter((s) => !s.disabled).length;
|
|
377
|
+
const sso = oidcEnabled() ? " + OIDC SSO" : "";
|
|
378
|
+
const auth = enterpriseMode()
|
|
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";
|
|
133
385
|
const provs = configuredProviders();
|
|
134
386
|
console.log(`ada backend → http://localhost:${PORT} [${auth}]`);
|
|
135
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
|
+
}
|