@loomcycle/client 1.125.0 → 1.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,400 @@
1
+ "use strict";
2
+ // App sign-in (RFC FL) — the client side of loomcycle's OAuth 2.0
3
+ // authorization server for its own apps.
4
+ //
5
+ // The flow is the authorization code grant with PKCE S256 (RFC 6749, RFC 7636,
6
+ // RFC 8252 for native apps): the app sends the person to loomcycle's login
7
+ // page, they sign in through whatever gates the operator configured, loomcycle
8
+ // redirects back with a single-use code, and the app exchanges the code (with
9
+ // the PKCE verifier only it holds) for a short-lived access token and a
10
+ // rotating refresh token. The app never sees a password or the gate config.
11
+ //
12
+ // Everything here runs on Web Crypto + fetch, so the same code serves a
13
+ // browser SPA, a Chrome extension and a Tauri or Node app. Node 18 has no
14
+ // global `crypto`: pass `crypto: require("node:crypto").webcrypto`.
15
+ //
16
+ // What this module never does: put a token or the verifier in a URL, send a
17
+ // bearer or a cookie to the token endpoint, or log anything.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.OAuthTokenProvider = exports.LoomcycleOAuth = void 0;
20
+ exports.parseAuthorizeResponse = parseAuthorizeResponse;
21
+ exports.pkceChallenge = pkceChallenge;
22
+ const errors_js_1 = require("./errors.js");
23
+ /** The server's bound on `state` (oauthMaxStateLen). */
24
+ const MAX_STATE_LEN = 1024;
25
+ /**
26
+ * The app sign-in helper. Construct one per loomcycle server.
27
+ *
28
+ * ```ts
29
+ * const oauth = new LoomcycleOAuth({ baseUrl: "https://loom.example.com" });
30
+ * // 1. Start: keep verifier + state in session storage, then navigate.
31
+ * const req = await oauth.beginAuthorize({ clientId: "loomboard", redirectUri });
32
+ * sessionStorage.setItem("lc_pkce", JSON.stringify({ verifier: req.verifier, state: req.state }));
33
+ * location.assign(req.url);
34
+ * // 2. Back on redirectUri:
35
+ * const saved = JSON.parse(sessionStorage.getItem("lc_pkce")!);
36
+ * const tokens = await oauth.completeAuthorize({
37
+ * ...parseAuthorizeResponse(location.href),
38
+ * expectedState: saved.state, verifier: saved.verifier, clientId: "loomboard", redirectUri,
39
+ * });
40
+ * ```
41
+ */
42
+ class LoomcycleOAuth {
43
+ baseUrl;
44
+ fetchImpl;
45
+ cryptoImpl;
46
+ issuer;
47
+ metadata;
48
+ constructor(opts = {}) {
49
+ this.baseUrl = trimTrailingSlashes(opts.baseUrl ?? "http://127.0.0.1:8787");
50
+ // Called through a closure, never as this.fetchImpl(...): a browser's
51
+ // fetch called with any other receiver than the window throws.
52
+ const f = opts.fetch;
53
+ this.fetchImpl = f ? (input, init) => f(input, init) : (input, init) => fetch(input, init);
54
+ this.cryptoImpl = opts.crypto;
55
+ this.issuer = opts.issuer === undefined ? undefined : trimTrailingSlashes(opts.issuer);
56
+ }
57
+ /** Start a sign-in: a fresh PKCE verifier, its S256 challenge, a state,
58
+ * and the authorize URL to send the person to. Nothing is sent to the
59
+ * server. */
60
+ async beginAuthorize(opts) {
61
+ if (!opts.clientId)
62
+ throw new TypeError("beginAuthorize: clientId is required");
63
+ if (!opts.redirectUri)
64
+ throw new TypeError("beginAuthorize: redirectUri is required");
65
+ const state = opts.state ?? randomToken(this.webCrypto());
66
+ if (state.length < 1 || state.length > MAX_STATE_LEN) {
67
+ throw new TypeError(`beginAuthorize: state must be 1 to ${MAX_STATE_LEN} characters`);
68
+ }
69
+ const verifier = randomToken(this.webCrypto());
70
+ const challenge = await pkceChallenge(verifier, this.webCrypto());
71
+ const q = new URLSearchParams({
72
+ response_type: "code",
73
+ client_id: opts.clientId,
74
+ redirect_uri: opts.redirectUri,
75
+ code_challenge: challenge,
76
+ code_challenge_method: "S256",
77
+ state,
78
+ });
79
+ return { url: `${this.baseUrl}/v1/_oauth/authorize?${q.toString()}`, verifier, state };
80
+ }
81
+ /** Finish a sign-in from the redirect's parameters (see
82
+ * {@link parseAuthorizeResponse}). In order: the `state` must be the one
83
+ * this sign-in sent, an `iss` must name this server (RFC 9207), an
84
+ * `error` is thrown as an {@link OAuthError}, and only then is the code
85
+ * exchanged. */
86
+ async completeAuthorize(opts) {
87
+ if (!opts.expectedState || opts.state === undefined || !constantTimeEqual(opts.state, opts.expectedState)) {
88
+ throw new errors_js_1.OAuthError("state_mismatch", {
89
+ errorDescription: "the redirect's state is not the one this sign-in sent",
90
+ });
91
+ }
92
+ if (opts.iss !== undefined) {
93
+ const issuer = this.issuer ?? (await this.discover({ signal: opts.signal })).issuer;
94
+ if (opts.iss !== issuer) {
95
+ throw new errors_js_1.OAuthError("issuer_mismatch", {
96
+ errorDescription: "the redirect names another authorization server",
97
+ });
98
+ }
99
+ }
100
+ if (opts.error) {
101
+ throw new errors_js_1.OAuthError(opts.error, { errorDescription: opts.error_description });
102
+ }
103
+ if (!opts.code) {
104
+ throw new errors_js_1.OAuthError("invalid_response", { errorDescription: "the redirect carries no code" });
105
+ }
106
+ return this.tokenRequest({
107
+ grant_type: "authorization_code",
108
+ code: opts.code,
109
+ redirect_uri: opts.redirectUri,
110
+ code_verifier: opts.verifier,
111
+ client_id: opts.clientId,
112
+ }, opts.signal);
113
+ }
114
+ /** Trade a refresh token for a new token set. The refresh token is spent:
115
+ * persist the returned one before anything else uses the old. A spent
116
+ * token presented again ends the whole sign-in. */
117
+ async refresh(opts) {
118
+ return this.tokenRequest({ grant_type: "refresh_token", refresh_token: opts.refreshToken, client_id: opts.clientId }, opts.signal);
119
+ }
120
+ /** Sign out (RFC 7009): a refresh token ends the whole sign-in, an access
121
+ * token ends that token. The server answers the same for a token it does
122
+ * not know, so this resolves unless the request itself failed. */
123
+ async revoke(opts) {
124
+ const resp = await this.postForm("/v1/_oauth/revoke", { token: opts.token, client_id: opts.clientId }, opts.signal);
125
+ if (!resp.ok)
126
+ throw await oauthErrorFrom(resp);
127
+ await resp.body?.cancel().catch(() => undefined);
128
+ }
129
+ /** The server's authorization server metadata (RFC 8414). Cached after the
130
+ * first success. Fails with a 404 when the server does not know its public
131
+ * address (`LOOMCYCLE_PUBLIC_URL` unset). */
132
+ async discover(opts) {
133
+ if (!this.metadata) {
134
+ const p = this.fetchMetadata(opts?.signal);
135
+ this.metadata = p;
136
+ p.catch(() => {
137
+ if (this.metadata === p)
138
+ this.metadata = undefined;
139
+ });
140
+ }
141
+ return this.metadata;
142
+ }
143
+ async fetchMetadata(signal) {
144
+ const resp = await this.fetchImpl(`${this.baseUrl}/.well-known/oauth-authorization-server`, {
145
+ method: "GET",
146
+ headers: { Accept: "application/json" },
147
+ credentials: "omit",
148
+ signal,
149
+ });
150
+ if (!resp.ok)
151
+ throw await oauthErrorFrom(resp);
152
+ const md = (await resp.json().catch(() => undefined));
153
+ if (!md || typeof md.issuer !== "string" || !md.issuer) {
154
+ throw new errors_js_1.OAuthError("invalid_response", { status: resp.status, errorDescription: "metadata without an issuer" });
155
+ }
156
+ return md;
157
+ }
158
+ async tokenRequest(params, signal) {
159
+ const resp = await this.postForm("/v1/_oauth/token", params, signal);
160
+ if (!resp.ok)
161
+ throw await oauthErrorFrom(resp);
162
+ // The body holds tokens: on a malformed one, say so without echoing it.
163
+ const t = (await resp.json().catch(() => undefined));
164
+ if (!t ||
165
+ typeof t.access_token !== "string" || !t.access_token ||
166
+ typeof t.refresh_token !== "string" || !t.refresh_token ||
167
+ typeof t.expires_in !== "number" || !Number.isFinite(t.expires_in) ||
168
+ typeof t.token_type !== "string" || t.token_type.toLowerCase() !== "bearer") {
169
+ throw new errors_js_1.OAuthError("invalid_response", { status: resp.status, errorDescription: "not a bearer token response" });
170
+ }
171
+ return {
172
+ access_token: t.access_token,
173
+ token_type: t.token_type,
174
+ expires_in: t.expires_in,
175
+ refresh_token: t.refresh_token,
176
+ expires_at: Date.now() + t.expires_in * 1000,
177
+ };
178
+ }
179
+ /** POST a form: parameters in the body, never the URL; no bearer and no
180
+ * cookie (the code or the refresh token is the credential); only
181
+ * CORS-safelisted headers, so a browser sends it without a preflight. */
182
+ postForm(path, params, signal) {
183
+ return this.fetchImpl(this.baseUrl + path, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
186
+ body: new URLSearchParams(params).toString(),
187
+ credentials: "omit",
188
+ signal,
189
+ });
190
+ }
191
+ webCrypto() {
192
+ const c = this.cryptoImpl ?? globalThis.crypto;
193
+ if (!c?.subtle || typeof c.getRandomValues !== "function") {
194
+ throw new Error("loomcycle sign-in: no Web Crypto implementation; pass opts.crypto (on Node 18: require('node:crypto').webcrypto)");
195
+ }
196
+ return c;
197
+ }
198
+ }
199
+ exports.LoomcycleOAuth = LoomcycleOAuth;
200
+ /** Read the authorization response parameters from the redirect URL (its
201
+ * query). Pass the result to {@link LoomcycleOAuth.completeAuthorize}. */
202
+ function parseAuthorizeResponse(url) {
203
+ const q = (typeof url === "string" ? new URL(url) : url).searchParams;
204
+ const out = {};
205
+ for (const k of ["code", "state", "iss", "error", "error_description"]) {
206
+ const v = q.get(k);
207
+ if (v !== null)
208
+ out[k] = v;
209
+ }
210
+ return out;
211
+ }
212
+ /** The PKCE S256 code challenge of a verifier (RFC 7636 §4.2):
213
+ * BASE64URL(SHA-256(ASCII(verifier))). */
214
+ async function pkceChallenge(verifier, cryptoImpl) {
215
+ const c = cryptoImpl ?? globalThis.crypto;
216
+ if (!c?.subtle) {
217
+ throw new Error("pkceChallenge: no Web Crypto implementation; pass cryptoImpl");
218
+ }
219
+ const digest = await c.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
220
+ return base64url(new Uint8Array(digest));
221
+ }
222
+ /**
223
+ * A {@link TokenProvider} over an app sign-in: hands out the access token,
224
+ * refreshes it before it expires and once after a 401, and shares one
225
+ * refresh between concurrent requests (two refreshes of the same refresh
226
+ * token would end the sign-in, since the server takes the second as a
227
+ * replay).
228
+ *
229
+ * One provider per sign-in: two providers — or two browser tabs — refreshing
230
+ * the same refresh token end it the same way.
231
+ */
232
+ class OAuthTokenProvider {
233
+ opts;
234
+ current;
235
+ inflight;
236
+ ended;
237
+ skew;
238
+ constructor(opts) {
239
+ this.opts = opts;
240
+ this.current = opts.tokens;
241
+ this.skew = opts.refreshSkewMs ?? 60_000;
242
+ }
243
+ /** The current token set; undefined once signed out. */
244
+ get tokens() {
245
+ return this.current;
246
+ }
247
+ async getToken() {
248
+ const cur = this.current;
249
+ if (!cur)
250
+ throw this.ended ?? new errors_js_1.OAuthError("signed_out");
251
+ if (Date.now() < cur.expires_at - this.skew)
252
+ return cur.access_token;
253
+ try {
254
+ return (await this.refreshOnce()).access_token;
255
+ }
256
+ catch (err) {
257
+ // A refresh ahead of expiry that failed for a transient reason leaves
258
+ // a token that still works; past expiry there is none to send.
259
+ if (this.current === cur && Date.now() < cur.expires_at && !isSignInOver(err))
260
+ return cur.access_token;
261
+ throw err;
262
+ }
263
+ }
264
+ async onUnauthorized(rejected) {
265
+ const cur = this.current;
266
+ if (!cur)
267
+ return undefined;
268
+ // Another request already replaced the token this one was refused with.
269
+ if (rejected !== cur.access_token)
270
+ return cur.access_token;
271
+ try {
272
+ return (await this.refreshOnce()).access_token;
273
+ }
274
+ catch {
275
+ return undefined;
276
+ }
277
+ }
278
+ /** Refresh now, sharing a refresh already under way. */
279
+ refresh() {
280
+ return this.refreshOnce();
281
+ }
282
+ /** End the sign-in: revoke the refresh token on the server (which also
283
+ * ends the access tokens it issued) and forget the tokens. Resolves even
284
+ * if the server could not be reached; the tokens are forgotten either
285
+ * way. */
286
+ async signOut(opts) {
287
+ const cur = this.current;
288
+ this.end(new errors_js_1.OAuthError("signed_out"));
289
+ if (!cur)
290
+ return;
291
+ try {
292
+ await this.opts.oauth.revoke({ token: cur.refresh_token, clientId: this.opts.clientId, signal: opts?.signal });
293
+ }
294
+ catch {
295
+ // Forgotten locally; the refresh token lapses on its own.
296
+ }
297
+ }
298
+ refreshOnce() {
299
+ if (!this.inflight) {
300
+ const p = this.doRefresh();
301
+ this.inflight = p;
302
+ const clear = () => {
303
+ if (this.inflight === p)
304
+ this.inflight = undefined;
305
+ };
306
+ p.then(clear, clear);
307
+ }
308
+ return this.inflight;
309
+ }
310
+ async doRefresh() {
311
+ const cur = this.current;
312
+ if (!cur)
313
+ throw this.ended ?? new errors_js_1.OAuthError("signed_out");
314
+ let next;
315
+ try {
316
+ next = await this.opts.oauth.refresh({ refreshToken: cur.refresh_token, clientId: this.opts.clientId });
317
+ }
318
+ catch (err) {
319
+ if (isSignInOver(err) && this.current === cur)
320
+ this.end(err);
321
+ throw err;
322
+ }
323
+ if (this.current !== cur) {
324
+ // Signed out while the refresh was in flight: do not resurrect it.
325
+ throw this.ended ?? new errors_js_1.OAuthError("signed_out");
326
+ }
327
+ this.current = next;
328
+ await this.opts.onTokens?.(next);
329
+ return next;
330
+ }
331
+ end(reason) {
332
+ const was = this.current;
333
+ this.current = undefined;
334
+ this.ended = reason;
335
+ if (was)
336
+ this.opts.onSignedOut?.(reason);
337
+ }
338
+ }
339
+ exports.OAuthTokenProvider = OAuthTokenProvider;
340
+ /** A refresh refused for good: the token is spent, revoked, expired, or its
341
+ * client is gone. */
342
+ function isSignInOver(err) {
343
+ return err instanceof errors_js_1.OAuthError && (err.code === "invalid_grant" || err.code === "invalid_client");
344
+ }
345
+ /** An OAuthError from a non-2xx answer: an RFC 6749 §5.2 body
346
+ * (`error`, `error_description`) or loomcycle's JSON error (`code`). */
347
+ async function oauthErrorFrom(resp) {
348
+ const text = await resp.text().catch(() => "");
349
+ let code = "";
350
+ let desc;
351
+ try {
352
+ const b = JSON.parse(text);
353
+ if (typeof b.error === "string" && /^[a-z_]+$/.test(b.error)) {
354
+ code = b.error;
355
+ if (typeof b.error_description === "string" && b.error_description)
356
+ desc = b.error_description;
357
+ }
358
+ else if (typeof b.code === "string" && b.code) {
359
+ code = b.code;
360
+ if (typeof b.error === "string" && b.error)
361
+ desc = b.error;
362
+ }
363
+ }
364
+ catch {
365
+ // not JSON
366
+ }
367
+ if (!code)
368
+ code = resp.status === 429 ? "temporarily_unavailable" : "server_error";
369
+ return new errors_js_1.OAuthError(code, { status: resp.status, bodyText: text.slice(0, 1024), errorDescription: desc });
370
+ }
371
+ /** 32 random bytes, base64url: a 43-character PKCE verifier (RFC 7636 §4.1)
372
+ * or state. */
373
+ function randomToken(c) {
374
+ const b = new Uint8Array(32);
375
+ c.getRandomValues(b);
376
+ return base64url(b);
377
+ }
378
+ function base64url(bytes) {
379
+ let s = "";
380
+ for (const b of bytes)
381
+ s += String.fromCharCode(b);
382
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
383
+ }
384
+ /** Compares two strings in time that depends only on their lengths. */
385
+ function constantTimeEqual(a, b) {
386
+ if (a.length !== b.length)
387
+ return false;
388
+ let diff = 0;
389
+ for (let i = 0; i < a.length; i++)
390
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
391
+ return diff === 0;
392
+ }
393
+ /** trimTrailingSlashes drops every trailing "/" in one linear pass (a
394
+ * `/\/+$/` replace backtracks on a long run of slashes). */
395
+ function trimTrailingSlashes(url) {
396
+ let end = url.length;
397
+ while (end > 0 && url[end - 1] === "/")
398
+ end--;
399
+ return url.slice(0, end);
400
+ }
@@ -55,16 +55,20 @@ export interface ConnectClientToolsOptions {
55
55
  */
56
56
  export declare class ClientToolHost {
57
57
  private readonly url;
58
- private readonly authToken;
58
+ private readonly auth;
59
59
  private readonly opts;
60
60
  private ws?;
61
61
  private closed;
62
- constructor(url: string, authToken: string | undefined, opts: ConnectClientToolsOptions);
62
+ /** `auth` is the bearer, or a function that supplies one on each
63
+ * (re)connect — a client built with a token provider passes one, so a
64
+ * reconnect after the access token expired carries the refreshed one. */
65
+ constructor(url: string, auth: string | undefined | (() => Promise<string | undefined>), opts: ConnectClientToolsOptions);
63
66
  /** Open the connection (called for you by connectClientTools). */
64
67
  start(): void;
65
68
  /** Stop the host + close the socket; suppresses reconnect. */
66
69
  close(): void;
67
70
  private connect;
71
+ private open;
68
72
  private onMessage;
69
73
  private sendJSON;
70
74
  private scheduleReconnect;
@@ -10,6 +10,7 @@
10
10
  // 22+); on older Node pass `WebSocketImpl` (e.g. the `ws` package). The bearer
11
11
  // rides the `Sec-WebSocket-Protocol` subprotocol (browsers can't set an
12
12
  // Authorization header on a WebSocket).
13
+ import { OAuthError } from "./errors.js";
13
14
  /** The app-level subprotocol the server negotiates for /v1/client-tools. */
14
15
  export const CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
15
16
  /**
@@ -20,13 +21,16 @@ export const CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
20
21
  */
21
22
  export class ClientToolHost {
22
23
  url;
23
- authToken;
24
+ auth;
24
25
  opts;
25
26
  ws;
26
27
  closed = false;
27
- constructor(url, authToken, opts) {
28
+ /** `auth` is the bearer, or a function that supplies one on each
29
+ * (re)connect — a client built with a token provider passes one, so a
30
+ * reconnect after the access token expired carries the refreshed one. */
31
+ constructor(url, auth, opts) {
28
32
  this.url = url;
29
- this.authToken = authToken;
33
+ this.auth = auth;
30
34
  this.opts = opts;
31
35
  }
32
36
  /** Open the connection (called for you by connectClientTools). */
@@ -45,9 +49,28 @@ export class ClientToolHost {
45
49
  if (!WS) {
46
50
  throw new Error("connectClientTools: no WebSocket implementation available — pass WebSocketImpl (e.g. the `ws` package) on Node < 22");
47
51
  }
52
+ if (typeof this.auth === "function") {
53
+ this.auth().then((token) => this.open(WS, token), (err) => {
54
+ this.opts.onError?.(err);
55
+ // A sign-in that ended does not come back by retrying: stop
56
+ // rather than ask again every few seconds.
57
+ if (err instanceof OAuthError && !err.retryable) {
58
+ this.closed = true;
59
+ this.opts.onStatus?.("closed");
60
+ return;
61
+ }
62
+ this.scheduleReconnect();
63
+ });
64
+ return;
65
+ }
66
+ this.open(WS, this.auth);
67
+ }
68
+ open(WS, token) {
69
+ if (this.closed)
70
+ return;
48
71
  const protocols = [CLIENT_TOOL_SUBPROTOCOL];
49
- if (this.authToken)
50
- protocols.push("bearer." + this.authToken);
72
+ if (token)
73
+ protocols.push("bearer." + token);
51
74
  this.opts.onStatus?.("connecting");
52
75
  const ws = new WS(this.url, protocols);
53
76
  this.ws = ws;