chore(sync): merge main into next

This commit is contained in:
Gustavo Valverde
2026-08-11 14:14:49 -04:00
50 changed files with 2048 additions and 455 deletions
+44 -8
View File
@@ -15,6 +15,7 @@ import {
useRef,
useState,
} from "react";
import { getChatErrorMessage } from "@/lib/ai-chat/error";
import { cn } from "@/lib/utils";
import { Markdown } from "./markdown";
import { MessageFeedback } from "./message-feedback";
@@ -396,11 +397,20 @@ function PanelMessages({ className, ...props }: ComponentProps<"div">) {
// ─── Panel Input ─────────────────────────────────────────────────────────────
function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
const { status, sendMessage, stop, setMessages, messages, regenerate } =
useChatContext();
const {
status,
error,
sendMessage,
stop,
setMessages,
messages,
regenerate,
clearError,
} = useChatContext();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isLoading = status === "streaming" || status === "submitted";
const hasError = error != null;
const inputDragRef = useRef<{ startY: number; startHeight: number } | null>(
null,
);
@@ -420,7 +430,7 @@ function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
const onSubmit = (e?: SyntheticEvent) => {
e?.preventDefault();
if (!input.trim() || isLoading) return;
if (!input.trim() || isLoading || hasError) return;
void sendMessage({ text: input });
setInput("");
requestAnimationFrame(adjustHeight);
@@ -487,8 +497,14 @@ function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
onSubmit();
}
}}
disabled={isLoading}
placeholder={isLoading ? "AI is answering..." : "Ask a question"}
disabled={isLoading || hasError}
placeholder={
isLoading
? "AI is answering..."
: hasError
? "Retry or clear the chat to continue"
: "Ask a question"
}
autoFocus={autoFocus}
rows={1}
style={{
@@ -525,8 +541,25 @@ function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
</div>
</form>
</div>
<div className="flex items-center gap-1.5 p-1 empty:hidden">
{!isLoading &&
<div className="flex flex-wrap items-center gap-1.5 p-1 empty:hidden">
{hasError && (
<div
role="alert"
className="flex w-full items-center justify-between gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-2 text-xs text-destructive"
>
<span>{getChatErrorMessage(error)}</span>
<button
type="button"
className="flex shrink-0 items-center gap-1 font-medium hover:underline"
onClick={() => void regenerate()}
>
<RefreshCw className="size-3" />
Retry
</button>
</div>
)}
{!hasError &&
!isLoading &&
messages.length > 0 &&
messages.at(-1)?.role === "assistant" && (
<button
@@ -542,7 +575,10 @@ function PanelInput({ autoFocus = false }: { autoFocus?: boolean }) {
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1 text-xs border rounded-full text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMessages([])}
onClick={() => {
setMessages([]);
clearError();
}}
>
Clear Chat
</button>
+13
View File
@@ -76,6 +76,19 @@ npx auth@latest init
* `--database` - The database you want to use. Currently, the only supported database is `SQLite`.
* `--package-manager` - The package manager you want to use. Currently, the only supported package managers are `npm`, `pnpm`, `yarn`, `bun` (defaults to the manager you used to initialize the CLI).
## Upgrade
The `upgrade` command updates older `better-auth` and official `@better-auth/*` dependencies to the version of the CLI being run. Dependencies at the same or a newer version are left unchanged.
```package-install title="Terminal"
npx auth@latest upgrade
```
### Options
* `--cwd` - The project directory containing the `package.json` to update. Defaults to the current directory.
* `--yes` - Skip the confirmation prompt and install the updates directly.
## Info
The `info` command provides diagnostic information about your Better Auth setup and environment. Useful for debugging and sharing when seeking support.
+27 -3
View File
@@ -872,7 +872,17 @@ await auth.api.registerSSOProvider({
});
```
Now when users from `acmecorp.com` sign in through this provider, they'll automatically be added to the "Acme Corp" organization with the appropriate role.
Users who sign in through this provider are automatically added to the "Acme
Corp" organization because the provider is explicitly linked to that
organization. Domain-derived assignment from another sign-in method requires
domain verification, a verified stored user email, and one unambiguous
organization match.
<Callout type="info">
Automatic SSO provisioning does not accept or cancel pending invitations. An
invited user must complete the existing invitation flow before Better Auth
creates an automatic membership for the same organization.
</Callout>
#### Self-Service SSO Dashboard
@@ -1397,11 +1407,16 @@ The provider ID is stored in the OAuth state so the callback can identify which
## Domain verification
Domain verification allows your application to automatically trust a new SSO provider
by automatically validating ownership via the associated domain.
Domain verification allows your application to trust a new SSO provider after
validating ownership of every associated domain.
When a provider's domain is verified, it is also trusted for **automatic account linking**. This means that if a user signs in with an SSO provider (OIDC or SAML) and an existing account with the same email exists, the accounts will be linked automatically — as long as the user's email domain matches the provider's verified domain.
Domain verification is also required for automatic organization assignment
after a user signs in through another identity provider, such as a social
provider. Better Auth uses the stored user's verified email and assigns an
organization only when the verified domain maps to exactly one.
<Tabs items={["client", "server"]}>
<Tab value="client">
```ts title="auth-client.ts"
@@ -1453,6 +1468,13 @@ Once enabled, make sure you migrate the database schema (again).
See the [Schema](#if-you-have-enabled-domain-verification) section to add the fields manually.
<Callout type="info">
With domain verification disabled, Better Auth does not create organization
memberships from email-domain matches. Signing in through a provider that is
explicitly linked to an organization still provisions normally, and
`signIn.sso` still resolves providers by email or domain.
</Callout>
### Verify your domain
When domain verification is enabled, every new SSO provider will be untrusted at first.
@@ -1494,6 +1516,8 @@ Once you have configured your domain, you can use your `auth` instance to submit
This request will either result in a rejection (could not prove your ownership over the domain)
or if the verification is successful, your SSO provider domain will be marked as verified.
Verification applies to the provider's exact domain configuration when the request starts. If the provider is updated or deleted while DNS verification is in progress, the request returns a `409` response with the `SSO_PROVIDER_CHANGED` code. Reload the provider, confirm its current domains, and retry verification. Request a new token first if the current provider does not have an active verification token.
<APIMethod path="/sso/verify-domain" method="POST" requireSession>
```ts
type verifyDomain = {
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
CHAT_ERROR_MESSAGE,
CHAT_RATE_LIMIT_MESSAGE,
getChatErrorMessage,
} from "./error";
describe("getChatErrorMessage", () => {
it("returns a recoverable message without exposing internal errors", () => {
expect(
getChatErrorMessage(new Error("Gateway request failed: secret")),
).toBe(CHAT_ERROR_MESSAGE);
});
it("explains when the request was rate limited", () => {
const errors = [
"Rate limit exceeded",
"rate_limit_error",
"Too Many Requests",
"RESOURCE_EXCEEDED",
"RESOURCE_EXHAUSTED",
"Quota exceeded",
"quota_exhausted",
"Request failed with status 429",
"HTTP 429",
];
for (const error of errors) {
expect(getChatErrorMessage(new Error(error))).toBe(
CHAT_RATE_LIMIT_MESSAGE,
);
}
});
it("does not mistake unrelated quota or numeric details for rate limits", () => {
const errors = [
"Moderation blocked due to output quota policy",
"Unexpected value 429 in response",
];
for (const error of errors) {
expect(getChatErrorMessage(new Error(error))).toBe(CHAT_ERROR_MESSAGE);
}
});
it("preserves sanitized errors received from the stream", () => {
expect(getChatErrorMessage(new Error(CHAT_ERROR_MESSAGE))).toBe(
CHAT_ERROR_MESSAGE,
);
expect(getChatErrorMessage(new Error(CHAT_RATE_LIMIT_MESSAGE))).toBe(
CHAT_RATE_LIMIT_MESSAGE,
);
});
it("handles non-Error values", () => {
expect(getChatErrorMessage(undefined)).toBe(CHAT_ERROR_MESSAGE);
});
});
+22
View File
@@ -0,0 +1,22 @@
export const CHAT_ERROR_MESSAGE =
"The response was interrupted. Please try again.";
export const CHAT_RATE_LIMIT_MESSAGE =
"Too many requests. Please wait a moment and try again.";
const RATE_LIMIT_PATTERN =
/rate[_ -]?limit|too many requests|resource_(?:exceeded|exhausted)|quota[_ -]?(?:exceeded|exhausted)|(?:http(?: status)?|status(?: code)?)\s*:?\s*429/i;
export function getChatErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message === CHAT_ERROR_MESSAGE || message === CHAT_RATE_LIMIT_MESSAGE) {
return message;
}
if (RATE_LIMIT_PATTERN.test(message)) {
return CHAT_RATE_LIMIT_MESSAGE;
}
return CHAT_ERROR_MESSAGE;
}
+9 -1
View File
@@ -1,5 +1,7 @@
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import * as z from "zod";
import { getChatErrorMessage } from "@/lib/ai-chat/error";
import { prepareChatStep } from "@/lib/ai-chat/step";
import { getLLMText } from "@/lib/llm-text";
import { source } from "@/lib/source";
import { checkRateLimit, getClientIP } from "./rate-limit";
@@ -400,10 +402,16 @@ export async function POST(req: Request) {
},
}),
},
prepareStep: prepareChatStep,
stopWhen: stepCountIs(8),
});
return result.toUIMessageStreamResponse();
return result.toUIMessageStreamResponse({
onError: (error) => {
console.error(`[ai-chat] stream error (${errorId})`, error);
return getChatErrorMessage(error);
},
});
} catch (err) {
console.error(`[ai-chat] unhandled error (${errorId})`, err);
return new Response(
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { prepareChatStep } from "./step";
describe("prepareChatStep", () => {
it("requires a tool call before the assistant can answer", () => {
expect(prepareChatStep({ stepNumber: 0 })).toEqual({
toolChoice: "required",
});
});
it("lets the assistant answer after using a tool", () => {
expect(prepareChatStep({ stepNumber: 1 })).toEqual({});
});
});
+7
View File
@@ -0,0 +1,7 @@
export function prepareChatStep({ stepNumber }: { stepNumber: number }) {
if (stepNumber === 0) {
return { toolChoice: "required" as const };
}
return {};
}
+1 -1
View File
@@ -79,7 +79,7 @@
"js-beautify": "^1.15.4",
"lottie-react": "^2.4.1",
"lucide-react": "^1.21.0",
"mermaid": "^11.15.0",
"mermaid": "^11.16.1",
"next": "16.2.11",
"next-mdx-remote": "^6.0.0",
"next-themes": "^0.4.6",
+5 -1
View File
@@ -1,3 +1,7 @@
import { defineProject } from "vitest/config";
export default defineProject({});
export default defineProject({
test: {
environment: "node",
},
});
+8
View File
@@ -54,6 +54,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+17
View File
@@ -378,6 +378,23 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- [#10657](https://github.com/better-auth/better-auth/pull/10657) [`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b) Thanks [@bytaesu](https://github.com/bytaesu)! - Aligned endpoint and middleware context types with runtime route parameters, and preserved response headers when resolving sessions from endpoint contexts.
- [#10676](https://github.com/better-auth/better-auth/pull/10676) [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839) Thanks [@bytaesu](https://github.com/bytaesu)! - Deduplicate in-flight session requests when React retries a suspended component.
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
- @better-auth/drizzle-adapter@1.6.27
- @better-auth/kysely-adapter@1.6.27
- @better-auth/memory-adapter@1.6.27
- @better-auth/mongo-adapter@1.6.27
- @better-auth/prisma-adapter@1.6.27
- @better-auth/telemetry@1.6.27
## 1.6.26
### Patch Changes
@@ -1784,6 +1784,7 @@ describe("cookie cache refreshCache", async () => {
context: ctx,
headers,
query: {},
responseHeaders: new Headers(),
} as unknown as GenericEndpointContext;
await runWithRequestState(new WeakMap(), async () => {
@@ -1792,7 +1793,7 @@ describe("cookie cache refreshCache", async () => {
expect(session?.user.email).toBe(testUser.email);
const parsed = parseSetCookieHeader(
endpointCtx.context.responseHeaders?.get("set-cookie") || "",
endpointCtx.responseHeaders.get("set-cookie") || "",
);
const forwardedSessionDataCookie = Array.from(parsed.keys()).some(
(name) =>
@@ -490,12 +490,10 @@ export const getSessionFromCtx = async <
if (lowerKey === "cache-control" || lowerKey === "pragma") {
return;
}
if (!ctx.context.responseHeaders) {
ctx.context.responseHeaders = new Headers({ [key]: value });
} else if (lowerKey === "set-cookie") {
ctx.context.responseHeaders.append(key, value);
if (lowerKey === "set-cookie") {
ctx.responseHeaders.append(key, value);
} else {
ctx.context.responseHeaders.set(key, value);
ctx.responseHeaders.set(key, value);
}
});
}
+7 -1
View File
@@ -116,7 +116,13 @@ describe("general types", async () => {
{
method: "GET",
},
async () => "ok",
async (context) => {
expectTypeOf(context.path).toEqualTypeOf<string>();
expectTypeOf(context.params).toEqualTypeOf<
Record<string, string | undefined> | undefined
>();
return "ok";
},
),
testServerScoped: createAuthEndpoint(
"/test-server-scoped",
+11
View File
@@ -113,6 +113,17 @@
- [#9547](https://github.com/better-auth/better-auth/pull/9547) [`15bb169`](https://github.com/better-auth/better-auth/commit/15bb1690b274b9f1e2a58c5db039bd240dc5e99b) Thanks [@GautamBytes](https://github.com/GautamBytes)! - Add a `create-admin` CLI command for creating an initial admin user through the configured Better Auth instance.
## 1.6.27
### Patch Changes
- [#10743](https://github.com/better-auth/better-auth/pull/10743) [`b8077b7`](https://github.com/better-auth/better-auth/commit/b8077b74ef9a80a7757220b72834349bd8de05c0) Thanks [@bytaesu](https://github.com/bytaesu)! - The upgrade command now targets the running CLI version, including prereleases, instead of the npm `latest` tag.
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
- @better-auth/telemetry@1.6.27
## 1.6.26
### Patch Changes
+1 -1
View File
@@ -7,7 +7,6 @@ import open from "open";
import prompts from "prompts";
import yoctoSpinner from "yocto-spinner";
import * as z from "zod";
import { cliVersion } from "../..";
import { generateDrizzleSchema } from "../../generators/drizzle";
import { generatePrismaSchema } from "../../generators/prisma";
import {
@@ -22,6 +21,7 @@ import {
import { getPackageInfo, hasDependency } from "../../utils/get-package-info";
import { generateSecretHash, tryCatch } from "../../utils/helper";
import { installDependencies } from "../../utils/install-dependencies";
import { cliVersion } from "../../version";
import type { DatabaseAdapter } from "./configs/databases.config";
import type { Framework } from "./configs/frameworks.config";
import { FRAMEWORKS } from "./configs/frameworks.config";
+12 -22
View File
@@ -7,9 +7,9 @@ import * as semver from "semver";
import yoctoSpinner from "yocto-spinner";
import * as z from "zod";
import { detectPackageManager } from "../utils/check-package-managers";
import { fetchLatestVersion } from "../utils/fetch-latest-version";
import { getPackageInfo } from "../utils/get-package-info";
import { installDependencies } from "../utils/install-dependencies";
import { cliVersion } from "../version";
function isBetterAuthPackage(name: string): boolean {
return name === "better-auth" || name.startsWith("@better-auth/");
@@ -18,11 +18,12 @@ function isBetterAuthPackage(name: string): boolean {
interface UpgradeEntry {
name: string;
current: string;
latest: string;
target: string;
depType: "prod" | "dev";
}
async function upgradeAction(opts: unknown) {
/** @internal */
export async function upgradeAction(opts: unknown) {
const options = z
.object({
cwd: z.string(),
@@ -73,22 +74,11 @@ async function upgradeAction(opts: unknown) {
const spinner = yoctoSpinner({ text: "checking for updates..." }).start();
const results = await Promise.allSettled(
candidates.map(async (c) => {
const latest = await fetchLatestVersion(c.name);
return { ...c, latest };
}),
);
const upgrades: UpgradeEntry[] = [];
for (const result of results) {
if (result.status !== "fulfilled" || !result.value.latest) {
continue;
}
const { name, current, latest, depType } = result.value;
const coerced = semver.coerce(current);
if (coerced && semver.lt(coerced, latest)) {
upgrades.push({ name, current, latest, depType });
for (const { name, current, depType } of candidates) {
const currentVersion = semver.minVersion(current);
if (currentVersion && semver.lt(currentVersion, cliVersion)) {
upgrades.push({ name, current, target: cliVersion, depType });
}
}
@@ -102,7 +92,7 @@ async function upgradeAction(opts: unknown) {
console.log(`\nThe following packages can be upgraded:\n`);
for (const u of upgrades) {
console.log(
` ${chalk.cyan(u.name)} ${chalk.gray(u.current)} ${chalk.white("→")} ${chalk.green(u.latest)}`,
` ${chalk.cyan(u.name)} ${chalk.gray(u.current)} ${chalk.white("→")} ${chalk.green(u.target)}`,
);
}
console.log();
@@ -127,10 +117,10 @@ async function upgradeAction(opts: unknown) {
const prodUpgrades = upgrades
.filter((u) => u.depType === "prod")
.map((u) => `${u.name}@${u.latest}`);
.map((u) => `${u.name}@${u.target}`);
const devUpgrades = upgrades
.filter((u) => u.depType === "dev")
.map((u) => `${u.name}@${u.latest}`);
.map((u) => `${u.name}@${u.target}`);
const installSpinner = yoctoSpinner({
text: "installing updates...",
@@ -163,7 +153,7 @@ async function upgradeAction(opts: unknown) {
}
export const upgrade = new Command("upgrade")
.description("Upgrade better-auth packages to their latest versions")
.description("Upgrade better-auth packages to the running CLI version")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
+1 -11
View File
@@ -11,7 +11,7 @@ import { mcp } from "./commands/mcp";
import { migrate } from "./commands/migrate";
import { generateSecret } from "./commands/secret";
import { upgrade } from "./commands/upgrade";
import { getPackageInfo } from "./utils/get-package-info";
import { cliVersion } from "./version";
import "dotenv/config";
@@ -19,18 +19,8 @@ import "dotenv/config";
process.on("SIGINT", () => process.exit(0));
process.on("SIGTERM", () => process.exit(0));
export let cliVersion = "1.1.2";
async function main() {
const program = new Command("better-auth");
let packageInfo: Record<string, any> = {};
try {
packageInfo = await getPackageInfo();
cliVersion = packageInfo.version || "1.1.2";
} catch {
// it doesn't matter if we can't read the package.json file, we'll just use an empty object
}
program
.addCommand(ai)
.addCommand(createAdmin)
@@ -1,19 +0,0 @@
export async function fetchLatestVersion(
packageName: string,
): Promise<string | null> {
const encoded = packageName.startsWith("@")
? `@${encodeURIComponent(packageName.slice(1))}`
: encodeURIComponent(packageName);
try {
const response = await fetch(
`https://registry.npmjs.org/${encoded}/latest`,
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as { version?: string };
return data.version ?? null;
} catch {
return null;
}
}
+3
View File
@@ -0,0 +1,3 @@
import packageJson from "../package.json" with { type: "json" };
export const cliVersion = packageJson.version;
+1 -2
View File
@@ -11,8 +11,7 @@ import { getPackageInfo, hasDependency } from "../src/utils/get-package-info";
import { installDependencies } from "../src/utils/install-dependencies";
import { testWithTmpDir } from "./test-utils";
// Mock src/index.ts to prevent it from executing during tests
vi.mock("../src/index.ts", () => ({
vi.mock(import("../src/version"), () => ({
cliVersion: "1.0.0",
}));
+85
View File
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Spinner } from "yocto-spinner";
import { upgradeAction } from "../src/commands/upgrade";
import { detectPackageManager } from "../src/utils/check-package-managers";
import { getPackageInfo } from "../src/utils/get-package-info";
import { installDependencies } from "../src/utils/install-dependencies";
vi.mock(import("../src/version"), () => ({
cliVersion: "1.7.0-rc.4",
}));
vi.mock(import("../src/utils/check-package-managers"), { spy: true });
vi.mock(import("../src/utils/get-package-info"), { spy: true });
vi.mock(import("../src/utils/install-dependencies"), { spy: true });
vi.mock(import("yocto-spinner"), () => ({
default: vi.fn(() => {
const spinner: Spinner = {
text: "",
color: "cyan",
start: vi.fn(() => spinner),
stop: vi.fn(() => spinner),
success: vi.fn(() => spinner),
error: vi.fn(() => spinner),
warning: vi.fn(() => spinner),
info: vi.fn(() => spinner),
clear: vi.fn(() => spinner),
get isSpinning() {
return false;
},
};
return spinner;
}),
}));
const mockDetectPackageManager = vi.mocked(detectPackageManager);
const mockGetPackageInfo = vi.mocked(getPackageInfo);
const mockInstallDependencies = vi.mocked(installDependencies);
describe("upgradeAction", () => {
beforeEach(() => {
vi.spyOn(console, "log").mockImplementation(() => {});
mockGetPackageInfo.mockReturnValue({
dependencies: {
"better-auth": "^1.6.26",
"@better-auth/core": "1.7.0-beta.10",
},
devDependencies: {
"@better-auth/passkey": "~1.6.26",
},
});
mockDetectPackageManager.mockResolvedValue({ packageManager: "pnpm" });
mockInstallDependencies.mockResolvedValue(true);
});
it("upgrades Better Auth packages to the running CLI version", async () => {
await upgradeAction({ cwd: process.cwd(), yes: true });
expect(mockInstallDependencies).toHaveBeenCalledTimes(2);
expect(mockInstallDependencies).toHaveBeenCalledWith({
dependencies: ["better-auth@1.7.0-rc.4", "@better-auth/core@1.7.0-rc.4"],
packageManager: "pnpm",
cwd: process.cwd(),
type: "prod",
});
expect(mockInstallDependencies).toHaveBeenCalledWith({
dependencies: ["@better-auth/passkey@1.7.0-rc.4"],
packageManager: "pnpm",
cwd: process.cwd(),
type: "dev",
});
});
it("does not downgrade packages newer than the running CLI", async () => {
mockGetPackageInfo.mockReturnValue({
dependencies: {
"better-auth": "^1.7.0",
"@better-auth/core": "^1.7.0-rc.4",
},
});
await upgradeAction({ cwd: process.cwd(), yes: true });
expect(mockDetectPackageManager).not.toHaveBeenCalled();
expect(mockInstallDependencies).not.toHaveBeenCalled();
});
});
+2 -1
View File
@@ -10,5 +10,6 @@
{
"path": "../core/tsconfig.json"
}
]
],
"include": ["**/*.ts", "package.json"]
}
+6
View File
@@ -163,6 +163,12 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- [#10657](https://github.com/better-auth/better-auth/pull/10657) [`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b) Thanks [@bytaesu](https://github.com/bytaesu)! - Aligned endpoint and middleware context types with runtime route parameters, and preserved response headers when resolving sessions from endpoint contexts.
## 1.6.26
### Patch Changes
+101 -100
View File
@@ -1,6 +1,7 @@
import type {
EndpointContext,
EndpointHandler,
EndpointOptions,
MiddlewareHandler,
StrictEndpoint,
} from "better-call";
import {
@@ -75,13 +76,47 @@ export const createAuthMiddleware = createMiddleware.create({
],
});
const use = [optionsMiddleware];
const createEndpointWithAuthContext = createEndpoint.create({
use: [optionsMiddleware],
});
type EndpointHandler<
type AuthEndpointHandler<
Path extends string,
Options extends EndpointOptions,
R,
> = (context: EndpointContext<Path, Options, AuthContext>) => Promise<R>;
> = EndpointHandler<Path, Options, R, AuthContext>;
type PathlessAuthEndpointHandler<
Options extends EndpointOptions,
R,
> = AuthEndpointHandler<string, Options, R>;
function wrapEndpointHandler<
Path extends string,
Options extends EndpointOptions,
R,
>(
handler: AuthEndpointHandler<Path, Options, R>,
options: Options,
): AuthEndpointHandler<Path, Options, R> {
const noStore =
(options as { metadata?: { noStore?: boolean } }).metadata?.noStore ===
true;
return async (context) => {
if (noStore) {
for (const [name, value] of Object.entries(NO_STORE_HEADERS)) {
context.setHeader(name, value);
}
}
try {
return await runWithEndpointContext(context, () => handler(context));
} catch (error) {
attachResponseHeadersToAPIError(context.responseHeaders, error);
throw error;
}
};
}
export function createAuthEndpoint<
Path extends string,
@@ -90,79 +125,44 @@ export function createAuthEndpoint<
>(
path: Path,
options: Options,
handler: EndpointHandler<Path, Options, R>,
handler: AuthEndpointHandler<Path, Options, R>,
): StrictEndpoint<Path, Options, R>;
export function createAuthEndpoint<
InferredPath extends string,
Options extends EndpointOptions,
R,
>(
options: Options,
handler: PathlessAuthEndpointHandler<Options, R>,
): StrictEndpoint<InferredPath, Options, R>;
export function createAuthEndpoint<
Path extends string,
Options extends EndpointOptions,
R,
>(
options: Options,
handler: EndpointHandler<Path, Options, R>,
): StrictEndpoint<Path, Options, R>;
export function createAuthEndpoint<
Path extends string,
Opts extends EndpointOptions,
R,
>(
pathOrOptions: Path | Opts,
handlerOrOptions: EndpointHandler<Path, Opts, R> | Opts,
handlerOrNever?: any,
...args:
| [
path: Path,
options: Options,
handler: AuthEndpointHandler<Path, Options, R>,
]
| [options: Options, handler: PathlessAuthEndpointHandler<Options, R>]
) {
const path: Path | undefined =
typeof pathOrOptions === "string" ? pathOrOptions : undefined;
const options: Opts =
typeof handlerOrOptions === "object"
? handlerOrOptions
: (pathOrOptions as Opts);
const handler: EndpointHandler<Path, Opts, R> =
typeof handlerOrOptions === "function" ? handlerOrOptions : handlerOrNever;
// Endpoints that return credentials declare `metadata: { noStore: true }`.
// Emit the no-store headers at the boundary, before the handler runs, so they
// land on every response the handler produces: a success harvests
// `responseHeaders`, and a thrown error carries the same headers through
// `attachResponseHeadersToAPIError`. Validation that rejects the request
// before the handler runs is not covered (and returns no credentials).
const noStore =
(options as { metadata?: { noStore?: boolean } }).metadata?.noStore ===
true;
// todo: prettify the code, we want to call `runWithEndpointContext` to top level
const wrapped: EndpointHandler<Path, Opts, R> = async (ctx) => {
if (noStore) {
for (const [name, value] of Object.entries(NO_STORE_HEADERS)) {
ctx.setHeader(name, value);
}
}
const runtimeCtx = ctx as unknown as { responseHeaders?: Headers };
try {
return await runWithEndpointContext(ctx as any, () => handler(ctx));
} catch (e) {
attachResponseHeadersToAPIError(runtimeCtx.responseHeaders, e);
throw e;
}
};
if (path) {
return createEndpoint(
if (args.length === 3) {
const [path, options, handler] = args;
return createEndpointWithAuthContext(
path,
{
...options,
use: [...(options?.use || []), ...use],
},
wrapped,
options,
wrapEndpointHandler(handler, options),
);
}
return createEndpoint(
{
...options,
use: [...(options?.use || []), ...use],
},
wrapped,
const [options, handler] = args;
return createEndpointWithAuthContext(
options,
wrapEndpointHandler(handler, options),
);
}
@@ -179,44 +179,45 @@ function withServerOnly<Options extends EndpointOptions>(
} as Options;
}
export namespace createAuthEndpoint {
/**
* Declare a **server-only** endpoint.
*
* The endpoint is callable through `auth.api.*` from trusted server code but is
* never registered on the HTTP router and never emitted into the OpenAPI
* schema. It takes no path because it has no URL to be reached at.
*
* Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
* Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
* keeps the endpoint off the HTTP surface even if a path is later added by
* mistake: better-call's router skips an endpoint when its path is missing *or*
* when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
* on path omission alone is invisible and one keystroke away from exposure.
*
* @example
* ```ts
* viewBackupCodes: createAuthEndpoint.serverOnly(
* { method: "POST", body: schema },
* async (ctx) => { ... },
* )
* ```
*/
export function serverOnly<
Path extends string,
Options extends EndpointOptions,
R,
>(
options: Options,
handler: EndpointHandler<Path, Options, R>,
): StrictEndpoint<Path, Options, R> {
return createAuthEndpoint(withServerOnly(options), handler);
}
}
/**
* Declare a **server-only** endpoint.
*
* The endpoint is callable through `auth.api.*` from trusted server code but is
* never registered on the HTTP router and never emitted into the OpenAPI
* schema. It takes no path because it has no URL to be reached at.
*
* Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
* Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
* keeps the endpoint off the HTTP surface even if a path is later added by
* mistake: better-call's router skips an endpoint when its path is missing *or*
* when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
* on path omission alone is invisible and one keystroke away from exposure.
*
* @example
* ```ts
* viewBackupCodes: createAuthEndpoint.serverOnly(
* { method: "POST", body: schema },
* async (ctx) => { ... },
* )
* ```
*/
createAuthEndpoint.serverOnly = <
InferredPath extends string,
Options extends EndpointOptions,
R,
>(
options: Options,
handler: PathlessAuthEndpointHandler<Options, R>,
): StrictEndpoint<InferredPath, Options, R> =>
createAuthEndpoint<InferredPath, Options, R>(
withServerOnly(options),
handler,
);
export type AuthEndpoint<
Path extends string,
Opts extends EndpointOptions,
R,
> = ReturnType<typeof createAuthEndpoint<Path, Opts, R>>;
export type AuthMiddleware = ReturnType<typeof createAuthMiddleware>;
export type AuthMiddleware = MiddlewareHandler;
+7
View File
@@ -73,6 +73,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -66,6 +66,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -62,6 +62,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -54,6 +54,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -59,6 +59,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -49,6 +49,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -53,6 +53,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -235,6 +235,14 @@
Migration: run the schema migration (`npx @better-auth/cli migrate`, or `generate` if you manage the schema yourself) to add the new resource columns.
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -58,6 +58,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -49,6 +49,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -49,6 +49,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -125,6 +125,14 @@
Connections created before this release carry no owner. Access now fails closed, so those connections are no longer reachable through the management endpoints, including token regeneration. Reclaim them at the database level: delete `scimProvider` rows that have neither `organizationId` nor `userId`, or set `userId` to the intended owner, then regenerate tokens as needed. Organization-scoped connections are not affected.
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+12
View File
@@ -160,6 +160,18 @@
Closes [#5604](https://github.com/better-auth/better-auth/issues/5604).
Supersedes [#4992](https://github.com/better-auth/better-auth/issues/4992) and [#5443](https://github.com/better-auth/better-auth/issues/5443).
## 1.6.27
### Patch Changes
- [`999acbd`](https://github.com/better-auth/better-auth/commit/999acbd41d4d6bad81b24ce9edef063632ad22bf) Thanks [@bytaesu](https://github.com/bytaesu)! - Domain verification now applies only to the domains the provider held when the request started. If the provider changes while the DNS check is still running, the request returns `409` with the `SSO_PROVIDER_CHANGED` code instead of recording a result, so callers can reload the provider and retry.
- [`999acbd`](https://github.com/better-auth/better-auth/commit/999acbd41d4d6bad81b24ce9edef063632ad22bf) Thanks [@bytaesu](https://github.com/bytaesu)! - Automatic organization assignment from an email domain now requires a verified provider domain and a verified stored user email, so a social sign-in no longer joins an organization whose SSO provider only claims that domain. Explicit organization-bound OIDC and SAML provisioning is unchanged.
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
@@ -0,0 +1,395 @@
import { getTestInstance } from "better-auth/test";
import { afterEach, describe, expect, it, vi } from "vitest";
import { sso } from ".";
import { ssoClient } from "./client";
const dnsMock = vi.hoisted(() => ({
resolveTxt: vi.fn(),
}));
vi.mock("node:dns/promises", () => ({
...dnsMock,
default: dnsMock,
}));
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function createDomainVerificationHarness() {
const { auth, signInWithTestUser } = await getTestInstance(
{
plugins: [
sso({
domainVerification: { enabled: true },
}),
],
},
{
clientOptions: {
plugins: [
ssoClient({
domainVerification: { enabled: true },
}),
],
},
},
);
const { headers: sessionHeaders } = await signInWithTestUser();
const request = (
path: string,
options: {
method?: "GET" | "POST";
body?: Record<string, unknown>;
} = {},
) => {
const headers = new Headers(sessionHeaders);
if (options.body) {
headers.set("content-type", "application/json");
}
return auth.handler(
new Request(`http://localhost:3000/api/auth${path}`, {
method: options.method ?? "GET",
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
}),
);
};
const registerProvider = async (
providerId: string,
domain: string,
options: {
audience?: string;
issuer?: string;
} = {},
) => {
const issuer = options.issuer ?? "https://idp.example";
const response = await request("/sso/register", {
method: "POST",
body: {
providerId,
issuer,
domain,
samlConfig: {
entryPoint: `${issuer}/sso`,
cert: "test-certificate",
callbackUrl: `http://localhost:3000/api/auth/sso/saml2/sp/acs/${providerId}`,
audience: options.audience,
idpMetadata: {
entityID: issuer,
},
spMetadata: {},
},
},
});
expect(response.status).toBe(200);
return (await response.json()) as {
domain: string;
domainVerificationToken: string;
domainVerified: boolean;
providerId: string;
samlConfig: {
audience?: string;
};
};
};
const getProvider = async (providerId: string) => {
const response = await request(
`/sso/get-provider?providerId=${providerId}`,
);
const provider =
response.status === 200
? ((await response.json()) as {
domain: string;
domainVerified: boolean;
samlConfig: {
audience?: string;
};
})
: null;
return { response, provider };
};
return { getProvider, registerProvider, request };
}
const staleProviderError = {
code: "SSO_PROVIDER_CHANGED",
message:
"SSO provider changed while domain verification was in progress. Reload the provider and try again",
};
describe("domain verification concurrency", () => {
afterEach(() => {
dnsMock.resolveTxt.mockReset();
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("rejects a DNS proof after the provider domain changes", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "domain-update-race";
const attackerDomain = "attacker.example";
const victimDomain = "victim.example";
const provider = await registerProvider(providerId, attackerDomain);
const dnsStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
dnsStarted.resolve();
return dnsCompletion.promise;
});
const verificationPromise = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
await dnsStarted.promise;
const updateResponse = await request("/sso/update-provider", {
method: "POST",
body: {
providerId,
domain: victimDomain,
},
});
expect(updateResponse.status).toBe(200);
dnsCompletion.resolve([[provider.domainVerificationToken]]);
const verificationResponse = await verificationPromise;
expect(verificationResponse.status).toBe(409);
expect(await verificationResponse.json()).toEqual(staleProviderError);
// The proof was collected for the domain held at request start, not the one
// swapped in.
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
`_better-auth-token-${providerId}.${attackerDomain}`,
);
const { provider: persistedProvider } = await getProvider(providerId);
expect(persistedProvider).toMatchObject({
domain: victimDomain,
domainVerified: false,
});
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("keeps a later domain update unverified after verification completes", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "verify-before-update";
const originalDomain = "original.example";
const changedDomain = "changed.example";
const provider = await registerProvider(providerId, originalDomain);
dnsMock.resolveTxt.mockResolvedValue([[provider.domainVerificationToken]]);
const verificationResponse = await request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
expect(verificationResponse.status).toBe(204);
const updateResponse = await request("/sso/update-provider", {
method: "POST",
body: {
providerId,
domain: changedDomain,
},
});
expect(updateResponse.status).toBe(200);
const { provider: persistedProvider } = await getProvider(providerId);
expect(persistedProvider).toMatchObject({
domain: changedDomain,
domainVerified: false,
});
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("rejects a DNS proof after the provider is deleted", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "delete-race";
const domain = "delete.example";
const provider = await registerProvider(providerId, domain);
const dnsStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
dnsStarted.resolve();
return dnsCompletion.promise;
});
const verificationPromise = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
await dnsStarted.promise;
const deleteResponse = await request("/sso/delete-provider", {
method: "POST",
body: { providerId },
});
expect(deleteResponse.status).toBe(200);
dnsCompletion.resolve([[provider.domainVerificationToken]]);
const verificationResponse = await verificationPromise;
expect(verificationResponse.status).toBe(409);
expect(await verificationResponse.json()).toEqual(staleProviderError);
const { response: getProviderResponse } = await getProvider(providerId);
expect(getProviderResponse.status).toBe(404);
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("does not apply an old proof to a replacement with the same provider ID", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "replacement-race";
const originalDomain = "original.example";
const replacementDomain = "replacement.example";
const originalProvider = await registerProvider(providerId, originalDomain);
const dnsStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
dnsStarted.resolve();
return dnsCompletion.promise;
});
const verificationPromise = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
await dnsStarted.promise;
const deleteResponse = await request("/sso/delete-provider", {
method: "POST",
body: { providerId },
});
expect(deleteResponse.status).toBe(200);
const replacement = await registerProvider(providerId, replacementDomain, {
issuer: "https://replacement-idp.example",
});
expect(replacement.domainVerified).toBe(false);
dnsCompletion.resolve([[originalProvider.domainVerificationToken]]);
const verificationResponse = await verificationPromise;
expect(verificationResponse.status).toBe(409);
expect(await verificationResponse.json()).toEqual(staleProviderError);
const { provider: persistedProvider } = await getProvider(providerId);
expect(persistedProvider).toMatchObject({
domain: replacementDomain,
domainVerified: false,
});
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("verifies the domain when two simultaneous requests prove the same domain", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "simultaneous-verification";
const domain = "simultaneous.example";
const provider = await registerProvider(providerId, domain);
let startedCount = 0;
const bothStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
startedCount += 1;
if (startedCount === 2) {
bothStarted.resolve();
}
return dnsCompletion.promise;
});
const firstVerification = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
const secondVerification = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
await bothStarted.promise;
dnsCompletion.resolve([[provider.domainVerificationToken]]);
// Both requests proved the domain that is still stored, so neither is
// stale. Reporting a conflict here would tell a retrying client the
// provider changed when nothing did.
const responses = await Promise.all([
firstVerification,
secondVerification,
]);
expect(responses.map((response) => response.status)).toEqual([204, 204]);
const { provider: persistedProvider } = await getProvider(providerId);
expect(persistedProvider).toMatchObject({ domain, domainVerified: true });
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("allows an unrelated provider update while DNS verification is in flight", async () => {
const { getProvider, registerProvider, request } =
await createDomainVerificationHarness();
const providerId = "unrelated-update";
const domain = "unchanged.example";
const provider = await registerProvider(providerId, domain, {
audience: "original-audience",
});
const dnsStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
dnsStarted.resolve();
return dnsCompletion.promise;
});
const verificationPromise = request("/sso/verify-domain", {
method: "POST",
body: { providerId },
});
await dnsStarted.promise;
const updateResponse = await request("/sso/update-provider", {
method: "POST",
body: {
providerId,
samlConfig: {
audience: "updated-audience",
},
},
});
expect(updateResponse.status).toBe(200);
dnsCompletion.resolve([[provider.domainVerificationToken]]);
const verificationResponse = await verificationPromise;
expect(verificationResponse.status).toBe(204);
const { provider: persistedProvider } = await getProvider(providerId);
expect(persistedProvider).toMatchObject({
domain,
domainVerified: true,
samlConfig: {
audience: "updated-audience",
},
});
});
});
@@ -15,6 +15,14 @@ const dnsMock = vi.hoisted(() => {
};
});
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
vi.mock("node:dns/promises", () => {
return {
...dnsMock,
@@ -556,6 +564,151 @@ describe("Domain verification", async () => {
);
});
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("rejects a stale proof after a domain update with the memory adapter", async () => {
const { auth, getAuthHeaders, registerSSOProvider } = createTestAuth();
const headers = await getAuthHeaders(testUser);
const provider = await registerSSOProvider(headers);
const dnsStarted = createDeferred<void>();
const dnsCompletion = createDeferred<string[][]>();
dnsMock.resolveTxt.mockImplementation(async () => {
dnsStarted.resolve();
return dnsCompletion.promise;
});
const verificationPromise = auth.api.verifyDomain({
body: {
providerId: provider.providerId,
},
headers,
asResponse: true,
});
await dnsStarted.promise;
const updateResponse = await auth.api.updateSSOProvider({
body: {
providerId: provider.providerId,
domain: "changed.example",
},
headers,
asResponse: true,
});
expect(updateResponse.status).toBe(200);
dnsCompletion.resolve([[provider.domainVerificationToken]]);
const verificationResponse = await verificationPromise;
expect(verificationResponse.status).toBe(409);
expect(await verificationResponse.json()).toEqual({
code: "SSO_PROVIDER_CHANGED",
message:
"SSO provider changed while domain verification was in progress. Reload the provider and try again",
});
const persistedProvider = await auth.api.getSSOProvider({
query: { providerId: provider.providerId },
headers,
});
expect(persistedProvider).toMatchObject({
domain: "changed.example",
domainVerified: false,
});
});
/**
* `domainVerified` only joins the schema once `domainVerification` is
* enabled, so providers registered before that have no stored value for the
* bit. Enabling the option later must not strand them.
*
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-8c5h-wx78-2cfg
*/
it("verifies a provider registered before domain verification was enabled", async () => {
const db = {
user: [] as Record<string, unknown>[],
session: [] as Record<string, unknown>[],
account: [] as Record<string, unknown>[],
verification: [] as Record<string, unknown>[],
ssoProvider: [] as Record<string, unknown>[],
};
const buildAuth = (domainVerificationEnabled: boolean) =>
betterAuth({
database: memoryAdapter(db),
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
plugins: [
sso(
domainVerificationEnabled
? { domainVerification: { enabled: true } }
: {},
),
],
});
const signIn = async (
auth: ReturnType<typeof buildAuth>,
options: { register?: boolean } = {},
) => {
const client = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer()],
fetchOptions: {
customFetchImpl: async (url, init) =>
auth.handler(new Request(url, init)),
},
});
if (options.register) {
await client.signUp.email(testUser);
}
const headers = new Headers();
await client.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
return headers;
};
const legacyAuth = buildAuth(false);
await legacyAuth.api.registerSSOProvider({
body: {
providerId: "pre-upgrade-provider",
issuer: "http://hello.com:8081",
domain: "http://hello.com:8081",
samlConfig: {
entryPoint: "http://idp.com:",
cert: "the-cert",
callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
spMetadata: {},
},
},
headers: await signIn(legacyAuth, { register: true }),
});
// Precondition: the column is absent, so the bit was never stored.
expect(db.ssoProvider[0]).not.toHaveProperty("domainVerified");
const auth = buildAuth(true);
const headers = await signIn(auth);
const { domainVerificationToken } =
await auth.api.requestDomainVerification({
body: { providerId: "pre-upgrade-provider" },
headers,
});
dnsMock.resolveTxt.mockResolvedValue([[domainVerificationToken]]);
const response = await auth.api.verifyDomain({
body: { providerId: "pre-upgrade-provider" },
headers,
asResponse: true,
});
expect(response.status).toBe(204);
const persistedProvider = await auth.api.getSSOProvider({
query: { providerId: "pre-upgrade-provider" },
headers,
});
expect(persistedProvider).toMatchObject({ domainVerified: true });
});
it("should verify a provider domain ownership (custom token verification prefix)", async () => {
const { auth, getAuthHeaders, registerSSOProvider } = createTestAuth({
domainVerification: { tokenPrefix: "auth-prefix" },
@@ -0,0 +1,286 @@
import type { User } from "better-auth";
import { organization } from "better-auth/plugins";
import { getTestInstance } from "better-auth/test";
import { SignJWT } from "jose";
import { describe, expect, it, vi } from "vitest";
import { sso } from "..";
const TEST_DOMAIN = "verified-corp.test";
const TEST_EMAIL = `employee@${TEST_DOMAIN}`;
function createInstance(domainVerification?: { enabled: true }) {
return getTestInstance({
plugins: [
sso(domainVerification ? { domainVerification } : {}),
organization(),
],
});
}
type Instance = Awaited<ReturnType<typeof createInstance>>;
async function createGoogleIdToken(emailVerified: boolean, subject: string) {
return new SignJWT({
aud: "test",
azp: "test",
email: TEST_EMAIL,
email_verified: emailVerified,
iss: "https://accounts.google.com",
name: "Verified Employee",
sub: subject,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(new TextEncoder().encode("private-test-only"));
}
function mockGoogleToken(idToken: string) {
const originalFetch = globalThis.fetch;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
if (url === "https://oauth2.googleapis.com/token") {
return Response.json({
access_token: "test-access-token",
expires_in: 3600,
id_token: idToken,
token_type: "Bearer",
});
}
return originalFetch(input, init);
});
}
/**
* Creates an organization owned by the test user and an SSO provider that claims
* `TEST_DOMAIN` for it.
*/
async function registerOrganizationProvider(
instance: Instance,
options: { domainVerified: boolean },
) {
const { headers } = await instance.signInWithTestUser();
const targetOrganization = await instance.auth.api.createOrganization({
body: { name: "Target Organization", slug: "target-organization" },
headers,
});
if (!targetOrganization) {
throw new Error("Target organization was not created");
}
await instance.auth.api.registerSSOProvider({
body: {
domain: TEST_DOMAIN,
issuer: "https://idp.example.test",
organizationId: targetOrganization.id,
providerId: "target-provider",
samlConfig: {
audience: "target-provider",
callbackUrl: "http://localhost:3000/api/auth/sso/callback",
cert: "test-certificate",
entryPoint: "https://idp.example.test/sso",
idpMetadata: { entityID: "https://idp.example.test" },
spMetadata: {},
wantAssertionsSigned: true,
},
},
headers,
});
if (options.domainVerified) {
await instance.db.update({
model: "ssoProvider",
where: [{ field: "providerId", value: "target-provider" }],
update: { domainVerified: true },
});
}
return targetOrganization;
}
/** Drives a full Google sign-in and returns the resulting user row. */
async function signInWithGoogle(instance: Instance) {
const headers = new Headers();
const signInResponse = await instance.client.signIn.social({
callbackURL: "/signed-in",
fetchOptions: { onSuccess: instance.cookieSetter(headers) },
provider: "google",
});
const authorizationURL = signInResponse.data?.url;
if (!authorizationURL) {
throw new Error("Google authorization URL was not created");
}
const state = new URL(authorizationURL).searchParams.get("state");
if (!state) {
throw new Error("OAuth state was not created");
}
let callbackStatus: number | undefined;
await instance.client.$fetch("/callback/google", {
headers,
method: "GET",
onError(context) {
callbackStatus = context.response.status;
},
query: { code: "test-authorization-code", state },
});
expect(callbackStatus).toBe(302);
const user = await instance.db.findOne<User>({
model: "user",
where: [{ field: "email", value: TEST_EMAIL }],
});
if (!user) {
throw new Error(`Google sign-in did not produce a user for ${TEST_EMAIL}`);
}
return user;
}
/** Signs up a verified email-password user and links Google to it. */
async function linkGoogleToExistingUser(instance: Instance) {
const sessionHeaders = new Headers();
const signUpResponse = await instance.client.signUp.email(
{ email: TEST_EMAIL, name: "Existing User", password: "test-password" },
{ onSuccess: instance.cookieSetter(sessionHeaders) },
);
const userId = signUpResponse.data?.user.id;
if (!userId) {
throw new Error("Existing user was not created");
}
await instance.db.update({
model: "user",
where: [{ field: "id", value: userId }],
update: { emailVerified: true },
});
const linkResponse = await instance.client.linkSocial(
{ callbackURL: "/settings", provider: "google" },
{
headers: sessionHeaders,
onSuccess: instance.cookieSetter(sessionHeaders),
},
);
const authorizationURL = linkResponse.data?.url;
if (!authorizationURL) {
throw new Error("Google account-link URL was not created");
}
const state = new URL(authorizationURL).searchParams.get("state");
if (!state) {
throw new Error("OAuth state was not created");
}
await instance.client.$fetch("/callback/google", {
headers: sessionHeaders,
method: "GET",
query: { code: "test-link-code", state },
});
return { sessionHeaders, userId };
}
function findMembership(
instance: Instance,
organizationId: string,
userId: string,
) {
return instance.db.findOne({
model: "member",
where: [
{ field: "organizationId", value: organizationId },
{ field: "userId", value: userId },
],
});
}
/**
* @see https://github.com/better-auth/better-auth/security/advisories/GHSA-phx7-w8x2-3xgf
*/
describe("SSO verified-domain organization assignment", () => {
it("does not auto-join a social-login user through an unverified SSO domain claim", async () => {
mockGoogleToken(await createGoogleIdToken(true, "disabled-verification"));
const instance = await createInstance();
const target = await registerOrganizationProvider(instance, {
domainVerified: false,
});
const user = await signInWithGoogle(instance);
await expect(
findMembership(instance, target.id, user.id),
).resolves.toBeNull();
});
it("auto-joins a verified social-login user through a verified SSO domain", async () => {
mockGoogleToken(await createGoogleIdToken(true, "verified-social"));
const instance = await createInstance({ enabled: true });
const target = await registerOrganizationProvider(instance, {
domainVerified: true,
});
const user = await signInWithGoogle(instance);
await expect(
findMembership(instance, target.id, user.id),
).resolves.not.toBeNull();
});
it("does not auto-join when the social provider does not verify the email", async () => {
mockGoogleToken(await createGoogleIdToken(false, "unverified-social"));
const instance = await createInstance({ enabled: true });
const target = await registerOrganizationProvider(instance, {
domainVerified: true,
});
const user = await signInWithGoogle(instance);
expect(user.emailVerified).toBe(false);
await expect(
findMembership(instance, target.id, user.id),
).resolves.toBeNull();
});
it("does not auto-join while a social account is linked to an existing user", async () => {
mockGoogleToken(await createGoogleIdToken(true, "linked-social"));
const instance = await createInstance({ enabled: true });
const target = await registerOrganizationProvider(instance, {
domainVerified: true,
});
const { userId } = await linkGoogleToExistingUser(instance);
await expect(
instance.db.findOne({
model: "account",
where: [
{ field: "providerId", value: "google" },
{ field: "userId", value: userId },
],
}),
).resolves.not.toBeNull();
await expect(
findMembership(instance, target.id, userId),
).resolves.toBeNull();
});
it("auto-joins the linked user on their next social sign-in", async () => {
mockGoogleToken(await createGoogleIdToken(true, "linked-social"));
const instance = await createInstance({ enabled: true });
const target = await registerOrganizationProvider(instance, {
domainVerified: true,
});
const { sessionHeaders, userId } = await linkGoogleToExistingUser(instance);
await instance.client.signOut({
fetchOptions: { headers: sessionHeaders },
});
await signInWithGoogle(instance);
await expect(
findMembership(instance, target.id, userId),
).resolves.not.toBeNull();
});
});
+353 -102
View File
@@ -2,107 +2,123 @@ import type { GenericEndpointContext, User } from "better-auth";
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { organization } from "better-auth/plugins";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { sso } from "..";
import { assignOrganizationByDomain } from "./org-assignment";
import {
assignOrganizationByDomain,
assignOrganizationFromProvider,
} from "./org-assignment";
describe("assignOrganizationByDomain", () => {
const createTestContext = () => {
const data = {
user: [] as User[],
session: [] as { id: string }[],
account: [] as { id: string }[],
ssoProvider: [] as {
id: string;
providerId: string;
issuer: string;
domain: string;
domainVerified: boolean;
organizationId: string | null;
userId: string;
}[],
member: [] as {
id: string;
organizationId: string;
userId: string;
role: string;
createdAt: Date;
}[],
organization: [] as {
id: string;
name: string;
slug: string;
createdAt: Date;
}[],
};
const memory = memoryAdapter(data);
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [
sso({
domainVerification: {
enabled: true,
},
}),
organization(),
],
});
const createContext = async () => {
const context = await auth.$context;
return { context } as unknown as Partial<GenericEndpointContext>;
};
return { auth, data, createContext };
};
const createUser = (overrides: Partial<User> = {}): User => ({
id: "user-1",
email: "alice@example.com",
name: "Alice",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});
const createOrg = (
overrides: Partial<{ id: string; name: string; slug: string }> = {},
) => ({
id: "org-1",
name: "Test Org",
slug: "test-org",
createdAt: new Date(),
...overrides,
});
const createProvider = (
overrides: Partial<{
const createTestContext = (domainVerificationEnabled = true) => {
const data = {
user: [] as User[],
session: [] as { id: string }[],
account: [] as { id: string }[],
ssoProvider: [] as {
id: string;
providerId: string;
issuer: string;
domain: string;
domainVerified: boolean;
domainVerified?: boolean;
organizationId: string | null;
userId: string;
}> = {},
) => ({
id: "provider-1",
providerId: "test-provider",
issuer: "https://idp.example.com",
domain: "example.com",
domainVerified: false,
organizationId: "org-1" as string | null,
userId: "user-1",
...overrides,
}[],
member: [] as {
id: string;
organizationId: string;
userId: string;
role: string;
createdAt: Date;
}[],
invitation: [] as {
id: string;
email: string;
organizationId: string;
inviterId: string;
role: string;
status: "pending" | "accepted" | "canceled" | "rejected";
expiresAt: Date;
}[],
organization: [] as {
id: string;
name: string;
slug: string;
createdAt: Date;
}[],
};
const memory = memoryAdapter(data);
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [
sso(
domainVerificationEnabled
? {
domainVerification: {
enabled: true,
},
}
: {},
),
organization(),
],
});
const createContext = async () => {
const context = await auth.$context;
return { context } as unknown as Partial<GenericEndpointContext>;
};
return { auth, data, createContext };
};
const createUser = (overrides: Partial<User> = {}): User => ({
id: "user-1",
email: "alice@example.com",
name: "Alice",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
});
const createOrg = (
overrides: Partial<{ id: string; name: string; slug: string }> = {},
) => ({
id: "org-1",
name: "Test Org",
slug: "test-org",
createdAt: new Date(),
...overrides,
});
const createProvider = (
overrides: Partial<{
id: string;
providerId: string;
issuer: string;
domain: string;
domainVerified: boolean;
organizationId: string | null;
userId: string;
}> = {},
) => ({
id: "provider-1",
providerId: "test-provider",
issuer: "https://idp.example.com",
domain: "example.com",
domainVerified: false,
organizationId: "org-1" as string | null,
userId: "user-1",
...overrides,
});
describe("assignOrganizationByDomain", () => {
it("should NOT assign user to org when provider domain is unverified", async () => {
const { data, createContext } = createTestContext();
@@ -274,8 +290,8 @@ describe("assignOrganizationByDomain", () => {
expect(members).toHaveLength(0);
});
it("should assign user when verification is disabled (no domainVerified check)", async () => {
const { data, createContext } = createTestContext();
it("should NOT assign user when domain verification is disabled", async () => {
const { data, createContext } = createTestContext(false);
const org = createOrg();
data.organization.push(org);
@@ -293,8 +309,7 @@ describe("assignOrganizationByDomain", () => {
});
const members = data.member.filter((m) => m.userId === user.id);
expect(members).toHaveLength(1);
expect(members[0]?.organizationId).toBe(org.id);
expect(members).toHaveLength(0);
});
it("should NOT assign user when already a member of the org", async () => {
@@ -328,7 +343,12 @@ describe("assignOrganizationByDomain", () => {
expect(members[0]?.role).toBe("admin");
});
it("should only find verified provider when multiple providers claim same domain", async () => {
it.for([
{ insertionOrder: ["unverified", "verified"] },
{ insertionOrder: ["verified", "unverified"] },
])("should only find the verified provider when multiple providers claim the same domain ($insertionOrder)", async ({
insertionOrder,
}) => {
const { data, createContext } = createTestContext();
const legitOrg = createOrg({
@@ -343,23 +363,23 @@ describe("assignOrganizationByDomain", () => {
});
data.organization.push(legitOrg, attackerOrg);
data.ssoProvider.push(
createProvider({
const providers = {
unverified: createProvider({
id: "attacker-provider",
providerId: "attacker-provider",
issuer: "https://attacker.com",
domainVerified: false,
organizationId: attackerOrg.id,
}),
);
data.ssoProvider.push(
createProvider({
verified: createProvider({
id: "legit-provider",
providerId: "legit-provider",
domainVerified: true,
organizationId: legitOrg.id,
}),
};
data.ssoProvider.push(
...insertionOrder.map((key) => providers[key as keyof typeof providers]),
);
const user = createUser();
@@ -375,4 +395,235 @@ describe("assignOrganizationByDomain", () => {
expect(members).toHaveLength(1);
expect(members[0]?.organizationId).toBe(legitOrg.id);
});
it.for([
{ insertionOrder: ["first", "second"] },
{ insertionOrder: ["second", "first"] },
])("should NOT assign user when verified providers map one domain to different organizations ($insertionOrder)", async ({
insertionOrder,
}) => {
const { data, createContext } = createTestContext();
const firstOrg = createOrg({
id: "first-org",
name: "First Org",
slug: "first-org",
});
const secondOrg = createOrg({
id: "second-org",
name: "Second Org",
slug: "second-org",
});
data.organization.push(firstOrg, secondOrg);
const firstProvider = createProvider({
id: "first-provider",
providerId: "first-provider",
domainVerified: true,
organizationId: firstOrg.id,
});
const secondProvider = createProvider({
id: "second-provider",
providerId: "second-provider",
domainVerified: true,
organizationId: secondOrg.id,
});
const providers = { first: firstProvider, second: secondProvider };
data.ssoProvider.push(
...insertionOrder.map((key) => providers[key as keyof typeof providers]),
);
const user = createUser();
data.user.push(user);
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
});
expect(
data.member.filter((member) => member.userId === user.id),
).toHaveLength(0);
});
it("should assign once when verified providers map one domain to the same organization", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
data.organization.push(org);
data.ssoProvider.push(
createProvider({
id: "second-provider",
providerId: "second-provider",
domainVerified: true,
organizationId: org.id,
}),
createProvider({
id: "first-provider",
providerId: "first-provider",
domainVerified: true,
organizationId: org.id,
}),
);
const user = createUser();
data.user.push(user);
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
});
expect(data.member).toHaveLength(1);
expect(data.member[0]?.organizationId).toBe(org.id);
});
it("should require the canonical stored user email to be verified", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
data.organization.push(org);
data.ssoProvider.push(
createProvider({
domainVerified: true,
organizationId: org.id,
}),
);
const callbackUser = createUser({ emailVerified: true });
data.user.push(createUser({ emailVerified: false }));
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user: callbackUser,
domainVerification: { enabled: true },
});
expect(data.member).toHaveLength(0);
});
it("should use a freshly verified canonical user instead of stale callback state", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
data.organization.push(org);
data.ssoProvider.push(
createProvider({
domainVerified: true,
organizationId: org.id,
}),
);
const callbackUser = createUser({ emailVerified: false });
data.user.push(createUser({ emailVerified: true }));
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user: callbackUser,
domainVerification: { enabled: true },
});
const members = data.member.filter(
(member) => member.userId === callbackUser.id,
);
expect(members).toHaveLength(1);
expect(members[0]?.organizationId).toBe(org.id);
});
it("should skip automatic membership while an invitation is pending", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
const user = createUser();
const invitation = {
id: "invitation-1",
email: user.email,
organizationId: org.id,
inviterId: "inviter-1",
role: "admin",
status: "pending" as const,
expiresAt: new Date(Date.now() + 60_000),
};
data.organization.push(org);
data.user.push(user);
data.ssoProvider.push(
createProvider({
domainVerified: true,
organizationId: org.id,
}),
);
data.invitation.push(invitation);
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
});
expect(data.member).toHaveLength(0);
expect(data.invitation).toEqual([invitation]);
});
it("should surface adapter failures instead of treating them as ineligibility", async () => {
const { data, createContext } = createTestContext();
const user = createUser();
data.user.push(user);
const ctx = (await createContext()) as GenericEndpointContext;
vi.spyOn(ctx.context.adapter, "findMany").mockRejectedValueOnce(
new Error("provider lookup failed"),
);
await expect(
assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
}),
).rejects.toThrow("provider lookup failed");
});
});
describe("assignOrganizationFromProvider", () => {
it.for([
"oidc",
"saml",
] as const)("should preserve direct %s organization provisioning without domain verification", async (providerType) => {
const { data, createContext } = createTestContext(false);
const user = createUser();
const organizationRecord = createOrg({
name: "Enterprise",
slug: "enterprise",
});
const provider = {
...createProvider({
providerId: "enterprise-provider",
userId: "owner-1",
}),
organizationId: organizationRecord.id,
};
data.user.push(user);
data.organization.push(organizationRecord);
data.ssoProvider.push(provider);
const organizationId = organizationRecord.id;
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationFromProvider(ctx, {
user,
provider,
profile: {
providerType,
providerId: provider.providerId,
accountId: "enterprise-account",
email: user.email,
emailVerified: true,
},
});
expect(data.member).toHaveLength(1);
expect(data.member[0]).toMatchObject({
organizationId,
role: "member",
userId: user.id,
});
});
});
+180 -118
View File
@@ -1,6 +1,6 @@
import type { GenericEndpointContext, OAuth2Tokens, User } from "better-auth";
import type { SSOOptions, SSOProvider } from "../types";
import { domainMatches } from "../utils";
import { domainMatches, parseProviderDomains } from "../utils";
import type { NormalizedSSOProfile } from "./types";
export interface OrganizationProvisioningOptions {
@@ -22,60 +22,6 @@ export interface AssignOrganizationFromProviderOptions {
provisioningOptions?: OrganizationProvisioningOptions;
}
/**
* Assigns a user to an organization based on the SSO provider's organizationId.
* Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
*/
export async function assignOrganizationFromProvider(
ctx: GenericEndpointContext,
options: AssignOrganizationFromProviderOptions,
): Promise<void> {
const { user, profile, provider, token, provisioningOptions } = options;
if (!provider.organizationId) {
return;
}
if (provisioningOptions?.disabled) {
return;
}
if (!ctx.context.hasPlugin("organization")) {
return;
}
const isAlreadyMember = await ctx.context.adapter.findOne({
model: "member",
where: [
{ field: "organizationId", value: provider.organizationId },
{ field: "userId", value: user.id },
],
});
if (isAlreadyMember) {
return;
}
const role = provisioningOptions?.getRole
? await provisioningOptions.getRole({
user,
userInfo: profile.rawAttributes || {},
token,
provider,
})
: provisioningOptions?.defaultRole || "member";
await ctx.context.adapter.create({
model: "member",
data: {
organizationId: provider.organizationId,
userId: user.id,
role,
createdAt: new Date(),
},
});
}
export interface AssignOrganizationByDomainOptions {
user: User;
provisioningOptions?: OrganizationProvisioningOptions;
@@ -84,93 +30,209 @@ export interface AssignOrganizationByDomainOptions {
};
}
/**
* Assigns a user to an organization based on their email domain.
* Looks up SSO providers that match the user's email domain and assigns
* the user to the associated organization.
*
* This enables domain-based org assignment for non-SSO sign-in methods
* (e.g., Google OAuth with @acme.com email gets added to Acme's org).
*/
export async function assignOrganizationByDomain(
type OrganizationAssignmentOptions =
| ({
reason: "provider-bound";
} & AssignOrganizationFromProviderOptions)
| ({
reason: "verified-domain";
} & AssignOrganizationByDomainOptions);
type OrganizationAssignmentResult =
| "assigned"
| "already-member"
| "ambiguous-target"
| "ineligible-source"
| "invitation-pending"
| "no-target"
| "provisioning-disabled"
| "unverified-identity";
function getEmailDomain(email: string): string | null {
const normalizedEmail = email.trim().toLowerCase();
const parts = normalizedEmail.split("@");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null;
}
const domain = parts[1];
if (domain.includes("/") || domain.includes("\\") || domain.includes(":")) {
return null;
}
const normalizedDomains = parseProviderDomains(domain);
if (!normalizedDomains || normalizedDomains.length !== 1) {
return null;
}
return normalizedDomains[0] ?? null;
}
async function findVerifiedDomainProviders(
ctx: GenericEndpointContext,
options: AssignOrganizationByDomainOptions,
): Promise<void> {
const { user, provisioningOptions, domainVerification } = options;
if (provisioningOptions?.disabled) {
return;
}
if (!ctx.context.hasPlugin("organization")) {
return;
}
const domain = user.email.split("@")[1];
if (!domain) {
return;
}
// Support comma-separated domains for multi-domain SSO
// First try exact match (fast path)
const whereClause: { field: string; value: string | boolean }[] = [
{ field: "domain", value: domain },
];
if (domainVerification?.enabled) {
whereClause.push({ field: "domainVerified", value: true });
}
let ssoProvider = await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
domain: string,
): Promise<SSOProvider<SSOOptions>[]> {
const allVerifiedProviders = await ctx.context.adapter.findMany<
SSOProvider<SSOOptions>
>({
model: "ssoProvider",
where: whereClause,
where: [{ field: "domainVerified", value: true }],
});
return allVerifiedProviders.filter((provider) =>
domainMatches(domain, provider.domain),
);
}
// If not found, search all providers for comma-separated domain match
if (!ssoProvider) {
const allProviders = await ctx.context.adapter.findMany<
SSOProvider<SSOOptions>
>({
model: "ssoProvider",
where: domainVerification?.enabled
? [{ field: "domainVerified", value: true }]
: [],
});
ssoProvider =
allProviders.find((p) => domainMatches(domain, p.domain)) ?? null;
async function assignOrganization(
ctx: GenericEndpointContext,
options: OrganizationAssignmentOptions,
): Promise<OrganizationAssignmentResult> {
if (options.provisioningOptions?.disabled) {
return "provisioning-disabled";
}
if (!ctx.context.hasPlugin("organization")) {
return "ineligible-source";
}
if (!ssoProvider || !ssoProvider.organizationId) {
return;
let user: User;
let provider: SSOProvider<SSOOptions>;
let organizationId: string;
let userInfo: Record<string, unknown>;
let token: OAuth2Tokens | undefined;
if (options.reason === "provider-bound") {
if (!options.provider.organizationId) {
return "no-target";
}
user = options.user;
provider = options.provider;
organizationId = options.provider.organizationId;
userInfo = options.profile.rawAttributes || {};
token = options.token;
} else {
if (!options.domainVerification?.enabled) {
return "provisioning-disabled";
}
const canonicalUser = await ctx.context.internalAdapter.findUserById(
options.user.id,
);
if (!canonicalUser) {
ctx.context.logger.error(
"Unable to assign SSO organization membership because the canonical user was not found",
{ userId: options.user.id },
);
return "ineligible-source";
}
if (!canonicalUser.emailVerified) {
return "unverified-identity";
}
const domain = getEmailDomain(canonicalUser.email);
if (!domain) {
return "unverified-identity";
}
const matchingProviders = (
await findVerifiedDomainProviders(ctx, domain)
).filter(
(
matchingProvider,
): matchingProvider is SSOProvider<SSOOptions> & {
organizationId: string;
} => Boolean(matchingProvider.organizationId),
);
const organizationIds = new Set(
matchingProviders.map(
(matchingProvider) => matchingProvider.organizationId,
),
);
if (organizationIds.size > 1) {
ctx.context.logger.warn(
"Skipped SSO organization provisioning because a verified domain maps to multiple organizations",
{ domain, userId: canonicalUser.id },
);
return "ambiguous-target";
}
if (matchingProviders.length === 0) {
return "no-target";
}
matchingProviders.sort((left, right) =>
left.providerId.localeCompare(right.providerId),
);
const selectedProvider = matchingProviders[0];
if (!selectedProvider) {
return "no-target";
}
user = canonicalUser;
provider = selectedProvider;
organizationId = selectedProvider.organizationId;
userInfo = {};
token = undefined;
}
const isAlreadyMember = await ctx.context.adapter.findOne({
model: "member",
where: [
{ field: "organizationId", value: ssoProvider.organizationId },
{ field: "organizationId", value: organizationId },
{ field: "userId", value: user.id },
],
});
if (isAlreadyMember) {
return;
return "already-member";
}
const role = provisioningOptions?.getRole
? await provisioningOptions.getRole({
user,
userInfo: {},
provider: ssoProvider,
})
: provisioningOptions?.defaultRole || "member";
const pendingInvitation = await ctx.context.adapter.findOne({
model: "invitation",
where: [
{ field: "organizationId", value: organizationId },
{ field: "email", value: user.email.toLowerCase() },
{ field: "status", value: "pending" },
],
});
if (pendingInvitation) {
return "invitation-pending";
}
const role = options.provisioningOptions?.getRole
? await options.provisioningOptions.getRole({
user,
userInfo,
token,
provider,
})
: options.provisioningOptions?.defaultRole || "member";
// FIXME(sso-membership-policy): route automatic SSO membership through the
// organization plugin's limits, hooks, additional fields, and atomic guard.
await ctx.context.adapter.create({
model: "member",
data: {
organizationId: ssoProvider.organizationId,
organizationId,
userId: user.id,
role,
createdAt: new Date(),
},
});
return "assigned";
}
/**
* Assigns a user to the organization explicitly bound to an SSO provider.
*/
export async function assignOrganizationFromProvider(
ctx: GenericEndpointContext,
options: AssignOrganizationFromProviderOptions,
): Promise<void> {
await assignOrganization(ctx, {
reason: "provider-bound",
...options,
});
}
/**
* Assigns a verified user to the organization for their verified email domain.
*/
export async function assignOrganizationByDomain(
ctx: GenericEndpointContext,
options: AssignOrganizationByDomainOptions,
): Promise<void> {
await assignOrganization(ctx, {
reason: "verified-domain",
...options,
});
}
+34 -8
View File
@@ -1,3 +1,4 @@
import type { AuthContext } from "better-auth";
import {
APIError,
createAuthEndpoint,
@@ -16,6 +17,37 @@ const domainVerificationBodySchema = z.object({
providerId: z.string(),
});
async function completeDomainVerification(
adapter: AuthContext["adapter"],
provider: {
id: string;
domain: string;
},
) {
// Guarding on `id` and `domain` is what binds the proof to what DNS answered
// for. `domainVerified` stays out of the guard: it is nullable for providers
// that predate the option, and an equality guard would never match those.
const verifiedProvider = await adapter.incrementOne<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [
{ field: "id", value: provider.id },
{ field: "domain", value: provider.domain },
],
increment: {},
set: {
domainVerified: true,
},
});
if (!verifiedProvider) {
throw new APIError("CONFLICT", {
code: "SSO_PROVIDER_CHANGED",
message:
"SSO provider changed while domain verification was in progress. Reload the provider and try again",
});
}
}
export function getVerificationIdentifier(
options: SSOOptions,
providerId: string,
@@ -109,7 +141,7 @@ export const verifyDomain = (options: SSOOptions) => {
},
"409": {
description:
"Domain has already been verified or no pending verification exists",
"Domain has already been verified, or the provider changed while verification was in progress",
},
"502": {
description:
@@ -214,13 +246,7 @@ export const verifyDomain = (options: SSOOptions) => {
// FIXME(next): this remains a provider-level proof bit. When the next
// schema can change, store verification per normalized domain or force
// previously verified multi-domain providers through re-verification.
await ctx.context.adapter.update<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [{ field: "providerId", value: provider.providerId }],
update: {
domainVerified: true,
},
});
await completeDomainVerification(ctx.context.adapter, provider);
ctx.setStatus(204);
return;
+8
View File
@@ -66,6 +66,14 @@
## 1.7.0-beta.3
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+7
View File
@@ -49,6 +49,13 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b)]:
- @better-auth/core@1.6.27
## 1.6.26
### Patch Changes
+8
View File
@@ -54,6 +54,14 @@
## 1.7.0-beta.4
## 1.6.27
### Patch Changes
- Updated dependencies [[`2ae491e`](https://github.com/better-auth/better-auth/commit/2ae491eac3ece50839a0eb2d4f868c4deedac67b), [`90b5093`](https://github.com/better-auth/better-auth/commit/90b509344794b8064700371cbc04b985d0519839)]:
- @better-auth/core@1.6.27
- better-auth@1.6.27
## 1.6.26
### Patch Changes
+61 -49
View File
@@ -13,8 +13,8 @@ catalogs:
specifier: 1.3.1
version: 1.3.1
better-call:
specifier: 1.3.7
version: 1.3.7
specifier: 1.4.0
version: 1.4.0
nanostores:
specifier: ^1.3.0
version: 1.3.0
@@ -352,8 +352,8 @@ importers:
specifier: ^1.21.0
version: 1.21.0(react@19.2.7)
mermaid:
specifier: ^11.15.0
version: 11.15.0
specifier: ^11.16.1
version: 11.16.1
next:
specifier: 16.2.11
version: 16.2.11(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(@types/node@25.9.4)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -935,7 +935,7 @@ importers:
version: 0.4.2
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
zod:
specifier: 4.3.6
version: 4.3.6
@@ -990,7 +990,7 @@ importers:
version: 7.4.1(prisma@7.4.1(@types/react@19.2.17)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(typescript@6.0.3)
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
better-sqlite3:
specifier: ^12.0.0
version: 12.6.2
@@ -1108,7 +1108,7 @@ importers:
dependencies:
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
devDependencies:
'@better-auth/core':
specifier: workspace:*
@@ -1264,7 +1264,7 @@ importers:
version: 2.10.0(@opentelemetry/api@1.9.1)
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
jose:
specifier: ^6.2.3
version: 6.2.3
@@ -1306,7 +1306,7 @@ importers:
version: 1.3.1
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
conf:
specifier: ^15.0.2
version: 15.1.0
@@ -1340,7 +1340,7 @@ importers:
version: 1.3.1
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
zod:
specifier: 4.3.6
version: 4.3.6
@@ -1429,7 +1429,7 @@ importers:
version: link:../better-auth
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
tsdown:
specifier: 'catalog:'
version: 0.22.7(@arethetypeswrong/core@0.18.3)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.21.0)(typescript@6.0.3)
@@ -1477,7 +1477,7 @@ importers:
version: 1.3.1
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
jose:
specifier: ^6.2.3
version: 6.2.3
@@ -1517,7 +1517,7 @@ importers:
version: 13.3.1
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
zod:
specifier: 4.3.6
version: 4.3.6
@@ -1591,7 +1591,7 @@ importers:
version: link:../better-auth
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
zod:
specifier: 4.3.6
version: 4.3.6
@@ -1662,7 +1662,7 @@ importers:
version: link:../better-auth
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
body-parser:
specifier: ^2.3.0
version: 2.3.0
@@ -1696,7 +1696,7 @@ importers:
version: link:../better-auth
better-call:
specifier: 'catalog:'
version: 1.3.7(zod@4.3.6)
version: 1.4.0(zod@4.3.6)
stripe:
specifier: ^22.0.1
version: 22.0.1(@types/node@25.9.4)
@@ -2399,6 +2399,9 @@ packages:
'@better-auth/utils@0.4.2':
resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==}
'@better-auth/utils@0.5.0':
resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==}
'@better-fetch/fetch@1.3.1':
resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==}
@@ -3738,8 +3741,8 @@ packages:
'@types/react': '>=16'
react: '>=16'
'@mermaid-js/parser@1.1.1':
resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==}
'@mermaid-js/parser@1.2.0':
resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==}
'@modelcontextprotocol/client@2.0.0':
resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==}
@@ -7785,8 +7788,8 @@ packages:
peerDependencies:
better-auth: ^1.0.3
better-call@1.3.7:
resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==}
better-call@1.4.0:
resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==}
peerDependencies:
zod: 4.3.6
peerDependenciesMeta:
@@ -8422,8 +8425,8 @@ packages:
peerDependencies:
cytoscape: ^3.2.0
cytoscape@3.33.1:
resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==}
cytoscape@3.34.0:
resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==}
engines: {node: '>=0.10'}
d3-array@2.12.1:
@@ -10510,8 +10513,8 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
katex@0.16.33:
resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==}
katex@0.16.47:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
khroma@2.1.0:
@@ -11073,8 +11076,8 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mermaid@11.15.0:
resolution: {integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==}
mermaid@11.16.1:
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
metro-babel-transformer@0.83.3:
resolution: {integrity: sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==}
@@ -12812,12 +12815,12 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
rou3@0.8.1:
resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==}
rou3@0.9.1:
resolution: {integrity: sha512-z/sSmzvtwMDDnxsPVhfWMuG6F6mbmhFDXoVqLmMfbpDD9qfV3GDmSQpf0+W296/ZDIpW2wcMmBfpVFzcnOi/nA==}
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
@@ -12943,6 +12946,9 @@ packages:
set-cookie-parser@3.1.0:
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -15483,6 +15489,10 @@ snapshots:
dependencies:
'@noble/hashes': 2.0.1
'@better-auth/utils@0.5.0':
dependencies:
'@noble/hashes': 2.2.0
'@better-fetch/fetch@1.3.1': {}
'@biomejs/biome@2.5.0':
@@ -16547,7 +16557,7 @@ snapshots:
dependencies:
'@antfu/install-pkg': 1.1.0
'@iconify/types': 2.0.0
mlly: 1.8.0
mlly: 1.8.2
'@img/colour@1.1.0': {}
@@ -16971,7 +16981,7 @@ snapshots:
'@types/react': 19.2.17
react: 19.2.7
'@mermaid-js/parser@1.1.1':
'@mermaid-js/parser@1.2.0':
dependencies:
'@chevrotain/types': 11.1.2
@@ -21284,12 +21294,12 @@ snapshots:
mailchecker: 6.0.19
validator: 13.15.26
better-call@1.3.7(zod@4.3.6):
better-call@1.4.0(zod@4.3.6):
dependencies:
'@better-auth/utils': 0.4.2
'@better-auth/utils': 0.5.0
'@better-fetch/fetch': 1.3.1
rou3: 0.7.12
set-cookie-parser: 3.1.0
rou3: 0.9.1
set-cookie-parser: 3.1.2
optionalDependencies:
zod: 4.3.6
@@ -22008,17 +22018,17 @@ snapshots:
csstype@3.2.3: {}
cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1):
cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0):
dependencies:
cose-base: 1.0.3
cytoscape: 3.33.1
cytoscape: 3.34.0
cytoscape-fcose@2.2.0(cytoscape@3.33.1):
cytoscape-fcose@2.2.0(cytoscape@3.34.0):
dependencies:
cose-base: 2.2.0
cytoscape: 3.33.1
cytoscape: 3.34.0
cytoscape@3.33.1: {}
cytoscape@3.34.0: {}
d3-array@2.12.1:
dependencies:
@@ -24020,7 +24030,7 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
katex@0.16.33:
katex@0.16.47:
dependencies:
commander: 8.3.0
@@ -24670,23 +24680,23 @@ snapshots:
merge2@1.4.1: {}
mermaid@11.15.0:
mermaid@11.16.1:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.0
'@mermaid-js/parser': 1.1.1
'@mermaid-js/parser': 1.2.0
'@types/d3': 7.4.3
'@upsetjs/venn.js': 2.0.0
cytoscape: 3.33.1
cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1)
cytoscape-fcose: 2.2.0(cytoscape@3.33.1)
cytoscape: 3.34.0
cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0)
cytoscape-fcose: 2.2.0(cytoscape@3.34.0)
d3: 7.9.0
d3-sankey: 0.12.3
dagre-d3-es: 7.0.14
dayjs: 1.11.20
dompurify: 3.4.13
es-toolkit: 1.47.0
katex: 0.16.33
katex: 0.16.47
khroma: 2.1.0
marked: 16.4.2
roughjs: 4.6.6
@@ -27387,10 +27397,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.4
fsevents: 2.3.3
rou3@0.7.12: {}
rou3@0.8.1: {}
rou3@0.9.1: {}
roughjs@4.6.6:
dependencies:
hachure-fill: 0.5.2
@@ -27549,6 +27559,8 @@ snapshots:
set-cookie-parser@3.1.0: {}
set-cookie-parser@3.1.2: {}
setprototypeof@1.2.0: {}
sharp@0.35.3(@types/node@25.9.4):
+1 -1
View File
@@ -63,7 +63,7 @@ overrides:
catalog:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
better-call: 1.3.7
better-call: 1.4.0
kysely: ^0.28.17 || ^0.29.0
nanostores: ^1.3.0
tsdown: 0.22.7