@better-auth/core 1.6.11 → 1.6.13

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.
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.6.11";
5
+ const __betterAuthVersion = "1.6.13";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -711,7 +711,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
711
711
  limit: 1
712
712
  }))[0];
713
713
  if (!target) return null;
714
- return await trx.deleteMany({
714
+ const deleted = await trx.deleteMany({
715
715
  model: unsafeModel,
716
716
  where: [...unsafeWhere, {
717
717
  field: "id",
@@ -720,7 +720,9 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
720
720
  connector: "AND",
721
721
  mode: "sensitive"
722
722
  }]
723
- }) > 0 ? target : null;
723
+ });
724
+ if (typeof deleted !== "number") throw new BetterAuthError(`Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`);
725
+ return deleted > 0 ? target : null;
724
726
  }));
725
727
  resultNeedsOutputTransform = false;
726
728
  }
@@ -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.11";
5
+ const INSTRUMENTATION_VERSION = "1.6.13";
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
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 Error("Missing jwt kid");
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.name === "JWTExpired") throw new APIError("UNAUTHORIZED", { message: "token expired" });
55
- else if (error.name === "JWTInvalid") throw new APIError("UNAUTHORIZED", { message: "token invalid" });
56
- else throw error;
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 !== nonce) return false;
68
+ if (nonce && !await nonceMatches(jwtClaims.nonce, nonce)) return false;
59
69
  return !!jwtClaims;
60
70
  } catch {
61
71
  return false;
@@ -89,7 +89,14 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
89
89
  * @param id - The account row's primary key (the `id` column, not the `accountId` column).
90
90
  */
91
91
  deleteAccount(id: string): Promise<void>;
92
- deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
92
+ /**
93
+ * Delete every session belonging to a user.
94
+ */
95
+ deleteUserSessions(userId: string): Promise<void>;
96
+ /**
97
+ * Delete sessions by their session tokens.
98
+ */
99
+ deleteSessions(sessionTokens: string[]): Promise<void>;
93
100
  findOAuthUser(email: string, accountId: string, providerId: string): Promise<{
94
101
  user: User;
95
102
  linkedAccount: Account | null;
@@ -107,7 +114,6 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
107
114
  updateUserByEmail<T extends Record<string, any>>(email: string, data: Partial<User & Record<string, any>>): Promise<User & T>;
108
115
  updatePassword(userId: string, password: string): Promise<void>;
109
116
  findAccounts(userId: string): Promise<Account[]>;
110
- findAccount(accountId: string): Promise<Account | null>;
111
117
  findAccountByProviderId(accountId: string, providerId: string): Promise<Account | null>;
112
118
  findAccountByUserId(userId: string): Promise<Account[]>;
113
119
  updateAccount(id: string, data: Partial<Account>): Promise<Account>;
@@ -118,9 +124,11 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
118
124
  * Atomically consume a single-use verification row by `identifier` and
119
125
  * return it. Only the first concurrent caller receives the latest row;
120
126
  * subsequent callers receive `null`. Consuming one row invalidates the
121
- * whole identifier so stale rows cannot be replayed. Callers MUST gate any
122
- * state change (issue session, mint token, change password) on a non-null
123
- * result.
127
+ * whole identifier so stale rows cannot be replayed. Rows past their
128
+ * `expiresAt` are treated as already invalid: the row is deleted but
129
+ * `null` is returned, so callers do not need to gate on `expiresAt`
130
+ * themselves. Callers MUST gate any state change (issue session, mint
131
+ * token, change password) on a non-null result.
124
132
  *
125
133
  * Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
126
134
  * pair at single-use credential consumption sites.
@@ -183,7 +191,7 @@ type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> = Plugin
183
191
  * - "cookie": Store state in an encrypted cookie (stateless)
184
192
  * - "database": Store state in the database
185
193
  *
186
- * @default "cookie"
194
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
187
195
  */
188
196
  storeStateStrategy: "database" | "cookie";
189
197
  };
@@ -963,7 +963,11 @@ type BetterAuthOptions = {
963
963
  */
964
964
  allowUnlinkingAll?: boolean;
965
965
  /**
966
- * If enabled (true), this will update the user information based on the newly linked account
966
+ * When enabled, linking an account copies the provider's profile onto
967
+ * the local user, matching the fields persisted on sign-up (`name`,
968
+ * `image`, and any `mapProfileToUser` fields). The local `email` and
969
+ * `emailVerified` are never changed, so a link cannot rebind the
970
+ * account's identity.
967
971
  *
968
972
  * @default false
969
973
  */
@@ -996,7 +1000,7 @@ type BetterAuthOptions = {
996
1000
  * - "cookie": Store state in an encrypted cookie (stateless)
997
1001
  * - "database": Store state in the database
998
1002
  *
999
- * @default "cookie"
1003
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
1000
1004
  */
1001
1005
  storeStateStrategy?: "database" | "cookie";
1002
1006
  /**
@@ -0,0 +1,19 @@
1
+ import * as z from "zod";
2
+
3
+ //#region src/utils/redirect-uri.d.ts
4
+ /**
5
+ * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
6
+ * server stores and later hands back to a browser.
7
+ *
8
+ * - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
9
+ * - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
10
+ * `*.localhost` per RFC 6761), where HTTP is allowed for local development.
11
+ * - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
12
+ *
13
+ * This is the single source of truth for redirect-URI validation across the
14
+ * OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
15
+ * rather than re-implementing the scheme policy per plugin.
16
+ */
17
+ declare const SafeUrlSchema: z.ZodURL;
18
+ //#endregion
19
+ export { SafeUrlSchema };
@@ -0,0 +1,41 @@
1
+ import { isLoopbackHost } from "./host.mjs";
2
+ import { DANGEROUS_URL_SCHEMES } from "./url.mjs";
3
+ import * as z from "zod";
4
+ //#region src/utils/redirect-uri.ts
5
+ /**
6
+ * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
7
+ * server stores and later hands back to a browser.
8
+ *
9
+ * - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
10
+ * - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
11
+ * `*.localhost` per RFC 6761), where HTTP is allowed for local development.
12
+ * - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
13
+ *
14
+ * This is the single source of truth for redirect-URI validation across the
15
+ * OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
16
+ * rather than re-implementing the scheme policy per plugin.
17
+ */
18
+ const SafeUrlSchema = z.url().superRefine((val, ctx) => {
19
+ if (!URL.canParse(val)) {
20
+ ctx.addIssue({
21
+ code: "custom",
22
+ message: "URL must be parseable",
23
+ fatal: true
24
+ });
25
+ return z.NEVER;
26
+ }
27
+ const u = new URL(val);
28
+ if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
29
+ ctx.addIssue({
30
+ code: "custom",
31
+ message: "URL cannot use javascript:, data:, or vbscript: scheme"
32
+ });
33
+ return;
34
+ }
35
+ if (u.protocol === "http:" && !isLoopbackHost(u.host)) ctx.addIssue({
36
+ code: "custom",
37
+ message: "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)"
38
+ });
39
+ });
40
+ //#endregion
41
+ export { SafeUrlSchema };
@@ -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 };
@@ -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 };
@@ -16,5 +16,22 @@
16
16
  * // Returns: "/sso/saml2/callback/provider1"
17
17
  */
18
18
  declare function normalizePathname(requestUrl: string, basePath: string): string;
19
+ /**
20
+ * Schemes that execute or embed code when navigated to or accepted as a
21
+ * redirect target. These are never safe as an OAuth `redirect_uri` or as a
22
+ * client-side navigation target (`window.location.href`, `location.assign`, ...).
23
+ */
24
+ declare const DANGEROUS_URL_SCHEMES: string[];
25
+ /**
26
+ * Returns `false` only when `value` is an absolute URL using a dangerous scheme
27
+ * (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
28
+ * safe absolute schemes (`http`, `https`, custom app schemes such as
29
+ * `myapp://`) return `true`.
30
+ *
31
+ * Use this to guard browser navigation sinks and any redirect target that may
32
+ * originate from untrusted input. It is intentionally narrow: it blocks code
33
+ * execution schemes without rejecting relative paths or mobile deep links.
34
+ */
35
+ declare function isSafeUrlScheme(value: string): boolean;
19
36
  //#endregion
20
- export { normalizePathname };
37
+ export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
@@ -27,5 +27,29 @@ function normalizePathname(requestUrl, basePath) {
27
27
  if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
28
28
  return pathname;
29
29
  }
30
+ /**
31
+ * Schemes that execute or embed code when navigated to or accepted as a
32
+ * redirect target. These are never safe as an OAuth `redirect_uri` or as a
33
+ * client-side navigation target (`window.location.href`, `location.assign`, ...).
34
+ */
35
+ const DANGEROUS_URL_SCHEMES = [
36
+ "javascript:",
37
+ "data:",
38
+ "vbscript:"
39
+ ];
40
+ /**
41
+ * Returns `false` only when `value` is an absolute URL using a dangerous scheme
42
+ * (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
43
+ * safe absolute schemes (`http`, `https`, custom app schemes such as
44
+ * `myapp://`) return `true`.
45
+ *
46
+ * Use this to guard browser navigation sinks and any redirect target that may
47
+ * originate from untrusted input. It is intentionally narrow: it blocks code
48
+ * execution schemes without rejecting relative paths or mobile deep links.
49
+ */
50
+ function isSafeUrlScheme(value) {
51
+ if (!URL.canParse(value)) return true;
52
+ return !DANGEROUS_URL_SCHEMES.includes(new URL(value).protocol);
53
+ }
30
54
  //#endregion
31
- export { normalizePathname };
55
+ export { DANGEROUS_URL_SCHEMES, isSafeUrlScheme, normalizePathname };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.11",
3
+ "version": "1.6.13",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -152,7 +152,7 @@
152
152
  "zod": "^4.3.6"
153
153
  },
154
154
  "devDependencies": {
155
- "@better-auth/utils": "0.4.0",
155
+ "@better-auth/utils": "0.4.1",
156
156
  "@better-fetch/fetch": "1.1.21",
157
157
  "@opentelemetry/api": "^1.9.0",
158
158
  "@opentelemetry/sdk-trace-base": "^1.30.0",
@@ -160,18 +160,18 @@
160
160
  "better-call": "1.3.5",
161
161
  "@cloudflare/workers-types": "^4.20250121.0",
162
162
  "jose": "^6.1.3",
163
- "kysely": "^0.28.17",
163
+ "kysely": "^0.28.17 || ^0.29.0",
164
164
  "nanostores": "^1.1.1",
165
165
  "tsdown": "0.21.1"
166
166
  },
167
167
  "peerDependencies": {
168
- "@better-auth/utils": "0.4.0",
168
+ "@better-auth/utils": "0.4.1",
169
169
  "@better-fetch/fetch": "1.1.21",
170
170
  "@opentelemetry/api": "^1.9.0",
171
171
  "better-call": "1.3.5",
172
172
  "@cloudflare/workers-types": ">=4",
173
173
  "jose": "^6.1.0",
174
- "kysely": "^0.28.5",
174
+ "kysely": "^0.28.5 || ^0.29.0",
175
175
  "nanostores": "^1.0.1"
176
176
  },
177
177
  "peerDependenciesMeta": {
@@ -1391,6 +1391,12 @@ export const createAdapterFactory =
1391
1391
  },
1392
1392
  ],
1393
1393
  });
1394
+ // A non-numeric count coerces to a false miss, so fail loud.
1395
+ if (typeof deleted !== "number") {
1396
+ throw new BetterAuthError(
1397
+ `Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`,
1398
+ );
1399
+ }
1394
1400
  return deleted > 0 ? (target as T) : null;
1395
1401
  }),
1396
1402
  );
@@ -16,6 +16,7 @@ export {
16
16
  refreshAccessTokenRequest,
17
17
  } from "./refresh-access-token";
18
18
  export {
19
+ applyDefaultAccessTokenExpiry,
19
20
  generateCodeChallenge,
20
21
  getOAuth2Tokens,
21
22
  getPrimaryClientId,
@@ -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
@@ -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) throw new Error("Missing jwt 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.name === "JWTExpired") {
153
+ } else if (error instanceof joseErrors.JWTExpired) {
141
154
  throw new APIError("UNAUTHORIZED", {
142
155
  message: "token expired",
143
156
  });
144
- } else if (error.name === "JWTInvalid") {
157
+ } else if (error instanceof joseErrors.JOSEError) {
158
+ if (isJoseInfrastructureError(error)) {
159
+ throw error;
160
+ }
145
161
  throw new APIError("UNAUTHORIZED", {
146
- message: "token invalid",
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 !== nonce) {
162
+ if (nonce && !(await nonceMatches(jwtClaims.nonce, nonce))) {
145
163
  return false;
146
164
  }
147
165
  return !!jwtClaims;
@@ -158,7 +158,15 @@ export interface InternalAdapter<
158
158
  */
159
159
  deleteAccount(id: string): Promise<void>;
160
160
 
161
- deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>;
161
+ /**
162
+ * Delete every session belonging to a user.
163
+ */
164
+ deleteUserSessions(userId: string): Promise<void>;
165
+
166
+ /**
167
+ * Delete sessions by their session tokens.
168
+ */
169
+ deleteSessions(sessionTokens: string[]): Promise<void>;
162
170
 
163
171
  findOAuthUser(
164
172
  email: string,
@@ -196,8 +204,6 @@ export interface InternalAdapter<
196
204
 
197
205
  findAccounts(userId: string): Promise<Account[]>;
198
206
 
199
- findAccount(accountId: string): Promise<Account | null>;
200
-
201
207
  findAccountByProviderId(
202
208
  accountId: string,
203
209
  providerId: string,
@@ -220,9 +226,11 @@ export interface InternalAdapter<
220
226
  * Atomically consume a single-use verification row by `identifier` and
221
227
  * return it. Only the first concurrent caller receives the latest row;
222
228
  * subsequent callers receive `null`. Consuming one row invalidates the
223
- * whole identifier so stale rows cannot be replayed. Callers MUST gate any
224
- * state change (issue session, mint token, change password) on a non-null
225
- * result.
229
+ * whole identifier so stale rows cannot be replayed. Rows past their
230
+ * `expiresAt` are treated as already invalid: the row is deleted but
231
+ * `null` is returned, so callers do not need to gate on `expiresAt`
232
+ * themselves. Callers MUST gate any state change (issue session, mint
233
+ * token, change password) on a non-null result.
226
234
  *
227
235
  * Replaces the racy `findVerificationValue` + `deleteVerificationByIdentifier`
228
236
  * pair at single-use credential consumption sites.
@@ -319,7 +327,7 @@ export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> =
319
327
  * - "cookie": Store state in an encrypted cookie (stateless)
320
328
  * - "database": Store state in the database
321
329
  *
322
- * @default "cookie"
330
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
323
331
  */
324
332
  storeStateStrategy: "database" | "cookie";
325
333
  };
@@ -1093,7 +1093,11 @@ export type BetterAuthOptions = {
1093
1093
  */
1094
1094
  allowUnlinkingAll?: boolean;
1095
1095
  /**
1096
- * If enabled (true), this will update the user information based on the newly linked account
1096
+ * When enabled, linking an account copies the provider's profile onto
1097
+ * the local user, matching the fields persisted on sign-up (`name`,
1098
+ * `image`, and any `mapProfileToUser` fields). The local `email` and
1099
+ * `emailVerified` are never changed, so a link cannot rebind the
1100
+ * account's identity.
1097
1101
  *
1098
1102
  * @default false
1099
1103
  */
@@ -1126,7 +1130,7 @@ export type BetterAuthOptions = {
1126
1130
  * - "cookie": Store state in an encrypted cookie (stateless)
1127
1131
  * - "database": Store state in the database
1128
1132
  *
1129
- * @default "cookie"
1133
+ * @default "database" when `database` or `secondaryStorage` is configured, "cookie" otherwise
1130
1134
  */
1131
1135
  storeStateStrategy?: "database" | "cookie";
1132
1136
  /**
@@ -0,0 +1,45 @@
1
+ import * as z from "zod";
2
+ import { isLoopbackHost } from "./host";
3
+ import { DANGEROUS_URL_SCHEMES } from "./url";
4
+
5
+ /**
6
+ * Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
7
+ * server stores and later hands back to a browser.
8
+ *
9
+ * - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
10
+ * - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
11
+ * `*.localhost` per RFC 6761), where HTTP is allowed for local development.
12
+ * - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
13
+ *
14
+ * This is the single source of truth for redirect-URI validation across the
15
+ * OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
16
+ * rather than re-implementing the scheme policy per plugin.
17
+ */
18
+ export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
19
+ if (!URL.canParse(val)) {
20
+ ctx.addIssue({
21
+ code: "custom",
22
+ message: "URL must be parseable",
23
+ fatal: true,
24
+ });
25
+ return z.NEVER;
26
+ }
27
+
28
+ const u = new URL(val);
29
+
30
+ if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
31
+ ctx.addIssue({
32
+ code: "custom",
33
+ message: "URL cannot use javascript:, data:, or vbscript: scheme",
34
+ });
35
+ return;
36
+ }
37
+
38
+ if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
39
+ ctx.addIssue({
40
+ code: "custom",
41
+ message:
42
+ "Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)",
43
+ });
44
+ }
45
+ });
@@ -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
+ }
package/src/utils/url.ts CHANGED
@@ -41,3 +41,28 @@ export function normalizePathname(
41
41
 
42
42
  return pathname;
43
43
  }
44
+
45
+ /**
46
+ * Schemes that execute or embed code when navigated to or accepted as a
47
+ * redirect target. These are never safe as an OAuth `redirect_uri` or as a
48
+ * client-side navigation target (`window.location.href`, `location.assign`, ...).
49
+ */
50
+ export const DANGEROUS_URL_SCHEMES = ["javascript:", "data:", "vbscript:"];
51
+
52
+ /**
53
+ * Returns `false` only when `value` is an absolute URL using a dangerous scheme
54
+ * (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
55
+ * safe absolute schemes (`http`, `https`, custom app schemes such as
56
+ * `myapp://`) return `true`.
57
+ *
58
+ * Use this to guard browser navigation sinks and any redirect target that may
59
+ * originate from untrusted input. It is intentionally narrow: it blocks code
60
+ * execution schemes without rejecting relative paths or mobile deep links.
61
+ */
62
+ export function isSafeUrlScheme(value: string): boolean {
63
+ if (!URL.canParse(value)) {
64
+ // Relative URLs carry no scheme to abuse.
65
+ return true;
66
+ }
67
+ return !DANGEROUS_URL_SCHEMES.includes(new URL(value).protocol);
68
+ }