@better-auth/core 1.6.10 → 1.6.12
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/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +60 -0
- package/dist/db/adapter/index.d.mts +35 -1
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +12 -0
- package/dist/error/codes.d.mts +1 -0
- package/dist/error/codes.mjs +1 -0
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/index.d.mts +2 -2
- package/dist/oauth2/index.mjs +2 -2
- package/dist/oauth2/utils.d.mts +9 -1
- package/dist/oauth2/utils.mjs +12 -1
- package/dist/oauth2/verify.mjs +15 -5
- package/dist/social-providers/apple.mjs +11 -1
- package/dist/types/context.d.mts +14 -0
- package/dist/types/init-options.d.mts +23 -3
- package/dist/utils/ip.d.mts +5 -4
- package/dist/utils/ip.mjs +3 -3
- package/dist/utils/string.d.mts +5 -1
- package/dist/utils/string.mjs +20 -1
- package/package.json +9 -8
- package/src/db/adapter/factory.ts +112 -0
- package/src/db/adapter/index.ts +32 -0
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +12 -0
- package/src/error/codes.ts +1 -0
- package/src/oauth2/index.ts +1 -0
- package/src/oauth2/utils.ts +19 -0
- package/src/oauth2/verify.ts +20 -4
- package/src/social-providers/apple.ts +19 -1
- package/src/types/context.ts +15 -0
- package/src/types/init-options.ts +23 -3
- package/src/utils/ip.ts +12 -13
- package/src/utils/string.ts +37 -0
package/dist/context/global.mjs
CHANGED
|
@@ -57,6 +57,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
57
57
|
else if (method === "findMany" && !config.debugLogs.findMany) return;
|
|
58
58
|
else if (method === "delete" && !config.debugLogs.delete) return;
|
|
59
59
|
else if (method === "deleteMany" && !config.debugLogs.deleteMany) return;
|
|
60
|
+
else if (method === "consumeOne" && !config.debugLogs.consumeOne) return;
|
|
60
61
|
else if (method === "count" && !config.debugLogs.count) return;
|
|
61
62
|
}
|
|
62
63
|
logger.info(`[${config.adapterName}]`, ...args);
|
|
@@ -676,6 +677,65 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
676
677
|
});
|
|
677
678
|
return res;
|
|
678
679
|
},
|
|
680
|
+
consumeOne: async ({ model: unsafeModel, where: unsafeWhere }) => {
|
|
681
|
+
transactionId++;
|
|
682
|
+
const thisTransactionId = transactionId;
|
|
683
|
+
const model = getModelName(unsafeModel);
|
|
684
|
+
const where = transformWhereClause({
|
|
685
|
+
model: unsafeModel,
|
|
686
|
+
where: unsafeWhere,
|
|
687
|
+
action: "consumeOne"
|
|
688
|
+
});
|
|
689
|
+
unsafeModel = getDefaultModelName(unsafeModel);
|
|
690
|
+
debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`, `${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`, {
|
|
691
|
+
model,
|
|
692
|
+
where
|
|
693
|
+
});
|
|
694
|
+
let res;
|
|
695
|
+
let resultNeedsOutputTransform = true;
|
|
696
|
+
if (adapterInstance.consumeOne) res = await withSpan(`db consumeOne ${model}`, {
|
|
697
|
+
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
698
|
+
[ATTR_DB_COLLECTION_NAME]: model
|
|
699
|
+
}, () => adapterInstance.consumeOne({
|
|
700
|
+
model,
|
|
701
|
+
where
|
|
702
|
+
}));
|
|
703
|
+
else {
|
|
704
|
+
res = await withSpan(`db consumeOne ${model}`, {
|
|
705
|
+
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
706
|
+
[ATTR_DB_COLLECTION_NAME]: model
|
|
707
|
+
}, () => adapter.transaction(async (trx) => {
|
|
708
|
+
const target = (await trx.findMany({
|
|
709
|
+
model: unsafeModel,
|
|
710
|
+
where: unsafeWhere,
|
|
711
|
+
limit: 1
|
|
712
|
+
}))[0];
|
|
713
|
+
if (!target) return null;
|
|
714
|
+
return await trx.deleteMany({
|
|
715
|
+
model: unsafeModel,
|
|
716
|
+
where: [...unsafeWhere, {
|
|
717
|
+
field: "id",
|
|
718
|
+
value: target.id,
|
|
719
|
+
operator: "eq",
|
|
720
|
+
connector: "AND",
|
|
721
|
+
mode: "sensitive"
|
|
722
|
+
}]
|
|
723
|
+
}) > 0 ? target : null;
|
|
724
|
+
}));
|
|
725
|
+
resultNeedsOutputTransform = false;
|
|
726
|
+
}
|
|
727
|
+
debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`, `${formatMethod("consumeOne")} ${formatAction("DB Result")}:`, {
|
|
728
|
+
model,
|
|
729
|
+
data: res
|
|
730
|
+
});
|
|
731
|
+
let transformed = res;
|
|
732
|
+
if (!config.disableTransformOutput && resultNeedsOutputTransform && res) transformed = await transformOutput(res, unsafeModel, void 0, void 0);
|
|
733
|
+
debugLog({ method: "consumeOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`, `${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`, {
|
|
734
|
+
model,
|
|
735
|
+
data: transformed
|
|
736
|
+
});
|
|
737
|
+
return transformed;
|
|
738
|
+
},
|
|
679
739
|
count: async ({ model: unsafeModel, where: unsafeWhere }) => {
|
|
680
740
|
transactionId++;
|
|
681
741
|
const thisTransactionId = transactionId;
|
|
@@ -22,6 +22,7 @@ type DBAdapterDebugLogOption = boolean | {
|
|
|
22
22
|
findMany?: boolean | undefined;
|
|
23
23
|
delete?: boolean | undefined;
|
|
24
24
|
deleteMany?: boolean | undefined;
|
|
25
|
+
consumeOne?: boolean | undefined;
|
|
25
26
|
count?: boolean | undefined;
|
|
26
27
|
} | {
|
|
27
28
|
/**
|
|
@@ -197,7 +198,7 @@ interface DBAdapterFactoryConfig<Options extends BetterAuthOptions = BetterAuthO
|
|
|
197
198
|
/**
|
|
198
199
|
* The action which was called from the adapter.
|
|
199
200
|
*/
|
|
200
|
-
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "count";
|
|
201
|
+
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
|
|
201
202
|
/**
|
|
202
203
|
* The model name.
|
|
203
204
|
*/
|
|
@@ -415,6 +416,26 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
415
416
|
model: string;
|
|
416
417
|
where: Where[];
|
|
417
418
|
}) => Promise<number>;
|
|
419
|
+
/**
|
|
420
|
+
* Atomically consume a single row matching the where clause: delete it and
|
|
421
|
+
* return the deleted row, or return `null` if no row matched.
|
|
422
|
+
* Implementations MUST NOT delete any additional rows that also match a
|
|
423
|
+
* non-unique predicate.
|
|
424
|
+
*
|
|
425
|
+
* Under concurrent invocation against the same row, exactly one caller
|
|
426
|
+
* receives the row; subsequent racers receive `null`. This is the
|
|
427
|
+
* race-safe primitive for consuming single-use credentials
|
|
428
|
+
* (verification tokens, authorization codes, one-time tokens).
|
|
429
|
+
*
|
|
430
|
+
* Always defined on the factory-wrapped adapter. When the underlying
|
|
431
|
+
* `CustomAdapter` does not implement `consumeOne`, the factory provides
|
|
432
|
+
* a fallback that wraps `findMany + deleteMany` in `transaction(...)`
|
|
433
|
+
* and returns the row only when the delete reports an affected row.
|
|
434
|
+
*/
|
|
435
|
+
consumeOne: <T>(data: {
|
|
436
|
+
model: string;
|
|
437
|
+
where: Where[];
|
|
438
|
+
}) => Promise<T | null>;
|
|
418
439
|
/**
|
|
419
440
|
* Execute multiple operations in a transaction.
|
|
420
441
|
* If the adapter doesn't support transactions, operations will be executed sequentially.
|
|
@@ -496,6 +517,19 @@ interface CustomAdapter {
|
|
|
496
517
|
model: string;
|
|
497
518
|
where: CleanedWhere[];
|
|
498
519
|
}) => Promise<number>;
|
|
520
|
+
/**
|
|
521
|
+
* Optional native atomic single-row consume. When omitted, the adapter
|
|
522
|
+
* factory falls back to `transaction(findMany + deleteMany)`.
|
|
523
|
+
* Implementing this method natively (e.g. `DELETE ... RETURNING *`,
|
|
524
|
+
* `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
|
|
525
|
+
* strongest race-safety guarantee. Implementations must delete at most
|
|
526
|
+
* one matching row. TODO(consume-one-required): tighten to required in the
|
|
527
|
+
* next minor on `next`.
|
|
528
|
+
*/
|
|
529
|
+
consumeOne?: <T>(data: {
|
|
530
|
+
model: string;
|
|
531
|
+
where: CleanedWhere[];
|
|
532
|
+
}) => Promise<T | null>;
|
|
499
533
|
count: ({
|
|
500
534
|
model,
|
|
501
535
|
where
|
|
@@ -94,7 +94,7 @@ type AdapterFactoryCustomizeAdapterCreator = (config: {
|
|
|
94
94
|
}: {
|
|
95
95
|
where: W;
|
|
96
96
|
model: string;
|
|
97
|
-
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "count";
|
|
97
|
+
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
|
|
98
98
|
}) => W extends undefined ? undefined : CleanedWhere[];
|
|
99
99
|
}) => CustomAdapter;
|
|
100
100
|
type AdapterTestDebugLogs = {
|
package/dist/db/type.d.mts
CHANGED
|
@@ -141,6 +141,18 @@ interface SecondaryStorage {
|
|
|
141
141
|
* @returns - Value of the key
|
|
142
142
|
*/
|
|
143
143
|
get: (key: string) => Awaitable<unknown>;
|
|
144
|
+
/**
|
|
145
|
+
* Atomically get a value and delete it from storage.
|
|
146
|
+
*
|
|
147
|
+
* This is optional for backwards compatibility with existing secondary
|
|
148
|
+
* storage implementations. Single-use credential consumers use it when
|
|
149
|
+
* present to avoid a read-then-delete race.
|
|
150
|
+
*
|
|
151
|
+
* TODO(secondary-storage-atomic-consume): make this required in the next
|
|
152
|
+
* breaking release, or require database-backed verification storage for
|
|
153
|
+
* security-sensitive consume paths.
|
|
154
|
+
*/
|
|
155
|
+
getAndDelete?: (key: string) => Awaitable<unknown>;
|
|
144
156
|
set: (
|
|
145
157
|
/**
|
|
146
158
|
* Key to store
|
package/dist/error/codes.d.mts
CHANGED
|
@@ -36,6 +36,7 @@ declare const BASE_ERROR_CODES: {
|
|
|
36
36
|
USER_ALREADY_EXISTS: RawError<"USER_ALREADY_EXISTS">;
|
|
37
37
|
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: RawError<"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL">;
|
|
38
38
|
EMAIL_CAN_NOT_BE_UPDATED: RawError<"EMAIL_CAN_NOT_BE_UPDATED">;
|
|
39
|
+
CHANGE_EMAIL_DISABLED: RawError<"CHANGE_EMAIL_DISABLED">;
|
|
39
40
|
CREDENTIAL_ACCOUNT_NOT_FOUND: RawError<"CREDENTIAL_ACCOUNT_NOT_FOUND">;
|
|
40
41
|
ACCOUNT_NOT_FOUND: RawError<"ACCOUNT_NOT_FOUND">;
|
|
41
42
|
SESSION_EXPIRED: RawError<"SESSION_EXPIRED">;
|
package/dist/error/codes.mjs
CHANGED
|
@@ -23,6 +23,7 @@ const BASE_ERROR_CODES = defineErrorCodes({
|
|
|
23
23
|
USER_ALREADY_EXISTS: "User already exists.",
|
|
24
24
|
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: "User already exists. Use another email.",
|
|
25
25
|
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
|
|
26
|
+
CHANGE_EMAIL_DISABLED: "Change email is disabled",
|
|
26
27
|
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
|
|
27
28
|
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
|
|
28
29
|
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
|
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
|
|
|
2
2
|
import { getOpenTelemetryAPI } from "./api.mjs";
|
|
3
3
|
//#region src/instrumentation/tracer.ts
|
|
4
4
|
const INSTRUMENTATION_SCOPE = "better-auth";
|
|
5
|
-
const INSTRUMENTATION_VERSION = "1.6.
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.6.12";
|
|
6
6
|
/**
|
|
7
7
|
* Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
|
|
8
8
|
* callbacks). These are APIErrors with 3xx status codes and should not be
|
package/dist/oauth2/index.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { OAuth2Tokens, OAuth2UserInfo, OAuthProvider, ProviderOptions } from "./
|
|
|
2
2
|
import { clientCredentialsToken, clientCredentialsTokenRequest, createClientCredentialsTokenRequest } from "./client-credentials-token.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "./create-authorization-url.mjs";
|
|
4
4
|
import { createRefreshAccessTokenRequest, refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
|
|
5
|
-
import { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
5
|
+
import { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
6
6
|
import { authorizationCodeRequest, createAuthorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
|
|
7
7
|
import { getJwks, verifyAccessToken, verifyJwsAccessToken } from "./verify.mjs";
|
|
8
|
-
export { type OAuth2Tokens, type OAuth2UserInfo, type OAuthProvider, type ProviderOptions, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
|
8
|
+
export { type OAuth2Tokens, type OAuth2UserInfo, type OAuthProvider, type ProviderOptions, applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
package/dist/oauth2/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { clientCredentialsToken, clientCredentialsTokenRequest, createClientCredentialsTokenRequest } from "./client-credentials-token.mjs";
|
|
2
|
-
import { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
2
|
+
import { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId } from "./utils.mjs";
|
|
3
3
|
import { createAuthorizationURL } from "./create-authorization-url.mjs";
|
|
4
4
|
import { createRefreshAccessTokenRequest, refreshAccessToken, refreshAccessTokenRequest } from "./refresh-access-token.mjs";
|
|
5
5
|
import { authorizationCodeRequest, createAuthorizationCodeRequest, validateAuthorizationCode, validateToken } from "./validate-authorization-code.mjs";
|
|
6
6
|
import { getJwks, verifyAccessToken, verifyJwsAccessToken } from "./verify.mjs";
|
|
7
|
-
export { authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
|
7
|
+
export { applyDefaultAccessTokenExpiry, authorizationCodeRequest, clientCredentialsToken, clientCredentialsTokenRequest, createAuthorizationCodeRequest, createAuthorizationURL, createClientCredentialsTokenRequest, createRefreshAccessTokenRequest, generateCodeChallenge, getJwks, getOAuth2Tokens, getPrimaryClientId, refreshAccessToken, refreshAccessTokenRequest, validateAuthorizationCode, validateToken, verifyAccessToken, verifyJwsAccessToken };
|
package/dist/oauth2/utils.d.mts
CHANGED
|
@@ -2,6 +2,14 @@ import { OAuth2Tokens } from "./oauth-provider.mjs";
|
|
|
2
2
|
|
|
3
3
|
//#region src/oauth2/utils.d.ts
|
|
4
4
|
declare function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens;
|
|
5
|
+
/**
|
|
6
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
7
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
8
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
9
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
10
|
+
* fallback is configured.
|
|
11
|
+
*/
|
|
12
|
+
declare function applyDefaultAccessTokenExpiry(tokens: OAuth2Tokens, accessTokenExpiresIn: number | undefined): OAuth2Tokens;
|
|
5
13
|
/**
|
|
6
14
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
7
15
|
* array index 0 for the cross-platform form used by ID token audience
|
|
@@ -13,4 +21,4 @@ declare function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens;
|
|
|
13
21
|
declare function getPrimaryClientId(clientId: unknown): string | undefined;
|
|
14
22
|
declare function generateCodeChallenge(codeVerifier: string): Promise<string>;
|
|
15
23
|
//#endregion
|
|
16
|
-
export { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
|
24
|
+
export { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
package/dist/oauth2/utils.mjs
CHANGED
|
@@ -17,6 +17,17 @@ function getOAuth2Tokens(data) {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
21
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
22
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
23
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
24
|
+
* fallback is configured.
|
|
25
|
+
*/
|
|
26
|
+
function applyDefaultAccessTokenExpiry(tokens, accessTokenExpiresIn) {
|
|
27
|
+
if (!tokens.accessTokenExpiresAt && accessTokenExpiresIn) tokens.accessTokenExpiresAt = new Date(Date.now() + accessTokenExpiresIn * 1e3);
|
|
28
|
+
return tokens;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
20
31
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
21
32
|
* array index 0 for the cross-platform form used by ID token audience
|
|
22
33
|
* verification. Index 0 is the designated primary and pairs with
|
|
@@ -34,4 +45,4 @@ async function generateCodeChallenge(codeVerifier) {
|
|
|
34
45
|
return base64Url.encode(new Uint8Array(hash), { padding: false });
|
|
35
46
|
}
|
|
36
47
|
//#endregion
|
|
37
|
-
export { generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
|
48
|
+
export { applyDefaultAccessTokenExpiry, generateCodeChallenge, getOAuth2Tokens, getPrimaryClientId };
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { logger } from "../env/logger.mjs";
|
|
2
2
|
import { APIError } from "better-call";
|
|
3
3
|
import { betterFetch } from "@better-fetch/fetch";
|
|
4
|
-
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
|
|
4
|
+
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
5
|
//#region src/oauth2/verify.ts
|
|
6
|
+
const joseInfrastructureErrorCodes = new Set([
|
|
7
|
+
errors.JWKSTimeout.code,
|
|
8
|
+
errors.JWKSInvalid.code,
|
|
9
|
+
errors.JWKSMultipleMatchingKeys.code
|
|
10
|
+
]);
|
|
11
|
+
function isJoseInfrastructureError(error) {
|
|
12
|
+
return joseInfrastructureErrorCodes.has(error.code);
|
|
13
|
+
}
|
|
6
14
|
/** Last fetched jwks used locally in getJwks @internal */
|
|
7
15
|
let jwks;
|
|
8
16
|
/**
|
|
@@ -28,7 +36,7 @@ async function getJwks(token, opts) {
|
|
|
28
36
|
if (error instanceof Error) throw error;
|
|
29
37
|
throw new Error(error);
|
|
30
38
|
}
|
|
31
|
-
if (!jwtHeaders.kid) throw new
|
|
39
|
+
if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
32
40
|
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
|
|
33
41
|
jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
34
42
|
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
@@ -51,9 +59,11 @@ async function verifyAccessToken(token, opts) {
|
|
|
51
59
|
verifyOptions: opts.verifyOptions
|
|
52
60
|
});
|
|
53
61
|
} catch (error) {
|
|
54
|
-
if (error instanceof Error) if (error.name === "TypeError" || error.name === "JWSInvalid") {} else if (error
|
|
55
|
-
else if (error
|
|
56
|
-
|
|
62
|
+
if (error instanceof Error) if (error.name === "TypeError" || error.name === "JWSInvalid") {} else if (error instanceof errors.JWTExpired) throw new APIError("UNAUTHORIZED", { message: "token expired" });
|
|
63
|
+
else if (error instanceof errors.JOSEError) {
|
|
64
|
+
if (isJoseInfrastructureError(error)) throw error;
|
|
65
|
+
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
66
|
+
} else throw error;
|
|
57
67
|
else throw new Error(error);
|
|
58
68
|
}
|
|
59
69
|
if (opts?.remoteVerify) {
|
|
@@ -7,6 +7,16 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
|
|
|
7
7
|
import { betterFetch } from "@better-fetch/fetch";
|
|
8
8
|
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
9
9
|
//#region src/social-providers/apple.ts
|
|
10
|
+
async function sha256Hex(value) {
|
|
11
|
+
const data = new TextEncoder().encode(value);
|
|
12
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
13
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
14
|
+
}
|
|
15
|
+
async function nonceMatches(jwtNonce, nonce) {
|
|
16
|
+
if (typeof jwtNonce !== "string") return false;
|
|
17
|
+
if (jwtNonce === nonce) return true;
|
|
18
|
+
return jwtNonce === await sha256Hex(nonce);
|
|
19
|
+
}
|
|
10
20
|
const apple = (options) => {
|
|
11
21
|
const tokenEndpoint = "https://appleid.apple.com/auth/token";
|
|
12
22
|
return {
|
|
@@ -55,7 +65,7 @@ const apple = (options) => {
|
|
|
55
65
|
["email_verified", "is_private_email"].forEach((field) => {
|
|
56
66
|
if (jwtClaims[field] !== void 0) jwtClaims[field] = Boolean(jwtClaims[field]);
|
|
57
67
|
});
|
|
58
|
-
if (nonce && jwtClaims.nonce
|
|
68
|
+
if (nonce && !await nonceMatches(jwtClaims.nonce, nonce)) return false;
|
|
59
69
|
return !!jwtClaims;
|
|
60
70
|
} catch {
|
|
61
71
|
return false;
|
package/dist/types/context.d.mts
CHANGED
|
@@ -114,6 +114,20 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
|
|
|
114
114
|
createVerificationValue(data: Omit<Verification, "createdAt" | "id" | "updatedAt"> & Partial<Verification>): Promise<Verification>;
|
|
115
115
|
findVerificationValue(identifier: string): Promise<Verification | null>;
|
|
116
116
|
deleteVerificationByIdentifier(identifier: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Atomically consume a single-use verification row by `identifier` and
|
|
119
|
+
* return it. Only the first concurrent caller receives the latest row;
|
|
120
|
+
* subsequent callers receive `null`. Consuming one row invalidates the
|
|
121
|
+
* whole identifier so stale rows cannot be replayed. Rows past their
|
|
122
|
+
* `expiresAt` are treated as already invalid: the row is deleted but
|
|
123
|
+
* `null` is returned, so callers do not need to gate on `expiresAt`
|
|
124
|
+
* themselves. Callers MUST gate any state change (issue session, mint
|
|
125
|
+
* token, change password) on a non-null result.
|
|
126
|
+
*
|
|
127
|
+
* Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
|
|
128
|
+
* pair at single-use credential consumption sites.
|
|
129
|
+
*/
|
|
130
|
+
consumeVerificationValue(identifier: string): Promise<Verification | null>;
|
|
117
131
|
updateVerificationByIdentifier(identifier: string, data: Partial<Verification>): Promise<Verification>;
|
|
118
132
|
refreshUserSessions(user: User): Promise<void>;
|
|
119
133
|
}
|
|
@@ -157,12 +157,13 @@ type BetterAuthAdvancedOptions = {
|
|
|
157
157
|
*/
|
|
158
158
|
disableIpTracking?: boolean;
|
|
159
159
|
/**
|
|
160
|
-
* IPv6
|
|
161
|
-
*
|
|
160
|
+
* IPv6 prefix length used to collapse addresses before rate-limit keying.
|
|
161
|
+
* Any integer from 0 to 128 is accepted; common values are 32, 48, 56, 64, 128.
|
|
162
|
+
* Out-of-range values fall back to safe behavior (negative -> mask all, > 128 -> no mask).
|
|
162
163
|
*
|
|
163
164
|
* @default 64
|
|
164
165
|
*/
|
|
165
|
-
ipv6Subnet?:
|
|
166
|
+
ipv6Subnet?: number;
|
|
166
167
|
} | undefined;
|
|
167
168
|
/**
|
|
168
169
|
* Force cookies to always use the `Secure` attribute. By default,
|
|
@@ -906,6 +907,25 @@ type BetterAuthOptions = {
|
|
|
906
907
|
* @default false
|
|
907
908
|
*/
|
|
908
909
|
disableImplicitLinking?: boolean;
|
|
910
|
+
/**
|
|
911
|
+
* Require the existing local user row to have
|
|
912
|
+
* `emailVerified: true` before implicit account linking
|
|
913
|
+
* uses the IdP's `email_verified` claim as ownership
|
|
914
|
+
* proof. Defaults to `true` so an attacker who
|
|
915
|
+
* pre-registers an unverified account at a victim's
|
|
916
|
+
* email cannot have the victim's OAuth identity linked
|
|
917
|
+
* into the attacker-owned row on first sign-in. Set to
|
|
918
|
+
* `false` for backward compatibility on apps whose
|
|
919
|
+
* users sign up via OAuth without verifying their email
|
|
920
|
+
* locally; understand the takeover risk before doing
|
|
921
|
+
* so.
|
|
922
|
+
*
|
|
923
|
+
* @default true
|
|
924
|
+
*
|
|
925
|
+
* @deprecated The option will be removed on the next
|
|
926
|
+
* minor; the gate will become unconditional.
|
|
927
|
+
*/
|
|
928
|
+
requireLocalEmailVerified?: boolean;
|
|
909
929
|
/**
|
|
910
930
|
* List of trusted providers. Can be a static array or a function
|
|
911
931
|
* that returns providers dynamically. The function is called
|
package/dist/utils/ip.d.mts
CHANGED
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
interface NormalizeIPOptions {
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
* Common values: 32, 48, 64, 128
|
|
13
|
+
* Prefix length used to collapse IPv6 addresses before keying.
|
|
14
|
+
* Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
|
|
15
|
+
* Values outside 0-128 are clamped.
|
|
15
16
|
*
|
|
16
|
-
* @default
|
|
17
|
+
* @default 64
|
|
17
18
|
*/
|
|
18
|
-
ipv6Subnet?:
|
|
19
|
+
ipv6Subnet?: number;
|
|
19
20
|
}
|
|
20
21
|
/**
|
|
21
22
|
* Checks if an IP is valid IPv4 or IPv6
|
package/dist/utils/ip.mjs
CHANGED
|
@@ -60,8 +60,8 @@ function expandIPv6(ipv6) {
|
|
|
60
60
|
*/
|
|
61
61
|
function normalizeIPv6(ipv6, subnetPrefix) {
|
|
62
62
|
const groups = expandIPv6(ipv6);
|
|
63
|
-
if (subnetPrefix && subnetPrefix < 128) {
|
|
64
|
-
let bitsRemaining = subnetPrefix;
|
|
63
|
+
if (subnetPrefix !== void 0 && subnetPrefix < 128) {
|
|
64
|
+
let bitsRemaining = Math.max(0, Math.floor(subnetPrefix));
|
|
65
65
|
return groups.map((group) => {
|
|
66
66
|
if (bitsRemaining <= 0) return "0000";
|
|
67
67
|
if (bitsRemaining >= 16) {
|
|
@@ -99,7 +99,7 @@ function normalizeIP(ip, options = {}) {
|
|
|
99
99
|
if (!isIPv6(ip)) return ip.toLowerCase();
|
|
100
100
|
const ipv4 = extractIPv4FromMapped(ip);
|
|
101
101
|
if (ipv4) return ipv4.toLowerCase();
|
|
102
|
-
return normalizeIPv6(ip, options.ipv6Subnet
|
|
102
|
+
return normalizeIPv6(ip, options.ipv6Subnet ?? 64);
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
105
|
* Creates a rate limit key from IP and path
|
package/dist/utils/string.d.mts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
//#region src/utils/string.d.ts
|
|
2
2
|
declare function capitalizeFirstLetter(str: string): string;
|
|
3
|
+
declare function toSnakeCase(input: string): string;
|
|
4
|
+
declare function toKebabCase(input: string): string;
|
|
5
|
+
declare function toCamelCase(input: string): string;
|
|
6
|
+
declare function toPascalCase(input: string): string;
|
|
3
7
|
//#endregion
|
|
4
|
-
export { capitalizeFirstLetter };
|
|
8
|
+
export { capitalizeFirstLetter, toCamelCase, toKebabCase, toPascalCase, toSnakeCase };
|
package/dist/utils/string.mjs
CHANGED
|
@@ -2,5 +2,24 @@
|
|
|
2
2
|
function capitalizeFirstLetter(str) {
|
|
3
3
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
4
4
|
}
|
|
5
|
+
const WORD_PATTERN = /[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
|
|
6
|
+
const APOSTROPHE_PATTERN = /['\u2019]/g;
|
|
7
|
+
function splitWords(input) {
|
|
8
|
+
return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
|
|
9
|
+
}
|
|
10
|
+
function toSnakeCase(input) {
|
|
11
|
+
return splitWords(input).map((word) => word.toLowerCase()).join("_");
|
|
12
|
+
}
|
|
13
|
+
function toKebabCase(input) {
|
|
14
|
+
return splitWords(input).map((word) => word.toLowerCase()).join("-");
|
|
15
|
+
}
|
|
16
|
+
function toCamelCase(input) {
|
|
17
|
+
return splitWords(input).reduce((acc, word, i) => {
|
|
18
|
+
return acc + (i === 0 ? word.toLowerCase() : `${word[0].toUpperCase()}${word.slice(1)}`);
|
|
19
|
+
}, "");
|
|
20
|
+
}
|
|
21
|
+
function toPascalCase(input) {
|
|
22
|
+
return splitWords(input).map((word) => `${word[0].toUpperCase()}${word.slice(1).toLowerCase()}`).join("");
|
|
23
|
+
}
|
|
5
24
|
//#endregion
|
|
6
|
-
export { capitalizeFirstLetter };
|
|
25
|
+
export { capitalizeFirstLetter, toCamelCase, toKebabCase, toPascalCase, toSnakeCase };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.12",
|
|
4
4
|
"description": "The most comprehensive authentication framework for TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,12 +93,13 @@
|
|
|
93
93
|
"./instrumentation": {
|
|
94
94
|
"dev-source": "./src/instrumentation/index.ts",
|
|
95
95
|
"types": "./dist/instrumentation/index.d.mts",
|
|
96
|
+
"workerd": "./dist/instrumentation/pure.index.mjs",
|
|
97
|
+
"edge": "./dist/instrumentation/pure.index.mjs",
|
|
98
|
+
"browser": "./dist/instrumentation/pure.index.mjs",
|
|
96
99
|
"node": "./dist/instrumentation/index.mjs",
|
|
97
100
|
"deno": "./dist/instrumentation/index.mjs",
|
|
98
101
|
"bun": "./dist/instrumentation/index.mjs",
|
|
99
|
-
"
|
|
100
|
-
"workerd": "./dist/instrumentation/pure.index.mjs",
|
|
101
|
-
"browser": "./dist/instrumentation/pure.index.mjs",
|
|
102
|
+
"import": "./dist/instrumentation/index.mjs",
|
|
102
103
|
"default": "./dist/instrumentation/index.mjs"
|
|
103
104
|
}
|
|
104
105
|
},
|
|
@@ -151,7 +152,7 @@
|
|
|
151
152
|
"zod": "^4.3.6"
|
|
152
153
|
},
|
|
153
154
|
"devDependencies": {
|
|
154
|
-
"@better-auth/utils": "0.4.
|
|
155
|
+
"@better-auth/utils": "0.4.1",
|
|
155
156
|
"@better-fetch/fetch": "1.1.21",
|
|
156
157
|
"@opentelemetry/api": "^1.9.0",
|
|
157
158
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
@@ -159,18 +160,18 @@
|
|
|
159
160
|
"better-call": "1.3.5",
|
|
160
161
|
"@cloudflare/workers-types": "^4.20250121.0",
|
|
161
162
|
"jose": "^6.1.3",
|
|
162
|
-
"kysely": "^0.28.
|
|
163
|
+
"kysely": "^0.28.17 || ^0.29.0",
|
|
163
164
|
"nanostores": "^1.1.1",
|
|
164
165
|
"tsdown": "0.21.1"
|
|
165
166
|
},
|
|
166
167
|
"peerDependencies": {
|
|
167
|
-
"@better-auth/utils": "0.4.
|
|
168
|
+
"@better-auth/utils": "0.4.1",
|
|
168
169
|
"@better-fetch/fetch": "1.1.21",
|
|
169
170
|
"@opentelemetry/api": "^1.9.0",
|
|
170
171
|
"better-call": "1.3.5",
|
|
171
172
|
"@cloudflare/workers-types": ">=4",
|
|
172
173
|
"jose": "^6.1.0",
|
|
173
|
-
"kysely": "^0.28.5",
|
|
174
|
+
"kysely": "^0.28.5 || ^0.29.0",
|
|
174
175
|
"nanostores": "^1.0.1"
|
|
175
176
|
},
|
|
176
177
|
"peerDependenciesMeta": {
|
|
@@ -133,6 +133,11 @@ export const createAdapterFactory =
|
|
|
133
133
|
!config.debugLogs.deleteMany
|
|
134
134
|
) {
|
|
135
135
|
return;
|
|
136
|
+
} else if (
|
|
137
|
+
method === "consumeOne" &&
|
|
138
|
+
!config.debugLogs.consumeOne
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
136
141
|
} else if (method === "count" && !config.debugLogs.count) {
|
|
137
142
|
return;
|
|
138
143
|
}
|
|
@@ -485,6 +490,7 @@ export const createAdapterFactory =
|
|
|
485
490
|
| "updateMany"
|
|
486
491
|
| "delete"
|
|
487
492
|
| "deleteMany"
|
|
493
|
+
| "consumeOne"
|
|
488
494
|
| "count";
|
|
489
495
|
}): W extends undefined ? undefined : CleanedWhere[] => {
|
|
490
496
|
if (!where) return undefined as any;
|
|
@@ -1312,6 +1318,112 @@ export const createAdapterFactory =
|
|
|
1312
1318
|
);
|
|
1313
1319
|
return res;
|
|
1314
1320
|
},
|
|
1321
|
+
consumeOne: async <T>({
|
|
1322
|
+
model: unsafeModel,
|
|
1323
|
+
where: unsafeWhere,
|
|
1324
|
+
}: {
|
|
1325
|
+
model: string;
|
|
1326
|
+
where: Where[];
|
|
1327
|
+
}): Promise<T | null> => {
|
|
1328
|
+
transactionId++;
|
|
1329
|
+
const thisTransactionId = transactionId;
|
|
1330
|
+
const model = getModelName(unsafeModel);
|
|
1331
|
+
const where = transformWhereClause({
|
|
1332
|
+
model: unsafeModel,
|
|
1333
|
+
where: unsafeWhere,
|
|
1334
|
+
action: "consumeOne",
|
|
1335
|
+
});
|
|
1336
|
+
unsafeModel = getDefaultModelName(unsafeModel);
|
|
1337
|
+
debugLog(
|
|
1338
|
+
{ method: "consumeOne" },
|
|
1339
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`,
|
|
1340
|
+
`${formatMethod("consumeOne")} ${formatAction("ConsumeOne")}:`,
|
|
1341
|
+
{ model, where },
|
|
1342
|
+
);
|
|
1343
|
+
|
|
1344
|
+
let res: T | null;
|
|
1345
|
+
let resultNeedsOutputTransform = true;
|
|
1346
|
+
if (adapterInstance.consumeOne) {
|
|
1347
|
+
res = await withSpan(
|
|
1348
|
+
`db consumeOne ${model}`,
|
|
1349
|
+
{
|
|
1350
|
+
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
1351
|
+
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1352
|
+
},
|
|
1353
|
+
() => adapterInstance.consumeOne!<T>({ model, where }),
|
|
1354
|
+
);
|
|
1355
|
+
} else {
|
|
1356
|
+
// TODO(consume-one-required): adapters without native `consumeOne`
|
|
1357
|
+
// fall back to `transaction(findMany + deleteMany)`. Race-safe on
|
|
1358
|
+
// engines with real transaction isolation; race window narrows
|
|
1359
|
+
// (does not close) on adapters that fall through to sequential
|
|
1360
|
+
// execution. Remove this branch when consumeOne becomes required.
|
|
1361
|
+
// FIXME(consume-one-nested-transaction): custom adapters without a
|
|
1362
|
+
// native consumeOne have no portable signal for "already inside a
|
|
1363
|
+
// transaction". First-party adapters mark transaction-scoped
|
|
1364
|
+
// adapters as as-is; make that capability explicit in the next
|
|
1365
|
+
// breaking adapter contract.
|
|
1366
|
+
res = await withSpan(
|
|
1367
|
+
`db consumeOne ${model}`,
|
|
1368
|
+
{
|
|
1369
|
+
[ATTR_DB_OPERATION_NAME]: "consumeOne",
|
|
1370
|
+
[ATTR_DB_COLLECTION_NAME]: model,
|
|
1371
|
+
},
|
|
1372
|
+
() =>
|
|
1373
|
+
adapter.transaction(async (trx) => {
|
|
1374
|
+
const rows = await trx.findMany<Record<string, any>>({
|
|
1375
|
+
model: unsafeModel,
|
|
1376
|
+
where: unsafeWhere,
|
|
1377
|
+
limit: 1,
|
|
1378
|
+
});
|
|
1379
|
+
const target = rows[0];
|
|
1380
|
+
if (!target) return null;
|
|
1381
|
+
const deleted = await trx.deleteMany({
|
|
1382
|
+
model: unsafeModel,
|
|
1383
|
+
where: [
|
|
1384
|
+
...unsafeWhere,
|
|
1385
|
+
{
|
|
1386
|
+
field: "id",
|
|
1387
|
+
value: target.id,
|
|
1388
|
+
operator: "eq",
|
|
1389
|
+
connector: "AND",
|
|
1390
|
+
mode: "sensitive",
|
|
1391
|
+
},
|
|
1392
|
+
],
|
|
1393
|
+
});
|
|
1394
|
+
return deleted > 0 ? (target as T) : null;
|
|
1395
|
+
}),
|
|
1396
|
+
);
|
|
1397
|
+
resultNeedsOutputTransform = false;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
debugLog(
|
|
1401
|
+
{ method: "consumeOne" },
|
|
1402
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`,
|
|
1403
|
+
`${formatMethod("consumeOne")} ${formatAction("DB Result")}:`,
|
|
1404
|
+
{ model, data: res },
|
|
1405
|
+
);
|
|
1406
|
+
let transformed: any = res;
|
|
1407
|
+
if (
|
|
1408
|
+
!config.disableTransformOutput &&
|
|
1409
|
+
resultNeedsOutputTransform &&
|
|
1410
|
+
res
|
|
1411
|
+
) {
|
|
1412
|
+
transformed = await transformOutput(
|
|
1413
|
+
res as Record<string, any>,
|
|
1414
|
+
unsafeModel,
|
|
1415
|
+
undefined,
|
|
1416
|
+
undefined,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
debugLog(
|
|
1420
|
+
{ method: "consumeOne" },
|
|
1421
|
+
`${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`,
|
|
1422
|
+
`${formatMethod("consumeOne")} ${formatAction("Parsed Result")}:`,
|
|
1423
|
+
{ model, data: transformed },
|
|
1424
|
+
);
|
|
1425
|
+
return transformed as T | null;
|
|
1426
|
+
},
|
|
1315
1427
|
count: async ({
|
|
1316
1428
|
model: unsafeModel,
|
|
1317
1429
|
where: unsafeWhere,
|
package/src/db/adapter/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type DBAdapterDebugLogOption =
|
|
|
15
15
|
findMany?: boolean | undefined;
|
|
16
16
|
delete?: boolean | undefined;
|
|
17
17
|
deleteMany?: boolean | undefined;
|
|
18
|
+
consumeOne?: boolean | undefined;
|
|
18
19
|
count?: boolean | undefined;
|
|
19
20
|
}
|
|
20
21
|
| {
|
|
@@ -211,6 +212,7 @@ export interface DBAdapterFactoryConfig<
|
|
|
211
212
|
| "updateMany"
|
|
212
213
|
| "delete"
|
|
213
214
|
| "deleteMany"
|
|
215
|
+
| "consumeOne"
|
|
214
216
|
| "count";
|
|
215
217
|
/**
|
|
216
218
|
* The model name.
|
|
@@ -445,6 +447,23 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
445
447
|
}) => Promise<number>;
|
|
446
448
|
delete: <_T>(data: { model: string; where: Where[] }) => Promise<void>;
|
|
447
449
|
deleteMany: (data: { model: string; where: Where[] }) => Promise<number>;
|
|
450
|
+
/**
|
|
451
|
+
* Atomically consume a single row matching the where clause: delete it and
|
|
452
|
+
* return the deleted row, or return `null` if no row matched.
|
|
453
|
+
* Implementations MUST NOT delete any additional rows that also match a
|
|
454
|
+
* non-unique predicate.
|
|
455
|
+
*
|
|
456
|
+
* Under concurrent invocation against the same row, exactly one caller
|
|
457
|
+
* receives the row; subsequent racers receive `null`. This is the
|
|
458
|
+
* race-safe primitive for consuming single-use credentials
|
|
459
|
+
* (verification tokens, authorization codes, one-time tokens).
|
|
460
|
+
*
|
|
461
|
+
* Always defined on the factory-wrapped adapter. When the underlying
|
|
462
|
+
* `CustomAdapter` does not implement `consumeOne`, the factory provides
|
|
463
|
+
* a fallback that wraps `findMany + deleteMany` in `transaction(...)`
|
|
464
|
+
* and returns the row only when the delete reports an affected row.
|
|
465
|
+
*/
|
|
466
|
+
consumeOne: <T>(data: { model: string; where: Where[] }) => Promise<T | null>;
|
|
448
467
|
/**
|
|
449
468
|
* Execute multiple operations in a transaction.
|
|
450
469
|
* If the adapter doesn't support transactions, operations will be executed sequentially.
|
|
@@ -531,6 +550,19 @@ export interface CustomAdapter {
|
|
|
531
550
|
model: string;
|
|
532
551
|
where: CleanedWhere[];
|
|
533
552
|
}) => Promise<number>;
|
|
553
|
+
/**
|
|
554
|
+
* Optional native atomic single-row consume. When omitted, the adapter
|
|
555
|
+
* factory falls back to `transaction(findMany + deleteMany)`.
|
|
556
|
+
* Implementing this method natively (e.g. `DELETE ... RETURNING *`,
|
|
557
|
+
* `findOneAndDelete`, `OUTPUT deleted.*`) gives one round trip and the
|
|
558
|
+
* strongest race-safety guarantee. Implementations must delete at most
|
|
559
|
+
* one matching row. TODO(consume-one-required): tighten to required in the
|
|
560
|
+
* next minor on `next`.
|
|
561
|
+
*/
|
|
562
|
+
consumeOne?: <T>(data: {
|
|
563
|
+
model: string;
|
|
564
|
+
where: CleanedWhere[];
|
|
565
|
+
}) => Promise<T | null>;
|
|
534
566
|
count: ({
|
|
535
567
|
model,
|
|
536
568
|
where,
|
package/src/db/adapter/types.ts
CHANGED
package/src/db/type.ts
CHANGED
|
@@ -311,6 +311,18 @@ export interface SecondaryStorage {
|
|
|
311
311
|
* @returns - Value of the key
|
|
312
312
|
*/
|
|
313
313
|
get: (key: string) => Awaitable<unknown>;
|
|
314
|
+
/**
|
|
315
|
+
* Atomically get a value and delete it from storage.
|
|
316
|
+
*
|
|
317
|
+
* This is optional for backwards compatibility with existing secondary
|
|
318
|
+
* storage implementations. Single-use credential consumers use it when
|
|
319
|
+
* present to avoid a read-then-delete race.
|
|
320
|
+
*
|
|
321
|
+
* TODO(secondary-storage-atomic-consume): make this required in the next
|
|
322
|
+
* breaking release, or require database-backed verification storage for
|
|
323
|
+
* security-sensitive consume paths.
|
|
324
|
+
*/
|
|
325
|
+
getAndDelete?: (key: string) => Awaitable<unknown>;
|
|
314
326
|
set: (
|
|
315
327
|
/**
|
|
316
328
|
* Key to store
|
package/src/error/codes.ts
CHANGED
|
@@ -37,6 +37,7 @@ export const BASE_ERROR_CODES = defineErrorCodes({
|
|
|
37
37
|
USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL:
|
|
38
38
|
"User already exists. Use another email.",
|
|
39
39
|
EMAIL_CAN_NOT_BE_UPDATED: "Email can not be updated",
|
|
40
|
+
CHANGE_EMAIL_DISABLED: "Change email is disabled",
|
|
40
41
|
CREDENTIAL_ACCOUNT_NOT_FOUND: "Credential account not found",
|
|
41
42
|
SESSION_EXPIRED: "Session expired. Re-authenticate to perform this action.",
|
|
42
43
|
FAILED_TO_UNLINK_LAST_ACCOUNT: "You can't unlink your last account",
|
package/src/oauth2/index.ts
CHANGED
package/src/oauth2/utils.ts
CHANGED
|
@@ -28,6 +28,25 @@ export function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Fill in `accessTokenExpiresAt` from the provider's configured
|
|
33
|
+
* `accessTokenExpiresIn` when the token response omitted `expires_in`. Without a
|
|
34
|
+
* known expiry, `getAccessToken` cannot tell the token is expired and never
|
|
35
|
+
* refreshes it. No-op when the provider already supplied an expiry or no
|
|
36
|
+
* fallback is configured.
|
|
37
|
+
*/
|
|
38
|
+
export function applyDefaultAccessTokenExpiry(
|
|
39
|
+
tokens: OAuth2Tokens,
|
|
40
|
+
accessTokenExpiresIn: number | undefined,
|
|
41
|
+
): OAuth2Tokens {
|
|
42
|
+
if (!tokens.accessTokenExpiresAt && accessTokenExpiresIn) {
|
|
43
|
+
tokens.accessTokenExpiresAt = new Date(
|
|
44
|
+
Date.now() + accessTokenExpiresIn * 1000,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return tokens;
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/**
|
|
32
51
|
* Return the provider's primary Client ID: the single string, or the entry at
|
|
33
52
|
* array index 0 for the cross-platform form used by ID token audience
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -9,11 +9,22 @@ import type {
|
|
|
9
9
|
import {
|
|
10
10
|
createLocalJWKSet,
|
|
11
11
|
decodeProtectedHeader,
|
|
12
|
+
errors as joseErrors,
|
|
12
13
|
jwtVerify,
|
|
13
14
|
UnsecuredJWT,
|
|
14
15
|
} from "jose";
|
|
15
16
|
import { logger } from "../env";
|
|
16
17
|
|
|
18
|
+
const joseInfrastructureErrorCodes = new Set([
|
|
19
|
+
joseErrors.JWKSTimeout.code,
|
|
20
|
+
joseErrors.JWKSInvalid.code,
|
|
21
|
+
joseErrors.JWKSMultipleMatchingKeys.code,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
25
|
+
return joseInfrastructureErrorCodes.has(error.code);
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
/** Last fetched jwks used locally in getJwks @internal */
|
|
18
29
|
let jwks: JSONWebKeySet | undefined;
|
|
19
30
|
|
|
@@ -82,7 +93,9 @@ export async function getJwks(
|
|
|
82
93
|
throw new Error(error as unknown as string);
|
|
83
94
|
}
|
|
84
95
|
|
|
85
|
-
if (!jwtHeaders.kid)
|
|
96
|
+
if (!jwtHeaders.kid) {
|
|
97
|
+
throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
|
|
98
|
+
}
|
|
86
99
|
|
|
87
100
|
// Fetch jwks if not set or has a different kid than the one stored
|
|
88
101
|
if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
|
|
@@ -137,13 +150,16 @@ export async function verifyAccessToken(
|
|
|
137
150
|
if (error instanceof Error) {
|
|
138
151
|
if (error.name === "TypeError" || error.name === "JWSInvalid") {
|
|
139
152
|
// likely an opaque token (continue)
|
|
140
|
-
} else if (error
|
|
153
|
+
} else if (error instanceof joseErrors.JWTExpired) {
|
|
141
154
|
throw new APIError("UNAUTHORIZED", {
|
|
142
155
|
message: "token expired",
|
|
143
156
|
});
|
|
144
|
-
} else if (error
|
|
157
|
+
} else if (error instanceof joseErrors.JOSEError) {
|
|
158
|
+
if (isJoseInfrastructureError(error)) {
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
145
161
|
throw new APIError("UNAUTHORIZED", {
|
|
146
|
-
message: "token
|
|
162
|
+
message: "invalid access token",
|
|
147
163
|
});
|
|
148
164
|
} else {
|
|
149
165
|
throw error;
|
|
@@ -77,6 +77,24 @@ export interface AppleOptions extends ProviderOptions<AppleProfile> {
|
|
|
77
77
|
audience?: (string | string[]) | undefined;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
async function sha256Hex(value: string) {
|
|
81
|
+
const data = new TextEncoder().encode(value);
|
|
82
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
83
|
+
return Array.from(new Uint8Array(digest))
|
|
84
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
85
|
+
.join("");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function nonceMatches(jwtNonce: unknown, nonce: string) {
|
|
89
|
+
if (typeof jwtNonce !== "string") {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (jwtNonce === nonce) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
return jwtNonce === (await sha256Hex(nonce));
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
export const apple = (options: AppleOptions) => {
|
|
81
99
|
const tokenEndpoint = "https://appleid.apple.com/auth/token";
|
|
82
100
|
return {
|
|
@@ -141,7 +159,7 @@ export const apple = (options: AppleOptions) => {
|
|
|
141
159
|
jwtClaims[field] = Boolean(jwtClaims[field]);
|
|
142
160
|
}
|
|
143
161
|
});
|
|
144
|
-
if (nonce && jwtClaims.nonce
|
|
162
|
+
if (nonce && !(await nonceMatches(jwtClaims.nonce, nonce))) {
|
|
145
163
|
return false;
|
|
146
164
|
}
|
|
147
165
|
return !!jwtClaims;
|
package/src/types/context.ts
CHANGED
|
@@ -216,6 +216,21 @@ export interface InternalAdapter<
|
|
|
216
216
|
|
|
217
217
|
deleteVerificationByIdentifier(identifier: string): Promise<void>;
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Atomically consume a single-use verification row by `identifier` and
|
|
221
|
+
* return it. Only the first concurrent caller receives the latest row;
|
|
222
|
+
* subsequent callers receive `null`. Consuming one row invalidates the
|
|
223
|
+
* whole identifier so stale rows cannot be replayed. Rows past their
|
|
224
|
+
* `expiresAt` are treated as already invalid: the row is deleted but
|
|
225
|
+
* `null` is returned, so callers do not need to gate on `expiresAt`
|
|
226
|
+
* themselves. Callers MUST gate any state change (issue session, mint
|
|
227
|
+
* token, change password) on a non-null result.
|
|
228
|
+
*
|
|
229
|
+
* Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
|
|
230
|
+
* pair at single-use credential consumption sites.
|
|
231
|
+
*/
|
|
232
|
+
consumeVerificationValue(identifier: string): Promise<Verification | null>;
|
|
233
|
+
|
|
219
234
|
updateVerificationByIdentifier(
|
|
220
235
|
identifier: string,
|
|
221
236
|
data: Partial<Verification>,
|
|
@@ -207,12 +207,13 @@ export type BetterAuthAdvancedOptions = {
|
|
|
207
207
|
*/
|
|
208
208
|
disableIpTracking?: boolean;
|
|
209
209
|
/**
|
|
210
|
-
* IPv6
|
|
211
|
-
*
|
|
210
|
+
* IPv6 prefix length used to collapse addresses before rate-limit keying.
|
|
211
|
+
* Any integer from 0 to 128 is accepted; common values are 32, 48, 56, 64, 128.
|
|
212
|
+
* Out-of-range values fall back to safe behavior (negative -> mask all, > 128 -> no mask).
|
|
212
213
|
*
|
|
213
214
|
* @default 64
|
|
214
215
|
*/
|
|
215
|
-
ipv6Subnet?:
|
|
216
|
+
ipv6Subnet?: number;
|
|
216
217
|
}
|
|
217
218
|
| undefined;
|
|
218
219
|
/**
|
|
@@ -1020,6 +1021,25 @@ export type BetterAuthOptions = {
|
|
|
1020
1021
|
* @default false
|
|
1021
1022
|
*/
|
|
1022
1023
|
disableImplicitLinking?: boolean;
|
|
1024
|
+
/**
|
|
1025
|
+
* Require the existing local user row to have
|
|
1026
|
+
* `emailVerified: true` before implicit account linking
|
|
1027
|
+
* uses the IdP's `email_verified` claim as ownership
|
|
1028
|
+
* proof. Defaults to `true` so an attacker who
|
|
1029
|
+
* pre-registers an unverified account at a victim's
|
|
1030
|
+
* email cannot have the victim's OAuth identity linked
|
|
1031
|
+
* into the attacker-owned row on first sign-in. Set to
|
|
1032
|
+
* `false` for backward compatibility on apps whose
|
|
1033
|
+
* users sign up via OAuth without verifying their email
|
|
1034
|
+
* locally; understand the takeover risk before doing
|
|
1035
|
+
* so.
|
|
1036
|
+
*
|
|
1037
|
+
* @default true
|
|
1038
|
+
*
|
|
1039
|
+
* @deprecated The option will be removed on the next
|
|
1040
|
+
* minor; the gate will become unconditional.
|
|
1041
|
+
*/
|
|
1042
|
+
requireLocalEmailVerified?: boolean;
|
|
1023
1043
|
/**
|
|
1024
1044
|
* List of trusted providers. Can be a static array or a function
|
|
1025
1045
|
* that returns providers dynamically. The function is called
|
package/src/utils/ip.ts
CHANGED
|
@@ -12,12 +12,13 @@ import * as z from "zod";
|
|
|
12
12
|
|
|
13
13
|
interface NormalizeIPOptions {
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
* Common values: 32, 48, 64, 128
|
|
15
|
+
* Prefix length used to collapse IPv6 addresses before keying.
|
|
16
|
+
* Any integer from 0 to 128 is accepted. Common values: 32, 48, 56, 64, 128.
|
|
17
|
+
* Values outside 0-128 are clamped.
|
|
17
18
|
*
|
|
18
|
-
* @default
|
|
19
|
+
* @default 64
|
|
19
20
|
*/
|
|
20
|
-
ipv6Subnet?:
|
|
21
|
+
ipv6Subnet?: number;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
/**
|
|
@@ -117,15 +118,13 @@ function expandIPv6(ipv6: string): string[] {
|
|
|
117
118
|
* Normalizes an IPv6 address to canonical form
|
|
118
119
|
* e.g., "2001:DB8::1" -> "2001:0db8:0000:0000:0000:0000:0000:0001"
|
|
119
120
|
*/
|
|
120
|
-
function normalizeIPv6(
|
|
121
|
-
ipv6: string,
|
|
122
|
-
subnetPrefix?: 128 | 32 | 48 | 64,
|
|
123
|
-
): string {
|
|
121
|
+
function normalizeIPv6(ipv6: string, subnetPrefix?: number): string {
|
|
124
122
|
const groups = expandIPv6(ipv6);
|
|
125
123
|
|
|
126
|
-
if (subnetPrefix && subnetPrefix < 128) {
|
|
127
|
-
//
|
|
128
|
-
|
|
124
|
+
if (subnetPrefix !== undefined && subnetPrefix < 128) {
|
|
125
|
+
// Clamp to a valid bit range so out-of-spec inputs degrade safely:
|
|
126
|
+
// negative or fractional values would otherwise produce malformed masks.
|
|
127
|
+
const prefix = Math.max(0, Math.floor(subnetPrefix));
|
|
129
128
|
let bitsRemaining: number = prefix;
|
|
130
129
|
|
|
131
130
|
const maskedGroups = groups.map((group) => {
|
|
@@ -191,8 +190,8 @@ export function normalizeIP(
|
|
|
191
190
|
return ipv4.toLowerCase();
|
|
192
191
|
}
|
|
193
192
|
|
|
194
|
-
// Normalize IPv6
|
|
195
|
-
const subnetPrefix = options.ipv6Subnet
|
|
193
|
+
// Normalize IPv6. Use ?? so an explicit 0 (mask-all) is honoured.
|
|
194
|
+
const subnetPrefix = options.ipv6Subnet ?? 64;
|
|
196
195
|
return normalizeIPv6(ip, subnetPrefix);
|
|
197
196
|
}
|
|
198
197
|
|
package/src/utils/string.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
1
|
export function capitalizeFirstLetter(str: string) {
|
|
2
2
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
3
3
|
}
|
|
4
|
+
|
|
5
|
+
const WORD_PATTERN =
|
|
6
|
+
/[\p{Ll}\d]+|\p{Lu}+(?!\p{Ll})|\p{Lu}[\p{Ll}\d]+|\p{Lo}+/gu;
|
|
7
|
+
const APOSTROPHE_PATTERN = /['\u2019]/g;
|
|
8
|
+
|
|
9
|
+
function splitWords(input: string): string[] {
|
|
10
|
+
return input.replace(APOSTROPHE_PATTERN, "").match(WORD_PATTERN) ?? [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function toSnakeCase(input: string): string {
|
|
14
|
+
return splitWords(input)
|
|
15
|
+
.map((word) => word.toLowerCase())
|
|
16
|
+
.join("_");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function toKebabCase(input: string): string {
|
|
20
|
+
return splitWords(input)
|
|
21
|
+
.map((word) => word.toLowerCase())
|
|
22
|
+
.join("-");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function toCamelCase(input: string): string {
|
|
26
|
+
return splitWords(input).reduce((acc, word, i) => {
|
|
27
|
+
return (
|
|
28
|
+
acc +
|
|
29
|
+
(i === 0
|
|
30
|
+
? word.toLowerCase()
|
|
31
|
+
: `${word[0]!.toUpperCase()}${word.slice(1)}`)
|
|
32
|
+
);
|
|
33
|
+
}, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function toPascalCase(input: string): string {
|
|
37
|
+
return splitWords(input)
|
|
38
|
+
.map((word) => `${word[0]!.toUpperCase()}${word.slice(1).toLowerCase()}`)
|
|
39
|
+
.join("");
|
|
40
|
+
}
|