@aotter/mantle-cloudflare 0.1.2 → 0.1.3-alpha.2

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/README.md +6 -0
  2. package/dist/auth/conventionalAuth.d.ts +1 -1
  3. package/dist/auth/conventionalAuth.d.ts.map +1 -1
  4. package/dist/auth/conventionalAuth.js +2 -1
  5. package/dist/auth/conventionalAuth.js.map +1 -1
  6. package/dist/auth/createAuth.d.ts +15 -426
  7. package/dist/auth/createAuth.d.ts.map +1 -1
  8. package/dist/auth/createAuth.js +24 -1260
  9. package/dist/auth/createAuth.js.map +1 -1
  10. package/dist/index.d.ts +1 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -2
  13. package/dist/index.js.map +1 -1
  14. package/dist/mount/bootRuntimeOnce.d.ts +1 -1
  15. package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
  16. package/dist/mount/cmsConfig.d.ts +1 -1
  17. package/dist/mount/cmsConfig.d.ts.map +1 -1
  18. package/dist/mount/index.d.ts +1 -1
  19. package/dist/mount/index.d.ts.map +1 -1
  20. package/dist/mount/index.js +1 -1
  21. package/dist/mount/index.js.map +1 -1
  22. package/dist/mount/mountMcp.d.ts.map +1 -1
  23. package/dist/mount/mountMcp.js +62 -32
  24. package/dist/mount/mountMcp.js.map +1 -1
  25. package/dist/mount/mountPublicRoutes.js +1 -1
  26. package/dist/mount/mountPublicRoutes.js.map +1 -1
  27. package/dist/mount/mountRuntimeEndpoints.d.ts.map +1 -1
  28. package/dist/mount/mountRuntimeEndpoints.js +5 -11
  29. package/dist/mount/mountRuntimeEndpoints.js.map +1 -1
  30. package/dist/mount/resolveCaller.d.ts +32 -2
  31. package/dist/mount/resolveCaller.d.ts.map +1 -1
  32. package/dist/mount/resolveCaller.js +71 -13
  33. package/dist/mount/resolveCaller.js.map +1 -1
  34. package/dist/worker/createMantleWorker.d.ts +1 -1
  35. package/dist/worker/createMantleWorker.d.ts.map +1 -1
  36. package/package.json +6 -8
  37. package/dist/auth/ConsoleEmailSender.d.ts +0 -15
  38. package/dist/auth/ConsoleEmailSender.d.ts.map +0 -1
  39. package/dist/auth/ConsoleEmailSender.js +0 -19
  40. package/dist/auth/ConsoleEmailSender.js.map +0 -1
  41. package/dist/auth/appleClientSecret.d.ts +0 -72
  42. package/dist/auth/appleClientSecret.d.ts.map +0 -1
  43. package/dist/auth/appleClientSecret.js +0 -113
  44. package/dist/auth/appleClientSecret.js.map +0 -1
  45. package/dist/auth/emailTemplates.d.ts +0 -7
  46. package/dist/auth/emailTemplates.d.ts.map +0 -1
  47. package/dist/auth/emailTemplates.js +0 -27
  48. package/dist/auth/emailTemplates.js.map +0 -1
@@ -1,1268 +1,32 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
- import { betterAuth, } from "better-auth";
3
- import { getMigrations } from "better-auth/db/migration";
1
+ import { createMantleAuth } from "@aotter/mantle-auth";
4
2
  import { D1DatabaseDriver } from "../bindings/D1DatabaseDriver.js";
5
- import { createDpopReplayStore, enforceDpopBinding, isDpopBindingError, parseAccessTokenAuthorization, verifyJwsAccessToken, } from "better-auth/oauth2";
6
- import { admin, emailOTP, jwt, magicLink, } from "better-auth/plugins";
7
- import { createAccessControl } from "better-auth/plugins/access";
8
- import { defaultStatements } from "better-auth/plugins/admin/access";
9
- import { genericOAuth } from "better-auth/plugins/generic-oauth";
10
- import { splitSetCookieHeader } from "better-auth/cookies";
11
- import { oauthProvider } from "@better-auth/oauth-provider";
12
- import { mcp } from "@better-auth/mcp";
13
- import { cimd } from "@better-auth/cimd";
14
- import { decodeMemberCursor, encodeMemberCursor, } from "@aotter/mantle-admin";
15
- import { signInCodeEmail, signInLinkEmail, staffInvitationEmail } from "./emailTemplates.js";
16
- import { STAFF_ROLES } from "@aotter/mantle-spec";
17
- // Better Auth 1.7.2 initializes its shared stores asynchronously. Seed them
18
- // before any request can be canceled; the accessor-identity regression test
19
- // pins this version-specific integration to the stores Better Auth uses.
20
- const betterAuthGlobalKey = Symbol.for("better-auth:global");
21
- const betterAuthGlobals = globalThis;
22
- const betterAuthGlobal = betterAuthGlobals[betterAuthGlobalKey] ??= {
23
- version: "",
24
- epoch: 0,
25
- context: {},
26
- };
27
- betterAuthGlobal.context.requestStateAsyncStorage ??= new AsyncLocalStorage();
28
- betterAuthGlobal.context.endpointContextAsyncStorage ??= new AsyncLocalStorage();
29
- betterAuthGlobal.context.adapterAsyncStorage ??= new AsyncLocalStorage();
30
- export { decodeMemberCursor, encodeMemberCursor };
31
- export { STAFF_ROLES };
32
- /**
33
- * Set lookup for "is this role string a staff role?" — handlers/MCP
34
- * gating reach for this every request, so the Set form is worth the
35
- * one-time allocation over `STAFF_ROLES.includes(x)`.
36
- */
37
- export const STAFF_ROLE_SET = new Set(STAFF_ROLES);
38
- function normalizeAuthBasePath(basePath) {
39
- if (basePath === undefined)
40
- return "/api/auth";
41
- const trimmed = basePath.trim();
42
- if (trimmed === "")
43
- return "/api/auth";
44
- if (!trimmed.startsWith("/")) {
45
- throw new Error("createAuth: basePath must start with '/'.");
46
- }
47
- if (trimmed === "/") {
48
- throw new Error("createAuth: basePath must not be '/'.");
49
- }
50
- if (trimmed.endsWith("/")) {
51
- return trimmed.replace(/\/+$/, "");
52
- }
53
- return trimmed;
54
- }
55
- function normalizeAuthErrorURL(errorURL, baseURL) {
56
- const base = new URL(baseURL);
57
- const resolved = new URL(errorURL ?? "/", base);
58
- if (resolved.origin !== base.origin) {
59
- throw new Error("createAuth: errorURL must be same-origin with baseURL.");
60
- }
61
- return `${resolved.pathname}${resolved.search}`;
62
- }
63
- /** @internal Exported for regression tests. */
64
- export function normalizeAuthResponseCookies(response) {
65
- const setCookie = response.headers.get("set-cookie");
66
- if (!setCookie)
67
- return response;
68
- const values = response.headers.getSetCookie?.() ??
69
- [setCookie];
70
- const cookies = values.flatMap(splitSetCookieHeader);
71
- if (cookies.length < 2)
72
- return response;
73
- const headers = new Headers(response.headers);
74
- headers.delete("set-cookie");
75
- for (const cookie of cookies)
76
- headers.append("set-cookie", cookie);
77
- return new Response(response.body, {
78
- status: response.status,
79
- statusText: response.statusText,
80
- headers,
81
- });
82
- }
83
- const ac = createAccessControl(defaultStatements);
84
- const ownerAc = ac.newRole({
85
- user: defaultStatements.user,
86
- session: defaultStatements.session,
87
- });
88
- const editorAc = ac.newRole({
89
- user: ["list", "ban", "get", "update"],
90
- session: ["list", "revoke"],
91
- });
92
- const contributorAc = ac.newRole({
93
- user: ["list", "get"],
94
- session: [],
95
- });
96
- const userAc = ac.newRole({
97
- user: [],
98
- session: [],
99
- });
100
- function withGithubLogin(options) {
101
- return typeof options === "function"
102
- ? async () => withGithubLoginMapper(await options())
103
- : withGithubLoginMapper(options);
104
- }
105
- function withGithubLoginMapper(options) {
106
- const developerMapper = options.mapProfileToUser;
107
- return {
108
- ...options,
109
- mapProfileToUser: async (profile) => ({
110
- ...(developerMapper ? await developerMapper(profile) : {}),
111
- githubLogin: profile.login,
112
- }),
113
- };
114
- }
115
- /** @internal exported for unit tests; not part of the public API. */
116
- export function buildSocialProviders(methods) {
117
- const out = {};
118
- // Duplicate-provider guard: catch the case where two `social`
119
- // methods declare the same `provider` id. Better Auth would
120
- // silently keep the latter (Record overwrite); for SDK adopters —
121
- // and especially for the upcoming feature-overlay path where a
122
- // feature can contribute auth methods into the same starter's
123
- // `methods[]` array — that silent overwrite is a footgun. Throw at
124
- // construction with a clear message so the conflict surfaces
125
- // before the first sign-in.
126
- const seenProviders = new Set();
127
- for (const method of methods) {
128
- if (method.kind !== "social")
129
- continue;
130
- if (seenProviders.has(method.provider)) {
131
- throw new Error(`createAuth: social provider '${method.provider}' is registered more than once; ` +
132
- `each provider can have only one methods[] entry. Remove the redundant entry or pick a different provider.`);
133
- }
134
- seenProviders.add(method.provider);
135
- out[method.provider] = method.provider === "github"
136
- ? withGithubLogin(method.options)
137
- : method.options;
138
- }
139
- return out;
140
- }
141
- /** @internal exported for unit tests; not part of the public API. */
142
- export function buildGenericOAuthProviders(methods) {
143
- const seenProviderIds = new Set();
144
- const socialProviderIds = new Set(methods.flatMap((method) => method.kind === "social" ? [method.provider] : []));
145
- const out = [];
146
- for (const method of methods) {
147
- if (method.kind !== "oauth")
148
- continue;
149
- if (socialProviderIds.has(method.options.providerId)) {
150
- throw new Error(`createAuth: OAuth providerId '${method.options.providerId}' conflicts with a registered social provider id. Provider ids must be unique across methods[].`);
151
- }
152
- if (seenProviderIds.has(method.options.providerId)) {
153
- throw new Error(`createAuth: OAuth provider '${method.options.providerId}' is registered more than once; ` +
154
- `each providerId can have only one methods[] entry.`);
155
- }
156
- seenProviderIds.add(method.options.providerId);
157
- if (!method.options.discoveryUrl && !(method.options.authorizationUrl && method.options.tokenUrl)) {
158
- throw new Error(`createAuth: OAuth provider '${method.options.providerId}' needs either discoveryUrl or both authorizationUrl and tokenUrl.`);
159
- }
160
- out.push(method.options);
161
- }
162
- return out;
163
- }
164
- /**
165
- * First tag off `Accept-Language`, quality values ignored. Locale
166
- * contract lives in `EmailSender.ts`.
167
- */
168
- /** @internal exported for unit tests; not part of the public API. */
169
- export function pickLocale(req, fallback) {
170
- const header = req?.headers.get("accept-language");
171
- if (!header)
172
- return fallback;
173
- const first = header.split(",")[0]?.split(";")[0]?.trim();
174
- return first && first.length > 0 ? first : fallback;
175
- }
176
- const MAGIC_LINK_DEFAULT_EXPIRES_SECONDS = 900;
177
- function buildMagicLinkPlugin(method) {
178
- const fallback = method.fallbackLocale ?? "en";
179
- return magicLink({
180
- storeToken: "hashed",
181
- expiresIn: MAGIC_LINK_DEFAULT_EXPIRES_SECONDS,
182
- ...method.options,
183
- // Returned synchronously — same fire-and-forget contract as
184
- // email-otp via `advanced.backgroundTasks.handler`. The body
185
- // carries the click-URL; SDK doesn't ship a template, the
186
- // sender can render plain text or richer HTML.
187
- sendMagicLink: (data, ctx) => {
188
- const locale = pickLocale(ctx?.request, fallback);
189
- return method.sender.send({
190
- to: data.email,
191
- ...signInLinkEmail(data.url),
192
- locale,
193
- category: "auth.magic-link.sign-in",
194
- });
195
- },
196
- });
197
- }
198
- function buildEmailOTPPlugin(method) {
199
- const fallback = method.fallbackLocale ?? "en";
200
- return emailOTP({
201
- storeOTP: "hashed",
202
- ...method.options,
203
- // Return synchronously — the promise is fire-and-forget via the
204
- // `advanced.backgroundTasks.handler` we wire in `buildAuth`. For
205
- // `email-verification` / `forget-password` types Better Auth only
206
- // calls this when the user exists, so awaiting would leak account
207
- // existence through response latency. See Better Auth's own
208
- // sendVerificationOTP docstring + reviewer finding in PR #161.
209
- sendVerificationOTP: (data, ctx) => {
210
- const locale = pickLocale(ctx?.request, fallback);
211
- return method.sender.send({
212
- to: data.email,
213
- ...signInCodeEmail(data.otp),
214
- locale,
215
- category: `auth.email-otp.${data.type}`,
216
- });
217
- },
218
- });
219
- }
220
- /**
221
- * Cross-check bootstrap rule against registered methods. Catches the
222
- * silent-no-op case where the rule's discriminator can never match
223
- * any signal a registered method actually produces — e.g.
224
- * `match: "github-login"` with no `github` provider registered. Throws
225
- * at construction so vibe-coders see the mistake before the first
226
- * sign-in attempt.
227
- *
228
- * `match: "email"` is permissive — every Better Auth method that
229
- * creates a user populates `email`, including GitHub (via the
230
- * upstream profile). No registration constraint to enforce.
231
- */
232
- /** @internal exported for unit tests; not part of the public API. */
233
- export function validateBootstrap(rule, methods) {
234
- if (rule.match === "github-login") {
235
- const hasGithub = methods.some((method) => (method.kind === "social" && method.provider === "github") ||
236
- (method.kind === "oauth" && method.options.providerId === "github"));
237
- if (!hasGithub) {
238
- throw new Error("createAuth: bootstrapOwner.match='github-login' but no GitHub provider is registered. " +
239
- "Register social GitHub or a trusted OAuth providerId='github', or switch to email matching.");
240
- }
241
- }
242
- }
243
- /** @internal exported for unit tests; not part of the public API. */
244
- export function shouldPromoteToOwner(rule, user) {
245
- const target = rule.value.trim().toLowerCase();
246
- switch (rule.match) {
247
- case "github-login":
248
- return !!user.githubLogin && user.githubLogin.toLowerCase() === target;
249
- case "email":
250
- return !!user.email && user.email.toLowerCase() === target;
251
- }
252
- }
253
- /** @internal Keep provider-owned GitHub logins off user-controlled auth paths. */
254
- export function guardGithubLoginProfile(user, context, methods) {
255
- if (!("githubLogin" in user))
256
- return undefined;
257
- const providerId = context?.path === "/callback/:id" ? context.params?.id : null;
258
- const trusted = typeof providerId === "string" && methods.some((method) => method.kind === "social"
259
- ? context?.path === "/callback/:id" && method.provider === "github" && providerId === "github"
260
- : method.kind === "oauth" &&
261
- context?.path === "/callback/:id" &&
262
- method.options.providerId === providerId &&
263
- Boolean(method.options.mapProfileToUser));
264
- return trusted ? undefined : { data: { githubLogin: null } };
265
- }
266
- /**
267
- * Find the at-most-one method of `kind`. Throws when adopters register
268
- * the same kind twice — Better Auth's plugin layer accepts duplicates
269
- * silently, which would mask the intent at boot. One helper covers
270
- * every singleton-shaped method (email-otp, magic-link, future ones).
271
- */
272
- function pickSingleton(methods, kind) {
273
- const matches = methods.filter((m) => m.kind === kind);
274
- if (matches.length > 1) {
275
- throw new Error(`createAuth: more than one \`${kind}\` method registered. Combine into one.`);
276
- }
277
- return matches[0];
278
- }
279
- /**
280
- * Origins each registered social provider needs in
281
- * `trustedOrigins`. Adding a provider that demands an extra
282
- * `trustedOrigins` entry = adding a row here. Apple is the only one
283
- * in 1.6.9 that hard-requires this; if Better Auth ever drops the
284
- * requirement, the entry stays harmless (Better Auth dedupes).
285
- */
286
- const SOCIAL_PROVIDER_TRUSTED_ORIGINS = {
287
- apple: ["https://appleid.apple.com"],
288
- };
289
- export function buildTrustedOriginsFor(methods, configured = []) {
290
- const origins = methods.flatMap((m) => m.kind === "social" ? SOCIAL_PROVIDER_TRUSTED_ORIGINS[m.provider] ?? [] : []);
291
- return [...new Set([...origins, ...configured])];
292
- }
293
- /**
294
- * Apple uses `response_mode=form_post` — Apple POSTs cross-site to
295
- * our callback. The OAuth state cookie must have `sameSite: "none"`
296
- * (and `secure: true`, which browsers require alongside) or the
297
- * cookie won't ride the POST and Better Auth raises a state mismatch.
298
- * Other providers don't need this. We only auto-set when Apple is
299
- * registered AND the adopter hasn't already specified
300
- * `defaultCookieAttributes.sameSite` themselves.
301
- */
302
- function methodsRequireSameSiteNone(methods) {
303
- return methods.some((m) => m.kind === "social" && m.provider === "apple");
304
- }
305
- /** @internal exported for provider-option mapping tests. */
306
- export function buildOAuthProviderOptions(config) {
3
+ /** Ingress header Cloudflare overwrites. `createAuth` always passes this
4
+ * to the portable package; callers do not configure it. */
5
+ export const CLOUDFLARE_CLIENT_IP_HEADERS = ["cf-connecting-ip"];
6
+ // Stable Cloudflare-facing surface. Explicit names only — do not star-export
7
+ // `@aotter/mantle-auth` (that would leak the portable constructor and make
8
+ // the Cloudflare header look like a portable default).
9
+ export { STAFF_ROLE_SET, STAFF_ROLES, buildGenericOAuthProviders, buildOAuthProviderOptions, buildSocialProviders, buildTrustedOriginsFor, createSetupIncompleteAuth, decodeMemberCursor, encodeMemberCursor, getProviderAccessTokenForRequest, guardGithubLoginProfile, hasEmailAuthSurface, hashEmailOtp, isSetupIncompleteAuth, mapRegisteredOAuthClient, normalizeAuthBasePath, normalizeAuthResponseCookies, pickLocale, shouldPromoteToOwner, validateBootstrap, verifyOAuthJwt, verifyOAuthJwtWithLocalJwks, } from "@aotter/mantle-auth";
10
+ /** Workers KV as a Better Auth session cache. Key naming and TTL policy stay
11
+ * in `@aotter/mantle-auth`; this only stores what it is handed. */
12
+ export function kvSessionCache(kv) {
307
13
  return {
308
- loginPage: config.loginPage,
309
- consentPage: config.consentPage,
310
- ...(config.scopes ? { scopes: [...config.scopes] } : {}),
311
- ...(config.resources
312
- ? { resources: [...config.resources] }
313
- : {}),
314
- ...(config.clientRegistrationDefaultResources
315
- ? { clientRegistrationDefaultResources: [...config.clientRegistrationDefaultResources] }
316
- : {}),
317
- ...(config.mcpResource
318
- ? {
319
- clientRegistrationClientSecretExpiration: "90d",
320
- allowPublicClientPrelogin: true,
321
- }
322
- : {}),
323
- ...(config.allowDynamicClientRegistration !== undefined
324
- ? { allowDynamicClientRegistration: config.allowDynamicClientRegistration }
325
- : {}),
326
- ...(config.allowUnauthenticatedClientRegistration !== undefined
327
- ? {
328
- allowUnauthenticatedClientRegistration: config.allowUnauthenticatedClientRegistration,
329
- }
330
- : {}),
331
- ...(config.clientRegistrationDefaultScopes
332
- ? {
333
- clientRegistrationDefaultScopes: [
334
- ...config.clientRegistrationDefaultScopes,
335
- ],
336
- }
337
- : {}),
338
- ...(config.clientRegistrationAllowedScopes
339
- ? {
340
- clientRegistrationAllowedScopes: [
341
- ...config.clientRegistrationAllowedScopes,
342
- ],
343
- }
344
- : {}),
345
- ...(config.cachedTrustedClients
346
- ? { cachedTrustedClients: new Set(config.cachedTrustedClients) }
347
- : {}),
348
- ...(config.clientPrivileges
349
- ? { clientPrivileges: config.clientPrivileges }
350
- : {}),
14
+ get: (key) => kv.get(key),
15
+ set: (key, value, ttlSeconds) => kv.put(key, value, ttlSeconds ? { expirationTtl: ttlSeconds } : undefined),
16
+ delete: (key) => kv.delete(key),
351
17
  };
352
18
  }
353
- function buildAuth(config) {
354
- if (config.hostOnlyCookies && (new URL(config.baseURL).protocol !== "https:" || config.crossSubDomainCookies?.enabled)) {
355
- throw new Error("createAuth: hostOnlyCookies requires HTTPS and cannot share cookies across subdomains.");
356
- }
357
- if (config.methods.length === 0 && !config.plugins?.length) {
358
- throw new Error("createAuth: methods[] is empty — register an AuthMethodConfig or native Better Auth plugin so staff can sign in.");
359
- }
360
- if (config.bootstrapOwner) {
361
- validateBootstrap(config.bootstrapOwner, config.methods);
362
- }
363
- const socialProviders = buildSocialProviders(config.methods);
364
- const genericOAuthProviders = buildGenericOAuthProviders(config.methods);
365
- const bootstrap = config.bootstrapOwner;
366
- const emailOtpMethod = pickSingleton(config.methods, "email-otp");
367
- const magicLinkMethod = pickSingleton(config.methods, "magic-link");
368
- const providerOptions = config.oauthProvider
369
- ? buildOAuthProviderOptions(config.oauthProvider)
370
- : null;
371
- // Workers may not set NODE_ENV. Explicitly enable the provider's route
372
- // limits too (notably anonymous DCR: 5/minute).
373
- // ponytail: memory limits are per isolate; use an ingress rate-limit rule
374
- // when a deployment needs a distributed abuse quota.
375
- const hasEmailMethod = !!(emailOtpMethod || magicLinkMethod);
376
- const rateLimit = {
377
- window: 60,
378
- max: hasEmailMethod ? 10 : 100,
379
- ...config.rateLimit,
380
- enabled: true,
381
- storage: "memory",
382
- };
383
- // `trustedOrigins`: per-provider auto-origins (Apple needs
384
- // `https://appleid.apple.com`) plus adopter-owned first-party
385
- // origins for flows such as hosted auth across trusted subdomains.
386
- const trustedOrigins = buildTrustedOriginsFor(config.methods, config.trustedOrigins);
387
- const sdkPlugins = [
388
- admin({
389
- defaultRole: "user",
390
- adminRoles: [...STAFF_ROLES],
391
- ac,
392
- roles: {
393
- owner: ownerAc,
394
- editor: editorAc,
395
- contributor: contributorAc,
396
- user: userAc,
397
- },
398
- }),
399
- ...(genericOAuthProviders.length > 0
400
- ? [
401
- genericOAuth({
402
- config: genericOAuthProviders,
403
- }),
404
- ]
405
- : []),
406
- ...(emailOtpMethod ? [buildEmailOTPPlugin(emailOtpMethod)] : []),
407
- ...(magicLinkMethod ? [buildMagicLinkPlugin(magicLinkMethod)] : []),
408
- ...(config.oauthProvider && providerOptions
409
- ? [
410
- jwt(),
411
- config.oauthProvider.mcpResource
412
- ? mcp({
413
- ...providerOptions,
414
- resource: config.oauthProvider.mcpResource,
415
- extensions: [{
416
- claims: {
417
- accessToken: ({ referenceId }) => ({ mantle_consent_id: referenceId ?? null }),
418
- },
419
- }],
420
- })
421
- : oauthProvider(providerOptions),
422
- ...(config.oauthProvider.mcpResource
423
- ? [
424
- cimd({
425
- // Cloudflare's `global_fetch_strictly_public` flag is the
426
- // runtime network boundary: resolution and connection stay
427
- // on the public Internet. Better Auth owns timeout, limits,
428
- // validation, caching, and redirect rejection above it.
429
- // Workers does not implement `redirect: "error"`; `manual`
430
- // exposes 3xx responses so Better Auth can reject them.
431
- fetchClientMetadataResource: (input, init) => fetch(input, { ...init, redirect: "manual" }),
432
- metadataProfile: "mcp-2026-07-28",
433
- }),
434
- ]
435
- : []),
436
- ]
437
- : []),
438
- ];
439
- const plugins = [...sdkPlugins, ...(config.plugins ?? [])];
440
- const pluginIds = new Set();
441
- for (const plugin of plugins) {
442
- if (pluginIds.has(plugin.id)) {
443
- throw new Error(`createAuth: Better Auth plugin '${plugin.id}' is registered more than once. Remove the duplicate plugin.`);
444
- }
445
- pluginIds.add(plugin.id);
446
- }
447
- // `user.additionalFields`: SDK owns `githubLogin` only.
448
- const userConfig = {
449
- additionalFields: {
450
- githubLogin: {
451
- type: "string",
452
- required: false,
453
- // Better Auth applies `input: false` to trusted provider profiles too.
454
- // Database hooks below keep this field provider-only instead.
455
- input: true,
456
- },
457
- },
458
- };
459
- // `advanced`: SDK owns `backgroundTasks`. Apple auto-injects
460
- // `defaultCookieAttributes.sameSite: "none"` because Apple's
461
- // `form_post` callback is cross-site and a `lax` cookie won't ride
462
- // it (Better Auth's default raises a state-mismatch).
463
- const appleNeedsCrossSite = methodsRequireSameSiteNone(config.methods);
464
- const advancedConfig = {
465
- // Cloudflare overwrites this at ingress. Never trust client-controlled XFF.
466
- ipAddress: { ipAddressHeaders: ["cf-connecting-ip"] },
467
- ...(appleNeedsCrossSite
468
- ? {
469
- // Browsers require `secure: true` whenever `sameSite: "none"`.
470
- defaultCookieAttributes: { secure: true, sameSite: "none" },
471
- }
472
- : {}),
473
- ...(config.crossSubDomainCookies
474
- ? { crossSubDomainCookies: config.crossSubDomainCookies }
475
- : {}),
476
- ...(config.cookiePrefix ? { cookiePrefix: config.cookiePrefix } : {}),
477
- ...(config.hostOnlyCookies ? {
478
- // Better Auth prepends __Secure- otherwise. Secure is set explicitly below.
479
- useSecureCookies: false,
480
- cookiePrefix: `__Host-${config.cookiePrefix || "better-auth"}`,
481
- crossSubDomainCookies: { enabled: false },
482
- defaultCookieAttributes: { secure: true, path: "/", ...(appleNeedsCrossSite ? { sameSite: "none" } : {}) },
483
- } : {}),
484
- // Fire-and-forget hook closes the user-existence timing oracle
485
- // on OTP send — see § "Auth as contract" notes in ADR-0014.
486
- backgroundTasks: {
487
- handler: (p) => {
488
- p.catch((err) => {
489
- // eslint-disable-next-line no-console
490
- console.error("[better-auth backgroundTask]", err);
491
- });
492
- },
493
- },
494
- };
495
- // `databaseHooks`: SDK owns `user.create.after` for bootstrap-owner
496
- // promotion (when `bootstrapOwner` is configured).
497
- const sdkUserCreateAfter = async (user) => {
498
- if (!bootstrap)
499
- return;
500
- const u = user;
501
- if (!shouldPromoteToOwner(bootstrap, u))
502
- return;
503
- // Atomic check-then-promote: the `NOT EXISTS` is a GLOBAL guard —
504
- // it asks "does any user already hold a staff role?". The whole
505
- // statement runs as one D1 op, so two concurrent first signups
506
- // can't both win: the loser's UPDATE finds a staff user in the
507
- // subquery and silently writes zero rows.
508
- const placeholders = STAFF_ROLES.map(() => "?").join(",");
509
- const result = await config.database
510
- .prepare(`UPDATE user SET role = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM user WHERE role IN (${placeholders}))`)
511
- .bind("owner", u.id, ...STAFF_ROLES)
512
- .run();
513
- if ((result.meta?.changes ?? 0) === 0) {
514
- // Operator-visible signal that the rule matched but a prior
515
- // staff user already exists — otherwise the silent no-op makes a
516
- // misconfigured bootstrap rule indistinguishable from a working
517
- // first-promotion.
518
- console.warn(`[bootstrap] user ${u.id} matched bootstrapOwner rule but promotion was blocked — a staff user already exists.`);
519
- }
520
- };
521
- const databaseHooks = {
522
- user: {
523
- create: {
524
- before: async (user, context) => guardGithubLoginProfile(user, context, config.methods),
525
- after: sdkUserCreateAfter,
526
- },
527
- update: {
528
- before: async (user, context) => guardGithubLoginProfile(user, context, config.methods),
529
- },
530
- },
531
- verification: {
532
- create: {
533
- before: async (verification) => {
534
- if (!config.oauthProvider?.mcpResource)
535
- return;
536
- let value;
537
- try {
538
- value = JSON.parse(verification.value);
539
- }
540
- catch {
541
- return; // OTPs and other verification values are not OAuth grants.
542
- }
543
- if (value?.type !== "authorization_code" || typeof value.userId !== "string" ||
544
- typeof value.query?.client_id !== "string")
545
- return;
546
- const consent = await config.database
547
- .prepare("SELECT id FROM oauthConsent WHERE userId = ? AND clientId = ? LIMIT 1")
548
- .bind(value.userId, value.query.client_id)
549
- .first();
550
- // Better Auth carries referenceId from this code through every
551
- // refresh rotation. Never rebind an old lineage to a new consent.
552
- return { data: { value: JSON.stringify({ ...value, referenceId: consent?.id ?? "" }) } };
553
- },
554
- },
555
- },
556
- };
557
- const secondaryStorage = config.sessionCacheKv ? {
558
- get: (key) => key.startsWith("verification:")
559
- ? Promise.resolve(null)
560
- : config.sessionCacheKv.get(`better-auth:${key}`),
561
- set: (key, value, ttl) => key.startsWith("verification:")
562
- ? Promise.resolve()
563
- : config.sessionCacheKv.put(`better-auth:${key}`, value, ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl)) } : undefined),
564
- delete: (key) => key.startsWith("verification:")
565
- ? Promise.resolve()
566
- : config.sessionCacheKv.delete(`better-auth:${key}`),
567
- // Verification and rate limiting stay in D1/memory below. Fail loudly if
568
- // Better Auth starts routing either atomic operation through this adapter.
569
- getAndDelete: async () => { throw new Error("Better Auth KV getAndDelete is disabled"); },
570
- increment: async () => { throw new Error("Better Auth KV increment is disabled"); },
571
- } : undefined;
572
- return betterAuth({
573
- database: config.database,
574
- secondaryStorage,
575
- session: { storeSessionInDatabase: true },
576
- verification: { storeInDatabase: true },
577
- secret: config.secret,
578
- baseURL: config.baseURL,
579
- basePath: normalizeAuthBasePath(config.basePath),
580
- onAPIError: { errorURL: normalizeAuthErrorURL(config.errorURL, config.baseURL) },
581
- socialProviders,
582
- user: userConfig,
583
- rateLimit,
584
- trustedOrigins,
585
- advanced: advancedConfig,
586
- plugins,
587
- databaseHooks,
588
- });
589
- }
590
- const SETUP_INCOMPLETE_AUTHS = new WeakSet();
591
- /** True only for the fail-closed facade returned by createSetupIncompleteAuth. */
592
- export function isSetupIncompleteAuth(auth) {
593
- return SETUP_INCOMPLETE_AUTHS.has(auth);
594
- }
595
- const LEGACY_DCR_TTL_MS = 90 * 24 * 60 * 60 * 1_000;
596
- const LEGACY_DCR_CLEANUP_INTERVAL_MS = 60 * 60 * 1_000;
19
+ /** Better Auth on Cloudflare: D1 for state, optional Workers KV for session
20
+ * reads. Wiring only — the auth surface itself is host-neutral. Always
21
+ * supplies `cf-connecting-ip` as the rate-limit identity header. */
597
22
  export function createAuth(config) {
598
- const auth = buildAuth(config);
599
- const ready = auth.$context.then(() => undefined);
600
- // Observe eager initialization even for low-level callers; keep the original
601
- // rejection available to callers awaiting ready and Better Auth's handlers.
602
- void ready.catch(() => { });
603
- // Auth owns its schema even when content uses a different semantic store.
604
- // Keep schema work lazy: static/plan-only routes must not prepare tables.
605
- let schemaReady = null;
606
- const prepareAuth = () => schemaReady ??= (async () => {
607
- const context = await auth.$context;
608
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(context.tables)));
609
- const id = `auth-schema:1:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("")}`;
610
- try {
611
- const applied = await config.database.prepare("SELECT id FROM _migrations WHERE id = ?").bind(id).first();
612
- if (applied?.id === id)
613
- return;
614
- }
615
- catch (error) {
616
- // A new, auth-only database has no legacy Runtime ledger yet.
617
- if (!/no such table: _migrations/i.test(String(error)))
618
- throw error;
619
- }
620
- const { compileMigrations } = await getMigrations(context.options);
621
- await new D1DatabaseDriver(config.database).migrations.runAll([{
622
- id,
623
- description: "Selected Better Auth schema and staff-role access path",
624
- sql: `${await compileMigrations()}\nCREATE INDEX IF NOT EXISTS user_role_idx ON user (role) WHERE role IS NOT NULL;`,
625
- }]);
626
- })().catch(error => { schemaReady = null; throw error; });
627
- const basePath = normalizeAuthBasePath(config.basePath);
628
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
- const api = auth.api;
630
- const localJwksCacheKey = {};
631
- let dpopReplayStore = null;
632
- const getDpopReplayStore = async () => {
633
- if (dpopReplayStore)
634
- return dpopReplayStore;
635
- const context = await auth.$context;
636
- dpopReplayStore = createDpopReplayStore(context.internalAdapter);
637
- return dpopReplayStore;
638
- };
639
- const verifyAccessToken = config.oauthProvider
640
- ? async (token, audience) => {
641
- await prepareAuth();
642
- const context = await auth.$context;
643
- const claims = await verifyOAuthJwtWithLocalJwks(token, audience, context.baseURL, async () => api.getJwks(), localJwksCacheKey);
644
- if (config.oauthProvider?.mcpResource && (audience === config.oauthProvider.mcpResource || config.oauthProvider.resources?.includes(audience))) {
645
- await assertActiveUserGrant(config.database, claims, audience);
646
- }
647
- return claims;
648
- }
649
- : null;
650
- let nextDcrCleanupAt = 0;
651
- const pruneExpiredDynamicClients = async () => {
652
- const now = Date.now();
653
- if (!config.oauthProvider?.mcpResource || now < nextDcrCleanupAt)
654
- return;
655
- // Set before awaiting so concurrent OAuth requests do not fan out writes.
656
- nextDcrCleanupAt = now + LEGACY_DCR_CLEANUP_INTERVAL_MS;
657
- try {
658
- await config.database
659
- .prepare("DELETE FROM oauthClient WHERE clientDiscoveryId IS NULL AND userId IS NULL AND referenceId IS NULL AND createdAt < ?")
660
- .bind(new Date(now - LEGACY_DCR_TTL_MS).toISOString())
661
- .run();
662
- }
663
- catch (error) {
664
- // Cleanup is bounded storage hygiene, not an authorization decision.
665
- console.error("[better-auth] legacy DCR cleanup failed", error);
666
- }
667
- };
668
- return {
669
- basePath,
670
- ready,
671
- ...(config.oauthProvider?.mcpResource
672
- ? { mcpResource: config.oauthProvider.mcpResource }
673
- : {}),
674
- handler: async (request) => {
675
- await prepareAuth();
676
- const pathname = new URL(request.url).pathname;
677
- if (pathname.startsWith(`${basePath}/oauth2/`)) {
678
- await pruneExpiredDynamicClients();
679
- }
680
- return normalizeAuthResponseCookies(await auth.handler(request));
681
- },
682
- getSession: async (request) => {
683
- let session;
684
- try {
685
- session = await api.getSession({ headers: request.headers });
686
- }
687
- catch {
688
- // Existing sessions resolve from KV without paying the schema-ledger read.
689
- // A fresh database with a stale cookie prepares once, then retries safely.
690
- await prepareAuth();
691
- session = await api.getSession({ headers: request.headers });
692
- }
693
- return session
694
- ? {
695
- ...session,
696
- user: {
697
- ...session.user,
698
- // Secondary storage can outlive or be shared across a D1 replacement.
699
- // Its user snapshot is therefore not authoritative for staff access.
700
- ...(!config.sessionCacheKv && Object.hasOwn(session.user, "role")
701
- ? { roleCurrent: true }
702
- : {}),
703
- },
704
- }
705
- : null;
706
- },
707
- getUserRole: async (userId) => {
708
- await prepareAuth();
709
- const row = await config.database
710
- .prepare("SELECT role FROM user WHERE id = ? LIMIT 1")
711
- .bind(userId)
712
- .first();
713
- return row?.role ?? null;
714
- },
715
- getUser: async (userId) => {
716
- await prepareAuth();
717
- const row = await config.database
718
- .prepare("SELECT id, email, name, image, role, githubLogin, emailVerified, createdAt FROM user WHERE id = ? LIMIT 1")
719
- .bind(userId)
720
- .first();
721
- if (!row)
722
- return null;
723
- const createdAt = new Date(row.createdAt);
724
- if (Number.isNaN(createdAt.getTime())) {
725
- throw new Error("Auth user row has an invalid createdAt timestamp.");
726
- }
727
- return {
728
- id: row.id,
729
- email: row.email,
730
- name: row.name,
731
- image: row.image,
732
- role: row.role,
733
- githubLogin: row.githubLogin,
734
- emailVerified: row.emailVerified !== 0,
735
- createdAt,
736
- };
737
- },
738
- getProviderAccessToken: async (request, providerId) => {
739
- await prepareAuth();
740
- const session = await api.getSession({ headers: request.headers });
741
- const userId = session?.user?.id;
742
- const account = userId
743
- ? await config.database
744
- .prepare("SELECT id FROM account WHERE userId = ? AND providerId = ? LIMIT 1")
745
- .bind(userId, providerId)
746
- .first()
747
- : null;
748
- if (!account) {
749
- throw new Error(`getProviderAccessToken: provider '${providerId}' is not linked to the current user.`);
750
- }
751
- return getProviderAccessTokenForRequest(api, request, account.id, providerId);
752
- },
753
- verifyOAuthAccessToken: async (tokenOrRequest, options) => {
754
- return verifyOAuthJwt(tokenOrRequest, options, verifyAccessToken, getDpopReplayStore);
755
- },
756
- getOAuthConsentRequest: async (request) => {
757
- if (!config.oauthProvider)
758
- return null;
759
- const url = new URL(request.url);
760
- const clientId = url.searchParams.get("client_id");
761
- if (!clientId || !url.search)
762
- return null;
763
- await prepareAuth();
764
- const oauthQuery = url.search.slice(1);
765
- const client = await api.getOAuthClientPublicPrelogin({
766
- headers: request.headers,
767
- body: { client_id: clientId, oauth_query: oauthQuery },
768
- });
769
- const redirectUri = url.searchParams.get("redirect_uri") ??
770
- (Array.isArray(client?.redirect_uris) &&
771
- typeof client.redirect_uris[0] === "string"
772
- ? client.redirect_uris[0]
773
- : "");
774
- return {
775
- clientName: typeof client?.client_name === "string"
776
- ? client.client_name
777
- : clientId,
778
- redirectUri,
779
- scopes: (url.searchParams.get("scope") ?? "")
780
- .split(/\s+/u)
781
- .filter(Boolean),
782
- oauthQuery,
783
- };
784
- },
785
- completeOAuthConsent: async (request, accept) => {
786
- if (!config.oauthProvider) {
787
- throw new Error("completeOAuthConsent: oauthProvider is not configured.");
788
- }
789
- const form = await request.formData();
790
- const oauthQuery = form.get("oauth_query");
791
- if (typeof oauthQuery !== "string" || oauthQuery.length === 0) {
792
- throw new Error("completeOAuthConsent: oauth_query is missing.");
793
- }
794
- await prepareAuth();
795
- const headers = new Headers(request.headers);
796
- headers.set("content-type", "application/json");
797
- headers.delete("content-length");
798
- const response = await auth.handler(new Request(new URL(`${basePath}/oauth2/consent`, request.url), {
799
- method: "POST",
800
- headers,
801
- body: JSON.stringify({ accept, oauth_query: oauthQuery }),
802
- }));
803
- if (!response.ok) {
804
- throw new Error(`completeOAuthConsent: Better Auth returned ${response.status}.`);
805
- }
806
- const result = await response.json();
807
- if (!result || typeof result.url !== "string") {
808
- throw new Error("completeOAuthConsent: Better Auth omitted the redirect URL.");
809
- }
810
- return result.url;
811
- },
812
- ...(config.oauthProvider
813
- ? {
814
- listOAuthConsents: async (userId) => {
815
- await prepareAuth();
816
- const result = await config.database
817
- .prepare(`SELECT consent.id, consent.clientId,
818
- COALESCE(client.name, consent.clientId) AS clientName,
819
- consent.scopes
820
- FROM oauthConsent AS consent
821
- LEFT JOIN oauthClient AS client ON client.clientId = consent.clientId
822
- WHERE consent.userId = ?
823
- ORDER BY consent.updatedAt DESC, consent.id ASC`)
824
- .bind(userId)
825
- .all();
826
- return (result.results ?? []).map((row) => ({
827
- id: row.id,
828
- clientId: row.clientId,
829
- clientName: row.clientName,
830
- scopes: parseStoredStringArray(row.scopes) ?? [],
831
- }));
832
- },
833
- revokeOAuthConsent: async (userId, consentId) => {
834
- await prepareAuth();
835
- const consent = await config.database
836
- .prepare("SELECT clientId FROM oauthConsent WHERE id = ? AND userId = ? LIMIT 1")
837
- .bind(consentId, userId)
838
- .first();
839
- if (!consent)
840
- return false;
841
- const revokedAt = new Date().toISOString();
842
- await config.database.batch([
843
- config.database
844
- .prepare("UPDATE oauthRefreshToken SET revoked = ? WHERE userId = ? AND clientId = ? AND revoked IS NULL")
845
- .bind(revokedAt, userId, consent.clientId),
846
- config.database
847
- .prepare("UPDATE oauthAccessToken SET revoked = ? WHERE userId = ? AND clientId = ? AND revoked IS NULL")
848
- .bind(revokedAt, userId, consent.clientId),
849
- config.database
850
- .prepare(`DELETE FROM verification
851
- WHERE CASE WHEN json_valid(value) THEN json_extract(value, '$.type') END = 'authorization_code'
852
- AND CASE WHEN json_valid(value) THEN json_extract(value, '$.userId') END = ?
853
- AND CASE WHEN json_valid(value) THEN json_extract(value, '$.query.client_id') END = ?`)
854
- .bind(userId, consent.clientId),
855
- config.database
856
- .prepare("DELETE FROM oauthConsent WHERE userId = ? AND clientId = ?")
857
- .bind(userId, consent.clientId),
858
- ]);
859
- return true;
860
- },
861
- }
862
- : {}),
863
- methods: config.methods.map((m) => {
864
- switch (m.kind) {
865
- case "social":
866
- return { kind: "social", provider: m.provider };
867
- case "oauth":
868
- return {
869
- kind: "oauth",
870
- providerId: m.options.providerId,
871
- ...(m.displayName ? { displayName: m.displayName } : {}),
872
- };
873
- case "email-otp":
874
- case "magic-link":
875
- return { kind: m.kind };
876
- }
877
- }),
878
- listLinkedAccounts: async (userId) => {
879
- await prepareAuth();
880
- const result = await config.database
881
- .prepare("SELECT id, providerId, accountId, createdAt, updatedAt FROM account WHERE userId = ? ORDER BY createdAt ASC, id ASC")
882
- .bind(userId)
883
- .all();
884
- return (result.results ?? []).map((row) => ({
885
- id: row.id,
886
- providerId: row.providerId,
887
- accountId: row.accountId,
888
- createdAt: new Date(row.createdAt),
889
- updatedAt: new Date(row.updatedAt),
890
- }));
891
- },
892
- unlinkAccount: async (userId, providerId) => {
893
- await prepareAuth();
894
- const result = await config.database
895
- .prepare("DELETE FROM account WHERE userId = ? AND providerId = ?")
896
- .bind(userId, providerId)
897
- .run();
898
- return (result.meta?.changes ?? 0) > 0;
899
- },
900
- listUsers: async () => {
901
- await prepareAuth();
902
- const placeholders = STAFF_ROLES.map(() => "?").join(",");
903
- const result = await config.database
904
- .prepare(`SELECT id, email, name, role, githubLogin, emailVerified, createdAt FROM user WHERE role IN (${placeholders}) ORDER BY createdAt ASC, id ASC`)
905
- .bind(...STAFF_ROLES)
906
- .all();
907
- return (result.results ?? []).map((row) => ({
908
- id: row.id,
909
- email: row.email,
910
- name: row.name,
911
- role: row.role,
912
- githubLogin: row.githubLogin,
913
- emailVerified: row.emailVerified !== 0,
914
- createdAt: new Date(row.createdAt),
915
- }));
916
- },
917
- listMembers: async ({ search, cursor, cursorDirection = "forward", limit }) => {
918
- await prepareAuth();
919
- const parsedCursor = cursor ? decodeMemberCursor(cursor) : null;
920
- const backward = cursorDirection === "backward";
921
- const conditions = [
922
- `(role IS NULL OR role NOT IN (${STAFF_ROLES.map(() => "?").join(",")}))`,
923
- ];
924
- const bindings = [...STAFF_ROLES];
925
- const term = search?.trim().toLowerCase();
926
- if (term) {
927
- conditions.push("(LOWER(id) LIKE ? ESCAPE '\\' OR LOWER(name) LIKE ? ESCAPE '\\' OR LOWER(email) LIKE ? ESCAPE '\\')");
928
- const like = `%${term.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
929
- bindings.push(like, like, like);
930
- }
931
- if (parsedCursor) {
932
- const operator = backward ? "<" : ">";
933
- conditions.push(`(createdAt ${operator} ? OR (createdAt = ? AND id ${operator} ?))`);
934
- bindings.push(parsedCursor[0], parsedCursor[0], parsedCursor[1]);
935
- }
936
- bindings.push(limit + 1);
937
- const result = await config.database
938
- .prepare(`SELECT id, email, name, emailVerified, createdAt FROM user WHERE ${conditions.join(" AND ")} ORDER BY createdAt ${backward ? "DESC" : "ASC"}, id ${backward ? "DESC" : "ASC"} LIMIT ?`)
939
- .bind(...bindings)
940
- .all();
941
- const rows = (result.results ?? []).slice(0, limit);
942
- if (backward)
943
- rows.reverse();
944
- const items = rows.map((row) => ({
945
- id: row.id,
946
- email: row.email,
947
- name: row.name,
948
- emailVerified: row.emailVerified !== 0,
949
- createdAt: new Date(row.createdAt),
950
- }));
951
- const hasMore = (result.results?.length ?? 0) > limit;
952
- return {
953
- items,
954
- previousCursor: (backward ? hasMore : Boolean(parsedCursor)) && rows[0]
955
- ? encodeMemberCursor(rows[0].createdAt, rows[0].id)
956
- : null,
957
- nextCursor: (backward ? Boolean(parsedCursor) : hasMore) && rows.at(-1)
958
- ? encodeMemberCursor(rows.at(-1).createdAt, rows.at(-1).id)
959
- : null,
960
- };
961
- },
962
- setUserRole: async (userId, role) => {
963
- if (role !== null && !STAFF_ROLE_SET.has(role)) {
964
- throw new Error(`setUserRole: '${role}' is not a staff role — expected one of [${STAFF_ROLES.join(", ")}] or null.`);
965
- }
966
- await prepareAuth();
967
- if (config.sessionCacheKv) {
968
- const context = await auth.$context;
969
- return !!await context.internalAdapter.updateUser(userId, {
970
- role,
971
- updatedAt: new Date(),
972
- });
973
- }
974
- const result = await config.database
975
- .prepare("UPDATE user SET role = ?, updatedAt = ? WHERE id = ?")
976
- .bind(role, new Date().toISOString(), userId)
977
- .run();
978
- return (result.meta?.changes ?? 0) > 0;
979
- },
980
- inviteUser: async (email, role) => {
981
- if (!STAFF_ROLE_SET.has(role)) {
982
- throw new Error(`inviteUser: '${role}' is not a staff role — expected one of [${STAFF_ROLES.join(", ")}].`);
983
- }
984
- await prepareAuth();
985
- const normalized = email.trim().toLowerCase();
986
- const existing = await config.database
987
- .prepare("SELECT id FROM user WHERE email = ? LIMIT 1")
988
- .bind(normalized)
989
- .first();
990
- if (existing)
991
- return { kind: "exists", id: existing.id };
992
- const id = generateUserId();
993
- const now = new Date().toISOString();
994
- // `name` defaults to the address's local part — Better Auth
995
- // requires NOT NULL, and the invitee's real display name arrives
996
- // with their first sign-in (social) or stays editable later.
997
- await config.database
998
- .prepare("INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt, role) VALUES (?, ?, ?, 0, ?, ?, ?)")
999
- .bind(id, normalized.split("@")[0] ?? normalized, normalized, now, now, role)
1000
- .run();
1001
- return { kind: "created", id };
1002
- },
1003
- ...(config.staffInvitationSender ? {
1004
- sendStaffInvitation: async (email, role) => {
1005
- const normalized = email.trim().toLowerCase();
1006
- await config.staffInvitationSender.send({
1007
- to: normalized,
1008
- ...staffInvitationEmail(role, new URL("/admin/sign-in", config.baseURL).href),
1009
- locale: "en",
1010
- category: "auth.staff-invitation",
1011
- });
1012
- },
1013
- } : {}),
1014
- revokeInvite: async (userId) => {
1015
- await prepareAuth();
1016
- const result = await config.database
1017
- .prepare("DELETE FROM user WHERE id = ? AND emailVerified = 0 AND NOT EXISTS (SELECT 1 FROM account WHERE account.userId = user.id)")
1018
- .bind(userId)
1019
- .run();
1020
- return (result.meta?.changes ?? 0) > 0;
1021
- },
1022
- registerOAuthClient: async (input) => {
1023
- if (!config.oauthProvider) {
1024
- throw new Error("registerOAuthClient: oauthProvider is not configured.");
1025
- }
1026
- await prepareAuth();
1027
- const created = await api.adminCreateOAuthClient({
1028
- headers: new Headers(input.requestHeaders),
1029
- body: {
1030
- redirect_uris: [...input.redirectUris],
1031
- ...(input.scope ? { scope: input.scope.join(" ") } : {}),
1032
- ...(input.clientName ? { client_name: input.clientName } : {}),
1033
- ...(input.clientUri ? { client_uri: input.clientUri } : {}),
1034
- ...(input.logoUri ? { logo_uri: input.logoUri } : {}),
1035
- ...(input.contacts ? { contacts: [...input.contacts] } : {}),
1036
- ...(input.tosUri ? { tos_uri: input.tosUri } : {}),
1037
- ...(input.policyUri ? { policy_uri: input.policyUri } : {}),
1038
- ...(input.postLogoutRedirectUris
1039
- ? { post_logout_redirect_uris: [...input.postLogoutRedirectUris] }
1040
- : {}),
1041
- ...(input.tokenEndpointAuthMethod
1042
- ? { token_endpoint_auth_method: input.tokenEndpointAuthMethod }
1043
- : {}),
1044
- ...(input.grantTypes ? { grant_types: [...input.grantTypes] } : {}),
1045
- ...(input.responseTypes ? { response_types: [...input.responseTypes] } : {}),
1046
- ...(input.applicationType ? { application_type: input.applicationType } : {}),
1047
- ...(input.skipConsent !== undefined ? { skip_consent: input.skipConsent } : {}),
1048
- ...(input.enableEndSession !== undefined
1049
- ? { enable_end_session: input.enableEndSession }
1050
- : {}),
1051
- ...(input.requirePKCE !== undefined ? { require_pkce: input.requirePKCE } : {}),
1052
- ...(input.subjectType ? { subject_type: input.subjectType } : {}),
1053
- ...(input.metadata ? { metadata: input.metadata } : {}),
1054
- },
1055
- });
1056
- return mapRegisteredOAuthClient(created);
1057
- },
1058
- };
1059
- }
1060
- /**
1061
- * Safe Auth facade for first-deploy/bootstrap windows where an
1062
- * adopter's public Worker should boot before staff sign-in providers
1063
- * have been provisioned. Auth-gated routes should still be blocked by
1064
- * the consumer; this facade never authenticates anyone.
1065
- */
1066
- export function createSetupIncompleteAuth(options = {}) {
1067
- const basePath = normalizeAuthBasePath(options.basePath);
1068
- const message = options.message ?? "Auth is not configured yet.";
1069
- const response = options.response ??
1070
- (() => Response.json({ error: "setup_incomplete", message }, { status: 503, headers: { "cache-control": "private, no-store" } }));
1071
- const auth = {
1072
- basePath,
1073
- handler: async () => response(),
1074
- getSession: async () => null,
1075
- getUserRole: async () => null,
1076
- getUser: async () => null,
1077
- getProviderAccessToken: async () => {
1078
- throw new Error(message);
1079
- },
1080
- verifyOAuthAccessToken: async () => ({
1081
- ok: false,
1082
- status: 401,
1083
- reason: "invalid-token",
1084
- }),
1085
- getOAuthConsentRequest: async () => null,
1086
- completeOAuthConsent: async () => {
1087
- throw new Error(message);
1088
- },
1089
- methods: [],
1090
- listLinkedAccounts: async () => [],
1091
- unlinkAccount: async () => false,
1092
- listUsers: async () => [],
1093
- listMembers: async () => ({ items: [], previousCursor: null, nextCursor: null }),
1094
- setUserRole: async () => false,
1095
- inviteUser: async () => {
1096
- throw new Error(message);
1097
- },
1098
- revokeInvite: async () => false,
1099
- registerOAuthClient: async () => {
1100
- throw new Error(message);
1101
- },
1102
- };
1103
- SETUP_INCOMPLETE_AUTHS.add(auth);
1104
- return auth;
1105
- }
1106
- /** @internal exported to pin the session-bound Better Auth request and
1107
- * secret-minimizing response mapping. */
1108
- export async function getProviderAccessTokenForRequest(api, request, accountId, providerId) {
1109
- const value = (await api.getAccessToken({
1110
- headers: request.headers,
1111
- body: { accountId },
1112
- }));
1113
- if (typeof value?.accessToken !== "string") {
1114
- throw new Error(`getProviderAccessToken: provider '${providerId}' returned no access token.`);
1115
- }
1116
- return {
1117
- accessToken: value.accessToken,
1118
- ...(value.accessTokenExpiresAt instanceof Date
1119
- ? { accessTokenExpiresAt: value.accessTokenExpiresAt }
1120
- : {}),
1121
- scopes: Array.isArray(value.scopes)
1122
- ? value.scopes.filter((scope) => typeof scope === "string")
1123
- : [],
1124
- };
1125
- }
1126
- function scopesFromClaim(value) {
1127
- if (typeof value === "string")
1128
- return value.split(/\s+/).filter(Boolean);
1129
- if (Array.isArray(value)) {
1130
- return value.filter((scope) => typeof scope === "string");
1131
- }
1132
- return [];
1133
- }
1134
- function parseStoredStringArray(value) {
1135
- if (typeof value !== "string")
1136
- return null;
1137
- try {
1138
- const parsed = JSON.parse(value);
1139
- return Array.isArray(parsed) && parsed.every((item) => typeof item === "string")
1140
- ? parsed
1141
- : null;
1142
- }
1143
- catch {
1144
- return null;
1145
- }
1146
- }
1147
- async function assertActiveUserGrant(database, claims, audience) {
1148
- const userId = claims["sub"];
1149
- const clientId = claims["azp"];
1150
- const sessionId = claims["sid"];
1151
- const consentId = claims["mantle_consent_id"];
1152
- if (typeof userId !== "string" ||
1153
- typeof clientId !== "string" ||
1154
- typeof sessionId !== "string" ||
1155
- typeof consentId !== "string" || !consentId) {
1156
- throw new Error("OAuth token is not bound to a user session.");
1157
- }
1158
- const result = await database
1159
- .prepare("SELECT c.resources, c.scopes FROM oauthConsent AS c " +
1160
- "JOIN session AS s ON s.id = ? AND s.userId = c.userId AND s.expiresAt > ? " +
1161
- "WHERE c.id = ? AND c.userId = ? AND c.clientId = ?")
1162
- .bind(sessionId, new Date().toISOString(), consentId, userId, clientId)
1163
- .first();
1164
- const tokenScopes = scopesFromClaim(claims["scope"]);
1165
- const resources = parseStoredStringArray(result?.resources);
1166
- const scopes = parseStoredStringArray(result?.scopes);
1167
- const active = resources?.includes(audience) === true && scopes !== null &&
1168
- tokenScopes.every((scope) => scopes.includes(scope));
1169
- if (!active)
1170
- throw new Error("OAuth authorization grant is no longer active.");
1171
- }
1172
- /** @internal exported to keep same-Worker OAuth verification off the network. */
1173
- export function verifyOAuthJwtWithLocalJwks(token, audience, issuer, jwksFetch, jwksCacheKey) {
1174
- return verifyJwsAccessToken(token, {
1175
- jwksFetch,
1176
- ...(jwksCacheKey ? { jwksCacheKey } : {}),
1177
- verifyOptions: { audience, issuer },
23
+ const { database, sessionCacheKv, ...rest } = config;
24
+ return createMantleAuth({
25
+ ...rest,
26
+ database,
27
+ driver: new D1DatabaseDriver(database),
28
+ sessionCache: sessionCacheKv ? kvSessionCache(sessionCacheKv) : undefined,
29
+ ipAddressHeaders: CLOUDFLARE_CLIENT_IP_HEADERS,
1178
30
  });
1179
31
  }
1180
- /** @internal exported to pin the stable facade's normalization and
1181
- * 401/403 contract independently of Better Auth network/JWKS I/O. */
1182
- export async function verifyOAuthJwt(tokenOrRequest, options, verify, getDpopReplayStore) {
1183
- const request = typeof tokenOrRequest === "string" ? null : tokenOrRequest;
1184
- const authorization = parseAccessTokenAuthorization(request?.headers.get("authorization") ?? `Bearer ${tokenOrRequest}`);
1185
- const token = authorization?.token;
1186
- // JWT compact serialization has exactly three non-empty parts.
1187
- // Reject opaque tokens before any network/JWKS work.
1188
- if (!verify ||
1189
- !token ||
1190
- token.split(".").length !== 3 ||
1191
- token.split(".").some((part) => part.length === 0)) {
1192
- return { ok: false, status: 401, reason: "invalid-token" };
1193
- }
1194
- try {
1195
- const claims = await verify(token, options.audience);
1196
- await enforceDpopBinding({
1197
- payload: claims,
1198
- authorization,
1199
- proofJwt: request?.headers.get("dpop"),
1200
- method: request?.method ?? "GET",
1201
- url: request?.url ?? options.audience,
1202
- ...(request && authorization.scheme === "DPoP" && getDpopReplayStore
1203
- ? { replayStore: await getDpopReplayStore() }
1204
- : {}),
1205
- });
1206
- if (typeof claims["sub"] !== "string" || claims["sub"].length === 0) {
1207
- return { ok: false, status: 401, reason: "invalid-token" };
1208
- }
1209
- const scopes = scopesFromClaim(claims["scope"]);
1210
- const missingScopes = (options.scopes ?? []).filter((scope) => !scopes.includes(scope));
1211
- if (missingScopes.length > 0) {
1212
- return {
1213
- ok: false,
1214
- status: 403,
1215
- reason: "insufficient-scope",
1216
- missingScopes,
1217
- };
1218
- }
1219
- return {
1220
- ok: true,
1221
- userId: claims["sub"],
1222
- clientId: typeof claims["azp"] === "string" ? claims["azp"] : null,
1223
- credentialId: typeof claims["jti"] === "string" ? claims["jti"] : null,
1224
- scopes,
1225
- };
1226
- }
1227
- catch (error) {
1228
- if (isDpopBindingError(error)) {
1229
- return { ok: false, status: 401, reason: "invalid-dpop-proof" };
1230
- }
1231
- return { ok: false, status: 401, reason: "invalid-token" };
1232
- }
1233
- }
1234
- /** @internal exported to pin secret-minimizing DCR response mapping. */
1235
- export function mapRegisteredOAuthClient(value) {
1236
- const row = value;
1237
- if (!row.client_id || !Array.isArray(row.redirect_uris)) {
1238
- throw new Error("registerOAuthClient: Better Auth returned an invalid client.");
1239
- }
1240
- return {
1241
- clientId: row.client_id,
1242
- ...(row.client_secret && row.token_endpoint_auth_method !== "none"
1243
- ? { clientSecret: row.client_secret }
1244
- : {}),
1245
- redirectUris: row.redirect_uris,
1246
- ...(row.scope ? { scope: row.scope.split(" ").filter(Boolean) } : {}),
1247
- ...(row.client_name ? { clientName: row.client_name } : {}),
1248
- ...(row.client_uri ? { clientUri: row.client_uri } : {}),
1249
- ...(row.token_endpoint_auth_method
1250
- ? { tokenEndpointAuthMethod: row.token_endpoint_auth_method }
1251
- : {}),
1252
- ...(row.application_type ? { applicationType: row.application_type } : {}),
1253
- };
1254
- }
1255
- /** Random 32-char alphanumeric id, shaped like Better Auth's own user
1256
- * ids so invited rows are indistinguishable from organically-created
1257
- * ones. Modulo bias over 62 symbols is irrelevant here — ids need
1258
- * uniqueness, not uniform entropy. */
1259
- function generateUserId() {
1260
- const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1261
- const bytes = new Uint8Array(32);
1262
- crypto.getRandomValues(bytes);
1263
- let id = "";
1264
- for (const b of bytes)
1265
- id += alphabet[b % alphabet.length];
1266
- return id;
1267
- }
1268
32
  //# sourceMappingURL=createAuth.js.map