@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.
- package/README.md +23 -8
- package/dist/args.d.ts +13 -4
- package/dist/args.js +43 -14
- package/dist/args.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +6 -1
- package/dist/config.js +19 -1
- package/dist/config.js.map +1 -1
- package/dist/login/contract.d.ts +34 -34
- package/dist/login/contract.js +56 -43
- package/dist/login/contract.js.map +1 -1
- package/dist/login/credentials.d.ts +34 -0
- package/dist/login/credentials.js +98 -0
- package/dist/login/credentials.js.map +1 -0
- package/dist/login/loopback.d.ts +8 -10
- package/dist/login/loopback.js +78 -63
- package/dist/login/loopback.js.map +1 -1
- package/dist/login/session.d.ts +16 -17
- package/dist/login/session.js +34 -48
- package/dist/login/session.js.map +1 -1
- package/dist/login/tokens.d.ts +16 -0
- package/dist/login/tokens.js +78 -0
- package/dist/login/tokens.js.map +1 -0
- package/dist/login/workspaces.d.ts +17 -0
- package/dist/login/workspaces.js +65 -0
- package/dist/login/workspaces.js.map +1 -0
- package/dist/report.d.ts +5 -1
- package/dist/report.js +11 -0
- package/dist/report.js.map +1 -1
- package/dist/router.js +82 -43
- package/dist/router.js.map +1 -1
- package/package.json +1 -1
|
@@ -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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"}
|
package/dist/login/loopback.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
/**
|
|
3
|
-
export declare const LOGIN_TIMEOUT_MS =
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
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.
|
|
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 `*`. */
|
package/dist/login/loopback.js
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The loopback listener that receives the login callback.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
13
|
-
/**
|
|
14
|
-
export const LOGIN_TIMEOUT_MS =
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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 (
|
|
92
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
/*
|
|
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":"AAAA
|
|
1
|
+
{"version":3,"file":"loopback.js","sourceRoot":"","sources":["../../src/login/loopback.ts"],"names":[],"mappings":"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"}
|
package/dist/login/session.d.ts
CHANGED
|
@@ -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: "
|
|
17
|
-
readonly
|
|
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: "
|
|
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
|
-
/**
|
|
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>;
|
package/dist/login/session.js
CHANGED
|
@@ -1,75 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `login` orchestration:
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|