@better-auth/core 1.6.15 → 1.6.17

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.
Files changed (44) hide show
  1. package/dist/api/index.d.mts +3 -0
  2. package/dist/api/index.mjs +36 -0
  3. package/dist/context/global.mjs +1 -1
  4. package/dist/db/adapter/factory.mjs +82 -0
  5. package/dist/db/adapter/index.d.mts +51 -1
  6. package/dist/db/adapter/types.d.mts +1 -1
  7. package/dist/db/type.d.mts +15 -0
  8. package/dist/env/env-impl.mjs +1 -1
  9. package/dist/instrumentation/tracer.mjs +1 -1
  10. package/dist/oauth2/verify.d.mts +29 -6
  11. package/dist/oauth2/verify.mjs +112 -12
  12. package/dist/social-providers/facebook.mjs +35 -2
  13. package/dist/social-providers/google.d.mts +6 -1
  14. package/dist/social-providers/google.mjs +5 -0
  15. package/dist/social-providers/index.d.mts +2 -2
  16. package/dist/social-providers/index.mjs +2 -2
  17. package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
  18. package/dist/social-providers/microsoft-entra-id.mjs +13 -1
  19. package/dist/social-providers/paypal.d.mts +2 -1
  20. package/dist/social-providers/paypal.mjs +38 -4
  21. package/dist/social-providers/reddit.mjs +4 -3
  22. package/dist/social-providers/wechat.mjs +1 -1
  23. package/dist/types/context.d.mts +16 -0
  24. package/dist/types/init-options.d.mts +29 -0
  25. package/dist/utils/host.mjs +4 -0
  26. package/dist/utils/url.mjs +4 -3
  27. package/package.json +5 -5
  28. package/src/api/index.ts +45 -0
  29. package/src/db/adapter/factory.ts +152 -0
  30. package/src/db/adapter/index.ts +51 -0
  31. package/src/db/adapter/types.ts +1 -0
  32. package/src/db/type.ts +15 -0
  33. package/src/env/env-impl.ts +1 -2
  34. package/src/oauth2/verify.ts +211 -41
  35. package/src/social-providers/facebook.ts +75 -2
  36. package/src/social-providers/google.ts +27 -1
  37. package/src/social-providers/microsoft-entra-id.ts +40 -1
  38. package/src/social-providers/paypal.ts +91 -4
  39. package/src/social-providers/reddit.ts +7 -3
  40. package/src/social-providers/wechat.ts +8 -1
  41. package/src/types/context.ts +17 -0
  42. package/src/types/init-options.ts +26 -0
  43. package/src/utils/host.ts +15 -0
  44. package/src/utils/url.ts +10 -4
@@ -4,6 +4,7 @@ import type {
4
4
  JSONWebKeySet,
5
5
  JWTPayload,
6
6
  JWTVerifyOptions,
7
+ JWTVerifyResult,
7
8
  ProtectedHeaderParameters,
8
9
  } from "jose";
9
10
  import {
@@ -25,8 +26,102 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
25
26
  return joseInfrastructureErrorCodes.has(error.code);
26
27
  }
27
28
 
28
- /** Last fetched jwks used locally in getJwks @internal */
29
- let jwks: JSONWebKeySet | undefined;
29
+ interface JwksCacheEntry {
30
+ jwks: JSONWebKeySet;
31
+ fetchedAt: number;
32
+ noKidRefetchedAt?: number | undefined;
33
+ }
34
+
35
+ type JwksFetchOptions = {
36
+ /** Jwks url or promise of a Jwks */
37
+ jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
38
+ /**
39
+ * Stable object to cache the result of a function `jwksFetch` under,
40
+ * with the same TTL and kid-miss refetch rules as string sources.
41
+ * Without it, a function source is fetched on every verification.
42
+ */
43
+ jwksCacheKey?: object;
44
+ };
45
+
46
+ type ResolvedJwks = {
47
+ jwks: JSONWebKeySet;
48
+ fromCache: boolean;
49
+ kid: string | undefined;
50
+ noKidRefetchedAt?: number | undefined;
51
+ };
52
+
53
+ /**
54
+ * @internal
55
+ */
56
+ export const jwksCache = new Map<string, JwksCacheEntry>();
57
+
58
+ /**
59
+ * Cache for function jwks sources, keyed by a caller-provided stable object.
60
+ * Entries are released with their key, so per-request keys cannot accumulate.
61
+ */
62
+ const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
63
+
64
+ /**
65
+ * How long a cached JWKS is trusted before it is refetched
66
+ *
67
+ * @internal
68
+ */
69
+ const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
70
+ const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
71
+
72
+ /**
73
+ * Returns the cached key set when it is within the TTL. When the token carries
74
+ * `kid`, the cached set must contain that key id; without `kid`, key selection
75
+ * is deferred to JOSE because RFC 7515 makes the header parameter optional.
76
+ */
77
+ function getFreshJwksWithKid(
78
+ cached: JwksCacheEntry | undefined,
79
+ kid: string | undefined,
80
+ ): JSONWebKeySet | undefined {
81
+ if (!cached) return undefined;
82
+ if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
83
+ if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
84
+ return undefined;
85
+ }
86
+ return cached.jwks;
87
+ }
88
+
89
+ function shouldRefetchCachedJwksWithoutKid(
90
+ error: unknown,
91
+ resolved: ResolvedJwks,
92
+ ) {
93
+ const isRetryableNoKidFailure =
94
+ resolved.fromCache &&
95
+ !resolved.kid &&
96
+ (error instanceof joseErrors.JWKSNoMatchingKey ||
97
+ error instanceof joseErrors.JWSSignatureVerificationFailed);
98
+ if (!isRetryableNoKidFailure) return false;
99
+ if (!resolved.noKidRefetchedAt) return true;
100
+ return (
101
+ Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
102
+ );
103
+ }
104
+
105
+ async function fetchJwks(
106
+ jwksFetch: JwksFetchOptions["jwksFetch"],
107
+ ): Promise<JSONWebKeySet> {
108
+ const jwks =
109
+ typeof jwksFetch === "string"
110
+ ? await betterFetch<JSONWebKeySet>(jwksFetch, {
111
+ headers: {
112
+ Accept: "application/json",
113
+ },
114
+ }).then(async (res) => {
115
+ if (res.error)
116
+ throw new Error(
117
+ `Jwks failed: ${res.error.message ?? res.error.statusText}`,
118
+ );
119
+ return res.data;
120
+ })
121
+ : await jwksFetch();
122
+ if (!jwks) throw new Error("No jwks found");
123
+ return jwks;
124
+ }
30
125
 
31
126
  export interface VerifyAccessTokenRemote {
32
127
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
@@ -41,6 +136,20 @@ export interface VerifyAccessTokenRemote {
41
136
  * is also still active.
42
137
  */
43
138
  force?: boolean;
139
+ /**
140
+ * Accept introspection responses that omit the `aud` claim even when a
141
+ * required `audience` is configured in `verifyOptions`.
142
+ *
143
+ * By default verification fails closed: if you configure an `audience` and
144
+ * the introspection response has no `aud` (or a mismatching one), the token
145
+ * is rejected. Some authorization servers legitimately omit `aud` from
146
+ * introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
147
+ * this if you trust the issuer to bind the token to this resource through
148
+ * another mechanism, as it skips the audience check in that case.
149
+ *
150
+ * @default false
151
+ */
152
+ allowMissingAudience?: boolean;
44
153
  }
45
154
 
46
155
  /**
@@ -50,21 +159,36 @@ export interface VerifyAccessTokenRemote {
50
159
  */
51
160
  export async function verifyJwsAccessToken(
52
161
  token: string,
53
- opts: {
54
- /** Jwks url or promise of a Jwks */
55
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
162
+ opts: JwksFetchOptions & {
56
163
  /** Verify options */
57
164
  verifyOptions: JWTVerifyOptions &
58
165
  Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
59
166
  },
60
167
  ) {
61
168
  try {
62
- const jwks = await getJwks(token, opts);
63
- const jwt = await jwtVerify<JWTPayload>(
64
- token,
65
- createLocalJWKSet(jwks),
66
- opts.verifyOptions,
67
- );
169
+ const resolved = await getJwksForVerification(token, opts);
170
+ let jwt: JWTVerifyResult<JWTPayload>;
171
+ try {
172
+ jwt = await jwtVerify<JWTPayload>(
173
+ token,
174
+ createLocalJWKSet(resolved.jwks),
175
+ opts.verifyOptions,
176
+ );
177
+ } catch (error) {
178
+ if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
179
+ const refreshed = await getJwksForVerification(token, {
180
+ ...opts,
181
+ forceRefresh: true,
182
+ });
183
+ jwt = await jwtVerify<JWTPayload>(
184
+ token,
185
+ createLocalJWKSet(refreshed.jwks),
186
+ opts.verifyOptions,
187
+ );
188
+ } else {
189
+ throw error;
190
+ }
191
+ }
68
192
  // Return the JWT payload in introspection format
69
193
  // https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
70
194
  if (jwt.payload.azp) {
@@ -77,12 +201,13 @@ export async function verifyJwsAccessToken(
77
201
  }
78
202
  }
79
203
 
80
- export async function getJwks(
204
+ export async function getJwks(token: string, opts: JwksFetchOptions) {
205
+ return (await getJwksForVerification(token, opts)).jwks;
206
+ }
207
+
208
+ async function getJwksForVerification(
81
209
  token: string,
82
- opts: {
83
- /** Jwks url or promise of a Jwks */
84
- jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
85
- },
210
+ opts: JwksFetchOptions & { forceRefresh?: boolean },
86
211
  ) {
87
212
  // Attempt to decode the token and find a matching kid in jwks
88
213
  let jwtHeaders: ProtectedHeaderParameters | undefined;
@@ -93,30 +218,65 @@ export async function getJwks(
93
218
  throw new Error(error as unknown as string);
94
219
  }
95
220
 
96
- if (!jwtHeaders.kid) {
97
- throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
98
- }
221
+ const kid = jwtHeaders.kid;
99
222
 
100
- // Fetch jwks if not set or has a different kid than the one stored
101
- if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
102
- jwks =
103
- typeof opts.jwksFetch === "string"
104
- ? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
105
- headers: {
106
- Accept: "application/json",
107
- },
108
- }).then(async (res) => {
109
- if (res.error)
110
- throw new Error(
111
- `Jwks failed: ${res.error.message ?? res.error.statusText}`,
112
- );
113
- return res.data;
114
- })
115
- : await opts.jwksFetch();
223
+ // Function sources have no usable identity of their own (callers pass
224
+ // fresh closures per request), so they are cached only under a stable
225
+ // caller-provided key object.
226
+ if (typeof opts.jwksFetch !== "string") {
227
+ const cacheKey = opts.jwksCacheKey;
228
+ if (!cacheKey) {
229
+ const jwks = await opts.jwksFetch();
230
+ if (!jwks) throw new Error("No jwks found");
231
+ return { jwks, fromCache: false, kid };
232
+ }
233
+ const cached = functionJwksCache.get(cacheKey);
234
+ const cachedJwks = opts.forceRefresh
235
+ ? undefined
236
+ : getFreshJwksWithKid(cached, kid);
237
+ if (cachedJwks) {
238
+ return {
239
+ jwks: cachedJwks,
240
+ fromCache: true,
241
+ kid,
242
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
243
+ };
244
+ }
245
+ const jwks = await opts.jwksFetch();
116
246
  if (!jwks) throw new Error("No jwks found");
247
+ const fetchedAt = Date.now();
248
+ functionJwksCache.set(cacheKey, {
249
+ jwks,
250
+ fetchedAt,
251
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
252
+ });
253
+ return { jwks, fromCache: false, kid };
117
254
  }
118
255
 
119
- return jwks;
256
+ // The cache is scoped to `cacheKey`, so a token is only ever matched
257
+ // against the key set published by its own source.
258
+ const cacheKey = opts.jwksFetch;
259
+ const cached = jwksCache.get(cacheKey);
260
+ const cachedJwks = opts.forceRefresh
261
+ ? undefined
262
+ : getFreshJwksWithKid(cached, kid);
263
+ if (!cachedJwks) {
264
+ const jwks = await fetchJwks(opts.jwksFetch);
265
+ const fetchedAt = Date.now();
266
+ jwksCache.set(cacheKey, {
267
+ jwks,
268
+ fetchedAt,
269
+ ...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
270
+ });
271
+ return { jwks, fromCache: false, kid };
272
+ }
273
+
274
+ return {
275
+ jwks: cachedJwks,
276
+ fromCache: true,
277
+ kid,
278
+ noKidRefetchedAt: cached?.noKidRefetchedAt,
279
+ };
120
280
  }
121
281
 
122
282
  /**
@@ -201,13 +361,23 @@ export async function verifyAccessToken(
201
361
  throw new APIError("UNAUTHORIZED", {
202
362
  message: "token inactive",
203
363
  });
204
- // Verifies payload using verify options (token valid through introspect)
364
+ // Verifies payload using verify options (token valid through introspect).
365
+ // Audience is enforced by default: when `verifyOptions.audience` is set
366
+ // but the introspection response omits `aud` (or it mismatches),
367
+ // `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
368
+ // token issued for a different resource/client on the same issuer would
369
+ // also pass. Only drop the audience check when the caller has explicitly
370
+ // opted in via `remoteVerify.allowMissingAudience`.
205
371
  try {
206
372
  const unsecuredJwt = new UnsecuredJWT(introspect).encode();
207
- const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
208
- const verify = introspect.aud
209
- ? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions)
210
- : UnsecuredJWT.decode(unsecuredJwt, verifyOptions);
373
+ const { audience: _audience, ...verifyOptionsNoAudience } =
374
+ opts.verifyOptions;
375
+ const skipAudience =
376
+ !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
377
+ const verify = UnsecuredJWT.decode(
378
+ unsecuredJwt,
379
+ skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
380
+ );
211
381
  payload = verify.payload;
212
382
  } catch (error) {
213
383
  throw new Error(error as unknown as string);
@@ -24,6 +24,58 @@ export interface FacebookProfile {
24
24
  };
25
25
  }
26
26
 
27
+ interface FacebookDebugTokenData {
28
+ app_id?: string;
29
+ is_valid?: boolean;
30
+ user_id?: string;
31
+ }
32
+
33
+ /**
34
+ * Validate an opaque Facebook access token against the configured app.
35
+ *
36
+ * Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
37
+ * token minted for any Facebook app returns that app's profile. Without this
38
+ * check, a token issued to an unrelated app could be presented to this
39
+ * app's direct sign-in path and accepted as proof of identity. We call the
40
+ * `debug_token` endpoint and require the token to be valid, bound to one of the
41
+ * configured client ids, and tied to a user.
42
+ *
43
+ * @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
44
+ *
45
+ * @returns the inspected token's `user_id` when the token is valid and bound to
46
+ * the configured app, otherwise `null`.
47
+ */
48
+ async function verifyFacebookAccessToken(
49
+ accessToken: string,
50
+ options: FacebookOptions,
51
+ ): Promise<string | null> {
52
+ const primaryClientId = getPrimaryClientId(options.clientId);
53
+ if (!primaryClientId || !options.clientSecret) {
54
+ return null;
55
+ }
56
+ const clientIds = Array.isArray(options.clientId)
57
+ ? options.clientId
58
+ : [options.clientId];
59
+ const appAccessToken = `${primaryClientId}|${options.clientSecret}`;
60
+ const { data, error } = await betterFetch<{ data?: FacebookDebugTokenData }>(
61
+ "https://graph.facebook.com/debug_token",
62
+ {
63
+ query: {
64
+ input_token: accessToken,
65
+ access_token: appAccessToken,
66
+ },
67
+ },
68
+ );
69
+ if (error || !data?.data) {
70
+ return null;
71
+ }
72
+ const { is_valid, app_id, user_id } = data.data;
73
+ if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) {
74
+ return null;
75
+ }
76
+ return user_id;
77
+ }
78
+
27
79
  export interface FacebookOptions extends ProviderOptions<FacebookProfile> {
28
80
  clientId: string | string[];
29
81
  /**
@@ -117,7 +169,10 @@ export const facebook = (options: FacebookOptions) => {
117
169
  }
118
170
 
119
171
  /* access_token */
120
- return true;
172
+ // An opaque access token carries no app binding of its own, so it
173
+ // must be validated against the configured app before it can be
174
+ // trusted as proof of identity.
175
+ return (await verifyFacebookAccessToken(token, options)) !== null;
121
176
  },
122
177
  refreshAccessToken: options.refreshAccessToken
123
178
  ? options.refreshAccessToken
@@ -178,6 +233,20 @@ export const facebook = (options: FacebookOptions) => {
178
233
  };
179
234
  }
180
235
 
236
+ // The profile is fetched with `accessToken`, which is the credential
237
+ // that actually proves identity here — and a separate request field
238
+ // from the `idToken`/token validated by `verifyIdToken`. Since an
239
+ // opaque token is not app-bound at `/me`, validate this exact token
240
+ // against the configured app before trusting the profile it returns.
241
+ const accessToken = token.accessToken;
242
+ if (!accessToken) {
243
+ return null;
244
+ }
245
+ const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
246
+ if (!tokenUserId) {
247
+ return null;
248
+ }
249
+
181
250
  const fields = [
182
251
  "id",
183
252
  "name",
@@ -190,13 +259,17 @@ export const facebook = (options: FacebookOptions) => {
190
259
  {
191
260
  auth: {
192
261
  type: "Bearer",
193
- token: token.accessToken,
262
+ token: accessToken,
194
263
  },
195
264
  },
196
265
  );
197
266
  if (error) {
198
267
  return null;
199
268
  }
269
+ // Bind the validated token to the profile it returned.
270
+ if (profile.id !== tokenUserId) {
271
+ return null;
272
+ }
200
273
  const userMap = await options.mapProfileToUser?.(profile);
201
274
  return {
202
275
  user: {
@@ -48,7 +48,12 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
48
48
  */
49
49
  display?: ("page" | "popup" | "touch" | "wap") | undefined;
50
50
  /**
51
- * The hosted domain of the user
51
+ * The hosted domain (Google Workspace) the user must belong to.
52
+ *
53
+ * This is sent to Google as the `hd` authorization hint and, when set, is
54
+ * also enforced against the `hd` claim of the returned id token/profile.
55
+ * Sign-in is rejected when the claim is missing or does not match, so this
56
+ * can be used to restrict sign-in to a Workspace domain.
52
57
  */
53
58
  hd?: string | undefined;
54
59
  }
@@ -147,6 +152,15 @@ export const google = (options: GoogleOptions) => {
147
152
  return false;
148
153
  }
149
154
 
155
+ // Google's `hd` authorization parameter is only a UI hint and can
156
+ // be removed or changed by the user. When a hosted domain is
157
+ // configured, the `hd` claim in the verified id token is the
158
+ // authoritative value and must match, otherwise accounts outside
159
+ // the workspace domain would be accepted.
160
+ if (options.hd && jwtClaims.hd !== options.hd) {
161
+ return false;
162
+ }
163
+
150
164
  return true;
151
165
  } catch {
152
166
  return false;
@@ -160,6 +174,18 @@ export const google = (options: GoogleOptions) => {
160
174
  return null;
161
175
  }
162
176
  const user = decodeJwt(token.idToken) as GoogleProfile;
177
+ // Enforce the configured hosted domain on the callback profile path
178
+ // as well. The `hd` claim must be present and match, since the
179
+ // authorization-time `hd` hint does not restrict which account signs
180
+ // in.
181
+ if (options.hd && user.hd !== options.hd) {
182
+ logger.error(
183
+ `Google sign-in rejected: id token hosted domain (hd) "${
184
+ user.hd ?? "<missing>"
185
+ }" does not match the configured "hd" option "${options.hd}".`,
186
+ );
187
+ return null;
188
+ }
163
189
  const userMap = await options.mapProfileToUser?.(user);
164
190
  return {
165
191
  user: {
@@ -11,6 +11,14 @@ import {
11
11
  validateAuthorizationCode,
12
12
  } from "../oauth2";
13
13
 
14
+ /**
15
+ * Microsoft's fixed tenant id for personal (consumer) Microsoft accounts. Every
16
+ * personal-account token carries it as the `tid` claim, so it distinguishes the
17
+ * consumer account class from work/school tenants.
18
+ * @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
19
+ */
20
+ const MICROSOFT_CONSUMER_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad";
21
+
14
22
  /**
15
23
  * @see [Microsoft Identity Platform - Optional claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference)
16
24
  */
@@ -143,7 +151,14 @@ export interface MicrosoftOptions
143
151
 
144
152
  export const microsoft = (options: MicrosoftOptions) => {
145
153
  const tenant = options.tenantId || "common";
146
- const authority = options.authority || "https://login.microsoftonline.com";
154
+ // Trim any trailing slash so endpoint URLs and the issuer comparison below
155
+ // never produce a double slash (e.g. a configured `https://host/` would make
156
+ // the expected issuer `https://host//<tid>/v2.0` and reject every token). A
157
+ // loop avoids a trailing-slash regex, which is a polynomial-ReDoS shape.
158
+ let authority = options.authority || "https://login.microsoftonline.com";
159
+ while (authority.endsWith("/")) {
160
+ authority = authority.slice(0, -1);
161
+ }
147
162
  const authorizationEndpoint = `${authority}/${tenant}/oauth2/v2.0/authorize`;
148
163
  const tokenEndpoint = `${authority}/${tenant}/oauth2/v2.0/token`;
149
164
  return {
@@ -229,6 +244,30 @@ export const microsoft = (options: MicrosoftOptions) => {
229
244
  return false;
230
245
  }
231
246
 
247
+ // The multi-tenant endpoints (common/organizations/consumers) skip
248
+ // jose's issuer check above because the issuer varies per tenant, and
249
+ // the organizations and consumers JWKS sets overlap. Enforce the tenant
250
+ // binding explicitly so a token from a disallowed account class cannot
251
+ // pass: the issuer must name the token's own tenant, and the account
252
+ // class must match the configured restriction.
253
+ // @see https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
254
+ const tid = jwtClaims.tid;
255
+ if (
256
+ typeof tid !== "string" ||
257
+ jwtClaims.iss !== `${authority}/${tid}/v2.0`
258
+ ) {
259
+ return false;
260
+ }
261
+ if (
262
+ tenant === "organizations" &&
263
+ tid === MICROSOFT_CONSUMER_TENANT_ID
264
+ ) {
265
+ return false;
266
+ }
267
+ if (tenant === "consumers" && tid !== MICROSOFT_CONSUMER_TENANT_ID) {
268
+ return false;
269
+ }
270
+
232
271
  return true;
233
272
  } catch (error) {
234
273
  logger.error("Failed to verify ID token:", error);
@@ -1,11 +1,20 @@
1
1
  import { base64 } from "@better-auth/utils/base64";
2
2
  import { betterFetch } from "@better-fetch/fetch";
3
- import { decodeJwt } from "jose";
3
+ import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
4
4
  import { logger } from "../env";
5
- import { BetterAuthError } from "../error";
5
+ import { APIError, BetterAuthError } from "../error";
6
6
  import type { OAuthProvider, ProviderOptions } from "../oauth2";
7
7
  import { createAuthorizationURL } from "../oauth2";
8
8
 
9
+ /**
10
+ * ID token signing algorithms advertised by PayPal's OpenID configuration.
11
+ * Anything outside this allowlist is rejected so each token is only ever
12
+ * verified with the algorithm it was issued for.
13
+ *
14
+ * @see https://www.paypal.com/.well-known/openid-configuration
15
+ */
16
+ const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"] as const;
17
+
9
18
  export interface PayPalProfile {
10
19
  user_id: string;
11
20
  name: string;
@@ -75,6 +84,19 @@ export const paypal = (options: PayPalOptions) => {
75
84
  ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo"
76
85
  : "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
77
86
 
87
+ /**
88
+ * Issuer and JWKS endpoints used to cryptographically verify ID tokens.
89
+ *
90
+ * @see https://www.paypal.com/.well-known/openid-configuration
91
+ */
92
+ const issuer = isSandbox
93
+ ? "https://www.sandbox.paypal.com"
94
+ : "https://www.paypal.com";
95
+
96
+ const jwksEndpoint = isSandbox
97
+ ? "https://api.sandbox.paypal.com/v1/oauth2/certs"
98
+ : "https://api.paypal.com/v1/oauth2/certs";
99
+
78
100
  return {
79
101
  id: "paypal",
80
102
  name: "PayPal",
@@ -201,9 +223,48 @@ export const paypal = (options: PayPalOptions) => {
201
223
  if (options.verifyIdToken) {
202
224
  return options.verifyIdToken(token, nonce);
203
225
  }
226
+
227
+ // Cryptographically verify the ID token. Decoding alone is not enough:
228
+ // the signature, issuer, audience and expiration must all be checked
229
+ // before the token's claims can be relied on as proof of identity.
230
+ // See https://www.paypal.com/.well-known/openid-configuration
231
+
204
232
  try {
205
- const payload = decodeJwt(token);
206
- return !!payload.sub;
233
+ const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
234
+ if (!jwtAlg) return false;
235
+ if (
236
+ !PAYPAL_ID_TOKEN_ALGORITHMS.includes(
237
+ jwtAlg as (typeof PAYPAL_ID_TOKEN_ALGORITHMS)[number],
238
+ )
239
+ ) {
240
+ return false;
241
+ }
242
+
243
+ // PayPal can sign ID tokens either asymmetrically (RS256, verified
244
+ // against the published JWKS) or symmetrically (HS256, verified with
245
+ // the client secret). Selecting the key by algorithm keeps the two
246
+ // paths separate so each algorithm is only verified with its
247
+ // corresponding key type.
248
+ const key =
249
+ jwtAlg === "HS256"
250
+ ? new TextEncoder().encode(options.clientSecret)
251
+ : kid
252
+ ? await getPayPalPublicKey(kid, jwksEndpoint)
253
+ : undefined;
254
+ if (!key) return false;
255
+
256
+ const { payload: jwtClaims } = await jwtVerify(token, key, {
257
+ algorithms: [jwtAlg],
258
+ issuer,
259
+ audience: options.clientId,
260
+ maxTokenAge: "1h",
261
+ });
262
+
263
+ if (nonce && jwtClaims.nonce !== nonce) {
264
+ return false;
265
+ }
266
+
267
+ return true;
207
268
  } catch (error) {
208
269
  logger.error("Failed to verify PayPal ID token:", error);
209
270
  return false;
@@ -261,3 +322,29 @@ export const paypal = (options: PayPalOptions) => {
261
322
  options,
262
323
  } satisfies OAuthProvider<PayPalProfile>;
263
324
  };
325
+
326
+ export const getPayPalPublicKey = async (kid: string, jwksUri: string) => {
327
+ const { data } = await betterFetch<{
328
+ keys: Array<{
329
+ kid: string;
330
+ alg: string;
331
+ kty: string;
332
+ use: string;
333
+ n: string;
334
+ e: string;
335
+ }>;
336
+ }>(jwksUri);
337
+
338
+ if (!data?.keys) {
339
+ throw new APIError("BAD_REQUEST", {
340
+ message: "Keys not found",
341
+ });
342
+ }
343
+
344
+ const jwk = data.keys.find((key) => key.kid === kid);
345
+ if (!jwk) {
346
+ throw new Error(`JWK with kid ${kid} not found`);
347
+ }
348
+
349
+ return await importJWK(jwk, jwk.alg);
350
+ };
@@ -104,15 +104,19 @@ export const reddit = (options: RedditOptions) => {
104
104
  }
105
105
 
106
106
  const userMap = await options.mapProfileToUser?.(profile);
107
-
107
+ // Reddit's identity scope does not return an email. Synthesize a stable,
108
+ // non-routable placeholder (RFC 2606 `.invalid`) keyed to the user's
109
+ // Reddit id rather than the routable `reddit.com`, which could collide
110
+ // with a real address. Left unverified; `mapProfileToUser` can override.
111
+ const email = userMap?.email || `${profile.id}@reddit.invalid`;
108
112
  return {
109
113
  user: {
110
114
  id: profile.id,
111
115
  name: profile.name,
112
- email: profile.oauth_client_id,
113
- emailVerified: profile.has_verified_email,
114
116
  image: profile.icon_img?.split("?")[0]!,
115
117
  ...userMap,
118
+ email,
119
+ emailVerified: userMap?.emailVerified ?? false,
116
120
  },
117
121
  data: profile,
118
122
  };