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
@@ -0,0 +1,4 @@
|
|||||||
|
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
|
||||||
|
import { auth } from "../../../lib/auth";
|
||||||
|
|
||||||
|
export const GET = oAuthDiscoveryMetadata(auth);
|
||||||
38
demo/nextjs/app/api/[transport]/route.ts
Normal 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 };
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { toNextJsHandler } from "better-auth/next-js";
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
|
|
||||||
export const { GET } = toNextJsHandler(auth);
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
|
|
||||||
export const POST = async (req: NextRequest) => {
|
|
||||||
const res = await auth.handler(req);
|
|
||||||
return res;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
oneTap,
|
oneTap,
|
||||||
oAuthProxy,
|
oAuthProxy,
|
||||||
openAPI,
|
openAPI,
|
||||||
oidcProvider,
|
|
||||||
customSession,
|
customSession,
|
||||||
|
mcp,
|
||||||
} from "better-auth/plugins";
|
} from "better-auth/plugins";
|
||||||
import { reactInvitationEmail } from "./email/invitation";
|
import { reactInvitationEmail } from "./email/invitation";
|
||||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||||
@@ -19,9 +19,9 @@ import { MysqlDialect } from "kysely";
|
|||||||
import { createPool } from "mysql2/promise";
|
import { createPool } from "mysql2/promise";
|
||||||
import { nextCookies } from "better-auth/next-js";
|
import { nextCookies } from "better-auth/next-js";
|
||||||
import { passkey } from "better-auth/plugins/passkey";
|
import { passkey } from "better-auth/plugins/passkey";
|
||||||
import { expo } from "@better-auth/expo";
|
|
||||||
import { stripe } from "@better-auth/stripe";
|
import { stripe } from "@better-auth/stripe";
|
||||||
import { Stripe } from "stripe";
|
import { Stripe } from "stripe";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||||
const to = process.env.TEST_EMAIL || "";
|
const to = process.env.TEST_EMAIL || "";
|
||||||
@@ -52,10 +52,7 @@ const STARTER_PRICE_ID = {
|
|||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
appName: "Better Auth Demo",
|
appName: "Better Auth Demo",
|
||||||
database: {
|
database: new Database("auth.db"),
|
||||||
dialect,
|
|
||||||
type: process.env.USE_MYSQL ? "mysql" : "sqlite",
|
|
||||||
},
|
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
async sendVerificationEmail({ user, url }) {
|
async sendVerificationEmail({ user, url }) {
|
||||||
const res = await resend.emails.send({
|
const res = await resend.emails.send({
|
||||||
@@ -117,6 +114,9 @@ export const auth = betterAuth({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
mcp({
|
||||||
|
loginPage: "/sign-in",
|
||||||
|
}),
|
||||||
organization({
|
organization({
|
||||||
async sendInvitationEmail(data) {
|
async sendInvitationEmail(data) {
|
||||||
await resend.emails.send({
|
await resend.emails.send({
|
||||||
@@ -160,9 +160,7 @@ export const auth = betterAuth({
|
|||||||
multiSession(),
|
multiSession(),
|
||||||
oAuthProxy(),
|
oAuthProxy(),
|
||||||
nextCookies(),
|
nextCookies(),
|
||||||
oidcProvider({
|
|
||||||
loginPage: "/sign-in",
|
|
||||||
}),
|
|
||||||
oneTap(),
|
oneTap(),
|
||||||
customSession(async (session) => {
|
customSession(async (session) => {
|
||||||
return {
|
return {
|
||||||
@@ -198,7 +196,6 @@ export const auth = betterAuth({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
expo(),
|
|
||||||
],
|
],
|
||||||
trustedOrigins: ["exp://"],
|
trustedOrigins: ["exp://"],
|
||||||
});
|
});
|
||||||
|
|||||||
219
docs/app/blog/[[...slug]]/page.tsx
Normal 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();
|
||||||
|
}
|
||||||
110
docs/app/blog/_components/_layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
docs/app/blog/_components/changelog-layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
docs/app/blog/_components/default-changelog.tsx
Normal 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 `[](https://github.com/${username})`;
|
||||||
|
});
|
||||||
|
if (!mentions) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
// Remove  
|
||||||
|
return mainContent.replace(/ /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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
docs/app/blog/_components/fmt-dates.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
docs/app/blog/_components/icons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
docs/app/blog/_components/stat-field.tsx
Normal 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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -115,7 +115,10 @@ export const navMenu = [
|
|||||||
name: "docs",
|
name: "docs",
|
||||||
path: "/docs",
|
path: "/docs",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "blog",
|
||||||
|
path: "/blogs",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "changelogs",
|
name: "changelogs",
|
||||||
path: "/changelogs",
|
path: "/changelogs",
|
||||||
|
|||||||
@@ -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",
|
href: "/docs/plugins/api-key",
|
||||||
icon: () => <KeyRound className="size-4" />,
|
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",
|
title: "Organization",
|
||||||
icon: () => <Users2 className="w-4 h-4" />,
|
icon: () => <Users2 className="w-4 h-4" />,
|
||||||
|
|||||||
178
docs/content/blogs/mcp-auth.mdx
Normal 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.
|
||||||
10
docs/content/blogs/meta.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"title": "Blog",
|
||||||
|
"description": "Latest updates, articles, and insights about Better Auth",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"title": "Latest",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
224
docs/content/docs/plugins/mcp.mdx
Normal 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
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { changelogCollection, docs } from "@/.source";
|
import { changelogCollection, docs, blogCollection } from "@/.source";
|
||||||
import { loader } from "fumadocs-core/source";
|
import { loader } from "fumadocs-core/source";
|
||||||
import { createMDXSource } from "fumadocs-mdx";
|
import { createMDXSource } from "fumadocs-mdx";
|
||||||
|
|
||||||
@@ -11,3 +11,8 @@ export const changelogs = loader({
|
|||||||
baseUrl: "/changelogs",
|
baseUrl: "/changelogs",
|
||||||
source: createMDXSource(changelogCollection),
|
source: createMDXSource(changelogCollection),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const blogs = loader({
|
||||||
|
baseUrl: "/blogs",
|
||||||
|
source: createMDXSource(blogCollection),
|
||||||
|
});
|
||||||
|
|||||||
BIN
docs/public/avatar/beka.jpg
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
docs/public/images/blogs/better auth (1).png
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -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({
|
export default defineConfig({
|
||||||
mdxOptions: {
|
mdxOptions: {
|
||||||
remarkPlugins: [
|
remarkPlugins: [
|
||||||
|
|||||||
41
examples/nextjs-mcp/.gitignore
vendored
Normal 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
|
||||||
93
examples/nextjs-mcp/README.md
Normal 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 isn’t 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!!
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { oAuthDiscoveryMetadata } from "better-auth/plugins";
|
||||||
|
import { auth } from "../../../lib/auth";
|
||||||
|
|
||||||
|
export const GET = oAuthDiscoveryMetadata(auth);
|
||||||
38
examples/nextjs-mcp/app/api/[transport]/route.ts
Normal 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 };
|
||||||
4
examples/nextjs-mcp/app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
BIN
examples/nextjs-mcp/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
26
examples/nextjs-mcp/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
34
examples/nextjs-mcp/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
examples/nextjs-mcp/app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
examples/nextjs-mcp/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
examples/nextjs-mcp/lib/auth.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
examples/nextjs-mcp/lib/authClient.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient();
|
||||||
7
examples/nextjs-mcp/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
29
examples/nextjs-mcp/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
examples/nextjs-mcp/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
examples/nextjs-mcp/public/file.svg
Normal 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 |
1
examples/nextjs-mcp/public/globe.svg
Normal 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 |
1
examples/nextjs-mcp/public/next.svg
Normal 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 |
1
examples/nextjs-mcp/public/vercel.svg
Normal 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 |
1
examples/nextjs-mcp/public/window.svg
Normal 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 |
33
examples/nextjs-mcp/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true
|
||||||
"moduleResolution": "Bundler"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "better-auth",
|
"name": "better-auth",
|
||||||
"version": "1.2.8",
|
"version": "1.2.9-beta.1",
|
||||||
"description": "The most comprehensive authentication library for TypeScript.",
|
"description": "The most comprehensive authentication library for TypeScript.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export * from "./captcha";
|
|||||||
export * from "./api-key";
|
export * from "./api-key";
|
||||||
export * from "./haveibeenpwned";
|
export * from "./haveibeenpwned";
|
||||||
export * from "./one-time-token";
|
export * from "./one-time-token";
|
||||||
|
export * from "./mcp";
|
||||||
|
|||||||
232
packages/better-auth/src/plugins/mcp/authorize.ts
Normal 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());
|
||||||
|
}
|
||||||
928
packages/better-auth/src/plugins/mcp/index.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -23,7 +23,7 @@ import { parseSetCookieHeader } from "../../cookies";
|
|||||||
import { createHash } from "@better-auth/utils/hash";
|
import { createHash } from "@better-auth/utils/hash";
|
||||||
import { base64 } from "@better-auth/utils/base64";
|
import { base64 } from "@better-auth/utils/base64";
|
||||||
|
|
||||||
const getMetadata = (
|
export const getMetadata = (
|
||||||
ctx: GenericEndpointContext,
|
ctx: GenericEndpointContext,
|
||||||
options?: OIDCOptions,
|
options?: OIDCOptions,
|
||||||
): OIDCMetadata => {
|
): OIDCMetadata => {
|
||||||
@@ -50,6 +50,7 @@ const getMetadata = (
|
|||||||
"client_secret_basic",
|
"client_secret_basic",
|
||||||
"client_secret_post",
|
"client_secret_post",
|
||||||
],
|
],
|
||||||
|
code_challenge_methods_supported: ["S256"],
|
||||||
claims_supported: [
|
claims_supported: [
|
||||||
"sub",
|
"sub",
|
||||||
"iss",
|
"iss",
|
||||||
|
|||||||
@@ -513,4 +513,12 @@ export interface OIDCMetadata {
|
|||||||
* ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"]
|
* ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"]
|
||||||
*/
|
*/
|
||||||
claims_supported: string[];
|
claims_supported: string[];
|
||||||
|
/**
|
||||||
|
* Supported code challenge methods.
|
||||||
|
*
|
||||||
|
* only `S256` is supported.
|
||||||
|
*
|
||||||
|
* @default ["S256"]
|
||||||
|
*/
|
||||||
|
code_challenge_methods_supported: ["S256"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@better-auth/cli",
|
"name": "@better-auth/cli",
|
||||||
"version": "1.2.8",
|
"version": "1.2.9-beta.1",
|
||||||
"description": "The CLI for Better Auth",
|
"description": "The CLI for Better Auth",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@better-auth/expo",
|
"name": "@better-auth/expo",
|
||||||
"version": "1.2.8",
|
"version": "1.2.9-beta.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
"module": "dist/index.mjs",
|
"module": "dist/index.mjs",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@better-auth/stripe",
|
"name": "@better-auth/stripe",
|
||||||
"author": "Bereket Engida",
|
"author": "Bereket Engida",
|
||||||
"version": "1.2.8",
|
"version": "1.2.9-beta.1",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||