mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-20 20:26:55 -05:00
Merge branch 'canary' into 2026-02-09/fix/dynamic-access-control-has-permission
This commit is contained in:
@@ -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 (
|
||||
<div className="space-y-2 max-w-sm min-w-3xs">
|
||||
<p className="text-sm font-medium">Continue as:</p>
|
||||
<Separator />
|
||||
{users.map((session) => (
|
||||
<ContinueAsUser
|
||||
key={session.user.id}
|
||||
session={session}
|
||||
isLoading={isLoading}
|
||||
onContinue={handleContinueAsUser(session)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContinueAsUser({
|
||||
session,
|
||||
isLoading,
|
||||
onContinue,
|
||||
}: {
|
||||
session: typeof authClient.$Infer.Session;
|
||||
isLoading: boolean;
|
||||
onContinue: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2.5 bg-background p-2.5 border border-border rounded-md drop-shadow-md hover:bg-neutral-50 dark:hover:bg-neutral-900 transition-colors text-left disabled:pointer-events-none"
|
||||
aria-label={`Continue as ${session.user.name}`}
|
||||
disabled={isLoading}
|
||||
onClick={onContinue}
|
||||
>
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage
|
||||
src={session.user.image ?? undefined}
|
||||
alt={session.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{session.user.name
|
||||
.normalize("NFD")
|
||||
.split(" ", 2)
|
||||
.map((n) => n.charAt(0))
|
||||
.join("")
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1 truncate max-w-46 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium">{session.user.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "icon",
|
||||
className:
|
||||
"ms-auto size-8! group-hover:bg-accent group-hover:text-accent-foreground",
|
||||
})}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<LogInIcon className="size-3.5" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="w-full">
|
||||
<div className="flex items-center flex-col justify-center w-full md:py-10">
|
||||
<div className="w-full max-w-md">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
title: "Sign In",
|
||||
value: "sign-in",
|
||||
content: <SignIn />,
|
||||
},
|
||||
{
|
||||
title: "Sign Up",
|
||||
value: "sign-up",
|
||||
content: <SignUp />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center flex-col justify-center w-full md:py-10",
|
||||
(isLoading || session !== null) && "max-h-[calc(100vh-6.4688rem)]",
|
||||
)}
|
||||
>
|
||||
{!isLoading ? (
|
||||
<>
|
||||
{session !== null ? (
|
||||
<ElectronTransferUser session={session} />
|
||||
) : (
|
||||
<div className="w-full max-w-md">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
title: "Sign In",
|
||||
value: "sign-in",
|
||||
content: <SignIn />,
|
||||
},
|
||||
{
|
||||
title: "Sign Up",
|
||||
value: "sign-up",
|
||||
content: <SignUp />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-gray-900 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,15 +41,33 @@ const columns: ColumnDef<CommunityPlugin>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const author = row.original.author;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={row.original.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-sm hover:underline text-primary"
|
||||
>
|
||||
{row.original.name}
|
||||
</a>
|
||||
<div className="w-[220px]">
|
||||
<a
|
||||
href={row.original.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-sm hover:underline text-primary"
|
||||
>
|
||||
{row.original.name}
|
||||
</a>
|
||||
<br />
|
||||
<a
|
||||
href={`https://github.com/${author?.github}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors text-muted-foreground h-12"
|
||||
>
|
||||
<img
|
||||
src={author?.avatar}
|
||||
alt={author?.name}
|
||||
className="rounded-full w-6 h-6 border opacity-70 m-0"
|
||||
/>
|
||||
<span className="text-sm">{author?.name}</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -64,38 +82,6 @@ const columns: ColumnDef<CommunityPlugin>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "author.name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 font-semibold hover:text-foreground transition-colors"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Author
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const author = row.original.author;
|
||||
return (
|
||||
<a
|
||||
href={`https://github.com/${author.github}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
<img
|
||||
src={author.avatar}
|
||||
alt={author.name}
|
||||
className="rounded-full w-6 h-6 border opacity-70 m-0"
|
||||
/>
|
||||
<span className="text-sm">{author.name}</span>
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const communityPlugins: CommunityPlugin[] = [
|
||||
|
||||
+5
-124
@@ -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"
|
||||
>
|
||||
<mask
|
||||
id="a"
|
||||
style={{ maskType: "alpha" }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="100"
|
||||
height="100"
|
||||
>
|
||||
<circle cx="50" cy="50" r="50" className="fill-foreground" />
|
||||
</mask>
|
||||
<g mask="url(#a)">
|
||||
<circle
|
||||
cx="11"
|
||||
cy="119"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="125"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="9"
|
||||
cy="131"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-muted-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="88"
|
||||
cy="119"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="fill-foreground"
|
||||
d="M89 35h2v5h-2zM83 34l2 1-1 4h-2zM77 31l2 1-3 4-2-1zM73 27l1 1-3 4-1-2zM70 23l1 1-4 3-1-2zM68 18v2l-4 1-1-2zM68 11l1 2-5 1-1-2zM69 6v2h-5V6z"
|
||||
/>
|
||||
<circle
|
||||
cx="89"
|
||||
cy="125"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<circle
|
||||
cx="90"
|
||||
cy="131"
|
||||
r="52"
|
||||
className="fill-muted-foreground stroke-muted-foreground"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<ellipse
|
||||
cx="49.5"
|
||||
cy="119"
|
||||
rx="41.5"
|
||||
ry="51"
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 38v-9c1 1 2 4 5 6l7 30-8 2c-1-23-2-23-4-29Z"
|
||||
className="fill-foreground stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M95 123c0 31-20 57-45 57S5 154 5 123c0-27 14-50 33-56l12-2c25 0 45 26 45 58Zm-45 47c22 0 39-22 39-50S72 70 50 70s-39 22-39 50 17 50 39 50Z"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29c-4-8-11-5-14-4 2 3 5 4 9 4h5Z"
|
||||
className="fill-foreground stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M25 38c-1 6 0 14 2 18 5-7 7-13 7-18v-9c-5 1-7 5-9 9Z"
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29c-1 3-5 11-5 16m5-16c-5 1-7 5-9 9-1 6 0 14 2 18 5-7 7-13 7-18v-9Z"
|
||||
className="stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M44 18c-10 1-11 7-10 11l4-3c5-4 6-7 6-8Z"
|
||||
className="fill-foreground stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29h7l18 4c-3-6-9-14-21-7l-4 3Z"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29c4-2 12-5 18-1m-18 1h7l18 4c-3-6-9-14-21-7l-4 3Z"
|
||||
className="stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M32 29a1189 1189 0 0 1-16 19c0-17 7-18 13-19h5a14 14 0 0 1-2 0Z"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29c-5 1-7 5-9 9l-9 10c0-17 7-18 13-19h5Zm0 0c-5 2-11 3-14 10"
|
||||
className="stroke-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M41 29c9 2 13 10 15 14a25 25 0 0 1-22-14h7Z"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M34 29c3 1 11 5 15 9m-15-9h7c9 2 13 10 15 14a25 25 0 0 1-22-14Z"
|
||||
className="stroke-muted-foreground"
|
||||
/>
|
||||
<circle
|
||||
cx="91.5"
|
||||
cy="12.5"
|
||||
r="18.5"
|
||||
className="fill-foreground stroke-muted-foreground"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M306 1c9-2 18-1 27-1a324 324 0 0 1 205 72 327 327 0 0 1-27 535 334 334 0 0 1-329 18A330 330 0 0 1 293 2h4l9-1zM196 550c-15 2-31 4-43 13h30c18 0 37 3 54 8 22 6 41 18 62 26 23 8 45 11 69 11 12 0 24 0 35-3-6-4-14-3-21-4a305 305 0 0 1-73-25l-6-3-28-12c-25-9-53-14-79-11zm22-63c-29 0-59 6-85 19l-18 11-6 5c0 3 2 4 3 6l20-10c45-17 100-14 145 2 23 8 43 20 65 29 25 10 50 17 76 20 31 4 67 1 95-13 10-5 24-12 28-23-5 0-10 3-15 4a238 238 0 0 1-182-16l-3-1-6-3-3-1-38-15c-24-8-50-14-76-14zm51-448c-35 8-66 23-97 42-17 11-34 22-49 37a299 299 0 0 0-85 257c7 38 19 74 40 107 6-3 11-10 17-15a277 277 0 0 1 51-34c2-1 5-2 6-4 2-3-1-9-2-13-1-10-2-20-1-30 0-30 6-60 16-88a198 198 0 0 1 50-73c3-3 7-6 9-10-10 0-20 3-30 6-21 7-42 16-59 30-8 6-14 14-22 21-2 2-6 4-10 3-4-2-4-12-4-16-2-17 4-38 12-53 13-24 37-44 66-43 12 0 23 5 34 7l-11-10c-9-9-19-17-30-22-4-3-9-4-14-6-2-1-5-2-6-5-2-4 7-9 10-11 13-8 28-15 43-15 23-1 46 13 56 35 4 8 5 16 5 24v4l1 1v3c7-6 12-15 20-21 16-15 40-22 61-15 20 6 37 19 48 38 2 4 10 16 4 21-4 3-9 0-14-1l-10-3h-4c-12-3-26-3-38 0l-23 5v2a62 62 0 0 1 60 55c3 10 1 22-2 32-1 3-2 9-7 10-2 1-5-2-7-4l-13-15-41-31c-7-5-15-12-23-15l5 17c2 18-5 36-17 50-3 4-7 8-12 11-1 2-4 3-6 3-8-1-9-13-10-19-4-20-2-39 3-59-5 3-8 8-11 12a176 176 0 0 0-12 171c2 5 6 4 11 4h4l13 1c2 0 5 0 7 2s1 5 1 7c-2 7-2 13-2 20 9-1 11-16 15-23l10-25c2-4 4-8 4-13-1-5-5-10-7-15-5-8-11-17-14-25-3-7 0-16 8-19 4-1 9 1 12 5 6 6 10 16 14 24l35 62 8 16 5 6c2 1 5 0 7-1l17-3c6-1 16-1 21-6-7-2-15-2-22-2-4 1-9 2-13 0-6-4-10-13-14-19l-26-44-1-2-3-5-4-7c-1-1-3-4-1-6 1-2 5-2 8-2h4c12-2 24-3 37-3-1-4-4-7-3-11 2-8 13-10 19-5l8 14 10 18 18 32 1 2 1 2 7 13c-21-1-37-25-46-41l2 7a83 83 0 0 0 22 34c12 8 26 8 40 7 9 0 19-4 28-1 8 2 12 12 7 19-4 5-9 7-15 8l14 28c2 4 4 10 8 13h10c-3-9-9-15-12-24 9 1 17 5 25 8l46 18 8 3 19 8c2 1 5 3 8 2l4-6a289 289 0 0 0 39-89A296 296 0 0 0 269 39zm135 411c-17 5-35 9-53 11 2 8 8 16 12 23 2 3 3 7 7 8 3 1 8-1 11-2l-6-12c-1-2-3-4-2-6 1-4 7-4 11-4l25-6c3-1 8-2 11-1 2 1 4 2 5 5h1l2 4c4 3 9 3 13 4-2-6-7-11-10-17-1-4-3-9-6-11-5-3-16 3-21 4zm-110-40c-1 10-4 19-9 28l17 2 12 5c-2-7-6-12-9-18-4-6-7-13-11-17z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
expo: (props?: SVGProps<any>) => (
|
||||
|
||||
@@ -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
|
||||
- **Plugin extensibility** - Add custom features without starting from scratch
|
||||
- **Production ready** - Works embedded in your app or as a dedicated auth server
|
||||
@@ -29,6 +29,11 @@ Better Auth can be integrated into Electron apps to provide secure authenticatio
|
||||
better-auth @better-auth/electron
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
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).
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
<Callout>
|
||||
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).
|
||||
</Callout>
|
||||
|
||||
### Verify Email with OTP
|
||||
|
||||
@@ -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<string | null>`
|
||||
**Type:** `(ctx: GenericEndpointContext) => string | null | Promise<string | null>`
|
||||
|
||||
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`.
|
||||
|
||||
<Callout type="info">
|
||||
`request` could be undefined for non-HTTP calls.
|
||||
`ctx.request` could be undefined for non-HTTP calls.
|
||||
</Callout>
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
</APIMethod>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -407,7 +407,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
userInfo: {
|
||||
id: String(userInfo.id),
|
||||
email: userInfo.email,
|
||||
name: userInfo.name || userInfo.email,
|
||||
name: userInfo.name || "",
|
||||
image: userInfo.image,
|
||||
emailVerified: userInfo.emailVerified,
|
||||
},
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(
|
||||
filter?:
|
||||
| {
|
||||
field: string;
|
||||
operator?: "eq" | "ne" | "lt" | "lte" | "gt" | "gte" | "contains";
|
||||
operator?: WhereOperator;
|
||||
value: any;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = <O extends OrganizationOptions>(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",
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/// <reference types="electron" />
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<typeof electron>,
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
|
||||
@@ -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<boolean>;
|
||||
},
|
||||
) =>
|
||||
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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";
|
||||
},
|
||||
}),
|
||||
|
||||
+13
-16
@@ -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 = <Locales extends string[]>(
|
||||
};
|
||||
|
||||
async function detectLocale(
|
||||
request: Request | undefined,
|
||||
headers: Headers | undefined,
|
||||
ctx: AuthContext,
|
||||
ctx: GenericEndpointContext,
|
||||
): Promise<Locales[number]> {
|
||||
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 = <Locales extends string[]>(
|
||||
}
|
||||
|
||||
case "session": {
|
||||
if (ctx.session?.user) {
|
||||
const userLocale = (ctx.session.user as Record<string, unknown>)[
|
||||
opts.userLocaleField
|
||||
];
|
||||
if (ctx.context.session?.user) {
|
||||
const userLocale = (
|
||||
ctx.context.session.user as Record<string, unknown>
|
||||
)[opts.userLocaleField];
|
||||
if (
|
||||
typeof userLocale === "string" &&
|
||||
availableLocales.includes(userLocale)
|
||||
@@ -133,7 +134,7 @@ export const i18n = <Locales extends string[]>(
|
||||
|
||||
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 = <Locales extends string[]>(
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = await detectLocale(
|
||||
ctx.request,
|
||||
ctx.headers,
|
||||
ctx.context,
|
||||
);
|
||||
const locale = await detectLocale(ctx);
|
||||
|
||||
const translation = opts.translations[locale]?.[errorCode];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AuthContext,
|
||||
BetterAuthPluginRegistry,
|
||||
BetterAuthPluginRegistryIdentifier,
|
||||
GenericEndpointContext,
|
||||
UnionToIntersection,
|
||||
} from "@better-auth/core";
|
||||
|
||||
@@ -84,14 +84,15 @@ export interface I18nOptions<Locales extends string[]> {
|
||||
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> | Locales[number] | null);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, any>,
|
||||
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<string, any>,
|
||||
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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Generated
+19
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user