🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@better-auth/core

Package Overview
Dependencies
Maintainers
2
Versions
155
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@better-auth/core - npm Package Compare versions

Comparing version
1.6.20
to
1.6.21
+1
-1
dist/context/global.mjs

@@ -5,3 +5,3 @@ //#region src/context/global.ts

const __context = {};
const __betterAuthVersion = "1.6.20";
const __betterAuthVersion = "1.6.21";
/**

@@ -8,0 +8,0 @@ * We store context instance in the globalThis.

@@ -462,2 +462,3 @@ import { getCurrentAdapter, runWithTransaction } from "../../context/transaction.mjs";

});
if (where.length === 0) return null;
debugLog({ method: "update" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`, `${formatMethod("update")} ${formatAction("Unsafe Input")}:`, {

@@ -464,0 +465,0 @@ model,

@@ -399,4 +399,10 @@ import { BetterAuthDBSchema, DBFieldAttribute } from "../type.mjs";

/**
* ⚠︎ Update may not return the updated data
* if multiple where clauses are provided
* Update a single row matching the where clause.
*
* Returns the updated row, or `null` when no row matched. Empty `where`
* clauses return `null`; use `updateMany` for intentional bulk updates.
*
* This is not the race-safe primitive for guarded state transitions. Use
* `incrementOne` when the predicate is both selector and guard, and use
* `consumeOne` for single-use destructive reads.
*/

@@ -403,0 +409,0 @@ update: <T>(data: {

@@ -11,3 +11,4 @@ //#region src/db/get-tables.ts

},
modelName: value.modelName || key
modelName: value.modelName || key,
disableMigrations: value.disableMigration ?? acc[key]?.disableMigrations
};

@@ -14,0 +15,0 @@ return acc;

@@ -5,3 +5,3 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";

const INSTRUMENTATION_SCOPE = "better-auth";
const INSTRUMENTATION_VERSION = "1.6.20";
const INSTRUMENTATION_VERSION = "1.6.21";
/**

@@ -8,0 +8,0 @@ * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth

import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";
import { JWTPayload } from "jose";
//#region src/social-providers/google.d.ts

@@ -44,7 +46,26 @@ interface GoogleProfile {

* also enforced against the `hd` claim of the returned id token/profile.
* Sign-in is rejected when the claim is missing or does not match, so this
* can be used to restrict sign-in to a Workspace domain.
* Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
* rejected when the claim is missing or does not satisfy this restriction.
*/
hd?: string | undefined;
}
interface VerifyGoogleIdTokenOptions {
token: string;
audience: string | string[];
nonce?: string | undefined;
}
/**
* Verifies a Google ID token against Google's issuer, audience, signature,
* expiry, and maximum token age.
*/
declare const verifyGoogleIdToken: ({
token,
audience,
nonce
}: VerifyGoogleIdTokenOptions) => Promise<JWTPayload | null>;
/**
* Checks whether Google's verified `hd` claim satisfies the configured hosted
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
*/
declare const isGoogleHostedDomainAllowed: (configuredHostedDomain: string | undefined, tokenHostedDomain: unknown) => boolean;
declare const google: (options: GoogleOptions) => {

@@ -103,2 +124,2 @@ id: "google";

//#endregion
export { GoogleOptions, GoogleProfile, getGooglePublicKey, google };
export { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken };

@@ -10,2 +10,33 @@ import { APIError, BetterAuthError } from "../error/index.mjs";

//#region src/social-providers/google.ts
const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
/**
* Verifies a Google ID token against Google's issuer, audience, signature,
* expiry, and maximum token age.
*/
const verifyGoogleIdToken = async ({ token, audience, nonce }) => {
try {
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!kid || !jwtAlg) return null;
const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), {
algorithms: [jwtAlg],
issuer: ["https://accounts.google.com", "accounts.google.com"],
audience,
maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE
});
if (nonce && jwtClaims.nonce !== nonce) return null;
return jwtClaims;
} catch {
return null;
}
};
/**
* Checks whether Google's verified `hd` claim satisfies the configured hosted
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
*/
const isGoogleHostedDomainAllowed = (configuredHostedDomain, tokenHostedDomain) => {
if (!configuredHostedDomain) return true;
if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) return false;
if (configuredHostedDomain === "*") return true;
return tokenHostedDomain === configuredHostedDomain;
};
const google = (options) => {

@@ -67,17 +98,9 @@ return {

if (options.verifyIdToken) return options.verifyIdToken(token, nonce);
try {
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!kid || !jwtAlg) return false;
const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), {
algorithms: [jwtAlg],
issuer: ["https://accounts.google.com", "accounts.google.com"],
audience: options.clientId,
maxTokenAge: "1h"
});
if (nonce && jwtClaims.nonce !== nonce) return false;
if (options.hd && jwtClaims.hd !== options.hd) return false;
return true;
} catch {
return false;
}
const jwtClaims = await verifyGoogleIdToken({
token,
audience: options.clientId,
nonce
});
if (!jwtClaims) return false;
return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd);
},

@@ -88,4 +111,4 @@ async getUserInfo(token) {

const user = decodeJwt(token.idToken);
if (options.hd && user.hd !== options.hd) {
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${options.hd}".`);
return null;

@@ -117,2 +140,2 @@ }

//#endregion
export { getGooglePublicKey, google };
export { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken };

@@ -9,3 +9,3 @@ import { AppleNonConformUser, AppleOptions, AppleProfile, apple, getApplePublicKey } from "./apple.mjs";

import { MicrosoftEntraIDProfile, MicrosoftOptions, getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs";
import { GoogleOptions, GoogleProfile, getGooglePublicKey, google } from "./google.mjs";
import { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs";
import { HuggingFaceOptions, HuggingFaceProfile, huggingface } from "./huggingface.mjs";

@@ -1835,2 +1835,2 @@ import { SlackOptions, SlackProfile, slack } from "./slack.mjs";

//#endregion
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 };
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, VerifyGoogleIdTokenOptions, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom };

@@ -10,3 +10,3 @@ import { apple, getApplePublicKey } from "./apple.mjs";

import { gitlab } from "./gitlab.mjs";
import { getGooglePublicKey, google } from "./google.mjs";
import { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs";
import { huggingface } from "./huggingface.mjs";

@@ -79,2 +79,2 @@ import { kakao } from "./kakao.mjs";

//#endregion
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 };
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom };
import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";
//#region src/social-providers/paypal.d.ts
interface PayPalProfile {
sub?: string | undefined;
user_id: string;

@@ -5,0 +6,0 @@ name: string;

@@ -6,3 +6,3 @@ import { APIError, BetterAuthError } from "../error/index.mjs";

import { betterFetch } from "@better-fetch/fetch";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
//#region src/social-providers/paypal.ts

@@ -147,2 +147,16 @@ /**

const userInfo = response.data;
if (token.idToken) {
let idTokenSubject;
try {
idTokenSubject = decodeJwt(token.idToken).sub;
} catch (error) {
logger.error("Failed to decode PayPal ID token:", error);
return null;
}
const userInfoSubject = userInfo.sub ?? userInfo.user_id;
if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
logger.error("PayPal user info subject does not match ID token subject");
return null;
}
}
const userMap = await options.mapProfileToUser?.(userInfo);

@@ -149,0 +163,0 @@ return {

@@ -179,3 +179,3 @@ import { DBFieldAttribute, ModelNames, SecondaryStorage } from "../db/type.mjs";

* @default
* @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8
* @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
*/

@@ -197,2 +197,17 @@ ipAddressHeaders?: string[];

ipv6Subnet?: number;
/**
* Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
* chain is walked right to left, trusted hops are skipped, and the
* first untrusted address is the client IP. Unset trusts only
* single-value IP headers. Use the actual address or subnet of your
* proxies, not a broad private range that also covers clients.
*
* This only interprets the forwarded header chain and cannot verify
* the direct sender. It is safe only when your origin is reachable
* through these proxies and clients cannot set forwarded headers
* directly.
*
* @example ["192.0.2.10", "10.0.0.0/24"]
*/
trustedProxies?: string[];
} | undefined;

@@ -199,0 +214,0 @@ /**

@@ -0,1 +1,2 @@

import { BetterAuthOptions } from "../types/init-options.mjs";
//#region src/utils/ip.d.ts

@@ -46,2 +47,23 @@ /**

/**
* Trusted-proxy entries that are not a valid IP address or CIDR range.
*/
declare function findInvalidTrustedProxies(entries: string[]): string[];
/**
* Resolves the client IP from a forwarded header. The leftmost token is spoofable,
* so with `trustedProxies` the chain is stripped from the right to the first
* untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
* when no trustworthy client IP can be resolved.
*/
declare function getIPFromHeader(value: string, options?: {
ipv6Subnet?: number;
trustedProxies?: string[];
}): string | null;
/**
* Resolves the client IP for a request from the configured IP headers.
* Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
* `x-forwarded-for`), and falls back to localhost in development and test.
* Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
*/
declare function getIp(req: Request | Headers, options: BetterAuthOptions): string | null;
/**
* Creates a rate limit key from IP and path

@@ -56,2 +78,2 @@ * Uses a separator to prevent collision attacks

//#endregion
export { createRateLimitKey, isValidIP, normalizeIP };
export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP };

@@ -0,1 +1,2 @@

import { isDevelopment, isTest } from "../env/env-impl.mjs";
import * as z from "zod";

@@ -105,2 +106,115 @@ //#region src/utils/ip.ts

/**
* Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
*/
function ipToBytes(ip) {
if (z.ipv4().safeParse(ip).success) return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
if (!isIPv6(ip)) return null;
const mapped = extractIPv4FromMapped(ip);
if (mapped) return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
const groups = expandIPv6(ip);
const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const group = Number.parseInt(groups[i] ?? "0", 16);
bytes[i * 2] = group >> 8 & 255;
bytes[i * 2 + 1] = group & 255;
}
return bytes;
}
const CIDR_PREFIX_PATTERN = /^\d+$/;
/**
* Parses an IP or `IP/prefix` string into network bytes and a prefix length.
* The prefix must be digits only and within the address family. `null` if the
* value is not a valid IP or CIDR range, which keeps a malformed entry from
* silently behaving like a non-match.
*/
function parseCIDR(value) {
const slash = value.lastIndexOf("/");
const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash));
if (!bytes) return null;
const maxBits = bytes.length * 8;
if (slash === -1) return {
bytes,
prefix: maxBits
};
const prefixPart = value.slice(slash + 1);
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) return null;
const prefix = Number(prefixPart);
return prefix <= maxBits ? {
bytes,
prefix
} : null;
}
/**
* Whether `ipBytes` falls inside an already-parsed CIDR network.
*/
function matchesCIDR(ipBytes, net) {
if (ipBytes.length !== net.bytes.length) return false;
let bitsRemaining = net.prefix;
for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) {
const take = bitsRemaining >= 8 ? 8 : bitsRemaining;
const mask = take === 8 ? 255 : 255 << 8 - take & 255;
if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) return false;
bitsRemaining -= 8;
}
return true;
}
/**
* Trusted-proxy entries that are not a valid IP address or CIDR range.
*/
function findInvalidTrustedProxies(entries) {
return entries.filter((entry) => parseCIDR(entry) === null);
}
/**
* Resolves the client IP from a forwarded header. The leftmost token is spoofable,
* so with `trustedProxies` the chain is stripped from the right to the first
* untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
* when no trustworthy client IP can be resolved.
*/
function getIPFromHeader(value, options = {}) {
const forwardedIps = value.split(",").map((ip) => ip.trim()).filter(Boolean);
if (forwardedIps.length === 0) return null;
const trustedProxies = (options.trustedProxies ?? []).map(parseCIDR).filter((proxy) => {
return proxy !== null;
});
if (trustedProxies.length > 0) {
for (let i = forwardedIps.length - 1; i >= 0; i--) {
const ip = forwardedIps[i];
const ipBytes = ip ? ipToBytes(ip) : null;
if (!ip || !ipBytes) return null;
if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) continue;
return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet });
}
return null;
}
if (forwardedIps.length !== 1) return null;
const selectedIp = forwardedIps[0];
if (!selectedIp || !isValidIP(selectedIp)) return null;
return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet });
}
const LOCALHOST_IP = "127.0.0.1";
const DEFAULT_IP_HEADERS = ["x-forwarded-for"];
/**
* Resolves the client IP for a request from the configured IP headers.
* Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
* `x-forwarded-for`), and falls back to localhost in development and test.
* Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
*/
function getIp(req, options) {
if (options.advanced?.ipAddress?.disableIpTracking) return null;
const headers = "headers" in req ? req.headers : req;
const ipHeaders = options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS;
for (const key of ipHeaders) {
const value = "get" in headers ? headers.get(key) : headers[key];
if (typeof value === "string") {
const ip = getIPFromHeader(value, {
ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet,
trustedProxies: options.advanced?.ipAddress?.trustedProxies
});
if (ip) return ip;
}
}
if (isTest() || isDevelopment()) return LOCALHOST_IP;
return null;
}
/**
* Creates a rate limit key from IP and path

@@ -117,2 +231,2 @@ * Uses a separator to prevent collision attacks

//#endregion
export { createRateLimitKey, isValidIP, normalizeIP };
export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP };
{
"name": "@better-auth/core",
"version": "1.6.20",
"version": "1.6.21",
"description": "The most comprehensive authentication framework for TypeScript.",

@@ -160,3 +160,3 @@ "type": "module",

"@opentelemetry/sdk-trace-node": "^1.30.0",
"better-call": "1.3.6",
"better-call": "1.3.7",
"@cloudflare/workers-types": "^4.20250121.0",

@@ -172,3 +172,3 @@ "jose": "^6.1.3",

"@opentelemetry/api": "^1.9.0",
"better-call": "1.3.6",
"better-call": "1.3.7",
"@cloudflare/workers-types": ">=4",

@@ -175,0 +175,0 @@ "jose": "^6.1.0",

@@ -970,2 +970,7 @@ import {

});
// `update` targets a single row. Empty predicates have no
// target, so fail closed and leave bulk writes to `updateMany`.
if (where.length === 0) {
return null;
}
debugLog(

@@ -972,0 +977,0 @@ { method: "update" },

@@ -437,4 +437,10 @@ import type { BetterAuthOptions } from "../../types";

/**
* ⚠︎ Update may not return the updated data
* if multiple where clauses are provided
* Update a single row matching the where clause.
*
* Returns the updated row, or `null` when no row matched. Empty `where`
* clauses return `null`; use `updateMany` for intentional bulk updates.
*
* This is not the race-safe primitive for guarded state transitions. Use
* `incrementOne` when the predicate is both selector and guard, and use
* `consumeOne` for single-use destructive reads.
*/

@@ -441,0 +447,0 @@ update: <T>(data: {

@@ -18,2 +18,4 @@ import type { BetterAuthOptions } from "../types";

modelName: value.modelName || key,
disableMigrations:
value.disableMigration ?? acc[key]?.disableMigrations,
};

@@ -25,3 +27,7 @@ }

string,
{ fields: Record<string, DBFieldAttribute>; modelName: string }
{
fields: Record<string, DBFieldAttribute>;
modelName: string;
disableMigrations?: boolean | undefined;
}
>,

@@ -28,0 +34,0 @@ );

import { betterFetch } from "@better-fetch/fetch";
import type { JWTPayload } from "jose";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";

@@ -55,4 +56,4 @@ import { logger } from "../env";

* also enforced against the `hd` claim of the returned id token/profile.
* Sign-in is rejected when the claim is missing or does not match, so this
* can be used to restrict sign-in to a Workspace domain.
* Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
* rejected when the claim is missing or does not satisfy this restriction.
*/

@@ -62,2 +63,57 @@ hd?: string | undefined;

const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
export interface VerifyGoogleIdTokenOptions {
token: string;
audience: string | string[];
nonce?: string | undefined;
}
/**
* Verifies a Google ID token against Google's issuer, audience, signature,
* expiry, and maximum token age.
*/
export const verifyGoogleIdToken = async ({
token,
audience,
nonce,
}: VerifyGoogleIdTokenOptions): Promise<JWTPayload | null> => {
try {
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!kid || !jwtAlg) return null;
const publicKey = await getGooglePublicKey(kid);
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
algorithms: [jwtAlg],
issuer: ["https://accounts.google.com", "accounts.google.com"],
audience,
maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
});
if (nonce && jwtClaims.nonce !== nonce) {
return null;
}
return jwtClaims;
} catch {
return null;
}
};
/**
* Checks whether Google's verified `hd` claim satisfies the configured hosted
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
*/
export const isGoogleHostedDomainAllowed = (
configuredHostedDomain: string | undefined,
tokenHostedDomain: unknown,
) => {
if (!configuredHostedDomain) return true;
if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) {
return false;
}
if (configuredHostedDomain === "*") return true;
return tokenHostedDomain === configuredHostedDomain;
};
export const google = (options: GoogleOptions) => {

@@ -138,34 +194,12 @@ return {

// Verify JWT integrity
// See https://developers.google.com/identity/sign-in/web/backend-auth#verify-the-integrity-of-the-id-token
try {
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
if (!kid || !jwtAlg) return false;
const publicKey = await getGooglePublicKey(kid);
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
algorithms: [jwtAlg],
issuer: ["https://accounts.google.com", "accounts.google.com"],
audience: options.clientId,
maxTokenAge: "1h",
});
if (nonce && jwtClaims.nonce !== nonce) {
return false;
}
// Google's `hd` authorization parameter is only a UI hint and can
// be removed or changed by the user. When a hosted domain is
// configured, the `hd` claim in the verified id token is the
// authoritative value and must match, otherwise accounts outside
// the workspace domain would be accepted.
if (options.hd && jwtClaims.hd !== options.hd) {
return false;
}
return true;
} catch {
const jwtClaims = await verifyGoogleIdToken({
token,
audience: options.clientId,
nonce,
});
if (!jwtClaims) {
return false;
}
return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd);
},

@@ -180,11 +214,10 @@ async getUserInfo(token) {

const user = decodeJwt(token.idToken) as GoogleProfile;
// Enforce the configured hosted domain on the callback profile path
// as well. The `hd` claim must be present and match, since the
// authorization-time `hd` hint does not restrict which account signs
// in.
if (options.hd && user.hd !== options.hd) {
// Enforce the configured hosted domain on the callback profile path.
// The authorization-time `hd` value is only a UI hint; the verified
// token/profile claim is the authoritative Workspace signal.
if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
logger.error(
`Google sign-in rejected: id token hosted domain (hd) "${
user.hd ?? "<missing>"
}" does not match the configured "hd" option "${options.hd}".`,
}" does not satisfy the configured "hd" option "${options.hd}".`,
);

@@ -191,0 +224,0 @@ return null;

import { base64 } from "@better-auth/utils/base64";
import { betterFetch } from "@better-fetch/fetch";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import { logger } from "../env";

@@ -19,2 +19,3 @@ import { APIError, BetterAuthError } from "../error";

export interface PayPalProfile {
sub?: string | undefined;
user_id: string;

@@ -302,2 +303,22 @@ name: string;

const userInfo = response.data;
if (token.idToken) {
let idTokenSubject: string | undefined;
try {
idTokenSubject = decodeJwt(token.idToken).sub;
} catch (error) {
logger.error("Failed to decode PayPal ID token:", error);
return null;
}
// OIDC binds UserInfo to the ID Token with `sub`. Keep `user_id`
// as the account id below for existing PayPal account mappings.
const userInfoSubject = userInfo.sub ?? userInfo.user_id;
if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
logger.error(
"PayPal user info subject does not match ID token subject",
);
return null;
}
}
const userMap = await options.mapProfileToUser?.(userInfo);

@@ -304,0 +325,0 @@

@@ -226,3 +226,3 @@ import type { Database as BunDatabase } from "bun:sqlite";

* @default
* @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8
* @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
*/

@@ -244,2 +244,17 @@ ipAddressHeaders?: string[];

ipv6Subnet?: number;
/**
* Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
* chain is walked right to left, trusted hops are skipped, and the
* first untrusted address is the client IP. Unset trusts only
* single-value IP headers. Use the actual address or subnet of your
* proxies, not a broad private range that also covers clients.
*
* This only interprets the forwarded header chain and cannot verify
* the direct sender. It is safe only when your origin is reachable
* through these proxies and clients cannot set forwarded headers
* directly.
*
* @example ["192.0.2.10", "10.0.0.0/24"]
*/
trustedProxies?: string[];
}

@@ -246,0 +261,0 @@ | undefined;

import * as z from "zod";
import { isDevelopment, isTest } from "../env";
import type { BetterAuthOptions } from "../types";

@@ -199,2 +201,185 @@ /**

/**
* Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
*/
function ipToBytes(ip: string): Uint8Array | null {
if (z.ipv4().safeParse(ip).success) {
return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
}
if (!isIPv6(ip)) {
return null;
}
const mapped = extractIPv4FromMapped(ip);
if (mapped) {
return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
}
const groups = expandIPv6(ip);
const bytes = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
const group = Number.parseInt(groups[i] ?? "0", 16);
bytes[i * 2] = (group >> 8) & 0xff;
bytes[i * 2 + 1] = group & 0xff;
}
return bytes;
}
// A CIDR prefix length must be decimal digits only, so values like "8x" or
// "1e3" that `Number()` would otherwise coerce are rejected.
const CIDR_PREFIX_PATTERN = /^\d+$/;
/**
* Parses an IP or `IP/prefix` string into network bytes and a prefix length.
* The prefix must be digits only and within the address family. `null` if the
* value is not a valid IP or CIDR range, which keeps a malformed entry from
* silently behaving like a non-match.
*/
function parseCIDR(
value: string,
): { bytes: Uint8Array; prefix: number } | null {
const slash = value.lastIndexOf("/");
const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash));
if (!bytes) {
return null;
}
const maxBits = bytes.length * 8;
if (slash === -1) {
return { bytes, prefix: maxBits };
}
const prefixPart = value.slice(slash + 1);
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
return null;
}
const prefix = Number(prefixPart);
return prefix <= maxBits ? { bytes, prefix } : null;
}
/**
* Whether `ipBytes` falls inside an already-parsed CIDR network.
*/
function matchesCIDR(
ipBytes: Uint8Array,
net: { bytes: Uint8Array; prefix: number },
): boolean {
if (ipBytes.length !== net.bytes.length) {
return false;
}
let bitsRemaining = net.prefix;
for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) {
const take = bitsRemaining >= 8 ? 8 : bitsRemaining;
const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) {
return false;
}
bitsRemaining -= 8;
}
return true;
}
/**
* Trusted-proxy entries that are not a valid IP address or CIDR range.
*/
export function findInvalidTrustedProxies(entries: string[]): string[] {
return entries.filter((entry) => parseCIDR(entry) === null);
}
/**
* Resolves the client IP from a forwarded header. The leftmost token is spoofable,
* so with `trustedProxies` the chain is stripped from the right to the first
* untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
* when no trustworthy client IP can be resolved.
*/
export function getIPFromHeader(
value: string,
options: {
ipv6Subnet?: number;
trustedProxies?: string[];
} = {},
): string | null {
const forwardedIps = value
.split(",")
.map((ip) => ip.trim())
.filter(Boolean);
if (forwardedIps.length === 0) {
return null;
}
// Parse trusted proxies once, dropping malformed entries so a config typo
// cannot leave the chain enabled-but-empty and return a real proxy hop as
// the client. With no valid proxy the chain mode does not engage.
const trustedProxies = (options.trustedProxies ?? [])
.map(parseCIDR)
.filter((proxy): proxy is { bytes: Uint8Array; prefix: number } => {
return proxy !== null;
});
if (trustedProxies.length > 0) {
for (let i = forwardedIps.length - 1; i >= 0; i--) {
const ip = forwardedIps[i];
const ipBytes = ip ? ipToBytes(ip) : null;
// A malformed hop breaks the chain: fail closed.
if (!ip || !ipBytes) {
return null;
}
if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) {
continue;
}
return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet });
}
return null;
}
// Without valid trusted proxies a multi-hop chain is unresolvable.
if (forwardedIps.length !== 1) {
return null;
}
const selectedIp = forwardedIps[0];
if (!selectedIp || !isValidIP(selectedIp)) {
return null;
}
return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet });
}
const LOCALHOST_IP = "127.0.0.1";
const DEFAULT_IP_HEADERS = ["x-forwarded-for"];
/**
* Resolves the client IP for a request from the configured IP headers.
* Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
* `x-forwarded-for`), and falls back to localhost in development and test.
* Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
*/
export function getIp(
req: Request | Headers,
options: BetterAuthOptions,
): string | null {
if (options.advanced?.ipAddress?.disableIpTracking) {
return null;
}
const headers = "headers" in req ? req.headers : req;
const ipHeaders =
options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS;
for (const key of ipHeaders) {
const value = "get" in headers ? headers.get(key) : headers[key];
if (typeof value === "string") {
const ip = getIPFromHeader(value, {
ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet,
trustedProxies: options.advanced?.ipAddress?.trustedProxies,
});
if (ip) {
return ip;
}
}
}
if (isTest() || isDevelopment()) {
return LOCALHOST_IP;
}
return null;
}
/**
* Creates a rate limit key from IP and path

@@ -201,0 +386,0 @@ * Uses a separator to prevent collision attacks