@better-auth/core 1.6.15 → 1.6.16

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.15";
5
+ const __betterAuthVersion = "1.6.16";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -27,7 +27,7 @@ const env = new Proxy(_envShim, {
27
27
  function toBoolean(val) {
28
28
  return val ? val !== "false" : false;
29
29
  }
30
- const nodeENV = typeof process !== "undefined" && process.env && process.env.NODE_ENV || "";
30
+ const nodeENV = env.NODE_ENV ?? "";
31
31
  /** Detect if `NODE_ENV` environment variable is `production` */
32
32
  const isProduction = nodeENV === "production";
33
33
  /** Detect if `NODE_ENV` environment variable is `dev` or `development` */
@@ -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.15";
5
+ const INSTRUMENTATION_VERSION = "1.6.16";
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
@@ -14,6 +14,20 @@ interface VerifyAccessTokenRemote {
14
14
  * is also still active.
15
15
  */
16
16
  force?: boolean;
17
+ /**
18
+ * Accept introspection responses that omit the `aud` claim even when a
19
+ * required `audience` is configured in `verifyOptions`.
20
+ *
21
+ * By default verification fails closed: if you configure an `audience` and
22
+ * the introspection response has no `aud` (or a mismatching one), the token
23
+ * is rejected. Some authorization servers legitimately omit `aud` from
24
+ * introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
25
+ * this if you trust the issuer to bind the token to this resource through
26
+ * another mechanism, as it skips the audience check in that case.
27
+ *
28
+ * @default false
29
+ */
30
+ allowMissingAudience?: boolean;
17
31
  }
18
32
  /**
19
33
  * Performs local verification of an access token for your APIs.
@@ -11,8 +11,13 @@ const joseInfrastructureErrorCodes = new Set([
11
11
  function isJoseInfrastructureError(error) {
12
12
  return joseInfrastructureErrorCodes.has(error.code);
13
13
  }
14
- /** Last fetched jwks used locally in getJwks @internal */
15
- let jwks;
14
+ const jwksCache = /* @__PURE__ */ new Map();
15
+ /**
16
+ * How long a cached JWKS is trusted before it is refetched
17
+ *
18
+ * @internal
19
+ */
20
+ const JWKS_CACHE_TTL_MS = 300 * 1e3;
16
21
  /**
17
22
  * Performs local verification of an access token for your APIs.
18
23
  *
@@ -37,14 +42,24 @@ async function getJwks(token, opts) {
37
42
  throw new Error(error);
38
43
  }
39
44
  if (!jwtHeaders.kid) throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
40
- if (!jwks || !jwks.keys.find((jwk) => jwk.kid === jwtHeaders.kid)) {
41
- jwks = typeof opts.jwksFetch === "string" ? await betterFetch(opts.jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
45
+ const kid = jwtHeaders.kid;
46
+ const cacheKey = opts.jwksFetch;
47
+ 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) => {
42
52
  if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
43
53
  return res.data;
44
54
  }) : await opts.jwksFetch();
45
55
  if (!jwks) throw new Error("No jwks found");
56
+ jwksCache.set(cacheKey, {
57
+ jwks,
58
+ fetchedAt: Date.now()
59
+ });
60
+ return jwks;
46
61
  }
47
- return jwks;
62
+ return cached.jwks;
48
63
  }
49
64
  /**
50
65
  * Performs local verification of an access token for your API.
@@ -85,8 +100,9 @@ async function verifyAccessToken(token, opts) {
85
100
  if (!introspect.active) throw new APIError("UNAUTHORIZED", { message: "token inactive" });
86
101
  try {
87
102
  const unsecuredJwt = new UnsecuredJWT(introspect).encode();
88
- const { audience: _audience, ...verifyOptions } = opts.verifyOptions;
89
- payload = (introspect.aud ? UnsecuredJWT.decode(unsecuredJwt, opts.verifyOptions) : UnsecuredJWT.decode(unsecuredJwt, verifyOptions)).payload;
103
+ const { audience: _audience, ...verifyOptionsNoAudience } = opts.verifyOptions;
104
+ const skipAudience = !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
105
+ payload = UnsecuredJWT.decode(unsecuredJwt, skipAudience ? verifyOptionsNoAudience : opts.verifyOptions).payload;
90
106
  } catch (error) {
91
107
  throw new Error(error);
92
108
  }
@@ -7,6 +7,34 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
7
7
  import { betterFetch } from "@better-fetch/fetch";
8
8
  import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
9
9
  //#region src/social-providers/facebook.ts
10
+ /**
11
+ * Validate an opaque Facebook access token against the configured app.
12
+ *
13
+ * Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
14
+ * token minted for any Facebook app returns that app's profile. Without this
15
+ * check, a token issued to an unrelated app could be presented to this
16
+ * app's direct sign-in path and accepted as proof of identity. We call the
17
+ * `debug_token` endpoint and require the token to be valid, bound to one of the
18
+ * configured client ids, and tied to a user.
19
+ *
20
+ * @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
21
+ *
22
+ * @returns the inspected token's `user_id` when the token is valid and bound to
23
+ * the configured app, otherwise `null`.
24
+ */
25
+ async function verifyFacebookAccessToken(accessToken, options) {
26
+ const primaryClientId = getPrimaryClientId(options.clientId);
27
+ if (!primaryClientId || !options.clientSecret) return null;
28
+ const clientIds = Array.isArray(options.clientId) ? options.clientId : [options.clientId];
29
+ const { data, error } = await betterFetch("https://graph.facebook.com/debug_token", { query: {
30
+ input_token: accessToken,
31
+ access_token: `${primaryClientId}|${options.clientSecret}`
32
+ } });
33
+ if (error || !data?.data) return null;
34
+ const { is_valid, app_id, user_id } = data.data;
35
+ if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) return null;
36
+ return user_id;
37
+ }
10
38
  const facebook = (options) => {
11
39
  return {
12
40
  id: "facebook",
@@ -52,7 +80,7 @@ const facebook = (options) => {
52
80
  } catch {
53
81
  return false;
54
82
  }
55
- return true;
83
+ return await verifyFacebookAccessToken(token, options) !== null;
56
84
  },
57
85
  refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => {
58
86
  return refreshAccessToken({
@@ -93,6 +121,10 @@ const facebook = (options) => {
93
121
  data: profile
94
122
  };
95
123
  }
124
+ const accessToken = token.accessToken;
125
+ if (!accessToken) return null;
126
+ const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
127
+ if (!tokenUserId) return null;
96
128
  const { data: profile, error } = await betterFetch("https://graph.facebook.com/me?fields=" + [
97
129
  "id",
98
130
  "name",
@@ -101,9 +133,10 @@ const facebook = (options) => {
101
133
  ...options?.fields || []
102
134
  ].join(","), { auth: {
103
135
  type: "Bearer",
104
- token: token.accessToken
136
+ token: accessToken
105
137
  } });
106
138
  if (error) return null;
139
+ if (profile.id !== tokenUserId) return null;
107
140
  const userMap = await options.mapProfileToUser?.(profile);
108
141
  return {
109
142
  user: {
@@ -37,7 +37,12 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
37
37
  */
38
38
  display?: ("page" | "popup" | "touch" | "wap") | undefined;
39
39
  /**
40
- * The hosted domain of the user
40
+ * The hosted domain (Google Workspace) the user must belong to.
41
+ *
42
+ * This is sent to Google as the `hd` authorization hint and, when set, is
43
+ * also enforced against the `hd` claim of the returned id token/profile.
44
+ * Sign-in is rejected when the claim is missing or does not match, so this
45
+ * can be used to restrict sign-in to a Workspace domain.
41
46
  */
42
47
  hd?: string | undefined;
43
48
  }
@@ -73,6 +73,7 @@ const google = (options) => {
73
73
  maxTokenAge: "1h"
74
74
  });
75
75
  if (nonce && jwtClaims.nonce !== nonce) return false;
76
+ if (options.hd && jwtClaims.hd !== options.hd) return false;
76
77
  return true;
77
78
  } catch {
78
79
  return false;
@@ -82,6 +83,10 @@ const google = (options) => {
82
83
  if (options.getUserInfo) return options.getUserInfo(token);
83
84
  if (!token.idToken) return null;
84
85
  const user = decodeJwt(token.idToken);
86
+ if (options.hd && user.hd !== options.hd) {
87
+ logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
88
+ return null;
89
+ }
85
90
  const userMap = await options.mapProfileToUser?.(user);
86
91
  return {
87
92
  user: {
@@ -28,7 +28,7 @@ import { KakaoOptions, KakaoProfile, kakao } from "./kakao.mjs";
28
28
  import { NaverOptions, NaverProfile, naver } from "./naver.mjs";
29
29
  import { LineIdTokenPayload, LineOptions, LineUserInfo, line } from "./line.mjs";
30
30
  import { PaybinOptions, PaybinProfile, paybin } from "./paybin.mjs";
31
- import { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal } from "./paypal.mjs";
31
+ import { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal } from "./paypal.mjs";
32
32
  import { PolarOptions, PolarProfile, polar } from "./polar.mjs";
33
33
  import { RailwayOptions, RailwayProfile, railway } from "./railway.mjs";
34
34
  import { VercelOptions, VercelProfile, vercel } from "./vercel.mjs";
@@ -1831,4 +1831,4 @@ type SocialProviders = { [K in SocialProviderList[number]]?: AwaitableFunction<P
1831
1831
  }> };
1832
1832
  type SocialProviderList = typeof socialProviderList;
1833
1833
  //#endregion
1834
- export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
1834
+ export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
@@ -18,7 +18,7 @@ import { getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs";
18
18
  import { naver } from "./naver.mjs";
19
19
  import { notion } from "./notion.mjs";
20
20
  import { paybin } from "./paybin.mjs";
21
- import { paypal } from "./paypal.mjs";
21
+ import { getPayPalPublicKey, paypal } from "./paypal.mjs";
22
22
  import { polar } from "./polar.mjs";
23
23
  import { railway } from "./railway.mjs";
24
24
  import { reddit } from "./reddit.mjs";
@@ -75,4 +75,4 @@ const socialProviders = {
75
75
  const socialProviderList = Object.keys(socialProviders);
76
76
  const SocialProviderListEnum = z.enum(socialProviderList).or(z.string());
77
77
  //#endregion
78
- export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
78
+ export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
@@ -125,5 +125,6 @@ declare const paypal: (options: PayPalOptions) => {
125
125
  } | null>;
126
126
  options: PayPalOptions;
127
127
  };
128
+ declare const getPayPalPublicKey: (kid: string, jwksUri: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
128
129
  //#endregion
129
- export { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal };
130
+ export { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal };
@@ -1,15 +1,30 @@
1
- import { BetterAuthError } from "../error/index.mjs";
1
+ import { APIError, BetterAuthError } from "../error/index.mjs";
2
2
  import { logger } from "../env/logger.mjs";
3
3
  import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
4
4
  import { base64 } from "@better-auth/utils/base64";
5
5
  import { betterFetch } from "@better-fetch/fetch";
6
- import { decodeJwt } from "jose";
6
+ import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
7
7
  //#region src/social-providers/paypal.ts
8
+ /**
9
+ * ID token signing algorithms advertised by PayPal's OpenID configuration.
10
+ * Anything outside this allowlist is rejected so each token is only ever
11
+ * verified with the algorithm it was issued for.
12
+ *
13
+ * @see https://www.paypal.com/.well-known/openid-configuration
14
+ */
15
+ const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"];
8
16
  const paypal = (options) => {
9
17
  const isSandbox = (options.environment || "sandbox") === "sandbox";
10
18
  const authorizationEndpoint = isSandbox ? "https://www.sandbox.paypal.com/signin/authorize" : "https://www.paypal.com/signin/authorize";
11
19
  const tokenEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/oauth2/token" : "https://api-m.paypal.com/v1/oauth2/token";
12
20
  const userInfoEndpoint = isSandbox ? "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo" : "https://api-m.paypal.com/v1/identity/oauth2/userinfo";
21
+ /**
22
+ * Issuer and JWKS endpoints used to cryptographically verify ID tokens.
23
+ *
24
+ * @see https://www.paypal.com/.well-known/openid-configuration
25
+ */
26
+ const issuer = isSandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com";
27
+ const jwksEndpoint = isSandbox ? "https://api.sandbox.paypal.com/v1/oauth2/certs" : "https://api.paypal.com/v1/oauth2/certs";
13
28
  return {
14
29
  id: "paypal",
15
30
  name: "PayPal",
@@ -94,7 +109,19 @@ const paypal = (options) => {
94
109
  if (options.disableIdTokenSignIn) return false;
95
110
  if (options.verifyIdToken) return options.verifyIdToken(token, nonce);
96
111
  try {
97
- return !!decodeJwt(token).sub;
112
+ const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
113
+ if (!jwtAlg) return false;
114
+ if (!PAYPAL_ID_TOKEN_ALGORITHMS.includes(jwtAlg)) return false;
115
+ const key = jwtAlg === "HS256" ? new TextEncoder().encode(options.clientSecret) : kid ? await getPayPalPublicKey(kid, jwksEndpoint) : void 0;
116
+ if (!key) return false;
117
+ const { payload: jwtClaims } = await jwtVerify(token, key, {
118
+ algorithms: [jwtAlg],
119
+ issuer,
120
+ audience: options.clientId,
121
+ maxTokenAge: "1h"
122
+ });
123
+ if (nonce && jwtClaims.nonce !== nonce) return false;
124
+ return true;
98
125
  } catch (error) {
99
126
  logger.error("Failed to verify PayPal ID token:", error);
100
127
  return false;
@@ -136,5 +163,12 @@ const paypal = (options) => {
136
163
  options
137
164
  };
138
165
  };
166
+ const getPayPalPublicKey = async (kid, jwksUri) => {
167
+ const { data } = await betterFetch(jwksUri);
168
+ if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" });
169
+ const jwk = data.keys.find((key) => key.kid === kid);
170
+ if (!jwk) throw new Error(`JWK with kid ${kid} not found`);
171
+ return await importJWK(jwk, jwk.alg);
172
+ };
139
173
  //#endregion
140
- export { paypal };
174
+ export { getPayPalPublicKey, paypal };
@@ -61,14 +61,15 @@ 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
65
  return {
65
66
  user: {
66
67
  id: profile.id,
67
68
  name: profile.name,
68
- email: profile.oauth_client_id,
69
- emailVerified: profile.has_verified_email,
70
69
  image: profile.icon_img?.split("?")[0],
71
- ...userMap
70
+ ...userMap,
71
+ email,
72
+ emailVerified: userMap?.emailVerified ?? false
72
73
  },
73
74
  data: profile
74
75
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.15",
3
+ "version": "1.6.16",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -153,11 +153,11 @@
153
153
  },
154
154
  "devDependencies": {
155
155
  "@better-auth/utils": "0.4.1",
156
- "@better-fetch/fetch": "1.1.21",
156
+ "@better-fetch/fetch": "1.2.2",
157
157
  "@opentelemetry/api": "^1.9.0",
158
158
  "@opentelemetry/sdk-trace-base": "^1.30.0",
159
159
  "@opentelemetry/sdk-trace-node": "^1.30.0",
160
- "better-call": "1.3.5",
160
+ "better-call": "1.3.6",
161
161
  "@cloudflare/workers-types": "^4.20250121.0",
162
162
  "jose": "^6.1.3",
163
163
  "kysely": "^0.28.17 || ^0.29.0",
@@ -166,9 +166,9 @@
166
166
  },
167
167
  "peerDependencies": {
168
168
  "@better-auth/utils": "0.4.1",
169
- "@better-fetch/fetch": "1.1.21",
169
+ "@better-fetch/fetch": "1.2.2",
170
170
  "@opentelemetry/api": "^1.9.0",
171
- "better-call": "1.3.5",
171
+ "better-call": "1.3.6",
172
172
  "@cloudflare/workers-types": ">=4",
173
173
  "jose": "^6.1.0",
174
174
  "kysely": "^0.28.5 || ^0.29.0",
@@ -46,8 +46,7 @@ function toBoolean(val: boolean | string | undefined) {
46
46
  return val ? val !== "false" : false;
47
47
  }
48
48
 
49
- export const nodeENV =
50
- (typeof process !== "undefined" && process.env && process.env.NODE_ENV) || "";
49
+ export const nodeENV = env.NODE_ENV ?? "";
51
50
 
52
51
  /** Detect if `NODE_ENV` environment variable is `production` */
53
52
  export const isProduction = nodeENV === "production";
@@ -25,8 +25,22 @@ function isJoseInfrastructureError(error: joseErrors.JOSEError) {
25
25
  return joseInfrastructureErrorCodes.has(error.code);
26
26
  }
27
27
 
28
- /** Last fetched jwks used locally in getJwks @internal */
29
- let jwks: JSONWebKeySet | undefined;
28
+ interface JwksCacheEntry {
29
+ jwks: JSONWebKeySet;
30
+ fetchedAt: number;
31
+ }
32
+
33
+ const jwksCache = new Map<
34
+ string | (() => Promise<JSONWebKeySet | undefined>),
35
+ JwksCacheEntry
36
+ >();
37
+
38
+ /**
39
+ * How long a cached JWKS is trusted before it is refetched
40
+ *
41
+ * @internal
42
+ */
43
+ const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
30
44
 
31
45
  export interface VerifyAccessTokenRemote {
32
46
  /** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
@@ -41,6 +55,20 @@ export interface VerifyAccessTokenRemote {
41
55
  * is also still active.
42
56
  */
43
57
  force?: boolean;
58
+ /**
59
+ * Accept introspection responses that omit the `aud` claim even when a
60
+ * required `audience` is configured in `verifyOptions`.
61
+ *
62
+ * By default verification fails closed: if you configure an `audience` and
63
+ * the introspection response has no `aud` (or a mismatching one), the token
64
+ * is rejected. Some authorization servers legitimately omit `aud` from
65
+ * introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
66
+ * this if you trust the issuer to bind the token to this resource through
67
+ * another mechanism, as it skips the audience check in that case.
68
+ *
69
+ * @default false
70
+ */
71
+ allowMissingAudience?: boolean;
44
72
  }
45
73
 
46
74
  /**
@@ -96,10 +124,21 @@ export async function getJwks(
96
124
  if (!jwtHeaders.kid) {
97
125
  throw new APIError("UNAUTHORIZED", { message: "invalid access token" });
98
126
  }
127
+ const kid = jwtHeaders.kid;
99
128
 
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 =
129
+ const cacheKey = opts.jwksFetch;
130
+ const cached = jwksCache.get(cacheKey);
131
+ const isFresh = cached
132
+ ? Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS
133
+ : false;
134
+ const hasKid = cached?.jwks.keys.some((jwk) => jwk.kid === kid) ?? false;
135
+
136
+ // Refetch when this source has no cached set, the cached set has expired, or
137
+ // it does not contain the token's kid (e.g. a newly rotated-in key). The
138
+ // cache is scoped to `cacheKey`, so a token is only ever matched against the
139
+ // key set published by its own source.
140
+ if (!cached || !isFresh || !hasKid) {
141
+ const jwks =
103
142
  typeof opts.jwksFetch === "string"
104
143
  ? await betterFetch<JSONWebKeySet>(opts.jwksFetch, {
105
144
  headers: {
@@ -114,9 +153,11 @@ export async function getJwks(
114
153
  })
115
154
  : await opts.jwksFetch();
116
155
  if (!jwks) throw new Error("No jwks found");
156
+ jwksCache.set(cacheKey, { jwks, fetchedAt: Date.now() });
157
+ return jwks;
117
158
  }
118
159
 
119
- return jwks;
160
+ return cached.jwks;
120
161
  }
121
162
 
122
163
  /**
@@ -201,13 +242,23 @@ export async function verifyAccessToken(
201
242
  throw new APIError("UNAUTHORIZED", {
202
243
  message: "token inactive",
203
244
  });
204
- // Verifies payload using verify options (token valid through introspect)
245
+ // Verifies payload using verify options (token valid through introspect).
246
+ // Audience is enforced by default: when `verifyOptions.audience` is set
247
+ // but the introspection response omits `aud` (or it mismatches),
248
+ // `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
249
+ // token issued for a different resource/client on the same issuer would
250
+ // also pass. Only drop the audience check when the caller has explicitly
251
+ // opted in via `remoteVerify.allowMissingAudience`.
205
252
  try {
206
253
  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);
254
+ const { audience: _audience, ...verifyOptionsNoAudience } =
255
+ opts.verifyOptions;
256
+ const skipAudience =
257
+ !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
258
+ const verify = UnsecuredJWT.decode(
259
+ unsecuredJwt,
260
+ skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
261
+ );
211
262
  payload = verify.payload;
212
263
  } catch (error) {
213
264
  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: {
@@ -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,15 @@ export const reddit = (options: RedditOptions) => {
104
104
  }
105
105
 
106
106
  const userMap = await options.mapProfileToUser?.(profile);
107
-
107
+ const email = userMap?.email || `${profile.id}@reddit.com`;
108
108
  return {
109
109
  user: {
110
110
  id: profile.id,
111
111
  name: profile.name,
112
- email: profile.oauth_client_id,
113
- emailVerified: profile.has_verified_email,
114
112
  image: profile.icon_img?.split("?")[0]!,
115
113
  ...userMap,
114
+ email,
115
+ emailVerified: userMap?.emailVerified ?? false,
116
116
  },
117
117
  data: profile,
118
118
  };