@better-auth/core 1.7.2 → 1.7.3

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 (78) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/context/global.mjs +1 -1
  3. package/dist/context/transaction.mjs +3 -0
  4. package/dist/db/adapter/atomic-fallback.mjs +134 -0
  5. package/dist/db/adapter/factory.mjs +22 -4
  6. package/dist/db/adapter/index.d.mts +15 -11
  7. package/dist/db/get-tables.mjs +1 -9
  8. package/dist/db/index.d.mts +2 -2
  9. package/dist/db/index.mjs +2 -2
  10. package/dist/db/internal.d.mts +3 -1
  11. package/dist/db/internal.mjs +3 -1
  12. package/dist/db/schema/account.d.mts +2 -13
  13. package/dist/db/schema/account.mjs +1 -19
  14. package/dist/db/schema-check.d.mts +48 -0
  15. package/dist/db/schema-check.mjs +80 -0
  16. package/dist/db/schema-diff.d.mts +104 -0
  17. package/dist/db/schema-diff.mjs +154 -0
  18. package/dist/instrumentation/tracer.mjs +1 -1
  19. package/dist/oauth2/index.d.mts +2 -2
  20. package/dist/oauth2/oauth-provider.d.mts +0 -10
  21. package/dist/oauth2/token-endpoint-auth.d.mts +26 -2
  22. package/dist/oauth2/token-endpoint-auth.mjs +11 -0
  23. package/dist/social-providers/apple.d.mts +0 -1
  24. package/dist/social-providers/apple.mjs +0 -1
  25. package/dist/social-providers/cloudflare.d.mts +132 -0
  26. package/dist/social-providers/cloudflare.mjs +85 -0
  27. package/dist/social-providers/cognito.d.mts +0 -1
  28. package/dist/social-providers/cognito.mjs +0 -1
  29. package/dist/social-providers/facebook.d.mts +0 -1
  30. package/dist/social-providers/facebook.mjs +0 -1
  31. package/dist/social-providers/google.d.mts +0 -1
  32. package/dist/social-providers/google.mjs +0 -1
  33. package/dist/social-providers/index.d.mts +53 -21
  34. package/dist/social-providers/index.mjs +3 -1
  35. package/dist/social-providers/line.d.mts +0 -1
  36. package/dist/social-providers/line.mjs +0 -1
  37. package/dist/social-providers/microsoft-entra-id.d.mts +0 -3
  38. package/dist/social-providers/microsoft-entra-id.mjs +0 -1
  39. package/dist/social-providers/paybin.d.mts +0 -1
  40. package/dist/social-providers/paybin.mjs +0 -1
  41. package/dist/social-providers/paypal.d.mts +3 -11
  42. package/dist/social-providers/paypal.mjs +20 -47
  43. package/dist/social-providers/reddit.mjs +17 -22
  44. package/dist/social-providers/tiktok.d.mts +1 -0
  45. package/dist/social-providers/tiktok.mjs +14 -9
  46. package/dist/types/context.d.mts +11 -0
  47. package/dist/types/init-options.d.mts +11 -0
  48. package/dist/utils/ip.mjs +11 -9
  49. package/package.json +2 -2
  50. package/src/context/transaction.ts +5 -0
  51. package/src/db/adapter/atomic-fallback.ts +237 -0
  52. package/src/db/adapter/factory.ts +33 -17
  53. package/src/db/adapter/index.ts +15 -11
  54. package/src/db/get-tables.ts +1 -14
  55. package/src/db/index.ts +0 -2
  56. package/src/db/internal.ts +19 -0
  57. package/src/db/schema/account.ts +3 -22
  58. package/src/db/schema/user.ts +1 -1
  59. package/src/db/schema-check.ts +107 -0
  60. package/src/db/schema-diff.ts +270 -0
  61. package/src/oauth2/index.ts +2 -0
  62. package/src/oauth2/oauth-provider.ts +0 -10
  63. package/src/oauth2/token-endpoint-auth.ts +39 -6
  64. package/src/social-providers/apple.ts +0 -1
  65. package/src/social-providers/cloudflare.ts +221 -0
  66. package/src/social-providers/cognito.ts +0 -1
  67. package/src/social-providers/facebook.ts +0 -1
  68. package/src/social-providers/google.ts +0 -1
  69. package/src/social-providers/index.ts +3 -0
  70. package/src/social-providers/line.ts +0 -1
  71. package/src/social-providers/microsoft-entra-id.ts +0 -1
  72. package/src/social-providers/paybin.ts +0 -1
  73. package/src/social-providers/paypal.ts +30 -71
  74. package/src/social-providers/reddit.ts +27 -36
  75. package/src/social-providers/tiktok.ts +18 -13
  76. package/src/types/context.ts +11 -0
  77. package/src/types/init-options.ts +11 -0
  78. package/src/utils/ip.ts +13 -9
@@ -0,0 +1,270 @@
1
+ import { BetterAuthError } from "../error";
2
+ import type { BetterAuthOptions } from "../types";
3
+ import { getAuthTables } from "./get-tables";
4
+ import type { DBFieldAttribute } from "./type";
5
+
6
+ /**
7
+ * A column as the database, or an ORM schema definition, reports it.
8
+ */
9
+ export interface IntrospectedColumn {
10
+ name: string;
11
+ nullable: boolean;
12
+ /**
13
+ * The store fills the column when an insert omits it.
14
+ */
15
+ hasDefault: boolean;
16
+ }
17
+
18
+ /**
19
+ * A table as the database, or an ORM schema definition, reports it.
20
+ */
21
+ export interface IntrospectedTable {
22
+ name: string;
23
+ /**
24
+ * The schema the table lives in, when the store has schemas.
25
+ */
26
+ schema?: string | undefined;
27
+ columns: IntrospectedColumn[];
28
+ }
29
+
30
+ /**
31
+ * The tables Better Auth writes, keyed the way the store addresses them:
32
+ * physical table name, then physical column name. A table that manages its
33
+ * own storage is excluded from migrations and from this comparison.
34
+ */
35
+ export type ExpectedSchema = Record<
36
+ string,
37
+ {
38
+ fields: Record<string, DBFieldAttribute>;
39
+ idColumn?: string | undefined;
40
+ disableMigrations?: boolean | undefined;
41
+ /**
42
+ * The schema the table is addressed in. Unset when the store has no
43
+ * schemas or the table is found by name alone.
44
+ */
45
+ schema?: string | undefined;
46
+ }
47
+ >;
48
+
49
+ /**
50
+ * The tables this configuration writes, keyed the way the adapter addresses
51
+ * them. Tables that share a physical name are merged into one entry.
52
+ */
53
+ export function getExpectedSchema(
54
+ options: BetterAuthOptions,
55
+ { usePlural = false }: { usePlural?: boolean | undefined } = {},
56
+ ): ExpectedSchema {
57
+ const expected: ExpectedSchema = {};
58
+ for (const table of Object.values(getAuthTables(options))) {
59
+ const name = usePlural ? `${table.modelName}s` : table.modelName;
60
+ const entry = (expected[name] ??= { fields: {}, disableMigrations: true });
61
+ for (const [key, field] of Object.entries(table.fields)) {
62
+ entry.fields[field.fieldName || key] = field;
63
+ }
64
+ entry.disableMigrations =
65
+ entry.disableMigrations && !!table.disableMigrations;
66
+ }
67
+ return expected;
68
+ }
69
+
70
+ export type SchemaFinding =
71
+ | { kind: "missing-table"; table: string }
72
+ | { kind: "missing-column"; table: string; column: string }
73
+ | { kind: "unexpected-required-column"; table: string; column: string };
74
+
75
+ /**
76
+ * How the schema reaches the store, which decides the fix each finding names.
77
+ */
78
+ export type SchemaSource = "database" | "drizzle" | "prisma";
79
+
80
+ /**
81
+ * Compares the tables Better Auth writes with what the store holds.
82
+ *
83
+ * A table or column Better Auth writes must exist. A column Better Auth does
84
+ * not write must accept an insert that omits it, so it is nullable or carries
85
+ * a default. Otherwise every insert into that table fails with a constraint
86
+ * error that says nothing about why the schema drifted.
87
+ */
88
+ export function diffSchema(
89
+ expected: ExpectedSchema,
90
+ actual: readonly IntrospectedTable[],
91
+ ): SchemaFinding[] {
92
+ const findings: SchemaFinding[] = [];
93
+ for (const [tableName, table] of Object.entries(expected)) {
94
+ if (table.disableMigrations) continue;
95
+ const actualTable = actual.find(
96
+ (candidate) =>
97
+ candidate.name === tableName &&
98
+ (table.schema === undefined || candidate.schema === table.schema),
99
+ );
100
+ if (!actualTable) {
101
+ findings.push({ kind: "missing-table", table: tableName });
102
+ continue;
103
+ }
104
+ const written = new Set([
105
+ table.idColumn ?? "id",
106
+ ...Object.keys(table.fields),
107
+ ]);
108
+ for (const column of written) {
109
+ if (!actualTable.columns.some((candidate) => candidate.name === column)) {
110
+ findings.push({ kind: "missing-column", table: tableName, column });
111
+ }
112
+ }
113
+ for (const column of actualTable.columns) {
114
+ if (written.has(column.name) || column.nullable || column.hasDefault) {
115
+ continue;
116
+ }
117
+ findings.push({
118
+ kind: "unexpected-required-column",
119
+ table: tableName,
120
+ column: column.name,
121
+ });
122
+ }
123
+ }
124
+ return findings;
125
+ }
126
+
127
+ const applyHint: Record<SchemaSource, string> = {
128
+ database: "Run `npx auth migrate` to add it.",
129
+ drizzle:
130
+ "Run `npx auth generate` to refresh the Drizzle schema, then apply it with your migration tool.",
131
+ prisma:
132
+ "Run `npx auth generate` to refresh the Prisma schema, then run `prisma migrate`.",
133
+ };
134
+
135
+ const relaxHint: Record<SchemaSource, string> = {
136
+ database: "Drop the column, make it nullable, or give it a database default.",
137
+ drizzle:
138
+ "Remove it from the Drizzle schema, make it nullable, or give it a default, then apply the change with your migration tool.",
139
+ prisma:
140
+ "Remove it from the Prisma schema, make it optional, or give it a default, then run `prisma migrate`.",
141
+ };
142
+
143
+ const sourceLabel: Record<SchemaSource, string> = {
144
+ database: "Database",
145
+ drizzle: "Drizzle",
146
+ prisma: "Prisma",
147
+ };
148
+
149
+ /**
150
+ * One finding as a sentence that names the change resolving it.
151
+ */
152
+ export function formatSchemaFinding(
153
+ finding: SchemaFinding,
154
+ source: SchemaSource,
155
+ ): string {
156
+ switch (finding.kind) {
157
+ case "missing-table":
158
+ return `Table "${finding.table}" is missing. ${applyHint[source]}`;
159
+ case "missing-column":
160
+ return `Column "${finding.column}" is missing from table "${finding.table}". ${applyHint[source]}`;
161
+ case "unexpected-required-column": {
162
+ const issuer =
163
+ finding.column === "issuer"
164
+ ? " If this column came from Better Auth 1.7.0 through 1.7.2, follow the upgrade guide before removing it: https://www.better-auth.com/docs/guides/1-7-upgrade-guide"
165
+ : "";
166
+ return `Column "${finding.column}" on table "${finding.table}" is required but Better Auth never writes it, so every insert into "${finding.table}" fails. ${relaxHint[source]}${issuer}`;
167
+ }
168
+ }
169
+ }
170
+
171
+ const repairHint: Record<SchemaSource, string> = {
172
+ database:
173
+ "Make the listed columns nullable, give them defaults, or remove them.",
174
+ drizzle:
175
+ "Make the listed columns nullable in your Drizzle schema, give them defaults, or remove them.",
176
+ prisma:
177
+ "Make the listed fields optional in your Prisma schema, give them defaults, or remove them.",
178
+ };
179
+
180
+ const migrationHint: Record<SchemaSource, string> = {
181
+ ...applyHint,
182
+ database: "Run `npx auth migrate` to add the missing tables and columns.",
183
+ };
184
+
185
+ function formatSchemaMismatch(
186
+ findings: readonly SchemaFinding[],
187
+ source: SchemaSource,
188
+ ): string {
189
+ const tables: string[] = [];
190
+ const columns: string[] = [];
191
+ const required: string[] = [];
192
+ const affectedTables = new Set<string>();
193
+ let hasIssuer = false;
194
+ for (const finding of findings) {
195
+ switch (finding.kind) {
196
+ case "missing-table":
197
+ tables.push(finding.table);
198
+ break;
199
+ case "missing-column":
200
+ columns.push(`${finding.table}.${finding.column}`);
201
+ break;
202
+ case "unexpected-required-column":
203
+ required.push(`${finding.table}.${finding.column}`);
204
+ affectedTables.add(finding.table);
205
+ hasIssuer ||= finding.column === "issuer";
206
+ break;
207
+ }
208
+ }
209
+
210
+ const sections = [`${sourceLabel[source]} schema mismatch`];
211
+ if (tables.length)
212
+ sections.push(` Missing tables\n ${tables.join(", ")}`);
213
+ if (columns.length)
214
+ sections.push(` Missing columns\n ${columns.join("\n ")}`);
215
+ if (required.length) {
216
+ sections.push(
217
+ ` Required columns Better Auth never writes\n ${required.join("\n ")}`,
218
+ );
219
+ sections.push(
220
+ ` Inserts into ${[...affectedTables].join(", ")} will fail.`,
221
+ );
222
+ }
223
+
224
+ const help: string[] = [];
225
+ if (required.length) help.push(repairHint[source]);
226
+ if (
227
+ tables.length ||
228
+ columns.length ||
229
+ (required.length && source !== "database")
230
+ ) {
231
+ help.push(migrationHint[source]);
232
+ }
233
+ if (help.length) sections.push(` help: ${help.join("\n ")}`);
234
+ if (hasIssuer) {
235
+ sections.push(
236
+ " note: If this column came from Better Auth 1.7.0 through 1.7.2,\n" +
237
+ " follow the upgrade guide before removing it:\n" +
238
+ " https://www.better-auth.com/docs/guides/1-7-upgrade-guide",
239
+ );
240
+ }
241
+ return sections.join("\n\n");
242
+ }
243
+
244
+ /**
245
+ * The store cannot hold what this configuration writes.
246
+ *
247
+ * `findings` carries every problem as data; `message` lists each one with the
248
+ * change that resolves it. Reported during initialization and thrown when
249
+ * requests await validation, in every environment. Also thrown by
250
+ * `auth migrate` before it changes anything.
251
+ *
252
+ * @example
253
+ * ```ts
254
+ * try {
255
+ * await auth.api.getSession({ headers });
256
+ * } catch (error) {
257
+ * if (error instanceof SchemaMismatchError) console.error(error.findings);
258
+ * }
259
+ * ```
260
+ */
261
+ export class SchemaMismatchError extends BetterAuthError {
262
+ readonly code = "SCHEMA_MISMATCH";
263
+
264
+ constructor(
265
+ readonly findings: readonly SchemaFinding[],
266
+ readonly source: SchemaSource,
267
+ ) {
268
+ super(formatSchemaMismatch(findings, source));
269
+ }
270
+ }
@@ -80,6 +80,8 @@ export {
80
80
  export type {
81
81
  TokenEndpointAuth,
82
82
  TokenEndpointAuthMethod,
83
+ TokenEndpointRequestContext,
84
+ TokenEndpointRequestHook,
83
85
  TokenEndpointSecretAuthentication,
84
86
  } from "./token-endpoint-auth";
85
87
  export {
@@ -251,16 +251,6 @@ export interface OAuthProvider<
251
251
  * against this value to prevent authorization server mix-up attacks.
252
252
  */
253
253
  issuer?: string | undefined;
254
- /**
255
- * Stable issuer used with the provider subject to recognize an account.
256
- *
257
- * Use the validated OpenID Connect issuer for OIDC providers. A resolver is
258
- * supported for tenant-specific issuers and receives only provider-verified
259
- * data. OAuth providers without an issuer omit this property and are scoped
260
- * to the synthetic `local:oauth:<encoded providerId>` issuer, where the
261
- * provider ID segment is percent-encoded.
262
- */
263
- accountIssuer?: string | OAuthAccountKeyResolver<T, string> | undefined;
264
254
  /**
265
255
  * Require shared OAuth redirect routes to bind ID-token verification to an
266
256
  * authorization request nonce. When true, routes generate `idTokenNonce`,
@@ -19,23 +19,44 @@ export type TokenEndpointAuth =
19
19
  | {
20
20
  method: "private_key_jwt";
21
21
  getClientAssertion: ClientAssertionGetter;
22
+ }
23
+ | {
24
+ method: "custom";
25
+ /**
26
+ * Customize the token request after standard grant parameters are set.
27
+ */
28
+ customizeRequest: TokenEndpointRequestHook;
22
29
  };
23
30
 
24
31
  export type TokenEndpointAuthMethod = TokenEndpointAuth["method"];
25
32
 
26
33
  export type TokenEndpointSecretAuthentication = "basic" | "post";
27
34
 
28
- export interface TokenEndpointClientOptions {
29
- clientId?: string | string[] | undefined;
30
- clientSecret?: string | undefined;
31
- }
32
-
33
- export interface ApplyTokenEndpointAuthInput {
35
+ /**
36
+ * Mutable token request state passed to a custom authentication strategy.
37
+ */
38
+ export interface TokenEndpointRequestContext {
34
39
  body: URLSearchParams;
35
40
  headers: Record<string, string>;
36
41
  options: TokenEndpointClientOptions;
37
42
  tokenEndpoint: string;
38
43
  grantType: ClientAssertionGrantType;
44
+ }
45
+
46
+ /**
47
+ * Applies provider-specific authentication to a token request.
48
+ */
49
+ export type TokenEndpointRequestHook = (
50
+ context: TokenEndpointRequestContext,
51
+ ) => void | Promise<void>;
52
+
53
+ export interface TokenEndpointClientOptions {
54
+ clientId?: string | string[] | undefined;
55
+ clientSecret?: string | undefined;
56
+ }
57
+
58
+ export interface ApplyTokenEndpointAuthInput
59
+ extends TokenEndpointRequestContext {
39
60
  tokenEndpointAuth?: TokenEndpointAuth | undefined;
40
61
  authentication?: TokenEndpointSecretAuthentication | undefined;
41
62
  }
@@ -172,6 +193,18 @@ export async function applyTokenEndpointAuth({
172
193
  const auth =
173
194
  tokenEndpointAuth ?? getDefaultTokenEndpointAuth(options, authentication);
174
195
 
196
+ if (auth.method === "custom") {
197
+ await auth.customizeRequest({
198
+ body,
199
+ headers,
200
+ options,
201
+ tokenEndpoint,
202
+ grantType,
203
+ });
204
+ assertCompleteManualClientAssertion(body);
205
+ return;
206
+ }
207
+
175
208
  if (auth.method === "private_key_jwt") {
176
209
  assertNoClientSecret(auth.method, options, body);
177
210
  assertClientIdConfigured(auth.method, clientId);
@@ -83,7 +83,6 @@ export const apple = (options: AppleOptions) => {
83
83
  id: "apple",
84
84
  name: "Apple",
85
85
  accountSubject: ({ profile }) => profile.sub,
86
- accountIssuer: "https://appleid.apple.com",
87
86
  async createAuthorizationURL({
88
87
  state,
89
88
  scopes,
@@ -0,0 +1,221 @@
1
+ import { betterFetch } from "@better-fetch/fetch";
2
+ import { logger } from "../env";
3
+ import type {
4
+ OAuthProvider,
5
+ ProviderOptions,
6
+ TokenEndpointAuth,
7
+ } from "../oauth2";
8
+ import {
9
+ createAuthorizationURL,
10
+ refreshAccessToken,
11
+ validateAuthorizationCode,
12
+ } from "../oauth2";
13
+
14
+ const authorizationEndpoint = "https://dash.cloudflare.com/oauth2/auth";
15
+ const tokenEndpoint = "https://dash.cloudflare.com/oauth2/token";
16
+
17
+ /**
18
+ * Cloudflare's OIDC `userinfo` endpoint only returns the `sub` claim, so it
19
+ * cannot be used to build a user. The user's profile (email, name, ...) is
20
+ * read from the Cloudflare API `/user` endpoint instead, which the access
21
+ * token can call when the `user-details.read` scope is granted.
22
+ */
23
+ const userEndpoint = "https://api.cloudflare.com/client/v4/user";
24
+
25
+ /**
26
+ * The user profile returned by the Cloudflare API `/user` endpoint.
27
+ *
28
+ * @see https://developers.cloudflare.com/api/resources/user/methods/get/
29
+ */
30
+ export interface CloudflareProfile {
31
+ /**
32
+ * Identifier of the user.
33
+ */
34
+ id: string;
35
+ /**
36
+ * Current email address of the user.
37
+ */
38
+ email: string;
39
+ /**
40
+ * The user's first name.
41
+ */
42
+ first_name?: string | null | undefined;
43
+ /**
44
+ * The user's last name.
45
+ */
46
+ last_name?: string | null | undefined;
47
+ /**
48
+ * The country in which the user lives.
49
+ */
50
+ country?: string | null | undefined;
51
+ /**
52
+ * The user's telephone number.
53
+ */
54
+ telephone?: string | null | undefined;
55
+ /**
56
+ * The zipcode or postal code where the user lives.
57
+ */
58
+ zipcode?: string | null | undefined;
59
+ /**
60
+ * Indicates whether two-factor authentication is enabled for the user account.
61
+ */
62
+ two_factor_authentication_enabled?: boolean | undefined;
63
+ /**
64
+ * Indicates whether the user has been suspended.
65
+ */
66
+ suspended?: boolean | undefined;
67
+ }
68
+
69
+ /**
70
+ * The standard Cloudflare API response envelope for the `/user` endpoint.
71
+ */
72
+ interface CloudflareUserResponse {
73
+ success: boolean;
74
+ errors: { code: number; message: string }[];
75
+ result: CloudflareProfile | null;
76
+ }
77
+
78
+ /**
79
+ * Token endpoint authentication supported by Cloudflare OAuth clients.
80
+ *
81
+ * @see https://developers.cloudflare.com/fundamentals/oauth/create-an-oauth-client/#choose-a-flow
82
+ */
83
+ type CloudflareClientAuthentication =
84
+ | {
85
+ /**
86
+ * The client secret of a confidential Cloudflare OAuth client.
87
+ */
88
+ clientSecret: string;
89
+ /**
90
+ * The authentication method configured for the token endpoint.
91
+ *
92
+ * @default "client_secret_basic"
93
+ */
94
+ tokenEndpointAuthMethod?:
95
+ | "client_secret_basic"
96
+ | "client_secret_post"
97
+ | undefined;
98
+ }
99
+ | {
100
+ /**
101
+ * Clients that use PKCE do not have a client secret.
102
+ */
103
+ clientSecret?: undefined;
104
+ /**
105
+ * Clients without a secret do not authenticate at the token endpoint.
106
+ *
107
+ * @default "none"
108
+ */
109
+ tokenEndpointAuthMethod?: "none" | undefined;
110
+ };
111
+
112
+ interface CloudflareBaseOptions extends ProviderOptions<CloudflareProfile> {
113
+ /**
114
+ * The client ID of the Cloudflare OAuth client.
115
+ */
116
+ clientId: string;
117
+ }
118
+
119
+ /**
120
+ * Options for configuring the Cloudflare social provider.
121
+ */
122
+ export type CloudflareOptions = CloudflareBaseOptions &
123
+ CloudflareClientAuthentication;
124
+
125
+ const getTokenEndpointAuth = (
126
+ options: CloudflareOptions,
127
+ ): TokenEndpointAuth => {
128
+ const defaultMethod = options.clientSecret ? "client_secret_basic" : "none";
129
+ const method = options.tokenEndpointAuthMethod ?? defaultMethod;
130
+
131
+ return { method };
132
+ };
133
+
134
+ export const cloudflare = (options: CloudflareOptions) => {
135
+ return {
136
+ id: "cloudflare",
137
+ name: "Cloudflare",
138
+ accountSubject: ({ profile }) => profile.id,
139
+ createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
140
+ const _scopes = options.disableDefaultScope ? [] : ["user-details.read"];
141
+
142
+ if (options.scope?.length) {
143
+ _scopes.push(...options.scope);
144
+ }
145
+
146
+ if (scopes?.length) {
147
+ _scopes.push(...scopes);
148
+ }
149
+
150
+ return createAuthorizationURL({
151
+ id: "cloudflare",
152
+ options,
153
+ authorizationEndpoint,
154
+ scopes: _scopes.length ? [...new Set(_scopes)] : undefined,
155
+ state,
156
+ codeVerifier,
157
+ redirectURI,
158
+ });
159
+ },
160
+ validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
161
+ return validateAuthorizationCode({
162
+ code,
163
+ codeVerifier,
164
+ redirectURI,
165
+ options,
166
+ tokenEndpoint,
167
+ tokenEndpointAuth: getTokenEndpointAuth(options),
168
+ });
169
+ },
170
+ refreshAccessToken: options.refreshAccessToken
171
+ ? options.refreshAccessToken
172
+ : async (refreshToken) => {
173
+ return refreshAccessToken({
174
+ refreshToken,
175
+ options: {
176
+ clientId: options.clientId,
177
+ clientKey: options.clientKey,
178
+ clientSecret: options.clientSecret,
179
+ },
180
+ tokenEndpoint,
181
+ tokenEndpointAuth: getTokenEndpointAuth(options),
182
+ });
183
+ },
184
+ async getUserInfo(token) {
185
+ if (options.getUserInfo) {
186
+ return options.getUserInfo(token);
187
+ }
188
+
189
+ const { data, error } = await betterFetch<CloudflareUserResponse>(
190
+ userEndpoint,
191
+ { headers: { authorization: `Bearer ${token.accessToken}` } },
192
+ );
193
+
194
+ if (error || !data?.success || !data.result) {
195
+ logger.error(
196
+ "Failed to fetch user info from Cloudflare:",
197
+ error ?? data?.errors,
198
+ );
199
+ return null;
200
+ }
201
+
202
+ const profile = data.result;
203
+ const name =
204
+ [profile.first_name, profile.last_name].filter(Boolean).join(" ") ||
205
+ profile.email;
206
+ const userMap = await options.mapProfileToUser?.(profile);
207
+
208
+ return {
209
+ user: {
210
+ name,
211
+ email: profile.email,
212
+ // Cloudflare does not expose email verification status
213
+ emailVerified: false,
214
+ ...userMap,
215
+ },
216
+ data: profile,
217
+ };
218
+ },
219
+ options,
220
+ } satisfies OAuthProvider<CloudflareProfile>;
221
+ };
@@ -74,7 +74,6 @@ export const cognito = (options: CognitoOptions) => {
74
74
  id: "cognito",
75
75
  name: "Cognito",
76
76
  accountSubject: ({ profile }) => profile.sub,
77
- accountIssuer: `https://cognito-idp.${options.region}.amazonaws.com/${options.userPoolId}`,
78
77
  async createAuthorizationURL({
79
78
  state,
80
79
  scopes,
@@ -108,7 +108,6 @@ export const facebook = (options: FacebookOptions) => {
108
108
  name: "Facebook",
109
109
  accountSubject: ({ profile }) =>
110
110
  "sub" in profile ? profile.sub : profile.id,
111
- accountIssuer: "https://www.facebook.com",
112
111
  async createAuthorizationURL({
113
112
  state,
114
113
  scopes,
@@ -151,7 +151,6 @@ export const google = (options: GoogleOptions) => {
151
151
  id: "google",
152
152
  name: "Google",
153
153
  accountSubject: ({ profile }) => profile.sub,
154
- accountIssuer: "https://accounts.google.com",
155
154
  async createAuthorizationURL({
156
155
  state,
157
156
  scopes,
@@ -2,6 +2,7 @@ import * as z from "zod";
2
2
  import type { AwaitableFunction } from "../types";
3
3
  import { apple } from "./apple";
4
4
  import { atlassian } from "./atlassian";
5
+ import { cloudflare } from "./cloudflare";
5
6
  import { cognito } from "./cognito";
6
7
  import { discord } from "./discord";
7
8
  import { dropbox } from "./dropbox";
@@ -39,6 +40,7 @@ import { zoom } from "./zoom";
39
40
  export const socialProviders = {
40
41
  apple,
41
42
  atlassian,
43
+ cloudflare,
42
44
  cognito,
43
45
  discord,
44
46
  facebook,
@@ -95,6 +97,7 @@ export type SocialProviders = {
95
97
 
96
98
  export * from "./apple";
97
99
  export * from "./atlassian";
100
+ export * from "./cloudflare";
98
101
  export * from "./cognito";
99
102
  export * from "./discord";
100
103
  export * from "./dropbox";
@@ -51,7 +51,6 @@ export const line = (options: LineOptions) => {
51
51
  id: "line",
52
52
  name: "LINE",
53
53
  accountSubject: ({ profile }) => profile.sub,
54
- accountIssuer: "https://access.line.me",
55
54
  async createAuthorizationURL({
56
55
  state,
57
56
  scopes,
@@ -188,7 +188,6 @@ export const microsoft = (options: MicrosoftOptions) => {
188
188
  id: "microsoft",
189
189
  name: "Microsoft EntraID",
190
190
  accountSubject: ({ profile }) => profile.oid,
191
- accountIssuer: ({ profile }) => profile.iss,
192
191
  createAuthorizationURL(data) {
193
192
  // Microsoft Entra supports public clients (SPA / native apps with
194
193
  // PKCE only), so clientSecret is intentionally not required here.
@@ -37,7 +37,6 @@ export const paybin = (options: PaybinOptions) => {
37
37
  id: "paybin",
38
38
  name: "Paybin",
39
39
  accountSubject: ({ profile }) => profile.sub,
40
- accountIssuer: issuer,
41
40
  async createAuthorizationURL({
42
41
  state,
43
42
  scopes,