@ai21/gateway 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ export interface StoredCredential {
2
+ readonly refreshToken: string;
3
+ readonly firebaseApiKey: string;
4
+ readonly projectId: string;
5
+ readonly userId: string;
6
+ readonly userEmail: string;
7
+ readonly workspaceId?: string | undefined;
8
+ /** ISO 8601, for `whoami` to say how old the session is. */
9
+ readonly obtainedAt: string;
10
+ }
11
+ /** Injected so tests never touch a real home directory. */
12
+ export interface CredentialPaths {
13
+ readonly home: string;
14
+ }
15
+ export declare function credentialsPath(ctx: CredentialPaths): string;
16
+ export type LoadOutcome = {
17
+ readonly kind: "none";
18
+ } | {
19
+ readonly kind: "credential";
20
+ readonly credential: StoredCredential;
21
+ }
22
+ /** The file exists but cannot be trusted or read — the message says what to do. */
23
+ | {
24
+ readonly kind: "unusable";
25
+ readonly reason: string;
26
+ };
27
+ export declare function load(gateway: string, ctx: CredentialPaths): LoadOutcome;
28
+ /**
29
+ * Writes one gateway's credential, leaving the others alone. Created at 0600 rather than
30
+ * chmodded after: the gap between the two is openable by another local user.
31
+ */
32
+ export declare function save(gateway: string, credential: StoredCredential, ctx: CredentialPaths): void;
33
+ /** Forgets one gateway's session. Returns false when there was nothing to forget. */
34
+ export declare function clear(gateway: string, ctx: CredentialPaths): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Where a signed-in session lives between runs: one owner-only file holding a Firebase
3
+ * refresh token, which is long-lived and acts as the user. Keyed by gateway URL, so a
4
+ * staging or local session cannot be handed to production.
5
+ */
6
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ const DIR_MODE = 0o700;
9
+ const FILE_MODE = 0o600;
10
+ export function credentialsPath(ctx) {
11
+ return join(ctx.home, ".config", "ai21-gateway", "credentials.json");
12
+ }
13
+ function readFile(path) {
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
16
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
17
+ ? parsed
18
+ : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ function isComplete(value) {
25
+ if (typeof value !== "object" || value === null)
26
+ return false;
27
+ const c = value;
28
+ return (typeof c["refreshToken"] === "string" &&
29
+ typeof c["firebaseApiKey"] === "string" &&
30
+ typeof c["projectId"] === "string" &&
31
+ typeof c["userId"] === "string" &&
32
+ typeof c["userEmail"] === "string");
33
+ }
34
+ export function load(gateway, ctx) {
35
+ const path = credentialsPath(ctx);
36
+ let mode;
37
+ try {
38
+ mode = statSync(path).mode & 0o777;
39
+ }
40
+ catch {
41
+ return { kind: "none" };
42
+ }
43
+ /* Checked on read too: the file may predate this version or have been copied in, and a
44
+ * readable refresh token is another local user's session.
45
+ *
46
+ * Skipped on Windows, which has no POSIX mode: Node reports 0o666 for an ordinary
47
+ * writable file, so this would refuse every session and advise a `chmod` that does not
48
+ * exist there. ACLs are the equivalent, and checking them is not this function's job. */
49
+ if (process.platform !== "win32" && (mode & 0o077) !== 0) {
50
+ return {
51
+ kind: "unusable",
52
+ reason: `${path} is readable by other users (mode ${mode.toString(8)}). Run \`chmod 600 ${path}\`, or delete it and sign in again.`,
53
+ };
54
+ }
55
+ const file = readFile(path);
56
+ if (file === undefined) {
57
+ return { kind: "unusable", reason: `${path} is not readable as JSON. Delete it and sign in again.` };
58
+ }
59
+ const entry = file[gateway];
60
+ if (entry === undefined)
61
+ return { kind: "none" };
62
+ return isComplete(entry)
63
+ ? { kind: "credential", credential: entry }
64
+ : { kind: "unusable", reason: `the stored session for ${gateway} is incomplete. Sign in again.` };
65
+ }
66
+ /**
67
+ * Writes one gateway's credential, leaving the others alone. Created at 0600 rather than
68
+ * chmodded after: the gap between the two is openable by another local user.
69
+ */
70
+ export function save(gateway, credential, ctx) {
71
+ const path = credentialsPath(ctx);
72
+ mkdirSync(dirname(path), { recursive: true, mode: DIR_MODE });
73
+ const existing = readFile(path) ?? {};
74
+ const merged = { ...existing, [gateway]: credential };
75
+ // Temp then rename, so an interrupted write cannot replace a whole session with half of one.
76
+ const temp = `${path}.tmp`;
77
+ writeFileSync(temp, `${JSON.stringify(merged, null, 2)}\n`, { mode: FILE_MODE });
78
+ chmodSync(temp, FILE_MODE);
79
+ renameSync(temp, path);
80
+ }
81
+ /** Forgets one gateway's session. Returns false when there was nothing to forget. */
82
+ export function clear(gateway, ctx) {
83
+ const path = credentialsPath(ctx);
84
+ const existing = readFile(path);
85
+ if (existing === undefined || existing[gateway] === undefined)
86
+ return false;
87
+ const rest = Object.fromEntries(Object.entries(existing).filter(([key]) => key !== gateway));
88
+ if (Object.keys(rest).length === 0) {
89
+ rmSync(path, { force: true });
90
+ return true;
91
+ }
92
+ const temp = `${path}.tmp`;
93
+ writeFileSync(temp, `${JSON.stringify(rest, null, 2)}\n`, { mode: FILE_MODE });
94
+ chmodSync(temp, FILE_MODE);
95
+ renameSync(temp, path);
96
+ return true;
97
+ }
98
+ //# sourceMappingURL=credentials.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.js","sourceRoot":"","sources":["../../src/login/credentials.ts"],"names":[],"mappings":"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"}
@@ -1,9 +1,9 @@
1
- import { type CliCallback } from "./contract.js";
2
- /** Two minutes: long enough to sign in, short enough not to look wedged. */
3
- export declare const LOGIN_TIMEOUT_MS = 120000;
1
+ import { type CallbackOutcome } from "./contract.js";
2
+ /** Matches the `exp` the consent screen is told, so both sides give up together. */
3
+ export declare const LOGIN_TIMEOUT_MS = 180000;
4
4
  export type LoopbackOutcome = {
5
5
  readonly kind: "callback";
6
- readonly payload: CliCallback;
6
+ readonly outcome: CallbackOutcome;
7
7
  } | {
8
8
  readonly kind: "timeout";
9
9
  } | {
@@ -11,14 +11,12 @@ export type LoopbackOutcome = {
11
11
  readonly reason: string;
12
12
  };
13
13
  export interface Listener {
14
- /** Open this in the browser; it redirects to the sign-in page. */
15
- readonly startUrl: string;
16
- /** Where the sign-in page is redirected to, and what to print if no browser opens. */
14
+ /** The consent screen to open, and what to print if no browser opens. */
17
15
  readonly signInUrl: string;
18
- /** The URL to hand the webapp. Always loopback, always an ephemeral port. */
16
+ /** The URL to hand the webapp. */
19
17
  readonly callbackUrl: string;
20
18
  readonly state: string;
21
- /** The interface the socket is actually bound to — asserted in tests. */
19
+ /** The interface the socket is actually bound to. */
22
20
  readonly host: string;
23
21
  readonly port: number;
24
22
  /** Settles once a valid callback arrives, on timeout, or on a listener error. */
@@ -26,7 +24,7 @@ export interface Listener {
26
24
  /** Idempotent; safe to call after the promise has settled. */
27
25
  readonly close: () => void;
28
26
  }
29
- /** 256 bits from the CSPRNG. Guessing it is not a threat model we need to model. */
27
+ /** 256 bits from the CSPRNG. */
30
28
  export declare function newState(): string;
31
29
  export interface LoopbackOptions {
32
30
  /** Origin allowed to POST here — the webapp's, never `*`. */
@@ -1,20 +1,16 @@
1
1
  /**
2
- * The loopback listener that receives the login callback.
3
- *
4
- * This is the security surface of the whole handshake, so the invariants are
5
- * asserted by tests rather than trusted: bound to `127.0.0.1` only, one valid
6
- * callback accepted and no more, `state` compared before the payload is handed
7
- * over, a hard timeout so the CLI cannot sit open indefinitely, and a body size
8
- * cap so a stray client cannot make us buffer without bound.
2
+ * The loopback listener that receives the login callback. Security surface of the
3
+ * handshake, so its invariants are asserted in test/login.test.ts: 127.0.0.1 only,
4
+ * single-use, `state` compared before the body is trusted, hard timeout, body cap.
9
5
  */
10
6
  import { randomBytes, timingSafeEqual } from "node:crypto";
11
7
  import { createServer } from "node:http";
12
- import { CALLBACK_PATH, START_PATH, parseCallback } from "./contract.js";
13
- /** Two minutes: long enough to sign in, short enough not to look wedged. */
14
- export const LOGIN_TIMEOUT_MS = 120_000;
8
+ import { CALLBACK_PATH, parseCallback } from "./contract.js";
9
+ /** Matches the `exp` the consent screen is told, so both sides give up together. */
10
+ export const LOGIN_TIMEOUT_MS = 180_000;
15
11
  const MAX_BODY_BYTES = 8_192;
16
12
  const LOOPBACK_HOST = "127.0.0.1";
17
- /** 256 bits from the CSPRNG. Guessing it is not a threat model we need to model. */
13
+ /** 256 bits from the CSPRNG. */
18
14
  export function newState() {
19
15
  return randomBytes(32).toString("base64url");
20
16
  }
@@ -34,8 +30,7 @@ function readBody(req) {
34
30
  body += chunk.toString("utf8");
35
31
  if (body.length > MAX_BODY_BYTES) {
36
32
  tooBig = true;
37
- // Destroy rather than keep reading: the only client we expect sends a few
38
- // hundred bytes, so anything larger is not the flow we are in.
33
+ // Destroy rather than keep reading; the real client sends a few hundred bytes.
39
34
  req.destroy();
40
35
  }
41
36
  });
@@ -43,6 +38,39 @@ function readBody(req) {
43
38
  req.on("error", () => resolve(undefined));
44
39
  });
45
40
  }
41
+ /* Served to the tab that delivered the outcome — which on the form-POST path is where
42
+ * the user ends up, so a decline must not read as a success. Self-contained by necessity. */
43
+ function landingPage(outcome) {
44
+ const message = outcome.kind === "credential"
45
+ ? "Signed in. You can close this tab and return to your terminal."
46
+ : "Sign-in was not completed. You can close this tab and return to your terminal.";
47
+ return `<!doctype html>
48
+ <html lang="en"><head><meta charset="utf-8"><title>ai21-gateway</title></head>
49
+ <body style="font-family: system-ui, sans-serif; margin: 4rem; color: #111;">
50
+ <p>${message}</p>
51
+ </body></html>`;
52
+ }
53
+ /* JSON, or the form encoding the consent screen falls back to (deliverCliCredential.ts).
54
+ * A form POST is a "simple request", so no preflight ran — hence the Host and state
55
+ * checks before the body is read. */
56
+ function parseBody(contentType, raw) {
57
+ const type = ((contentType ?? "").split(";")[0] ?? "").trim().toLowerCase();
58
+ if (type === "application/json") {
59
+ try {
60
+ const parsed = JSON.parse(raw);
61
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
62
+ ? parsed
63
+ : undefined;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ if (type === "application/x-www-form-urlencoded") {
70
+ return Object.fromEntries(new URLSearchParams(raw));
71
+ }
72
+ return undefined;
73
+ }
46
74
  /**
47
75
  * Starts the listener. Resolves once it is bound, so the caller can put the real
48
76
  * port into the URL it opens.
@@ -55,10 +83,7 @@ export function startLoopback(options) {
55
83
  settle = resolve;
56
84
  });
57
85
  let done = false;
58
- /**
59
- * The single-use latch. A second callback — a replayed POST, a double-submitting
60
- * page — must not overwrite credentials we have already accepted.
61
- */
86
+ /** Single-use latch: a replay or double-submit must not overwrite what we accepted. */
62
87
  const finish = (outcome) => {
63
88
  if (done)
64
89
  return;
@@ -69,38 +94,26 @@ export function startLoopback(options) {
69
94
  void handle(req, res);
70
95
  });
71
96
  const cors = (res) => {
72
- // The page POSTing here is on the webapp's origin, so without CORS the browser
73
- // refuses the request outright. Named explicitly rather than `*`: any origin
74
- // being able to talk to this port is the thing we are trying to avoid.
97
+ // Named explicitly, never `*` — any origin reaching this port is what we are avoiding.
75
98
  res.setHeader("Access-Control-Allow-Origin", options.allowedOrigin);
76
99
  res.setHeader("Access-Control-Allow-Headers", "content-type");
77
100
  res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
78
- // Chrome's Private Network Access: a public HTTPS page reaching a loopback address
79
- // must be granted it explicitly, or the preflight fails and the POST never happens.
101
+ // Chrome's Private Network Access grant; without it the preflight fails.
80
102
  res.setHeader("Access-Control-Allow-Private-Network", "true");
81
103
  res.setHeader("Vary", "Origin");
82
104
  };
83
- // Single-use: an attacker who guesses the port and beats the browser to /start
84
- // gets the state, but the browser then fails visibly instead of the run being
85
- // hijacked in silence.
86
- let redirected = false;
87
105
  let signInUrl = "";
106
+ let expectedHost = "";
107
+ /* DNS rebinding arrives same-origin, so CORS never runs and Origin cannot see it.
108
+ * Host is what gives it away: browsers send the name dialled, not the address. */
109
+ const hostIsLoopback = (req) => (req.headers.host ?? "") === expectedHost;
88
110
  async function handle(req, res) {
89
- cors(res);
90
111
  const path = (req.url ?? "").split("?")[0];
91
- if (path === START_PATH) {
92
- if (req.method !== "GET") {
93
- res.writeHead(405).end();
94
- }
95
- else if (redirected) {
96
- res.writeHead(410, { "content-type": "text/plain" }).end("This sign-in link has already been used.");
97
- }
98
- else {
99
- redirected = true;
100
- res.writeHead(302, { location: signInUrl }).end();
101
- }
112
+ if (!hostIsLoopback(req)) {
113
+ res.writeHead(403).end();
102
114
  return;
103
115
  }
116
+ cors(res);
104
117
  if (path !== CALLBACK_PATH) {
105
118
  res.writeHead(404).end();
106
119
  return;
@@ -110,41 +123,48 @@ export function startLoopback(options) {
110
123
  return;
111
124
  }
112
125
  if (req.method !== "POST") {
113
- // Notably this rejects GET, which is what a credential-in-the-URL flow would
114
- // use. The secret must arrive in a body.
126
+ // Rejects GET, so a credential cannot arrive in a URL.
115
127
  res.writeHead(405).end();
116
128
  return;
117
129
  }
130
+ /* Wrong origin refused; absent or opaque tolerated. The form-POST fallback is a
131
+ * navigation from an https page to http loopback, which browsers report as the
132
+ * literal `null` rather than omitting the header — refusing that would 403 the one
133
+ * path that works on a deployed dashboard. `state` carries the weight in both cases. */
134
+ const origin = req.headers.origin;
135
+ const stated = origin !== undefined && origin !== "null";
136
+ if (stated && origin.replace(/\/$/, "") !== options.allowedOrigin.replace(/\/$/, "")) {
137
+ res.writeHead(403).end();
138
+ return;
139
+ }
118
140
  const raw = await readBody(req);
119
141
  if (raw === undefined) {
120
142
  res.writeHead(413).end();
121
143
  return;
122
144
  }
123
- let parsed;
124
- try {
125
- parsed = JSON.parse(raw);
126
- }
127
- catch {
145
+ const parsed = parseBody(req.headers["content-type"], raw);
146
+ if (parsed === undefined) {
128
147
  res.writeHead(400).end();
129
148
  return;
130
149
  }
131
- const payload = parseCallback(parsed);
132
- if (payload === undefined) {
150
+ /* Rejected without abandoning the run: a stale tab must not cancel a live login. */
151
+ const offered = parsed["state"];
152
+ if (typeof offered !== "string" || !statesMatch(state, offered)) {
133
153
  res.writeHead(400).end();
134
154
  return;
135
155
  }
136
- if (!statesMatch(state, payload.state)) {
137
- // Rejected, and the run is *not* abandoned: a spoofed or stale POST must not
138
- // be able to cancel a legitimate login that is still in progress.
139
- res.writeHead(403).end();
156
+ const outcome = parseCallback(parsed);
157
+ if (outcome === undefined) {
158
+ res.writeHead(400).end();
140
159
  return;
141
160
  }
142
161
  if (done) {
143
162
  res.writeHead(409).end();
144
163
  return;
145
164
  }
146
- res.writeHead(200, { "content-type": "application/json" }).end('{"ok":true}');
147
- finish({ kind: "callback", payload });
165
+ // The form POST is a navigation, so this response is the page the user lands on.
166
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(landingPage(outcome));
167
+ finish({ kind: "callback", outcome });
148
168
  }
149
169
  const timer = setTimeout(() => finish({ kind: "timeout" }), timeoutMs);
150
170
  // Never hold the process open on our own account.
@@ -153,31 +173,26 @@ export function startLoopback(options) {
153
173
  clearTimeout(timer);
154
174
  server.close();
155
175
  };
156
- // Closing on settle rather than making the caller remember to: a listener left
157
- // open after the flow ends is an open port nobody is watching.
176
+ // Closed on settle, so the flow cannot leave an unwatched port open.
158
177
  void result.then(close);
159
178
  return new Promise((resolve, reject) => {
160
179
  const onError = (err) => {
161
- // Nothing bound, so nothing to close — and the timer would otherwise
162
- // outlive a run that never started.
180
+ // Nothing bound, and the timer would outlive a run that never started.
163
181
  clearTimeout(timer);
164
182
  reject(err);
165
183
  };
166
184
  server.once("error", onError);
167
- // Port 0 asks the OS for an ephemeral one. The CLI tells the browser where it
168
- // landed, so nothing has to guess — and there is no fixed port to collide over.
185
+ // Ephemeral: the CLI tells the browser where it landed, so no fixed port to collide over.
169
186
  server.listen({ port: 0, host: LOOPBACK_HOST }, () => {
170
187
  server.removeListener("error", onError);
171
- /* Swapped only now that the socket is bound: until then an error is a bind
172
- * failure, which rejects the promise rather than settling a run that never
173
- * started. */
188
+ /* Only now that it is bound: until here an error is a bind failure, which rejects. */
174
189
  server.on("error", (err) => finish({ kind: "failed", reason: err.message }));
175
190
  const address = server.address();
176
191
  const origin = `http://${LOOPBACK_HOST}:${address.port}`;
177
192
  const callbackUrl = `${origin}${CALLBACK_PATH}`;
193
+ expectedHost = `${LOOPBACK_HOST}:${address.port}`;
178
194
  signInUrl = options.signInUrl(callbackUrl, state);
179
195
  resolve({
180
- startUrl: `${origin}${START_PATH}`,
181
196
  signInUrl,
182
197
  callbackUrl,
183
198
  state,
@@ -1 +1 @@
1
- {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../src/login/loopback.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../src/login/loopback.ts"],"names":[],"mappings":"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"}
@@ -1,36 +1,35 @@
1
- /**
2
- * `login` orchestration: open the browser, wait on the loopback listener, hand
3
- * whatever comes back to the ordinary config writer.
4
- *
5
- * The credentials live in a local variable for the length of one run and are
6
- * written only into `settings.json`. Nothing else is persisted — no token cache, no
7
- * `~/.ai21/credentials` — so there is nothing for an attacker to find later.
8
- */
9
1
  import { type ResolvedUrls } from "../args.js";
2
+ import type { FetchLike } from "./tokens.js";
3
+ import { type CliCredential } from "./contract.js";
10
4
  import { type Listener, type LoopbackOptions } from "./loopback.js";
11
5
  export interface LoginDeps {
12
6
  readonly startListener: (options: LoopbackOptions) => Promise<Listener>;
13
7
  readonly openBrowser: (url: string) => boolean;
8
+ /** For the token exchange and workspace listing; injected so tests make no calls. */
9
+ readonly fetchImpl: FetchLike;
14
10
  }
15
11
  export type LoginOutcome = {
16
- readonly kind: "credentials";
17
- readonly agentId: string;
18
- readonly ai21Key: string;
19
- /** Present only when the callback supplied a well-formed one. */
20
- readonly workspaceId?: string | undefined;
12
+ readonly kind: "credential";
13
+ readonly credential: CliCredential;
21
14
  } | {
22
15
  readonly kind: "timeout";
23
16
  } | {
24
- readonly kind: "rejected";
17
+ readonly kind: "declined";
25
18
  readonly reason: string;
26
19
  } | {
27
20
  readonly kind: "failed";
28
21
  readonly reason: string;
29
22
  };
30
- /** The `/welcome` URL, carrying where to call back and the nonce to echo. */
31
- export declare function loginUrl(urls: ResolvedUrls, callbackUrl: string, state: string): string;
32
23
  export interface LoginProgress {
33
- /** Told what to say while waiting; the caller decides which stream it lands on. */
24
+ /** The caller decides which stream this lands on. */
34
25
  readonly notify: (message: string) => void;
35
26
  }
27
+ /**
28
+ * The consent screen's URL.
29
+ *
30
+ * `exp` is here because the listener does not outlive the deadline but the tab does: once
31
+ * the port is freed, whatever binds it next would receive the credential if the user came
32
+ * back and approved. The code cannot catch that — it is rendered from this same URL.
33
+ */
34
+ export declare function loginUrl(urls: ResolvedUrls, callbackUrl: string, state: string, code: string, expiresAtSeconds: number): string;
36
35
  export declare function runLogin(urls: ResolvedUrls, deps: LoginDeps, io: LoginProgress): Promise<LoginOutcome>;
@@ -1,75 +1,61 @@
1
1
  /**
2
- * `login` orchestration: open the browser, wait on the loopback listener, hand
3
- * whatever comes back to the ordinary config writer.
2
+ * `login` orchestration: bind the listener, send the user to the consent screen, and hand
3
+ * back the credential. Bound before the browser opens, so the page is never told to post
4
+ * to a port nothing is listening on.
5
+ */
6
+ import { randomInt } from "node:crypto";
7
+ import { CLIENT_ID, CLIENT_PARAM, CLI_LOGIN_PATH, CODE_PARAM, EXPIRES_PARAM, REDIRECT_PARAM, STATE_PARAM, verificationCode, } from "./contract.js";
8
+ import { LOGIN_TIMEOUT_MS } from "./loopback.js";
9
+ /**
10
+ * The consent screen's URL.
4
11
  *
5
- * The credentials live in a local variable for the length of one run and are
6
- * written only into `settings.json`. Nothing else is persisted — no token cache, no
7
- * `~/.ai21/credentials` — so there is nothing for an attacker to find later.
12
+ * `exp` is here because the listener does not outlive the deadline but the tab does: once
13
+ * the port is freed, whatever binds it next would receive the credential if the user came
14
+ * back and approved. The code cannot catch that — it is rendered from this same URL.
8
15
  */
9
- import { containsControlCharacters } from "../args.js";
10
- import { CALLBACK_PARAM, STATE_PARAM } from "./contract.js";
11
- /** The `/welcome` URL, carrying where to call back and the nonce to echo. */
12
- export function loginUrl(urls, callbackUrl, state) {
13
- const url = new URL("/welcome", urls.app);
14
- url.searchParams.set(CALLBACK_PARAM, callbackUrl);
16
+ export function loginUrl(urls, callbackUrl, state, code, expiresAtSeconds) {
17
+ const url = new URL(CLI_LOGIN_PATH, urls.app);
18
+ url.searchParams.set(REDIRECT_PARAM, callbackUrl);
15
19
  url.searchParams.set(STATE_PARAM, state);
20
+ url.searchParams.set(CODE_PARAM, code);
21
+ url.searchParams.set(EXPIRES_PARAM, String(expiresAtSeconds));
22
+ // So the screen names this CLI and not another.
23
+ url.searchParams.set(CLIENT_PARAM, CLIENT_ID);
16
24
  return url.toString();
17
25
  }
18
- /**
19
- * Callback values are remote input, so they are validated by exactly the rule the
20
- * flags use. They land in the same `\n`-joined header string, where a newline
21
- * forges a header that Claude Code would then send on every request — and unlike a
22
- * flag, nobody typed this.
23
- */
24
- function rejectBadValues(payload) {
25
- for (const [name, value] of [
26
- ["agentId", payload.agentId],
27
- ["ai21Key", payload.ai21Key],
28
- ]) {
29
- if (containsControlCharacters(value)) {
30
- return `the ${name} in the login response contains control characters`;
31
- }
32
- }
33
- return undefined;
34
- }
35
26
  export async function runLogin(urls, deps, io) {
27
+ const code = verificationCode((max) => randomInt(max));
28
+ // Stamped early, so the page gives up a moment before the listener, not after.
29
+ const expiresAtSeconds = Math.floor((Date.now() + LOGIN_TIMEOUT_MS) / 1000);
36
30
  let listener;
37
31
  try {
38
32
  listener = await deps.startListener({
39
33
  allowedOrigin: urls.app,
40
- signInUrl: (callbackUrl, state) => loginUrl(urls, callbackUrl, state),
34
+ signInUrl: (callbackUrl, state) => loginUrl(urls, callbackUrl, state, code, expiresAtSeconds),
41
35
  });
42
36
  }
43
37
  catch (err) {
44
38
  return { kind: "failed", reason: err instanceof Error ? err.message : "the local listener could not be started" };
45
39
  }
46
- // The browser is pointed at the loopback redirect, so the nonce never reaches its
47
- // command line. The printed URL is the real one, since a manual path needs it.
48
- //
49
- // Claims nothing about whether a browser appeared: `spawn` reports a missing binary
50
- // asynchronously, so a failed launch still looks like a success from here.
51
- deps.openBrowser(listener.startUrl);
52
- io.notify(`Sign in to continue — your browser should open. If it does not, visit:\n\n ${listener.signInUrl}\n\nWaiting…`);
40
+ /* Printed as well as opened: `openBrowser` cannot tell us a browser appeared, and over
41
+ * SSH the URL is the only way in. */
42
+ deps.openBrowser(listener.signInUrl);
43
+ io.notify(`Sign in to continue — your browser should open. If it does not, visit:\n\n ${listener.signInUrl}\n\n` +
44
+ ` verification code: ${code}\n\n` +
45
+ `The page will show this code. If it shows anything else, choose Deny — something\n` +
46
+ `other than this terminal is asking to sign in.\n\nWaiting…`);
53
47
  return settle(await listener.result);
54
48
  }
55
- /** Shared tail: turn a listener outcome into the credentials, or say why not. */
56
49
  function settle(outcome) {
57
50
  switch (outcome.kind) {
58
51
  case "timeout":
59
52
  return { kind: "timeout" };
60
53
  case "failed":
61
54
  return { kind: "failed", reason: outcome.reason };
62
- case "callback": {
63
- const problem = rejectBadValues(outcome.payload);
64
- if (problem !== undefined)
65
- return { kind: "rejected", reason: problem };
66
- return {
67
- kind: "credentials",
68
- agentId: outcome.payload.agentId,
69
- ai21Key: outcome.payload.ai21Key,
70
- ...(outcome.payload.workspaceId === undefined ? {} : { workspaceId: outcome.payload.workspaceId }),
71
- };
72
- }
55
+ case "callback":
56
+ return outcome.outcome.kind === "declined"
57
+ ? { kind: "declined", reason: outcome.outcome.reason }
58
+ : { kind: "credential", credential: outcome.outcome.credential };
73
59
  }
74
60
  }
75
61
  //# sourceMappingURL=session.js.map