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.
@@ -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
- return 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;
23
27
  }
24
28
 
25
- /** A request is allowed if it carries a known static client key, OR a valid GitHub/Google
26
- * login token (allowlisted). With nothing configured, the backend is open (dev mode). */
27
- async function authorized(req: IncomingMessage): Promise<boolean> {
28
- if (!locked()) return true; // dev mode: no auth configured
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 (!token) return false;
32
- const keys = clientKeys();
33
- if (keys?.includes(token)) return true; // static client key
34
- const id = await verifyIdentity(token); // GitHub / Google login
35
- return !!id && isAllowed(id.user);
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
- const provider = route(model, typeof body.provider === "string" ? body.provider : undefined);
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
- async function handleEmbeddings(req: IncomingMessage, res: ServerResponse): Promise<void> {
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
- if (!(await authorized(req))) return json(res, 401, { error: { message: "not logged in" } });
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
- if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
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
- if (!(await authorized(req))) return json(res, 401, { error: { message: "unauthorized — invalid client key or login" } });
115
- return await handleEmbeddings(req, res);
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
- const auth = locked()
131
- ? `auth ON (client keys + GitHub/Google login${allowedUsers() ? `, allowlist: ${allowedUsers()!.length}` : ""})`
132
- : "AUTH DISABLED (dev) set ADA_CLIENT_KEYS or ADA_ALLOWED_USERS to lock down";
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)"}`);
@@ -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
+ }