@coffre/core 0.0.0 → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -3
  3. package/dist/access.d.ts +100 -0
  4. package/dist/access.js +109 -0
  5. package/dist/audit.d.ts +119 -0
  6. package/dist/audit.js +160 -0
  7. package/dist/dotenv.d.ts +45 -0
  8. package/dist/dotenv.js +194 -0
  9. package/dist/envelope.d.ts +50 -0
  10. package/dist/envelope.js +83 -0
  11. package/dist/identity.d.ts +433 -0
  12. package/dist/identity.js +691 -0
  13. package/dist/kek.d.ts +72 -0
  14. package/dist/kek.js +486 -0
  15. package/dist/registry-C0NGpxcc.d.ts +126 -0
  16. package/dist/schemas.d.ts +18 -0
  17. package/dist/schemas.js +13 -0
  18. package/dist/types-Mi7-Tzj9.js +57 -0
  19. package/dist/vault.d.ts +375 -0
  20. package/dist/vault.js +45 -0
  21. package/package.json +75 -5
  22. package/src/access-fault.ts +61 -0
  23. package/src/access.ts +152 -0
  24. package/src/audit/chain.ts +207 -0
  25. package/src/checkpoint.ts +13 -0
  26. package/src/context.ts +54 -0
  27. package/src/dotenv.ts +242 -0
  28. package/src/envelope.ts +127 -0
  29. package/src/identity/auth.ts +124 -0
  30. package/src/identity/index.ts +6 -0
  31. package/src/identity/signin/config.ts +301 -0
  32. package/src/identity/signin/github.ts +191 -0
  33. package/src/identity/signin/index.ts +5 -0
  34. package/src/identity/signin/oidc.ts +199 -0
  35. package/src/identity/signin/sealed.ts +55 -0
  36. package/src/identity/signin/types.ts +94 -0
  37. package/src/identity/tokens.ts +45 -0
  38. package/src/identity/types.ts +51 -0
  39. package/src/identity/verifier.ts +86 -0
  40. package/src/kek/aws-kms.ts +279 -0
  41. package/src/kek/cancellation.ts +56 -0
  42. package/src/kek/index.ts +6 -0
  43. package/src/kek/local.ts +122 -0
  44. package/src/kek/registry.ts +63 -0
  45. package/src/kek/sigv4.ts +75 -0
  46. package/src/kek/types.ts +79 -0
  47. package/src/schemas.ts +11 -0
  48. package/src/vault.ts +276 -0
@@ -0,0 +1,691 @@
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
2
+ import * as oauth from "oauth4webapi";
3
+ import { createRemoteJWKSet, jwtVerify } from "jose";
4
+ //#region src/identity/signin/types.ts
5
+ var SigninError = class extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ }
11
+ };
12
+ //#endregion
13
+ //#region src/identity/signin/github.ts
14
+ /**
15
+ * GitHub's OAuth web flow, which is OAuth 2.0 without OpenID Connect: no ID
16
+ * token, so the profile comes from the REST API with the access token the
17
+ * code is exchanged for. That token is used for these two or three calls and
18
+ * then dropped; coffre never stores it.
19
+ *
20
+ * Works with an OAuth App or a GitHub App's user authorization, on github.com
21
+ * or GitHub Enterprise Server.
22
+ */
23
+ var GitHubSigninProvider = class {
24
+ id;
25
+ issuer;
26
+ label;
27
+ brand;
28
+ /** What it was made from, as checked. */
29
+ config;
30
+ #fetch;
31
+ constructor(config, options = {}) {
32
+ this.id = config.id;
33
+ this.issuer = config.apiUrl;
34
+ this.label = config.label;
35
+ this.brand = config.brand;
36
+ this.config = config;
37
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
38
+ }
39
+ async start(redirectUri, options = {}) {
40
+ const pending = {
41
+ state: oauth.generateRandomState(),
42
+ codeVerifier: oauth.generateRandomCodeVerifier(),
43
+ nonce: null
44
+ };
45
+ const url = new URL(`${this.config.webUrl}/login/oauth/authorize`);
46
+ const params = url.searchParams;
47
+ params.set("client_id", this.config.clientId);
48
+ params.set("redirect_uri", redirectUri);
49
+ params.set("scope", this.config.organization ? "read:user user:email read:org" : "read:user user:email");
50
+ params.set("state", pending.state);
51
+ params.set("code_challenge", await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
52
+ params.set("code_challenge_method", "S256");
53
+ params.set("allow_signup", "false");
54
+ if (options.loginHint !== void 0) params.set("login", options.loginHint);
55
+ return {
56
+ url,
57
+ pending
58
+ };
59
+ }
60
+ async finish(callbackUrl, redirectUri, pending) {
61
+ const label = this.config.label;
62
+ const query = callbackUrl.searchParams;
63
+ if (query.get("state") !== pending.state) throw new SigninError("state_mismatch", `the ${label} callback does not match this sign-in`);
64
+ const refused = query.get("error");
65
+ if (refused !== null) throw new SigninError("provider_denied", `${label} refused the sign-in: ${refused}`);
66
+ const code = query.get("code");
67
+ if (code === null || code === "") throw new SigninError("invalid_response", `the ${label} callback carries no code`);
68
+ const token = await this.#exchange(code, redirectUri, pending.codeVerifier);
69
+ const user = await this.#api(token, "/user");
70
+ if (typeof user?.id !== "number") throw new SigninError("invalid_response", `${label} did not say who signed in`);
71
+ const organization = this.config.organization;
72
+ if (organization !== null && !await this.#isMember(token, organization)) throw new SigninError("not_in_organization", `only members of the ${organization} organization may sign in with ${label}`);
73
+ const listed = await this.#api(token, "/user/emails");
74
+ const emails = (Array.isArray(listed) ? listed : []).filter((entry) => entry.verified && typeof entry.email === "string").sort((a, b) => Number(b.primary) - Number(a.primary)).map((entry) => entry.email.trim().toLowerCase());
75
+ return {
76
+ subject: String(user.id),
77
+ emails: [...new Set(emails)],
78
+ name: user.name ?? user.login
79
+ };
80
+ }
81
+ async #exchange(code, redirectUri, codeVerifier) {
82
+ const label = this.config.label;
83
+ const response = await this.#send(`${this.config.webUrl}/login/oauth/access_token`, {
84
+ method: "POST",
85
+ headers: {
86
+ accept: "application/json",
87
+ "content-type": "application/x-www-form-urlencoded"
88
+ },
89
+ body: new URLSearchParams({
90
+ client_id: this.config.clientId,
91
+ client_secret: this.config.clientSecret,
92
+ code,
93
+ redirect_uri: redirectUri,
94
+ code_verifier: codeVerifier
95
+ })
96
+ });
97
+ const body = await readJson(response);
98
+ if (!response.ok || body === null) throw new SigninError("invalid_response", `${label} answered the code exchange with ${response.status}`);
99
+ if (typeof body.error === "string") throw new SigninError("provider_denied", `${label} refused the code: ${body.error}`);
100
+ if (typeof body.access_token !== "string" || body.access_token === "") throw new SigninError("invalid_response", `${label} returned no access token`);
101
+ return body.access_token;
102
+ }
103
+ async #isMember(token, organization) {
104
+ const response = await this.#send(`${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`, { headers: apiHeaders(token) });
105
+ if (response.status === 404 || response.status === 403) return false;
106
+ const body = await readJson(response);
107
+ if (!response.ok || body === null) throw new SigninError("invalid_response", `${this.config.label} answered the membership check with ${response.status}`);
108
+ return body.state === "active";
109
+ }
110
+ async #api(token, path) {
111
+ const response = await this.#send(`${this.config.apiUrl}${path}`, { headers: apiHeaders(token) });
112
+ if (!response.ok) throw new SigninError("invalid_response", `${this.config.label} answered ${path} with ${response.status}`);
113
+ return await readJson(response);
114
+ }
115
+ async #send(url, init) {
116
+ try {
117
+ return await this.#fetch(url, {
118
+ ...init,
119
+ redirect: "manual",
120
+ signal: AbortSignal.timeout(1e4)
121
+ });
122
+ } catch (cause) {
123
+ const error = new SigninError("provider_unavailable", `${this.config.label} could not be reached`);
124
+ error.cause = cause;
125
+ throw error;
126
+ }
127
+ }
128
+ };
129
+ function apiHeaders(token) {
130
+ return {
131
+ accept: "application/vnd.github+json",
132
+ authorization: `Bearer ${token}`,
133
+ "user-agent": "coffre",
134
+ "x-github-api-version": "2022-11-28"
135
+ };
136
+ }
137
+ async function readJson(response) {
138
+ try {
139
+ return await response.json();
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+ //#endregion
145
+ //#region src/identity/signin/oidc.ts
146
+ /** Discovery documents change rarely; an hour keeps one fetch off every sign-in. */
147
+ const DISCOVERY_TTL_MS = 36e5;
148
+ /**
149
+ * The authorization code flow with PKCE, against any OpenID Connect issuer.
150
+ *
151
+ * Protocol work is delegated to oauth4webapi: discovery with issuer
152
+ * validation, the RFC 9207 `iss` check on the callback, the token request and
153
+ * ID token claim validation (issuer, audience, expiry, nonce). The ID token is
154
+ * received straight from the token endpoint over TLS with client
155
+ * authentication, which OpenID Connect Core 3.1.3.7 accepts in place of
156
+ * checking its signature.
157
+ */
158
+ var OidcSigninProvider = class {
159
+ id;
160
+ issuer;
161
+ label;
162
+ brand;
163
+ /** What it was made from, as checked. */
164
+ config;
165
+ #fetch;
166
+ #insecure;
167
+ #discovered = null;
168
+ constructor(config, options = {}) {
169
+ this.id = config.id;
170
+ this.issuer = config.issuer;
171
+ this.label = config.label;
172
+ this.brand = config.brand;
173
+ this.config = config;
174
+ this.#fetch = options.fetch;
175
+ this.#insecure = new URL(config.issuer).protocol === "http:";
176
+ }
177
+ #http() {
178
+ const options = { signal: () => AbortSignal.timeout(1e4) };
179
+ if (this.#fetch !== void 0) {
180
+ const fetchImpl = this.#fetch;
181
+ options[oauth.customFetch] = (url, init) => fetchImpl(url, init);
182
+ }
183
+ if (this.#insecure) options[oauth.allowInsecureRequests] = true;
184
+ return options;
185
+ }
186
+ get #client() {
187
+ return { client_id: this.config.clientId };
188
+ }
189
+ async #server() {
190
+ if (this.#discovered !== null && Date.now() - this.#discovered.fetchedAt < DISCOVERY_TTL_MS) return this.#discovered.server;
191
+ const issuer = new URL(this.config.issuer);
192
+ let server;
193
+ try {
194
+ const response = await oauth.discoveryRequest(issuer, {
195
+ ...this.#http(),
196
+ algorithm: "oidc"
197
+ });
198
+ server = await oauth.processDiscoveryResponse(issuer, response);
199
+ } catch (error) {
200
+ throw unavailable(this.config.label, error);
201
+ }
202
+ this.#discovered = {
203
+ server,
204
+ fetchedAt: Date.now()
205
+ };
206
+ return server;
207
+ }
208
+ async start(redirectUri, options = {}) {
209
+ const server = await this.#server();
210
+ if (server.authorization_endpoint === void 0) throw new SigninError("invalid_response", `${this.config.label} publishes no authorization endpoint`);
211
+ const pending = {
212
+ state: oauth.generateRandomState(),
213
+ codeVerifier: oauth.generateRandomCodeVerifier(),
214
+ nonce: oauth.generateRandomNonce()
215
+ };
216
+ const url = new URL(server.authorization_endpoint);
217
+ const params = url.searchParams;
218
+ params.set("client_id", this.config.clientId);
219
+ params.set("redirect_uri", redirectUri);
220
+ params.set("response_type", "code");
221
+ params.set("scope", this.config.scopes.join(" "));
222
+ params.set("state", pending.state);
223
+ params.set("nonce", pending.nonce);
224
+ params.set("code_challenge", await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
225
+ params.set("code_challenge_method", "S256");
226
+ for (const [name, value] of Object.entries(this.config.authorizationParams)) params.set(name, value);
227
+ if (options.loginHint !== void 0) params.set("login_hint", options.loginHint);
228
+ return {
229
+ url,
230
+ pending
231
+ };
232
+ }
233
+ async finish(callbackUrl, redirectUri, pending) {
234
+ const server = await this.#server();
235
+ const label = this.config.label;
236
+ let params;
237
+ try {
238
+ params = oauth.validateAuthResponse(server, this.#client, callbackUrl, pending.state);
239
+ } catch (error) {
240
+ if (error instanceof oauth.AuthorizationResponseError) throw new SigninError("provider_denied", `${label} refused the sign-in: ${error.error}`);
241
+ throw new SigninError("state_mismatch", `the ${label} callback does not match this sign-in`);
242
+ }
243
+ let claims;
244
+ try {
245
+ const response = await oauth.authorizationCodeGrantRequest(server, this.#client, oauth.ClientSecretPost(this.config.clientSecret), params, redirectUri, pending.codeVerifier, this.#http());
246
+ const result = await oauth.processAuthorizationCodeResponse(server, this.#client, response, {
247
+ expectedNonce: pending.nonce ?? oauth.expectNoNonce,
248
+ requireIdToken: true
249
+ });
250
+ claims = oauth.getValidatedIdTokenClaims(result);
251
+ } catch (error) {
252
+ if (error instanceof SigninError) throw error;
253
+ if (error instanceof oauth.ResponseBodyError) throw new SigninError("provider_denied", `${label} refused the code: ${error.error}`);
254
+ if (isNetworkError(error)) throw unavailable(label, error);
255
+ throw new SigninError("invalid_response", `${label} returned a token coffre cannot trust`);
256
+ }
257
+ return this.#profile(claims);
258
+ }
259
+ #profile(claims) {
260
+ const label = this.config.label;
261
+ if (this.config.hostedDomain !== null && claims.hd !== this.config.hostedDomain) throw new SigninError("wrong_domain", `only ${this.config.hostedDomain} accounts may sign in with ${label}`);
262
+ const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : "";
263
+ const authoritative = this.config.brand !== "google" || email.endsWith("@gmail.com") || typeof claims.hd === "string" && claims.hd !== "";
264
+ const emails = email !== "" && claims.email_verified === true && authoritative && this.config.brand !== "microsoft" ? [email] : [];
265
+ return {
266
+ subject: claims.sub,
267
+ emails,
268
+ name: typeof claims.name === "string" ? claims.name : null
269
+ };
270
+ }
271
+ };
272
+ function isNetworkError(error) {
273
+ return error instanceof TypeError || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
274
+ }
275
+ function unavailable(label, cause) {
276
+ const error = new SigninError("provider_unavailable", `${label} could not be reached`);
277
+ error.cause = cause;
278
+ return error;
279
+ }
280
+ //#endregion
281
+ //#region src/identity/signin/config.ts
282
+ /**
283
+ * Sign-in providers, and the sign-in they make up.
284
+ *
285
+ * coffre speaks two protocols: OpenID Connect, which covers Google, Microsoft,
286
+ * Okta, Auth0, Keycloak, Authentik, Clerk, WorkOS and nearly everyone else,
287
+ * and GitHub's own OAuth, because GitHub is the one provider people want that
288
+ * does not speak OIDC. Everything named here is a preset of one of those two:
289
+ * `google()` is OIDC with Google's issuer and a Workspace-domain check,
290
+ * `microsoft()` is OIDC with a tenant's issuer. A provider that is not listed
291
+ * needs no code, only `oidc()` and its issuer URL.
292
+ *
293
+ * A deployment writes them in its own code, where a misspelt option fails
294
+ * the typecheck:
295
+ *
296
+ * signin({ providers: [github({ clientId, clientSecret: env.GITHUB_SECRET, organization: 'acme' })] })
297
+ *
298
+ * Each returns a `SigninProvider`, and a provider that speaks neither can be
299
+ * one of the deployment's own, in the same list.
300
+ */
301
+ const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
302
+ const BRANDS = [
303
+ "github",
304
+ "google",
305
+ "microsoft",
306
+ "oidc"
307
+ ];
308
+ function checkId(id) {
309
+ if (typeof id !== "string" || !PROVIDER_ID.test(id)) throw new Error(`sign-in provider id "${id}" must be 1-32 lowercase letters, digits or dashes`);
310
+ return id;
311
+ }
312
+ function credentials(id, options) {
313
+ if (!options.clientId || !options.clientSecret) throw new Error(`sign-in provider ${id} needs a client id and a client secret`);
314
+ return {
315
+ clientId: options.clientId,
316
+ clientSecret: options.clientSecret
317
+ };
318
+ }
319
+ /**
320
+ * Any OpenID Connect provider, by issuer URL.
321
+ *
322
+ * Okta, Auth0, Keycloak, Authentik, Zitadel, Clerk, WorkOS, Dex and the like
323
+ * all live here; so does a broker, for providers that only speak SAML or LDAP.
324
+ */
325
+ function oidc(options) {
326
+ return new OidcSigninProvider(oidcConfig(options));
327
+ }
328
+ function oidcConfig(options) {
329
+ const id = checkId(options.id);
330
+ return {
331
+ id,
332
+ label: options.label,
333
+ brand: "oidc",
334
+ ...credentials(id, options),
335
+ issuer: issuerUrl(options.issuer, `sign-in provider ${id}`),
336
+ scopes: options.scopes ?? [
337
+ "openid",
338
+ "email",
339
+ "profile"
340
+ ],
341
+ authorizationParams: options.authorizationParams ?? {},
342
+ hostedDomain: null
343
+ };
344
+ }
345
+ /** Google accounts; with `domain`, only that Google Workspace's accounts. */
346
+ function google(options) {
347
+ const domain = options.domain?.trim().toLowerCase() || null;
348
+ return new OidcSigninProvider({
349
+ ...oidcConfig({
350
+ id: options.id ?? "google",
351
+ label: options.label ?? "Google",
352
+ issuer: "https://accounts.google.com",
353
+ clientId: options.clientId,
354
+ clientSecret: options.clientSecret,
355
+ authorizationParams: domain ? {
356
+ hd: domain,
357
+ prompt: "select_account"
358
+ } : { prompt: "select_account" }
359
+ }),
360
+ brand: "google",
361
+ hostedDomain: domain
362
+ });
363
+ }
364
+ const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
365
+ /**
366
+ * Microsoft Entra ID, for one tenant.
367
+ *
368
+ * The tenant must be its GUID. Entra's multi-tenant endpoints (`common`,
369
+ * `organizations`) publish an issuer template rather than an issuer, which
370
+ * standard OIDC validation rejects, and "any work account in the world" is not
371
+ * an audience a secrets manager wants anyway.
372
+ *
373
+ * Entra email claims do not prove address ownership. Sign in through another
374
+ * provider and link the Microsoft account first; later sign-ins use its subject.
375
+ */
376
+ function microsoft(options) {
377
+ const tenant = options.tenant.trim();
378
+ if (!TENANT_ID.test(tenant)) throw new Error("the Microsoft sign-in tenant must be the directory (tenant) ID, a GUID, not a domain or \"common\"");
379
+ return new OidcSigninProvider({
380
+ ...oidcConfig({
381
+ id: options.id ?? "microsoft",
382
+ label: options.label ?? "Microsoft",
383
+ issuer: `https://login.microsoftonline.com/${tenant.toLowerCase()}/v2.0`,
384
+ clientId: options.clientId,
385
+ clientSecret: options.clientSecret,
386
+ authorizationParams: { prompt: "select_account" }
387
+ }),
388
+ brand: "microsoft"
389
+ });
390
+ }
391
+ /** GitHub accounts; with `organization`, only that organization's members. */
392
+ function github(options) {
393
+ const id = checkId(options.id ?? "github");
394
+ return new GitHubSigninProvider({
395
+ id,
396
+ label: options.label ?? "GitHub",
397
+ brand: "github",
398
+ ...credentials(id, options),
399
+ webUrl: baseUrl(options.webUrl ?? "https://github.com", `sign-in provider ${id} web URL`),
400
+ apiUrl: baseUrl(options.apiUrl ?? "https://api.github.com", `sign-in provider ${id} API URL`),
401
+ organization: options.organization?.trim() || null
402
+ });
403
+ }
404
+ function isLoopback(url) {
405
+ return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
406
+ }
407
+ /** HTTPS, except on loopback, where the dev IdP runs. */
408
+ function checkedUrl(value, what) {
409
+ let url;
410
+ try {
411
+ url = new URL(value);
412
+ } catch {
413
+ throw new Error(`${what} must be an absolute URL`);
414
+ }
415
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url))) throw new Error(`${what} must use HTTPS`);
416
+ if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") throw new Error(`${what} must not carry credentials, a query or a fragment`);
417
+ return url;
418
+ }
419
+ /**
420
+ * Issuers are compared byte for byte with the `iss` of every token, so the
421
+ * configured value is kept as written, less a trailing slash nobody means.
422
+ */
423
+ function issuerUrl(value, what) {
424
+ checkedUrl(value, `${what} issuer`);
425
+ return value.endsWith("/") ? value.slice(0, -1) : value;
426
+ }
427
+ function baseUrl(value, what) {
428
+ const url = checkedUrl(value, what);
429
+ return url.href.endsWith("/") ? url.href.slice(0, -1) : url.href;
430
+ }
431
+ function publicOrigin(value) {
432
+ const url = checkedUrl(value, "the public URL");
433
+ if (url.pathname !== "/") throw new Error("the public URL must be an origin, with no path");
434
+ return url.origin;
435
+ }
436
+ /** Sign-in configuration, checked: at least one provider, each whole, ids unique, lifetimes sane. */
437
+ function defineSignin(options) {
438
+ if (options.providers.length === 0) throw new Error("sign-in needs at least one provider");
439
+ const seen = /* @__PURE__ */ new Set();
440
+ for (const provider of options.providers) {
441
+ checkProvider(provider);
442
+ if (seen.has(provider.id)) throw new Error(`sign-in provider id "${provider.id}" is used twice`);
443
+ seen.add(provider.id);
444
+ }
445
+ return {
446
+ publicUrl: publicOrigin(options.publicUrl),
447
+ providers: options.providers,
448
+ page: {
449
+ title: options.page?.title ?? "Sign in to coffre",
450
+ note: options.page?.note ?? null
451
+ },
452
+ browserSessionHours: lifetime(options.browserSessionHours, "browserSessionHours", 12, 168),
453
+ cliSessionDays: lifetime(options.cliSessionDays, "cliSessionDays", 30, 365)
454
+ };
455
+ }
456
+ /** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
457
+ function checkProvider(provider) {
458
+ checkId(provider?.id);
459
+ if (typeof provider.issuer !== "string" || provider.issuer.trim() === "") throw new Error(`sign-in provider ${provider.id} needs a stable issuer`);
460
+ if (typeof provider.label !== "string" || provider.label.trim() === "") throw new Error(`sign-in provider ${provider.id} needs a label`);
461
+ if (!BRANDS.includes(provider.brand)) throw new Error(`sign-in provider ${provider.id}'s brand must be one of ${BRANDS.join(", ")}`);
462
+ if (typeof provider.start !== "function" || typeof provider.finish !== "function") throw new Error(`sign-in provider ${provider.id} needs start() and finish()`);
463
+ }
464
+ function lifetime(value, name, fallback, max) {
465
+ if (value === void 0) return fallback;
466
+ if (!Number.isFinite(value) || value <= 0 || value > max) throw new Error(`${name} must be a number above 0 and at most ${max}`);
467
+ return value;
468
+ }
469
+ //#endregion
470
+ //#region src/identity/auth.ts
471
+ function httpsOrigin(what, value) {
472
+ let url;
473
+ try {
474
+ url = new URL(value);
475
+ } catch {
476
+ throw new Error(`${what} must be an absolute URL`);
477
+ }
478
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error(`${what} must be an HTTPS origin, with no path, query or credentials`);
479
+ return url;
480
+ }
481
+ function checkAudience(audience) {
482
+ if (audience.length === 0 || audience.length > 64 || /\s/.test(audience)) throw new Error("the Access audience must be a non-whitespace Access AUD tag of at most 64 characters");
483
+ return audience;
484
+ }
485
+ /**
486
+ * Cloudflare Access in front of coffre: every request carries Access's
487
+ * signed assertion, checked against the team's keys and the application's
488
+ * AUD tag.
489
+ *
490
+ * cloudflareAccess({ teamDomain: 'acme.cloudflareaccess.com', audience: env.ACCESS_AUD })
491
+ */
492
+ function cloudflareAccess(options) {
493
+ const domain = options.teamDomain.trim();
494
+ const issuerUrl = httpsOrigin("the Access team domain", domain.includes("://") ? domain : `https://${domain}`);
495
+ if (issuerUrl.port !== "" || !issuerUrl.hostname.endsWith(".cloudflareaccess.com") || issuerUrl.hostname === "cloudflareaccess.com") throw new Error("the Access team domain must be <team>.cloudflareaccess.com");
496
+ const issuer = issuerUrl.origin;
497
+ const config = {
498
+ mode: "cloudflare",
499
+ access: {
500
+ issuer,
501
+ jwksUrl: `${issuer}/cdn-cgi/access/certs`,
502
+ audience: checkAudience(options.audience.trim())
503
+ }
504
+ };
505
+ return { resolve: () => config };
506
+ }
507
+ /**
508
+ * coffre's own sign-in page, with these providers:
509
+ *
510
+ * signin({ providers: [github({ clientId, clientSecret, organization: 'acme' })] })
511
+ */
512
+ function signin(options) {
513
+ defineSignin({
514
+ ...toSignin(options),
515
+ publicUrl: "https://coffre.invalid"
516
+ });
517
+ return { resolve: (publicUrl) => ({
518
+ mode: "signin",
519
+ signin: defineSignin({
520
+ ...toSignin(options),
521
+ publicUrl
522
+ })
523
+ }) };
524
+ }
525
+ function toSignin(options) {
526
+ return {
527
+ providers: options.providers,
528
+ page: {
529
+ title: options.title,
530
+ note: options.note
531
+ },
532
+ browserSessionHours: options.browserSessionHours,
533
+ cliSessionDays: options.cliSessionDays
534
+ };
535
+ }
536
+ //#endregion
537
+ //#region src/identity/signin/sealed.ts
538
+ const IV_BYTES = 12;
539
+ const VERSION = "v1";
540
+ /**
541
+ * Derive a purpose-bound key from one of coffre's existing 32-byte secrets.
542
+ *
543
+ * HKDF with a distinct `info` string gives a key that is independent of the
544
+ * source for every practical purpose, so the sign-in cookie needs no new
545
+ * secret to provision and a leak of one derived key says nothing about the
546
+ * others.
547
+ */
548
+ function deriveKey(secret, purpose) {
549
+ return Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), `coffre/${purpose}`, 32));
550
+ }
551
+ /**
552
+ * Encrypt a small JSON value for a round trip through the browser.
553
+ *
554
+ * AES-256-GCM authenticates as well as hides, so a tampered or forged value
555
+ * fails to open rather than decoding into something else. The expiry sits
556
+ * inside the ciphertext, which keeps an old cookie from being replayed past
557
+ * it even by a client that ignores Max-Age.
558
+ */
559
+ function seal(key, value, ttlSeconds, now = Date.now()) {
560
+ const iv = randomBytes(IV_BYTES);
561
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
562
+ cipher.setAAD(Buffer.from(VERSION));
563
+ const plaintext = Buffer.from(JSON.stringify({
564
+ exp: now + ttlSeconds * 1e3,
565
+ value
566
+ }));
567
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
568
+ return `${VERSION}.${Buffer.concat([
569
+ iv,
570
+ cipher.getAuthTag(),
571
+ ciphertext
572
+ ]).toString("base64url")}`;
573
+ }
574
+ /** The sealed value, or null when it is malformed, forged, tampered with or expired. */
575
+ function unseal(key, sealed, now = Date.now()) {
576
+ if (!sealed?.startsWith(`${VERSION}.`)) return null;
577
+ const raw = Buffer.from(sealed.slice(3), "base64url");
578
+ if (raw.length <= 28) return null;
579
+ try {
580
+ const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, IV_BYTES));
581
+ decipher.setAAD(Buffer.from(VERSION));
582
+ decipher.setAuthTag(raw.subarray(IV_BYTES, 28));
583
+ const plaintext = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
584
+ const parsed = JSON.parse(plaintext.toString("utf8"));
585
+ if (typeof parsed.exp !== "number" || parsed.exp <= now) return null;
586
+ return parsed.value;
587
+ } catch {
588
+ return null;
589
+ }
590
+ }
591
+ //#endregion
592
+ //#region src/identity/tokens.ts
593
+ const PREFIX = {
594
+ browser: "coffre_web_",
595
+ cli: "coffre_cli_",
596
+ service: "coffre_svc_"
597
+ };
598
+ const TOKEN = /^coffre_(web|cli|svc)_[A-Za-z0-9_-]{43}$/;
599
+ function generateToken(kind) {
600
+ return PREFIX[kind] + randomBytes(32).toString("base64url");
601
+ }
602
+ /** Whether a string has the shape of a coffre token, before any lookup. */
603
+ function isCoffreToken(value) {
604
+ return TOKEN.test(value);
605
+ }
606
+ function hashToken(token) {
607
+ return createHash("sha256").update(token, "utf8").digest();
608
+ }
609
+ /**
610
+ * Enough of a token to recognize it in a list, never enough to use it: the
611
+ * kind prefix and the last four characters. The prefix is matched rather than
612
+ * cut at the last underscore, because base64url uses `_` too.
613
+ */
614
+ function tokenHint(token) {
615
+ return `${Object.values(PREFIX).find((candidate) => token.startsWith(candidate)) ?? ""}…${token.slice(-4)}`;
616
+ }
617
+ //#endregion
618
+ //#region src/identity/types.ts
619
+ /** Access delivers the JWT to the origin in this header. */
620
+ const ACCESS_JWT_HEADER = "cf-access-jwt-assertion";
621
+ //#endregion
622
+ //#region src/identity/verifier.ts
623
+ /**
624
+ * Signature algorithms we accept, as an explicit allowlist.
625
+ *
626
+ * Without this, the algorithm is taken from the attacker-controlled token
627
+ * header, which is how `alg: none` and RS256->HS256 confusion work. jose
628
+ * defaults to permitting whatever the key supports; we do not rely on that.
629
+ */
630
+ const ALLOWED_ALGORITHMS = ["RS256", "ES256"];
631
+ function asNonEmptyString(value) {
632
+ return typeof value === "string" && value.length > 0 ? value : null;
633
+ }
634
+ /**
635
+ * Verifies Cloudflare Access JWTs.
636
+ *
637
+ * Signature and claim verification for the TanStack request boundary. The
638
+ * boundary also rejects `x-middleware-subrequest` before calling this verifier.
639
+ */
640
+ var AccessIdentityVerifier = class {
641
+ #config;
642
+ #jwks;
643
+ constructor(config) {
644
+ this.#config = config;
645
+ this.#jwks = createRemoteJWKSet(new URL(config.jwksUrl), {
646
+ cooldownDuration: 3e4,
647
+ cacheMaxAge: 6e5
648
+ });
649
+ }
650
+ async verify(token) {
651
+ if (typeof token !== "string" || token.length === 0) throw new Error("missing or empty Access token");
652
+ const { payload } = await jwtVerify(token, this.#jwks, {
653
+ issuer: this.#config.issuer,
654
+ audience: this.#config.audience,
655
+ algorithms: [...ALLOWED_ALGORITHMS],
656
+ clockTolerance: this.#config.clockToleranceSeconds ?? 0
657
+ });
658
+ return toPrincipal(payload);
659
+ }
660
+ };
661
+ /**
662
+ * Derive the caller from verified claims.
663
+ *
664
+ * Order matters. A service token is identified by `common_name` and carries an
665
+ * empty `sub`; an identity token carries both `sub` and `email`. Anything else
666
+ * is rejected rather than being allowed through as an authenticated caller
667
+ * with no name -- an audit row with a null actor answers no question worth
668
+ * asking.
669
+ */
670
+ function toPrincipal(claims) {
671
+ const commonName = asNonEmptyString(claims.common_name);
672
+ if (commonName !== null) return {
673
+ type: "service",
674
+ id: commonName,
675
+ commonName
676
+ };
677
+ const email = asNonEmptyString(claims.email);
678
+ const subject = asNonEmptyString(claims.sub);
679
+ if (email !== null && subject !== null) {
680
+ const id = email.toLowerCase();
681
+ return {
682
+ type: "user",
683
+ id,
684
+ email: id,
685
+ subject
686
+ };
687
+ }
688
+ throw new Error("token verified but carries no usable identity: expected common_name (service) or sub+email (user)");
689
+ }
690
+ //#endregion
691
+ export { ACCESS_JWT_HEADER, AccessIdentityVerifier, GitHubSigninProvider, OidcSigninProvider, SigninError, cloudflareAccess, defineSignin, deriveKey, generateToken, github, google, hashToken, isCoffreToken, microsoft, oidc, publicOrigin, seal, signin, toPrincipal, tokenHint, unseal };