better-auth 1.7.4 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/index.d.mts +6 -6
- package/dist/api/routes/account.d.mts +1 -1
- package/dist/api/routes/callback.mjs +16 -64
- package/dist/api/routes/password.mjs +3 -5
- package/dist/api/routes/sign-in.d.mts +2 -2
- package/dist/api/routes/sign-in.mjs +2 -0
- package/dist/api/routes/sign-up.mjs +3 -10
- package/dist/api/routes/update-user.mjs +7 -20
- package/dist/client/config.d.mts +2 -2
- package/dist/client/query-atom.mjs +13 -0
- package/dist/client/query.mjs +37 -27
- package/dist/client/react/react-store.mjs +2 -1
- package/dist/client/session-atom.mjs +2 -1
- package/dist/context/create-context.mjs +3 -2
- package/dist/cookies/index.d.mts +3 -2
- package/dist/cookies/index.mjs +15 -36
- package/dist/db/get-migration.mjs +81 -60
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +2 -2
- package/dist/oauth2/link-account.d.mts +42 -14
- package/dist/oauth2/link-account.mjs +61 -1
- package/dist/package.mjs +1 -1
- package/dist/plugins/admin/admin.d.mts +3 -3
- package/dist/plugins/admin/admin.mjs +3 -1
- package/dist/plugins/admin/routes.mjs +4 -10
- package/dist/plugins/admin/types.d.mts +4 -1
- package/dist/plugins/captcha/constants.d.mts +1 -0
- package/dist/plugins/captcha/constants.mjs +2 -1
- package/dist/plugins/captcha/index.d.mts +2 -2
- package/dist/plugins/captcha/index.mjs +7 -0
- package/dist/plugins/captcha/types.d.mts +44 -2
- package/dist/plugins/captcha/utils.mjs +11 -1
- package/dist/plugins/captcha/verify-handlers/cloudflare-turnstile.mjs +27 -8
- package/dist/plugins/captcha/verify-handlers/vercel-botid.mjs +27 -0
- package/dist/plugins/email-otp/routes.mjs +3 -4
- package/dist/plugins/index.d.mts +2 -2
- package/dist/plugins/oauth-proxy/index.mjs +19 -5
- package/dist/plugins/organization/routes/crud-members.d.mts +3 -3
- package/dist/plugins/organization/schema.d.mts +2 -2
- package/dist/plugins/phone-number/routes.mjs +4 -4
- package/dist/plugins/username/index.d.mts +4 -4
- package/dist/plugins/username/index.mjs +2 -0
- package/dist/plugins/username/schema.d.mts +4 -4
- package/dist/test-utils/http-test-instance.d.mts +2 -2
- package/dist/utils/password.mjs +16 -2
- package/package.json +10 -13
package/dist/cookies/index.mjs
CHANGED
|
@@ -268,6 +268,10 @@ const getSessionCookie = (request, config) => {
|
|
|
268
268
|
if (sessionToken) return sessionToken;
|
|
269
269
|
return null;
|
|
270
270
|
};
|
|
271
|
+
async function matchesVersion(payload, version) {
|
|
272
|
+
const expectedVersion = typeof version === "string" ? version : await version(payload.session, payload.user);
|
|
273
|
+
return (payload.version || "1") === expectedVersion;
|
|
274
|
+
}
|
|
271
275
|
const getCookieCache = async (request, config) => {
|
|
272
276
|
const cookies = (request instanceof Headers || !("headers" in request) ? request : request.headers).get("cookie");
|
|
273
277
|
if (!cookies) return null;
|
|
@@ -297,21 +301,12 @@ const getCookieCache = async (request, config) => {
|
|
|
297
301
|
const secret = config?.secret || env.BETTER_AUTH_SECRET;
|
|
298
302
|
if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
|
|
299
303
|
const payload = parseCookieCachePayload(await symmetricDecodeJWT(sessionData, secret, "better-auth-session"));
|
|
300
|
-
if (payload)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
let expectedVersion = "1";
|
|
304
|
-
if (typeof config.version === "string") expectedVersion = config.version;
|
|
305
|
-
else if (typeof config.version === "function") {
|
|
306
|
-
const result = config.version(payload.session, payload.user);
|
|
307
|
-
expectedVersion = isPromise(result) ? await result : result;
|
|
308
|
-
}
|
|
309
|
-
if (cookieVersion !== expectedVersion) return null;
|
|
310
|
-
}
|
|
311
|
-
if (isEmbeddedSessionExpired(payload.session)) return null;
|
|
312
|
-
return payload;
|
|
304
|
+
if (!payload) return null;
|
|
305
|
+
if (config?.version) {
|
|
306
|
+
if (!await matchesVersion(payload, config.version)) return null;
|
|
313
307
|
}
|
|
314
|
-
return null;
|
|
308
|
+
if (isEmbeddedSessionExpired(payload.session)) return null;
|
|
309
|
+
return payload;
|
|
315
310
|
} else if (strategy === "jwt") {
|
|
316
311
|
const jwks = config?.jwt?.jwks;
|
|
317
312
|
let payload;
|
|
@@ -324,21 +319,12 @@ const getCookieCache = async (request, config) => {
|
|
|
324
319
|
if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
|
|
325
320
|
payload = parseCookieCachePayload(await verifyJWT(sessionData, secret));
|
|
326
321
|
}
|
|
327
|
-
if (payload)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
let expectedVersion = "1";
|
|
331
|
-
if (typeof config.version === "string") expectedVersion = config.version;
|
|
332
|
-
else if (typeof config.version === "function") {
|
|
333
|
-
const result = config.version(payload.session, payload.user);
|
|
334
|
-
expectedVersion = isPromise(result) ? await result : result;
|
|
335
|
-
}
|
|
336
|
-
if (cookieVersion !== expectedVersion) return null;
|
|
337
|
-
}
|
|
338
|
-
if (isEmbeddedSessionExpired(payload.session)) return null;
|
|
339
|
-
return payload;
|
|
322
|
+
if (!payload) return null;
|
|
323
|
+
if (config?.version) {
|
|
324
|
+
if (!await matchesVersion(payload, config.version)) return null;
|
|
340
325
|
}
|
|
341
|
-
return null;
|
|
326
|
+
if (isEmbeddedSessionExpired(payload.session)) return null;
|
|
327
|
+
return payload;
|
|
342
328
|
} else {
|
|
343
329
|
const secret = config?.secret || env.BETTER_AUTH_SECRET;
|
|
344
330
|
if (!secret) throw new BetterAuthError("getCookieCache requires a secret to be provided. Either pass it as an option or set the BETTER_AUTH_SECRET environment variable");
|
|
@@ -351,14 +337,7 @@ const getCookieCache = async (request, config) => {
|
|
|
351
337
|
const payload = parseCookieCachePayload(sessionDataPayload.session);
|
|
352
338
|
if (!payload) return null;
|
|
353
339
|
if (config?.version) {
|
|
354
|
-
|
|
355
|
-
let expectedVersion = "1";
|
|
356
|
-
if (typeof config.version === "string") expectedVersion = config.version;
|
|
357
|
-
else if (typeof config.version === "function") {
|
|
358
|
-
const result = config.version(payload.session, payload.user);
|
|
359
|
-
expectedVersion = isPromise(result) ? await result : result;
|
|
360
|
-
}
|
|
361
|
-
if (cookieVersion !== expectedVersion) return null;
|
|
340
|
+
if (!await matchesVersion(payload, config.version)) return null;
|
|
362
341
|
}
|
|
363
342
|
if (typeof sessionDataPayload.expiresAt === "number" && sessionDataPayload.expiresAt < Date.now()) return null;
|
|
364
343
|
if (isEmbeddedSessionExpired(payload.session)) return null;
|
|
@@ -115,10 +115,12 @@ function toDatabaseIndexMap(indexes) {
|
|
|
115
115
|
}];
|
|
116
116
|
}));
|
|
117
117
|
}
|
|
118
|
-
async function getDatabaseIndexMap(db,
|
|
118
|
+
async function getDatabaseIndexMap(db, target, tableNames, introspectIndexes) {
|
|
119
119
|
if (introspectIndexes) return toDatabaseIndexMap(await introspectIndexes(tableNames));
|
|
120
120
|
let rows;
|
|
121
|
-
|
|
121
|
+
switch (target.type) {
|
|
122
|
+
case "sqlite":
|
|
123
|
+
rows = (await sql`
|
|
122
124
|
SELECT
|
|
123
125
|
tables.name AS "tableName",
|
|
124
126
|
index_list.name AS "indexName",
|
|
@@ -131,7 +133,9 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
|
|
|
131
133
|
INNER JOIN pragma_index_info(index_list.name) AS index_info
|
|
132
134
|
WHERE tables.type = 'table'
|
|
133
135
|
`.execute(db)).rows;
|
|
134
|
-
|
|
136
|
+
break;
|
|
137
|
+
case "postgres":
|
|
138
|
+
rows = (await sql`
|
|
135
139
|
SELECT
|
|
136
140
|
table_class.relname AS "tableName",
|
|
137
141
|
index_class.relname AS "indexName",
|
|
@@ -153,11 +157,13 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
|
|
|
153
157
|
LEFT JOIN pg_attribute AS index_attribute
|
|
154
158
|
ON index_attribute.attrelid = table_class.oid
|
|
155
159
|
AND index_attribute.attnum = index_column.attribute_number
|
|
156
|
-
WHERE table_namespace.nspname = ${
|
|
160
|
+
WHERE table_namespace.nspname = ${target.schema}
|
|
157
161
|
AND table_class.relkind = 'r'
|
|
158
162
|
AND index_column.ordinality <= index_data.indnkeyatts
|
|
159
163
|
`.execute(db)).rows;
|
|
160
|
-
|
|
164
|
+
break;
|
|
165
|
+
case "mysql":
|
|
166
|
+
rows = (await sql`
|
|
161
167
|
SELECT
|
|
162
168
|
table_name AS tableName,
|
|
163
169
|
index_name AS indexName,
|
|
@@ -169,7 +175,9 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
|
|
|
169
175
|
FROM information_schema.statistics
|
|
170
176
|
WHERE table_schema = DATABASE()
|
|
171
177
|
`.execute(db)).rows;
|
|
172
|
-
|
|
178
|
+
break;
|
|
179
|
+
case "mssql":
|
|
180
|
+
rows = (await sql`
|
|
173
181
|
SELECT
|
|
174
182
|
tables.name AS "tableName",
|
|
175
183
|
indexes.name AS "indexName",
|
|
@@ -190,10 +198,12 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
|
|
|
190
198
|
INNER JOIN sys.columns AS columns
|
|
191
199
|
ON columns.object_id = index_columns.object_id
|
|
192
200
|
AND columns.column_id = index_columns.column_id
|
|
193
|
-
WHERE table_schemas.name = ${
|
|
201
|
+
WHERE table_schemas.name = ${target.schema}
|
|
194
202
|
AND indexes.name IS NOT NULL
|
|
195
203
|
AND index_columns.key_ordinal > 0
|
|
196
204
|
`.execute(db)).rows;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
197
207
|
const indexMetadata = /* @__PURE__ */ new Map();
|
|
198
208
|
for (const row of rows) {
|
|
199
209
|
const table = row.tableName ?? row.table_name ?? row.TABLE_NAME ?? row.tablename ?? row.tbl_name;
|
|
@@ -228,46 +238,45 @@ async function getDatabaseIndexMap(db, dbType, schemaName, tableNames, introspec
|
|
|
228
238
|
}
|
|
229
239
|
return toDatabaseIndexMap([...indexMetadata.values()]);
|
|
230
240
|
}
|
|
231
|
-
async function getDatabaseColumnBounds(db,
|
|
232
|
-
if (dbType !== "mysql" && dbType !== "mssql") return /* @__PURE__ */ new Map();
|
|
241
|
+
async function getDatabaseColumnBounds(db, target) {
|
|
233
242
|
let rows;
|
|
234
|
-
|
|
243
|
+
switch (target.type) {
|
|
244
|
+
case "postgres":
|
|
245
|
+
case "sqlite": return /* @__PURE__ */ new Map();
|
|
246
|
+
case "mysql":
|
|
247
|
+
rows = (await sql`
|
|
235
248
|
SELECT
|
|
236
249
|
table_name AS tableName,
|
|
237
250
|
column_name AS columnName,
|
|
238
|
-
|
|
239
|
-
character_maximum_length AS characterMaximumLength
|
|
251
|
+
character_octet_length AS maxIndexBytes
|
|
240
252
|
FROM information_schema.columns
|
|
241
253
|
WHERE table_schema = DATABASE()
|
|
242
254
|
`.execute(db)).rows;
|
|
243
|
-
|
|
255
|
+
break;
|
|
256
|
+
case "mssql":
|
|
257
|
+
rows = (await sql`
|
|
244
258
|
SELECT
|
|
245
259
|
tables.name AS "tableName",
|
|
246
260
|
columns.name AS "columnName",
|
|
247
|
-
|
|
248
|
-
columns.max_length AS "maxLength"
|
|
261
|
+
columns.max_length AS "maxIndexBytes"
|
|
249
262
|
FROM sys.columns AS columns
|
|
250
263
|
INNER JOIN sys.tables AS tables
|
|
251
264
|
ON tables.object_id = columns.object_id
|
|
252
265
|
INNER JOIN sys.schemas AS table_schemas
|
|
253
266
|
ON table_schemas.schema_id = tables.schema_id
|
|
254
|
-
|
|
255
|
-
ON types.user_type_id = columns.user_type_id
|
|
256
|
-
WHERE table_schemas.name = ${schemaName}
|
|
267
|
+
WHERE table_schemas.name = ${target.schema}
|
|
257
268
|
`.execute(db)).rows;
|
|
258
|
-
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
const bounds = /* @__PURE__ */ new Map();
|
|
272
|
+
for (const row of rows) {
|
|
259
273
|
const table = row.tableName ?? row.TABLE_NAME;
|
|
260
274
|
const column = row.columnName ?? row.COLUMN_NAME;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
return [[createDatabaseColumnKey(table, column), { maxIndexBytes }]];
|
|
267
|
-
}
|
|
268
|
-
const maxLength = Number(row.maxLength ?? -1);
|
|
269
|
-
return [[createDatabaseColumnKey(table, column), { maxIndexBytes: maxLength < 0 ? null : maxLength }]];
|
|
270
|
-
}));
|
|
275
|
+
if (!table || !column) continue;
|
|
276
|
+
const maxIndexBytes = Number(row.maxIndexBytes ?? row.MAX_INDEX_BYTES ?? -1);
|
|
277
|
+
bounds.set(createDatabaseColumnKey(table, column), { maxIndexBytes: maxIndexBytes < 0 ? null : maxIndexBytes });
|
|
278
|
+
}
|
|
279
|
+
return bounds;
|
|
271
280
|
}
|
|
272
281
|
function assertExistingTableIndexFits({ columnBounds, dbType, existingColumns, fields, indexes, index, table }) {
|
|
273
282
|
const byteBudget = dbType === "mysql" ? 3072 : 1700;
|
|
@@ -341,7 +350,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
341
350
|
if (throwOnUnsafe) throw new UnsafeMigrationError(message);
|
|
342
351
|
unsafeChanges.push(message);
|
|
343
352
|
};
|
|
344
|
-
let { kysely: db, databaseType: dbType, introspectIndexes } = await createKyselyAdapter(config);
|
|
353
|
+
let { kysely: db, databaseType: dbType, introspectIndexes, schemaName } = await createKyselyAdapter(config);
|
|
345
354
|
if (!dbType) {
|
|
346
355
|
logger.warn("Could not determine database type, defaulting to sqlite. Please provide a type in the database options to avoid this.");
|
|
347
356
|
dbType = "sqlite";
|
|
@@ -350,39 +359,50 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
350
359
|
logger.error("Only kysely adapter is supported for migrations. You can use `generate` command to generate the schema, if you're using a different adapter.");
|
|
351
360
|
process.exit(1);
|
|
352
361
|
}
|
|
353
|
-
let currentSchema = dbType === "mssql" ? await getMssqlSchema(db) : "public";
|
|
354
|
-
if (dbType === "postgres") {
|
|
355
|
-
currentSchema = await getPostgresSchema(db);
|
|
356
|
-
logger.debug(`PostgreSQL migration: Using schema '${currentSchema}' (from search_path)`);
|
|
357
|
-
try {
|
|
358
|
-
const schemaCheck = await sql`
|
|
359
|
-
SELECT schema_name
|
|
360
|
-
FROM information_schema.schemata
|
|
361
|
-
WHERE schema_name = ${currentSchema}
|
|
362
|
-
`.execute(db);
|
|
363
|
-
if (!(schemaCheck.rows[0]?.schema_name ?? schemaCheck.rows[0]?.schemaName)) logger.warn(`Schema '${currentSchema}' does not exist. Tables will be inspected from available schemas. Consider creating the schema first or checking your database configuration.`);
|
|
364
|
-
} catch (error) {
|
|
365
|
-
logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`);
|
|
366
|
-
}
|
|
367
|
-
} else if (dbType === "mssql") logger.debug(`SQL Server migration: Using schema '${currentSchema}' (from the current user's default schema)`);
|
|
368
362
|
const allTableMetadata = await db.introspection.getTables();
|
|
369
|
-
|
|
370
|
-
const databaseColumnBounds = await getDatabaseColumnBounds(db, dbType, currentSchema);
|
|
363
|
+
let target;
|
|
371
364
|
let tableMetadata = allTableMetadata;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
365
|
+
switch (dbType) {
|
|
366
|
+
case "postgres": {
|
|
367
|
+
const schema = schemaName ?? await getPostgresSchema(db);
|
|
368
|
+
target = {
|
|
369
|
+
type: "postgres",
|
|
370
|
+
schema
|
|
371
|
+
};
|
|
372
|
+
logger.debug(`PostgreSQL migration: Using schema '${schema}' (${schemaName ? "from database.schemaName" : "from search_path"})`);
|
|
373
|
+
try {
|
|
374
|
+
if (!(await db.introspection.getSchemas()).some(({ name }) => name === schema)) if (schemaName) logger.debug(`Schema '${schema}' does not exist yet. The migration creates it before creating tables.`);
|
|
375
|
+
else logger.warn(`Schema '${schema}' does not exist. Create it before running migrations or check your database configuration.`);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
logger.debug(`Could not verify schema existence: ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Kysely 0.28 does not expose `isForeign`, while 0.29 adds foreign table metadata.
|
|
381
|
+
* @see https://github.com/kysely-org/kysely/pull/1494
|
|
382
|
+
*/
|
|
383
|
+
tableMetadata = allTableMetadata.filter((table) => table.schema === schema && !table.isView && !("isForeign" in table && table.isForeign));
|
|
384
|
+
logger.debug(`Found ${tableMetadata.length} table(s) in schema '${schema}': ${tableMetadata.map((table) => table.name).join(", ") || "(none)"}`);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
case "mssql": {
|
|
388
|
+
const schema = await getMssqlSchema(db);
|
|
389
|
+
target = {
|
|
390
|
+
type: "mssql",
|
|
391
|
+
schema
|
|
392
|
+
};
|
|
393
|
+
logger.debug(`SQL Server migration: Using schema '${schema}' (from the current user's default schema)`);
|
|
394
|
+
tableMetadata = allTableMetadata.filter((table) => table.schema === schema);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "mysql":
|
|
398
|
+
target = { type: "mysql" };
|
|
399
|
+
break;
|
|
400
|
+
case "sqlite":
|
|
401
|
+
target = { type: "sqlite" };
|
|
402
|
+
break;
|
|
384
403
|
}
|
|
385
|
-
|
|
404
|
+
const databaseIndexMap = await getDatabaseIndexMap(db, target, allTableMetadata.map((table) => table.name), introspectIndexes);
|
|
405
|
+
const databaseColumnBounds = await getDatabaseColumnBounds(db, target);
|
|
386
406
|
const schemaProblems = diffSchema(toPhysicalSchema(db, betterAuthSchema), toIntrospectedTables(tableMetadata)).filter((finding) => finding.kind === "unexpected-required-column").map((finding) => formatSchemaFinding(finding, "database"));
|
|
387
407
|
const toBeCreated = [];
|
|
388
408
|
const toBeAdded = [];
|
|
@@ -464,6 +484,7 @@ async function getMigrations(config, { throwOnUnsafe = true } = {}) {
|
|
|
464
484
|
});
|
|
465
485
|
}
|
|
466
486
|
const migrations = [];
|
|
487
|
+
if (schemaName && toBeCreated.length > 0) migrations.push(db.schema.createSchema(schemaName).ifNotExists());
|
|
467
488
|
const useUUIDs = config.advanced?.database?.generateId === "uuid";
|
|
468
489
|
const useNumberId = config.advanced?.database?.generateId === "serial";
|
|
469
490
|
function getType(field, fieldName, tableIndexStringLength) {
|
package/dist/oauth2/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GenerateStateOptions, generateIdTokenNonce, generateState, parseState } from "./state.mjs";
|
|
2
|
-
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
|
|
2
|
+
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
|
|
3
3
|
import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
|
|
4
4
|
export * from "@better-auth/core/oauth2";
|
|
5
|
-
export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
|
|
5
|
+
export { GenerateStateOptions, applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
|
package/dist/oauth2/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { decryptOAuthToken, getOAuthCallbackPath, setTokenUtil } from "./utils.mjs";
|
|
2
|
-
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo } from "./link-account.mjs";
|
|
2
|
+
import { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount } from "./link-account.mjs";
|
|
3
3
|
import { generateIdTokenNonce, generateState, parseState } from "./state.mjs";
|
|
4
4
|
export * from "@better-auth/core/oauth2";
|
|
5
|
-
export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, parseState, setTokenUtil };
|
|
5
|
+
export { applyUpdateUserInfoOnLink, decryptOAuthToken, generateIdTokenNonce, generateState, getOAuthCallbackPath, handleOAuthUserInfo, linkOAuthAccount, parseState, setTokenUtil };
|
|
@@ -2,9 +2,49 @@ import { Account, User } from "../types/models.mjs";
|
|
|
2
2
|
import { GenericEndpointContext, UserProvisioningSource } from "@better-auth/core";
|
|
3
3
|
|
|
4
4
|
//#region src/oauth2/link-account.d.ts
|
|
5
|
+
type OAuthAccountData = Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
|
|
6
|
+
/**
|
|
7
|
+
* Provider profile a freshly linked account may copy onto the local user.
|
|
8
|
+
* `email` and `emailVerified` are identity anchors and are stripped before
|
|
9
|
+
* the remaining fields are written. Provider identity is resolved separately
|
|
10
|
+
* from the raw profile through the provider's account-key contract.
|
|
11
|
+
*/
|
|
12
|
+
type LinkedProviderProfile = {
|
|
13
|
+
name?: string | undefined;
|
|
14
|
+
email?: string | null | undefined;
|
|
15
|
+
emailVerified?: boolean | undefined;
|
|
16
|
+
image?: string | null | undefined;
|
|
17
|
+
};
|
|
18
|
+
interface LinkOAuthAccountOptions {
|
|
19
|
+
link: {
|
|
20
|
+
userId: string;
|
|
21
|
+
email: string;
|
|
22
|
+
};
|
|
23
|
+
userInfo: LinkedProviderProfile;
|
|
24
|
+
account: OAuthAccountData;
|
|
25
|
+
profile: Record<string, unknown>;
|
|
26
|
+
scopes?: string[] | undefined;
|
|
27
|
+
}
|
|
28
|
+
interface LinkOAuthAccountFailure {
|
|
29
|
+
linked: false;
|
|
30
|
+
error: {
|
|
31
|
+
code: string;
|
|
32
|
+
message?: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
type LinkOAuthAccountResult = {
|
|
36
|
+
linked: true;
|
|
37
|
+
} | LinkOAuthAccountFailure;
|
|
38
|
+
declare function linkOAuthAccount(c: GenericEndpointContext, {
|
|
39
|
+
link,
|
|
40
|
+
userInfo,
|
|
41
|
+
account,
|
|
42
|
+
profile,
|
|
43
|
+
scopes
|
|
44
|
+
}: LinkOAuthAccountOptions): Promise<LinkOAuthAccountResult>;
|
|
5
45
|
declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
|
|
6
46
|
userInfo: Omit<User, "createdAt" | "updatedAt">;
|
|
7
|
-
account:
|
|
47
|
+
account: OAuthAccountData;
|
|
8
48
|
callbackURL?: string | undefined;
|
|
9
49
|
disableSignUp?: boolean | undefined;
|
|
10
50
|
overrideUserInfo?: boolean | undefined;
|
|
@@ -67,18 +107,6 @@ declare function handleOAuthUserInfo(c: GenericEndpointContext, opts: {
|
|
|
67
107
|
password?: string | null | undefined;
|
|
68
108
|
} | null;
|
|
69
109
|
}>;
|
|
70
|
-
/**
|
|
71
|
-
* Provider profile a freshly linked account may copy onto the local user.
|
|
72
|
-
* `email` and `emailVerified` are identity anchors and are stripped before
|
|
73
|
-
* the remaining fields are written. Provider identity is resolved separately
|
|
74
|
-
* from the raw profile through the provider's account-key contract.
|
|
75
|
-
*/
|
|
76
|
-
type LinkedProviderProfile = {
|
|
77
|
-
name?: string | undefined;
|
|
78
|
-
email?: string | null | undefined;
|
|
79
|
-
emailVerified?: boolean | undefined;
|
|
80
|
-
image?: string | null | undefined;
|
|
81
|
-
};
|
|
82
110
|
/**
|
|
83
111
|
* Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
|
|
84
112
|
* copy the freshly linked provider's profile onto the local user, matching the
|
|
@@ -93,4 +121,4 @@ type LinkedProviderProfile = {
|
|
|
93
121
|
*/
|
|
94
122
|
declare function applyUpdateUserInfoOnLink(c: GenericEndpointContext, userId: string, userInfo: LinkedProviderProfile): Promise<User | undefined>;
|
|
95
123
|
//#endregion
|
|
96
|
-
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
|
|
124
|
+
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
|
|
@@ -8,7 +8,67 @@ import { createEmailVerificationToken } from "../api/routes/email-verification.m
|
|
|
8
8
|
import { queueAfterTransactionHook, runWithTransaction } from "@better-auth/core/context";
|
|
9
9
|
import { isDevelopment } from "@better-auth/core/env";
|
|
10
10
|
import { APIError } from "@better-auth/core/error";
|
|
11
|
+
import { mergeScopes } from "@better-auth/core/oauth2";
|
|
11
12
|
//#region src/oauth2/link-account.ts
|
|
13
|
+
function linkOAuthAccountFailure(code, message) {
|
|
14
|
+
return {
|
|
15
|
+
linked: false,
|
|
16
|
+
error: {
|
|
17
|
+
code,
|
|
18
|
+
message
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function linkOAuthAccount(c, { link, userInfo, account, profile, scopes }) {
|
|
23
|
+
try {
|
|
24
|
+
await assertValidUserInfo(c, {
|
|
25
|
+
user: {
|
|
26
|
+
...userInfo,
|
|
27
|
+
id: link.userId,
|
|
28
|
+
email: userInfo.email ?? void 0
|
|
29
|
+
},
|
|
30
|
+
source: {
|
|
31
|
+
action: "link-account",
|
|
32
|
+
method: "oauth",
|
|
33
|
+
oauth: {
|
|
34
|
+
providerId: account.providerId,
|
|
35
|
+
profile
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!isAPIError(error) || !error.body?.code) throw error;
|
|
41
|
+
return linkOAuthAccountFailure(error.body.code, error.body.message);
|
|
42
|
+
}
|
|
43
|
+
if (!c.context.trustedProviders.includes(account.providerId) && !userInfo.emailVerified || c.context.options.account?.accountLinking?.enabled === false) {
|
|
44
|
+
c.context.logger.error("Unable to link account - untrusted provider");
|
|
45
|
+
return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
|
|
46
|
+
}
|
|
47
|
+
if (userInfo.email?.toLowerCase() !== link.email.toLowerCase() && c.context.options.account?.accountLinking?.allowDifferentEmails !== true) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
|
|
48
|
+
const existingAccount = await c.context.internalAdapter.findAccountByKey(account);
|
|
49
|
+
if (existingAccount) {
|
|
50
|
+
if (existingAccount.userId.toString() !== link.userId.toString()) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER);
|
|
51
|
+
const mergedScope = mergeScopes(existingAccount.scope, scopes);
|
|
52
|
+
const updateData = Object.fromEntries(Object.entries({
|
|
53
|
+
providerId: account.providerId,
|
|
54
|
+
accessToken: await setTokenUtil(account.accessToken, c.context),
|
|
55
|
+
refreshToken: await setTokenUtil(account.refreshToken, c.context),
|
|
56
|
+
idToken: account.idToken,
|
|
57
|
+
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
|
58
|
+
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
|
59
|
+
scope: mergedScope || void 0
|
|
60
|
+
}).filter(([_, value]) => value !== void 0));
|
|
61
|
+
await c.context.internalAdapter.updateAccount(existingAccount.id, updateData);
|
|
62
|
+
} else if (!await c.context.internalAdapter.createAccount({
|
|
63
|
+
userId: link.userId,
|
|
64
|
+
...account,
|
|
65
|
+
accessToken: await setTokenUtil(account.accessToken, c.context),
|
|
66
|
+
refreshToken: await setTokenUtil(account.refreshToken, c.context),
|
|
67
|
+
scope: scopes?.join(",") ?? account.scope
|
|
68
|
+
})) return linkOAuthAccountFailure(OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT);
|
|
69
|
+
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
|
|
70
|
+
return { linked: true };
|
|
71
|
+
}
|
|
12
72
|
async function handleOAuthUserInfo(c, opts) {
|
|
13
73
|
const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = opts;
|
|
14
74
|
const source = opts.source ?? {
|
|
@@ -326,4 +386,4 @@ async function applyUpdateUserInfoOnLink(c, userId, userInfo) {
|
|
|
326
386
|
}
|
|
327
387
|
}
|
|
328
388
|
//#endregion
|
|
329
|
-
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo };
|
|
389
|
+
export { applyUpdateUserInfoOnLink, handleOAuthUserInfo, linkOAuthAccount };
|
package/dist/package.mjs
CHANGED
|
@@ -297,13 +297,13 @@ declare const admin: <O extends AdminOptions>(options?: O | undefined) => {
|
|
|
297
297
|
filterField: _$zod.ZodOptional<_$zod.ZodString>;
|
|
298
298
|
filterValue: _$zod.ZodOptional<_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodUnion<[_$zod.ZodString, _$zod.ZodNumber]>, _$zod.ZodBoolean]>, _$zod.ZodArray<_$zod.ZodString>]>, _$zod.ZodArray<_$zod.ZodNumber>]>>;
|
|
299
299
|
filterOperator: _$zod.ZodOptional<_$zod.ZodEnum<{
|
|
300
|
+
in: "in";
|
|
301
|
+
lte: "lte";
|
|
300
302
|
eq: "eq";
|
|
301
303
|
ne: "ne";
|
|
304
|
+
lt: "lt";
|
|
302
305
|
gt: "gt";
|
|
303
306
|
gte: "gte";
|
|
304
|
-
lt: "lt";
|
|
305
|
-
lte: "lte";
|
|
306
|
-
in: "in";
|
|
307
307
|
not_in: "not_in";
|
|
308
308
|
contains: "contains";
|
|
309
309
|
starts_with: "starts_with";
|
|
@@ -42,8 +42,10 @@ const admin = (options) => {
|
|
|
42
42
|
});
|
|
43
43
|
return;
|
|
44
44
|
}
|
|
45
|
+
const bannedUserMessage = opts.bannedUserMessage;
|
|
46
|
+
const message = typeof bannedUserMessage === "function" ? await bannedUserMessage(user) : bannedUserMessage;
|
|
45
47
|
throw APIError.from("FORBIDDEN", {
|
|
46
|
-
message
|
|
48
|
+
message,
|
|
47
49
|
code: "BANNED_USER"
|
|
48
50
|
});
|
|
49
51
|
}
|
|
@@ -2,6 +2,7 @@ import { parseSessionOutput, parseUserOutput } from "../../db/schema.mjs";
|
|
|
2
2
|
import { getDate } from "../../utils/date.mjs";
|
|
3
3
|
import { deleteSessionCookie, expireCookie, setSessionCookie } from "../../cookies/index.mjs";
|
|
4
4
|
import { getAuthoritativeSessionFromCtx, getSessionFromCtx } from "../../api/routes/session.mjs";
|
|
5
|
+
import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
|
|
5
6
|
import { ADMIN_ERROR_CODES } from "./error-codes.mjs";
|
|
6
7
|
import { hasPermission } from "./has-permission.mjs";
|
|
7
8
|
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
|
|
@@ -190,6 +191,7 @@ const createUser = (opts) => createAuthEndpoint("/admin/create-user", {
|
|
|
190
191
|
}
|
|
191
192
|
const email = ctx.body.email.toLowerCase();
|
|
192
193
|
if (!z.email().safeParse(email).success) throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.INVALID_EMAIL);
|
|
194
|
+
if (ctx.body.password) assertPasswordNotTooLong(ctx, ctx.body.password);
|
|
193
195
|
if (await ctx.context.internalAdapter.findUserByEmail(email)) throw APIError.from("BAD_REQUEST", ADMIN_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL);
|
|
194
196
|
const user = await ctx.context.internalAdapter.createUser({
|
|
195
197
|
...userData,
|
|
@@ -823,16 +825,8 @@ const setUserPassword = (opts) => createAuthEndpoint("/admin/set-user-password",
|
|
|
823
825
|
permissions: { user: ["set-password"] }
|
|
824
826
|
})) throw APIError.from("FORBIDDEN", ADMIN_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD);
|
|
825
827
|
const { newPassword, userId } = ctx.body;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
ctx.context.logger.warn("Password is too short");
|
|
829
|
-
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_SHORT);
|
|
830
|
-
}
|
|
831
|
-
const maxPasswordLength = ctx.context.password.config.maxPasswordLength;
|
|
832
|
-
if (newPassword.length > maxPasswordLength) {
|
|
833
|
-
ctx.context.logger.warn("Password is too long");
|
|
834
|
-
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.PASSWORD_TOO_LONG);
|
|
835
|
-
}
|
|
828
|
+
assertPasswordNotTooShort(ctx, newPassword);
|
|
829
|
+
assertPasswordNotTooLong(ctx, newPassword);
|
|
836
830
|
const user = await ctx.context.internalAdapter.findUserById(userId);
|
|
837
831
|
if (!user) throw APIError.from("NOT_FOUND", BASE_ERROR_CODES.USER_NOT_FOUND);
|
|
838
832
|
const hashedPassword = await ctx.context.password.hash(newPassword);
|
|
@@ -2,6 +2,7 @@ import { Session, User } from "../../types/models.mjs";
|
|
|
2
2
|
import { InferOptionSchema } from "../../types/plugins.mjs";
|
|
3
3
|
import { AccessControl, Role } from "../access/types.mjs";
|
|
4
4
|
import { AdminSchema } from "./schema.mjs";
|
|
5
|
+
import { Awaitable } from "@better-auth/core";
|
|
5
6
|
|
|
6
7
|
//#region src/plugins/admin/types.d.ts
|
|
7
8
|
interface UserWithRole extends User {
|
|
@@ -70,8 +71,10 @@ interface AdminOptions {
|
|
|
70
71
|
* Message to show when a user is banned
|
|
71
72
|
*
|
|
72
73
|
* By default, the message is "You have been banned from this application"
|
|
74
|
+
*
|
|
75
|
+
* Can also be a function that receives the banned user and returns a message.
|
|
73
76
|
*/
|
|
74
|
-
bannedUserMessage?: string | undefined;
|
|
77
|
+
bannedUserMessage?: string | ((user: UserWithRole & Record<string, unknown>) => Awaitable<string>) | undefined;
|
|
75
78
|
/**
|
|
76
79
|
* Whether to allow impersonating other admins.
|
|
77
80
|
*
|
|
@@ -15,7 +15,8 @@ const Providers = {
|
|
|
15
15
|
CLOUDFLARE_TURNSTILE: "cloudflare-turnstile",
|
|
16
16
|
GOOGLE_RECAPTCHA: "google-recaptcha",
|
|
17
17
|
HCAPTCHA: "hcaptcha",
|
|
18
|
-
CAPTCHAFOX: "captchafox"
|
|
18
|
+
CAPTCHAFOX: "captchafox",
|
|
19
|
+
VERCEL_BOTID: "vercel-botid"
|
|
19
20
|
};
|
|
20
21
|
const siteVerifyMap = {
|
|
21
22
|
[Providers.CLOUDFLARE_TURNSTILE]: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider } from "./types.mjs";
|
|
1
|
+
import { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions } from "./types.mjs";
|
|
2
2
|
import * as _$_better_auth_core0 from "@better-auth/core";
|
|
3
3
|
import * as _$_better_auth_core_utils_error_codes0 from "@better-auth/core/utils/error-codes";
|
|
4
4
|
|
|
@@ -24,4 +24,4 @@ declare const captcha: (options: CaptchaOptions) => {
|
|
|
24
24
|
options: CaptchaOptions;
|
|
25
25
|
};
|
|
26
26
|
//#endregion
|
|
27
|
-
export { BaseCaptchaOptions, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, captcha };
|
|
27
|
+
export { BaseCaptchaOptions, BotIdVerification, CaptchaFoxOptions, CaptchaOptions, CloudflareTurnstileOptions, GoogleRecaptchaOptions, HCaptchaOptions, Provider, ValidateRequestContext, VercelBotIdOptions, captcha };
|
|
@@ -7,6 +7,7 @@ import { captchaFox } from "./verify-handlers/captchafox.mjs";
|
|
|
7
7
|
import { cloudflareTurnstile } from "./verify-handlers/cloudflare-turnstile.mjs";
|
|
8
8
|
import { googleRecaptcha } from "./verify-handlers/google-recaptcha.mjs";
|
|
9
9
|
import { hCaptcha } from "./verify-handlers/h-captcha.mjs";
|
|
10
|
+
import { vercelBotId } from "./verify-handlers/vercel-botid.mjs";
|
|
10
11
|
import { getIP } from "@better-auth/core/utils/ip";
|
|
11
12
|
//#region src/plugins/captcha/index.ts
|
|
12
13
|
const normalizeEndpointPath = (pathname, basePath) => {
|
|
@@ -26,6 +27,11 @@ const captcha = (options) => ({
|
|
|
26
27
|
const basePath = ctx.options.basePath ?? "/api/auth";
|
|
27
28
|
const pathname = normalizeEndpointPath(url.pathname, basePath);
|
|
28
29
|
if (!endpoints.some((endpoint) => endpoint.includes("*") ? wildcardMatch(endpoint)(pathname) : endpoint === pathname)) return;
|
|
30
|
+
if (options.provider === Providers.VERCEL_BOTID) return await vercelBotId({
|
|
31
|
+
request,
|
|
32
|
+
checkBotId: options.checkBotId,
|
|
33
|
+
validateRequest: options.validateRequest
|
|
34
|
+
});
|
|
29
35
|
if (!options.secretKey) throw new Error(INTERNAL_ERROR_CODES.MISSING_SECRET_KEY.message);
|
|
30
36
|
const captchaResponse = request.headers.get("x-captcha-response");
|
|
31
37
|
const remoteUserIP = getIP(request, ctx.options) ?? void 0;
|
|
@@ -42,6 +48,7 @@ const captcha = (options) => ({
|
|
|
42
48
|
};
|
|
43
49
|
if (options.provider === Providers.CLOUDFLARE_TURNSTILE) return await cloudflareTurnstile({
|
|
44
50
|
...handlerParams,
|
|
51
|
+
logger: ctx.logger,
|
|
45
52
|
expectedAction: options.expectedAction,
|
|
46
53
|
allowedHostnames: options.allowedHostnames
|
|
47
54
|
});
|