diff --git a/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx b/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx
new file mode 100644
index 0000000000..722765f967
--- /dev/null
+++ b/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { Loader2, LogInIcon } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import { useCallback, useEffect, useState, useTransition } from "react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { buttonVariants } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { authClient } from "@/lib/auth-client";
+
+export function ElectronTransferUser({
+ session: activeSession,
+}: {
+ session: typeof authClient.$Infer.Session;
+}) {
+ const params = Object.fromEntries(useSearchParams().entries());
+ const [isLoading, startTransition] = useTransition();
+ const [users, setUsers] = useState<(typeof authClient.$Infer.Session)[]>([
+ activeSession,
+ ]);
+
+ useEffect(() => {
+ startTransition(async () => {
+ const { data } = await authClient.multiSession.listDeviceSessions();
+
+ if (data?.length) {
+ setUsers((prev) => {
+ // Filter out unique users from device session
+ const seenUserIds = new Set(prev.map((s) => s.user.id));
+ const users = data.filter((session) => {
+ if (
+ session.user.id === activeSession.user.id || // Skip active user
+ seenUserIds.has(session.user.id)
+ )
+ return false;
+ seenUserIds.add(session.user.id);
+ return true;
+ }) as (typeof authClient.$Infer.Session)[];
+ return [...prev, ...users];
+ });
+ }
+ });
+ }, []);
+
+ const handleContinueAsUser = useCallback(
+ (session: typeof authClient.$Infer.Session) => () =>
+ startTransition(async () => {
+ if (session.user.id === activeSession.user.id) {
+ // Continue as current user
+ await authClient.electron.transferUser({
+ fetchOptions: {
+ query: params,
+ },
+ });
+ return;
+ }
+
+ // Switch to the selected user
+ const originalSessionToken = activeSession.session.token;
+ await authClient.multiSession.setActive({
+ sessionToken: session.session.token,
+ });
+
+ const promise = authClient.electron.transferUser({
+ fetchOptions: {
+ query: params,
+ },
+ });
+
+ // Switch back to the original session
+ await authClient.multiSession.setActive({
+ sessionToken: originalSessionToken,
+ });
+
+ // Transfer user
+ await promise;
+ }),
+ [params, activeSession],
+ );
+
+ return (
+
+
Continue as:
+
+ {users.map((session) => (
+
+ ))}
+
+ );
+}
+
+function ContinueAsUser({
+ session,
+ isLoading,
+ onContinue,
+}: {
+ session: typeof authClient.$Infer.Session;
+ isLoading: boolean;
+ onContinue: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/demo/nextjs/app/(auth)/sign-in/page.tsx b/demo/nextjs/app/(auth)/sign-in/page.tsx
index 6e710eecfd..032f2f7005 100644
--- a/demo/nextjs/app/(auth)/sign-in/page.tsx
+++ b/demo/nextjs/app/(auth)/sign-in/page.tsx
@@ -1,15 +1,21 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
-import { useEffect } from "react";
+import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import SignIn from "@/app/(auth)/sign-in/_components/sign-in";
import { SignUp } from "@/app/(auth)/sign-in/_components/sign-up";
import { Tabs } from "@/components/ui/tabs2";
import { authClient } from "@/lib/auth-client";
import { getCallbackURL } from "@/lib/shared";
+import { cn } from "@/lib/utils";
+import { ElectronTransferUser } from "./_components/electron";
export default function Page() {
+ const [isLoading, startTransition] = useTransition();
+ const [session, setSession] = useState<
+ typeof authClient.$Infer.Session | null
+ >(null);
const router = useRouter();
const params = useSearchParams();
@@ -28,25 +34,54 @@ export default function Page() {
});
}, []);
+ useEffect(() => {
+ if (params.get("client_id") === "electron") {
+ startTransition(async () => {
+ const { data: session } = await authClient.getSession();
+ if (session) {
+ setSession(session);
+ }
+ });
+ }
+ }, [params]);
+
return (
-
-
- ,
- },
- {
- title: "Sign Up",
- value: "sign-up",
- content: ,
- },
- ]}
- />
-
+
+ {!isLoading ? (
+ <>
+ {session !== null ? (
+
+ ) : (
+
+ ,
+ },
+ {
+ title: "Sign Up",
+ value: "sign-up",
+ content: ,
+ },
+ ]}
+ />
+
+ )}
+ >
+ ) : (
+
+ )}
);
diff --git a/docs/components/community-plugins-table.tsx b/docs/components/community-plugins-table.tsx
index 9fc2e5a079..0d4c07ce68 100644
--- a/docs/components/community-plugins-table.tsx
+++ b/docs/components/community-plugins-table.tsx
@@ -41,15 +41,33 @@ const columns: ColumnDef
[] = [
);
},
cell: ({ row }) => {
+ const author = row.original.author;
+
return (
-
- {row.original.name}
-
+
);
},
},
@@ -64,38 +82,6 @@ const columns: ColumnDef[] = [
);
},
},
- {
- accessorKey: "author.name",
- header: ({ column }) => {
- return (
-
- );
- },
- cell: ({ row }) => {
- const author = row.original.author;
- return (
-
-
- {author.name}
-
- );
- },
- },
];
export const communityPlugins: CommunityPlugin[] = [
diff --git a/docs/components/icons.tsx b/docs/components/icons.tsx
index e610563be1..516a75bbc2 100644
--- a/docs/components/icons.tsx
+++ b/docs/components/icons.tsx
@@ -322,131 +322,12 @@ export const Icons = {
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
- viewBox="0 0 100 100"
+ viewBox="0 0 663 660"
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
),
expo: (props?: SVGProps) => (
diff --git a/docs/content/docs/comparison.mdx b/docs/content/docs/comparison.mdx
index 661a1993e1..b8063aa621 100644
--- a/docs/content/docs/comparison.mdx
+++ b/docs/content/docs/comparison.mdx
@@ -13,21 +13,18 @@ Here are non detailed reasons why you may want to use Better Auth over other aut
- **Advanced features built-in** - 2FA, multi-tenancy, multi-session, rate limiting, and many more
- **Plugin system** - Extend functionality without forking or complex workarounds
- **Full control** - Customize auth flows exactly how you want
-
-### vs Self-Hosted Auth Servers
-
-- **No separate infrastructure** - Runs in your app, users stay in your database
-- **Zero server maintenance** - No auth servers to deploy, monitor, or update
-- **Complete feature set** - Everything you need without the operational overhead
+- **Flexible deployment** - Run alongside your app or as a standalone self-hosted auth server
### vs Managed Auth Services
- **Keep your data** - Users stay in your database, not a third-party service
- **No per-user costs** - Scale without worrying about auth billing
- **Single source of truth** - All user data in one place
+- **Self-host anywhere** - Deploy on your own infrastructure with full control
### vs Rolling Your Own
- **Security handled** - Battle-tested auth flows and security practices
- **Focus on your product** - Spend time on features that matter to your business
-- **Plugin extensibility** - Add custom features without starting from scratch
\ No newline at end of file
+- **Plugin extensibility** - Add custom features without starting from scratch
+- **Production ready** - Works embedded in your app or as a dedicated auth server
\ No newline at end of file
diff --git a/docs/content/docs/integrations/electron.mdx b/docs/content/docs/integrations/electron.mdx
index bbffd4794c..406d0643a6 100644
--- a/docs/content/docs/integrations/electron.mdx
+++ b/docs/content/docs/integrations/electron.mdx
@@ -29,6 +29,11 @@ Better Auth can be integrated into Electron apps to provide secure authenticatio
better-auth @better-auth/electron
```
+
+ We support two major versions behind the latest stable major release of Electron.
+ This keeps you on versions that receive security updates and aligns with [Electron's version support policy](https://www.electronjs.org/docs/latest/tutorial/electron-timelines#breaking-api-changes).
+
+
@@ -290,6 +295,8 @@ function SignIn({
}
```
+To handle already signed-in users, you can use the `electron.transferUser` method on the client and `electronTransferUser` on the server to transfer the user to the Electron app. [See Example](https://github.com/better-auth/better-auth/blob/main/demo/nextjs/app/(auth)/sign-in/_components/electron.tsx)
+
### Handling Authentication in Electron
In your Electron renderer process, you can use the IPC bridges exposed by the preload script.
diff --git a/docs/content/docs/plugins/admin.mdx b/docs/content/docs/plugins/admin.mdx
index d13bedabd7..76b48469b3 100644
--- a/docs/content/docs/plugins/admin.mdx
+++ b/docs/content/docs/plugins/admin.mdx
@@ -153,11 +153,11 @@ type listUsers = {
/**
* The value to filter by.
*/
- filterValue?: string | number | boolean = "hello@example.com"
+ filterValue?: string | number | boolean | string[] | number[] = "hello@example.com"
/**
- * The operator to use for the filter.
+ * The operator to use for the filter.
*/
- filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" = "eq"
+ filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" = "eq"
}
```
diff --git a/docs/content/docs/plugins/email-otp.mdx b/docs/content/docs/plugins/email-otp.mdx
index 0c89b7aea4..6ce7cfaa85 100644
--- a/docs/content/docs/plugins/email-otp.mdx
+++ b/docs/content/docs/plugins/email-otp.mdx
@@ -121,19 +121,27 @@ Once the user provides the OTP, you can sign in the user using the `signIn.email
```ts
type signInEmailOTP = {
/**
- * Email address to sign in.
+ * Email address to sign in.
*/
email: string = "user@example.com"
/**
- * OTP sent to the email.
+ * OTP sent to the email.
*/
otp: string = "123456"
+ /**
+ * User display name. Only used when the user is registering for the first time.
+ */
+ name?: string = "John Doe"
+ /**
+ * User profile image URL. Only used when the user is registering for the first time.
+ */
+ image?: string = "https://example.com/image.png"
}
```
-If the user is not registered, they'll be automatically registered. If you want to prevent this, you can pass `disableSignUp` as `true` in the [options](#options).
+If the user is not registered, they'll be automatically registered. Configured [additional fields](/docs/concepts/typescript#additional-fields) are also accepted for new users. To prevent automatic sign-up, pass `disableSignUp` as `true` in the [options](#options).
### Verify Email with OTP
diff --git a/docs/content/docs/plugins/i18n.mdx b/docs/content/docs/plugins/i18n.mdx
index d08c8ab065..712e94d642 100644
--- a/docs/content/docs/plugins/i18n.mdx
+++ b/docs/content/docs/plugins/i18n.mdx
@@ -131,16 +131,16 @@ For advanced use cases, you can provide a custom locale detection function:
i18n({
translations: { /* ... */ },
detection: ["callback", "header"],
- getLocale: (request, ctx) => {
+ getLocale: (ctx) => {
// Custom logic: use query param, custom header, etc.
- if (!request) return null;
- const url = new URL(request.url);
+ if (!ctx.request) return null;
+ const url = new URL(ctx.request.url);
return url.searchParams.get("lang");
},
})
```
-The callback receives the request object and the auth context, and should return the locale code or `null`.
+The callback receives the full endpoint context. It should return the locale code or `null`.
## Error Codes
@@ -208,12 +208,12 @@ The field name on the user object that stores their locale preference when using
### `getLocale`
-**Type:** `(request: Request | undefined, ctx: AuthContext) => string | null | Promise`
+**Type:** `(ctx: GenericEndpointContext) => string | null | Promise`
-A custom function to detect the locale when using the `"callback"` detection strategy. Receives the request and auth context, returns the locale code or `null`.
+A custom function to detect the locale when using the `"callback"` detection strategy. Receives the full endpoint context. Returns the locale code or `null`.
-`request` could be undefined for non-HTTP calls.
+`ctx.request` could be undefined for non-HTTP calls.
## Fallback Behavior
diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx
index b224360328..139852042e 100644
--- a/docs/content/docs/plugins/organization.mdx
+++ b/docs/content/docs/plugins/organization.mdx
@@ -1090,11 +1090,11 @@ type listMembers = {
/**
* The operator to filter by.
*/
- filterOperator?: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "contains" = "eq"
+ filterOperator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" = "eq"
/**
* The value to filter by.
*/
- filterValue?: string = "value"
+ filterValue?: string | number | boolean | string[] | number[] = "value"
}
```
diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts
index 8e2086ea35..8066450255 100644
--- a/packages/better-auth/src/api/routes/callback.ts
+++ b/packages/better-auth/src/api/routes/callback.ts
@@ -256,7 +256,7 @@ export const callbackOAuth = createAuthEndpoint(
...userInfo,
id: String(userInfo.id),
email: userInfo.email,
- name: userInfo.name || userInfo.email,
+ name: userInfo.name || "",
},
account: accountData,
callbackURL,
diff --git a/packages/better-auth/src/plugins/admin/routes.ts b/packages/better-auth/src/plugins/admin/routes.ts
index 0a80635b1d..54e4776da8 100644
--- a/packages/better-auth/src/plugins/admin/routes.ts
+++ b/packages/better-auth/src/plugins/admin/routes.ts
@@ -4,6 +4,7 @@ import {
} from "@better-auth/core/api";
import type { Session } from "@better-auth/core/db";
import type { Where } from "@better-auth/core/db/adapter";
+import { whereOperators } from "@better-auth/core/db/adapter";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import * as z from "zod";
import { getSessionFromCtx } from "../../api";
@@ -574,9 +575,11 @@ const listUsersQuerySchema = z.object({
})
.or(z.number())
.or(z.boolean())
+ .or(z.array(z.string()))
+ .or(z.array(z.number()))
.optional(),
filterOperator: z
- .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"])
+ .enum(whereOperators)
.meta({
description: "The operator to use for the filter",
})
diff --git a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts
index 64a3e2313a..3cd043bffb 100644
--- a/packages/better-auth/src/plugins/email-otp/email-otp.test.ts
+++ b/packages/better-auth/src/plugins/email-otp/email-otp.test.ts
@@ -97,6 +97,25 @@ describe("email-otp", async () => {
expect(newUser.data?.token).toBeDefined();
});
+ it("should sign-up with otp and set name and image", async () => {
+ const testUser3 = {
+ email: "test-email-with-name@domain.com",
+ };
+ await client.emailOtp.sendVerificationOtp({
+ email: testUser3.email,
+ type: "sign-in",
+ });
+ const newUser = await client.signIn.emailOtp({
+ email: testUser3.email,
+ otp,
+ name: "Test User",
+ image: "https://example.com/avatar.png",
+ });
+ expect(newUser.data?.token).toBeDefined();
+ expect(newUser.data?.user.name).toBe("Test User");
+ expect(newUser.data?.user.image).toBe("https://example.com/avatar.png");
+ });
+
it("should sign-up with uppercase email", async () => {
const testUser2 = {
email: "TEST-EMAIL@DOMAIN.COM",
@@ -687,23 +706,20 @@ describe("custom storeOTP", async () => {
});
it("should not be allowed to get otp if storeOTP is hashed", async () => {
- try {
- await auth.api.getVerificationOTP({
+ await expect(
+ auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
- });
- } catch (error: any) {
- expect(error.statusCode).toBe(400);
- expect(error.status).toBe("BAD_REQUEST");
- expect(error.body.code).toBe(
- "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
- );
- return;
- }
- // Should not reach here given the above should throw and thus return.
- expect(true).toBe(false);
+ }),
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ status: "BAD_REQUEST",
+ body: {
+ code: "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
+ },
+ });
});
it("should be able to sign in with normal otp", async () => {
@@ -788,22 +804,14 @@ describe("custom storeOTP", async () => {
});
it("should be allowed to get otp if storeOTP is encrypted", async () => {
- try {
- const res = await auth.api.getVerificationOTP({
- query: {
- email: userEmail1,
- type: "sign-in",
- },
- });
- if (!res.otp) {
- expect(true).toBe(false);
- return;
- }
- expect(res.otp).toEqual(validOTP);
- expect(res.otp.length).toBe(6);
- } catch (error: any) {
- expect(error).not.toBeDefined();
- }
+ const res = await auth.api.getVerificationOTP({
+ query: {
+ email: userEmail1,
+ type: "sign-in",
+ },
+ });
+ expect(res.otp).toEqual(validOTP);
+ expect(res.otp?.length).toBe(6);
});
it("should be able to sign in with encrypted otp", async () => {
@@ -893,23 +901,14 @@ describe("custom storeOTP", async () => {
});
it("should be allowed to get otp if storeOTP is custom encryptor", async () => {
- try {
- const res = await auth.api.getVerificationOTP({
- query: {
- email: userEmail1,
- type: "sign-in",
- },
- });
- if (!res.otp) {
- expect(true).toBe(false);
- return;
- }
- expect(res.otp).toEqual(validOTP);
- expect(res.otp.length).toBe(6);
- } catch (error: any) {
- console.error(error);
- expect(error).not.toBeDefined();
- }
+ const res = await auth.api.getVerificationOTP({
+ query: {
+ email: userEmail1,
+ type: "sign-in",
+ },
+ });
+ expect(res.otp).toEqual(validOTP);
+ expect(res.otp?.length).toBe(6);
});
it("should be able to sign in with custom encryptor otp", async () => {
@@ -996,23 +995,20 @@ describe("custom storeOTP", async () => {
});
it("should be allowed to get otp if storeOTP is custom hasher", async () => {
- try {
- await auth.api.getVerificationOTP({
+ await expect(
+ auth.api.getVerificationOTP({
query: {
email: userEmail1,
type: "sign-in",
},
- });
- } catch (error: any) {
- expect(error.statusCode).toBe(400);
- expect(error.status).toBe("BAD_REQUEST");
- expect(error.body.code).toBe(
- "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
- );
- return;
- }
- // Should not reach here given the above should throw and thus return.
- expect(true).toBe(false);
+ }),
+ ).rejects.toMatchObject({
+ statusCode: 400,
+ status: "BAD_REQUEST",
+ body: {
+ code: "OTP_IS_HASHED_CANNOT_RETURN_THE_PLAIN_TEXT_OTP",
+ },
+ });
});
it("should be able to sign in with custom hasher otp", async () => {
@@ -1204,3 +1200,91 @@ describe("override default email verification", async () => {
);
});
});
+
+describe("sign-up with additional fields via email-otp", async () => {
+ let otp = "";
+ const { client, auth, sessionSetter } = await getTestInstance(
+ {
+ plugins: [
+ emailOTP({
+ async sendVerificationOTP({ otp: _otp }) {
+ otp = _otp;
+ },
+ }),
+ ],
+ user: {
+ additionalFields: {
+ lang: {
+ type: "string",
+ required: false,
+ input: true,
+ },
+ isAdmin: {
+ type: "boolean",
+ defaultValue: false,
+ input: false,
+ },
+ },
+ },
+ },
+ {
+ clientOptions: {
+ plugins: [emailOTPClient()],
+ },
+ },
+ );
+
+ it("should sign-up with additional fields", async () => {
+ const email = "additional-fields@domain.com";
+ const headers = new Headers();
+ await client.emailOtp.sendVerificationOtp({
+ email,
+ type: "sign-in",
+ });
+ const res = await client.signIn.emailOtp(
+ {
+ email,
+ otp,
+ name: "AF User",
+ lang: "ko",
+ },
+ {
+ onSuccess: sessionSetter(headers),
+ },
+ );
+ expect(res.data?.token).toBeDefined();
+ expect(res.data?.user.name).toBe("AF User");
+ const session = await auth.api.getSession({ headers });
+ if (!session) {
+ throw new Error("session not found");
+ }
+ expect(session.user.name).toBe("AF User");
+ expect(session.user.lang).toBe("ko");
+ expect(session.user.isAdmin).toBe(false);
+ });
+
+ it("should ignore input: false fields and use default value", async () => {
+ const email = "ignore-input-false@domain.com";
+ const headers = new Headers();
+ await client.emailOtp.sendVerificationOtp({
+ email,
+ type: "sign-in",
+ });
+ const res = await client.signIn.emailOtp(
+ {
+ email,
+ otp,
+ isAdmin: true,
+ },
+ {
+ onSuccess: sessionSetter(headers),
+ },
+ );
+ expect(res.data?.token).toBeDefined();
+ const session = await auth.api.getSession({ headers });
+ if (!session) {
+ throw new Error("session not found");
+ }
+ expect(session.user.isAdmin).toBe(false);
+ });
+});
diff --git a/packages/better-auth/src/plugins/email-otp/routes.ts b/packages/better-auth/src/plugins/email-otp/routes.ts
index 9e1945efc1..8b695d368b 100644
--- a/packages/better-auth/src/plugins/email-otp/routes.ts
+++ b/packages/better-auth/src/plugins/email-otp/routes.ts
@@ -5,7 +5,7 @@ import * as z from "zod";
import { APIError, getSessionFromCtx } from "../../api";
import { setCookieCache, setSessionCookie } from "../../cookies";
import { generateRandomString, symmetricDecrypt } from "../../crypto";
-import { parseUserOutput } from "../../db/schema";
+import { parseUserInput, parseUserOutput } from "../../db/schema";
import { getDate } from "../../utils/date";
import { storeOTP, verifyStoredOTP } from "./otp-token";
import type { EmailOTPOptions } from "./types";
@@ -562,15 +562,31 @@ export const verifyEmailOTP = (opts: RequiredEmailOTPOptions) =>
},
);
-const signInEmailOTPBodySchema = z.object({
- email: z.string({}).meta({
- description: "Email address to sign in",
- }),
- otp: z.string().meta({
- required: true,
- description: "OTP sent to the email",
- }),
-});
+const signInEmailOTPBodySchema = z
+ .object({
+ email: z.string({}).meta({
+ description: "Email address to sign in",
+ }),
+ otp: z.string().meta({
+ required: true,
+ description: "OTP sent to the email",
+ }),
+ name: z
+ .string()
+ .meta({
+ description:
+ 'User display name. Only used if the user is registering for the first time. Eg: "my-name"',
+ })
+ .optional(),
+ image: z
+ .string()
+ .meta({
+ description:
+ "User profile image URL. Only used if the user is registering for the first time.",
+ })
+ .optional(),
+ })
+ .and(z.record(z.string(), z.any()));
/**
* ### Endpoint
@@ -624,7 +640,8 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) =>
},
},
async (ctx) => {
- const email = ctx.body.email.toLowerCase();
+ const { email: rawEmail, otp, name, image, ...rest } = ctx.body;
+ const email = rawEmail.toLowerCase();
const verificationValue =
await ctx.context.internalAdapter.findVerificationValue(
`sign-in-otp-${email}`,
@@ -643,7 +660,7 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) =>
);
throw APIError.from("FORBIDDEN", ERROR_CODES.TOO_MANY_ATTEMPTS);
}
- const verified = await verifyStoredOTP(ctx, opts, otpValue, ctx.body.otp);
+ const verified = await verifyStoredOTP(ctx, opts, otpValue, otp);
if (!verified) {
await ctx.context.internalAdapter.updateVerificationValue(
verificationValue.id,
@@ -661,10 +678,17 @@ export const signInEmailOTP = (opts: RequiredEmailOTPOptions) =>
if (opts.disableSignUp) {
throw APIError.from("BAD_REQUEST", BASE_ERROR_CODES.USER_NOT_FOUND);
}
+ const additionalFields = parseUserInput(
+ ctx.context.options,
+ rest,
+ "create",
+ );
const newUser = await ctx.context.internalAdapter.createUser({
+ ...additionalFields,
email,
emailVerified: true,
- name: "",
+ name: name || "",
+ image,
});
const session = await ctx.context.internalAdapter.createSession(
newUser.id,
diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts
index e5fc18bd7f..e1f5831f57 100644
--- a/packages/better-auth/src/plugins/oauth-proxy/index.ts
+++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts
@@ -407,7 +407,7 @@ export const oAuthProxy = (opts?: O) => {
userInfo: {
id: String(userInfo.id),
email: userInfo.email,
- name: userInfo.name || userInfo.email,
+ name: userInfo.name || "",
image: userInfo.image,
emailVerified: userInfo.emailVerified,
},
diff --git a/packages/better-auth/src/plugins/organization/adapter.ts b/packages/better-auth/src/plugins/organization/adapter.ts
index bf042d5429..bd676c5dd6 100644
--- a/packages/better-auth/src/plugins/organization/adapter.ts
+++ b/packages/better-auth/src/plugins/organization/adapter.ts
@@ -1,5 +1,6 @@
import type { AuthContext, GenericEndpointContext } from "@better-auth/core";
import { getCurrentAdapter } from "@better-auth/core/context";
+import type { WhereOperator } from "@better-auth/core/db/adapter";
import { BetterAuthError } from "@better-auth/core/error";
import { filterOutputFields } from "@better-auth/core/utils/db";
import { parseJSON } from "../../client/parser";
@@ -134,7 +135,7 @@ export const getOrgAdapter = (
filter?:
| {
field: string;
- operator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains";
+ operator?: WhereOperator;
value: any;
}
| undefined;
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts
index aca71659be..beb6d6019b 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-members.test.ts
@@ -144,6 +144,51 @@ describe("listMembers", async () => {
expect(members.data?.total).toBe(10);
});
+ it("should filter the members with 'in' operator", async () => {
+ const members = await client.organization.listMembers({
+ fetchOptions: {
+ headers,
+ },
+ query: {
+ filterField: "role",
+ filterOperator: "in",
+ filterValue: ["member", "owner"],
+ },
+ });
+ expect(members.data?.members.length).toBe(11);
+ expect(members.data?.total).toBe(11);
+ });
+
+ it("should filter the members with 'not_in' operator", async () => {
+ const members = await client.organization.listMembers({
+ fetchOptions: {
+ headers,
+ },
+ query: {
+ filterField: "role",
+ filterOperator: "not_in",
+ filterValue: ["owner"],
+ },
+ });
+ expect(members.data?.members.length).toBe(10);
+ expect(members.data?.total).toBe(10);
+ });
+
+ it("should filter the members with 'starts_with' operator", async () => {
+ const members = await client.organization.listMembers({
+ fetchOptions: {
+ headers,
+ },
+ query: {
+ filterField: "role",
+ filterOperator: "starts_with",
+ filterValue: "mem",
+ },
+ });
+ expect(members.data?.members.length).toBe(10);
+ expect(members.data?.total).toBe(10);
+ });
+
it("should sort the members", async () => {
const defaultMembers = await client.organization.listMembers({
fetchOptions: {
diff --git a/packages/better-auth/src/plugins/organization/routes/crud-members.ts b/packages/better-auth/src/plugins/organization/routes/crud-members.ts
index c6dc9022e9..8bc7a8b1fc 100644
--- a/packages/better-auth/src/plugins/organization/routes/crud-members.ts
+++ b/packages/better-auth/src/plugins/organization/routes/crud-members.ts
@@ -1,5 +1,6 @@
import type { LiteralString } from "@better-auth/core";
import { createAuthEndpoint } from "@better-auth/core/api";
+import { whereOperators } from "@better-auth/core/db/adapter";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import * as z from "zod";
import { getSessionFromCtx, sessionMiddleware } from "../../../api";
@@ -879,9 +880,11 @@ export const listMembers = (options: O) =>
})
.or(z.number())
.or(z.boolean())
+ .or(z.array(z.string()))
+ .or(z.array(z.number()))
.optional(),
filterOperator: z
- .enum(["eq", "ne", "lt", "lte", "gt", "gte", "contains"])
+ .enum(whereOperators)
.meta({
description: "The operator to use for the filter",
})
diff --git a/packages/better-auth/src/social.test.ts b/packages/better-auth/src/social.test.ts
index a6391bf6e1..8e47f35265 100644
--- a/packages/better-auth/src/social.test.ts
+++ b/packages/better-auth/src/social.test.ts
@@ -1061,12 +1061,8 @@ describe("Apple Provider", async () => {
// Name should NOT be the email address
expect(session.data?.user.name).not.toBe("user@privaterelay.appleid.com");
- // Name should be undefined, null, or space when not provided
- expect(
- session.data?.user.name === undefined ||
- session.data?.user.name === null ||
- session.data?.user.name === " ",
- ).toBe(true);
+ // Name should be empty string when not provided
+ expect(session.data?.user.name).toBe("");
});
it("should use firstName and lastName when provided in token.user", async () => {
diff --git a/packages/core/src/db/adapter/index.ts b/packages/core/src/db/adapter/index.ts
index c8d59383a6..159aae0dd5 100644
--- a/packages/core/src/db/adapter/index.ts
+++ b/packages/core/src/db/adapter/index.ts
@@ -301,25 +301,27 @@ export interface DBAdapterFactoryConfig<
disableTransformJoin?: boolean | undefined;
}
+export const whereOperators = [
+ "eq",
+ "ne",
+ "lt",
+ "lte",
+ "gt",
+ "gte",
+ "in",
+ "not_in",
+ "contains",
+ "starts_with",
+ "ends_with",
+] as const;
+
+export type WhereOperator = (typeof whereOperators)[number];
+
export type Where = {
/**
* @default eq
*/
- operator?:
- | (
- | "eq"
- | "ne"
- | "lt"
- | "lte"
- | "gt"
- | "gte"
- | "in"
- | "not_in"
- | "contains"
- | "starts_with"
- | "ends_with"
- )
- | undefined;
+ operator?: WhereOperator | undefined;
value: string | number | boolean | string[] | number[] | Date | null;
field: string;
/**
diff --git a/packages/core/src/oauth2/refresh-access-token.test.ts b/packages/core/src/oauth2/refresh-access-token.test.ts
new file mode 100644
index 0000000000..c0314424df
--- /dev/null
+++ b/packages/core/src/oauth2/refresh-access-token.test.ts
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@better-fetch/fetch", () => ({
+ betterFetch: vi.fn(),
+}));
+
+import { betterFetch } from "@better-fetch/fetch";
+import { refreshAccessToken } from "./refresh-access-token";
+
+const mockedBetterFetch = vi.mocked(betterFetch);
+
+describe("refreshAccessToken", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should set accessTokenExpiresAt when expires_in is returned", async () => {
+ const now = Date.now();
+ mockedBetterFetch.mockResolvedValueOnce({
+ data: {
+ access_token: "new-access-token",
+ refresh_token: "new-refresh-token",
+ expires_in: 3600,
+ token_type: "Bearer",
+ },
+ error: null,
+ });
+
+ const tokens = await refreshAccessToken({
+ refreshToken: "old-refresh-token",
+ options: { clientId: "test-client", clientSecret: "test-secret" },
+ tokenEndpoint: "https://example.com/token",
+ });
+
+ expect(tokens.accessToken).toBe("new-access-token");
+ expect(tokens.refreshToken).toBe("new-refresh-token");
+ expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date);
+ expect(tokens.accessTokenExpiresAt!.getTime()).toBeGreaterThanOrEqual(
+ now + 3600 * 1000 - 1000,
+ );
+ expect(tokens.refreshTokenExpiresAt).toBeUndefined();
+ });
+
+ /**
+ * @see https://github.com/better-auth/better-auth/issues/7682
+ */
+ it("should set refreshTokenExpiresAt when refresh_token_expires_in is returned", async () => {
+ const now = Date.now();
+ mockedBetterFetch.mockResolvedValueOnce({
+ data: {
+ access_token: "new-access-token",
+ refresh_token: "new-refresh-token",
+ expires_in: 3600,
+ refresh_token_expires_in: 86400,
+ token_type: "Bearer",
+ },
+ error: null,
+ });
+
+ const tokens = await refreshAccessToken({
+ refreshToken: "old-refresh-token",
+ options: { clientId: "test-client", clientSecret: "test-secret" },
+ tokenEndpoint: "https://example.com/token",
+ });
+
+ expect(tokens.accessToken).toBe("new-access-token");
+ expect(tokens.refreshToken).toBe("new-refresh-token");
+ expect(tokens.accessTokenExpiresAt).toBeInstanceOf(Date);
+ expect(tokens.refreshTokenExpiresAt).toBeInstanceOf(Date);
+ expect(tokens.refreshTokenExpiresAt!.getTime()).toBeGreaterThanOrEqual(
+ now + 86400 * 1000 - 1000,
+ );
+ });
+
+ it("should not set refreshTokenExpiresAt when refresh_token_expires_in is not returned", async () => {
+ mockedBetterFetch.mockResolvedValueOnce({
+ data: {
+ access_token: "new-access-token",
+ refresh_token: "new-refresh-token",
+ expires_in: 3600,
+ token_type: "Bearer",
+ },
+ error: null,
+ });
+
+ const tokens = await refreshAccessToken({
+ refreshToken: "old-refresh-token",
+ options: { clientId: "test-client", clientSecret: "test-secret" },
+ tokenEndpoint: "https://example.com/token",
+ });
+
+ expect(tokens.refreshTokenExpiresAt).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/oauth2/refresh-access-token.ts b/packages/core/src/oauth2/refresh-access-token.ts
index 0fe48df0d5..de3bf2b27b 100644
--- a/packages/core/src/oauth2/refresh-access-token.ts
+++ b/packages/core/src/oauth2/refresh-access-token.ts
@@ -119,6 +119,7 @@ export async function refreshAccessToken({
access_token: string;
refresh_token?: string | undefined;
expires_in?: number | undefined;
+ refresh_token_expires_in?: number | undefined;
token_type?: string | undefined;
scope?: string | undefined;
id_token?: string | undefined;
@@ -145,5 +146,12 @@ export async function refreshAccessToken({
);
}
+ if (data.refresh_token_expires_in) {
+ const now = new Date();
+ tokens.refreshTokenExpiresAt = new Date(
+ now.getTime() + data.refresh_token_expires_in * 1000,
+ );
+ }
+
return tokens;
}
diff --git a/packages/core/src/social-providers/apple.ts b/packages/core/src/social-providers/apple.ts
index cb02ad25aa..9067254628 100644
--- a/packages/core/src/social-providers/apple.ts
+++ b/packages/core/src/social-providers/apple.ts
@@ -158,15 +158,15 @@ export const apple = (options: AppleOptions) => {
return null;
}
- // TODO: " " masking will be removed when the name field is made optional
+ // TODO: "" masking will be removed when the name field is made optional
let name: string;
if (token.user?.name) {
const firstName = token.user.name.firstName || "";
const lastName = token.user.name.lastName || "";
const fullName = `${firstName} ${lastName}`.trim();
- name = fullName || " ";
+ name = fullName;
} else {
- name = profile.name || " ";
+ name = profile.name || "";
}
const emailVerified =
diff --git a/packages/core/src/social-providers/cognito.ts b/packages/core/src/social-providers/cognito.ts
index 7a55054a7c..456be22fc7 100644
--- a/packages/core/src/social-providers/cognito.ts
+++ b/packages/core/src/social-providers/cognito.ts
@@ -178,10 +178,7 @@ export const cognito = (options: CognitoOptions) => {
return null;
}
const name =
- profile.name ||
- profile.given_name ||
- profile.username ||
- profile.email;
+ profile.name || profile.given_name || profile.username || "";
const enrichedProfile = {
...profile,
name,
@@ -220,7 +217,11 @@ export const cognito = (options: CognitoOptions) => {
return {
user: {
id: userInfo.sub,
- name: userInfo.name || userInfo.given_name || userInfo.username,
+ name:
+ userInfo.name ||
+ userInfo.given_name ||
+ userInfo.username ||
+ "",
email: userInfo.email,
image: userInfo.picture,
emailVerified: userInfo.email_verified,
diff --git a/packages/core/src/social-providers/github.ts b/packages/core/src/social-providers/github.ts
index 5e5b342e0a..c74d4a4ec0 100644
--- a/packages/core/src/social-providers/github.ts
+++ b/packages/core/src/social-providers/github.ts
@@ -170,7 +170,7 @@ export const github = (options: GithubOptions) => {
return {
user: {
id: profile.id,
- name: profile.name || profile.login,
+ name: profile.name || profile.login || "",
email: profile.email,
image: profile.avatar_url,
emailVerified,
diff --git a/packages/core/src/social-providers/gitlab.ts b/packages/core/src/social-providers/gitlab.ts
index 8c694a836e..566b5ce979 100644
--- a/packages/core/src/social-providers/gitlab.ts
+++ b/packages/core/src/social-providers/gitlab.ts
@@ -141,7 +141,7 @@ export const gitlab = (options: GitlabOptions) => {
return {
user: {
id: profile.id,
- name: profile.name ?? profile.username,
+ name: profile.name ?? profile.username ?? "",
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.email_verified ?? false,
diff --git a/packages/core/src/social-providers/huggingface.ts b/packages/core/src/social-providers/huggingface.ts
index 774717a223..763f850ef9 100644
--- a/packages/core/src/social-providers/huggingface.ts
+++ b/packages/core/src/social-providers/huggingface.ts
@@ -104,7 +104,7 @@ export const huggingface = (options: HuggingFaceOptions) => {
return {
user: {
id: profile.sub,
- name: profile.name || profile.preferred_username,
+ name: profile.name || profile.preferred_username || "",
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified ?? false,
diff --git a/packages/core/src/social-providers/kakao.ts b/packages/core/src/social-providers/kakao.ts
index f976ccb36d..2b441dc942 100644
--- a/packages/core/src/social-providers/kakao.ts
+++ b/packages/core/src/social-providers/kakao.ts
@@ -161,7 +161,7 @@ export const kakao = (options: KakaoOptions) => {
const kakaoProfile = account.profile || {};
const user = {
id: String(profile.id),
- name: kakaoProfile.nickname || account.name || undefined,
+ name: kakaoProfile.nickname || account.name || "",
email: account.email,
image:
kakaoProfile.profile_image_url || kakaoProfile.thumbnail_image_url,
diff --git a/packages/core/src/social-providers/line.ts b/packages/core/src/social-providers/line.ts
index e77c811644..6c9b154833 100644
--- a/packages/core/src/social-providers/line.ts
+++ b/packages/core/src/social-providers/line.ts
@@ -147,7 +147,7 @@ export const line = (options: LineOptions) => {
const userMap = await options.mapProfileToUser?.(profile as any);
// ID preference order
const id = (profile as any).sub || (profile as any).userId;
- const name = (profile as any).name || (profile as any).displayName;
+ const name = (profile as any).name || (profile as any).displayName || "";
const image =
(profile as any).picture || (profile as any).pictureUrl || undefined;
const email = (profile as any).email;
diff --git a/packages/core/src/social-providers/naver.ts b/packages/core/src/social-providers/naver.ts
index 0f192d7cc1..f9ebcbe5ca 100644
--- a/packages/core/src/social-providers/naver.ts
+++ b/packages/core/src/social-providers/naver.ts
@@ -96,7 +96,7 @@ export const naver = (options: NaverOptions) => {
const res = profile.response || {};
const user = {
id: res.id,
- name: res.name || res.nickname,
+ name: res.name || res.nickname || "",
email: res.email,
image: res.profile_image,
emailVerified: false,
diff --git a/packages/core/src/social-providers/notion.ts b/packages/core/src/social-providers/notion.ts
index d6f4ece80f..7db9c46fb6 100644
--- a/packages/core/src/social-providers/notion.ts
+++ b/packages/core/src/social-providers/notion.ts
@@ -94,7 +94,7 @@ export const notion = (options: NotionOptions) => {
return {
user: {
id: userProfile.id,
- name: userProfile.name || "Notion User",
+ name: userProfile.name || "",
email: userProfile.person?.email || null,
image: userProfile.avatar_url,
emailVerified: false,
diff --git a/packages/core/src/social-providers/paybin.ts b/packages/core/src/social-providers/paybin.ts
index 4f357d4285..aca15616f6 100644
--- a/packages/core/src/social-providers/paybin.ts
+++ b/packages/core/src/social-providers/paybin.ts
@@ -104,11 +104,7 @@ export const paybin = (options: PaybinOptions) => {
return {
user: {
id: user.sub,
- name:
- user.name ||
- user.preferred_username ||
- (user.email ? user.email.split("@")[0] : "User") ||
- "User",
+ name: user.name || user.preferred_username || "",
email: user.email,
image: user.picture,
emailVerified: user.email_verified || false,
diff --git a/packages/core/src/social-providers/polar.ts b/packages/core/src/social-providers/polar.ts
index fe9734ab1a..ae855eda76 100644
--- a/packages/core/src/social-providers/polar.ts
+++ b/packages/core/src/social-providers/polar.ts
@@ -96,7 +96,7 @@ export const polar = (options: PolarOptions) => {
return {
user: {
id: profile.id,
- name: profile.public_name || profile.username,
+ name: profile.public_name || profile.username || "",
email: profile.email,
image: profile.avatar_url,
emailVerified: profile.email_verified ?? false,
diff --git a/packages/core/src/social-providers/tiktok.ts b/packages/core/src/social-providers/tiktok.ts
index fd6d86fa6d..b4512d8ec2 100644
--- a/packages/core/src/social-providers/tiktok.ts
+++ b/packages/core/src/social-providers/tiktok.ts
@@ -197,7 +197,8 @@ export const tiktok = (options: TiktokOptions) => {
user: {
email: profile.data.user.email || profile.data.user.username,
id: profile.data.user.open_id,
- name: profile.data.user.display_name || profile.data.user.username,
+ name:
+ profile.data.user.display_name || profile.data.user.username || "",
image: profile.data.user.avatar_large_url,
emailVerified: false,
},
diff --git a/packages/core/src/social-providers/vercel.ts b/packages/core/src/social-providers/vercel.ts
index 9048fe7c1a..35c41180c6 100644
--- a/packages/core/src/social-providers/vercel.ts
+++ b/packages/core/src/social-providers/vercel.ts
@@ -73,7 +73,7 @@ export const vercel = (options: VercelOptions) => {
return {
user: {
id: profile.sub,
- name: profile.name ?? profile.preferred_username,
+ name: profile.name ?? profile.preferred_username ?? "",
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified ?? false,
diff --git a/packages/electron/package.json b/packages/electron/package.json
index 83b26df3de..604a0938c5 100644
--- a/packages/electron/package.json
+++ b/packages/electron/package.json
@@ -83,6 +83,7 @@
"@better-fetch/fetch": "catalog:",
"better-auth": "workspace:*",
"better-call": "catalog:",
+ "electron": ">=36.0.0",
"conf": "^15.0.2"
},
"dependencies": {
@@ -91,6 +92,7 @@
"devDependencies": {
"@better-auth/core": "workspace:*",
"@types/better-sqlite3": "^7.6.13",
+ "electron": "^38.8.0",
"better-auth": "workspace:*",
"better-sqlite3": "^12.6.2",
"tsdown": "catalog:"
diff --git a/packages/electron/src/error-codes.ts b/packages/electron/src/error-codes.ts
index 2ff3950789..07d8d40be6 100644
--- a/packages/electron/src/error-codes.ts
+++ b/packages/electron/src/error-codes.ts
@@ -1,6 +1,7 @@
import { defineErrorCodes } from "better-auth";
export const ELECTRON_ERROR_CODES = defineErrorCodes({
+ INVALID_CLIENT_ID: "Invalid client ID",
INVALID_TOKEN: "Invalid or expired token.",
STATE_MISMATCH: "state mismatch",
MISSING_CODE_CHALLENGE: "missing code challenge",
diff --git a/packages/electron/src/index.ts b/packages/electron/src/index.ts
index 36e35a938f..a0d55db920 100644
--- a/packages/electron/src/index.ts
+++ b/packages/electron/src/index.ts
@@ -1,6 +1,9 @@
///
-import type { HookEndpointContext } from "@better-auth/core";
+import type {
+ GenericEndpointContext,
+ HookEndpointContext,
+} from "@better-auth/core";
import { createAuthMiddleware } from "@better-auth/core/api";
import { APIError } from "@better-auth/core/error";
import type { BetterAuthPlugin } from "better-auth";
@@ -8,7 +11,11 @@ import { safeJSONParse } from "better-auth";
import { generateRandomString } from "better-auth/crypto";
import * as z from "zod";
import { ELECTRON_ERROR_CODES } from "./error-codes";
-import { electronInitOAuthProxy, electronToken } from "./routes";
+import {
+ electronInitOAuthProxy,
+ electronToken,
+ electronTransferUser,
+} from "./routes";
import type { ElectronOptions } from "./types";
declare module "@better-auth/core" {
@@ -43,6 +50,58 @@ export const electron = (options?: ElectronOptions | undefined) => {
);
};
+ const handleTransfer = async (
+ ctx: GenericEndpointContext,
+ payload: {
+ client_id: string;
+ state: string;
+ code_challenge: string;
+ code_challenge_method?: string | undefined;
+ },
+ ) => {
+ const {
+ client_id,
+ state,
+ code_challenge,
+ code_challenge_method = "plain",
+ } = payload;
+ const userId =
+ ctx.context.session?.user.id || ctx.context.newSession?.user.id;
+ if (!userId || client_id !== opts.clientID) {
+ return false;
+ }
+ if (!state) {
+ throw APIError.from("BAD_REQUEST", ELECTRON_ERROR_CODES.MISSING_STATE);
+ }
+ if (!code_challenge) {
+ throw APIError.from("BAD_REQUEST", ELECTRON_ERROR_CODES.MISSING_PKCE);
+ }
+
+ const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`;
+
+ const identifier = generateRandomString(32, "a-z", "A-Z", "0-9");
+ const codeExpiresInMs = opts.codeExpiresIn * 1000;
+ const expiresAt = new Date(Date.now() + codeExpiresInMs);
+ await ctx.context.internalAdapter.createVerificationValue({
+ identifier: `electron:${identifier}`,
+ value: JSON.stringify({
+ userId,
+ codeChallenge: code_challenge,
+ codeChallengeMethod: code_challenge_method.toLowerCase(),
+ state,
+ }),
+ expiresAt,
+ });
+
+ ctx.setCookie(redirectCookieName, identifier, {
+ ...ctx.context.authCookies.sessionToken.attributes,
+ maxAge: opts.redirectCookieExpiresIn,
+ httpOnly: false,
+ });
+
+ return true;
+ };
+
return {
id: "electron",
async onRequest(request, _ctx) {
@@ -141,45 +200,7 @@ export const electron = (options?: ElectronOptions | undefined) => {
return;
}
- const { client_id, code_challenge, code_challenge_method, state } =
- transferPayload;
- if (client_id !== opts.clientID) {
- return;
- }
- if (!state) {
- throw APIError.from(
- "BAD_REQUEST",
- ELECTRON_ERROR_CODES.MISSING_STATE,
- );
- }
- if (!code_challenge) {
- throw APIError.from(
- "BAD_REQUEST",
- ELECTRON_ERROR_CODES.MISSING_PKCE,
- );
- }
-
- const redirectCookieName = `${opts.cookiePrefix}.${opts.clientID}`;
-
- const identifier = generateRandomString(32, "a-z", "A-Z", "0-9");
- const codeExpiresInMs = opts.codeExpiresIn * 1000;
- const expiresAt = new Date(Date.now() + codeExpiresInMs);
- await ctx.context.internalAdapter.createVerificationValue({
- identifier: `electron:${identifier}`,
- value: JSON.stringify({
- userId: ctx.context.newSession.user.id,
- codeChallenge: code_challenge,
- codeChallengeMethod: code_challenge_method.toLowerCase(),
- state,
- }),
- expiresAt,
- });
-
- ctx.setCookie(redirectCookieName, identifier, {
- ...ctx.context.authCookies.sessionToken.attributes,
- maxAge: opts.redirectCookieExpiresIn,
- httpOnly: false,
- });
+ await handleTransfer(ctx, transferPayload);
return ctx;
}),
@@ -189,6 +210,9 @@ export const electron = (options?: ElectronOptions | undefined) => {
endpoints: {
electronToken: electronToken(opts),
electronInitOAuthProxy: electronInitOAuthProxy(opts),
+ electronTransferUser: electronTransferUser(opts, {
+ handleTransfer,
+ }),
},
options: opts,
$ERROR_CODES: ELECTRON_ERROR_CODES,
diff --git a/packages/electron/src/proxy.ts b/packages/electron/src/proxy.ts
index 5b4586cba2..9971c8cadd 100644
--- a/packages/electron/src/proxy.ts
+++ b/packages/electron/src/proxy.ts
@@ -1,5 +1,6 @@
import type { BetterAuthClientPlugin } from "@better-auth/core";
import { parseCookies } from "better-auth/cookies";
+import type { electron } from "./index";
import type { ElectronProxyClientOptions } from "./types/client";
import { parseProtocolScheme } from "./utils";
@@ -71,5 +72,9 @@ export const electronProxyClient = (options: ElectronProxyClientOptions) => {
},
};
},
+ pathMethods: {
+ "/electron/transfer-user": "POST",
+ },
+ $InferServerPlugin: {} as ReturnType,
} satisfies BetterAuthClientPlugin;
};
diff --git a/packages/electron/src/routes.ts b/packages/electron/src/routes.ts
index 2dffbc8a2d..245c34e0bb 100644
--- a/packages/electron/src/routes.ts
+++ b/packages/electron/src/routes.ts
@@ -1,12 +1,13 @@
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
+import type { GenericEndpointContext } from "@better-auth/core";
import { APIError, BASE_ERROR_CODES } from "@better-auth/core/error";
import { SocialProviderListEnum } from "@better-auth/core/social-providers";
import { safeJSONParse } from "@better-auth/core/utils/json";
import { base64Url } from "@better-auth/utils/base64";
import { createHash } from "@better-auth/utils/hash";
import { betterFetch } from "@better-fetch/fetch";
-import { createAuthEndpoint } from "better-auth/api";
+import { createAuthEndpoint, sessionMiddleware } from "better-auth/api";
import { setSessionCookie } from "better-auth/cookies";
import type { User } from "better-auth/db";
import { parseUserOutput } from "better-auth/db";
@@ -20,7 +21,7 @@ const electronTokenBodySchema = z.object({
code_verifier: z.string().nonempty(),
});
-export const electronToken = (opts: ElectronOptions) =>
+export const electronToken = (_opts: ElectronOptions) =>
createAuthEndpoint(
"/electron/token",
{
@@ -28,6 +29,32 @@ export const electronToken = (opts: ElectronOptions) =>
body: electronTokenBodySchema,
metadata: {
scope: "http",
+ openapi: {
+ description: "Exchange the electron token for a session",
+ operationId: "electronToken",
+ responses: {
+ 200: {
+ description: "Returns the session token and user",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ token: {
+ type: "string",
+ },
+ user: {
+ type: "object",
+ $ref: "#/components/schemas/User",
+ },
+ },
+ required: ["token", "user"],
+ },
+ },
+ },
+ },
+ },
+ },
},
},
async (ctx) => {
@@ -129,6 +156,40 @@ export const electronInitOAuthProxy = (opts: ElectronOptions) =>
query: electronInitOAuthProxyQuerySchema,
metadata: {
scope: "http",
+ openapi: {
+ description: "Initialize the OAuth proxy for the electron app",
+ operationId: "electronInitOAuthProxy",
+ responses: {
+ 200: {
+ description:
+ "Returns the URL to redirect to and if the redirect should be performed",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ url: {
+ type: "string",
+ nullable: true,
+ },
+ redirect: {
+ type: "boolean",
+ },
+ user: {
+ type: "object",
+ $ref: "#/components/schemas/User",
+ },
+ token: {
+ type: "string",
+ },
+ },
+ required: ["url", "redirect"],
+ },
+ },
+ },
+ },
+ },
+ },
},
},
async (ctx) => {
@@ -189,3 +250,83 @@ export const electronInitOAuthProxy = (opts: ElectronOptions) =>
return ctx.json(res.data);
},
);
+
+const electronTransferUserQuerySchema = z.object({
+ client_id: z.string(),
+ state: z.string(),
+ code_challenge: z.string(),
+ code_challenge_method: z.string().optional(),
+});
+const electronTransferUserBodySchema = z.object({
+ callbackURL: z.string().optional(),
+});
+
+export const electronTransferUser = (
+ _opts: ElectronOptions,
+ {
+ handleTransfer,
+ }: {
+ handleTransfer: (
+ ctx: GenericEndpointContext,
+ payload: {
+ client_id: string;
+ state: string;
+ code_challenge: string;
+ code_challenge_method?: string | undefined;
+ },
+ ) => Promise;
+ },
+) =>
+ createAuthEndpoint(
+ "/electron/transfer-user",
+ {
+ method: "POST",
+ query: electronTransferUserQuerySchema,
+ body: electronTransferUserBodySchema,
+ use: [sessionMiddleware],
+ requireHeaders: true,
+ metadata: {
+ openapi: {
+ description: "Transfer the user to the electron app",
+ operationId: "electronTransferUser",
+ responses: {
+ 200: {
+ description:
+ "Returns the URL to redirect to and if the redirect should be performed",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ url: {
+ type: "string",
+ nullable: true,
+ },
+ redirect: {
+ type: "boolean",
+ },
+ },
+ required: ["url", "redirect"],
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ async (ctx) => {
+ const success = await handleTransfer(ctx, ctx.query);
+ if (!success) {
+ throw APIError.from(
+ "BAD_REQUEST",
+ ELECTRON_ERROR_CODES.INVALID_CLIENT_ID,
+ );
+ }
+
+ return ctx.json({
+ url: ctx.body.callbackURL ? ctx.body.callbackURL : null,
+ redirect: ctx.body.callbackURL ? true : false,
+ });
+ },
+ );
diff --git a/packages/electron/test/electron.test.ts b/packages/electron/test/electron.test.ts
index 8583cc4d66..4657ab2912 100644
--- a/packages/electron/test/electron.test.ts
+++ b/packages/electron/test/electron.test.ts
@@ -7,6 +7,7 @@ import { generateRandomString } from "better-auth/crypto";
import { getMigrations } from "better-auth/db/migration";
import { describe, expect, vi } from "vitest";
import { authenticate, kCodeVerifier, kState } from "../src/authenticate";
+import { ELECTRON_ERROR_CODES } from "../src/error-codes";
import { electron } from "../src/index";
import { it, testUtils } from "./utils";
@@ -494,6 +495,108 @@ describe("Electron", () => {
spy.mockRestore();
});
+ describe("transferUser", () => {
+ const transferQuery =
+ "client_id=electron&state=xyz&code_challenge=challenge";
+ const post = (cookie: string, body?: object) =>
+ auth.handler(
+ new Request(
+ `http://localhost:3000/api/auth/electron/transfer-user?${transferQuery}`,
+ {
+ method: "POST",
+ headers: {
+ cookie: cookie,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify(body ?? {}),
+ },
+ ),
+ );
+
+ async function getSessionCookie() {
+ let res = await auth.handler(
+ new Request("http://localhost:3000/api/auth/sign-up/email", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ email: "transfer-test@test.com",
+ password: "password",
+ name: "Transfer Test",
+ }),
+ }),
+ );
+ if (res.status !== 200) {
+ res = await auth.handler(
+ new Request("http://localhost:3000/api/auth/sign-in/email", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ email: "transfer-test@test.com",
+ password: "password",
+ }),
+ }),
+ );
+ }
+ const setCookie = res.headers.get("set-cookie") ?? "";
+ const parsed = parseSetCookieHeader(setCookie);
+ const parts: string[] = [];
+ parsed.forEach((value, name) => {
+ parts.push(`${name}=${value.value}`);
+ });
+ return parts.join("; ");
+ }
+
+ it("should return url and redirect from body when callbackURL provided", async () => {
+ const cookie = await getSessionCookie();
+ const res = await post(cookie, {
+ callbackURL: "https://app.example.com/callback",
+ });
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data).toEqual({
+ url: "https://app.example.com/callback",
+ redirect: true,
+ });
+ });
+
+ it("should return null url and false redirect when callbackURL omitted", async () => {
+ const cookie = await getSessionCookie();
+ const res = await post(cookie);
+ expect(res.status).toBe(200);
+ const data = await res.json();
+ expect(data).toEqual({ url: null, redirect: false });
+ });
+
+ it("should throw INVALID_CLIENT_ID when client_id does not match", async () => {
+ const cookie = await getSessionCookie();
+ const res = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/electron/transfer-user?client_id=wrong&state=xyz&code_challenge=challenge",
+ {
+ method: "POST",
+ headers: {
+ cookie,
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({}),
+ },
+ ),
+ );
+ expect(res.status).toBe(400);
+ const data = await res.json();
+ expect(data.code).toBe(ELECTRON_ERROR_CODES.INVALID_CLIENT_ID.code);
+ });
+
+ it("should set redirect cookie on success", async () => {
+ const cookie = await getSessionCookie();
+ const res = await post(cookie);
+ expect(res.status).toBe(200);
+ const setCookie = res.headers.get("set-cookie") ?? "";
+ const cookies = parseSetCookieHeader(setCookie);
+ expect(cookies.has("better-auth.electron")).toBe(true);
+ });
+ });
+
it("should register protocol", async ({ setProcessType }) => {
setProcessType("browser");
diff --git a/packages/i18n/src/i18n.test.ts b/packages/i18n/src/i18n.test.ts
index 60070dbe3d..8651dd86e4 100644
--- a/packages/i18n/src/i18n.test.ts
+++ b/packages/i18n/src/i18n.test.ts
@@ -187,9 +187,8 @@ describe("i18n plugin", async () => {
translations,
defaultLocale: "en",
detection: ["callback"],
- getLocale: (request, _ctx) => {
- if (!request) return null;
- return request.headers.get("X-Custom-Locale") ?? null;
+ getLocale: (ctx) => {
+ return ctx.headers?.get("X-Custom-Locale") ?? null;
},
}),
],
@@ -218,7 +217,7 @@ describe("i18n plugin", async () => {
translations,
defaultLocale: "en",
detection: ["callback"],
- getLocale: (_request, _ctx) => {
+ getLocale: () => {
return "fr";
},
}),
diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts
index c7fb1b37b9..001a1a4602 100644
--- a/packages/i18n/src/index.ts
+++ b/packages/i18n/src/index.ts
@@ -1,4 +1,7 @@
-import type { AuthContext, BetterAuthPlugin } from "@better-auth/core";
+import type {
+ BetterAuthPlugin,
+ GenericEndpointContext,
+} from "@better-auth/core";
import { APIError, createAuthMiddleware, isAPIError } from "better-auth/api";
import { parseCookies } from "better-auth/cookies";
import type { I18nOptions, LocaleDetectionStrategy } from "./types";
@@ -89,23 +92,21 @@ export const i18n = (
};
async function detectLocale(
- request: Request | undefined,
- headers: Headers | undefined,
- ctx: AuthContext,
+ ctx: GenericEndpointContext,
): Promise {
for (const strategy of opts.detection) {
let locale: Locales[number] | null = null;
switch (strategy) {
case "header": {
- const acceptLang = headers?.get("Accept-Language") ?? null;
+ const acceptLang = ctx.headers?.get("Accept-Language") ?? null;
const preferred = parseAcceptLanguage(acceptLang);
locale = preferred.find((l) => availableLocales.includes(l)) ?? null;
break;
}
case "cookie": {
- const cookieHeader = headers?.get("Cookie");
+ const cookieHeader = ctx.headers?.get("Cookie");
if (cookieHeader) {
const cookies = parseCookies(cookieHeader);
const cookieLocale = cookies.get(opts.localeCookie);
@@ -117,10 +118,10 @@ export const i18n = (
}
case "session": {
- if (ctx.session?.user) {
- const userLocale = (ctx.session.user as Record)[
- opts.userLocaleField
- ];
+ if (ctx.context.session?.user) {
+ const userLocale = (
+ ctx.context.session.user as Record
+ )[opts.userLocaleField];
if (
typeof userLocale === "string" &&
availableLocales.includes(userLocale)
@@ -133,7 +134,7 @@ export const i18n = (
case "callback": {
if (opts.getLocale) {
- const callbackLocale = await opts.getLocale(request, ctx);
+ const callbackLocale = await opts.getLocale(ctx);
if (callbackLocale && availableLocales.includes(callbackLocale)) {
locale = callbackLocale;
}
@@ -167,11 +168,7 @@ export const i18n = (
return;
}
- const locale = await detectLocale(
- ctx.request,
- ctx.headers,
- ctx.context,
- );
+ const locale = await detectLocale(ctx);
const translation = opts.translations[locale]?.[errorCode];
diff --git a/packages/i18n/src/types.ts b/packages/i18n/src/types.ts
index 25871bc6ab..a28a773612 100644
--- a/packages/i18n/src/types.ts
+++ b/packages/i18n/src/types.ts
@@ -1,7 +1,7 @@
import type {
- AuthContext,
BetterAuthPluginRegistry,
BetterAuthPluginRegistryIdentifier,
+ GenericEndpointContext,
UnionToIntersection,
} from "@better-auth/core";
@@ -84,14 +84,15 @@ export interface I18nOptions {
userLocaleField?: string | undefined;
/**
- * Custom locale detection function (when "callback" strategy is used)
- * Receives a request and an AuthContext, returns a locale code or null.
- * Note: `request` may be undefined for non-HTTP calls.
+ * Custom locale detection function (when "callback" strategy is used).
+ * @example
+ * getLocale: (ctx) => {
+ * return ctx.headers?.get("X-Custom-Locale") ?? null;
+ * }
*/
getLocale?:
| undefined
| ((
- request: Request | undefined,
- ctx: AuthContext,
+ ctx: GenericEndpointContext,
) => Promise | Locales[number] | null);
}
diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts
index f8caec02f8..58e17bb6ba 100644
--- a/packages/sso/src/oidc.test.ts
+++ b/packages/sso/src/oidc.test.ts
@@ -3,7 +3,7 @@ import { createAuthClient } from "better-auth/client";
import { organization } from "better-auth/plugins";
import { getTestInstance } from "better-auth/test";
import { OAuth2Server } from "oauth2-mock-server";
-import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { sso } from ".";
import { ssoClient } from "./client";
@@ -684,3 +684,139 @@ describe("provisioning", async (ctx) => {
expect(res.url).toContain("http://localhost:8080/authorize");
});
});
+
+/**
+ * @see https://github.com/better-auth/better-auth/issues/7857
+ */
+describe("provisionUser should only be called for new users", async () => {
+ const provisionUserFn = vi.fn();
+ const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
+ await getTestInstance({
+ trustedOrigins: ["http://localhost:8080"],
+ plugins: [
+ sso({
+ provisionUser: provisionUserFn,
+ }),
+ organization(),
+ ],
+ });
+
+ const authClient = createAuthClient({
+ plugins: [ssoClient()],
+ baseURL: "http://localhost:3000",
+ fetchOptions: {
+ customFetchImpl,
+ },
+ });
+
+ beforeAll(async () => {
+ await server.issuer.keys.generate("RS256");
+ server.service.removeAllListeners("beforeUserinfo");
+ server.service.removeAllListeners("beforeTokenSigning");
+ server.service.on("beforeUserinfo", (userInfoResponse) => {
+ userInfoResponse.body = {
+ email: "provision-test@localhost.com",
+ name: "Provision Test",
+ sub: "provision-test-sub",
+ picture: "https://test.com/picture.png",
+ email_verified: true,
+ };
+ userInfoResponse.statusCode = 200;
+ });
+ server.service.on("beforeTokenSigning", (token) => {
+ token.payload.email = "provision-test@localhost.com";
+ token.payload.email_verified = true;
+ token.payload.name = "Provision Test";
+ token.payload.picture = "https://test.com/picture.png";
+ });
+ await server.start(8080, "localhost");
+ });
+
+ afterAll(async () => {
+ await server.stop();
+ });
+
+ async function simulateOAuthFlow(authUrl: string, headers: Headers) {
+ let location: string | null = null;
+ await betterFetch(authUrl, {
+ method: "GET",
+ redirect: "manual",
+ onError(context) {
+ location = context.response.headers.get("location");
+ },
+ });
+
+ if (!location) throw new Error("No redirect location found");
+
+ let callbackURL = "";
+ const newHeaders = new Headers();
+ await betterFetch(location, {
+ method: "GET",
+ customFetchImpl,
+ headers,
+ onError(context) {
+ callbackURL = context.response.headers.get("location") || "";
+ cookieSetter(newHeaders)(context);
+ },
+ });
+
+ return { callbackURL, headers: newHeaders };
+ }
+
+ it("should call provisionUser only on first sign-in (new user), not on subsequent sign-ins", async () => {
+ const { headers } = await signInWithTestUser();
+ await auth.api.registerSSOProvider({
+ body: {
+ issuer: server.issuer.url!,
+ domain: "localhost.com",
+ oidcConfig: {
+ clientId: "test",
+ clientSecret: "test",
+ authorizationEndpoint: `${server.issuer.url}/authorize`,
+ tokenEndpoint: `${server.issuer.url}/token`,
+ jwksEndpoint: `${server.issuer.url}/jwks`,
+ discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
+ mapping: {
+ id: "sub",
+ email: "email",
+ emailVerified: "email_verified",
+ name: "name",
+ image: "picture",
+ },
+ },
+ providerId: "provision-test",
+ },
+ headers,
+ });
+
+ provisionUserFn.mockClear();
+
+ // First sign-in: new user -> provisionUser should be called
+ const signInHeaders1 = new Headers();
+ const res1 = await authClient.signIn.sso({
+ email: "user@localhost.com",
+ callbackURL: "/dashboard",
+ fetchOptions: {
+ throw: true,
+ onSuccess: cookieSetter(signInHeaders1),
+ },
+ });
+ await simulateOAuthFlow(res1.url, signInHeaders1);
+ expect(provisionUserFn).toHaveBeenCalledTimes(1);
+
+ provisionUserFn.mockClear();
+
+ // Second sign-in: existing user -> provisionUser should NOT be called
+ const signInHeaders2 = new Headers();
+ const res2 = await authClient.signIn.sso({
+ email: "user@localhost.com",
+ callbackURL: "/dashboard",
+ fetchOptions: {
+ throw: true,
+ onSuccess: cookieSetter(signInHeaders2),
+ },
+ });
+ await simulateOAuthFlow(res2.url, signInHeaders2);
+ expect(provisionUserFn).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts
index 691cadaae2..93596d92b6 100644
--- a/packages/sso/src/routes/sso.ts
+++ b/packages/sso/src/routes/sso.ts
@@ -1,3 +1,4 @@
+import { base64 } from "@better-auth/utils/base64";
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
import type { User, Verification } from "better-auth";
import {
@@ -1674,7 +1675,7 @@ export const callbackSSO = (options?: SSOOptions) => {
const linked = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email,
- name: userInfo.name || userInfo.email,
+ name: userInfo.name || "",
id: userInfo.id,
image: userInfo.image,
emailVerified: options?.trustEmailVerified
@@ -1701,7 +1702,7 @@ export const callbackSSO = (options?: SSOOptions) => {
}
const { session, user } = linked.data!;
- if (options?.provisionUser) {
+ if (options?.provisionUser && linked.isRegister) {
await options.provisionUser({
user,
userInfo,
@@ -1949,6 +1950,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
message: "Invalid SAML configuration",
});
}
+
+ const isTrusted = (
+ url: string,
+ settings?: { allowRelativePaths: boolean },
+ ) => ctx.context.isTrustedOrigin(url, settings);
+
+ const safeErrorUrl = getSafeRedirectUrl(
+ relayState?.errorURL ||
+ relayState?.callbackURL ||
+ parsedSamlConfig.callbackUrl,
+ currentCallbackPath,
+ appOrigin,
+ isTrusted,
+ );
+
const idpData = parsedSamlConfig.idpMetadata;
let idp: IdentityProvider | null = null;
@@ -2021,8 +2037,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
} catch (error) {
ctx.context.logger.error("SAML response validation failed", {
error,
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
- "utf-8",
+ decodedResponse: new TextDecoder().decode(
+ base64.decode(SAMLResponse),
),
});
throw new APIError("BAD_REQUEST", {
@@ -2073,12 +2089,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
"SAML InResponseTo validation failed: unknown or expired request ID",
{ inResponseTo, providerId: provider.providerId },
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
);
}
@@ -2095,12 +2107,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
);
}
@@ -2112,12 +2120,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
{ providerId: provider.providerId },
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
);
}
}
@@ -2167,12 +2171,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
providerId: provider.providerId,
},
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
+ `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
);
}
@@ -2216,7 +2216,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
.filter(Boolean)
.join(" ") ||
attributes[mapping.name || "displayName"] ||
- extract.nameID,
+ extract.nameID ||
+ "",
emailVerified:
options?.trustEmailVerified && mapping.emailVerified
? ((attributes[mapping.emailVerified] || false) as boolean)
@@ -2245,15 +2246,17 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
!!(provider as { domainVerified?: boolean }).domainVerified &&
validateEmailDomain(userInfo.email as string, provider.domain));
- const callbackUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
+ const safeCallbackUrl = getSafeRedirectUrl(
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
+ currentCallbackPath,
+ appOrigin,
+ isTrusted,
+ );
const result = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email as string,
- name: (userInfo.name || userInfo.email) as string,
+ name: (userInfo.name || "") as string,
id: userInfo.id as string,
emailVerified: Boolean(userInfo.emailVerified),
},
@@ -2263,20 +2266,20 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
accessToken: "",
refreshToken: "",
},
- callbackURL: callbackUrl,
+ callbackURL: safeCallbackUrl,
disableSignUp: options?.disableImplicitSignUp,
isTrustedProvider,
});
if (result.error) {
throw ctx.redirect(
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
);
}
const { session, user } = result.data!;
- if (options?.provisionUser) {
+ if (options?.provisionUser && result.isRegister) {
await options.provisionUser({
user: user as User & Record,
userInfo,
@@ -2299,14 +2302,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
});
await setSessionCookie(ctx, { session, user });
-
- const safeRedirectUrl = getSafeRedirectUrl(
- relayState?.callbackURL || parsedSamlConfig.callbackUrl,
- currentCallbackPath,
- appOrigin,
- (url, settings) => ctx.context.isTrustedOrigin(url, settings),
- );
- throw ctx.redirect(safeRedirectUrl);
+ throw ctx.redirect(safeCallbackUrl);
},
);
};
@@ -2427,6 +2423,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
}
const parsedSamlConfig = provider.samlConfig;
+
+ const isTrusted = (
+ url: string,
+ settings?: { allowRelativePaths: boolean },
+ ) => ctx.context.isTrustedOrigin(url, settings);
+
+ // Compute a safe error redirect URL once, reused by all error paths.
+ // Prefers errorURL from relay state, falls back to callbackURL, then provider config, then baseURL.
+ const safeErrorUrl = getSafeRedirectUrl(
+ relayState?.errorURL ||
+ relayState?.callbackURL ||
+ parsedSamlConfig.callbackUrl,
+ currentCallbackPath,
+ appOrigin,
+ isTrusted,
+ );
+
// Configure SP and IdP
const sp = saml.ServiceProvider({
entityID:
@@ -2471,16 +2484,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
validateSingleAssertion(SAMLResponse);
} catch (error) {
if (error instanceof APIError) {
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
const errorCode =
error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
? "multiple_assertions"
: "no_assertion";
throw ctx.redirect(
- `${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
+ `${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
);
}
throw error;
@@ -2502,8 +2511,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
} catch (error) {
ctx.context.logger.error("SAML response validation failed", {
error,
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
- "utf-8",
+ decodedResponse: new TextDecoder().decode(
+ base64.decode(SAMLResponse),
),
});
throw new APIError("BAD_REQUEST", {
@@ -2556,12 +2565,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
"SAML InResponseTo validation failed: unknown or expired request ID",
{ inResponseTo: inResponseToAcs, providerId },
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
);
}
@@ -2577,12 +2582,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
);
}
@@ -2594,19 +2595,15 @@ export const acsEndpoint = (options?: SSOOptions) => {
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
{ providerId },
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
);
}
}
// Assertion Replay Protection
- const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
- "utf-8",
+ const samlContentAcs = new TextDecoder().decode(
+ base64.decode(SAMLResponse),
);
const assertionIdAcs = extractAssertionId(samlContentAcs);
@@ -2649,12 +2646,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
providerId,
},
);
- const redirectUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
throw ctx.redirect(
- `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
+ `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
);
}
@@ -2698,7 +2691,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
.filter(Boolean)
.join(" ") ||
attributes[mapping.name || "displayName"] ||
- extract.nameID,
+ extract.nameID ||
+ "",
emailVerified:
options?.trustEmailVerified && mapping.emailVerified
? ((attributes[mapping.emailVerified] || false) as boolean)
@@ -2728,15 +2722,17 @@ export const acsEndpoint = (options?: SSOOptions) => {
!!(provider as { domainVerified?: boolean }).domainVerified &&
validateEmailDomain(userInfo.email as string, provider.domain));
- const callbackUrl =
- relayState?.callbackURL ||
- parsedSamlConfig.callbackUrl ||
- ctx.context.baseURL;
+ const safeCallbackUrl = getSafeRedirectUrl(
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
+ currentCallbackPath,
+ appOrigin,
+ isTrusted,
+ );
const result = await handleOAuthUserInfo(ctx, {
userInfo: {
email: userInfo.email as string,
- name: (userInfo.name || userInfo.email) as string,
+ name: (userInfo.name || "") as string,
id: userInfo.id as string,
emailVerified: Boolean(userInfo.emailVerified),
},
@@ -2746,20 +2742,20 @@ export const acsEndpoint = (options?: SSOOptions) => {
accessToken: "",
refreshToken: "",
},
- callbackURL: callbackUrl,
+ callbackURL: safeCallbackUrl,
disableSignUp: options?.disableImplicitSignUp,
isTrustedProvider,
});
if (result.error) {
throw ctx.redirect(
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
);
}
const { session, user } = result.data!;
- if (options?.provisionUser) {
+ if (options?.provisionUser && result.isRegister) {
await options.provisionUser({
user: user as User & Record,
userInfo,
@@ -2782,13 +2778,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
});
await setSessionCookie(ctx, { session, user });
- const safeRedirectUrl = getSafeRedirectUrl(
- relayState?.callbackURL || parsedSamlConfig.callbackUrl,
- currentCallbackPath,
- appOrigin,
- (url, settings) => ctx.context.isTrustedOrigin(url, settings),
- );
- throw ctx.redirect(safeRedirectUrl);
+ throw ctx.redirect(safeCallbackUrl);
},
);
};
diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts
index 8b586bd11e..c68455efdf 100644
--- a/packages/sso/src/saml.test.ts
+++ b/packages/sso/src/saml.test.ts
@@ -2111,6 +2111,160 @@ describe("SAML SSO", async () => {
const redirectLocation = response.headers.get("location") || "";
expect(redirectLocation).toContain("error=unsolicited_response");
});
+
+ /**
+ * @see https://github.com/better-auth/better-auth/issues/7777
+ */
+ it("should correctly parse verification-ID-based RelayState on ACS route (SP-initiated)", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [sso()],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "saml-acs-relay-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ // SP-initiated: signInSSO returns a URL with a RelayState verification ID
+ const signInRes = await auth.api.signInSSO({
+ body: {
+ providerId: "saml-acs-relay-provider",
+ callbackURL: "http://localhost:3000/dashboard",
+ },
+ returnHeaders: true,
+ });
+
+ const signInResponse = signInRes.response;
+ expect(signInResponse).toEqual({
+ url: expect.stringContaining("http://localhost:8081"),
+ redirect: true,
+ });
+
+ const samlRedirectUrl = new URL(signInResponse?.url);
+ const relayStateParam = samlRedirectUrl.searchParams.get("RelayState");
+ // RelayState should be a verification ID, not a raw URL
+ expect(relayStateParam).toBeTruthy();
+ expect(relayStateParam).not.toContain("http");
+
+ let samlResponse: any;
+ await betterFetch(signInResponse?.url, {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ // POST to the ACS endpoint with the verification-ID-based RelayState
+ const acsResponse = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-relay-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ Cookie: signInRes.headers.get("set-cookie") ?? "",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: relayStateParam!,
+ }),
+ },
+ ),
+ );
+
+ expect(acsResponse.status).toBe(302);
+ const acsRedirectLocation = acsResponse.headers.get("location") || "";
+ // Must redirect to the callbackURL from the relay state, not to the verification ID
+ expect(acsRedirectLocation).toContain("dashboard");
+ expect(acsRedirectLocation).not.toContain("error");
+ });
+
+ /**
+ * @see https://github.com/better-auth/better-auth/issues/7777
+ */
+ it("should fallback to provider callbackUrl on ACS route when RelayState is invalid", async () => {
+ const { auth, signInWithTestUser } = await getTestInstance({
+ plugins: [sso()],
+ });
+
+ const { headers } = await signInWithTestUser();
+
+ await auth.api.registerSSOProvider({
+ body: {
+ providerId: "saml-acs-bad-relay-provider",
+ issuer: "http://localhost:8081",
+ domain: "http://localhost:8081",
+ samlConfig: {
+ entryPoint: "http://localhost:8081/api/sso/saml2/idp/post",
+ cert: certificate,
+ callbackUrl: "http://localhost:3000/dashboard",
+ wantAssertionsSigned: false,
+ signatureAlgorithm: "sha256",
+ digestAlgorithm: "sha256",
+ idpMetadata: {
+ metadata: idpMetadata,
+ },
+ spMetadata: {
+ metadata: spMetadata,
+ },
+ identifierFormat:
+ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ },
+ },
+ headers,
+ });
+
+ let samlResponse: any;
+ await betterFetch("http://localhost:8081/api/sso/saml2/idp/post", {
+ onSuccess: async (context) => {
+ samlResponse = await context.data;
+ },
+ });
+
+ // POST with a garbage RelayState - should fallback to provider callbackUrl
+ const acsResponse = await auth.handler(
+ new Request(
+ "http://localhost:3000/api/auth/sso/saml2/sp/acs/saml-acs-bad-relay-provider",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ SAMLResponse: samlResponse.samlResponse,
+ RelayState: "not-a-valid-relay-state",
+ }),
+ },
+ ),
+ );
+
+ expect(acsResponse.status).toBe(302);
+ const location = acsResponse.headers.get("location") || "";
+ // Should redirect to the provider's callbackUrl, not the garbage RelayState
+ expect(location).toContain("dashboard");
+ expect(location).not.toContain("not-a-valid-relay-state");
+ });
});
describe("SAML SSO with custom fields", () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3de5ad5f42..62cdef206c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1654,6 +1654,9 @@ importers:
better-sqlite3:
specifier: ^12.6.2
version: 12.6.2
+ electron:
+ specifier: ^38.8.0
+ version: 38.8.0
tsdown:
specifier: 'catalog:'
version: 0.20.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.17.0)(publint@0.3.17)(synckit@0.11.11)(typescript@5.9.3)
@@ -9944,6 +9947,11 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
+ electron@38.8.0:
+ resolution: {integrity: sha512-cMvNuIFdXXDr4pJd5xD+F8FDjQhin9vyJ5hGSb5cZRIUb1VqfvRyP2nLBzU2Iazj0z3L686AUOkykp6Xd8bT1w==}
+ engines: {node: '>= 12.20.55'}
+ hasBin: true
+
electron@39.5.1:
resolution: {integrity: sha512-6s/sBQar+bbW59XSqohZj04MPic+kdVUAWjLbfQB/uLOeNw9jWX5FHaTxpHK29Xp3mKOHef7wErsjwMyCuWltg==}
engines: {node: '>= 12.20.55'}
@@ -22582,9 +22590,7 @@ snapshots:
metro-runtime: 0.83.3
transitivePeerDependencies:
- '@babel/core'
- - bufferutil
- supports-color
- - utf-8-validate
optional: true
'@react-native/metro-config@0.83.1(@babel/core@7.29.0)':
@@ -22595,9 +22601,7 @@ snapshots:
metro-runtime: 0.83.3
transitivePeerDependencies:
- '@babel/core'
- - bufferutil
- supports-color
- - utf-8-validate
optional: true
'@react-native/normalize-colors@0.74.89': {}
@@ -24314,14 +24318,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
- '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
+ '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.7(@types/node@25.0.10)(typescript@5.9.3)
- vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+ vite: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
@@ -26487,6 +26491,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ electron@38.8.0:
+ dependencies:
+ '@electron/get': 2.0.3
+ '@types/node': 22.19.8
+ extract-zip: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
electron@39.5.1:
dependencies:
'@electron/get': 2.0.3
@@ -34781,7 +34793,7 @@ snapshots:
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18