better-auth 1.7.6 → 1.7.7

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.
@@ -65,7 +65,10 @@ function rateLimitResponse(retryAfter) {
65
65
  return new Response(JSON.stringify({ message: "Too many requests. Please try again later." }), {
66
66
  status: 429,
67
67
  statusText: "Too Many Requests",
68
- headers: { "X-Retry-After": retryAfter.toString() }
68
+ headers: {
69
+ "Content-Type": "application/json",
70
+ "X-Retry-After": retryAfter.toString()
71
+ }
69
72
  });
70
73
  }
71
74
  function getRetryAfter(lastRequest, window) {
@@ -10,6 +10,7 @@ import { generateIdTokenNonce, generateState } from "../../oauth2/state.mjs";
10
10
  import { safeCloneRequest } from "../../utils/request.mjs";
11
11
  import { createEmailVerificationToken } from "./email-verification.mjs";
12
12
  import { assertPasswordNotTooLong } from "../../utils/password.mjs";
13
+ import "../../utils/index.mjs";
13
14
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
14
15
  import { additionalAuthorizationParamsSchema, supportsIdTokenSignIn, verifyProviderIdToken } from "@better-auth/core/oauth2";
15
16
  import { SocialProviderListEnum } from "@better-auth/core/social-providers";
@@ -193,7 +194,7 @@ const signInSocial = () => createAuthEndpoint("/sign-in/social", {
193
194
  idToken: token
194
195
  },
195
196
  callbackURL: c.body.callbackURL,
196
- disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.disableSignUp,
197
+ disableSignUp: provider.disableImplicitSignUp && !c.body.requestSignUp || provider.options?.disableSignUp || provider.disableSignUp,
197
198
  source: {
198
199
  method: "oauth",
199
200
  oauth: {
@@ -19,6 +19,7 @@ const resolvePublicAuthUrl = (basePath) => {
19
19
  } catch {}
20
20
  }
21
21
  };
22
+ const matchesSessionSignal = (path) => path === "/sign-out" || path === "/update-user" || path === "/update-session" || path === "/sign-up/email" || path === "/sign-in/email" || path === "/delete-user" || path === "/verify-email" || path === "/revoke-sessions" || path === "/revoke-session" || path === "/revoke-other-sessions" || path === "/change-email" || path === "/change-password";
22
23
  const getClientConfig = (options, loadEnv) => {
23
24
  const isCredentialsSupported = "credentials" in Request.prototype;
24
25
  const baseURL = getBaseURL(options?.baseURL, options?.basePath, void 0, loadEnv) ?? resolvePublicAuthUrl(options?.basePath) ?? "/api/auth";
@@ -72,9 +73,7 @@ const getClientConfig = (options, loadEnv) => {
72
73
  };
73
74
  const atomListeners = [{
74
75
  signal: "$sessionSignal",
75
- matcher(path) {
76
- return path === "/sign-out" || path === "/update-user" || path === "/update-session" || path === "/sign-up/email" || path === "/sign-in/email" || path === "/delete-user" || path === "/verify-email" || path === "/revoke-sessions" || path === "/revoke-session" || path === "/revoke-other-sessions" || path === "/change-email" || path === "/change-password";
77
- },
76
+ matcher: matchesSessionSignal,
78
77
  callback(path) {
79
78
  if (path === "/sign-out") broadcastSessionUpdate("signout");
80
79
  else if (path === "/update-user" || path === "/update-session") broadcastSessionUpdate("updateUser");
@@ -110,4 +109,4 @@ const getClientConfig = (options, loadEnv) => {
110
109
  };
111
110
  };
112
111
  //#endregion
113
- export { getClientConfig };
112
+ export { getClientConfig, matchesSessionSignal };
@@ -0,0 +1,19 @@
1
+ import { hkdf } from "@noble/hashes/hkdf.js";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
4
+ //#region src/crypto/purpose.ts
5
+ const salt = utf8ToBytes("better-auth:oauth-encryption:v1");
6
+ function deriveSecret(secret, purpose) {
7
+ return bytesToHex(hkdf(sha256, utf8ToBytes(secret), salt, utf8ToBytes(`better-auth:${purpose}:v1`), 32));
8
+ }
9
+ /** Derive an isolated key for one OAuth ciphertext purpose, retaining rotation versions. */
10
+ function derivePurposeKey(secret, purpose) {
11
+ if (typeof secret === "string") return deriveSecret(secret, purpose);
12
+ return {
13
+ keys: new Map(Array.from(secret.keys, ([version, value]) => [version, deriveSecret(value, purpose)])),
14
+ currentVersion: secret.currentVersion,
15
+ ...secret.legacySecret && { legacySecret: deriveSecret(secret.legacySecret, purpose) }
16
+ };
17
+ }
18
+ //#endregion
19
+ export { derivePurposeKey };
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@ import { getBaseURL, getHost, getHostFromSource, getOrigin, getProtocol, getProt
2
2
  import { generateGenericState, parseGenericState } from "./state.mjs";
3
3
  import { generateIdTokenNonce, generateState, parseState } from "./oauth2/state.mjs";
4
4
  import { HIDE_METADATA } from "./utils/hide-metadata.mjs";
5
+ import "./utils/index.mjs";
5
6
  import { APIError } from "./api/index.mjs";
6
7
  import { betterAuth } from "./auth/full.mjs";
7
8
  import { getCurrentAdapter } from "@better-auth/core/context";
package/dist/package.mjs CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "1.7.6";
2
+ var version = "1.7.7";
3
3
  //#endregion
4
4
  export { version };
@@ -23,6 +23,20 @@ const magicLinkVerifyQuerySchema = z.object({
23
23
  errorCallbackURL: z.string().meta({ description: "URL to redirect after error." }).optional(),
24
24
  newUserCallbackURL: z.string().meta({ description: "URL to redirect after new user signup. Only used if the user is registering for the first time." }).optional()
25
25
  });
26
+ const magicLinkRecordSchema = z.object({
27
+ type: z.literal("magic-link"),
28
+ email: z.email(),
29
+ name: z.string().optional()
30
+ }).strict();
31
+ function parseMagicLinkRecord(value) {
32
+ try {
33
+ const parsed = JSON.parse(value);
34
+ return magicLinkRecordSchema.safeParse(parsed);
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+ const magicLinkIdentifier = (storedToken) => `magic-link:${storedToken}`;
26
40
  const magicLink = (options) => {
27
41
  const opts = {
28
42
  storeToken: "plain",
@@ -75,8 +89,9 @@ const magicLink = (options) => {
75
89
  const verificationToken = opts?.generateToken ? await opts.generateToken(email) : generateRandomString(32, "a-z", "A-Z");
76
90
  const storedToken = await storeToken(ctx, verificationToken);
77
91
  await ctx.context.internalAdapter.createVerificationValue({
78
- identifier: storedToken,
92
+ identifier: magicLinkIdentifier(storedToken),
79
93
  value: JSON.stringify({
94
+ type: "magic-link",
80
95
  email,
81
96
  name: ctx.body.name
82
97
  }),
@@ -152,10 +167,14 @@ const magicLink = (options) => {
152
167
  throw ctx.redirect(errorCallbackURL.toString());
153
168
  }
154
169
  const newUserCallbackURL = new URL(ctx.query.newUserCallbackURL ? decodeURIComponent(ctx.query.newUserCallbackURL) : callbackURL, ctx.context.baseURL).toString();
155
- const storedToken = await storeToken(ctx, token);
156
- const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(storedToken);
170
+ const identifier = magicLinkIdentifier(await storeToken(ctx, token));
171
+ const pendingValue = await ctx.context.internalAdapter.findVerificationValue(identifier);
172
+ if (!pendingValue || !parseMagicLinkRecord(pendingValue.value)?.success) redirectWithError("INVALID_TOKEN");
173
+ const tokenValue = await ctx.context.internalAdapter.consumeVerificationValue(identifier);
157
174
  if (!tokenValue) redirectWithError("INVALID_TOKEN");
158
- const { email, name } = JSON.parse(tokenValue.value);
175
+ const record = parseMagicLinkRecord(tokenValue.value);
176
+ if (!record?.success) redirectWithError("INVALID_TOKEN");
177
+ const { email, name } = record.data;
159
178
  let isNewUser = false;
160
179
  let user = await ctx.context.internalAdapter.findUserByEmail(email).then((res) => res?.user);
161
180
  if (!user) if (!opts.disableSignUp) {
@@ -41,12 +41,11 @@ interface OAuthProxyOptions {
41
41
  * A dedicated secret used to encrypt and decrypt data passed between
42
42
  * servers during the OAuth proxy flow.
43
43
  *
44
- * When set, this secret is used **instead of** the global
45
- * `BETTER_AUTH_SECRET` for all OAuth proxy encryption operations.
46
- * This limits the blast radius if the secret is shared across
47
- * environments (production, preview, development): a leaked proxy
48
- * secret cannot be used to forge sessions or decrypt other data
49
- * protected by the main secret.
44
+ * When set, keys derived from this secret are used **instead of** keys
45
+ * derived from `BETTER_AUTH_SECRET` for OAuth proxy encryption.
46
+ * This limits exposure of data protected only by the main secret if
47
+ * the proxy secret is shared across environments (production, preview,
48
+ * development). A compromised proxy secret can still affect OAuth flows.
50
49
  *
51
50
  * All environments participating in the OAuth proxy flow must share
52
51
  * the same `secret` value.
@@ -9,7 +9,8 @@ import { redirectOnError } from "../../oauth2/errors.mjs";
9
9
  import { getOAuthCallbackPath } from "../../oauth2/utils.mjs";
10
10
  import { handleOAuthUserInfo, linkOAuthAccount } from "../../oauth2/link-account.mjs";
11
11
  import { setOAuthState } from "../../api/state/oauth.mjs";
12
- import { parseGenericState } from "../../state.mjs";
12
+ import { derivePurposeKey } from "../../crypto/purpose.mjs";
13
+ import { getAuthStateVerificationIdentifier, parseGenericState } from "../../state.mjs";
13
14
  import { PACKAGE_VERSION } from "../../version.mjs";
14
15
  import { parseJSON } from "../../client/parser.mjs";
15
16
  import { checkSkipProxy, resolveCurrentURL, stripTrailingSlash } from "./utils.mjs";
@@ -66,7 +67,7 @@ const oauthCallbackQuerySchema = z.object({
66
67
  });
67
68
  const oAuthProxy = (opts) => {
68
69
  const maxAge = opts?.maxAge ?? 60;
69
- const getEncryptionKey = (ctx) => opts?.secret ?? ctx.context.secretConfig;
70
+ const getEncryptionKey = (ctx, purpose) => derivePurposeKey(opts?.secret ?? ctx.context.secretConfig, purpose);
70
71
  const oauthProxyCompletion = createAuthEndpoint("/callback/:id/oauth-proxy", {
71
72
  method: "GET",
72
73
  operationId: "oauthProxyCompletion",
@@ -84,7 +85,7 @@ const oAuthProxy = (opts) => {
84
85
  let decryptedPayload;
85
86
  try {
86
87
  decryptedPayload = await symmetricDecrypt({
87
- key: getEncryptionKey(ctx),
88
+ key: getEncryptionKey(ctx, "oauth-proxy-profile"),
88
89
  data: encryptedProfile
89
90
  });
90
91
  } catch (e) {
@@ -225,7 +226,7 @@ const oAuthProxy = (opts) => {
225
226
  let statePackage;
226
227
  try {
227
228
  statePackage = parseJSON(await symmetricDecrypt({
228
- key: getEncryptionKey(ctx),
229
+ key: getEncryptionKey(ctx, "oauth-proxy-package"),
229
230
  data: state
230
231
  }));
231
232
  } catch {
@@ -245,7 +246,7 @@ const oAuthProxy = (opts) => {
245
246
  let stateData;
246
247
  try {
247
248
  stateData = parseJSON(await symmetricDecrypt({
248
- key: getEncryptionKey(ctx),
249
+ key: getEncryptionKey(ctx, "oauth-proxy-state"),
249
250
  data: statePackage.stateCookie
250
251
  }));
251
252
  } catch (e) {
@@ -338,7 +339,7 @@ const oAuthProxy = (opts) => {
338
339
  timestamp: Date.now()
339
340
  };
340
341
  const encryptedPayload = await symmetricEncrypt({
341
- key: getEncryptionKey(ctx),
342
+ key: getEncryptionKey(ctx, "oauth-proxy-profile"),
342
343
  data: JSON.stringify(payload)
343
344
  });
344
345
  proxyCallbackURL.searchParams.set("profile", encryptedPayload);
@@ -366,11 +367,11 @@ const oAuthProxy = (opts) => {
366
367
  const oauthStateCookie = ctx.context.createAuthCookie("oauth_state");
367
368
  const encryptedCookieValue = parseSetCookieHeader(setCookieHeader).get(oauthStateCookie.name)?.value;
368
369
  if (encryptedCookieValue) plaintextState = await symmetricDecrypt({
369
- key: ctx.context.secretConfig,
370
+ key: derivePurposeKey(ctx.context.secretConfig, "oauth-state-cookie"),
370
371
  data: encryptedCookieValue
371
372
  });
372
373
  }
373
- } else plaintextState = (await ctx.context.internalAdapter.findVerificationValue(originalState))?.value;
374
+ } else plaintextState = (await ctx.context.internalAdapter.findVerificationValue(getAuthStateVerificationIdentifier(originalState)))?.value;
374
375
  if (!plaintextState) {
375
376
  ctx.context.logger.warn("No OAuth state found for proxy");
376
377
  return;
@@ -378,13 +379,13 @@ const oAuthProxy = (opts) => {
378
379
  const statePackage = {
379
380
  state: originalState,
380
381
  stateCookie: await symmetricEncrypt({
381
- key: getEncryptionKey(ctx),
382
+ key: getEncryptionKey(ctx, "oauth-proxy-state"),
382
383
  data: plaintextState
383
384
  }),
384
385
  isOAuthProxy: true
385
386
  };
386
387
  const encryptedPackage = await symmetricEncrypt({
387
- key: getEncryptionKey(ctx),
388
+ key: getEncryptionKey(ctx, "oauth-proxy-package"),
388
389
  data: JSON.stringify(statePackage)
389
390
  });
390
391
  oauthURL.searchParams.set("state", encryptedPackage);
@@ -1,4 +1,5 @@
1
1
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
2
+ import "../../utils/index.mjs";
2
3
  import { APIError } from "../../api/index.mjs";
3
4
  import { PACKAGE_VERSION } from "../../version.mjs";
4
5
  import { generator } from "./generator.mjs";
@@ -1,5 +1,6 @@
1
1
  import { PACKAGE_VERSION } from "../../version.mjs";
2
2
  import { useAuthQuery } from "../../client/query.mjs";
3
+ import { matchesSessionSignal } from "../../client/config.mjs";
3
4
  import { adminAc, defaultRoles, memberAc, ownerAc } from "./access/statement.mjs";
4
5
  import { ORGANIZATION_ERROR_CODES } from "./error-codes.mjs";
5
6
  import { hasPermissionFn } from "./permission.mjs";
@@ -71,7 +72,7 @@ const organizationClient = (options) => {
71
72
  },
72
73
  {
73
74
  matcher(path) {
74
- return path === "/sign-out" || path.startsWith("/organization");
75
+ return matchesSessionSignal(path) || path.startsWith("/organization");
75
76
  },
76
77
  signal: "$activeOrgSignal"
77
78
  },
@@ -5,6 +5,7 @@ import { setSessionCookie } from "../../cookies/index.mjs";
5
5
  import { getSessionFromCtx } from "../../api/routes/session.mjs";
6
6
  import { HIDE_METADATA } from "../../utils/hide-metadata.mjs";
7
7
  import { assertPasswordNotTooLong, assertPasswordNotTooShort } from "../../utils/password.mjs";
8
+ import "../../utils/index.mjs";
8
9
  import { PHONE_NUMBER_ERROR_CODES } from "./error-codes.mjs";
9
10
  import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
10
11
  import { createAuthEndpoint } from "@better-auth/core/api";
package/dist/state.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { generateRandomString } from "./crypto/random.mjs";
2
2
  import { symmetricDecrypt, symmetricEncrypt } from "./crypto/index.mjs";
3
3
  import { expireCookie } from "./cookies/index.mjs";
4
+ import { derivePurposeKey } from "./crypto/purpose.mjs";
4
5
  import { BetterAuthError } from "@better-auth/core/error";
5
6
  import * as z from "zod";
6
7
  //#region src/state.ts
@@ -32,6 +33,8 @@ const stateDataSchema = z.looseObject({
32
33
  */
33
34
  serverContext: z.record(z.string(), z.unknown()).optional()
34
35
  });
36
+ /** Verification key for database-backed OAuth and SAML relay state. */
37
+ const getAuthStateVerificationIdentifier = (state) => `auth-state:${state}`;
35
38
  const INTERNAL_STATE_KEYS = new Set(Object.keys(stateDataSchema.shape));
36
39
  var StateError = class extends BetterAuthError {
37
40
  code;
@@ -59,7 +62,7 @@ async function generateGenericState(c, stateData, settings) {
59
62
  oauthState: state
60
63
  };
61
64
  const encryptedData = await symmetricEncrypt({
62
- key: c.context.secretConfig,
65
+ key: derivePurposeKey(c.context.secretConfig, "oauth-state-cookie"),
63
66
  data: JSON.stringify(payload)
64
67
  });
65
68
  const stateCookie = c.context.createAuthCookie(settings?.cookieName ?? "oauth_state", { maxAge: 600 });
@@ -78,7 +81,7 @@ async function generateGenericState(c, stateData, settings) {
78
81
  ...stateData,
79
82
  oauthState: state
80
83
  }),
81
- identifier: state,
84
+ identifier: getAuthStateVerificationIdentifier(state),
82
85
  expiresAt
83
86
  })) throw new StateError("Unable to create verification. Make sure the database adapter is properly working and there is a verification table in the database", { code: "state_generation_error" });
84
87
  return {
@@ -99,7 +102,7 @@ async function parseGenericState(c, state, settings) {
99
102
  });
100
103
  try {
101
104
  const decryptedData = await symmetricDecrypt({
102
- key: c.context.secretConfig,
105
+ key: derivePurposeKey(c.context.secretConfig, "oauth-state-cookie"),
103
106
  data: encryptedData
104
107
  });
105
108
  parsedData = stateDataSchema.parse(JSON.parse(decryptedData));
@@ -117,7 +120,7 @@ async function parseGenericState(c, state, settings) {
117
120
  });
118
121
  expireCookie(c, stateCookie);
119
122
  } else {
120
- const data = await c.context.internalAdapter.findVerificationValue(state);
123
+ const data = await c.context.internalAdapter.findVerificationValue(getAuthStateVerificationIdentifier(state));
121
124
  if (!data) throw new StateError("State mismatch: verification not found", {
122
125
  code: "state_mismatch",
123
126
  details: { state }
@@ -136,7 +139,7 @@ async function parseGenericState(c, state, settings) {
136
139
  errorURL: parsedData.errorURL
137
140
  });
138
141
  expireCookie(c, stateCookie);
139
- await c.context.internalAdapter.deleteVerificationByIdentifier(state);
142
+ await c.context.internalAdapter.deleteVerificationByIdentifier(getAuthStateVerificationIdentifier(state));
140
143
  }
141
144
  if (parsedData.expiresAt < Date.now()) throw new StateError("Invalid state: request expired", {
142
145
  code: "state_mismatch",
@@ -146,4 +149,4 @@ async function parseGenericState(c, state, settings) {
146
149
  return parsedData;
147
150
  }
148
151
  //#endregion
149
- export { INTERNAL_STATE_KEYS, StateError, generateGenericState, parseGenericState };
152
+ export { INTERNAL_STATE_KEYS, StateError, generateGenericState, getAuthStateVerificationIdentifier, parseGenericState };
@@ -1,5 +1,5 @@
1
- import { keccak_256 } from "@noble/hashes/sha3.js";
2
1
  import { utf8ToBytes } from "@noble/hashes/utils.js";
2
+ import { keccak_256 } from "@noble/hashes/sha3.js";
3
3
  //#region src/utils/hashing.ts
4
4
  /**
5
5
  * TS implementation of ERC-55 ("Mixed-case checksum address encoding") using @noble/hashes
@@ -0,0 +1,5 @@
1
+ import "./url.mjs";
2
+ import "../state.mjs";
3
+ import "../oauth2/state.mjs";
4
+ import "./hide-metadata.mjs";
5
+ export {};
@@ -2,6 +2,9 @@
2
2
  const middlewareResponse = ({ message, status, code }) => ({ response: new Response(JSON.stringify({
3
3
  message,
4
4
  code
5
- }), { status }) });
5
+ }), {
6
+ status,
7
+ headers: { "Content-Type": "application/json" }
8
+ }) });
6
9
  //#endregion
7
10
  export { middlewareResponse };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "better-auth",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -473,13 +473,13 @@
473
473
  "kysely": "^0.28.17 || ^0.29.0",
474
474
  "nanostores": "^1.3.0",
475
475
  "zod": "^4.5.4",
476
- "@better-auth/core": "1.7.6",
477
- "@better-auth/drizzle-adapter": "1.7.6",
478
- "@better-auth/kysely-adapter": "1.7.6",
479
- "@better-auth/memory-adapter": "1.7.6",
480
- "@better-auth/mongo-adapter": "1.7.6",
481
- "@better-auth/prisma-adapter": "1.7.6",
482
- "@better-auth/telemetry": "1.7.6"
476
+ "@better-auth/core": "1.7.7",
477
+ "@better-auth/drizzle-adapter": "1.7.7",
478
+ "@better-auth/kysely-adapter": "1.7.7",
479
+ "@better-auth/memory-adapter": "1.7.7",
480
+ "@better-auth/mongo-adapter": "1.7.7",
481
+ "@better-auth/prisma-adapter": "1.7.7",
482
+ "@better-auth/telemetry": "1.7.7"
483
483
  },
484
484
  "devDependencies": {
485
485
  "@lynx-js/react": "^0.121.2",