@aotter/mantle-cloudflare 0.1.2 → 0.1.3-alpha.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/README.md +6 -0
- package/dist/auth/conventionalAuth.d.ts +1 -1
- package/dist/auth/conventionalAuth.d.ts.map +1 -1
- package/dist/auth/conventionalAuth.js +2 -1
- package/dist/auth/conventionalAuth.js.map +1 -1
- package/dist/auth/createAuth.d.ts +15 -426
- package/dist/auth/createAuth.d.ts.map +1 -1
- package/dist/auth/createAuth.js +24 -1260
- package/dist/auth/createAuth.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/mount/bootRuntimeOnce.d.ts +1 -1
- package/dist/mount/bootRuntimeOnce.d.ts.map +1 -1
- package/dist/mount/cmsConfig.d.ts +1 -1
- package/dist/mount/cmsConfig.d.ts.map +1 -1
- package/dist/mount/index.d.ts +1 -1
- package/dist/mount/index.d.ts.map +1 -1
- package/dist/mount/index.js +1 -1
- package/dist/mount/index.js.map +1 -1
- package/dist/mount/mountMcp.d.ts.map +1 -1
- package/dist/mount/mountMcp.js +62 -32
- package/dist/mount/mountMcp.js.map +1 -1
- package/dist/mount/mountPublicRoutes.js +1 -1
- package/dist/mount/mountPublicRoutes.js.map +1 -1
- package/dist/mount/mountRuntimeEndpoints.d.ts.map +1 -1
- package/dist/mount/mountRuntimeEndpoints.js +5 -11
- package/dist/mount/mountRuntimeEndpoints.js.map +1 -1
- package/dist/mount/resolveCaller.d.ts +32 -2
- package/dist/mount/resolveCaller.d.ts.map +1 -1
- package/dist/mount/resolveCaller.js +71 -13
- package/dist/mount/resolveCaller.js.map +1 -1
- package/dist/worker/createMantleWorker.d.ts +1 -1
- package/dist/worker/createMantleWorker.d.ts.map +1 -1
- package/package.json +6 -8
- package/dist/auth/ConsoleEmailSender.d.ts +0 -15
- package/dist/auth/ConsoleEmailSender.d.ts.map +0 -1
- package/dist/auth/ConsoleEmailSender.js +0 -19
- package/dist/auth/ConsoleEmailSender.js.map +0 -1
- package/dist/auth/appleClientSecret.d.ts +0 -72
- package/dist/auth/appleClientSecret.d.ts.map +0 -1
- package/dist/auth/appleClientSecret.js +0 -113
- package/dist/auth/appleClientSecret.js.map +0 -1
- package/dist/auth/emailTemplates.d.ts +0 -7
- package/dist/auth/emailTemplates.d.ts.map +0 -1
- package/dist/auth/emailTemplates.js +0 -27
- package/dist/auth/emailTemplates.js.map +0 -1
package/dist/auth/createAuth.js
CHANGED
|
@@ -1,1268 +1,32 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|