@ai21/gateway 0.5.3 → 0.5.4
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 +21 -7
- package/dist/args.d.ts +13 -4
- package/dist/args.js +40 -13
- package/dist/args.js.map +1 -1
- package/dist/cli.js +1 -0
- package/dist/cli.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/router.js +76 -41
- package/dist/router.js.map +1 -1
- package/package.json +1 -1
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/login/session.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/login/session.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare class DeadCredentialError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export interface ExchangeResult {
|
|
4
|
+
readonly idToken: string;
|
|
5
|
+
/** Google may hand back a new refresh token; whatever it returns is what to store. */
|
|
6
|
+
readonly refreshToken: string;
|
|
7
|
+
}
|
|
8
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
9
|
+
/**
|
|
10
|
+
* Exchanges a refresh token for an ID token.
|
|
11
|
+
*
|
|
12
|
+
* Throws `DeadCredentialError` when the session is genuinely gone — the caller should
|
|
13
|
+
* tell the user to sign in again — and a plain `Error` for anything transient, which is
|
|
14
|
+
* worth retrying rather than acting on.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exchangeRefreshToken(refreshToken: string, firebaseApiKey: string, fetchImpl?: FetchLike): Promise<ExchangeResult>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning the stored refresh token into the hour-long ID token identity checks. One call
|
|
3
|
+
* to Google's secure token service, and this module's entire network surface.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here verifies a token: the CLI is the bearer, not the audience.
|
|
6
|
+
*/
|
|
7
|
+
const TOKEN_ENDPOINT = "https://securetoken.googleapis.com/v1/token";
|
|
8
|
+
const TIMEOUT_MS = 20_000;
|
|
9
|
+
/**
|
|
10
|
+
* Firebase's names for "this refresh token will never work again". Anything else — a 5xx,
|
|
11
|
+
* a timeout, a DNS blip — is transient, and signing the user out over it would be wrong.
|
|
12
|
+
* A wrong API key is deliberately absent: that is our misconfiguration, not a dead session.
|
|
13
|
+
*/
|
|
14
|
+
const DEAD_CREDENTIAL_ERRORS = ["TOKEN_EXPIRED", "INVALID_REFRESH_TOKEN", "USER_DISABLED", "USER_NOT_FOUND"];
|
|
15
|
+
export class DeadCredentialError extends Error {
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Exchanges a refresh token for an ID token.
|
|
19
|
+
*
|
|
20
|
+
* Throws `DeadCredentialError` when the session is genuinely gone — the caller should
|
|
21
|
+
* tell the user to sign in again — and a plain `Error` for anything transient, which is
|
|
22
|
+
* worth retrying rather than acting on.
|
|
23
|
+
*/
|
|
24
|
+
export async function exchangeRefreshToken(refreshToken, firebaseApiKey, fetchImpl = fetch) {
|
|
25
|
+
const url = `${TOKEN_ENDPOINT}?key=${encodeURIComponent(firebaseApiKey)}`;
|
|
26
|
+
const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await fetchImpl(url, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
32
|
+
body: body.toString(),
|
|
33
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// A transport failure is never a dead credential.
|
|
38
|
+
throw new Error(`could not reach Google's token service: ${err instanceof Error ? err.message : String(err)}`);
|
|
39
|
+
}
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
const message = errorMessage(text);
|
|
43
|
+
if (DEAD_CREDENTIAL_ERRORS.some((code) => message.includes(code))) {
|
|
44
|
+
throw new DeadCredentialError("your session has expired — run `ai21-gateway login` again");
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`the token service refused the request (${response.status})${message === "" ? "" : `: ${message}`}`);
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error("the token service returned a body that is not JSON");
|
|
54
|
+
}
|
|
55
|
+
const payload = parsed;
|
|
56
|
+
const idToken = payload["id_token"];
|
|
57
|
+
const rotated = payload["refresh_token"];
|
|
58
|
+
if (typeof idToken !== "string" || idToken === "") {
|
|
59
|
+
throw new Error("the token service returned no id_token");
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
idToken,
|
|
63
|
+
// Falls back to the token we sent: a response without one means it was not rotated.
|
|
64
|
+
refreshToken: typeof rotated === "string" && rotated !== "" ? rotated : refreshToken,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Pulls Firebase's error code out of its body, without assuming the shape. */
|
|
68
|
+
function errorMessage(text) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(text);
|
|
71
|
+
const message = parsed.error?.message;
|
|
72
|
+
return typeof message === "string" ? message : "";
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=tokens.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokens.js","sourceRoot":"","sources":["../../src/login/tokens.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's AI21 workspaces, so `login` can say which one it signed in to.
|
|
3
|
+
*
|
|
4
|
+
* Identity shares the gateway's origin (`api.<domain>/identity`), so the URL is derived
|
|
5
|
+
* from it. Authenticated with the Firebase ID token in a `firebase-token` header —
|
|
6
|
+
* identity does not read `Authorization: Bearer`.
|
|
7
|
+
*/
|
|
8
|
+
import type { FetchLike } from "./tokens.js";
|
|
9
|
+
export interface Workspace {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
}
|
|
13
|
+
/** `https://api.example.com/gateway` → `https://api.example.com`. */
|
|
14
|
+
export declare function identityBase(gatewayUrl: string): string | undefined;
|
|
15
|
+
/** Empty rather than throwing on anything unexpected: a completed login must not be lost
|
|
16
|
+
* to a failed listing. */
|
|
17
|
+
export declare function fetchWorkspaces(gatewayUrl: string, idToken: string, fetchImpl?: FetchLike): Promise<readonly Workspace[]>;
|