feat: MCP plugin (#2666)

* chore: wip

* wip

* feat: mcp plugin

* wip

* chore: fix lock file

* clean up

* schema

* docs

* chore: lint

* chore: release v1.2.9-beta.1

* blog

* chore: lint
This commit is contained in:
Bereket Engida
2025-05-23 12:44:51 -07:00
committed by GitHub
parent a12b7fc331
commit 9cc2e3d8ab
56 changed files with 4540 additions and 239 deletions

View File

@@ -0,0 +1,4 @@
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);

View File

@@ -0,0 +1,38 @@
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
import { auth } from "@/lib/auth";
const handler = withMcpAuth(auth, (req, session) => {
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };

View File

@@ -1,10 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
import { NextRequest } from "next/server";
export const { GET } = toNextJsHandler(auth);
export const POST = async (req: NextRequest) => {
const res = await auth.handler(req);
return res;
};
export const { GET, POST } = toNextJsHandler(auth);

View File

@@ -8,8 +8,8 @@ import {
oneTap,
oAuthProxy,
openAPI,
oidcProvider,
customSession,
mcp,
} from "better-auth/plugins";
import { reactInvitationEmail } from "./email/invitation";
import { LibsqlDialect } from "@libsql/kysely-libsql";
@@ -19,9 +19,9 @@ import { MysqlDialect } from "kysely";
import { createPool } from "mysql2/promise";
import { nextCookies } from "better-auth/next-js";
import { passkey } from "better-auth/plugins/passkey";
import { expo } from "@better-auth/expo";
import { stripe } from "@better-auth/stripe";
import { Stripe } from "stripe";
import Database from "better-sqlite3";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const to = process.env.TEST_EMAIL || "";
@@ -52,10 +52,7 @@ const STARTER_PRICE_ID = {
export const auth = betterAuth({
appName: "Better Auth Demo",
database: {
dialect,
type: process.env.USE_MYSQL ? "mysql" : "sqlite",
},
database: new Database("auth.db"),
emailVerification: {
async sendVerificationEmail({ user, url }) {
const res = await resend.emails.send({
@@ -117,6 +114,9 @@ export const auth = betterAuth({
},
},
plugins: [
mcp({
loginPage: "/sign-in",
}),
organization({
async sendInvitationEmail(data) {
await resend.emails.send({
@@ -160,9 +160,7 @@ export const auth = betterAuth({
multiSession(),
oAuthProxy(),
nextCookies(),
oidcProvider({
loginPage: "/sign-in",
}),
oneTap(),
customSession(async (session) => {
return {
@@ -198,7 +196,6 @@ export const auth = betterAuth({
],
},
}),
expo(),
],
trustedOrigins: ["exp://"],
});

View File

@@ -0,0 +1,219 @@
import { blogs } from "@/lib/source";
import { notFound } from "next/navigation";
import { absoluteUrl, formatDate } from "@/lib/utils";
import DatabaseTable from "@/components/mdx/database-tables";
import { cn } from "@/lib/utils";
import { Step, Steps } from "fumadocs-ui/components/steps";
import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { GenerateSecret } from "@/components/generate-secret";
import { AnimatePresence } from "@/components/ui/fade-in";
import { TypeTable } from "fumadocs-ui/components/type-table";
import { Features } from "@/components/blocks/features";
import { ForkButton } from "@/components/fork-button";
import Link from "next/link";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { File, Folder, Files } from "fumadocs-ui/components/files";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { Pre } from "fumadocs-ui/components/codeblock";
import { DocsBody } from "fumadocs-ui/page";
import { Glow } from "../_components/default-changelog";
import { IconLink } from "../_components/changelog-layout";
import { BookIcon, GitHubIcon, XIcon } from "../_components/icons";
import { DiscordLogoIcon } from "@radix-ui/react-icons";
import { StarField } from "../_components/stat-field";
import Image from "next/image";
const metaTitle = "Blogs";
const metaDescription = "Latest changes , fixes and updates.";
const ogImage = "https://better-auth.com/release-og/changelog-og.png";
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const page = blogs.getPage(slug);
if (!page) {
notFound();
}
const MDX = page.data?.body;
const toc = page.data?.toc;
const { title, description, date } = page.data;
return (
<div className="md:grid md:grid-cols-2 items-start relative">
<div className="bg-gradient-to-tr hidden md:block overflow-hidden px-12 py-24 md:py-0 -mt-[100px] md:h-dvh relative md:sticky top-0 from-transparent dark:via-stone-950/5 via-stone-100/30 to-stone-200/20 dark:to-transparent/10">
<StarField className="top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2" />
<Glow />
<div className="flex flex-col md:justify-center max-w-xl mx-auto h-full">
<div className="flex flex-col">
<div className="flex items-center cursor-pointer gap-x-2 text-xs w-full border-b border-white/20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="2.5em"
height="2.5em"
className="rotate-180"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M2 13v-2h16.172l-3.95-3.95l1.414-1.414L22 12l-6.364 6.364l-1.414-1.414l3.95-3.95z"
></path>
</svg>
</div>
<h1 className="mt-2 relative font-sans font-semibold tracking-tighter text-4xl mb-2 border-dashed">
{title}{" "}
</h1>
</div>
<p className="text-gray-600 dark:text-gray-300">{description}</p>
<div className="text-gray-600 text-sm dark:text-gray-400 flex items-center gap-x-1 text-left">
By {page.data?.author.name} | {formatDate(page.data?.date)}
</div>
<div className="mt-4">
<Image
src={page.data?.image}
alt={title}
width={804}
height={452}
className="rounded-md border bg-muted transition-colors"
/>
</div>
<hr className="h-px bg-gray-300 mt-5" />
<div className="mt-8 flex flex-wrap text-gray-600 dark:text-gray-300 gap-x-1 gap-y-3 sm:gap-x-2">
<IconLink
href="/docs"
icon={BookIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Documentation
</IconLink>
<IconLink
href="https://github.com/better-auth/better-auth"
icon={GitHubIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
GitHub
</IconLink>
<IconLink
href="https://discord.com/better-auth"
icon={DiscordLogoIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Community
</IconLink>
</div>
<p className="flex items-baseline absolute bottom-4 max-md:left-1/2 max-md:-translate-x-1/2 gap-x-2 text-[0.8125rem]/6 text-gray-500">
<IconLink href="https://x.com/better_auth" icon={XIcon} compact>
BETTER-AUTH.
</IconLink>
</p>
</div>
</div>
<div className="px-4 relative md:px-8 pb-12 md:py-12">
<div className="absolute top-0 left-0 h-full -translate-x-full w-px bg-gradient-to-b from-black/5 dark:from-white/10 via-black/3 dark:via-white/5 to-transparent"></div>
<DocsBody>
<MDX
components={{
...defaultMdxComponents,
Link: ({
className,
...props
}: React.ComponentProps<typeof Link>) => (
<Link
className={cn(
"font-medium underline underline-offset-4",
className,
)}
{...props}
/>
),
Step,
Steps,
File,
Folder,
Files,
Tab,
Tabs,
Pre: Pre,
GenerateSecret,
AnimatePresence,
TypeTable,
Features,
ForkButton,
DatabaseTable,
Accordion,
Accordions,
}}
/>
</DocsBody>
</div>
</div>
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
if (!slug) {
return {
metadataBase: new URL("https://better-auth.com/blogs"),
title: metaTitle,
description: metaDescription,
openGraph: {
title: metaTitle,
description: metaDescription,
images: [
{
url: ogImage,
},
],
url: "https://better-auth.com/blogs",
},
twitter: {
card: "summary_large_image",
title: metaTitle,
description: metaDescription,
images: [ogImage],
},
};
}
const page = blogs.getPage(slug);
if (page == null) notFound();
const baseUrl = process.env.NEXT_PUBLIC_URL || process.env.VERCEL_URL;
const url = new URL(`${baseUrl}/release-og/${slug.join("")}.png`);
const { title, description } = page.data;
return {
title,
description,
openGraph: {
title,
description,
type: "website",
url: absoluteUrl(`blogs/${slug.join("")}`),
images: [
{
url: url.toString(),
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [url.toString()],
},
};
}
export function generateStaticParams() {
return blogs.generateParams();
}

View File

@@ -0,0 +1,110 @@
import { useId } from "react";
import { Intro, IntroFooter } from "./changelog-layout";
import { StarField } from "./stat-field";
function Timeline() {
let id = useId();
return (
<div className="pointer-events-none absolute inset-0 z-50 overflow-hidden lg:right-[calc(max(2rem,50%-38rem)+40rem)] lg:min-w-[32rem] lg:overflow-visible">
<svg
className="absolute left-[max(0px,calc(50%-18.125rem))] top-0 h-full w-1.5 lg:left-full lg:ml-1 xl:left-auto xl:right-1 xl:ml-0"
aria-hidden="true"
>
<defs>
<pattern id={id} width="6" height="8" patternUnits="userSpaceOnUse">
<path
d="M0 0H6M0 8H6"
className="stroke-sky-900/10 xl:stroke-white/10 dark:stroke-white/10"
fill="none"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
</svg>
someone is
</div>
);
}
function Glow() {
let id = useId();
return (
<div className="absolute inset-0 overflow-hidden lg:right-[calc(max(2rem,50%-38rem)+40rem)] lg:min-w-[32rem]">
<svg
className="absolute -bottom-48 left-[-40%] h-[80rem] w-[180%] lg:-right-40 lg:bottom-auto lg:left-auto lg:top-[-40%] lg:h-[180%] lg:w-[80rem]"
aria-hidden="true"
>
<defs>
<radialGradient id={`${id}-desktop`} cx="100%">
<stop offset="0%" stopColor="rgba(214, 211, 209, 0.6)" />
<stop offset="53.95%" stopColor="rgba(214, 200, 209, 0.09)" />
<stop offset="100%" stopColor="rgba(10, 14, 23, 0)" />
</radialGradient>
<radialGradient id={`${id}-mobile`} cy="100%">
<stop offset="0%" stopColor="rgba(56, 189, 248, 0.3)" />
<stop offset="53.95%" stopColor="rgba(0, 71, 255, 0.09)" />
<stop offset="100%" stopColor="rgba(10, 14, 23, 0)" />
</radialGradient>
</defs>
<rect
width="100%"
height="100%"
fill={`url(#${id}-desktop)`}
className="hidden lg:block"
/>
<rect
width="100%"
height="100%"
fill={`url(#${id}-mobile)`}
className="lg:hidden"
/>
</svg>
<div className="absolute inset-x-0 bottom-0 right-0 h-px bg-white mix-blend-overlay lg:left-auto lg:top-0 lg:h-auto lg:w-px" />
</div>
);
}
function FixedSidebar({
main,
footer,
}: {
main: React.ReactNode;
footer: React.ReactNode;
}) {
return (
<div className="relative flex-none overflow-hidden px-10 lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex lg:px-0">
<Glow />
<div className="relative flex w-full lg:pointer-events-auto lg:mr-[calc(max(2rem,50%-35rem)+40rem)] lg:min-w-[32rem] lg:overflow-y-auto lg:overflow-x-hidden lg:pl-[max(4rem,calc(50%-38rem))]">
<div className="mx-auto max-w-lg lg:mx-auto lg:flex lg:max-w-4xl lg:flex-col lg:before:flex-1 lg:before:pt-6">
<div className="pb-16 pt-20 sm:pb-20 sm:pt-32 lg:py-20">
<div className="relative pr-10">
<StarField className="-right-44 top-14" />
{main}
</div>
</div>
<div className="flex flex-1 items-end justify-center pb-4 lg:justify-start lg:pb-6">
{footer}
</div>
</div>
</div>
</div>
);
}
export function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<FixedSidebar main={<Intro />} footer={<IntroFooter />} />
<div />
<div className="relative flex-auto">
<Timeline />
<main className="grid grid-cols-12 col-span-5 ml-auto space-y-20 py-20 sm:space-y-32 sm:py-32">
{children}
</main>
</div>
</>
);
}

View File

@@ -0,0 +1,135 @@
import Link from "next/link";
import { useId } from "react";
import clsx from "clsx";
import { DiscordLogoIcon } from "@radix-ui/react-icons";
function BookIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M7 3.41a1 1 0 0 0-.668-.943L2.275 1.039a.987.987 0 0 0-.877.166c-.25.192-.398.493-.398.812V12.2c0 .454.296.853.725.977l3.948 1.365A1 1 0 0 0 7 13.596V3.41ZM9 13.596a1 1 0 0 0 1.327.946l3.948-1.365c.429-.124.725-.523.725-.977V2.017c0-.32-.147-.62-.398-.812a.987.987 0 0 0-.877-.166L9.668 2.467A1 1 0 0 0 9 3.41v10.186Z" />
</svg>
);
}
function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M8 .198a8 8 0 0 0-8 8 7.999 7.999 0 0 0 5.47 7.59c.4.076.547-.172.547-.384 0-.19-.007-.694-.01-1.36-2.226.482-2.695-1.074-2.695-1.074-.364-.923-.89-1.17-.89-1.17-.725-.496.056-.486.056-.486.803.056 1.225.824 1.225.824.714 1.224 1.873.87 2.33.666.072-.518.278-.87.507-1.07-1.777-.2-3.644-.888-3.644-3.954 0-.873.31-1.586.823-2.146-.09-.202-.36-1.016.07-2.118 0 0 .67-.214 2.2.82a7.67 7.67 0 0 1 2-.27 7.67 7.67 0 0 1 2 .27c1.52-1.034 2.19-.82 2.19-.82.43 1.102.16 1.916.08 2.118.51.56.82 1.273.82 2.146 0 3.074-1.87 3.75-3.65 3.947.28.24.54.73.54 1.48 0 1.07-.01 1.93-.01 2.19 0 .21.14.46.55.38A7.972 7.972 0 0 0 16 8.199a8 8 0 0 0-8-8Z" />
</svg>
);
}
function FeedIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 3a.5.5 0 0 1 .5-.5h.5c5.523 0 10 4.477 10 10v.5a.5.5 0 0 1-.5.5h-.5a.5.5 0 0 1-.5-.5v-.5A8.5 8.5 0 0 0 3.5 4H3a.5.5 0 0 1-.5-.5V3Zm0 4.5A.5.5 0 0 1 3 7h.5A5.5 5.5 0 0 1 9 12.5v.5a.5.5 0 0 1-.5.5H8a.5.5 0 0 1-.5-.5v-.5a4 4 0 0 0-4-4H3a.5.5 0 0 1-.5-.5v-.5Zm0 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"
/>
</svg>
);
}
function XIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M9.51762 6.77491L15.3459 0H13.9648L8.90409 5.88256L4.86212 0H0.200195L6.31244 8.89547L0.200195 16H1.58139L6.92562 9.78782L11.1942 16H15.8562L9.51728 6.77491H9.51762ZM7.62588 8.97384L7.00658 8.08805L2.07905 1.03974H4.20049L8.17706 6.72795L8.79636 7.61374L13.9654 15.0075H11.844L7.62588 8.97418V8.97384Z" />
</svg>
);
}
export function Intro() {
return (
<>
<h1 className="mt-14 font-sans font-semibold tracking-tighter text-5xl">
All of the changes made will be{" "}
<span className="">available here.</span>
</h1>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
Better Auth is comprehensive authentication library for TypeScript that
provides a wide range of features to make authentication easier and more
secure.
</p>
<hr className="h-px bg-gray-300 mt-5" />
<div className="mt-8 flex flex-wrap text-gray-600 dark:text-gray-300 justify-center gap-x-1 gap-y-3 sm:gap-x-2 lg:justify-start">
<IconLink
href="/docs"
icon={BookIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Documentation
</IconLink>
<IconLink
href="https://github.com/better-auth/better-auth"
icon={GitHubIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
GitHub
</IconLink>
<IconLink
href="https://discord.com/better-auth"
icon={DiscordLogoIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Community
</IconLink>
</div>
</>
);
}
export function IntroFooter() {
return (
<p className="flex items-baseline gap-x-2 text-[0.8125rem]/6 text-gray-500">
Brought to you by{" "}
<IconLink href="#" icon={XIcon} compact>
BETTER-AUTH.
</IconLink>
</p>
);
}
export function SignUpForm() {
let id = useId();
return (
<form className="relative isolate mt-8 flex items-center pr-1">
<label htmlFor={id} className="sr-only">
Email address
</label>
<div className="absolute inset-0 -z-10 rounded-lg transition peer-focus:ring-4 peer-focus:ring-sky-300/15" />
<div className="absolute inset-0 -z-10 rounded-lg bg-white/2.5 ring-1 ring-white/15 transition peer-focus:ring-sky-300" />
</form>
);
}
export function IconLink({
children,
className,
compact = false,
icon: Icon,
...props
}: React.ComponentPropsWithoutRef<typeof Link> & {
compact?: boolean;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<Link
{...props}
className={clsx(
className,
"group relative isolate flex items-center px-2 py-0.5 text-[0.8125rem]/6 font-medium text-black/70 dark:text-white/30 transition-colors hover:text-stone-300 rounded-none",
compact ? "gap-x-2" : "gap-x-3",
)}
>
<span className="absolute inset-0 -z-10 scale-75 rounded-lg bg-white/5 opacity-0 transition group-hover:scale-100 group-hover:opacity-100" />
{Icon && <Icon className="h-4 w-4 flex-none" />}
<span className="self-baseline text-black/70 dark:text-white">
{children}
</span>
</Link>
);
}

View File

@@ -0,0 +1,254 @@
import Link from "next/link";
import { useId } from "react";
import { cn } from "@/lib/utils";
import { IconLink } from "./changelog-layout";
import { BookIcon, GitHubIcon, XIcon } from "./icons";
import { DiscordLogoIcon } from "@radix-ui/react-icons";
import { StarField } from "./stat-field";
import { betterFetch } from "@better-fetch/fetch";
import Markdown from "react-markdown";
import defaultMdxComponents from "fumadocs-ui/mdx";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/dark.css";
export const dynamic = "force-static";
const ChangelogPage = async () => {
const { data: releases } = await betterFetch<
{
id: number;
tag_name: string;
name: string;
body: string;
html_url: string;
prerelease: boolean;
published_at: string;
}[]
>("https://api.github.com/repos/better-auth/better-auth/releases");
const messages = releases
?.filter((release) => !release.prerelease)
.map((release) => ({
tag: release.tag_name,
title: release.name,
content: getContent(release.body),
date: new Date(release.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
}),
url: release.html_url,
}));
function getContent(content: string) {
const lines = content.split("\n");
const newContext = lines.map((line) => {
if (line.startsWith("- ")) {
const mainContent = line.split(";")[0];
const context = line.split(";")[2];
const mentions = context
?.split(" ")
.filter((word) => word.startsWith("@"))
.map((mention) => {
const username = mention.replace("@", "");
const avatarUrl = `https://github.com/${username}.png`;
return `[![${mention}](${avatarUrl})](https://github.com/${username})`;
});
if (!mentions) {
return line;
}
// Remove &nbsp
return mainContent.replace(/&nbsp/g, "") + " " + mentions.join(" ");
}
return line;
});
return newContext.join("\n");
}
return (
<div className="grid md:grid-cols-2 items-start">
<div className="bg-gradient-to-tr overflow-hidden px-12 py-24 md:py-0 -mt-[100px] md:h-dvh relative md:sticky top-0 from-transparent dark:via-stone-950/5 via-stone-100/30 to-stone-200/20 dark:to-transparent/10">
<StarField className="top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2" />
<Glow />
<div className="flex flex-col md:justify-center max-w-xl mx-auto h-full">
<h1 className="mt-14 font-sans font-semibold tracking-tighter text-5xl">
All of the changes made will be{" "}
<span className="">available here.</span>
</h1>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-300">
Better Auth is comprehensive authentication library for TypeScript
that provides a wide range of features to make authentication easier
and more secure.
</p>
<hr className="h-px bg-gray-300 mt-5" />
<div className="mt-8 flex flex-wrap text-gray-600 dark:text-gray-300 gap-x-1 gap-y-3 sm:gap-x-2">
<IconLink
href="/docs"
icon={BookIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Documentation
</IconLink>
<IconLink
href="https://github.com/better-auth/better-auth"
icon={GitHubIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
GitHub
</IconLink>
<IconLink
href="https://discord.com/better-auth"
icon={DiscordLogoIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Community
</IconLink>
</div>
<p className="flex items-baseline absolute bottom-4 max-md:left-1/2 max-md:-translate-x-1/2 gap-x-2 text-[0.8125rem]/6 text-gray-500">
<IconLink href="https://x.com/better_auth" icon={XIcon} compact>
BETTER-AUTH.
</IconLink>
</p>
</div>
</div>
<div className="px-4 relative md:px-8 pb-12 md:py-12">
<div className="absolute top-0 left-0 mb-2 w-2 h-full -translate-x-full bg-gradient-to-b from-black/10 dark:from-white/20 from-50% to-50% to-transparent bg-[length:100%_5px] bg-repeat-y"></div>
<div className="max-w-2xl relative">
<Markdown
rehypePlugins={[[rehypeHighlight]]}
components={{
pre: (props) => (
<defaultMdxComponents.pre
{...props}
className={cn(props.className, " ml-10 my-2")}
/>
),
h2: (props) => (
<h2
id={props.children?.toString().split("date=")[0].trim()} // Extract ID dynamically
className="text-2xl relative mb-6 font-bold flex-col flex justify-center tracking-tighter before:content-[''] before:block before:h-[65px] before:-mt-[10px]"
{...props}
>
<div className="sticky top-0 left-[-9.9rem] hidden md:block">
<time className="flex gap-2 items-center text-gray-500 dark:text-white/80 text-sm md:absolute md:left-[-9.8rem] font-normal tracking-normal">
{props.children?.toString().includes("date=") &&
props.children?.toString().split("date=")[1]}
<div className="w-4 h-[1px] dark:bg-white/60 bg-black" />
</time>
</div>
<Link
href={
props.children
?.toString()
.split("date=")[0]
.trim()
.endsWith(".00")
? `/changelogs/${props.children
?.toString()
.split("date=")[0]
.trim()}`
: `#${props.children
?.toString()
.split("date=")[0]
.trim()}`
}
>
{props.children?.toString().split("date=")[0].trim()}
</Link>
<p className="text-xs font-normal opacity-60 hidden">
{props.children?.toString().includes("date=") &&
props.children?.toString().split("date=")[1]}
</p>
</h2>
),
h3: (props) => (
<h3 className="text-xl tracking-tighter py-1" {...props}>
{props.children?.toString()?.trim()}
<hr className="h-[1px] my-1 mb-2 bg-input" />
</h3>
),
p: (props) => <p className="my-0 ml-10 text-sm" {...props} />,
ul: (props) => (
<ul
className="list-disc ml-10 text-[0.855rem] text-gray-600 dark:text-gray-300"
{...props}
/>
),
li: (props) => <li className="my-1" {...props} />,
a: ({ className, ...props }: any) => (
<Link
target="_blank"
className={cn("font-medium underline", className)}
{...props}
/>
),
strong: (props) => (
<strong className="font-semibold" {...props} />
),
img: (props) => (
<img
className="rounded-full w-6 h-6 border opacity-70 inline-block"
{...props}
style={{ maxWidth: "100%" }}
/>
),
}}
>
{messages
?.map((message) => {
return `
## ${message.title} date=${message.date}
${message.content}
`;
})
.join("\n")}
</Markdown>
</div>
</div>
</div>
);
};
export default ChangelogPage;
export function Glow() {
let id = useId();
return (
<div className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-tr from-transparent dark:via-stone-950/5 via-stone-100/30 to-stone-200/20 dark:to-transparent/10">
<svg
className="absolute -bottom-48 left-[-40%] h-[80rem] w-[180%] lg:-right-40 lg:bottom-auto lg:left-auto lg:top-[-40%] lg:h-[180%] lg:w-[80rem]"
aria-hidden="true"
>
<defs>
<radialGradient id={`${id}-desktop`} cx="100%">
<stop offset="0%" stopColor="rgba(41, 37, 36, 0.4)" />
<stop offset="53.95%" stopColor="rgba(28, 25, 23, 0.09)" />
<stop offset="100%" stopColor="rgba(0, 0, 0, 0)" />
</radialGradient>
<radialGradient id={`${id}-mobile`} cy="100%">
<stop offset="0%" stopColor="rgba(41, 37, 36, 0.3)" />
<stop offset="53.95%" stopColor="rgba(28, 25, 23, 0.09)" />
<stop offset="100%" stopColor="rgba(0, 0, 0, 0)" />
</radialGradient>
</defs>
<rect
width="100%"
height="100%"
fill={`url(#${id}-desktop)`}
className="hidden lg:block"
/>
<rect
width="100%"
height="100%"
fill={`url(#${id}-mobile)`}
className="lg:hidden"
/>
</svg>
<div className="absolute inset-x-0 bottom-0 right-0 h-px dark:bg-white/5 mix-blend-overlay lg:left-auto lg:top-0 lg:h-auto lg:w-px" />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from "@/lib/utils";
const dateFormatter = new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
export function FormattedDate({
date,
...props
}: React.ComponentPropsWithoutRef<"time"> & { date: string | Date }) {
date = typeof date === "string" ? new Date(date) : date;
return (
<time
className={cn(props.className, "")}
dateTime={date.toISOString()}
{...props}
>
{dateFormatter.format(date)}
</time>
);
}

View File

@@ -0,0 +1,35 @@
export function BookIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M7 3.41a1 1 0 0 0-.668-.943L2.275 1.039a.987.987 0 0 0-.877.166c-.25.192-.398.493-.398.812V12.2c0 .454.296.853.725.977l3.948 1.365A1 1 0 0 0 7 13.596V3.41ZM9 13.596a1 1 0 0 0 1.327.946l3.948-1.365c.429-.124.725-.523.725-.977V2.017c0-.32-.147-.62-.398-.812a.987.987 0 0 0-.877-.166L9.668 2.467A1 1 0 0 0 9 3.41v10.186Z" />
</svg>
);
}
export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M8 .198a8 8 0 0 0-8 8 7.999 7.999 0 0 0 5.47 7.59c.4.076.547-.172.547-.384 0-.19-.007-.694-.01-1.36-2.226.482-2.695-1.074-2.695-1.074-.364-.923-.89-1.17-.89-1.17-.725-.496.056-.486.056-.486.803.056 1.225.824 1.225.824.714 1.224 1.873.87 2.33.666.072-.518.278-.87.507-1.07-1.777-.2-3.644-.888-3.644-3.954 0-.873.31-1.586.823-2.146-.09-.202-.36-1.016.07-2.118 0 0 .67-.214 2.2.82a7.67 7.67 0 0 1 2-.27 7.67 7.67 0 0 1 2 .27c1.52-1.034 2.19-.82 2.19-.82.43 1.102.16 1.916.08 2.118.51.56.82 1.273.82 2.146 0 3.074-1.87 3.75-3.65 3.947.28.24.54.73.54 1.48 0 1.07-.01 1.93-.01 2.19 0 .21.14.46.55.38A7.972 7.972 0 0 0 16 8.199a8 8 0 0 0-8-8Z" />
</svg>
);
}
export function FeedIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2.5 3a.5.5 0 0 1 .5-.5h.5c5.523 0 10 4.477 10 10v.5a.5.5 0 0 1-.5.5h-.5a.5.5 0 0 1-.5-.5v-.5A8.5 8.5 0 0 0 3.5 4H3a.5.5 0 0 1-.5-.5V3Zm0 4.5A.5.5 0 0 1 3 7h.5A5.5 5.5 0 0 1 9 12.5v.5a.5.5 0 0 1-.5.5H8a.5.5 0 0 1-.5-.5v-.5a4 4 0 0 0-4-4H3a.5.5 0 0 1-.5-.5v-.5Zm0 5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"
/>
</svg>
);
}
export function XIcon(props: React.ComponentPropsWithoutRef<"svg">) {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" fill="currentColor" {...props}>
<path d="M9.51762 6.77491L15.3459 0H13.9648L8.90409 5.88256L4.86212 0H0.200195L6.31244 8.89547L0.200195 16H1.58139L6.92562 9.78782L11.1942 16H15.8562L9.51728 6.77491H9.51762ZM7.62588 8.97384L7.00658 8.08805L2.07905 1.03974H4.20049L8.17706 6.72795L8.79636 7.61374L13.9654 15.0075H11.844L7.62588 8.97418V8.97384Z" />
</svg>
);
}

View File

@@ -0,0 +1,219 @@
"use client";
import { useEffect, useId, useRef } from "react";
import clsx from "clsx";
import { animate, Segment } from "motion/react";
type Star = [x: number, y: number, dim?: boolean, blur?: boolean];
const stars: Array<Star> = [
[4, 4, true, true],
[4, 44, true],
[36, 22],
[50, 146, true, true],
[64, 43, true, true],
[76, 30, true],
[101, 116],
[140, 36, true],
[149, 134],
[162, 74, true],
[171, 96, true, true],
[210, 56, true, true],
[235, 90],
[275, 82, true, true],
[306, 6],
[307, 64, true, true],
[380, 68, true],
[380, 108, true, true],
[391, 148, true, true],
[405, 18, true],
[412, 86, true, true],
[426, 210, true, true],
[427, 56, true, true],
[538, 138],
[563, 88, true, true],
[611, 154, true, true],
[637, 150],
[651, 146, true],
[682, 70, true, true],
[683, 128],
[781, 82, true, true],
[785, 158, true],
[832, 146, true, true],
[852, 89],
];
const constellations: Array<Array<Star>> = [
[
[247, 103],
[261, 86],
[307, 104],
[357, 36],
],
[
[586, 120],
[516, 100],
[491, 62],
[440, 107],
[477, 180],
[516, 100],
],
[
[733, 100],
[803, 120],
[879, 113],
[823, 164],
[803, 120],
],
];
function Star({
blurId,
point: [cx, cy, dim, blur],
}: {
blurId: string;
point: Star;
}) {
let groupRef = useRef<React.ElementRef<"g">>(null);
let ref = useRef<React.ElementRef<"circle">>(null);
useEffect(() => {
if (!groupRef.current || !ref.current) {
return;
}
let delay = Math.random() * 2;
let animations = [
animate(groupRef.current, { opacity: 1 }, { duration: 4, delay }),
animate(
ref.current,
{
opacity: dim ? [0.2, 0.5] : [1, 0.6],
scale: dim ? [1, 1.2] : [1.2, 1],
},
{
duration: 10,
delay,
},
),
];
return () => {
for (let animation of animations) {
animation.cancel();
}
};
}, [dim]);
return (
<g ref={groupRef} className="opacity-0">
<circle
ref={ref}
cx={cx}
cy={cy}
r={1}
style={{
transformOrigin: `${cx / 16}rem ${cy / 16}rem`,
opacity: dim ? 0.2 : 1,
transform: `scale(${dim ? 1 : 1.2})`,
}}
filter={blur ? `url(#${blurId})` : undefined}
/>
</g>
);
}
function Constellation({
points,
blurId,
}: {
points: Array<Star>;
blurId: string;
}) {
let ref = useRef<React.ElementRef<"path">>(null);
let uniquePoints = points.filter(
(point, pointIndex) =>
points.findIndex((p) => String(p) === String(point)) === pointIndex,
);
let isFilled = uniquePoints.length !== points.length;
useEffect(() => {
if (!ref.current) {
return;
}
let sequence: Array<Segment> = [
[
ref.current,
{ strokeDashoffset: 0, visibility: "visible" },
{ duration: 5, delay: Math.random() * 3 + 2 },
],
];
if (isFilled) {
sequence.push([
ref.current,
{ fill: "rgb(255 255 255 / 0.02)" },
{ duration: 1 },
]);
}
let animation = animate(sequence);
return () => {
animation.cancel();
};
}, [isFilled]);
return (
<>
<path
ref={ref}
stroke="white"
strokeOpacity="0.2"
strokeDasharray={1}
strokeDashoffset={1}
pathLength={1}
fill="transparent"
d={`M ${points.join("L")}`}
className="invisible"
/>
{uniquePoints.map((point, pointIndex) => (
<Star key={pointIndex} point={point} blurId={blurId} />
))}
</>
);
}
export function StarField({ className }: { className?: string }) {
let blurId = useId();
return (
<svg
viewBox="0 0 881 211"
fill="white"
aria-hidden="true"
className={clsx(
"pointer-events-none absolute w-[55.0625rem] origin-top-right rotate-[30deg] overflow-visible opacity-70",
className,
)}
>
<defs>
<filter id={blurId}>
<feGaussianBlur in="SourceGraphic" stdDeviation=".5" />
</filter>
</defs>
{constellations.map((points, constellationIndex) => (
<Constellation
key={constellationIndex}
points={points}
blurId={blurId}
/>
))}
{stars.map((point, pointIndex) => (
<Star key={pointIndex} point={point} blurId={blurId} />
))}
</svg>
);
}

18
docs/app/blog/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog - Better Auth",
description: "Latest updates, articles, and insights about Better Auth",
};
interface BlogLayoutProps {
children: React.ReactNode;
}
export default function BlogLayout({ children }: BlogLayoutProps) {
return (
<div className="relative flex min-h-screen flex-col">
<main className="flex-1">{children}</main>
</div>
);
}

18
docs/app/blogs/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Blog - Better Auth",
description: "Latest updates, articles, and insights about Better Auth",
};
interface BlogLayoutProps {
children: React.ReactNode;
}
export default function BlogLayout({ children }: BlogLayoutProps) {
return (
<div className="relative flex min-h-screen flex-col">
<main className="flex-1">{children}</main>
</div>
);
}

104
docs/app/blogs/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { formatBlogDate } from "@/lib/blog";
import Link from "next/link";
import { blogs } from "@/lib/source";
import { IconLink } from "../blog/_components/changelog-layout";
import { GitHubIcon, BookIcon, XIcon } from "../blog/_components/icons";
import { Glow } from "../blog/_components/default-changelog";
import { StarField } from "../blog/_components/stat-field";
import { DiscordLogoIcon } from "@radix-ui/react-icons";
export default async function BlogPage() {
const posts = blogs.getPages();
return (
<div className="md:grid md:grid-cols-2 items-start">
<div className="bg-gradient-to-tr hidden md:block overflow-hidden px-12 py-24 md:py-0 -mt-[100px] md:h-dvh relative md:sticky top-0 from-transparent dark:via-stone-950/5 via-stone-100/30 to-stone-200/20 dark:to-transparent/10">
<StarField className="top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2" />
<Glow />
<div className="flex flex-col md:justify-center max-w-xl mx-auto h-full">
<h1 className="mt-14 font-sans font-semibold tracking-tighter text-5xl">
Blogs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-300">
Latest updates, articles, and insights about Better Auth
</p>
<hr className="h-px bg-gray-300 mt-5" />
<div className="mt-8 flex flex-wrap text-gray-600 dark:text-gray-300 gap-x-1 gap-y-3 sm:gap-x-2">
<IconLink
href="/docs"
icon={BookIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Documentation
</IconLink>
<IconLink
href="https://github.com/better-auth/better-auth"
icon={GitHubIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
GitHub
</IconLink>
<IconLink
href="https://discord.com/better-auth"
icon={DiscordLogoIcon}
className="flex-none text-gray-600 dark:text-gray-300"
>
Community
</IconLink>
</div>
<p className="flex items-baseline absolute bottom-4 max-md:left-1/2 max-md:-translate-x-1/2 gap-x-2 text-[0.8125rem]/6 text-gray-500">
<IconLink href="https://x.com/better_auth" icon={XIcon} compact>
BETTER-AUTH.
</IconLink>
</p>
</div>
</div>
<div className="py-6 lg:py-10 px-6">
<div className="flex flex-col gap-2">
{posts.map((post) => (
<article
key={post.slugs.join("/")}
className="group relative flex flex-col space-y-2"
>
<div className="flex gap-2">
{/* {post.data?.image && (
<Image
src={post.data.image}
alt={post.data.title}
width={402}
height={252}
className="rounded-md border bg-muted w-4/12 transition-colors"
/>
)} */}
<div className="flex flex-col gap-2 border-b border-dashed pb-2">
<p className="text-xs opacity-50">
{formatBlogDate(post.data.date)}
</p>
<h2 className="text-2xl font-bold">{post.data?.title}</h2>
<p className="text-muted-foreground">
{post.data?.description.substring(0, 100)}...
</p>
</div>
</div>
<p className="text-xs opacity-50">
{post.data.structuredData.contents[0].content.substring(0, 250)}
...
</p>
<Link href={`/blog/${post.slugs.join("/")}`}>
<p className="text-xs underline">Read More</p>
</Link>
<Link
href={`/blog/${post.slugs.join("/")}`}
className="absolute inset-0"
>
<span className="sr-only">View Article</span>
</Link>
</article>
))}
</div>
</div>
</div>
);
}

View File

@@ -115,7 +115,10 @@ export const navMenu = [
name: "docs",
path: "/docs",
},
{
name: "blog",
path: "/blogs",
},
{
name: "changelogs",
path: "/changelogs",

View File

@@ -1270,6 +1270,38 @@ C0.7,239.6,62.1,0.5,62.2,0.4c0,0,54,13.8,119.9,30.8S302.1,62,302.2,62c0.2,0,0.2,
href: "/docs/plugins/api-key",
icon: () => <KeyRound className="size-4" />,
},
{
title: "MCP",
icon: () => (
<svg
width="1.2em"
height="1.2em"
viewBox="0 0 156 173"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 80.9117L73.8822 13.0294C83.255 3.65685 98.451 3.65685 107.823 13.0294C117.196 22.4019 117.196 37.598 107.823 46.9706L56.5581 98.2359"
stroke="currentColor"
stroke-width="12"
stroke-linecap="round"
/>
<path
d="M57.2652 97.5289L107.823 46.9706C117.196 37.598 132.392 37.598 141.765 46.9706L142.118 47.324C151.491 56.6966 151.491 71.8926 142.118 81.2651L80.7248 142.659C77.6006 145.783 77.6006 150.848 80.7248 153.972L93.331 166.579"
stroke="currentColor"
stroke-width="12"
stroke-linecap="round"
/>
<path
d="M90.853 29.9999L40.6482 80.2045C31.2756 89.5768 31.2756 104.773 40.6482 114.146C50.0208 123.518 65.2167 123.518 74.5893 114.146L124.794 63.941"
stroke="currentColor"
stroke-width="12"
stroke-linecap="round"
/>
</svg>
),
href: "/docs/plugins/mcp",
},
{
title: "Organization",
icon: () => <Users2 className="w-4 h-4" />,

View File

@@ -0,0 +1,178 @@
---
title: Authenicating MCP servers
description: A deep dive into how to implement MCP auth with Better Auth & Vercel MCP adapter
date: 2025-05-19
image: /images/blogs/mcp-auth.png
author:
name: Bereket Engida
avatar: /avatars/beka.jpg
twitter: imbereket
tags:
- mcp
- vercel
- ai
- nextjs
- neon
---
## Introduction
[MCP](https://modelcontextprotocol.io) is an open protocol that standardizes how applications provide context to LLMs. It provides a standardized way to connect AI models to different data sources and tools. It's been sometime since the MCP spec by anthropic become a standard for building LLM based apps.
The protocol covers both client and server implementations. When you make a server for MCP clients to connect to, one of the requirements is to have a proper way to authenticate and authorize them. The MCP spec recommends using [OAuth 2.0](https://oauth.net/2/) for this purpose with some additional requirements.
In this article, we'll see how Better Auth MCP plugin integrates with your MCP server to authenticate and authorize MCP clients.
## How Better Auth MCP Plugin Works
The Better Auth MCP plugin implements the OAuth 2.0 authorization flow with some MCP-specific modifications. Let's break down how it works:
### 1. OAuth Discovery Endpoint
First, the plugin helps you expose an OAuth discovery endpoint at `/.well-known/oauth-authorization-server` that provides metadata about the authorization server:
```ts title=".well-known/oauth-authorization-server/route.ts"
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);
```
This endpoint returns standard OAuth metadata including:
- Authorization endpoint (`/mcp/authorize`)
- Token endpoint (`/mcp/token`)
- Supported scopes (`openid`, `profile`, `email`, `offline_access`)
- Supported response types (`code`)
- PKCE challenge methods (`S256`)
### 2. Authorization Flow
When an MCP client (like Claude Desktop) wants to connect to your server, it initiates the OAuth flow:
1. The client makes a request to your authorization endpoint with:
- `client_id`: Unique identifier for the client
- `redirect_uri`: Where to send the authorization code
- `response_type`: Always "code" for MCP
- `code_challenge`: PKCE challenge for security
- `scope`: Requested permissions (e.g. "openid profile")
2. If the client isn't registered yet (no `client_id`), it first needs to register using the dynamic client registration endpoint:
```ts
// Client sends POST request to /mcp/register
{
"redirect_uris": ["https://client.example.com/callback"],
"client_name": "My MCP Client",
"logo_uri": "https://client.example.com/logo.png",
"token_endpoint_auth_method": "client_secret_basic",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "openid profile"
}
// Server validates and responds with:
{
"client_id": "generated-client-id",
"client_secret": "generated-client-secret",
"client_id_issued_at": 1683900000,
"client_secret_expires_at": 0
}
```
3. Once registered (or if already registered), if the user isn't logged in, they're redirected to your login page:
```ts
await ctx.setSignedCookie(
'oidc_login_prompt',
JSON.stringify(ctx.query),
ctx.context.secret,
{
maxAge: 600,
path: '/',
sameSite: 'lax',
}
);
throw ctx.redirect(`${options.loginPage}?${queryFromURL}`);
```
4. After login, the plugin validates:
- Client ID exists and is enabled
- Redirect URI matches registered URIs
- Requested scopes are valid
- PKCE challenge is present (if required)
5. If everything is valid, it generates an authorization code:
```ts
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1000;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
```
### 3. Protecting Your MCP Server
The plugin provides a `withMcpAuth` middleware to protect your MCP server routes:
```ts
import { withMcpAuth } from "better-auth/plugins";
const handler = withMcpAuth(auth, (req, session) => {
// session contains the access token with scopes and user ID
return createMcpHandler(
(server) => {
// Define your MCP tools here
server.tool("echo", "Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: message }],
};
}
);
},
// ... rest of your MCP config
)(req);
});
```
or you can use `auth.api.getMcpSession` to get the session from the request headers.
```ts
const session = await auth.api.getMcpSession({
headers: req.headers
});
```
Make sure to handle the unauthenticated case properly by returning a 401 status code.
```ts
if (!session) {
return new Response(null, {
status: 401,
headers: {
"WWW-Authenticate": "Bearer"
}
});
}
```
### 4. Configuration Options
The plugin is highly configurable through the `mcp()` function:
```ts
mcp({
loginPage: "/sign-in", // Where to redirect for auth
oidcConfig: {
codeExpiresIn: 600, // Auth code expiry in seconds
accessTokenExpiresIn: 3600, // Access token expiry
refreshTokenExpiresIn: 604800, // Refresh token expiry
scopes: ["openid", "profile", "email"], // Supported scopes
requirePKCE: true, // Require PKCE security
}
})
```
## Conclusion
The Better Auth MCP plugin provides a secure and flexible way to authenticate and authorize MCP clients. It handles the OAuth flow, client registration, and session management, allowing you to focus on building your MCP server.

View File

@@ -0,0 +1,10 @@
{
"title": "Blog",
"description": "Latest updates, articles, and insights about Better Auth",
"items": [
{
"title": "Latest",
"items": []
}
]
}

View File

@@ -0,0 +1,224 @@
---
title: MCP
description: MCP provider plugin for Better Auth
---
`OAuth` `MCP`
The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It handles authentication and makes it easy to issue and manage access tokens for MCP applications.
## Installation
<Steps>
<Step>
### Add the Plugin
Add the MCP plugin to your auth configuration and specify the login page path.
```ts title="auth.ts"
import { betterAuth } from "better-auth";
import { mcp } from "better-auth/plugins";
export const auth = betterAuth({
plugins: [
mcp({
loginPage: "/sign-in" // path to your login page
})
]
});
```
<Callout>
This doesn't have a client plugin, so you don't need to make any changes to your authClient.
</Callout>
</Step>
<Step>
### Generate Schema
Run the migration or generate the schema to add the necessary fields and tables to the database.
<Tabs items={["migrate", "generate"]}>
<Tab value="migrate">
```bash
npx @better-auth/cli migrate
```
</Tab>
<Tab value="generate">
```bash
npx @better-auth/cli generate
```
</Tab>
</Tabs>
The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details.
</Step>
</Steps>
## Usage
### OAuth Discovery Metadata
Add a route to expose OAuth metadata for MCP clients:
```ts title=".well-known/oauth-authorization-server/route.ts"
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);
```
### MCP Session Handling
You can use the helper function `withMcpAuth` to get the session and handle unauthenticated calls automatically.
```ts title="api/[transport]/route.ts"
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = withMcpAuth(auth, (req, session) => {
// session contains the access token record with scopes and user ID
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };
```
You can also use `auth.api.getMCPSession` to get the session using the access token sent from the MCP client:
```ts title="api/[transport]/route.ts"
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = async (req: Request) => {
// session contains the access token record with scopes and user ID
const session = await auth.api.getMCPSession({
headers: req.headers
})
if(!session){
//this is important and you must return 401
return new Response(null, {
status: 401
})
}
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
}
export { handler as GET, handler as POST, handler as DELETE };
```
## Configuration
The MCP plugin accepts the following configuration options:
<TypeTable
type={{
loginPage: {
description: "Path to the login page where users will be redirected for authentication",
type: "string",
required: true
},
oidcConfig: {
description: "Optional OIDC configuration options",
type: "object",
required: false
}
}}
/>
### OIDC Configuration
The plugin supports additional OIDC configuration options through the `oidcConfig` parameter:
<TypeTable
type={{
codeExpiresIn: {
description: "Expiration time for authorization codes in seconds",
type: "number",
default: 600
},
accessTokenExpiresIn: {
description: "Expiration time for access tokens in seconds",
type: "number",
default: 3600
},
refreshTokenExpiresIn: {
description: "Expiration time for refresh tokens in seconds",
type: "number",
default: 604800
},
defaultScope: {
description: "Default scope for OAuth requests",
type: "string",
default: "openid"
},
scopes: {
description: "Additional scopes to support",
type: "string[]",
default: ["openid", "profile", "email", "offline_access"]
}
}}
/>
## Schema
The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](#schema) section for details.

76
docs/lib/blog.ts Normal file
View File

@@ -0,0 +1,76 @@
import { readFile, readdir } from "fs/promises";
import matter from "gray-matter";
import { join } from "path";
import { cache } from "react";
export interface BlogPost {
_id: string;
slug: string;
title: string;
description?: string;
date: string;
content: string;
image?: string;
author?: {
name: string;
avatar?: string;
twitter?: string;
};
tags?: string[];
}
const BLOGS_PATH = join(process.cwd(), "docs/content/blogs");
export const getBlogPost = cache(
async (slug: string): Promise<BlogPost | null> => {
try {
const filePath = join(BLOGS_PATH, `${slug}.mdx`);
const source = await readFile(filePath, "utf-8");
const { data, content } = matter(source);
return {
_id: slug,
slug,
content,
title: data.title,
description: data.description,
date: data.date,
image: data.image,
author: data.author,
tags: data.tags,
};
} catch (error) {
return null;
}
},
);
export const getAllBlogPosts = cache(async (): Promise<BlogPost[]> => {
try {
const files = await readdir(BLOGS_PATH);
const mdxFiles = files.filter((file) => file.endsWith(".mdx"));
const posts = await Promise.all(
mdxFiles.map(async (file) => {
const slug = file.replace(/\.mdx$/, "");
const post = await getBlogPost(slug);
return post;
}),
);
return posts
.filter((post): post is BlogPost => post !== null)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} catch (error) {
return [];
}
});
export function formatBlogDate(date: Date) {
let d = new Date(date);
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}

View File

@@ -1,4 +1,4 @@
import { changelogCollection, docs } from "@/.source";
import { changelogCollection, docs, blogCollection } from "@/.source";
import { loader } from "fumadocs-core/source";
import { createMDXSource } from "fumadocs-mdx";
@@ -11,3 +11,8 @@ export const changelogs = loader({
baseUrl: "/changelogs",
source: createMDXSource(changelogCollection),
});
export const blogs = loader({
baseUrl: "/blogs",
source: createMDXSource(blogCollection),
});

BIN
docs/public/avatar/beka.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -20,6 +20,23 @@ export const changelogCollection = defineCollections({
}),
});
export const blogCollection = defineCollections({
type: "doc",
dir: "./content/blogs",
schema: z.object({
title: z.string(),
description: z.string(),
date: z.date(),
author: z.object({
name: z.string(),
avatar: z.string(),
twitter: z.string(),
}),
image: z.string(),
tags: z.array(z.string()),
}),
});
export default defineConfig({
mdxOptions: {
remarkPlugins: [

41
examples/nextjs-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,93 @@
# Better Auth - MCP Demo
This is example repo on how to setup Better Auth for MCP Auth using Nextjs and Vercel MCP adapter.
## Usage
First, add the plugin to your auth instance
```ts
// auth.ts
import { betterAuth } from "better-auth";
import { mcp } from "better-auth/plugins";
export cosnt auth = betterAuth({
plugins: [
mcp({
loginPage: "/sign-in" // path to a page where users login
})
]
})
```
Make sure to `generate` or `migrate` required schema using the cli:
```bash
npx @better-auth/cli generate ## or (migrate)
```
Add a route to expose oauth metadata
```ts
// .well-known/oauth-authroization-server/route.ts
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);
```
Mount the handlers if you haven't
```ts
// api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
```
Use `auth.api.getMCPSession` to get the session using the access token sent from the MCP client
```ts
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = withMcpAuth(auth, (req, sesssion) => {
//session => This isnt a typical Better Auth session - instead, it returns the access token record along with the scopes and user ID.
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };
```
And that's it!!

View File

@@ -0,0 +1,4 @@
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
import { auth } from "../../../lib/auth";
export const GET = oAuthDiscoveryMetadata(auth);

View File

@@ -0,0 +1,38 @@
import { auth } from "@/lib/auth";
import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = withMcpAuth(auth, (req, sesssion) => {
return createMcpHandler(
(server) => {
server.tool(
"echo",
"Echo a message",
{ message: z.string() },
async ({ message }) => {
return {
content: [{ type: "text", text: `Tool echo: ${message}` }],
};
},
);
},
{
capabilities: {
tools: {
echo: {
description: "Echo a message",
},
},
},
},
{
redisUrl: process.env.REDIS_URL,
basePath: "/api",
verboseLogs: true,
maxDuration: 60,
},
)(req);
});
export { handler as GET, handler as POST, handler as DELETE };

View File

@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { authClient } from "@/lib/authClient";
import { useState } from "react";
export default function Login() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const session = authClient.useSession();
return (
<div className="flex flex-col gap-4 p-4">
{JSON.stringify({ session })}
<h1 className="font-bold text-2xl">Sign Up</h1>
<div>
<p>Name</p>
<input
type="text"
value={name}
className="border"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<p>Email</p>
<input
type="text"
value={email}
className="border"
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<p>Password</p>
<input
type="password"
value={password}
className="border"
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button
className="bg-white text-black"
onClick={async () => {
const { error } = await authClient.signUp.email({
email,
name,
password,
});
}}
>
<p>Sign Up</p>
</button>
<button
className="border-white text-whtie"
onClick={async () => {
const { error } = await authClient.signOut();
}}
>
<p>Logout</p>
</button>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { betterAuth } from "better-auth";
import { mcp } from "better-auth/plugins";
import Database from "better-sqlite3";
export const auth = betterAuth({
database: new Database("./auth.db"),
baseURL: "http://localhost:3000",
plugins: [
mcp({
loginPage: "/login",
}),
],
emailAndPassword: {
enabled: true,
},
});

View File

@@ -0,0 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -0,0 +1,29 @@
{
"name": "nextjs-mcp",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.12",
"@vercel/mcp-adapter": "^0.4.1",
"better-auth": "workspace:^",
"better-sqlite3": "^11.6.0",
"next": "15.3.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.24.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"app/.well-known/oauth-authorization-server/route.ts"
],
"exclude": ["node_modules"]
}

View File

@@ -8,7 +8,6 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "Bundler"
"strict": true
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "better-auth",
"version": "1.2.8",
"version": "1.2.9-beta.1",
"description": "The most comprehensive authentication library for TypeScript.",
"type": "module",
"repository": {

View File

@@ -22,3 +22,4 @@ export * from "./captcha";
export * from "./api-key";
export * from "./haveibeenpwned";
export * from "./one-time-token";
export * from "./mcp";

View File

@@ -0,0 +1,232 @@
import { APIError } from "better-call";
import type { GenericEndpointContext } from "../../types";
import { getSessionFromCtx } from "../../api";
import type {
AuthorizationQuery,
Client,
OIDCOptions,
} from "../oidc-provider/types";
import { generateRandomString } from "../../crypto";
function redirectErrorURL(url: string, error: string, description: string) {
return `${
url.includes("?") ? "&" : "?"
}error=${error}&error_description=${description}`;
}
export async function authorizeMCPOAuth(
ctx: GenericEndpointContext,
options: OIDCOptions,
) {
ctx.setHeader("Access-Control-Allow-Origin", "*");
ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
ctx.setHeader("Access-Control-Max-Age", "86400");
const opts = {
codeExpiresIn: 600,
defaultScope: "openid",
...options,
scopes: [
"openid",
"profile",
"email",
"offline_access",
...(options?.scopes || []),
],
};
if (!ctx.request) {
throw new APIError("UNAUTHORIZED", {
error_description: "request not found",
error: "invalid_request",
});
}
const session = await getSessionFromCtx(ctx);
if (!session) {
/**
* If the user is not logged in, we need to redirect them to the
* login page.
*/
await ctx.setSignedCookie(
"oidc_login_prompt",
JSON.stringify(ctx.query),
ctx.context.secret,
{
maxAge: 600,
path: "/",
sameSite: "lax",
},
);
const queryFromURL = ctx.request.url?.split("?")[1];
throw ctx.redirect(`${options.loginPage}?${queryFromURL}`);
}
const query = ctx.query as AuthorizationQuery;
console.log(query);
if (!query.client_id) {
throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`);
}
if (!query.response_type) {
throw ctx.redirect(
redirectErrorURL(
`${ctx.context.baseURL}/error`,
"invalid_request",
"response_type is required",
),
);
}
const client = await ctx.context.adapter
.findOne<Record<string, any>>({
model: "oauthApplication",
where: [
{
field: "clientId",
value: ctx.query.client_id,
},
],
})
.then((res) => {
if (!res) {
return null;
}
return {
...res,
redirectURLs: res.redirectURLs.split(","),
metadata: res.metadata ? JSON.parse(res.metadata) : {},
} as Client;
});
console.log(client);
if (!client) {
throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`);
}
const redirectURI = client.redirectURLs.find(
(url) => url === ctx.query.redirect_uri,
);
if (!redirectURI || !query.redirect_uri) {
/**
* show UI error here warning the user that the redirect URI is invalid
*/
throw new APIError("BAD_REQUEST", {
message: "Invalid redirect URI",
});
}
if (client.disabled) {
throw ctx.redirect(`${ctx.context.baseURL}/error?error=client_disabled`);
}
if (query.response_type !== "code") {
throw ctx.redirect(
`${ctx.context.baseURL}/error?error=unsupported_response_type`,
);
}
const requestScope =
query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" ");
const invalidScopes = requestScope.filter((scope) => {
const isInvalid =
!opts.scopes.includes(scope) ||
(scope === "offline_access" && query.prompt !== "consent");
return isInvalid;
});
if (invalidScopes.length) {
throw ctx.redirect(
redirectErrorURL(
query.redirect_uri,
"invalid_scope",
`The following scopes are invalid: ${invalidScopes.join(", ")}`,
),
);
}
if (
(!query.code_challenge || !query.code_challenge_method) &&
options.requirePKCE
) {
throw ctx.redirect(
redirectErrorURL(
query.redirect_uri,
"invalid_request",
"pkce is required",
),
);
}
if (!query.code_challenge_method) {
query.code_challenge_method = "plain";
}
if (
![
"s256",
options.allowPlainCodeChallengeMethod ? "plain" : "s256",
].includes(query.code_challenge_method?.toLowerCase() || "")
) {
throw ctx.redirect(
redirectErrorURL(
query.redirect_uri,
"invalid_request",
"invalid code_challenge method",
),
);
}
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
const codeExpiresInMs = opts.codeExpiresIn * 1000;
const expiresAt = new Date(Date.now() + codeExpiresInMs);
try {
/**
* Save the code in the database
*/
await ctx.context.internalAdapter.createVerificationValue(
{
value: JSON.stringify({
clientId: client.clientId,
redirectURI: query.redirect_uri,
scope: requestScope,
userId: session.user.id,
authTime: session.session.createdAt.getTime(),
/**
* If the prompt is set to `consent`, then we need
* to require the user to consent to the scopes.
*
* This means the code now needs to be treated as a
* consent request.
*
* once the user consents, teh code will be updated
* with the actual code. This is to prevent the
* client from using the code before the user
* consents.
*/
requireConsent: query.prompt === "consent",
state: query.prompt === "consent" ? query.state : null,
codeChallenge: query.code_challenge,
codeChallengeMethod: query.code_challenge_method,
nonce: query.nonce,
}),
identifier: code,
expiresAt,
},
ctx,
);
} catch (e) {
throw ctx.redirect(
redirectErrorURL(
query.redirect_uri,
"server_error",
"An error occurred while processing the request",
),
);
}
const redirectURIWithCode = new URL(redirectURI);
redirectURIWithCode.searchParams.set("code", code);
redirectURIWithCode.searchParams.set("state", ctx.query.state);
if (query.prompt !== "consent") {
throw ctx.redirect(redirectURIWithCode.toString());
}
throw ctx.redirect(redirectURIWithCode.toString());
}

View File

@@ -0,0 +1,928 @@
import { z } from "zod";
import {
createAuthEndpoint,
createAuthMiddleware,
type BetterAuthPlugin,
} from "..";
import {
oidcProvider,
type Client,
type CodeVerificationValue,
type OAuthAccessToken,
type OIDCMetadata,
type OIDCOptions,
} from "../oidc-provider";
import { APIError, getSessionFromCtx } from "../../api";
import { base64 } from "@better-auth/utils/base64";
import { generateRandomString } from "../../crypto";
import { createHash } from "@better-auth/utils/hash";
import { subtle } from "@better-auth/utils";
import { SignJWT } from "jose";
import type { GenericEndpointContext } from "../../types";
import { parseSetCookieHeader } from "../../cookies";
import { schema } from "../oidc-provider/schema";
import { authorizeMCPOAuth } from "./authorize";
interface MCPOptions {
loginPage: string;
oidcConfig?: OIDCOptions;
}
export const getMCPProviderMetadata = (
ctx: GenericEndpointContext,
options?: OIDCOptions,
): OIDCMetadata => {
const issuer = ctx.context.options.baseURL as string;
const baseURL = ctx.context.baseURL;
if (!issuer || !baseURL) {
throw new APIError("INTERNAL_SERVER_ERROR", {
error: "invalid_issuer",
error_description:
"issuer or baseURL is not set. If you're the app developer, please make sure to set the `baseURL` in your auth config.",
});
}
return {
issuer,
authorization_endpoint: `${baseURL}/mcp/authorize`,
token_endpoint: `${baseURL}/mcp/token`,
userinfo_endpoint: `${baseURL}/mcp/userinfo`,
jwks_uri: `${baseURL}/mcp/jwks`,
registration_endpoint: `${baseURL}/mcp/register`,
scopes_supported: ["openid", "profile", "email", "offline_access"],
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code"],
acr_values_supported: [
"urn:mace:incommon:iap:silver",
"urn:mace:incommon:iap:bronze",
],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256", "none"],
token_endpoint_auth_methods_supported: [
"client_secret_basic",
"client_secret_post",
],
code_challenge_methods_supported: ["S256"],
claims_supported: [
"sub",
"iss",
"aud",
"exp",
"nbf",
"iat",
"jti",
"email",
"email_verified",
"name",
],
...options?.metadata,
};
};
export const mcp = (options: MCPOptions) => {
const opts = {
codeExpiresIn: 600,
defaultScope: "openid",
accessTokenExpiresIn: 3600,
refreshTokenExpiresIn: 604800,
allowPlainCodeChallengeMethod: true,
...options.oidcConfig,
loginPage: options.loginPage,
scopes: [
"openid",
"profile",
"email",
"offline_access",
...(options.oidcConfig?.scopes || []),
],
};
const modelName = {
oauthClient: "oauthApplication",
oauthAccessToken: "oauthAccessToken",
oauthConsent: "oauthConsent",
};
const provider = oidcProvider(opts);
return {
id: "mcp",
hooks: {
after: [
{
matcher() {
return true;
},
handler: createAuthMiddleware(async (ctx) => {
const cookie = await ctx.getSignedCookie(
"oidc_login_prompt",
ctx.context.secret,
);
const cookieName = ctx.context.authCookies.sessionToken.name;
const parsedSetCookieHeader = parseSetCookieHeader(
ctx.context.responseHeaders?.get("set-cookie") || "",
);
const hasSessionToken = parsedSetCookieHeader.has(cookieName);
if (!cookie || !hasSessionToken) {
return;
}
ctx.setCookie("oidc_login_prompt", "", {
maxAge: 0,
});
const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value;
const sessionToken = sessionCookie?.split(".")[0];
if (!sessionToken) {
return;
}
const session =
await ctx.context.internalAdapter.findSession(sessionToken);
if (!session) {
return;
}
ctx.query = JSON.parse(cookie);
ctx.query!.prompt = "consent";
ctx.context.session = session;
const response = await authorizeMCPOAuth(ctx, opts).catch((e) => {
if (e instanceof APIError) {
if (e.statusCode === 302) {
return ctx.json({
redirect: true,
//@ts-expect-error
url: e.headers.get("location"),
});
}
}
throw e;
});
return response;
}),
},
],
},
endpoints: {
getMcpOAuthConfig: createAuthEndpoint(
"/.well-known/oauth-authorization-server",
{
method: "GET",
metadata: {
client: false,
},
},
async (c) => {
try {
const metadata = getMCPProviderMetadata(c, options);
return c.json(metadata);
} catch (e) {
console.log(e);
return c.json(null);
}
},
),
mcpOAuthAuthroize: createAuthEndpoint(
"/mcp/authorize",
{
method: "GET",
query: z.record(z.string(), z.any()),
metadata: {
openapi: {
description: "Authorize an OAuth2 request using MCP",
responses: {
"200": {
description: "Authorization response generated successfully",
content: {
"application/json": {
schema: {
type: "object",
additionalProperties: true,
description:
"Authorization response, contents depend on the authorize function implementation",
},
},
},
},
},
},
},
},
async (ctx) => {
return authorizeMCPOAuth(ctx, opts);
},
),
mcpOAuthToken: createAuthEndpoint(
"/mcp/token",
{
method: "POST",
body: z.record(z.any()),
metadata: {
isAction: false,
},
},
async (ctx) => {
//cors
ctx.setHeader("Access-Control-Allow-Origin", "*");
ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
ctx.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
ctx.setHeader("Access-Control-Max-Age", "86400");
let { body } = ctx;
if (!body) {
throw ctx.error("BAD_REQUEST", {
error_description: "request body not found",
error: "invalid_request",
});
}
if (body instanceof FormData) {
body = Object.fromEntries(body.entries());
}
if (!(body instanceof Object)) {
throw new APIError("BAD_REQUEST", {
error_description: "request body is not an object",
error: "invalid_request",
});
}
let { client_id, client_secret } = body;
const authorization =
ctx.request?.headers.get("authorization") || null;
if (
authorization &&
!client_id &&
!client_secret &&
authorization.startsWith("Basic ")
) {
try {
const encoded = authorization.replace("Basic ", "");
const decoded = new TextDecoder().decode(base64.decode(encoded));
if (!decoded.includes(":")) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client",
});
}
const [id, secret] = decoded.split(":");
if (!id || !secret) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client",
});
}
client_id = id;
client_secret = secret;
} catch (error) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid authorization header format",
error: "invalid_client",
});
}
}
const {
grant_type,
code,
redirect_uri,
refresh_token,
code_verifier,
} = body;
if (grant_type === "refresh_token") {
if (!refresh_token) {
throw new APIError("BAD_REQUEST", {
error_description: "refresh_token is required",
error: "invalid_request",
});
}
const token = await ctx.context.adapter.findOne<OAuthAccessToken>({
model: "oauthAccessToken",
where: [
{
field: "refreshToken",
value: refresh_token.toString(),
},
],
});
if (!token) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid refresh token",
error: "invalid_grant",
});
}
if (token.clientId !== client_id?.toString()) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client",
});
}
if (token.refreshTokenExpiresAt < new Date()) {
throw new APIError("UNAUTHORIZED", {
error_description: "refresh token expired",
error: "invalid_grant",
});
}
const accessToken = generateRandomString(32, "a-z", "A-Z");
const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
const accessTokenExpiresAt = new Date(
Date.now() + opts.accessTokenExpiresIn * 1000,
);
const refreshTokenExpiresAt = new Date(
Date.now() + opts.refreshTokenExpiresIn * 1000,
);
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken,
refreshToken: newRefreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
clientId: client_id.toString(),
userId: token.userId,
scopes: token.scopes,
createdAt: new Date(),
updatedAt: new Date(),
},
});
return ctx.json({
access_token: accessToken,
token_type: "bearer",
expires_in: opts.accessTokenExpiresIn,
refresh_token: newRefreshToken,
scope: token.scopes,
});
}
if (!code) {
throw new APIError("BAD_REQUEST", {
error_description: "code is required",
error: "invalid_request",
});
}
if (opts.requirePKCE && !code_verifier) {
throw new APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request",
});
}
/**
* We need to check if the code is valid before we can proceed
* with the rest of the request.
*/
const verificationValue =
await ctx.context.internalAdapter.findVerificationValue(
code.toString(),
);
if (!verificationValue) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid code",
error: "invalid_grant",
});
}
if (verificationValue.expiresAt < new Date()) {
throw new APIError("UNAUTHORIZED", {
error_description: "code expired",
error: "invalid_grant",
});
}
await ctx.context.internalAdapter.deleteVerificationValue(
verificationValue.id,
);
if (!client_id || !client_secret) {
throw new APIError("UNAUTHORIZED", {
error_description: "client_id and client_secret are required",
error: "invalid_client",
});
}
if (!grant_type) {
throw new APIError("BAD_REQUEST", {
error_description: "grant_type is required",
error: "invalid_request",
});
}
if (grant_type !== "authorization_code") {
throw new APIError("BAD_REQUEST", {
error_description: "grant_type must be 'authorization_code'",
error: "unsupported_grant_type",
});
}
if (!redirect_uri) {
throw new APIError("BAD_REQUEST", {
error_description: "redirect_uri is required",
error: "invalid_request",
});
}
const client = await ctx.context.adapter
.findOne<Record<string, any>>({
model: modelName.oauthClient,
where: [{ field: "clientId", value: client_id.toString() }],
})
.then((res) => {
if (!res) {
return null;
}
return {
...res,
redirectURLs: res.redirectURLs.split(","),
metadata: res.metadata ? JSON.parse(res.metadata) : {},
} as Client;
});
if (!client) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client",
});
}
if (client.disabled) {
throw new APIError("UNAUTHORIZED", {
error_description: "client is disabled",
error: "invalid_client",
});
}
const isValidSecret =
client.clientSecret === client_secret.toString();
if (!isValidSecret) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_secret",
error: "invalid_client",
});
}
const value = JSON.parse(
verificationValue.value,
) as CodeVerificationValue;
if (value.clientId !== client_id.toString()) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid client_id",
error: "invalid_client",
});
}
if (value.redirectURI !== redirect_uri.toString()) {
throw new APIError("UNAUTHORIZED", {
error_description: "invalid redirect_uri",
error: "invalid_client",
});
}
if (value.codeChallenge && !code_verifier) {
throw new APIError("BAD_REQUEST", {
error_description: "code verifier is missing",
error: "invalid_request",
});
}
const challenge =
value.codeChallengeMethod === "plain"
? code_verifier
: await createHash("SHA-256", "base64urlnopad").digest(
code_verifier,
);
if (challenge !== value.codeChallenge) {
throw new APIError("UNAUTHORIZED", {
error_description: "code verification failed",
error: "invalid_request",
});
}
const requestedScopes = value.scope;
await ctx.context.internalAdapter.deleteVerificationValue(
verificationValue.id,
);
const accessToken = generateRandomString(32, "a-z", "A-Z");
const refreshToken = generateRandomString(32, "A-Z", "a-z");
const accessTokenExpiresAt = new Date(
Date.now() + opts.accessTokenExpiresIn * 1000,
);
const refreshTokenExpiresAt = new Date(
Date.now() + opts.refreshTokenExpiresIn * 1000,
);
await ctx.context.adapter.create({
model: modelName.oauthAccessToken,
data: {
accessToken,
refreshToken,
accessTokenExpiresAt,
refreshTokenExpiresAt,
clientId: client_id.toString(),
userId: value.userId,
scopes: requestedScopes.join(" "),
createdAt: new Date(),
updatedAt: new Date(),
},
});
const user = await ctx.context.internalAdapter.findUserById(
value.userId,
);
if (!user) {
throw new APIError("UNAUTHORIZED", {
error_description: "user not found",
error: "invalid_grant",
});
}
let secretKey = {
alg: "HS256",
key: await subtle.generateKey(
{
name: "HMAC",
hash: "SHA-256",
},
true,
["sign", "verify"],
),
};
const profile = {
given_name: user.name.split(" ")[0],
family_name: user.name.split(" ")[1],
name: user.name,
profile: user.image,
updated_at: user.updatedAt.toISOString(),
};
const email = {
email: user.email,
email_verified: user.emailVerified,
};
const userClaims = {
...(requestedScopes.includes("profile") ? profile : {}),
...(requestedScopes.includes("email") ? email : {}),
};
const additionalUserClaims = opts.getAdditionalUserInfoClaim
? opts.getAdditionalUserInfoClaim(user, requestedScopes)
: {};
const idToken = await new SignJWT({
sub: user.id,
aud: client_id.toString(),
iat: Date.now(),
auth_time: ctx.context.session?.session.createdAt.getTime(),
nonce: value.nonce,
acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata
...userClaims,
...additionalUserClaims,
})
.setProtectedHeader({ alg: secretKey.alg })
.setIssuedAt()
.setExpirationTime(
Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn,
)
.sign(secretKey.key);
return ctx.json(
{
access_token: accessToken,
token_type: "Bearer",
expires_in: opts.accessTokenExpiresIn,
refresh_token: requestedScopes.includes("offline_access")
? refreshToken
: undefined,
scope: requestedScopes.join(" "),
id_token: requestedScopes.includes("openid")
? idToken
: undefined,
},
{
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
},
);
},
),
registerMcpClient: createAuthEndpoint(
"/mcp/register",
{
method: "POST",
body: z.object({
redirect_uris: z.array(z.string()),
token_endpoint_auth_method: z
.enum(["none", "client_secret_basic", "client_secret_post"])
.default("client_secret_basic")
.optional(),
grant_types: z
.array(
z.enum([
"authorization_code",
"implicit",
"password",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:jwt-bearer",
"urn:ietf:params:oauth:grant-type:saml2-bearer",
]),
)
.default(["authorization_code"])
.optional(),
response_types: z
.array(z.enum(["code", "token"]))
.default(["code"])
.optional(),
client_name: z.string().optional(),
client_uri: z.string().optional(),
logo_uri: z.string().optional(),
scope: z.string().optional(),
contacts: z.array(z.string()).optional(),
tos_uri: z.string().optional(),
policy_uri: z.string().optional(),
jwks_uri: z.string().optional(),
jwks: z.record(z.any()).optional(),
metadata: z.record(z.any()).optional(),
software_id: z.string().optional(),
software_version: z.string().optional(),
software_statement: z.string().optional(),
}),
metadata: {
openapi: {
description: "Register an OAuth2 application",
responses: {
"200": {
description: "OAuth2 application registered successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the OAuth2 application",
},
icon: {
type: "string",
nullable: true,
description: "Icon URL for the application",
},
metadata: {
type: "object",
additionalProperties: true,
nullable: true,
description:
"Additional metadata for the application",
},
clientId: {
type: "string",
description: "Unique identifier for the client",
},
clientSecret: {
type: "string",
description: "Secret key for the client",
},
redirectURLs: {
type: "array",
items: { type: "string", format: "uri" },
description: "List of allowed redirect URLs",
},
type: {
type: "string",
description: "Type of the client",
enum: ["web"],
},
authenticationScheme: {
type: "string",
description:
"Authentication scheme used by the client",
enum: ["client_secret"],
},
disabled: {
type: "boolean",
description: "Whether the client is disabled",
enum: [false],
},
userId: {
type: "string",
nullable: true,
description:
"ID of the user who registered the client, null if registered anonymously",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp",
},
},
required: [
"name",
"clientId",
"clientSecret",
"redirectURLs",
"type",
"authenticationScheme",
"disabled",
"createdAt",
"updatedAt",
],
},
},
},
},
},
},
},
},
async (ctx) => {
const body = ctx.body;
const session = await getSessionFromCtx(ctx);
ctx.setHeader("Access-Control-Allow-Origin", "*");
ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
ctx.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
ctx.setHeader("Access-Control-Max-Age", "86400");
ctx.headers?.set("Access-Control-Max-Age", "86400");
if (
(!body.grant_types ||
body.grant_types.includes("authorization_code") ||
body.grant_types.includes("implicit")) &&
(!body.redirect_uris || body.redirect_uris.length === 0)
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_redirect_uri",
error_description:
"Redirect URIs are required for authorization_code and implicit grant types",
});
}
if (body.grant_types && body.response_types) {
if (
body.grant_types.includes("authorization_code") &&
!body.response_types.includes("code")
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"When 'authorization_code' grant type is used, 'code' response type must be included",
});
}
if (
body.grant_types.includes("implicit") &&
!body.response_types.includes("token")
) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client_metadata",
error_description:
"When 'implicit' grant type is used, 'token' response type must be included",
});
}
}
const clientId =
opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z");
const clientSecret =
opts.generateClientSecret?.() ||
generateRandomString(32, "a-z", "A-Z");
await ctx.context.adapter.create({
model: modelName.oauthClient,
data: {
name: body.client_name,
icon: body.logo_uri,
metadata: body.metadata ? JSON.stringify(body.metadata) : null,
clientId: clientId,
clientSecret: clientSecret,
redirectURLs: body.redirect_uris.join(","),
type: "web",
authenticationScheme:
body.token_endpoint_auth_method || "client_secret_basic",
disabled: false,
userId: session?.session.userId,
createdAt: new Date(),
updatedAt: new Date(),
},
});
return ctx.json(
{
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_secret_expires_at: 0, // 0 means it doesn't expire
redirect_uris: body.redirect_uris,
token_endpoint_auth_method:
body.token_endpoint_auth_method || "client_secret_basic",
grant_types: body.grant_types || ["authorization_code"],
response_types: body.response_types || ["code"],
client_name: body.client_name,
client_uri: body.client_uri,
logo_uri: body.logo_uri,
scope: body.scope,
contacts: body.contacts,
tos_uri: body.tos_uri,
policy_uri: body.policy_uri,
jwks_uri: body.jwks_uri,
jwks: body.jwks,
software_id: body.software_id,
software_version: body.software_version,
software_statement: body.software_statement,
metadata: body.metadata,
},
{
status: 201,
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
},
);
},
),
getMcpSession: createAuthEndpoint(
"/mcp/get-session",
{
method: "GET",
requireHeaders: true,
},
async (c) => {
const accessToken = c.headers
?.get("Authorization")
?.replace("Bearer ", "");
if (!accessToken) {
c.headers?.set("WWW-Authenticate", "Bearer");
return c.json(null);
}
const accessTokenData =
await c.context.adapter.findOne<OAuthAccessToken>({
model: modelName.oauthAccessToken,
where: [
{
field: "accessToken",
value: accessToken,
},
],
});
if (!accessTokenData) {
return c.json(null);
}
return c.json(accessTokenData);
},
),
},
schema,
} satisfies BetterAuthPlugin;
};
export const withMcpAuth = <
Auth extends {
api: {
getMcpSession: (...args: any) => Promise<OAuthAccessToken | null>;
};
},
>(
auth: Auth,
handler: (
req: Request,
sesssion: OAuthAccessToken,
) => Response | Promise<Response>,
) => {
return async (req: Request) => {
const session = await auth.api.getMcpSession({
headers: req.headers,
});
const wwwAuthenticateValue =
"Bearer resource_metadata=http://localhost:3000/api/auth/.well-known/oauth-authorization-server";
if (!session) {
return Response.json(
{
jsonrpc: "2.0",
error: {
code: -32000,
message: "Unauthorized: Authentication required",
"www-authenticate": wwwAuthenticateValue,
},
id: null,
},
{
status: 401,
headers: {
"WWW-Authenticate": wwwAuthenticateValue,
},
},
);
}
return handler(req, session);
};
};
export const oAuthDiscoveryMetadata = <
Auth extends {
api: {
getMcpOAuthConfig: (...args: any) => any;
};
},
>(
auth: Auth,
) => {
return async (request: Request) => {
const res = await auth.api.getMcpOAuthConfig();
return new Response(JSON.stringify(res), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
};
};

View File

@@ -23,7 +23,7 @@ import { parseSetCookieHeader } from "../../cookies";
import { createHash } from "@better-auth/utils/hash";
import { base64 } from "@better-auth/utils/base64";
const getMetadata = (
export const getMetadata = (
ctx: GenericEndpointContext,
options?: OIDCOptions,
): OIDCMetadata => {
@@ -50,6 +50,7 @@ const getMetadata = (
"client_secret_basic",
"client_secret_post",
],
code_challenge_methods_supported: ["S256"],
claims_supported: [
"sub",
"iss",

View File

@@ -513,4 +513,12 @@ export interface OIDCMetadata {
* ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"]
*/
claims_supported: string[];
/**
* Supported code challenge methods.
*
* only `S256` is supported.
*
* @default ["S256"]
*/
code_challenge_methods_supported: ["S256"];
}

View File

@@ -1,6 +1,6 @@
{
"name": "@better-auth/cli",
"version": "1.2.8",
"version": "1.2.9-beta.1",
"description": "The CLI for Better Auth",
"module": "dist/index.mjs",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@better-auth/expo",
"version": "1.2.8",
"version": "1.2.9-beta.1",
"description": "",
"main": "dist/index.cjs",
"module": "dist/index.mjs",

View File

@@ -1,7 +1,7 @@
{
"name": "@better-auth/stripe",
"author": "Bereket Engida",
"version": "1.2.8",
"version": "1.2.9-beta.1",
"main": "dist/index.cjs",
"license": "MIT",
"keywords": [

1338
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff