@2kw/ai 5.2.0-dev.8 → 5.3.0-dev.11
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 +31 -6
- package/dist/commands/auth.d.ts +91 -1
- package/dist/commands/auth.js +326 -53
- package/dist/commands/config.d.ts +16 -0
- package/dist/commands/config.js +52 -19
- package/dist/commands/context.d.ts +25 -0
- package/dist/commands/context.js +108 -14
- package/dist/commands/convert.js +3 -2
- package/dist/commands/docs.js +10 -6
- package/dist/commands/schemas.js +9 -2
- package/dist/commands/transcribe.js +3 -1
- package/dist/lib/auth-service.d.ts +131 -0
- package/dist/lib/auth-service.js +240 -0
- package/dist/lib/auth-session.d.ts +139 -0
- package/dist/lib/auth-session.js +275 -0
- package/dist/lib/client.d.ts +57 -0
- package/dist/lib/client.js +81 -5
- package/dist/lib/config.d.ts +64 -5
- package/dist/lib/config.js +105 -15
- package/dist/lib/errors.d.ts +2 -1
- package/dist/lib/errors.js +66 -11
- package/dist/lib/redact.d.ts +35 -0
- package/dist/lib/redact.js +54 -0
- package/package.json +2 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin fetch wrappers for the auth service (Better Auth) endpoints the CLI
|
|
3
|
+
* needs. Deliberately dependency-free: endpoints are stable REST paths and a
|
|
4
|
+
* full better-auth client would drag react-oriented tooling into the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Failures leave through exactly three exits, and callers branch on which:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Mapped {@link DeviceTokenResult} values** — {@link pollDeviceToken} only.
|
|
9
|
+
* `pending` / `slow_down` / `denied` / `expired` are ordinary device-flow
|
|
10
|
+
* states, so they are returned rather than thrown and the poll loop reads as
|
|
11
|
+
* the state machine it is instead of a try/catch.
|
|
12
|
+
* 2. **{@link AuthServiceError}** — the service answered, and the answer was no.
|
|
13
|
+
* Carries the HTTP `status` and any OAuth `code`. A response came back, so
|
|
14
|
+
* replaying the identical request is usually pointless.
|
|
15
|
+
* 3. **A raw fetch `TypeError`** ("fetch failed") — the transport never
|
|
16
|
+
* completed: DNS, TLS, connection refused. This module neither catches nor
|
|
17
|
+
* wraps it; it propagates untouched, and it is the retry-worthy case.
|
|
18
|
+
*
|
|
19
|
+
* So `err instanceof AuthServiceError` separates "the server rejected us" from
|
|
20
|
+
* "we never reached the server" — the split retry and diagnostics need.
|
|
21
|
+
* `classifyAuthFailure` in errors.ts already covers the transport half.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* OAuth client id sent with every device-flow request.
|
|
25
|
+
*
|
|
26
|
+
* Cross-repo coupling: this must equal the auth service's configured app slug
|
|
27
|
+
* (`APP_1_SLUG`, default `backbone` — see compose.yaml), which the service's
|
|
28
|
+
* `validateClient` checks the incoming `client_id` against. Change it on one
|
|
29
|
+
* side only and every login fails with `invalid_client`.
|
|
30
|
+
*/
|
|
31
|
+
export const CLI_CLIENT_ID = "backbone";
|
|
32
|
+
/** RFC 8628 grant type used when exchanging a device code for a session. */
|
|
33
|
+
export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
34
|
+
/**
|
|
35
|
+
* A failed auth-service call. Carries the HTTP status and, when the service
|
|
36
|
+
* supplied one, the machine-readable OAuth error code (e.g. "invalid_client").
|
|
37
|
+
*/
|
|
38
|
+
export class AuthServiceError extends Error {
|
|
39
|
+
status;
|
|
40
|
+
code;
|
|
41
|
+
constructor(message, status, code) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.status = status;
|
|
44
|
+
this.code = code;
|
|
45
|
+
this.name = "AuthServiceError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
49
|
+
/** Build an absolute endpoint URL, tolerating trailing slashes on authUrl. */
|
|
50
|
+
function api(authUrl, path) {
|
|
51
|
+
return `${authUrl.replace(/\/+$/, "")}/api/auth${path}`;
|
|
52
|
+
}
|
|
53
|
+
function bearer(sessionToken) {
|
|
54
|
+
return { Authorization: `Bearer ${sessionToken}` };
|
|
55
|
+
}
|
|
56
|
+
function str(value) {
|
|
57
|
+
return typeof value === "string" ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
function num(value) {
|
|
60
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** View a parsed body as a keyed object; anything else (string, null) reads as empty. */
|
|
63
|
+
function rec(value) {
|
|
64
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse a JSON body, degrading to `{}` rather than throwing on an empty or
|
|
68
|
+
* non-JSON payload (a gateway's HTML 502, say). Returns `unknown` because these
|
|
69
|
+
* endpoints legitimately answer with arrays as well as objects.
|
|
70
|
+
*/
|
|
71
|
+
async function readJson(res) {
|
|
72
|
+
try {
|
|
73
|
+
return await res.json();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Return the parsed body of a successful response, or throw an AuthServiceError
|
|
81
|
+
* built from whatever the service reported.
|
|
82
|
+
*/
|
|
83
|
+
async function requireOk(res, what) {
|
|
84
|
+
const body = await readJson(res);
|
|
85
|
+
if (res.ok)
|
|
86
|
+
return body;
|
|
87
|
+
const err = rec(body);
|
|
88
|
+
const detail = str(err.error_description) ?? str(err.message) ?? `HTTP ${res.status}`;
|
|
89
|
+
throw new AuthServiceError(`${what}: ${detail}`, res.status, str(err.error));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Start a device authorization: returns the user code to display and the URL
|
|
93
|
+
* to open, plus the polling parameters for {@link pollDeviceToken}.
|
|
94
|
+
*
|
|
95
|
+
* The payload is validated rather than cast — the login flow prints these
|
|
96
|
+
* strings and polls with `device_code`, so a malformed 200 must fail here
|
|
97
|
+
* instead of surfacing as the literal "undefined" in a user's terminal.
|
|
98
|
+
*/
|
|
99
|
+
export async function requestDeviceCode(authUrl, clientId, fetchFn = fetch) {
|
|
100
|
+
const res = await fetchFn(api(authUrl, "/device/code"), {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { ...JSON_HEADERS },
|
|
103
|
+
body: JSON.stringify({ client_id: clientId }),
|
|
104
|
+
});
|
|
105
|
+
const body = rec(await requireOk(res, "Device authorization request failed"));
|
|
106
|
+
const deviceCode = str(body.device_code);
|
|
107
|
+
const userCode = str(body.user_code);
|
|
108
|
+
const verificationUri = str(body.verification_uri);
|
|
109
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
110
|
+
throw new AuthServiceError("Auth service returned an incomplete device authorization " +
|
|
111
|
+
"(device_code, user_code, and verification_uri are required).", res.status);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
device_code: deviceCode,
|
|
115
|
+
user_code: userCode,
|
|
116
|
+
verification_uri: verificationUri,
|
|
117
|
+
// RFC 8628 marks the pre-filled URI optional; fall back to the bare
|
|
118
|
+
// verification URI so callers always have something to open.
|
|
119
|
+
verification_uri_complete: str(body.verification_uri_complete) ?? verificationUri,
|
|
120
|
+
interval: num(body.interval),
|
|
121
|
+
expires_in: num(body.expires_in),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Poll once for the session token behind a device code.
|
|
126
|
+
*
|
|
127
|
+
* Unlike the other wrappers this does not treat a 400 as fatal: the RFC 8628
|
|
128
|
+
* flow reports its in-progress and terminal states through the error field of a
|
|
129
|
+
* 400 body, so those are mapped to results the caller's loop can act on. Only
|
|
130
|
+
* an outcome that is neither a token nor a known state throws.
|
|
131
|
+
*/
|
|
132
|
+
export async function pollDeviceToken(authUrl, clientId, deviceCode, fetchFn = fetch) {
|
|
133
|
+
const res = await fetchFn(api(authUrl, "/device/token"), {
|
|
134
|
+
method: "POST",
|
|
135
|
+
headers: { ...JSON_HEADERS },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
138
|
+
device_code: deviceCode,
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
const body = rec(await readJson(res));
|
|
143
|
+
const accessToken = str(body.access_token);
|
|
144
|
+
if (res.ok && accessToken)
|
|
145
|
+
return { status: "ok", accessToken };
|
|
146
|
+
switch (str(body.error)) {
|
|
147
|
+
case "authorization_pending":
|
|
148
|
+
return { status: "pending" };
|
|
149
|
+
case "slow_down":
|
|
150
|
+
return { status: "slow_down" };
|
|
151
|
+
case "access_denied":
|
|
152
|
+
return { status: "denied" };
|
|
153
|
+
case "expired_token":
|
|
154
|
+
return { status: "expired" };
|
|
155
|
+
}
|
|
156
|
+
// Neither a token nor a state we know. Say which, because "failed: HTTP 200"
|
|
157
|
+
// reads as a contradiction when the real fault is a well-formed empty answer.
|
|
158
|
+
const detail = str(body.error_description) ?? str(body.message);
|
|
159
|
+
throw new AuthServiceError(detail
|
|
160
|
+
? `Device token exchange failed: ${detail}`
|
|
161
|
+
: `Unrecognized device token response (HTTP ${res.status}, no access_token, no error).`, res.status, str(body.error));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Exchange a browser session for a short-lived organization JWT — the token the
|
|
165
|
+
* API itself accepts. Scoped to the session's currently active organization.
|
|
166
|
+
*/
|
|
167
|
+
export async function fetchJwt(authUrl, sessionToken, fetchFn = fetch) {
|
|
168
|
+
const res = await fetchFn(api(authUrl, "/token"), { headers: bearer(sessionToken) });
|
|
169
|
+
const body = rec(await requireOk(res, "Could not obtain an organization token"));
|
|
170
|
+
const token = str(body.token);
|
|
171
|
+
if (!token) {
|
|
172
|
+
throw new AuthServiceError("Auth service returned no token for this session.", res.status);
|
|
173
|
+
}
|
|
174
|
+
return token;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Organizations the session's user belongs to, projected to what the CLI shows.
|
|
178
|
+
*
|
|
179
|
+
* Entries without a usable string `id` are dropped: an org that cannot be named
|
|
180
|
+
* in `set-active` is not selectable, and coercing one into the list would put
|
|
181
|
+
* the string "undefined" in front of the user.
|
|
182
|
+
*/
|
|
183
|
+
export async function listOrganizations(authUrl, sessionToken, fetchFn = fetch) {
|
|
184
|
+
const res = await fetchFn(api(authUrl, "/organization/list"), {
|
|
185
|
+
headers: bearer(sessionToken),
|
|
186
|
+
});
|
|
187
|
+
const body = await requireOk(res, "Could not list organizations");
|
|
188
|
+
if (!Array.isArray(body))
|
|
189
|
+
return [];
|
|
190
|
+
return body.flatMap((entry) => {
|
|
191
|
+
const org = rec(entry);
|
|
192
|
+
const id = str(org.id);
|
|
193
|
+
if (!id)
|
|
194
|
+
return [];
|
|
195
|
+
// A nameless org is still selectable — show the id rather than drop it.
|
|
196
|
+
return [{ id, name: str(org.name) ?? id, slug: str(org.slug) ?? id }];
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Switch the session's active organization. The choice is server-side state, so
|
|
201
|
+
* every JWT minted afterwards is scoped to it.
|
|
202
|
+
*/
|
|
203
|
+
export async function setActiveOrganization(authUrl, sessionToken, organizationId, fetchFn = fetch) {
|
|
204
|
+
const res = await fetchFn(api(authUrl, "/organization/set-active"), {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
|
|
207
|
+
body: JSON.stringify({ organizationId }),
|
|
208
|
+
});
|
|
209
|
+
await requireOk(res, "Could not set the active organization");
|
|
210
|
+
}
|
|
211
|
+
/** Who the stored session belongs to — used by `auth status` and login output. */
|
|
212
|
+
export async function getSessionInfo(authUrl, sessionToken, fetchFn = fetch) {
|
|
213
|
+
const res = await fetchFn(api(authUrl, "/get-session"), {
|
|
214
|
+
headers: bearer(sessionToken),
|
|
215
|
+
});
|
|
216
|
+
const body = rec(await requireOk(res, "Could not read the current session"));
|
|
217
|
+
const email = str(rec(body.user).email);
|
|
218
|
+
if (!email) {
|
|
219
|
+
throw new AuthServiceError("Auth service returned a session without a user email.", res.status);
|
|
220
|
+
}
|
|
221
|
+
return { email };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Ask the auth service to revoke the session.
|
|
225
|
+
*
|
|
226
|
+
* A 200 is *not* proof of revocation: Better Auth answers `{success: true}`
|
|
227
|
+
* unconditionally and only deletes the session when the bearer actually
|
|
228
|
+
* resolves to one. Callers should therefore clear local credentials regardless
|
|
229
|
+
* of the outcome, and must not report "revoked server-side" on the strength of
|
|
230
|
+
* this call alone.
|
|
231
|
+
*/
|
|
232
|
+
export async function revokeSession(authUrl, sessionToken, fetchFn = fetch) {
|
|
233
|
+
const res = await fetchFn(api(authUrl, "/sign-out"), {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { ...JSON_HEADERS, ...bearer(sessionToken) },
|
|
236
|
+
body: "{}",
|
|
237
|
+
});
|
|
238
|
+
await requireOk(res, "Could not sign out");
|
|
239
|
+
}
|
|
240
|
+
//# sourceMappingURL=auth-service.js.map
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateful half of browser login: the RFC 8628 polling loop that waits for
|
|
3
|
+
* the user to approve a device code, and the cache that keeps the short-lived
|
|
4
|
+
* organization JWT out of the auth service between rapid commands.
|
|
5
|
+
*
|
|
6
|
+
* auth-service.ts owns single requests; this module owns what happens across
|
|
7
|
+
* several of them, so it is where the retry policy lives. It keeps the three
|
|
8
|
+
* exits that module documents apart: an {@link AuthServiceError} means the
|
|
9
|
+
* service answered and said no (replaying it would only repeat the answer),
|
|
10
|
+
* while a raw fetch `TypeError` means the request never landed (worth retrying).
|
|
11
|
+
*/
|
|
12
|
+
import { fetchJwt, type DeviceTokenResult, type OrgSummary } from "./auth-service.js";
|
|
13
|
+
import { type ResolvedConfig } from "./config.js";
|
|
14
|
+
/**
|
|
15
|
+
* The user's own answer to a device login: they refused it, or they left it
|
|
16
|
+
* long enough to lapse.
|
|
17
|
+
*
|
|
18
|
+
* Typed rather than a bare Error because the login command has to tell these
|
|
19
|
+
* two outcomes apart from everything else the polling loop can raise (a refused
|
|
20
|
+
* client id, a dead network). Those are faults and belong in the shared error
|
|
21
|
+
* rendering; these are answers, and get a plain line and a non-zero exit.
|
|
22
|
+
*/
|
|
23
|
+
export declare class DeviceApprovalError extends Error {
|
|
24
|
+
readonly reason: "denied" | "expired";
|
|
25
|
+
constructor(message: string, reason: "denied" | "expired");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The stored session is gone — revoked, or expired server-side. Distinct from
|
|
29
|
+
* AuthServiceError so callers can offer the one fix that works (sign in again)
|
|
30
|
+
* instead of reporting a bare 401.
|
|
31
|
+
*/
|
|
32
|
+
export declare class SessionExpiredError extends Error {
|
|
33
|
+
constructor();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Read a JWT's `exp` claim as epoch millis.
|
|
37
|
+
*
|
|
38
|
+
* Only the payload is decoded and nothing is verified — the CLI is not the
|
|
39
|
+
* audience for this token, it merely needs to know when to stop reusing it.
|
|
40
|
+
* Anything that is not a three-segment JWT with a numeric `exp` throws, because
|
|
41
|
+
* a silently-assumed expiry would either hand a dead token to the API or
|
|
42
|
+
* re-exchange on every single command.
|
|
43
|
+
*/
|
|
44
|
+
export declare function decodeJwtExpMs(jwt: string): number;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a cached JWT has enough life left to be worth reusing — an unknown
|
|
47
|
+
* expiry counts as stale, so a cache entry written by an older CLI is simply
|
|
48
|
+
* re-exchanged rather than trusted.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isJwtFresh(expMs: number | undefined, nowMs: number): boolean;
|
|
51
|
+
/** Inputs to {@link runDevicePolling}; `poll` and `sleep` are injectable for tests. */
|
|
52
|
+
export interface DevicePollingOptions {
|
|
53
|
+
/** One device-token poll — normally a bound `pollDeviceToken`. */
|
|
54
|
+
poll: () => Promise<DeviceTokenResult>;
|
|
55
|
+
/** Seconds between polls, per the device-code response. Defaults to 5. */
|
|
56
|
+
intervalSec?: number;
|
|
57
|
+
/** Total seconds the device code stays valid. Defaults to 1800. */
|
|
58
|
+
expiresInSec?: number;
|
|
59
|
+
/** Delay implementation; defaults to a real timer. */
|
|
60
|
+
sleep?: (ms: number) => Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Poll until the user approves the login in their browser, and return the
|
|
64
|
+
* session token.
|
|
65
|
+
*
|
|
66
|
+
* Failure handling follows the auth-service taxonomy. A transport error is
|
|
67
|
+
* retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
|
|
68
|
+
* by any poll that completes) because a laptop that drops its Wi-Fi mid-login
|
|
69
|
+
* should not have to start over. An {@link AuthServiceError} is not retried —
|
|
70
|
+
* the service answered, and asking again gets the same answer.
|
|
71
|
+
*
|
|
72
|
+
* The deadline is spent from the poll budget rather than read off the wall
|
|
73
|
+
* clock, so an injected sleep makes the whole loop deterministic.
|
|
74
|
+
*
|
|
75
|
+
* @throws {DeviceApprovalError} when the user denies the login, or the code
|
|
76
|
+
* expires — either because the service said so or because the budget ran out.
|
|
77
|
+
* @throws {AuthServiceError} when the service refused the exchange outright.
|
|
78
|
+
* @throws {TypeError} the last transport error, once retrying is pointless.
|
|
79
|
+
*/
|
|
80
|
+
export declare function runDevicePolling(opts: DevicePollingOptions): Promise<string>;
|
|
81
|
+
/** Asks one question and resolves with what the user typed. */
|
|
82
|
+
export type Asker = (question: string) => Promise<string>;
|
|
83
|
+
/** Injection points for {@link selectOrganization}; both default to the real thing. */
|
|
84
|
+
export interface SelectOrgDeps {
|
|
85
|
+
/** Question function — tests (and any non-readline caller) supply their own;
|
|
86
|
+
* the default reads a line from stdin. */
|
|
87
|
+
ask?: Asker;
|
|
88
|
+
/** Whether stdin can be prompted; defaults to `process.stdin.isTTY`. */
|
|
89
|
+
isTTY?: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Decide which organization the new session should act as.
|
|
93
|
+
*
|
|
94
|
+
* A single membership needs no ceremony, so it is chosen silently; several
|
|
95
|
+
* memberships get a numbered prompt that repeats until the answer names one of
|
|
96
|
+
* them. Lives here rather than in the login command because org switching
|
|
97
|
+
* reuses the identical choice.
|
|
98
|
+
*
|
|
99
|
+
* Two guards keep the prompt from becoming a hang. Without a terminal there is
|
|
100
|
+
* nobody to answer, and readline on a closed or piped stdin resolves nothing
|
|
101
|
+
* and never ends — so that case is refused up front, with the fix in the
|
|
102
|
+
* message. The attempt cap covers what the first guard cannot see: an input
|
|
103
|
+
* that does answer, always invalidly (a pipe that yields "" forever).
|
|
104
|
+
*
|
|
105
|
+
* @throws {Error} when the account has no organizations at all, when there is
|
|
106
|
+
* no terminal to prompt, or when the attempts run out.
|
|
107
|
+
*/
|
|
108
|
+
export declare function selectOrganization(orgs: OrgSummary[], deps?: SelectOrgDeps): Promise<OrgSummary>;
|
|
109
|
+
/** Injection points for {@link ensureJwt}; all default to the real thing. */
|
|
110
|
+
export interface EnsureJwtDeps {
|
|
111
|
+
/** Session-to-JWT exchange; defaults to {@link fetchJwt}. */
|
|
112
|
+
fetchJwtFn?: typeof fetchJwt;
|
|
113
|
+
/** Cache writer; defaults to {@link updateContext}. */
|
|
114
|
+
persist?: (contextName: string, patch: {
|
|
115
|
+
cachedJwt: string;
|
|
116
|
+
cachedJwtExp: number;
|
|
117
|
+
}) => void;
|
|
118
|
+
/** Clock; defaults to `Date.now`. */
|
|
119
|
+
now?: () => number;
|
|
120
|
+
/** Skip the cache and always exchange — e.g. right after switching org. */
|
|
121
|
+
force?: boolean;
|
|
122
|
+
}
|
|
123
|
+
type SessionConfig = Extract<ResolvedConfig, {
|
|
124
|
+
kind: "session";
|
|
125
|
+
}>;
|
|
126
|
+
/**
|
|
127
|
+
* Return an organization JWT for a session context, exchanging the session for
|
|
128
|
+
* a new one whenever the cached token is missing, stale, or explicitly bypassed.
|
|
129
|
+
*
|
|
130
|
+
* A fresh token is written both to the config store and back onto `cfg`: a
|
|
131
|
+
* single command may reach here more than once (a retry after a 401, say), and
|
|
132
|
+
* the in-memory copy is what those later reads see. Caching is best-effort — a
|
|
133
|
+
* token that cannot be cached is still a token that works.
|
|
134
|
+
*
|
|
135
|
+
* @throws {SessionExpiredError} when the stored session is no longer valid.
|
|
136
|
+
*/
|
|
137
|
+
export declare function ensureJwt(cfg: SessionConfig, deps?: EnsureJwtDeps): Promise<string>;
|
|
138
|
+
export {};
|
|
139
|
+
//# sourceMappingURL=auth-session.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateful half of browser login: the RFC 8628 polling loop that waits for
|
|
3
|
+
* the user to approve a device code, and the cache that keeps the short-lived
|
|
4
|
+
* organization JWT out of the auth service between rapid commands.
|
|
5
|
+
*
|
|
6
|
+
* auth-service.ts owns single requests; this module owns what happens across
|
|
7
|
+
* several of them, so it is where the retry policy lives. It keeps the three
|
|
8
|
+
* exits that module documents apart: an {@link AuthServiceError} means the
|
|
9
|
+
* service answered and said no (replaying it would only repeat the answer),
|
|
10
|
+
* while a raw fetch `TypeError` means the request never landed (worth retrying).
|
|
11
|
+
*/
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import chalk from "chalk";
|
|
15
|
+
import { AuthServiceError, fetchJwt, } from "./auth-service.js";
|
|
16
|
+
import { updateContext } from "./config.js";
|
|
17
|
+
/**
|
|
18
|
+
* How much life a cached JWT must have left to be reused. Also absorbs modest
|
|
19
|
+
* client-clock skew: a token minted against a server clock a few seconds ahead
|
|
20
|
+
* of ours must not be handed to the API a moment before it becomes valid.
|
|
21
|
+
*/
|
|
22
|
+
const JWT_FRESHNESS_MARGIN_MS = 30_000;
|
|
23
|
+
/** Consecutive-transport-failure count at which the loop gives up: two are
|
|
24
|
+
* ridden out, the third is rethrown. */
|
|
25
|
+
const MAX_CONSECUTIVE_POLL_ERRORS = 3;
|
|
26
|
+
/** RFC 8628 §3.2 recommends 5 s when the service states no interval. */
|
|
27
|
+
const DEFAULT_POLL_INTERVAL_SEC = 5;
|
|
28
|
+
/** Ceiling on the whole login wait when the service states no expiry. */
|
|
29
|
+
const DEFAULT_POLL_EXPIRES_SEC = 1800;
|
|
30
|
+
/** RFC 8628 §3.5: each slow_down widens the polling interval by 5 s. */
|
|
31
|
+
const SLOW_DOWN_BACKOFF_MS = 5_000;
|
|
32
|
+
const DENIED_MESSAGE = "Login was denied in the browser.";
|
|
33
|
+
const EXPIRED_MESSAGE = 'Login attempt expired — run "2kw auth login" again.';
|
|
34
|
+
/** Nothing to select from, and nothing the CLI can do about it — orgs are
|
|
35
|
+
* created in the web app. */
|
|
36
|
+
const NO_ORG_MESSAGE = "Your account belongs to no organization yet — create one in the web app first.";
|
|
37
|
+
/**
|
|
38
|
+
* The user's own answer to a device login: they refused it, or they left it
|
|
39
|
+
* long enough to lapse.
|
|
40
|
+
*
|
|
41
|
+
* Typed rather than a bare Error because the login command has to tell these
|
|
42
|
+
* two outcomes apart from everything else the polling loop can raise (a refused
|
|
43
|
+
* client id, a dead network). Those are faults and belong in the shared error
|
|
44
|
+
* rendering; these are answers, and get a plain line and a non-zero exit.
|
|
45
|
+
*/
|
|
46
|
+
export class DeviceApprovalError extends Error {
|
|
47
|
+
reason;
|
|
48
|
+
constructor(message, reason) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
this.name = "DeviceApprovalError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The stored session is gone — revoked, or expired server-side. Distinct from
|
|
56
|
+
* AuthServiceError so callers can offer the one fix that works (sign in again)
|
|
57
|
+
* instead of reporting a bare 401.
|
|
58
|
+
*/
|
|
59
|
+
export class SessionExpiredError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super('Session expired or revoked — run "2kw auth login" to sign in again.');
|
|
62
|
+
this.name = "SessionExpiredError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Wait `ms`, used when the caller injects no sleep of its own. */
|
|
66
|
+
function defaultSleep(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read a JWT's `exp` claim as epoch millis.
|
|
71
|
+
*
|
|
72
|
+
* Only the payload is decoded and nothing is verified — the CLI is not the
|
|
73
|
+
* audience for this token, it merely needs to know when to stop reusing it.
|
|
74
|
+
* Anything that is not a three-segment JWT with a numeric `exp` throws, because
|
|
75
|
+
* a silently-assumed expiry would either hand a dead token to the API or
|
|
76
|
+
* re-exchange on every single command.
|
|
77
|
+
*/
|
|
78
|
+
export function decodeJwtExpMs(jwt) {
|
|
79
|
+
const parts = jwt.split(".");
|
|
80
|
+
if (parts.length !== 3) {
|
|
81
|
+
throw new Error("Malformed token: expected three dot-separated JWT segments.");
|
|
82
|
+
}
|
|
83
|
+
let payload;
|
|
84
|
+
try {
|
|
85
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf-8"));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new Error("Malformed token: the JWT payload is not valid JSON.");
|
|
89
|
+
}
|
|
90
|
+
const exp = payload !== null && typeof payload === "object"
|
|
91
|
+
? payload.exp
|
|
92
|
+
: undefined;
|
|
93
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) {
|
|
94
|
+
throw new Error("Malformed token: the JWT payload carries no numeric exp claim.");
|
|
95
|
+
}
|
|
96
|
+
return exp * 1000;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a cached JWT has enough life left to be worth reusing — an unknown
|
|
100
|
+
* expiry counts as stale, so a cache entry written by an older CLI is simply
|
|
101
|
+
* re-exchanged rather than trusted.
|
|
102
|
+
*/
|
|
103
|
+
export function isJwtFresh(expMs, nowMs) {
|
|
104
|
+
return expMs !== undefined && expMs - nowMs > JWT_FRESHNESS_MARGIN_MS;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Poll until the user approves the login in their browser, and return the
|
|
108
|
+
* session token.
|
|
109
|
+
*
|
|
110
|
+
* Failure handling follows the auth-service taxonomy. A transport error is
|
|
111
|
+
* retried (up to {@link MAX_CONSECUTIVE_POLL_ERRORS} in a row, the count reset
|
|
112
|
+
* by any poll that completes) because a laptop that drops its Wi-Fi mid-login
|
|
113
|
+
* should not have to start over. An {@link AuthServiceError} is not retried —
|
|
114
|
+
* the service answered, and asking again gets the same answer.
|
|
115
|
+
*
|
|
116
|
+
* The deadline is spent from the poll budget rather than read off the wall
|
|
117
|
+
* clock, so an injected sleep makes the whole loop deterministic.
|
|
118
|
+
*
|
|
119
|
+
* @throws {DeviceApprovalError} when the user denies the login, or the code
|
|
120
|
+
* expires — either because the service said so or because the budget ran out.
|
|
121
|
+
* @throws {AuthServiceError} when the service refused the exchange outright.
|
|
122
|
+
* @throws {TypeError} the last transport error, once retrying is pointless.
|
|
123
|
+
*/
|
|
124
|
+
export async function runDevicePolling(opts) {
|
|
125
|
+
const { poll, sleep = defaultSleep } = opts;
|
|
126
|
+
// A zero or negative interval would spin the loop without ever spending the
|
|
127
|
+
// budget, so treat anything non-positive as "not stated".
|
|
128
|
+
const intervalSec = opts.intervalSec !== undefined && opts.intervalSec > 0
|
|
129
|
+
? opts.intervalSec
|
|
130
|
+
: DEFAULT_POLL_INTERVAL_SEC;
|
|
131
|
+
let intervalMs = intervalSec * 1000;
|
|
132
|
+
let remainingMs = (opts.expiresInSec ?? DEFAULT_POLL_EXPIRES_SEC) * 1000;
|
|
133
|
+
let consecutiveErrors = 0;
|
|
134
|
+
for (;;) {
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = await poll();
|
|
138
|
+
consecutiveErrors = 0;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
// Exit #2: the service answered. Retrying just replays the refusal.
|
|
142
|
+
if (err instanceof AuthServiceError)
|
|
143
|
+
throw err;
|
|
144
|
+
// Exit #3: the request never completed. Retry-worthy, within reason.
|
|
145
|
+
consecutiveErrors += 1;
|
|
146
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS)
|
|
147
|
+
throw err;
|
|
148
|
+
}
|
|
149
|
+
switch (result?.status) {
|
|
150
|
+
case "ok":
|
|
151
|
+
return result.accessToken;
|
|
152
|
+
case "denied":
|
|
153
|
+
throw new DeviceApprovalError(DENIED_MESSAGE, "denied");
|
|
154
|
+
case "expired":
|
|
155
|
+
throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
|
|
156
|
+
case "slow_down":
|
|
157
|
+
intervalMs += SLOW_DOWN_BACKOFF_MS;
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
// "pending", or a tolerated transport error: wait and ask again.
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
remainingMs -= intervalMs;
|
|
164
|
+
if (remainingMs <= 0)
|
|
165
|
+
throw new DeviceApprovalError(EXPIRED_MESSAGE, "expired");
|
|
166
|
+
await sleep(intervalMs);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Attempts allowed at the prompt before the loop is called a lost cause. */
|
|
170
|
+
const MAX_ORG_PROMPT_ATTEMPTS = 5;
|
|
171
|
+
const NO_TTY_MESSAGE = "Cannot prompt for an organization: no interactive terminal. " +
|
|
172
|
+
"Pass an org explicitly or run in a terminal.";
|
|
173
|
+
const TOO_MANY_ATTEMPTS_MESSAGE = `No organization chosen after ${MAX_ORG_PROMPT_ATTEMPTS} attempts.`;
|
|
174
|
+
/**
|
|
175
|
+
* Decide which organization the new session should act as.
|
|
176
|
+
*
|
|
177
|
+
* A single membership needs no ceremony, so it is chosen silently; several
|
|
178
|
+
* memberships get a numbered prompt that repeats until the answer names one of
|
|
179
|
+
* them. Lives here rather than in the login command because org switching
|
|
180
|
+
* reuses the identical choice.
|
|
181
|
+
*
|
|
182
|
+
* Two guards keep the prompt from becoming a hang. Without a terminal there is
|
|
183
|
+
* nobody to answer, and readline on a closed or piped stdin resolves nothing
|
|
184
|
+
* and never ends — so that case is refused up front, with the fix in the
|
|
185
|
+
* message. The attempt cap covers what the first guard cannot see: an input
|
|
186
|
+
* that does answer, always invalidly (a pipe that yields "" forever).
|
|
187
|
+
*
|
|
188
|
+
* @throws {Error} when the account has no organizations at all, when there is
|
|
189
|
+
* no terminal to prompt, or when the attempts run out.
|
|
190
|
+
*/
|
|
191
|
+
export async function selectOrganization(orgs, deps = {}) {
|
|
192
|
+
const { ask, isTTY = Boolean(process.stdin.isTTY) } = deps;
|
|
193
|
+
if (orgs.length === 0)
|
|
194
|
+
throw new Error(NO_ORG_MESSAGE);
|
|
195
|
+
if (orgs.length === 1)
|
|
196
|
+
return orgs[0];
|
|
197
|
+
// Checked before anything is printed: an injected asker owns its own input,
|
|
198
|
+
// but the default one is about to open readline on a stdin nobody will type
|
|
199
|
+
// into.
|
|
200
|
+
if (!ask && !isTTY)
|
|
201
|
+
throw new Error(NO_TTY_MESSAGE);
|
|
202
|
+
console.log("");
|
|
203
|
+
console.log("Select an organization:");
|
|
204
|
+
orgs.forEach((org, i) => {
|
|
205
|
+
console.log(` ${i + 1}) ${org.name}`);
|
|
206
|
+
});
|
|
207
|
+
// Only open a real readline when we have to: an injected asker means the
|
|
208
|
+
// caller owns the input, and an unused interface would hold stdin open.
|
|
209
|
+
const rl = ask ? undefined : createInterface({ input: process.stdin, output: process.stdout });
|
|
210
|
+
const askFn = ask ?? ((question) => rl.question(question));
|
|
211
|
+
try {
|
|
212
|
+
for (let attempt = 0; attempt < MAX_ORG_PROMPT_ATTEMPTS; attempt++) {
|
|
213
|
+
const answer = (await askFn(`Organization [1-${orgs.length}]: `)).trim();
|
|
214
|
+
// Digits only — parseInt would happily read "2 or so" as 2.
|
|
215
|
+
if (/^\d+$/.test(answer)) {
|
|
216
|
+
const choice = Number(answer);
|
|
217
|
+
if (choice >= 1 && choice <= orgs.length)
|
|
218
|
+
return orgs[choice - 1];
|
|
219
|
+
}
|
|
220
|
+
console.log(chalk.yellow(`Enter a number between 1 and ${orgs.length}.`));
|
|
221
|
+
}
|
|
222
|
+
throw new Error(TOO_MANY_ATTEMPTS_MESSAGE);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
rl?.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Return an organization JWT for a session context, exchanging the session for
|
|
230
|
+
* a new one whenever the cached token is missing, stale, or explicitly bypassed.
|
|
231
|
+
*
|
|
232
|
+
* A fresh token is written both to the config store and back onto `cfg`: a
|
|
233
|
+
* single command may reach here more than once (a retry after a 401, say), and
|
|
234
|
+
* the in-memory copy is what those later reads see. Caching is best-effort — a
|
|
235
|
+
* token that cannot be cached is still a token that works.
|
|
236
|
+
*
|
|
237
|
+
* @throws {SessionExpiredError} when the stored session is no longer valid.
|
|
238
|
+
*/
|
|
239
|
+
export async function ensureJwt(cfg, deps = {}) {
|
|
240
|
+
const { fetchJwtFn = fetchJwt, persist = updateContext, now = Date.now, force = false } = deps;
|
|
241
|
+
if (!force && cfg.cachedJwt && isJwtFresh(cfg.cachedJwtExp, now())) {
|
|
242
|
+
return cfg.cachedJwt;
|
|
243
|
+
}
|
|
244
|
+
let jwt;
|
|
245
|
+
try {
|
|
246
|
+
jwt = await fetchJwtFn(cfg.authUrl, cfg.sessionToken);
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
// Only a 401 means "this session is done". Every other failure — a 502, a
|
|
250
|
+
// dead network — says nothing about the session, so it must not tell the
|
|
251
|
+
// user to sign in again.
|
|
252
|
+
if (err instanceof AuthServiceError && err.status === 401) {
|
|
253
|
+
// The stale cache is dropped where this lands rather than here:
|
|
254
|
+
// handleError in errors.ts clears cachedJwt/cachedJwtExp for the active
|
|
255
|
+
// context, so every command path gets it, including the raw-fetch ones.
|
|
256
|
+
throw new SessionExpiredError();
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const expMs = decodeJwtExpMs(jwt);
|
|
262
|
+
cfg.cachedJwtExp = expMs;
|
|
263
|
+
cfg.cachedJwt = jwt;
|
|
264
|
+
persist(cfg.contextName, { cachedJwt: jwt, cachedJwtExp: expMs });
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// An unparseable token or a read-only config store must not fail a command
|
|
268
|
+
// that holds a working credential — skip the cache and use it this once.
|
|
269
|
+
// Cleared as a pair so a half-written entry can never be read back as fresh.
|
|
270
|
+
cfg.cachedJwt = undefined;
|
|
271
|
+
cfg.cachedJwtExp = undefined;
|
|
272
|
+
}
|
|
273
|
+
return jwt;
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=auth-session.js.map
|