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