@marktiderman/genesis-connect 0.0.0-stage → 0.1.1

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/index.cjs ADDED
@@ -0,0 +1,847 @@
1
+ 'use strict';
2
+
3
+ var chunkEUEVGKB6_cjs = require('./chunk-EUEVGKB6.cjs');
4
+
5
+ // src/providers.ts
6
+ var googleProvider = {
7
+ id: "google",
8
+ authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
9
+ tokenUrl: "https://oauth2.googleapis.com/token",
10
+ revokeUrl: "https://oauth2.googleapis.com/revoke",
11
+ defaultScopes: [],
12
+ authorizeParams: {
13
+ access_type: "offline",
14
+ // Without consent on every sign-in, Google sends a refresh token only the first time.
15
+ prompt: "consent",
16
+ // Only the scopes asked for now, so the granted-scope check means what it says.
17
+ include_granted_scopes: "false"
18
+ },
19
+ tokenAuth: "client_secret_post",
20
+ scopeAliases: {
21
+ email: "https://www.googleapis.com/auth/userinfo.email",
22
+ profile: "https://www.googleapis.com/auth/userinfo.profile",
23
+ openid: "openid"
24
+ }
25
+ };
26
+ var GOOGLE_SCOPES = {
27
+ /** OpenID Connect: an ID token naming the account. */
28
+ openid: "openid",
29
+ /** The account's email address (Google grants it as `…/auth/userinfo.email`). */
30
+ email: "email",
31
+ /** The account's name and picture (granted as `…/auth/userinfo.profile`). */
32
+ profile: "profile",
33
+ /** Files the person picked or created through the app, and nothing else. */
34
+ driveFile: "https://www.googleapis.com/auth/drive.file",
35
+ /** Every spreadsheet the person can reach. Prefer `driveFile`. */
36
+ spreadsheets: "https://www.googleapis.com/auth/spreadsheets",
37
+ /** Events on the person's calendars. */
38
+ calendarEvents: "https://www.googleapis.com/auth/calendar.events",
39
+ /** Send mail as the person. */
40
+ gmailSend: "https://www.googleapis.com/auth/gmail.send"
41
+ };
42
+
43
+ // src/oauth.ts
44
+ var CONNECT_RESULTS = [
45
+ "connected",
46
+ "expired",
47
+ "browser",
48
+ "denied",
49
+ "scope",
50
+ "failed"
51
+ ];
52
+ var ConnectError = class extends Error {
53
+ name = "ConnectError";
54
+ code;
55
+ retryable;
56
+ status;
57
+ retryAfterSeconds;
58
+ /**
59
+ * Set when the provider rotated the refresh token but it could not be
60
+ * handed over (no `onRotate`, or `onRotate` threw): the new one, sealed to
61
+ * the same row. Store it in place of the old one, or the grant is lost.
62
+ */
63
+ sealedRefreshToken;
64
+ constructor(code, message, options = {}) {
65
+ super(message);
66
+ this.code = code;
67
+ this.retryable = code === "unavailable";
68
+ this.status = options.status;
69
+ this.retryAfterSeconds = options.retryAfterSeconds;
70
+ this.sealedRefreshToken = options.sealedRefreshToken;
71
+ }
72
+ };
73
+ var CONNECT_ERROR_CODES = [
74
+ "config",
75
+ "usage",
76
+ "revoked",
77
+ "unavailable",
78
+ "failed"
79
+ ];
80
+ function isConnectError(error) {
81
+ return error instanceof Error && error.name === "ConnectError" && CONNECT_ERROR_CODES.includes(error.code);
82
+ }
83
+ var PUBLIC_SUFFIXES = /* @__PURE__ */ new Set([
84
+ "vercel.app",
85
+ "netlify.app",
86
+ "pages.dev",
87
+ "workers.dev",
88
+ "github.io",
89
+ "herokuapp.com",
90
+ "onrender.com",
91
+ "fly.dev",
92
+ "web.app",
93
+ "firebaseapp.com",
94
+ "azurewebsites.net",
95
+ "appspot.com",
96
+ "ngrok-free.app",
97
+ "ngrok.io",
98
+ "cloudfront.net",
99
+ "up.railway.app",
100
+ "deno.dev",
101
+ "co.uk",
102
+ "com.au",
103
+ "co.jp",
104
+ "com.br",
105
+ "co.in"
106
+ ]);
107
+ var COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
108
+ var SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
109
+ var HOSTNAME = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
110
+ var RESERVED_PARAMS = /* @__PURE__ */ new Set([
111
+ "client_id",
112
+ "redirect_uri",
113
+ "response_type",
114
+ "scope",
115
+ "state",
116
+ "code_challenge",
117
+ "code_challenge_method"
118
+ ]);
119
+ var isLocal = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
120
+ function checkOrigin(value, what) {
121
+ let url;
122
+ try {
123
+ url = new URL(value);
124
+ } catch {
125
+ throw new ConnectError("config", `${what} is not a URL`);
126
+ }
127
+ const origin = value.replace(/\/+$/, "");
128
+ if (url.origin !== origin)
129
+ throw new ConnectError(
130
+ "config",
131
+ `${what} must be an origin only (scheme, host and port)`
132
+ );
133
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocal(url.hostname))) {
134
+ throw new ConnectError(
135
+ "config",
136
+ `${what} must be https (http only on localhost)`
137
+ );
138
+ }
139
+ return origin;
140
+ }
141
+ function checkPath(value, what) {
142
+ if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//") || /[\\\s\x00-\x1f\x7f]/.test(value) || value.length > 512) {
143
+ throw new ConnectError(
144
+ "usage",
145
+ `${what} must be a path on this app, starting with one /`
146
+ );
147
+ }
148
+ const url = new URL(value, "https://origin.invalid");
149
+ if (url.origin !== "https://origin.invalid")
150
+ throw new ConnectError(
151
+ "usage",
152
+ `${what} must be a path on this app, starting with one /`
153
+ );
154
+ return `${url.pathname}${url.search}${url.hash}`;
155
+ }
156
+ var onDomain = (hostname, domain) => hostname === domain || hostname.endsWith(`.${domain}`);
157
+ function isSafeReturnOrigin(origin, domain) {
158
+ try {
159
+ const url = new URL(origin);
160
+ return url.protocol === "https:" && url.port === "" && url.origin === origin && onDomain(url.hostname, domain);
161
+ } catch {
162
+ return false;
163
+ }
164
+ }
165
+ function scopeList(scopes, separator) {
166
+ const list = [...new Set(scopes ?? [])];
167
+ for (const scope of list) {
168
+ if (typeof scope !== "string" || !SCOPE_TOKEN.test(scope) || scope.includes(separator))
169
+ throw new ConnectError(
170
+ "usage",
171
+ "A scope is malformed (no spaces, quotes or the provider's separator)"
172
+ );
173
+ }
174
+ return list;
175
+ }
176
+ var DEFAULT_TIMEOUT_MS = 1e4;
177
+ async function timedFetch(deps, url, init, signal) {
178
+ const timeout = AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS);
179
+ const combined = signal ? anySignal([timeout, signal]) : timeout;
180
+ try {
181
+ return await (deps.fetch ?? fetch)(url, {
182
+ ...init,
183
+ redirect: "error",
184
+ signal: combined
185
+ });
186
+ } catch (error) {
187
+ const name = error instanceof Error ? error.name : "";
188
+ if (name === "TimeoutError")
189
+ throw new ConnectError(
190
+ "unavailable",
191
+ "The provider did not answer in time"
192
+ );
193
+ if (name === "AbortError")
194
+ throw new ConnectError("unavailable", "The request was aborted");
195
+ throw new ConnectError("unavailable", "The provider could not be reached");
196
+ }
197
+ }
198
+ function anySignal(signals) {
199
+ const any = AbortSignal.any;
200
+ if (any) return any(signals);
201
+ const controller = new AbortController();
202
+ for (const signal of signals) {
203
+ if (signal.aborted) {
204
+ controller.abort(signal.reason);
205
+ break;
206
+ }
207
+ signal.addEventListener("abort", () => controller.abort(signal.reason), {
208
+ once: true
209
+ });
210
+ }
211
+ return controller.signal;
212
+ }
213
+ function discard(response) {
214
+ try {
215
+ void response.body?.cancel().catch(() => {
216
+ });
217
+ } catch {
218
+ }
219
+ }
220
+ async function jsonBody(response) {
221
+ const value = await response.json().catch(() => null);
222
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
223
+ }
224
+ function errorCode(body) {
225
+ const code = body["error"];
226
+ return typeof code === "string" && /^[a-z_]{1,64}$/.test(code) ? code : void 0;
227
+ }
228
+ function retryAfter(response) {
229
+ const header = response.headers.get("retry-after")?.trim();
230
+ return header && /^\d+$/.test(header) ? Math.min(86400, Math.max(1, Number(header))) : void 0;
231
+ }
232
+ var isRetryableStatus = (status) => status === 408 || status === 425 || status === 429 || status >= 500;
233
+ var tokenCache = /* @__PURE__ */ new Map();
234
+ var inFlight = /* @__PURE__ */ new Map();
235
+ var TOKEN_CACHE_LIMIT = 1e3;
236
+ function clearAccessTokenCache() {
237
+ tokenCache.clear();
238
+ }
239
+ function createConnector(config) {
240
+ const { provider } = config;
241
+ if (!provider?.id || !provider.authorizeUrl || !provider.tokenUrl)
242
+ throw new ConnectError(
243
+ "config",
244
+ "The provider needs an id, an authorize URL and a token URL"
245
+ );
246
+ if (!config.clientId || !config.clientSecret)
247
+ throw new ConnectError(
248
+ "config",
249
+ `The ${provider.id} OAuth client id or secret is not set`
250
+ );
251
+ if (!config.secrets)
252
+ throw new ConnectError(
253
+ "config",
254
+ "The connector needs secrets (createSecrets) to seal refresh tokens"
255
+ );
256
+ if (typeof config.store !== "function")
257
+ throw new ConnectError("config", "The connector needs a store function");
258
+ let stateKeyBytes;
259
+ try {
260
+ stateKeyBytes = chunkEUEVGKB6_cjs.secretKeyBytes(config.stateSecret);
261
+ } catch (error) {
262
+ throw new ConnectError(
263
+ "config",
264
+ `The state secret: ${error.message}`
265
+ );
266
+ }
267
+ const callbackPath = checkPath(config.callbackPath, "callbackPath");
268
+ const redirectOrigin = config.redirectOrigin ? checkOrigin(config.redirectOrigin, "redirectOrigin") : void 0;
269
+ const sharedDomain = config.sharedDomain?.trim().toLowerCase().replace(/^\./, "") || void 0;
270
+ if (sharedDomain) {
271
+ if (!HOSTNAME.test(sharedDomain) || PUBLIC_SUFFIXES.has(sharedDomain) || config.isPublicSuffix?.(sharedDomain)) {
272
+ throw new ConnectError(
273
+ "config",
274
+ "sharedDomain must be a registrable domain such as example.com, not a public suffix"
275
+ );
276
+ }
277
+ if (redirectOrigin && !onDomain(new URL(redirectOrigin).hostname, sharedDomain)) {
278
+ throw new ConnectError(
279
+ "config",
280
+ "redirectOrigin must be on sharedDomain"
281
+ );
282
+ }
283
+ }
284
+ const cookieName = config.cookieName ?? `genesis_connect_${provider.id.replace(/[^A-Za-z0-9_-]/g, "_")}`;
285
+ if (!COOKIE_NAME.test(cookieName))
286
+ throw new ConnectError("config", "cookieName is not a valid cookie name");
287
+ const cookiePath = checkPath(
288
+ config.cookiePath ?? callbackPath,
289
+ "cookiePath"
290
+ ).replace(/[?#].*$/, "");
291
+ if (/[;,]/.test(cookiePath)) {
292
+ throw new ConnectError("config", "cookiePath can't hold ; or ,");
293
+ }
294
+ const callbackPathOnly = callbackPath.replace(/[?#].*$/, "");
295
+ if (cookiePath !== callbackPathOnly && !callbackPathOnly.startsWith(
296
+ cookiePath.endsWith("/") ? cookiePath : `${cookiePath}/`
297
+ ))
298
+ throw new ConnectError("config", "cookiePath must cover callbackPath");
299
+ const ttl = Math.floor(config.stateTtlSeconds ?? 600);
300
+ if (!(ttl >= 60 && ttl <= 3600))
301
+ throw new ConnectError(
302
+ "config",
303
+ "stateTtlSeconds must be between 60 and 3600"
304
+ );
305
+ const resultParam = config.resultParam ?? "connect";
306
+ const fallbackPath = checkPath(config.fallbackPath ?? "/", "fallbackPath");
307
+ const aadFor = config.aad ?? ((id) => `${provider.id}-grant:${id}`);
308
+ const separator = provider.scopeSeparator ?? " ";
309
+ const defaultScopes = scopeList(
310
+ config.scopes ?? provider.defaultScopes,
311
+ separator
312
+ );
313
+ const aliases = provider.scopeAliases ?? {};
314
+ const normalScope = (scope) => aliases[scope] ?? scope;
315
+ const now = () => config.now?.() ?? Date.now();
316
+ const log = (event) => {
317
+ try {
318
+ config.log?.(event);
319
+ } catch {
320
+ }
321
+ };
322
+ let stateKey;
323
+ const hmacKey = () => stateKey ??= crypto.subtle.importKey("raw", stateKeyBytes, "HKDF", false, ["deriveKey"]).then(
324
+ (base) => crypto.subtle.deriveKey(
325
+ {
326
+ name: "HKDF",
327
+ hash: "SHA-256",
328
+ salt: new Uint8Array(0),
329
+ info: chunkEUEVGKB6_cjs.utf8.encode(`genesis-connect/state/${provider.id}`)
330
+ },
331
+ base,
332
+ { name: "HMAC", hash: "SHA-256", length: 256 },
333
+ false,
334
+ ["sign", "verify"]
335
+ )
336
+ );
337
+ async function signState(state) {
338
+ const body = chunkEUEVGKB6_cjs.base64url(
339
+ JSON.stringify({
340
+ v: 1,
341
+ s: state.subject,
342
+ i: state.intent,
343
+ r: state.returnTo,
344
+ o: state.origin,
345
+ n: state.nonce,
346
+ e: state.expiresAt,
347
+ c: state.scopes,
348
+ d: state.data
349
+ })
350
+ );
351
+ const mac = await crypto.subtle.sign(
352
+ "HMAC",
353
+ await hmacKey(),
354
+ chunkEUEVGKB6_cjs.utf8.encode(body)
355
+ );
356
+ return `${body}.${chunkEUEVGKB6_cjs.base64url(new Uint8Array(mac))}`;
357
+ }
358
+ async function verifyState(signed) {
359
+ if (!signed || signed.length > 4096) return null;
360
+ const [body, mac, extra] = signed.split(".");
361
+ if (!body || !mac || extra !== void 0) return null;
362
+ try {
363
+ if (!await crypto.subtle.verify(
364
+ "HMAC",
365
+ await hmacKey(),
366
+ chunkEUEVGKB6_cjs.fromBase64(mac),
367
+ chunkEUEVGKB6_cjs.utf8.encode(body)
368
+ ))
369
+ return null;
370
+ const raw = JSON.parse(
371
+ new TextDecoder().decode(chunkEUEVGKB6_cjs.fromBase64(body))
372
+ );
373
+ const state = {
374
+ subject: String(raw["s"] ?? ""),
375
+ intent: String(raw["i"] ?? ""),
376
+ returnTo: checkPath(String(raw["r"] ?? ""), "returnTo"),
377
+ origin: String(raw["o"] ?? ""),
378
+ nonce: String(raw["n"] ?? ""),
379
+ expiresAt: Number(raw["e"]),
380
+ scopes: Array.isArray(raw["c"]) ? raw["c"].filter((s) => typeof s === "string") : [],
381
+ data: raw["d"] && typeof raw["d"] === "object" && !Array.isArray(raw["d"]) ? raw["d"] : {}
382
+ };
383
+ if (raw["v"] !== 1 || !state.subject || !state.nonce || !Number.isFinite(state.expiresAt))
384
+ return null;
385
+ if (state.expiresAt * 1e3 <= now()) return null;
386
+ return state;
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+ const originOf = (request) => new URL(request.url).origin;
392
+ const redirectUri = (request) => `${redirectOrigin ?? originOf(request)}${callbackPath}`;
393
+ const cookieDomain = (request) => sharedDomain && onDomain(new URL(request.url).hostname, sharedDomain) ? sharedDomain : void 0;
394
+ const isLocalRequest = (request) => isLocal(new URL(request.url).hostname);
395
+ function cookie(request, value, maxAge, domain = cookieDomain(request) ?? null) {
396
+ const secure = isLocalRequest(request) ? "" : "; Secure";
397
+ return `${cookieName}=${value}; Path=${cookiePath}; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}${domain ? `; Domain=${domain}` : ""}`;
398
+ }
399
+ function cookieValues(request) {
400
+ const values = [];
401
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
402
+ const at = part.indexOf("=");
403
+ if (at > 0 && part.slice(0, at).trim() === cookieName)
404
+ values.push(part.slice(at + 1).trim());
405
+ }
406
+ return values;
407
+ }
408
+ function returnOrigin(request, started) {
409
+ const here = originOf(request);
410
+ if (!started || started === here) return here;
411
+ if (sharedDomain && cookieDomain(request) && isSafeReturnOrigin(started, sharedDomain))
412
+ return started;
413
+ return here;
414
+ }
415
+ function back(request, state, result, extra = {}, detail) {
416
+ const url = new URL(
417
+ state?.returnTo ?? fallbackPath,
418
+ returnOrigin(request, state?.origin)
419
+ );
420
+ for (const [key, value] of Object.entries(extra)) {
421
+ if (key !== resultParam && typeof value === "string")
422
+ url.searchParams.set(key, value);
423
+ }
424
+ url.searchParams.set(resultParam, result);
425
+ log({ step: "callback", result, ...detail ? { detail } : {} });
426
+ const headers = new Headers({
427
+ location: url.toString(),
428
+ "cache-control": "no-store",
429
+ "referrer-policy": "no-referrer"
430
+ });
431
+ headers.append("set-cookie", cookie(request, "", 0, null));
432
+ const domain = cookieDomain(request);
433
+ if (domain) headers.append("set-cookie", cookie(request, "", 0, domain));
434
+ return new Response(null, { status: 303, headers });
435
+ }
436
+ function clientAuth(body, headers) {
437
+ if (provider.tokenAuth === "client_secret_basic") {
438
+ const encode = (s) => encodeURIComponent(s).replace(/%20/g, "+");
439
+ headers["authorization"] = `Basic ${btoa(`${encode(config.clientId)}:${encode(config.clientSecret)}`)}`;
440
+ } else {
441
+ body.set("client_id", config.clientId);
442
+ body.set("client_secret", config.clientSecret);
443
+ }
444
+ }
445
+ async function tokenRequest(params, signal) {
446
+ const body = new URLSearchParams(params);
447
+ const headers = {
448
+ "content-type": "application/x-www-form-urlencoded",
449
+ accept: "application/json"
450
+ };
451
+ clientAuth(body, headers);
452
+ return timedFetch(
453
+ config,
454
+ provider.tokenUrl,
455
+ { method: "POST", headers, body: body.toString() },
456
+ signal
457
+ );
458
+ }
459
+ const granted = (body, asked) => {
460
+ const scope = typeof body["scope"] === "string" ? body["scope"] : asked.join(separator);
461
+ return {
462
+ scope,
463
+ scopes: scope.split(separator).filter(Boolean).map(normalScope)
464
+ };
465
+ };
466
+ const expiresIn = (body) => {
467
+ const value = Number(body["expires_in"]);
468
+ return Number.isFinite(value) && value > 0 ? value : 3600;
469
+ };
470
+ async function cacheKey(refreshToken) {
471
+ return chunkEUEVGKB6_cjs.sha256Hex(`${provider.id}
472
+ ${config.clientId}
473
+ ${refreshToken}`);
474
+ }
475
+ async function refreshWith(refreshToken, aad, signal) {
476
+ const response = await tokenRequest(
477
+ { grant_type: "refresh_token", refresh_token: refreshToken },
478
+ signal
479
+ );
480
+ if (!response.ok) {
481
+ const body2 = await jsonBody(response);
482
+ const code = errorCode(body2);
483
+ if (response.status === 400 && code === "invalid_grant") {
484
+ log({ step: "refresh", result: "revoked" });
485
+ throw new ConnectError(
486
+ "revoked",
487
+ `The ${provider.id} connection was removed. Connect it again.`,
488
+ { status: 400 }
489
+ );
490
+ }
491
+ const detail = `token endpoint answered ${response.status}${code ? ` (${code})` : ""}`;
492
+ if (isRetryableStatus(response.status)) {
493
+ log({ step: "refresh", result: "unavailable", detail });
494
+ throw new ConnectError("unavailable", `The ${provider.id} ${detail}`, {
495
+ status: response.status,
496
+ retryAfterSeconds: retryAfter(response)
497
+ });
498
+ }
499
+ log({ step: "refresh", result: "failed", detail });
500
+ throw new ConnectError("failed", `The ${provider.id} ${detail}`, {
501
+ status: response.status
502
+ });
503
+ }
504
+ const body = await jsonBody(response);
505
+ const accessToken = body["access_token"];
506
+ if (typeof accessToken !== "string" || !accessToken)
507
+ throw new ConnectError(
508
+ "unavailable",
509
+ `The ${provider.id} token endpoint sent no access token`
510
+ );
511
+ const rotated = typeof body["refresh_token"] === "string" && body["refresh_token"] && body["refresh_token"] !== refreshToken ? body["refresh_token"] : void 0;
512
+ const sealedRefreshToken = rotated ? await config.secrets.seal(rotated, aad) : void 0;
513
+ return {
514
+ accessToken,
515
+ expiresIn: expiresIn(body),
516
+ scope: granted(body, []).scope,
517
+ ...sealedRefreshToken ? { sealedRefreshToken, rotatedPlain: rotated } : {}
518
+ };
519
+ }
520
+ async function handOver(fresh, options, required) {
521
+ const sealedRefreshToken = fresh.sealedRefreshToken;
522
+ if (!sealedRefreshToken) return;
523
+ if (!options.onRotate) {
524
+ if (!required) return;
525
+ log({
526
+ step: "refresh",
527
+ result: "usage",
528
+ detail: "rotated without onRotate"
529
+ });
530
+ throw new ConnectError(
531
+ "usage",
532
+ `The ${provider.id} refresh token was rotated and no onRotate was given to store it: store this error's sealedRefreshToken`,
533
+ { sealedRefreshToken }
534
+ );
535
+ }
536
+ try {
537
+ await options.onRotate(sealedRefreshToken);
538
+ } catch {
539
+ log({ step: "refresh", result: "failed", detail: "onRotate threw" });
540
+ throw new ConnectError(
541
+ "failed",
542
+ `Storing the rotated ${provider.id} refresh token failed: store this error's sealedRefreshToken`,
543
+ { sealedRefreshToken }
544
+ );
545
+ }
546
+ }
547
+ const publicResult = (fresh) => ({
548
+ accessToken: fresh.accessToken,
549
+ expiresIn: fresh.expiresIn,
550
+ scope: fresh.scope,
551
+ ...fresh.sealedRefreshToken ? { sealedRefreshToken: fresh.sealedRefreshToken } : {}
552
+ });
553
+ async function cacheToken(refreshToken, fresh, at) {
554
+ const entry = {
555
+ token: fresh.accessToken,
556
+ expiresAt: at + fresh.expiresIn * 1e3
557
+ };
558
+ for (const plain of [refreshToken, fresh.rotatedPlain]) {
559
+ if (!plain) continue;
560
+ if (tokenCache.size >= TOKEN_CACHE_LIMIT)
561
+ tokenCache.delete(tokenCache.keys().next().value);
562
+ tokenCache.set(await cacheKey(plain), entry);
563
+ }
564
+ }
565
+ return {
566
+ provider,
567
+ redirectUri,
568
+ async start(request, options) {
569
+ const subject = typeof options.subject === "string" ? options.subject.trim() : "";
570
+ if (!subject || subject.length > 256)
571
+ throw new ConnectError(
572
+ "usage",
573
+ "start needs the signed-in subject (at most 256 characters)"
574
+ );
575
+ const intent = options.intent ?? "";
576
+ if (typeof intent !== "string" || intent.length > 64)
577
+ throw new ConnectError("usage", "intent must be at most 64 characters");
578
+ const returnTo = checkPath(options.returnTo, "returnTo");
579
+ if (new URL(request.url).protocol !== "https:" && !isLocalRequest(request)) {
580
+ throw new ConnectError(
581
+ "config",
582
+ "start needs https (http only on localhost); behind a TLS-ending proxy, pass a Request with the public https URL"
583
+ );
584
+ }
585
+ if (redirectOrigin && originOf(request) !== redirectOrigin && !cookieDomain(request)) {
586
+ throw new ConnectError(
587
+ "config",
588
+ "This origin can't finish on the pinned redirectOrigin: it is not on sharedDomain"
589
+ );
590
+ }
591
+ const scopes = options.scopes ? scopeList(options.scopes, separator) : defaultScopes;
592
+ if (scopes.length === 0)
593
+ throw new ConnectError(
594
+ "usage",
595
+ "No scopes to ask for: give the connector or start some"
596
+ );
597
+ const nonce = chunkEUEVGKB6_cjs.randomToken(16);
598
+ const verifier = chunkEUEVGKB6_cjs.randomToken(32);
599
+ const state = await signState({
600
+ subject,
601
+ intent,
602
+ returnTo,
603
+ origin: originOf(request),
604
+ nonce,
605
+ expiresAt: Math.floor(now() / 1e3) + ttl,
606
+ scopes,
607
+ data: options.data ?? {}
608
+ });
609
+ if (state.length > 2048)
610
+ throw new ConnectError(
611
+ "usage",
612
+ "The state is too large: keep data small"
613
+ );
614
+ const challenge = chunkEUEVGKB6_cjs.base64url(
615
+ new Uint8Array(
616
+ await crypto.subtle.digest("SHA-256", chunkEUEVGKB6_cjs.utf8.encode(verifier))
617
+ )
618
+ );
619
+ const url = new URL(provider.authorizeUrl);
620
+ for (const [key, value] of Object.entries(provider.authorizeParams ?? {}))
621
+ url.searchParams.set(key, value);
622
+ for (const [key, value] of Object.entries(options.params ?? {})) {
623
+ if (RESERVED_PARAMS.has(key))
624
+ throw new ConnectError(
625
+ "usage",
626
+ `start can't set the ${key} parameter`
627
+ );
628
+ url.searchParams.set(key, value);
629
+ }
630
+ url.searchParams.set("client_id", config.clientId);
631
+ url.searchParams.set("redirect_uri", redirectUri(request));
632
+ url.searchParams.set("response_type", "code");
633
+ url.searchParams.set("scope", scopes.join(separator));
634
+ url.searchParams.set("state", state);
635
+ url.searchParams.set("code_challenge", challenge);
636
+ url.searchParams.set("code_challenge_method", "S256");
637
+ const href = url.toString();
638
+ const setCookie = cookie(request, `${nonce}.${verifier}`, ttl);
639
+ const headers = { "set-cookie": setCookie, "cache-control": "no-store" };
640
+ return {
641
+ url: href,
642
+ setCookie,
643
+ redirect: () => new Response(null, {
644
+ status: 303,
645
+ headers: { ...headers, location: href }
646
+ }),
647
+ json: () => Response.json({ url: href }, { headers })
648
+ };
649
+ },
650
+ async callback(request) {
651
+ const query = new URL(request.url).searchParams;
652
+ const state = await verifyState(query.get("state"));
653
+ if (!state) return back(request, null, "expired");
654
+ const verifier = cookieValues(request).map((value) => value.split(".")).find(
655
+ ([nonce, v, extra2]) => nonce === state.nonce && v && extra2 === void 0
656
+ )?.[1];
657
+ if (!verifier) return back(request, state, "browser");
658
+ if (query.get("error"))
659
+ return back(
660
+ request,
661
+ state,
662
+ query.get("error") === "access_denied" ? "denied" : "failed"
663
+ );
664
+ const code = query.get("code");
665
+ if (!code) return back(request, state, "denied");
666
+ let body;
667
+ try {
668
+ const response = await tokenRequest({
669
+ grant_type: "authorization_code",
670
+ code,
671
+ code_verifier: verifier,
672
+ redirect_uri: redirectUri(request)
673
+ });
674
+ body = await jsonBody(response);
675
+ if (!response.ok) {
676
+ const code2 = errorCode(body);
677
+ return back(
678
+ request,
679
+ state,
680
+ "failed",
681
+ {},
682
+ `token endpoint answered ${response.status}${code2 ? ` (${code2})` : ""}`
683
+ );
684
+ }
685
+ } catch (error) {
686
+ return back(
687
+ request,
688
+ state,
689
+ "failed",
690
+ {},
691
+ error instanceof ConnectError ? error.message : "token request failed"
692
+ );
693
+ }
694
+ const accessToken = body["access_token"];
695
+ const refreshToken = body["refresh_token"];
696
+ if (typeof accessToken !== "string" || !accessToken) {
697
+ return back(request, state, "failed", {}, "no access token");
698
+ }
699
+ const { scope, scopes } = granted(body, state.scopes);
700
+ if (!state.scopes.every((s) => scopes.includes(normalScope(s))))
701
+ return back(request, state, "scope");
702
+ if ((config.requireRefreshToken ?? true) && (typeof refreshToken !== "string" || !refreshToken)) {
703
+ return back(
704
+ request,
705
+ state,
706
+ "failed",
707
+ {},
708
+ "no refresh token (is offline access asked for?)"
709
+ );
710
+ }
711
+ let extra;
712
+ try {
713
+ const id = config.randomId?.() ?? crypto.randomUUID();
714
+ const aad = aadFor(id);
715
+ const sealedRefreshToken = typeof refreshToken === "string" && refreshToken ? await config.secrets.seal(refreshToken, aad) : "";
716
+ extra = await config.store(
717
+ {
718
+ id,
719
+ aad,
720
+ sealedRefreshToken,
721
+ accessToken,
722
+ expiresIn: expiresIn(body),
723
+ scope,
724
+ scopes,
725
+ ...typeof body["id_token"] === "string" ? { idToken: body["id_token"] } : {},
726
+ subject: state.subject,
727
+ intent: state.intent,
728
+ returnTo: state.returnTo,
729
+ data: state.data
730
+ },
731
+ { request }
732
+ );
733
+ } catch {
734
+ return back(request, state, "failed", {}, "store threw");
735
+ }
736
+ return back(request, state, "connected", extra ?? {});
737
+ },
738
+ async refresh(sealedRefreshToken, aad, options = {}) {
739
+ const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
740
+ const fresh = await refreshWith(refreshToken, aad, options.signal);
741
+ await handOver(fresh, options, false);
742
+ return publicResult(fresh);
743
+ },
744
+ async accessToken(sealedRefreshToken, aad, options = {}) {
745
+ const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
746
+ const key = await cacheKey(refreshToken);
747
+ const hit = tokenCache.get(key);
748
+ if (hit && hit.expiresAt - 6e4 > now()) return hit.token;
749
+ let flight = inFlight.get(key);
750
+ if (!flight) {
751
+ const at = now();
752
+ flight = refreshWith(refreshToken, aad, options.signal).then(async (fresh2) => {
753
+ await cacheToken(refreshToken, fresh2, at);
754
+ return fresh2;
755
+ }).finally(() => inFlight.delete(key));
756
+ inFlight.set(key, flight);
757
+ }
758
+ const fresh = await flight;
759
+ await handOver(fresh, options, true);
760
+ return fresh.accessToken;
761
+ },
762
+ async revoke(sealedRefreshToken, aad, options = {}) {
763
+ const refreshToken = await config.secrets.open(sealedRefreshToken, aad);
764
+ tokenCache.delete(await cacheKey(refreshToken));
765
+ if (!provider.revokeUrl) return false;
766
+ const body = new URLSearchParams({
767
+ token: refreshToken,
768
+ token_type_hint: "refresh_token"
769
+ });
770
+ const headers = {
771
+ "content-type": "application/x-www-form-urlencoded",
772
+ accept: "application/json"
773
+ };
774
+ if (provider.revokeWithClientAuth) clientAuth(body, headers);
775
+ try {
776
+ const response = await timedFetch(
777
+ config,
778
+ provider.revokeUrl,
779
+ { method: "POST", headers, body: body.toString() },
780
+ options.signal
781
+ );
782
+ let gone = response.ok;
783
+ if (!gone && response.status === 400) {
784
+ gone = errorCode(await jsonBody(response)) === "invalid_token";
785
+ } else {
786
+ discard(response);
787
+ }
788
+ log({
789
+ step: "revoke",
790
+ result: gone ? "revoked" : "failed",
791
+ ...gone ? {} : { detail: `revoke endpoint answered ${response.status}` }
792
+ });
793
+ return gone;
794
+ } catch {
795
+ log({ step: "revoke", result: "unavailable" });
796
+ return false;
797
+ }
798
+ }
799
+ };
800
+ }
801
+
802
+ Object.defineProperty(exports, "KEY_ID", {
803
+ enumerable: true,
804
+ get: function () { return chunkEUEVGKB6_cjs.KEY_ID; }
805
+ });
806
+ Object.defineProperty(exports, "SecretError", {
807
+ enumerable: true,
808
+ get: function () { return chunkEUEVGKB6_cjs.SecretError; }
809
+ });
810
+ Object.defineProperty(exports, "createSecrets", {
811
+ enumerable: true,
812
+ get: function () { return chunkEUEVGKB6_cjs.createSecrets; }
813
+ });
814
+ Object.defineProperty(exports, "deriveSecretKey", {
815
+ enumerable: true,
816
+ get: function () { return chunkEUEVGKB6_cjs.deriveSecretKey; }
817
+ });
818
+ Object.defineProperty(exports, "isSecretError", {
819
+ enumerable: true,
820
+ get: function () { return chunkEUEVGKB6_cjs.isSecretError; }
821
+ });
822
+ Object.defineProperty(exports, "openWithKey", {
823
+ enumerable: true,
824
+ get: function () { return chunkEUEVGKB6_cjs.openWithKey; }
825
+ });
826
+ Object.defineProperty(exports, "parseKeyring", {
827
+ enumerable: true,
828
+ get: function () { return chunkEUEVGKB6_cjs.parseKeyring; }
829
+ });
830
+ Object.defineProperty(exports, "sealWithKey", {
831
+ enumerable: true,
832
+ get: function () { return chunkEUEVGKB6_cjs.sealWithKey; }
833
+ });
834
+ Object.defineProperty(exports, "secretKeyBytes", {
835
+ enumerable: true,
836
+ get: function () { return chunkEUEVGKB6_cjs.secretKeyBytes; }
837
+ });
838
+ exports.CONNECT_RESULTS = CONNECT_RESULTS;
839
+ exports.ConnectError = ConnectError;
840
+ exports.GOOGLE_SCOPES = GOOGLE_SCOPES;
841
+ exports.clearAccessTokenCache = clearAccessTokenCache;
842
+ exports.createConnector = createConnector;
843
+ exports.googleProvider = googleProvider;
844
+ exports.isConnectError = isConnectError;
845
+ exports.isSafeReturnOrigin = isSafeReturnOrigin;
846
+ //# sourceMappingURL=index.cjs.map
847
+ //# sourceMappingURL=index.cjs.map