@better-auth/core 1.6.16 → 1.6.18

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.
@@ -272,6 +272,9 @@ declare const createAuthMiddleware: {
272
272
  type EndpointHandler<Path extends string, Options extends EndpointOptions, R> = (context: EndpointContext<Path, Options, AuthContext>) => Promise<R>;
273
273
  declare function createAuthEndpoint<Path extends string, Options extends EndpointOptions, R>(path: Path, options: Options, handler: EndpointHandler<Path, Options, R>): StrictEndpoint<Path, Options, R>;
274
274
  declare function createAuthEndpoint<Path extends string, Options extends EndpointOptions, R>(options: Options, handler: EndpointHandler<Path, Options, R>): StrictEndpoint<Path, Options, R>;
275
+ declare namespace createAuthEndpoint {
276
+ var serverOnly: <Path extends string, Options extends EndpointOptions, R>(options: Options, handler: EndpointHandler<Path, Options, R>) => StrictEndpoint<Path, Options, R>;
277
+ }
275
278
  type AuthEndpoint<Path extends string, Opts extends EndpointOptions, R> = ReturnType<typeof createAuthEndpoint<Path, Opts, R>>;
276
279
  type AuthMiddleware = ReturnType<typeof createAuthMiddleware>;
277
280
  //#endregion
@@ -52,5 +52,41 @@ function createAuthEndpoint(pathOrOptions, handlerOrOptions, handlerOrNever) {
52
52
  use: [...options?.use || [], ...use]
53
53
  }, wrapped);
54
54
  }
55
+ /**
56
+ * Set `metadata.SERVER_ONLY` while preserving any existing metadata
57
+ * (`$Infer`, `openapi`, ...).
58
+ */
59
+ function withServerOnly(options) {
60
+ return {
61
+ ...options,
62
+ metadata: {
63
+ ...options.metadata,
64
+ SERVER_ONLY: true
65
+ }
66
+ };
67
+ }
68
+ /**
69
+ * Declare a **server-only** endpoint.
70
+ *
71
+ * The endpoint is callable through `auth.api.*` from trusted server code but is
72
+ * never registered on the HTTP router and never emitted into the OpenAPI
73
+ * schema. It takes no path because it has no URL to be reached at.
74
+ *
75
+ * Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
76
+ * Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
77
+ * keeps the endpoint off the HTTP surface even if a path is later added by
78
+ * mistake: better-call's router skips an endpoint when its path is missing *or*
79
+ * when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
80
+ * on path omission alone is invisible and one keystroke away from exposure.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * viewBackupCodes: createAuthEndpoint.serverOnly(
85
+ * { method: "POST", body: schema },
86
+ * async (ctx) => { ... },
87
+ * )
88
+ * ```
89
+ */
90
+ createAuthEndpoint.serverOnly = (options, handler) => createAuthEndpoint(withServerOnly(options), handler);
55
91
  //#endregion
56
92
  export { createAuthEndpoint, createAuthMiddleware, optionsMiddleware };
@@ -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.16";
5
+ const __betterAuthVersion = "1.6.18";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -58,6 +58,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
58
58
  else if (method === "delete" && !config.debugLogs.delete) return;
59
59
  else if (method === "deleteMany" && !config.debugLogs.deleteMany) return;
60
60
  else if (method === "consumeOne" && !config.debugLogs.consumeOne) return;
61
+ else if (method === "incrementOne" && !config.debugLogs.incrementOne) return;
61
62
  else if (method === "count" && !config.debugLogs.count) return;
62
63
  }
63
64
  logger.info(`[${config.adapterName}]`, ...args);
@@ -738,6 +739,87 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
738
739
  });
739
740
  return transformed;
740
741
  },
742
+ incrementOne: async ({ model: unsafeModel, where: unsafeWhere, increment: unsafeIncrement, set: unsafeSet }) => {
743
+ transactionId++;
744
+ const thisTransactionId = transactionId;
745
+ const model = getModelName(unsafeModel);
746
+ const where = transformWhereClause({
747
+ model: unsafeModel,
748
+ where: unsafeWhere,
749
+ action: "incrementOne"
750
+ });
751
+ unsafeModel = getDefaultModelName(unsafeModel);
752
+ debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`, `${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`, {
753
+ model,
754
+ where,
755
+ increment: unsafeIncrement,
756
+ set: unsafeSet
757
+ });
758
+ let res;
759
+ let resultNeedsOutputTransform = true;
760
+ if (adapterInstance.incrementOne) {
761
+ const mappedKeys = config.mapKeysTransformInput ?? {};
762
+ const increment = {};
763
+ for (const [field, delta] of Object.entries(unsafeIncrement)) increment[mappedKeys[field] || getFieldName({
764
+ model: unsafeModel,
765
+ field
766
+ })] = delta;
767
+ let set;
768
+ if (unsafeSet && !config.disableTransformInput) set = await transformInput(unsafeSet, unsafeModel, "update");
769
+ else set = unsafeSet;
770
+ res = await withSpan(`db incrementOne ${model}`, {
771
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
772
+ [ATTR_DB_COLLECTION_NAME]: model
773
+ }, () => adapterInstance.incrementOne({
774
+ model,
775
+ where,
776
+ increment,
777
+ set
778
+ }));
779
+ } else {
780
+ res = await withSpan(`db incrementOne ${model}`, {
781
+ [ATTR_DB_OPERATION_NAME]: "incrementOne",
782
+ [ATTR_DB_COLLECTION_NAME]: model
783
+ }, () => adapter.transaction(async (trx) => {
784
+ const target = (await trx.findMany({
785
+ model: unsafeModel,
786
+ where: unsafeWhere,
787
+ limit: 1
788
+ }))[0];
789
+ if (!target) return null;
790
+ const nextValues = { ...unsafeSet ?? {} };
791
+ for (const [field, delta] of Object.entries(unsafeIncrement)) nextValues[field] = (typeof target[field] === "number" ? target[field] : 0) + delta;
792
+ const updated = await trx.updateMany({
793
+ model: unsafeModel,
794
+ where: [...unsafeWhere, {
795
+ field: "id",
796
+ value: target.id,
797
+ operator: "eq",
798
+ connector: "AND",
799
+ mode: "sensitive"
800
+ }],
801
+ update: nextValues
802
+ });
803
+ if (typeof updated !== "number") throw new BetterAuthError(`Adapter "${config.adapterId}" returned a non-numeric value from updateMany during the incrementOne fallback. Return the number of updated rows, or implement a native incrementOne for atomic guarded counter updates.`);
804
+ return updated > 0 ? {
805
+ ...target,
806
+ ...nextValues
807
+ } : null;
808
+ }));
809
+ resultNeedsOutputTransform = false;
810
+ }
811
+ debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`, `${formatMethod("incrementOne")} ${formatAction("DB Result")}:`, {
812
+ model,
813
+ data: res
814
+ });
815
+ let transformed = res;
816
+ if (!config.disableTransformOutput && resultNeedsOutputTransform && res) transformed = await transformOutput(res, unsafeModel, void 0, void 0);
817
+ debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`, `${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`, {
818
+ model,
819
+ data: transformed
820
+ });
821
+ return transformed;
822
+ },
741
823
  count: async ({ model: unsafeModel, where: unsafeWhere }) => {
742
824
  transactionId++;
743
825
  const thisTransactionId = transactionId;
@@ -23,6 +23,7 @@ type DBAdapterDebugLogOption = boolean | {
23
23
  delete?: boolean | undefined;
24
24
  deleteMany?: boolean | undefined;
25
25
  consumeOne?: boolean | undefined;
26
+ incrementOne?: boolean | undefined;
26
27
  count?: boolean | undefined;
27
28
  } | {
28
29
  /**
@@ -198,7 +199,7 @@ interface DBAdapterFactoryConfig<Options extends BetterAuthOptions = BetterAuthO
198
199
  /**
199
200
  * The action which was called from the adapter.
200
201
  */
201
- action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
202
+ action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "incrementOne" | "count";
202
203
  /**
203
204
  * The model name.
204
205
  */
@@ -436,6 +437,36 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
436
437
  model: string;
437
438
  where: Where[];
438
439
  }) => Promise<T | null>;
440
+ /**
441
+ * Atomically apply signed numeric deltas to a single row matching the where
442
+ * clause. For each entry in `increment`, the operation applies
443
+ * `field = field + delta` in one atomic step; a negative delta decrements.
444
+ *
445
+ * The `where` clause is both the selector AND the guard: comparison
446
+ * operators are honored, so passing `{ field: "remaining", operator: "gt",
447
+ * value: 0 }` only mutates the row while `remaining` is still above zero.
448
+ * When the guard matches no row, the operation makes no change and returns
449
+ * `null`.
450
+ *
451
+ * The optional `set` map assigns absolute values to fields in the same
452
+ * atomic operation, alongside the increments.
453
+ *
454
+ * Returns the updated row, or `null` when the guard matched no row. Under
455
+ * concurrent invocation against the same row, this is the race-safe
456
+ * primitive for guarded counter updates (e.g. decrementing a remaining-uses
457
+ * counter only while it is still positive).
458
+ *
459
+ * Always defined on the factory-wrapped adapter. When the underlying
460
+ * `CustomAdapter` does not implement `incrementOne`, the factory provides a
461
+ * fallback that wraps `findMany + updateMany` in `transaction(...)` and
462
+ * re-applies the where clause as a compare-and-swap guard on the update.
463
+ */
464
+ incrementOne: <T>(data: {
465
+ model: string;
466
+ where: Where[];
467
+ increment: Record<string, number>;
468
+ set?: Record<string, unknown> | undefined;
469
+ }) => Promise<T | null>;
439
470
  /**
440
471
  * Execute multiple operations in a transaction.
441
472
  * If the adapter doesn't support transactions, operations will be executed sequentially.
@@ -530,6 +561,25 @@ interface CustomAdapter {
530
561
  model: string;
531
562
  where: CleanedWhere[];
532
563
  }) => Promise<T | null>;
564
+ /**
565
+ * Optional native atomic guarded counter mutation. Applies
566
+ * `field = field + delta` for each entry in `increment` (negative deltas
567
+ * decrement), with `where` acting as both selector and guard and `set`
568
+ * assigning absolute values in the same operation. Returns the updated row,
569
+ * or `null` when the guard matched no row.
570
+ *
571
+ * Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
572
+ * RETURNING *`) gives one round trip and the strongest race-safety
573
+ * guarantee. When omitted, the adapter factory provides a transaction-based
574
+ * fallback over `findMany + updateMany`. TODO(increment-one-required):
575
+ * tighten to required in the next minor on `next`.
576
+ */
577
+ incrementOne?: <T>(data: {
578
+ model: string;
579
+ where: CleanedWhere[];
580
+ increment: Record<string, number>;
581
+ set?: Record<string, unknown> | undefined;
582
+ }) => Promise<T | null>;
533
583
  count: ({
534
584
  model,
535
585
  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" | "consumeOne" | "count";
97
+ action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "incrementOne" | "count";
98
98
  }) => W extends undefined ? undefined : CleanedWhere[];
99
99
  }) => CustomAdapter;
100
100
  type AdapterTestDebugLogs = {
@@ -153,6 +153,21 @@ interface SecondaryStorage {
153
153
  * security-sensitive consume paths.
154
154
  */
155
155
  getAndDelete?: (key: string) => Awaitable<unknown>;
156
+ /**
157
+ * Atomically increment the counter at `key` by one, returning the
158
+ * post-increment value.
159
+ *
160
+ * When the key is absent, it is created with a value of `1` and the given
161
+ * `ttl` (in SECONDS). The TTL is applied only on creation; later increments
162
+ * never extend it, so the counter expires a fixed window after it was first
163
+ * created.
164
+ *
165
+ * This is optional for backwards compatibility with existing secondary
166
+ * storage implementations. TODO(secondary-storage-increment-required): make
167
+ * this required for secondary-storage-backed rate limiting in the next minor
168
+ * on `next`.
169
+ */
170
+ increment?: (key: string, ttl: number) => Awaitable<number>;
156
171
  set: (
157
172
  /**
158
173
  * Key to store
@@ -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.16";
5
+ const INSTRUMENTATION_VERSION = "1.6.18";
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
@@ -1,6 +1,18 @@
1
1
  import { JSONWebKeySet, JWTPayload, JWTVerifyOptions } from "jose";
2
2
 
3
3
  //#region src/oauth2/verify.d.ts
4
+ type JwksFetchOptions = {
5
+ /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
6
+ /**
7
+ * Stable object to cache the result of a function `jwksFetch` under,
8
+ * with the same TTL and kid-miss refetch rules as string sources.
9
+ * Without it, a function source is fetched on every verification.
10
+ */
11
+ jwksCacheKey?: object;
12
+ };
13
+ /**
14
+ * @internal
15
+ */
4
16
  interface VerifyAccessTokenRemote {
5
17
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
6
18
  introspectUrl: string;
@@ -34,13 +46,10 @@ interface VerifyAccessTokenRemote {
34
46
  *
35
47
  * Can also be configured for remote verification.
36
48
  */
37
- declare function verifyJwsAccessToken(token: string, opts: {
38
- /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>); /** Verify options */
39
- verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
49
+ declare function verifyJwsAccessToken(token: string, opts: JwksFetchOptions & {
50
+ /** Verify options */verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
40
51
  }): Promise<JWTPayload>;
41
- declare function getJwks(token: string, opts: {
42
- /** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
43
- }): Promise<JSONWebKeySet>;
52
+ declare function getJwks(token: string, opts: JwksFetchOptions): Promise<JSONWebKeySet>;
44
53
  /**
45
54
  * Performs local verification of an access token for your API.
46
55
  *
@@ -11,13 +11,46 @@ const joseInfrastructureErrorCodes = new Set([
11
11
  function isJoseInfrastructureError(error) {
12
12
  return joseInfrastructureErrorCodes.has(error.code);
13
13
  }
14
+ /**
15
+ * @internal
16
+ */
14
17
  const jwksCache = /* @__PURE__ */ new Map();
15
18
  /**
19
+ * Cache for function jwks sources, keyed by a caller-provided stable object.
20
+ * Entries are released with their key, so per-request keys cannot accumulate.
21
+ */
22
+ const functionJwksCache = /* @__PURE__ */ new WeakMap();
23
+ /**
16
24
  * How long a cached JWKS is trusted before it is refetched
17
25
  *
18
26
  * @internal
19
27
  */
20
28
  const JWKS_CACHE_TTL_MS = 300 * 1e3;
29
+ const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1e3;
30
+ /**
31
+ * Returns the cached key set when it is within the TTL. When the token carries
32
+ * `kid`, the cached set must contain that key id; without `kid`, key selection
33
+ * is deferred to JOSE because RFC 7515 makes the header parameter optional.
34
+ */
35
+ function getFreshJwksWithKid(cached, kid) {
36
+ if (!cached) return void 0;
37
+ if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return void 0;
38
+ if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) return;
39
+ return cached.jwks;
40
+ }
41
+ function shouldRefetchCachedJwksWithoutKid(error, resolved) {
42
+ if (!(resolved.fromCache && !resolved.kid && (error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JWSSignatureVerificationFailed))) return false;
43
+ if (!resolved.noKidRefetchedAt) return true;
44
+ return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
45
+ }
46
+ async function fetchJwks(jwksFetch) {
47
+ const jwks = typeof jwksFetch === "string" ? await betterFetch(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
48
+ if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
49
+ return res.data;
50
+ }) : await jwksFetch();
51
+ if (!jwks) throw new Error("No jwks found");
52
+ return jwks;
53
+ }
21
54
  /**
22
55
  * Performs local verification of an access token for your APIs.
23
56
  *
@@ -25,7 +58,17 @@ const JWKS_CACHE_TTL_MS = 300 * 1e3;
25
58
  */
26
59
  async function verifyJwsAccessToken(token, opts) {
27
60
  try {
28
- const jwt = await jwtVerify(token, createLocalJWKSet(await getJwks(token, opts)), opts.verifyOptions);
61
+ const resolved = await getJwksForVerification(token, opts);
62
+ let jwt;
63
+ try {
64
+ jwt = await jwtVerify(token, createLocalJWKSet(resolved.jwks), opts.verifyOptions);
65
+ } catch (error) {
66
+ if (shouldRefetchCachedJwksWithoutKid(error, resolved)) jwt = await jwtVerify(token, createLocalJWKSet((await getJwksForVerification(token, {
67
+ ...opts,
68
+ forceRefresh: true
69
+ })).jwks), opts.verifyOptions);
70
+ else throw error;
71
+ }
29
72
  if (jwt.payload.azp) jwt.payload.client_id = jwt.payload.azp;
30
73
  return jwt.payload;
31
74
  } catch (error) {
@@ -34,6 +77,9 @@ async function verifyJwsAccessToken(token, opts) {
34
77
  }
35
78
  }
36
79
  async function getJwks(token, opts) {
80
+ return (await getJwksForVerification(token, opts)).jwks;
81
+ }
82
+ async function getJwksForVerification(token, opts) {
37
83
  let jwtHeaders;
38
84
  try {
39
85
  jwtHeaders = decodeProtectedHeader(token);
@@ -41,25 +87,63 @@ async function getJwks(token, opts) {
41
87
  if (error instanceof Error) throw error;
42
88
  throw new Error(error);
43
89
  }
44
- if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
45
90
  const kid = jwtHeaders.kid;
91
+ if (typeof opts.jwksFetch !== "string") {
92
+ const cacheKey = opts.jwksCacheKey;
93
+ if (!cacheKey) {
94
+ const jwks = await opts.jwksFetch();
95
+ if (!jwks) throw new Error("No jwks found");
96
+ return {
97
+ jwks,
98
+ fromCache: false,
99
+ kid
100
+ };
101
+ }
102
+ const cached = functionJwksCache.get(cacheKey);
103
+ const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
104
+ if (cachedJwks) return {
105
+ jwks: cachedJwks,
106
+ fromCache: true,
107
+ kid,
108
+ noKidRefetchedAt: cached?.noKidRefetchedAt
109
+ };
110
+ const jwks = await opts.jwksFetch();
111
+ if (!jwks) throw new Error("No jwks found");
112
+ const fetchedAt = Date.now();
113
+ functionJwksCache.set(cacheKey, {
114
+ jwks,
115
+ fetchedAt,
116
+ ...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
117
+ });
118
+ return {
119
+ jwks,
120
+ fromCache: false,
121
+ kid
122
+ };
123
+ }
46
124
  const cacheKey = opts.jwksFetch;
47
125
  const cached = jwksCache.get(cacheKey);
48
- const isFresh = cached ? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS : false;
49
- const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
50
- if (!cached || !isFresh || !hasKid) {
51
- const jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
52
- if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
53
- return res.data;
54
- }) : await opts.jwksFetch();
55
- if (!jwks) throw new Error("No jwks found");
126
+ const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
127
+ if (!cachedJwks) {
128
+ const jwks = await fetchJwks(opts.jwksFetch);
129
+ const fetchedAt = Date.now();
56
130
  jwksCache.set(cacheKey, {
57
131
  jwks,
58
- fetchedAt: Date.now()
132
+ fetchedAt,
133
+ ...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
59
134
  });
60
- return jwks;
135
+ return {
136
+ jwks,
137
+ fromCache: false,
138
+ kid
139
+ };
61
140
  }
62
- return cached.jwks;
141
+ return {
142
+ jwks: cachedJwks,
143
+ fromCache: true,
144
+ kid,
145
+ noKidRefetchedAt: cached?.noKidRefetchedAt
146
+ };
63
147
  }
64
148
  /**
65
149
  * Performs local verification of an access token for your API.
@@ -151,7 +151,7 @@ declare const microsoft: (options: MicrosoftOptions) => {
151
151
  user?: {
152
152
  name?: {
153
153
  firstName?: string;
154
- lastName? /** The primary username that represents the user */: string;
154
+ lastName?: string;
155
155
  };
156
156
  email?: string;
157
157
  } | undefined;
@@ -8,9 +8,17 @@ import { base64 } from "@better-auth/utils/base64";
8
8
  import { betterFetch } from "@better-fetch/fetch";
9
9
  import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
10
10
  //#region src/social-providers/microsoft-entra-id.ts
11
+ /**
12
+ * Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
13
+ * personal-account token carries it as the `tid` claim, so it distinguishes the
14
+ * consumer account class from work/school tenants.
15
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
16
+ */
17
+ const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
11
18
  const microsoft = (options) => {
12
19
  const tenant = options.tenantId || "common";
13
- const authority = options.authority || "https://login.microsoftonline.com";
20
+ let authority = options.authority || "https://login.microsoftonline.com";
21
+ while (authority.endsWith("/")) authority = authority.slice(0, -1);
14
22
  const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
15
23
  const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
16
24
  return {
@@ -70,6 +78,10 @@ const microsoft = (options) => {
70
78
  if (tenant !== "common" && tenant !== "organizations" && tenant !== "consumers") verifyOptions.issuer = `${authority}/${tenant}/v2.0`;
71
79
  const { payload: jwtClaims } = await jwtVerify(token, publicKey, verifyOptions);
72
80
  if (nonce && jwtClaims.nonce !== nonce) return false;
81
+ const tid = jwtClaims.tid;
82
+ if (typeof tid !== "string" || jwtClaims.iss !== `${authority}/${tid}/v2.0`) return false;
83
+ if (tenant === "organizations" && tid === MICROSOFT_CONSUMER_TENANT_ID) return false;
84
+ if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) return false;
73
85
  return true;
74
86
  } catch (error) {
75
87
  logger.error("Failed to verify ID token:", error);
@@ -61,7 +61,7 @@ const reddit = (options) => {
61
61
  } });
62
62
  if (error) return null;
63
63
  const userMap = await options.mapProfileToUser?.(profile);
64
- const email = userMap?.email || `${profile.id}@reddit.com`;
64
+ const email = userMap?.email || `${profile.id}@reddit.invalid`;
65
65
  return {
66
66
  user: {
67
67
  id: profile.id,
@@ -66,7 +66,7 @@ const wechat = (options) => {
66
66
  user: {
67
67
  id: profile.unionid || profile.openid || openid,
68
68
  name: profile.nickname,
69
- email: profile.email || null,
69
+ email: profile.email || `${profile.unionid || profile.openid || openid}@wechat.invalid`,
70
70
  image: profile.headimgurl,
71
71
  emailVerified: false,
72
72
  ...userMap
@@ -134,6 +134,22 @@ interface InternalAdapter<_Options extends BetterAuthOptions = BetterAuthOptions
134
134
  * pair at single-use credential consumption sites.
135
135
  */
136
136
  consumeVerificationValue(identifier: string): Promise<Verification | null>;
137
+ /**
138
+ * First-writer-wins create keyed by a deterministic primary key derived from
139
+ * `identifier`. Returns `true` when this caller created the row and `false`
140
+ * when a row for the same identifier already existed.
141
+ *
142
+ * The dual of `consumeVerificationValue`: reserve races to create a marker
143
+ * exactly once, where consume races to delete one exactly once. Use it for
144
+ * replay tombstones (a SAML assertion id, a JWT `jti`) where the first caller
145
+ * wins. The database path is atomic via the primary key; the
146
+ * secondary-storage-only path is best-effort under concurrency.
147
+ */
148
+ reserveVerificationValue(data: {
149
+ identifier: string;
150
+ value: string;
151
+ expiresAt: Date;
152
+ }): Promise<boolean>;
137
153
  updateVerificationByIdentifier(identifier: string, data: Partial<Verification>): Promise<Verification>;
138
154
  refreshUserSessions(user: User): Promise<void>;
139
155
  }
@@ -73,6 +73,35 @@ type BaseURLConfig = string | DynamicBaseURLConfig;
73
73
  interface BetterAuthRateLimitStorage {
74
74
  get: (key: string) => Promise<RateLimit | null | undefined>;
75
75
  set: (key: string, value: RateLimit, update?: boolean | undefined) => Promise<void>;
76
+ /**
77
+ * Atomically records one request against `key` within the `window`
78
+ * (in seconds) and reports whether it is allowed.
79
+ *
80
+ * When `allowed` is true the request was counted within the active window;
81
+ * when `allowed` is false the limit was already reached and `retryAfter` is
82
+ * the number of seconds until the window frees up. Whether the window slides
83
+ * or is fixed depends on the backing storage: the database backend resets
84
+ * once the window elapses, while secondary storage uses a fixed time-to-live
85
+ * set when the window first opens.
86
+ *
87
+ * Performing the check and the increment in a single step closes the
88
+ * concurrent-bypass gap of the separate `get`/`set` path: N simultaneous
89
+ * requests can no longer all pass a stale read before any increment lands.
90
+ *
91
+ * Optional for backwards compatibility. A storage without it falls back to
92
+ * the legacy non-atomic `get`/`set` path, which is best-effort under
93
+ * concurrency.
94
+ *
95
+ * TODO(rate-limit-consume-required): make this the sole required member on
96
+ * `next`, dropping `get`/`set` and the non-atomic fallback.
97
+ */
98
+ consume?: (key: string, rule: {
99
+ window: number;
100
+ max: number;
101
+ }) => Promise<{
102
+ allowed: boolean;
103
+ retryAfter: number | null;
104
+ }>;
76
105
  }
77
106
  type BetterAuthRateLimitRule = {
78
107
  /**
@@ -1,10 +1,20 @@
1
1
  import { LiteralString } from "./helper.mjs";
2
- import { BetterAuthPlugin } from "./plugin.mjs";
3
2
  import { BetterAuthOptions } from "./init-options.mjs";
4
3
  import { BetterFetch, BetterFetchOption, BetterFetchPlugin } from "@better-fetch/fetch";
5
4
  import { Atom, WritableAtom } from "nanostores";
6
5
 
7
6
  //#region src/types/plugin-client.d.ts
7
+ type InferableServerPlugin = {
8
+ id?: LiteralString | undefined;
9
+ endpoints?: Record<string, unknown> | undefined;
10
+ schema?: Record<string, {
11
+ fields: Record<string, unknown>;
12
+ }> | undefined;
13
+ $ERROR_CODES?: Record<string, {
14
+ readonly code: string;
15
+ message: string;
16
+ }> | undefined;
17
+ };
8
18
  interface ClientStore {
9
19
  notify: (signal: string) => void;
10
20
  listen: (signal: string, listener: () => void) => void;
@@ -71,7 +81,7 @@ interface BetterAuthClientPlugin {
71
81
  * only used for type inference. don't pass the
72
82
  * actual plugin
73
83
  */
74
- $InferServerPlugin?: BetterAuthPlugin | undefined;
84
+ $InferServerPlugin?: InferableServerPlugin | undefined;
75
85
  /**
76
86
  * Custom actions
77
87
  */
@@ -126,6 +126,7 @@ function classifyIPv6(expanded) {
126
126
  if (firstByte === 254 && (secondByte & 192) === 128) return "linkLocal";
127
127
  if ((firstByte & 254) === 252) return "private";
128
128
  if (expanded.startsWith("2001:0db8:")) return "documentation";
129
+ if (expanded.startsWith("2001:0002:0000:")) return "benchmarking";
129
130
  if (expanded.startsWith("2002:")) {
130
131
  const embedded = extractEmbeddedIPv4(expanded, 1);
131
132
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
@@ -136,12 +137,15 @@ function classifyIPv6(expanded) {
136
137
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
137
138
  return "reserved";
138
139
  }
140
+ if (expanded.startsWith("0064:ff9b:0001:")) return "reserved";
139
141
  if (expanded.startsWith("2001:0000:")) {
140
142
  const embedded = extractEmbeddedIPv4(expanded, 6, { xor: true });
141
143
  if (embedded && classifyIPv4(embedded) !== "public") return "reserved";
142
144
  return "reserved";
143
145
  }
144
146
  if (expanded.startsWith("0100:0000:0000:0000:")) return "reserved";
147
+ if (expanded.startsWith("3fff:0")) return "documentation";
148
+ if (expanded.startsWith("5f00:")) return "reserved";
145
149
  return "public";
146
150
  }
147
151
  /**
@@ -22,9 +22,10 @@ function normalizePathname(requestUrl, basePath) {
22
22
  } catch {
23
23
  return "/";
24
24
  }
25
- if (basePath === "/" || basePath === "") return pathname;
26
- if (pathname === basePath) return "/";
27
- if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length).replace(/\/+$/, "") || "/";
25
+ const normalizedBasePath = basePath.replace(/\/+$/, "");
26
+ if (normalizedBasePath === "") return pathname;
27
+ if (pathname === normalizedBasePath) return "/";
28
+ if (pathname.startsWith(normalizedBasePath + "/")) return pathname.slice(normalizedBasePath.length).replace(/\/+$/, "") || "/";
28
29
  return pathname;
29
30
  }
30
31
  /**