chore: sync main to next

chore: sync main to next
This commit is contained in:
Gustavo Valverde
2026-05-31 13:47:54 +01:00
committed by GitHub
34 changed files with 579 additions and 191 deletions
-9
View File
@@ -1,9 +0,0 @@
---
"better-auth": patch
---
Support server-side `accountInfo` calls without session headers.
`auth.api.accountInfo` now accepts an optional `userId`, so a trusted server-side caller can read a user's provider profile without constructing session headers. This mirrors `getAccessToken` and `refreshToken`. HTTP callers still require a valid session, and a session always takes precedence over a supplied `userId`.
The shared "resolve the target user, then fetch a valid access token" logic behind these three endpoints now lives in one place. As part of that, a server-side call that supplies neither a session nor a `userId` reports `USER_ID_OR_SESSION_REQUIRED` (400) consistently, rather than `UNAUTHORIZED` on some endpoints.
-5
View File
@@ -1,5 +0,0 @@
---
"@better-auth/api-key": patch
---
`verifyApiKey` rejected keys created under a non-default `configId` when the request omitted `configId`. It now validates the key against its own configuration.
-9
View File
@@ -1,9 +0,0 @@
---
"@better-auth/expo": patch
---
Fix sign-in being lost on Expo when a provider issues large tokens.
After signing in with a provider whose tokens are large (such as Keycloak), the account cookie could silently fail to persist, leaving `useSession()` null and making `accountInfo` / `getAccessToken` return `ACCOUNT_NOT_FOUND`. The client stored the whole cookie jar as a single value, and native secure stores reject oversized writes, so the cookie never reached the next request.
The Expo storage adapter now splits an oversized value across keys and reassembles it on read, keeping each write within the device limit. Values that already fit are stored unchanged, and a write the backend rejects is logged instead of being dropped silently.
-5
View File
@@ -1,5 +0,0 @@
---
"better-auth": patch
---
When only `secondaryStorage` is configured (no primary database), `storeStateStrategy` now defaults to `"database"` instead of `"cookie"`, preventing oversized-cookie errors on platforms like AWS Lambda. The account cookie that holds OAuth tokens in database-less setups stays enabled, so `getAccessToken` keeps working.
-10
View File
@@ -1,10 +0,0 @@
---
"better-auth": patch
"@better-auth/core": patch
---
Fix two buggy `internalAdapter` helpers.
Remove `findAccount(accountId)`. It looked accounts up by account ID alone, which is unique neither across providers nor across users, so it returned a non-deterministic match. All callers now use a user-scoped or provider-scoped lookup.
Replace the ambiguous `deleteSessions(string | string[])` with two explicit methods. `deleteUserSessions(userId)` revokes every session for a user, and `deleteSessions(tokens)` revokes sessions by token. The old single-string overload silently treated its argument as a user ID, so a caller that meant to delete one session token could instead wipe all of a user's sessions or quietly match nothing.
-7
View File
@@ -1,7 +0,0 @@
---
"better-auth": patch
---
Fix Google One Tap signing in the wrong user when the presented Google account is already linked to someone else. One Tap now resolves identity through the shared OAuth path, so the user who owns the Google subject is signed in, matching the redirect and `signIn.social` flows. Previously it matched a local user by the token's email and used the subject only to decide linking, so a Google credential owned by one user could authenticate a different user who happened to share that email.
`/account-info` now resolves the account from the signed-in user's own linked accounts and accepts an optional `providerId` to disambiguate when two providers issue the same account ID. A colliding account ID returns a distinct `AMBIGUOUS_ACCOUNT` error instead of a misleading "not found", and an account with no configured social provider returns a 400 rather than a 500.
+7
View File
@@ -0,0 +1,7 @@
---
"@better-auth/oauth-provider": patch
"@better-auth/core": patch
"better-auth": patch
---
Harden redirect-URI validation across the OAuth provider plugins. `isSafeUrlScheme` and `SafeUrlSchema` no longer call `URL.canParse`, which is absent on some supported runtimes and could throw or silently disable the dangerous-scheme check. They now parse with a `try`/`catch` fallback. `SafeUrlSchema` also rejects redirect URIs that contain a fragment component, per RFC 6749 §3.1.2.
@@ -1,5 +0,0 @@
---
"@better-auth/sso": patch
---
Fix SAML Single Logout leaving the user signed in. The logout handlers passed the session row id to a delete that matches on the session token, so the session was never removed. The stored SAML session record now carries the session token, and all three logout paths revoke the session by token.
@@ -1,9 +0,0 @@
---
"@better-auth/sso": patch
---
Fix a high-severity XML injection in signed SAML assertions (GHSA-34r5-q4jw-r36m) by updating `samlify` from 2.10.2 to 2.13.1. A crafted `AttributeValue` could escalate privileges.
samlify 2.11 replaced `node-forge` with Node's native crypto, which parses private keys through OpenSSL 3 and rejects PEM blocks that carry leading whitespace. SAML private keys are now normalized before they reach samlify, so a key pasted with indentation (for example from an indented YAML or JSON config) keeps loading.
IdP-initiated Single Logout now derives its response from the parsed logout request, which fixes response generation under samlify 2.13. When mapping SAML attributes to user fields, a multi-valued attribute is read by its first value.
@@ -1,7 +0,0 @@
---
"better-auth": patch
---
Document `viewBackupCodes` as a server-only function so its API comment no longer reads like an HTTP route.
The JSDoc above `auth.api.viewBackupCodes` advertised `POST /two-factor/view-backup-codes`, but the endpoint is server-only: it is not registered on the HTTP router and has no client method. The comment now states that it is callable only from trusted server code and that the `userId` should come from an authenticated session.
-11
View File
@@ -1,11 +0,0 @@
---
"better-auth": patch
---
Apply `accountLinking.updateUserInfoOnLink` across every OAuth link flow.
Enabling `updateUserInfoOnLink` only synced the user's profile when linking through a direct ID token. Linking through the standard OAuth redirect (`linkSocial`, the generic OAuth `oauth2.link` endpoint, and implicit linking on social sign-in) ignored the option, so the name and image never changed. Every link path now honors it.
The synced fields match the sign-up path: `name`, `image`, and any fields your `mapProfileToUser` adds. The local `email` and `emailVerified` are never changed on a link, so linking a provider cannot rebind the account's identity.
Implicit linking on social sign-in also returned the pre-update user, so the freshly issued session served stale profile data from its cookie cache until the cache expired. The new session now carries the updated profile.
+1
View File
@@ -65,3 +65,4 @@ Oligo
febf
fdff
fffe
vbscript
@@ -85,13 +85,13 @@ All you need to do is provide the database logic, and the `createAdapterFactory`
create: async ({ data, model, select }) => {
// ...
},
update: async ({ data, model, select }) => {
update: async ({ model, where, update }) => {
// ...
},
updateMany: async ({ data, model, select }) => {
updateMany: async ({ model, where, update }) => {
// ...
},
delete: async ({ data, model, select }) => {
delete: async ({ model, where }) => {
// ...
},
// ...
@@ -117,8 +117,8 @@ Before we get into the adapter function, let's go over the parameters that are a
* `options`: The Better Auth options.
* `schema`: The schema from the user's Better Auth instance.
* `debugLog`: The debug log function.
* `getFieldName`: Function to get the transformed field name for the database.
* `getModelName`: Function to get the transformed model name for the database.
* `getFieldName`: Function to get the transformed field name for the database.
* `getDefaultModelName`: Function to get the default model name from the schema.
* `getDefaultFieldName`: Function to get the default field name from the schema.
* `getFieldAttributes`: Function to get field attributes for a specific model and field.
@@ -131,8 +131,8 @@ adapter: ({
options,
schema,
debugLog,
getFieldName,
getModelName,
getFieldName,
getDefaultModelName,
getDefaultFieldName,
getFieldAttributes,
@@ -168,9 +168,7 @@ parameters:
* `data`: The data to insert into the database.
* `select`: An array of fields to return from the database.
<Callout>
Make sure to return the data that is inserted into the database.
</Callout>
**Returns** `Promise<T>`: the record that was inserted into the database.
```ts title="Example"
create: async ({ model, data, select }) => {
@@ -189,10 +187,7 @@ parameters:
* `where`: The `where` clause to update the record by.
* `update`: The data to update the record with.
<Callout>
Make sure to return the data in the row which is updated. This includes any
fields that were not updated.
</Callout>
**Returns** `Promise<T | null>`: the updated row, or `null` if no row matched. With multiple `where` clauses, some adapters cannot return it.
```ts title="Example"
update: async ({ model, where, update }) => {
@@ -211,7 +206,7 @@ parameters:
* `where`: The `where` clause to update the records by.
* `update`: The data to update the records with.
<Callout>Make sure to return the number of records that were updated.</Callout>
**Returns** `Promise<number>`: the number of records that were updated.
```ts title="Example"
updateMany: async ({ model, where, update }) => {
@@ -229,6 +224,8 @@ parameters:
* `model`: The model/table name that the record will be deleted from.
* `where`: The `where` clause to delete the record by.
**Returns** `Promise<void>`: nothing.
```ts title="Example"
delete: async ({ model, where }) => {
// Example of deleting a record from the database.
@@ -245,7 +242,7 @@ parameters:
* `model`: The model/table name that the records will be deleted from.
* `where`: The `where` clause to delete the records by.
<Callout>Make sure to return the number of records that were deleted.</Callout>
**Returns** `Promise<number>`: the number of records that were deleted.
```ts title="Example"
deleteMany: async ({ model, where }) => {
@@ -254,6 +251,29 @@ deleteMany: async ({ model, where }) => {
};
```
### `consumeOne` method (optional)
The `consumeOne` method atomically deletes a single matching record and returns it, so that two concurrent requests cannot both consume the same row. It powers single-use credentials such as one-time verification challenges. Implementing it is optional: when omitted, the adapter factory falls back to wrapping `findMany` + `deleteMany` in a `transaction`, using the deleted-row count as the race gate.
parameters:
* `model`: The model/table name to consume a record from.
* `where`: The `where` clause to match the single record by.
**Returns** `Promise<T | null>`: the consumed record, or `null` if no row matched (including when another request consumed it first). Implementations must delete at most one matching row.
```ts title="Example"
consumeOne: async ({ model, where }) => {
// Single DELETE ... RETURNING is atomic, so only one caller gets the row.
const [row] = await db.delete(model).where(where).returning();
return row ?? null;
};
```
<Callout type="info">
Although the factory provides a fallback, implementing `consumeOne` natively is recommended: the fallback is race-safe only under a real `transaction` with an accurate delete count. Stores without multi-document transactions therefore need a native atomic delete-and-return (`DELETE ... RETURNING`, `findOneAndDelete`, or a revision-conditional delete).
</Callout>
### `findOne` method
The `findOne` method is used to find a single record in the database.
@@ -265,7 +285,7 @@ parameters:
* `select`: The `select` clause to return.
* `join`: Optional join configuration to fetch related records in a single query.
<Callout>Make sure to return the data that is found in the database.</Callout>
**Returns** `Promise<T | null>`: the matching record, or `null` if none was found.
```ts title="Example"
findOne: async ({ model, where, select, join }) => {
@@ -283,16 +303,15 @@ parameters:
* `model`: The model/table name that the records will be found in.
* `where`: The `where` clause to find the records by.
* `limit`: The limit of records to return.
* `select`: The `select` clause to return.
* `sortBy`: The `sortBy` clause to sort the records by.
* `offset`: The offset of records to return.
* `join`: Optional join configuration to fetch related records in a single query.
<Callout>
Make sure to return the array of data that is found in the database.
</Callout>
**Returns** `Promise<T[]>`: an array of the matching records.
```ts title="Example"
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
findMany: async ({ model, where, limit, select, sortBy, offset, join }) => {
// Example of finding multiple records in the database.
return await db
.select()
@@ -313,7 +332,7 @@ parameters:
* `model`: The model/table name that the records will be counted in.
* `where`: The `where` clause to count the records by.
<Callout>Make sure to return the number of records that were counted.</Callout>
**Returns** `Promise<number>`: the number of records that were counted.
```ts title="Example"
count: async ({ model, where }) => {
@@ -423,6 +442,10 @@ The name of the adapter.
Whether the database supports numeric IDs. If this is set to `false` and the user's config has enabled `useNumberId`, then we will throw an error.
### `supportsUUIDs`
Whether the database can natively generate UUIDs. Defaults to `false`. Set this to `true` if your database generates UUIDs itself.
### `supportsJSON`
Whether the database supports JSON. If the database doesn't support JSON, we will use a `string` to save the JSON data.And when we retrieve the data, we will safely parse the `string` back into a JSON object.
@@ -435,6 +458,10 @@ Whether the database supports dates. If the database doesn't support dates, we w
Whether the database supports booleans. If the database doesn't support booleans, we will use a `0` or `1` to save the boolean value. When we retrieve the data, we will safely parse the `0` or `1` back into a boolean value.
### `supportsArrays`
Whether the database supports arrays. Defaults to `false`, in which case array fields (`string[]`, `number[]`) are stored as a serialized `string` and safely parsed back when retrieved. Set this to `true` if your database supports array columns natively.
### `usePlural`
Whether the table names in the schema are plural. This is often defined by the user, and passed down through your custom adapter options. If you do not intend to allow the user to customize the table names, you can ignore this option, or set this to `false`.
@@ -601,14 +628,3 @@ Whether to disable join transformation. This should only be used if you know wha
<Callout type="warn">
Disabling join transformation can break join functionality.
</Callout>
### `supportsJoin`
Whether the adapter supports native joins. If set to `false` (the default), Better-Auth will handle joins by making multiple queries and combining the results. If set to `true`, the adapter is expected to handle joins natively (e.g., SQL JOIN operations).
```ts title="Example"
// For adapters that support native SQL joins
const adapter = myAdapter({
supportsJoin: true,
});
```
@@ -1,3 +1,4 @@
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
import type { BetterFetchPlugin } from "@better-fetch/fetch";
export const redirectPlugin = {
@@ -5,7 +6,11 @@ export const redirectPlugin = {
name: "Redirect",
hooks: {
onSuccess(context) {
if (context.data?.url && context.data?.redirect) {
if (
context.data?.url &&
context.data?.redirect &&
isSafeUrlScheme(context.data.url)
) {
if (typeof window !== "undefined" && window.location) {
if (window.location) {
try {
+11 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@better-auth/core/api";
import { isProduction, logger } from "@better-auth/core/env";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
import { getWebcryptoSubtle } from "@better-auth/utils";
import { base64 } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
@@ -129,7 +130,16 @@ export const getMCPProtectedResourceMetadata = (
};
const registerMcpClientBodySchema = z.object({
redirect_uris: z.array(z.string()),
// This plugin is migrating to @better-auth/oauth-provider (see the deprecation
// notice in docs/plugins/mcp). It gets only the non-breaking guard that rejects
// code-execution schemes here; full https-or-loopback parity comes from
// @better-auth/oauth-provider's SafeUrlSchema, not from tightening this plugin.
redirect_uris: z.array(
z.string().refine(isSafeUrlScheme, {
message:
"redirect_uri cannot use a javascript:, data:, or vbscript: scheme",
}),
),
token_endpoint_auth_method: z
.enum(["none", "client_secret_basic", "client_secret_post"])
.default("client_secret_basic")
@@ -8,6 +8,7 @@ import {
} from "@better-auth/core/api";
import { getCurrentAuthContext } from "@better-auth/core/context";
import { deprecate } from "@better-auth/core/utils/deprecate";
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
import { base64 } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import type { OpenAPIParameter } from "better-call";
@@ -144,10 +145,22 @@ const oAuthConsentBodySchema = z.object({
const oAuth2TokenBodySchema = z.record(z.any(), z.any());
const registerOAuthApplicationBodySchema = z.object({
redirect_uris: z.array(z.string()).meta({
description:
'A list of redirect URIs. Eg: ["https://client.example.com/callback"]',
}),
// This plugin is deprecated and removed in the next major. It gets only the
// non-breaking guard that rejects code-execution schemes (javascript:, data:,
// vbscript:). The stricter https-or-loopback policy lives in
// @better-auth/oauth-provider via SafeUrlSchema; migrating there is the path to
// full parity, so we do not tighten this plugin further.
redirect_uris: z
.array(
z.string().refine(isSafeUrlScheme, {
message:
"redirect_uri cannot use a javascript:, data:, or vbscript: scheme",
}),
)
.meta({
description:
'A list of redirect URIs. Eg: ["https://client.example.com/callback"]',
}),
token_endpoint_auth_method: z
.enum(["none", "client_secret_basic", "client_secret_post"])
.meta({
@@ -0,0 +1,57 @@
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-86j7-9j95-vpqj
*/
import { describe, expect, it } from "vitest";
import { createAuthClient } from "../../client";
import { getTestInstance } from "../../test-utils/test-instance";
import { oidcProvider } from ".";
import { oidcClient } from "./client";
async function getClient() {
const { customFetchImpl, signInWithTestUser } = await getTestInstance({
baseURL: "http://localhost:3000",
plugins: [
oidcProvider({
loginPage: "/login",
consentPage: "/consent",
}),
],
});
const { headers } = await signInWithTestUser();
return createAuthClient({
plugins: [oidcClient()],
baseURL: "http://localhost:3000",
fetchOptions: { customFetchImpl, headers },
});
}
describe("oidc-provider redirect_uri scheme validation", () => {
it("rejects javascript:, data:, and vbscript: redirect URIs at registration", async () => {
const client = await getClient();
for (const uri of [
"javascript:fetch('/api/auth/get-session')//",
"data:text/html,<script>alert(1)</script>",
"vbscript:msgbox(1)",
]) {
const reg = await client.oauth2.register({
client_name: "Evil App",
redirect_uris: [uri],
});
expect(reg.error?.status).toBe(400);
expect(reg.data?.client_id).toBeUndefined();
}
});
it("still accepts https and loopback http redirect URIs", async () => {
const client = await getClient();
const reg = await client.oauth2.register({
client_name: "Good App",
redirect_uris: [
"https://client.example.com/callback",
"http://localhost:3000/callback",
],
});
expect(reg.error).toBeNull();
expect(reg.data?.client_id).toBeDefined();
});
});
@@ -3,6 +3,7 @@ import type {
BetterAuthClientPlugin,
ClientFetchOption,
} from "@better-auth/core";
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
import { PACKAGE_VERSION } from "../../version";
declare global {
@@ -257,7 +258,10 @@ export const oneTapClient = (options: GoogleOneTapOptions) => {
});
if ((!opts?.fetchOptions && !fetchOptions) || opts?.callbackURL) {
window.location.href = opts?.callbackURL ?? "/";
const target = opts?.callbackURL ?? "/";
if (isSafeUrlScheme(target)) {
window.location.href = target;
}
}
}
@@ -303,7 +307,10 @@ export const oneTapClient = (options: GoogleOneTapOptions) => {
});
if ((!opts?.fetchOptions && !fetchOptions) || opts?.callbackURL) {
window.location.href = opts?.callbackURL ?? "/";
const target = opts?.callbackURL ?? "/";
if (isSafeUrlScheme(target)) {
window.location.href = target;
}
}
}
@@ -2611,7 +2611,7 @@ describe("Additional Fields", async () => {
expectTypeOf<Params>().toEqualTypeOf<{
name: string;
slug: string;
logo?: string | undefined;
logo?: string | null | undefined;
userId?: string | undefined;
metadata?: Record<string, any> | undefined;
someRequiredField: string;
@@ -2621,7 +2621,7 @@ describe("Additional Fields", async () => {
expectTypeOf<Params2>().toEqualTypeOf<{
name: string;
slug: string;
logo?: string | undefined;
logo?: string | null | undefined;
userId?: string | undefined;
metadata?: Record<string, any> | undefined;
someRequiredField: string;
@@ -550,3 +550,52 @@ describe("organization hooks", async () => {
expect(internalOrg?.name).toBe("Internal Org");
});
});
describe("updateOrganization", async () => {
const { auth, signInWithTestUser } = await getTestInstance({
plugins: [organization()],
});
/**
* @see https://github.com/better-auth/better-auth/issues/9829
*/
it("should clear the logo when passing null", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: {
name: "Logo Org",
slug: "logo-org",
logo: "https://example.com/logo.png",
},
headers,
});
expect(org?.logo).toBe("https://example.com/logo.png");
const updated = await auth.api.updateOrganization({
body: {
organizationId: org!.id,
data: {
logo: null,
},
},
headers,
});
expect(updated?.logo).toBeNull();
});
/**
* @see https://github.com/better-auth/better-auth/issues/9829
*/
it("should accept a null logo on create", async () => {
const { headers } = await signInWithTestUser();
const org = await auth.api.createOrganization({
body: {
name: "Null Logo Org",
slug: "null-logo-org",
logo: null,
},
headers,
});
expect(org?.logo).toBeNull();
});
});
@@ -38,7 +38,7 @@ const baseOrganizationSchema = z.object({
.meta({
description: "The logo of the organization",
})
.optional(),
.nullish(),
metadata: z
.record(z.string(), z.any())
.meta({
@@ -353,7 +353,7 @@ const baseUpdateOrganizationSchema = z.object({
.meta({
description: "The logo of the organization",
})
.optional(),
.nullish(),
metadata: z
.record(z.string(), z.any())
.meta({
@@ -373,7 +373,7 @@ export const updateOrganization = <O extends OrganizationOptions>(
data: {
name?: string | undefined;
slug?: string | undefined;
logo?: string | undefined;
logo?: string | null | undefined;
metadata?: Record<string, any> | undefined;
} & Partial<InferAdditionalFieldsFromPluginOptions<"organization", O>>;
organizationId?: string | undefined;
@@ -385,7 +385,7 @@ export interface OrganizationOptions {
organization: {
name?: string;
slug?: string;
logo?: string;
logo?: string | null;
metadata?: Record<string, any>;
[key: string]: any;
};
@@ -416,7 +416,7 @@ export interface OrganizationOptions {
organization: {
name?: string;
slug?: string;
logo?: string;
logo?: string | null;
metadata?: Record<string, any>;
[key: string]: any;
};
@@ -426,7 +426,7 @@ export interface OrganizationOptions {
data: {
name?: string;
slug?: string;
logo?: string;
logo?: string | null;
metadata?: Record<string, any>;
[key: string]: any;
};
@@ -1,4 +1,5 @@
import type { BetterAuthClientPlugin } from "@better-auth/core";
import { isSafeUrlScheme } from "@better-auth/core/utils/url";
import { PACKAGE_VERSION } from "../../version";
import type { twoFactor as twoFa } from ".";
import { TWO_FACTOR_ERROR_CODES } from "./error-code";
@@ -68,7 +69,11 @@ export const twoFactorClient = (
}
// fallback for when `onTwoFactorRedirect` is not used and only `twoFactorPage` is provided
if (options?.twoFactorPage && typeof window !== "undefined") {
if (
options?.twoFactorPage &&
typeof window !== "undefined" &&
isSafeUrlScheme(options.twoFactorPage)
) {
window.location.href = options.twoFactorPage;
}
}
@@ -143,4 +143,29 @@ describe("createAdapterFactory consumeOne fallback", () => {
expect(result).toBeNull();
});
it("throws when deleteMany returns a non-numeric value", async () => {
const adapter = createTestAdapter({
adapter: createCustomAdapter({
findMany: async <T>() =>
[
{
id: "verification-id",
identifier_text: "stored-token",
},
] as T[],
// A misbehaving adapter (e.g. a document store returning the raw
// delete response) breaks the count-based race gate. The fallback
// must surface this instead of reporting a spurious miss.
deleteMany: async () => ({ deleted: true }) as unknown as number,
}),
});
await expect(
adapter.consumeOne({
model: "verification",
where: [{ field: "identifier", value: "token" }],
}),
).rejects.toThrowError(/non-numeric value from deleteMany/);
});
});
+6
View File
@@ -1391,6 +1391,12 @@ export const createAdapterFactory =
},
],
});
// A non-numeric count coerces to a false miss, so fail loud.
if (typeof deleted !== "number") {
throw new BetterAuthError(
`Adapter "${config.adapterId}" returned a non-numeric value from deleteMany during the consumeOne fallback. Return the number of deleted rows, or implement a native consumeOne for atomic single-use consumption.`,
);
}
return deleted > 0 ? (target as T) : null;
}),
);
+54
View File
@@ -0,0 +1,54 @@
import * as z from "zod";
import { isLoopbackHost } from "./host";
import { DANGEROUS_URL_SCHEMES } from "./url";
/**
* Zod schema for OAuth redirect URIs and other developer-supplied URLs that the
* server stores and later hands back to a browser.
*
* - Rejects dangerous schemes (`javascript:`, `data:`, `vbscript:`).
* - Rejects URIs with a fragment component (`#...`) per RFC 6749 §3.1.2.
* - Requires HTTPS, except for loopback hosts (`127.0.0.0/8`, `[::1]`,
* `*.localhost` per RFC 6761), where HTTP is allowed for local development.
* - Allows custom schemes for mobile apps (e.g. `myapp://callback`).
*
* This is the single source of truth for redirect-URI validation across the
* OAuth provider plugins. Consume it from `@better-auth/core/utils/redirect-uri`
* rather than re-implementing the scheme policy per plugin.
*/
export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
let u: URL;
try {
u = new URL(val);
} catch {
ctx.addIssue({
code: "custom",
message: "URL must be parseable",
fatal: true,
});
return z.NEVER;
}
if (DANGEROUS_URL_SCHEMES.includes(u.protocol)) {
ctx.addIssue({
code: "custom",
message: "URL cannot use javascript:, data:, or vbscript: scheme",
});
return;
}
if (val.includes("#")) {
ctx.addIssue({
code: "custom",
message: "Redirect URI must not contain a fragment component",
});
}
if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
ctx.addIssue({
code: "custom",
message:
"Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)",
});
}
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { SafeUrlSchema } from "./redirect-uri";
import { isSafeUrlScheme } from "./url";
describe("isSafeUrlScheme", () => {
it("rejects code-execution schemes", () => {
expect(isSafeUrlScheme("javascript:alert(1)")).toBe(false);
expect(isSafeUrlScheme("data:text/html,<script>alert(1)</script>")).toBe(
false,
);
expect(isSafeUrlScheme("vbscript:msgbox(1)")).toBe(false);
});
it("normalizes the scheme before checking (mixed case is still blocked)", () => {
expect(isSafeUrlScheme("JavaScript:alert(1)")).toBe(false);
expect(isSafeUrlScheme("JAVASCRIPT:alert(1)")).toBe(false);
});
it("allows http(s), relative paths, and custom app schemes", () => {
expect(isSafeUrlScheme("https://example.com/callback")).toBe(true);
expect(isSafeUrlScheme("http://localhost:3000/callback")).toBe(true);
expect(isSafeUrlScheme("/dashboard")).toBe(true);
expect(isSafeUrlScheme("myapp://callback")).toBe(true);
});
});
describe("SafeUrlSchema", () => {
it("rejects dangerous schemes", () => {
expect(SafeUrlSchema.safeParse("javascript:alert(1)").success).toBe(false);
expect(SafeUrlSchema.safeParse("data:text/html,x").success).toBe(false);
expect(SafeUrlSchema.safeParse("vbscript:x").success).toBe(false);
});
it("requires https for non-loopback hosts", () => {
expect(SafeUrlSchema.safeParse("http://example.com/cb").success).toBe(
false,
);
expect(SafeUrlSchema.safeParse("https://example.com/cb").success).toBe(
true,
);
});
it("allows http for loopback hosts", () => {
expect(SafeUrlSchema.safeParse("http://localhost:3000/cb").success).toBe(
true,
);
expect(SafeUrlSchema.safeParse("http://127.0.0.1/cb").success).toBe(true);
});
it("rejects redirect URIs with a fragment component", () => {
expect(
SafeUrlSchema.safeParse("https://example.com/cb#token").success,
).toBe(false);
expect(SafeUrlSchema.safeParse("https://example.com/cb#").success).toBe(
false,
);
expect(SafeUrlSchema.safeParse("https://example.com/cb").success).toBe(
true,
);
});
});
+28
View File
@@ -41,3 +41,31 @@ export function normalizePathname(
return pathname;
}
/**
* Schemes that execute or embed code when navigated to or accepted as a
* redirect target. These are never safe as an OAuth `redirect_uri` or as a
* client-side navigation target (`window.location.href`, `location.assign`, ...).
*/
export const DANGEROUS_URL_SCHEMES = ["javascript:", "data:", "vbscript:"];
/**
* Returns `false` only when `value` is an absolute URL using a dangerous scheme
* (`javascript:`, `data:`, `vbscript:`). Relative URLs (e.g. `/dashboard`) and
* safe absolute schemes (`http`, `https`, custom app schemes such as
* `myapp://`) return `true`.
*
* Use this to guard browser navigation sinks and any redirect target that may
* originate from untrusted input. It is intentionally narrow: it blocks code
* execution schemes without rejecting relative paths or mobile deep links.
*/
export function isSafeUrlScheme(value: string): boolean {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
// Relative URLs carry no scheme to abuse.
return true;
}
return !DANGEROUS_URL_SCHEMES.includes(parsed.protocol);
}
@@ -352,4 +352,135 @@ describe("oauthClient", async () => {
expect(clientPrivileges).toHaveBeenCalledTimes(1);
});
it("should not create client via admin api without a session", async () => {
try {
await auth.api.adminCreateOAuthClient({
body: {
...testClientInput,
redirect_uris: [redirectUri],
},
});
throw new Error("should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(APIError);
if (error instanceof APIError) {
expect(error.statusCode).toBe(401);
expect(error.status).toBe("UNAUTHORIZED");
}
}
});
});
/**
* Dynamic Client Registration must enforce the same clientPrivileges "create"
* gate as the create-client endpoints. The gate lives in the shared creation
* chokepoint, so every creation route inherits it; a forbidden authenticated
* user cannot mint a client through registration, while the unauthenticated
* public-client path stays open and never consults the hook.
*
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-jmcv-4jfc-6qqj
*/
describe("oauthClient dynamic registration privileges", async () => {
const baseUrl = "http://localhost:3000";
const redirectUri = "http://localhost:5000/callback";
const allowedUser = {
email: "dcr-allowed@test.com",
password: "test123456",
name: "dcr allowed user",
};
const forbiddenUser = {
email: "dcr-forbidden@test.com",
password: "test123456",
name: "dcr forbidden user",
};
const clientPrivileges = vi.fn(({ user }) => {
if (user?.email === allowedUser.email) {
return true;
}
return false;
});
const { auth, customFetchImpl, signInWithUser } = await getTestInstance({
baseURL: baseUrl,
plugins: [
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
clientPrivileges,
silenceWarnings: {
oauthAuthServerConfig: true,
openidConfig: true,
},
}),
jwt(),
],
});
await auth.api.signUpEmail({ body: allowedUser });
await auth.api.signUpEmail({ body: forbiddenUser });
const { headers: allowedUserHeaders } = await signInWithUser(
allowedUser.email,
allowedUser.password,
);
const { headers: forbiddenUserHeaders } = await signInWithUser(
forbiddenUser.email,
forbiddenUser.password,
);
const allowedAuthClient = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl, headers: allowedUserHeaders },
});
const forbiddenAuthClient = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl, headers: forbiddenUserHeaders },
});
const unauthedAuthClient = createAuthClient({
plugins: [oauthProviderClient()],
baseURL: baseUrl,
fetchOptions: { customFetchImpl },
});
beforeEach(() => {
clientPrivileges.mockClear();
});
it("should not register a client for a forbidden user", async () => {
const client = await forbiddenAuthClient.oauth2.register({
redirect_uris: [redirectUri],
});
expect(client.error).toMatchObject({
status: 401,
statusText: "UNAUTHORIZED",
});
expect(client.data?.client_secret).toBeUndefined();
expect(clientPrivileges).toHaveBeenCalledWith(
expect.objectContaining({ action: "create" }),
);
});
it("should register a client for an allowed user", async () => {
const client = await allowedAuthClient.oauth2.register({
redirect_uris: [redirectUri],
});
expect(client.data?.client_id).toBeDefined();
expect(client.data?.client_secret).toBeDefined();
expect(clientPrivileges).toHaveBeenCalledWith(
expect.objectContaining({ action: "create" }),
);
});
it("should allow unauthenticated public registration without invoking the gate", async () => {
const client = await unauthedAuthClient.oauth2.register({
token_endpoint_auth_method: "none",
redirect_uris: [redirectUri],
});
expect(client.data?.client_id).toBeDefined();
expect(client.data?.client_secret).toBeUndefined();
expect(client.data?.public).toBe(true);
expect(clientPrivileges).not.toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@ import { checkOAuthClient, oauthToSchema, schemaToOAuth } from "../register";
import type { OAuthOptions, SchemaClient, Scope } from "../types";
import type { OAuthClient } from "../types/oauth";
import { getClient, storeClientSecret } from "../utils";
import { assertClientPrivileges } from "./privileges";
export async function getClientEndpoint(
ctx: GenericEndpointContext & { query: { client_id: string } },
@@ -348,24 +349,3 @@ export async function rotateClientSecretEndpoint(
clientSecret: (opts.prefix?.clientSecret ?? "") + clientSecret,
});
}
export async function assertClientPrivileges(
ctx: GenericEndpointContext,
session: Awaited<ReturnType<typeof getSessionFromCtx>>,
opts: OAuthOptions<Scope[]>,
action: "create" | "read" | "update" | "delete" | "list" | "rotate",
) {
if (!session) throw new APIError("UNAUTHORIZED");
if (!ctx.headers) throw new APIError("BAD_REQUEST");
if (
opts.clientPrivileges &&
!(await opts.clientPrivileges({
headers: ctx.headers,
action,
session: session.session,
user: session.user,
}))
) {
throw new APIError("UNAUTHORIZED");
}
}
@@ -1,15 +1,10 @@
import {
createAuthEndpoint,
getSessionFromCtx,
sessionMiddleware,
} from "better-auth/api";
import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
import * as z from "zod";
import { publicSessionMiddleware } from "../middleware";
import { createOAuthClientEndpoint } from "../register";
import type { OAuthOptions, Scope } from "../types";
import { SafeUrlSchema } from "../types/zod";
import {
assertClientPrivileges,
deleteClientEndpoint,
getClientEndpoint,
getClientPublicEndpoint,
@@ -238,8 +233,6 @@ export const adminCreateOAuthClient = (opts: OAuthOptions<Scope[]>) =>
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
await assertClientPrivileges(ctx, session, opts, "create");
return createOAuthClientEndpoint(ctx, opts, {
isRegister: false,
});
@@ -450,8 +443,6 @@ export const createOAuthClient = (opts: OAuthOptions<Scope[]>) =>
},
},
async (ctx) => {
const session = await getSessionFromCtx(ctx);
await assertClientPrivileges(ctx, session, opts, "create");
return createOAuthClientEndpoint(ctx, opts, {
isRegister: false,
});
@@ -0,0 +1,35 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type { getSessionFromCtx } from "better-auth/api";
import { APIError } from "better-auth/api";
import type { OAuthOptions, Scope } from "../types";
/**
* Authorizes a client action against the configured `clientPrivileges` hook.
*
* This is the single authorization helper for every OAuth client mutation. The
* create path enforces it at the shared creation chokepoint so that no
* registration route can reach client persistence without it.
*
* @throws APIError UNAUTHORIZED when there is no session or the hook denies the action.
* @throws APIError BAD_REQUEST when the request carries no headers.
*/
export async function assertClientPrivileges(
ctx: GenericEndpointContext,
session: Awaited<ReturnType<typeof getSessionFromCtx>>,
opts: OAuthOptions<Scope[]>,
action: "create" | "read" | "update" | "delete" | "list" | "rotate",
) {
if (!session) throw new APIError("UNAUTHORIZED");
if (!ctx.headers) throw new APIError("BAD_REQUEST");
if (
opts.clientPrivileges &&
!(await opts.clientPrivileges({
headers: ctx.headers,
action,
session: session.session,
user: session.user,
}))
) {
throw new APIError("UNAUTHORIZED");
}
}
+16
View File
@@ -2,6 +2,7 @@ import type { GenericEndpointContext } from "@better-auth/core";
import { APIError, getSessionFromCtx } from "better-auth/api";
import { generateRandomString } from "better-auth/crypto";
import { toExpJWT } from "better-auth/plugins";
import { assertClientPrivileges } from "./oauthClient/privileges";
import type { OAuthOptions, SchemaClient, Scope } from "./types";
import type { OAuthClient, TokenEndpointAuthMethod } from "./types/oauth";
import { parseClientMetadata, storeClientSecret } from "./utils";
@@ -272,6 +273,21 @@ export async function createOAuthClientEndpoint(
const body = ctx.body as OAuthClient;
const session = await getSessionFromCtx(ctx);
// Single authorization chokepoint for OAuth client creation. Every creation
// route reaches this function, so the create gate lives here rather than in
// each caller. Dynamic registration may be anonymous when
// allowUnauthenticatedClientRegistration is enabled, and registerEndpoint
// constrains that path to public clients, so it is authorized only when a
// session is present. Every other creation route requires an authorized
// session; assertClientPrivileges throws when none is present.
if (settings.isRegister) {
if (session) {
await assertClientPrivileges(ctx, session, opts, "create");
}
} else {
await assertClientPrivileges(ctx, session, opts, "create");
}
// Determine whether registration request for public client
// https://datatracker.ietf.org/doc/html/rfc7591#section-2
const isPublic = body.token_endpoint_auth_method === "none";
+3 -35
View File
@@ -1,8 +1,5 @@
import { isLoopbackHost } from "@better-auth/core/utils/host";
import * as z from "zod";
const DANGEROUS_SCHEMES = ["javascript:", "data:", "vbscript:"];
/**
* Runtime schema for OAuthAuthorizationQuery.
* Uses passthrough to tolerate fields added by future extensions (PAR, FPA, etc.)
@@ -45,36 +42,7 @@ export const verificationValueSchema = z
.passthrough();
/**
* Reusable URL validation for OAuth redirect URIs.
* - Blocks dangerous schemes (javascript:, data:, vbscript:)
* - For http/https: requires HTTPS (HTTP allowed only for loopback hosts: 127.0.0.0/8, [::1], *.localhost per RFC 6761)
* - Allows custom schemes for mobile apps (e.g., myapp://callback)
* Re-exported from `@better-auth/core` so every OAuth provider plugin shares one
* redirect-URI scheme policy. See `@better-auth/core/utils/redirect-uri`.
*/
export const SafeUrlSchema = z.url().superRefine((val, ctx) => {
if (!URL.canParse(val)) {
ctx.addIssue({
code: "custom",
message: "URL must be parseable",
fatal: true,
});
return z.NEVER;
}
const u = new URL(val);
if (DANGEROUS_SCHEMES.includes(u.protocol)) {
ctx.addIssue({
code: "custom",
message: "URL cannot use javascript:, data:, or vbscript: scheme",
});
return;
}
if (u.protocol === "http:" && !isLoopbackHost(u.host)) {
ctx.addIssue({
code: "custom",
message:
"Redirect URI must use HTTPS (HTTP allowed only for loopback hosts)",
});
}
});
export { SafeUrlSchema } from "@better-auth/core/utils/redirect-uri";