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 { toNextJsHandler } from "better-auth/next-js";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const { GET } = toNextJsHandler(auth);
|
||||
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const res = await auth.handler(req);
|
||||
return res;
|
||||
};
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
oneTap,
|
||||
oAuthProxy,
|
||||
openAPI,
|
||||
oidcProvider,
|
||||
customSession,
|
||||
mcp,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
@@ -19,9 +19,9 @@ import { MysqlDialect } from "kysely";
|
||||
import { createPool } from "mysql2/promise";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { passkey } from "better-auth/plugins/passkey";
|
||||
import { expo } from "@better-auth/expo";
|
||||
import { stripe } from "@better-auth/stripe";
|
||||
import { Stripe } from "stripe";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
|
||||
const to = process.env.TEST_EMAIL || "";
|
||||
@@ -52,10 +52,7 @@ const STARTER_PRICE_ID = {
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: "Better Auth Demo",
|
||||
database: {
|
||||
dialect,
|
||||
type: process.env.USE_MYSQL ? "mysql" : "sqlite",
|
||||
},
|
||||
database: new Database("auth.db"),
|
||||
emailVerification: {
|
||||
async sendVerificationEmail({ user, url }) {
|
||||
const res = await resend.emails.send({
|
||||
@@ -117,6 +114,9 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
mcp({
|
||||
loginPage: "/sign-in",
|
||||
}),
|
||||
organization({
|
||||
async sendInvitationEmail(data) {
|
||||
await resend.emails.send({
|
||||
@@ -160,9 +160,7 @@ export const auth = betterAuth({
|
||||
multiSession(),
|
||||
oAuthProxy(),
|
||||
nextCookies(),
|
||||
oidcProvider({
|
||||
loginPage: "/sign-in",
|
||||
}),
|
||||
|
||||
oneTap(),
|
||||
customSession(async (session) => {
|
||||
return {
|
||||
@@ -198,7 +196,6 @@ export const auth = betterAuth({
|
||||
],
|
||||
},
|
||||
}),
|
||||
expo(),
|
||||
],
|
||||
trustedOrigins: ["exp://"],
|
||||
});
|
||||
|
||||
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",
|
||||
path: "/docs",
|
||||
},
|
||||
|
||||
{
|
||||
name: "blog",
|
||||
path: "/blogs",
|
||||
},
|
||||
{
|
||||
name: "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",
|
||||
icon: () => <KeyRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "MCP",
|
||||
icon: () => (
|
||||
<svg
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 156 173"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 80.9117L73.8822 13.0294C83.255 3.65685 98.451 3.65685 107.823 13.0294C117.196 22.4019 117.196 37.598 107.823 46.9706L56.5581 98.2359"
|
||||
stroke="currentColor"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M57.2652 97.5289L107.823 46.9706C117.196 37.598 132.392 37.598 141.765 46.9706L142.118 47.324C151.491 56.6966 151.491 71.8926 142.118 81.2651L80.7248 142.659C77.6006 145.783 77.6006 150.848 80.7248 153.972L93.331 166.579"
|
||||
stroke="currentColor"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M90.853 29.9999L40.6482 80.2045C31.2756 89.5768 31.2756 104.773 40.6482 114.146C50.0208 123.518 65.2167 123.518 74.5893 114.146L124.794 63.941"
|
||||
stroke="currentColor"
|
||||
stroke-width="12"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
href: "/docs/plugins/mcp",
|
||||
},
|
||||
{
|
||||
title: "Organization",
|
||||
icon: () => <Users2 className="w-4 h-4" />,
|
||||
|
||||
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 { createMDXSource } from "fumadocs-mdx";
|
||||
|
||||
@@ -11,3 +11,8 @@ export const changelogs = loader({
|
||||
baseUrl: "/changelogs",
|
||||
source: createMDXSource(changelogCollection),
|
||||
});
|
||||
|
||||
export const blogs = loader({
|
||||
baseUrl: "/blogs",
|
||||
source: createMDXSource(blogCollection),
|
||||
});
|
||||
|
||||
BIN
docs/public/avatar/beka.jpg
Normal file
|
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({
|
||||
mdxOptions: {
|
||||
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,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "Bundler"
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "better-auth",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9-beta.1",
|
||||
"description": "The most comprehensive authentication library for TypeScript.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./captcha";
|
||||
export * from "./api-key";
|
||||
export * from "./haveibeenpwned";
|
||||
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 { base64 } from "@better-auth/utils/base64";
|
||||
|
||||
const getMetadata = (
|
||||
export const getMetadata = (
|
||||
ctx: GenericEndpointContext,
|
||||
options?: OIDCOptions,
|
||||
): OIDCMetadata => {
|
||||
@@ -50,6 +50,7 @@ const getMetadata = (
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
],
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
claims_supported: [
|
||||
"sub",
|
||||
"iss",
|
||||
|
||||
@@ -513,4 +513,12 @@ export interface OIDCMetadata {
|
||||
* ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"]
|
||||
*/
|
||||
claims_supported: string[];
|
||||
/**
|
||||
* Supported code challenge methods.
|
||||
*
|
||||
* only `S256` is supported.
|
||||
*
|
||||
* @default ["S256"]
|
||||
*/
|
||||
code_challenge_methods_supported: ["S256"];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@better-auth/cli",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9-beta.1",
|
||||
"description": "The CLI for Better Auth",
|
||||
"module": "dist/index.mjs",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@better-auth/expo",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9-beta.1",
|
||||
"description": "",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@better-auth/stripe",
|
||||
"author": "Bereket Engida",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9-beta.1",
|
||||
"main": "dist/index.cjs",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
|
||||