feat: SSO plugin with OIDC and SAML support (#3185)

* fix(email-verification): improve email verification logic to check session and user email consistency (#3042)

* docs(passkey): Fixed signIn passkey props (#3014)

callbackURL doesn't exist.

* fix(email-otp): auto-verify on email otp reset (#3022)

* fix: delete user should respect freshAge config (#3075)

* fix: delete user needs to enforced through fresh age

* cleanup

* cleanup

* chore(org): add comments explaining what shimContext does (#3098)

* feat: Allow passing `id` in DB hook `create` (#3048)

* feat(database-hooks): Allow passing `id` in DB hook `create`

It's the same to using a custom `idGenerator`, except configurable by the database hook which would in theory provide more data.

A use-case is to generate the id based on user info in the user before DB hook.

Solves https://discord.com/channels/1288403910284935179/1379190465588367540/1384217435535835216

* chore: lint

* fix: tests failing

* docs: basic errs with svg props (#3102)

* docs: corrected github user email scope name (#3099)

* docs: corrected github user email scope name

* docs: cubic dev suggestion

* fix: use correct refresh token endpoint for github (#3095)

* chore: fix typo in authorize comment (#3106)

* docs: fix session parameter spelling (#3108)

* docs: input field usage on additional fields (#2991)

* fix: onLinkAccount trigger on phone number verification (#3007)

* fix: expose headers override in jwt plugin (#3019)

* expose headers override in jwt plugin

* clean up

* lint

* fix(expo): remove duplicated trusted origins

* feat: link account with idToken  (#1830)

* add idToken to link account

* add docs

* Implemented linking accounts based on idToken

* fix: tests

* docs: prevent diff

* docs: prevent diff

---------

Co-authored-by: kzlar <120426485+kzlar@users.noreply.github.com>

* feat: add Hugging Face provider (#3089)

* feat: add huggingface provider

* Add hugging face to doc

* chore: update hugging face logo

* chore: release v1.2.10

* docs: fix builder failing to open

* docs(NextJS): Improve middleware example to be more secure (#3135)

* docs(NextJS): Improve middleware example to be more secure

Users can skim code without reading the text, and LLMs can read code and miss-understand context correctly.  Our current middleware example only checks for existence of a cookie, and doesn't validate it.

While we do warn users this isn't secure, some users has raised concern in a Github issue saying it's not obvious enough for users who skim.

Also we don't provide examples on how to authenticate users on each route, we only show middleware optimistic check examples.

* Update docs/content/docs/integrations/next.mdx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix(username): log the correct username (#3127)

* docs: fix typo in plugin (#3122)

* typo

* typo

* typo

* typo

* typo

* docs: fix typos on mcp guide (#3146)

* docs: update TanStack Start integration guide (#3142)

* fix(sveltekit): only dynamic import $app/environment once (#3152)

Co-authored-by: Work <work@Jasons-MacBook-Pro.local>

* docs: fix typo in oauth proxy documentation (#3151)

* blog: seed round announcement  (#3168)

* init

* cleanup

* fix seed round announcemnt

* fix seed round announcemnt

* seed round blog

* add nav mobile

* fix typo

* Update docs/content/blogs/seed-round.mdx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update docs/app/blog/[[...slug]]/page.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update docs/app/blog/[[...slug]]/page.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* update og

* cleanup

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* docs: fix email address

* refactor(mongo-adapter): migrate to createAdapter (#3170)

In the past we didn't have mongoDb adapter move over to createAdapter since we've seen users running into issues.

However some time ago I've merged a PR which I believe fixed the issue, and after testing the org plugin with the mongo adapter that uses `createAdapter` I don't see any issues.

* fix(api-key): update should only use by ID

* docs: fix blog page layout (#3176)

* fix/blog-page-layouts

* clean up

* docs: update contact email in seed round blog

* init

* cleanup

* feat(better-auth): add test utilities and update dependencies

- Introduced a new test utility module in `src/test-utils/index.ts` for better testing support.
- Updated `package.json` to include new test utilities in the build configuration.
- Added `oauth2-mock-server` dependency to `pnpm-lock.yaml` and `sso/package.json` for OAuth2 testing.
- Enhanced the SSO provider registration process with improved error handling.

* docs update

---------

Co-authored-by: Maxwell <145994855+ping-maxwell@users.noreply.github.com>
Co-authored-by: KinfeMichael Tariku <65047246+Kinfe123@users.noreply.github.com>
Co-authored-by: Undefined Ninja <74867549+0xCodeMaieutics@users.noreply.github.com>
Co-authored-by: artemoire <18062266+artemoire@users.noreply.github.com>
Co-authored-by: reslear <12596485+reslear@users.noreply.github.com>
Co-authored-by: kzlar <120426485+kzlar@users.noreply.github.com>
Co-authored-by: Eliott C. <coyotte508@protonmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Alessandro Bortolin <bortolin.alessandro@outlook.it>
Co-authored-by: Lakshya Thakur <lapstjup@gmail.com>
Co-authored-by: Usman S. (Max Programming) <51731966+max-programming@users.noreply.github.com>
Co-authored-by: Jason Venable <jason.venable@gmail.com>
Co-authored-by: Work <work@Jasons-MacBook-Pro.local>
Co-authored-by: Dan McGrath <daniel.mcgrath9@gmail.com>
This commit is contained in:
Bereket Engida
2025-06-27 20:19:19 -07:00
committed by GitHub
parent 4e38645b44
commit a6a66d9c7e
60 changed files with 4982 additions and 662 deletions

View File

@@ -53,5 +53,3 @@ export const {
useListOrganizations,
useActiveOrganization,
} = client;
client.$store.listen("$sessionSignal", async () => {});

View File

@@ -22,6 +22,7 @@ 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";
import { BlogPage } from "../_components/blog-list";
const metaTitle = "Blogs";
const metaDescription = "Latest changes , fixes and updates.";
@@ -33,6 +34,9 @@ export default async function Page({
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
if (!slug) {
return <BlogPage />;
}
const page = blogs.getPage(slug);
if (!page) {
notFound();
@@ -41,31 +45,33 @@ export default async function Page({
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">
<div className="md:flex min-h-screen items-stretch 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 min-h-screen flex-shrink-0 w-full md:w-1/2">
<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>
<Link href="/blog" className="text-gray-600 dark:text-gray-300">
<div className="flex flex-col">
<div className="flex items-center cursor-pointer gap-x-2 text-xs w-full 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>
<h1 className="mt-2 relative font-sans font-semibold tracking-tighter text-4xl mb-2 border-dashed">
{title}{" "}
</h1>
</div>
</Link>
<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">
@@ -111,7 +117,7 @@ export default async function Page({
</p>
</div>
</div>
<div className="px-4 relative md:px-8 pb-12 md:py-12">
<div className="flex-1 min-h-0 h-screen overflow-y-auto 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
@@ -185,7 +191,11 @@ export async function generateMetadata({
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 url = new URL(
`${baseUrl?.startsWith("http") ? baseUrl : `https://${baseUrl}`}${
page.data?.image
}`,
);
const { title, description } = page.data;
return {
@@ -195,7 +205,7 @@ export async function generateMetadata({
title,
description,
type: "website",
url: absoluteUrl(`blogs/${slug.join("")}`),
url: absoluteUrl(`blog/${slug.join("/")}`),
images: [
{
url: url.toString(),

View File

@@ -1,15 +1,14 @@
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 { IconLink } from "./changelog-layout";
import { GitHubIcon, BookIcon, XIcon } from "./icons";
import { Glow } from "./default-changelog";
import { StarField } from "./stat-field";
import { DiscordLogoIcon } from "@radix-ui/react-icons";
export default async function BlogPage() {
export 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">

View File

@@ -34,24 +34,22 @@ export default async function HomePage() {
<div className="flex flex-col md:flex-row items-center justify-center h-12">
<span className="font-medium flex gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<span className=" text-zinc-900 dark:text-white/90 hover:text-zinc-950 text-xs md:text-sm dark:hover:text-zinc-100 transition-colors">
Introducing{" "}
<span className="font-semibold">
Better Auth Infrastructure
</span>
Announcing Our{" "}
<span className="font-semibold">$5M seed round</span>
</span>
<span className=" text-zinc-400 hidden md:block">|</span>
<Link
href="https://better-auth.build"
href="/blog/seed-round"
className="font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 hidden dark:hover:text-blue-300 transition-colors md:block"
>
Join the waitlist
Read more
</Link>
</span>
<Link
href="https://better-auth.build"
href="/blog/seed-round"
className="font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 text-xs dark:hover:text-blue-300 transition-colors md:hidden"
>
Join the waitlist
Read more
</Link>
</div>
</div>

View File

@@ -120,6 +120,10 @@ export const navMenu = [
name: "changelogs",
path: "/changelogs",
},
{
name: "blogs",
path: "/blog",
},
{
name: "community",
path: "/community",

View File

@@ -217,6 +217,10 @@ export const navMenu: {
name: "changelogs",
path: "/changelogs",
},
{
name: "blogs",
path: "/blog",
},
{
name: "community",
path: "/community",

View File

@@ -516,6 +516,23 @@ export const contents: Content[] = [
</svg>
),
},
{
title: "Hugging Face",
href: "/docs/authentication/huggingface",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M12.025 1.13c-5.77 0-10.449 4.647-10.449 10.378c0 1.112.178 2.181.503 3.185c.064-.222.203-.444.416-.577a.96.96 0 0 1 .524-.15c.293 0 .584.124.84.284c.278.173.48.408.71.694c.226.282.458.611.684.951v-.014c.017-.324.106-.622.264-.874s.403-.487.762-.543c.3-.047.596.06.787.203s.31.313.4.467c.15.257.212.468.233.542c.01.026.653 1.552 1.657 2.54c.616.605 1.01 1.223 1.082 1.912c.055.537-.096 1.059-.38 1.572c.637.121 1.294.187 1.967.187c.657 0 1.298-.063 1.921-.178c-.287-.517-.44-1.041-.384-1.581c.07-.69.465-1.307 1.081-1.913c1.004-.987 1.647-2.513 1.657-2.539c.021-.074.083-.285.233-.542c.09-.154.208-.323.4-.467a1.08 1.08 0 0 1 .787-.203c.359.056.604.29.762.543s.247.55.265.874v.015c.225-.34.457-.67.683-.952c.23-.286.432-.52.71-.694c.257-.16.547-.284.84-.285a.97.97 0 0 1 .524.151c.228.143.373.388.43.625l.006.04a10.3 10.3 0 0 0 .534-3.273c0-5.731-4.678-10.378-10.449-10.378M8.327 6.583a1.5 1.5 0 0 1 .713.174a1.487 1.487 0 0 1 .617 2.013c-.183.343-.762-.214-1.102-.094c-.38.134-.532.914-.917.71a1.487 1.487 0 0 1 .69-2.803m7.486 0a1.487 1.487 0 0 1 .689 2.803c-.385.204-.536-.576-.916-.71c-.34-.12-.92.437-1.103.094a1.487 1.487 0 0 1 .617-2.013a1.5 1.5 0 0 1 .713-.174m-10.68 1.55a.96.96 0 1 1 0 1.921a.96.96 0 0 1 0-1.92m13.838 0a.96.96 0 1 1 0 1.92a.96.96 0 0 1 0-1.92M8.489 11.458c.588.01 1.965 1.157 3.572 1.164c1.607-.007 2.984-1.155 3.572-1.164c.196-.003.305.12.305.454c0 .886-.424 2.328-1.563 3.202c-.22-.756-1.396-1.366-1.63-1.32q-.011.001-.02.006l-.044.026l-.01.008l-.03.024q-.018.017-.035.036l-.032.04a1 1 0 0 0-.058.09l-.014.025q-.049.088-.11.19a1 1 0 0 1-.083.116a1.2 1.2 0 0 1-.173.18q-.035.029-.075.058a1.3 1.3 0 0 1-.251-.243a1 1 0 0 1-.076-.107c-.124-.193-.177-.363-.337-.444c-.034-.016-.104-.008-.2.022q-.094.03-.216.087q-.06.028-.125.063l-.13.074q-.067.04-.136.086a3 3 0 0 0-.135.096a3 3 0 0 0-.26.219a2 2 0 0 0-.12.121a2 2 0 0 0-.106.128l-.002.002a2 2 0 0 0-.09.132l-.001.001a1.2 1.2 0 0 0-.105.212q-.013.036-.024.073c-1.139-.875-1.563-2.317-1.563-3.203c0-.334.109-.457.305-.454m.836 10.354c.824-1.19.766-2.082-.365-3.194c-1.13-1.112-1.789-2.738-1.789-2.738s-.246-.945-.806-.858s-.97 1.499.202 2.362c1.173.864-.233 1.45-.685.64c-.45-.812-1.683-2.896-2.322-3.295s-1.089-.175-.938.647s2.822 2.813 2.562 3.244s-1.176-.506-1.176-.506s-2.866-2.567-3.49-1.898s.473 1.23 2.037 2.16c1.564.932 1.686 1.178 1.464 1.53s-3.675-2.511-4-1.297c-.323 1.214 3.524 1.567 3.287 2.405c-.238.839-2.71-1.587-3.216-.642c-.506.946 3.49 2.056 3.522 2.064c1.29.33 4.568 1.028 5.713-.624m5.349 0c-.824-1.19-.766-2.082.365-3.194c1.13-1.112 1.789-2.738 1.789-2.738s.246-.945.806-.858s.97 1.499-.202 2.362c-1.173.864.233 1.45.685.64c.451-.812 1.683-2.896 2.322-3.295s1.089-.175.938.647s-2.822 2.813-2.562 3.244s1.176-.506 1.176-.506s2.866-2.567 3.49-1.898s-.473 1.23-2.037 2.16c-1.564.932-1.686 1.178-1.464 1.53s3.675-2.511 4-1.297c.323 1.214-3.524 1.567-3.287 2.405c.238.839 2.71-1.587 3.216-.642c.506.946-3.49 2.056-3.522 2.064c-1.29.33-4.568 1.028-5.713-.624"
/>
</svg>
),
},
{
title: "Kick",
href: "/docs/authentication/kick",
@@ -1283,20 +1300,20 @@ 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,
<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"
strokeWidth="12"
strokeLinecap="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"
strokeWidth="12"
strokeLinecap="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"
strokeWidth="12"
strokeLinecap="round"
/>
</svg>
),
@@ -1571,8 +1588,8 @@ 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,
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M32.5 64C50.1731 64 64.5 49.6731 64.5 32C64.5 20.1555 58.0648 9.81393 48.5 4.28099V31.9999V47.9998H40.5V45.8594C38.1466 47.2207 35.4143 47.9999 32.5 47.9999C23.6634 47.9999 16.5 40.8364 16.5 31.9999C16.5 23.1633 23.6634 15.9999 32.5 15.9999C35.4143 15.9999 38.1466 16.779 40.5 18.1404V1.00812C37.943 0.350018 35.2624 0 32.5 0C14.8269 0 0.500038 14.3269 0.500038 32C0.500038 49.6731 14.8269 64 32.5 64Z"
fill="currentColor"
/>

View File

@@ -0,0 +1,35 @@
---
title: "Announcing our $5M seed round"
description: "We raised $5M seed led by Peak XV Partners"
date: 2025-06-24
author:
name: "Bereket Engida"
avatar: "/blogs/bereket.png"
twitter: "iambereket"
image: "/blogs/seed-round.png"
tags: ["seed round", "authentication", "funding"]
---
## Announcing our $5M seed round
Were excited to share that Better Auth has raised a $5 million seed round led by Peak XV Partners (formerly Sequoia Capital India & SEA), with participation from Y Combinator, Chapter One, P1 Ventures, and a group of incredible investors and angels.
This funding fuels the next phase of **Better Auth**.
From the start we are obsessed with making it possible for developers to **own their auth**. To **democratize high quality authentication** and make rolling your own auth not just doable, but the obvious choice.
It started with building the framework. Since then, weve seen incredible growth and support from the community. Thank you everyone for being part of this journey. Its still early days, and theres so much more to build. This funding will allow us to have more people invloved and to push the boundaries of what's possible.
On top of the framework, were also building the infrastructure to cover the gaps we couldn't cover in the framework:
* A unified dashboard to manage users and user analytics
* Enterprise-grade security: bot, abuse, and fraud protection
* Authentication Email and SMS service
* Fast, globally distributed session storage
* and more.
[Join the waitlist](https://better-auth.build) to get early access to the infrastructure.
And if you're excited about making auth accessible - we're hiring!
Reach out to [bereket@better-auth.com](mailto:bereket@better-auth.com).

View File

@@ -10,7 +10,7 @@ description: GitHub provider setup and usage.
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/github` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
Important: You MUST include the user.email scope in your Github app. See details below.
Important: You MUST include the user:email scope in your GitHub app. See details below.
</Step>
<Step>

View File

@@ -0,0 +1,47 @@
---
title: Hugging Face
description: Hugging Face provider setup and usage.
---
<Steps>
<Step>
### Get your Hugging Face credentials
To use Hugging Face sign in, you need a client ID and client secret. [Hugging Face OAuth documentation](https://huggingface.co/docs/hub/oauth). Make sure the created oauth app on Hugging Face has the "email" scope.
Make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/huggingface` for local development. For production, you should set it to the URL of your application. If you change the base path of the auth routes, you should update the redirect URL accordingly.
</Step>
<Step>
### Configure the provider
To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
export const auth = betterAuth({
socialProviders: {
huggingface: { // [!code highlight]
clientId: process.env.HUGGINGFACE_CLIENT_ID as string, // [!code highlight]
clientSecret: process.env.HUGGINGFACE_CLIENT_SECRET as string, // [!code highlight]
}, // [!code highlight]
},
})
```
</Step>
<Step>
### Sign In with Hugging Face
To sign in with Hugging Face, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `huggingface`.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()
const signIn = async () => {
const data = await authClient.signIn.social({
provider: "huggingface"
})
}
```
</Step>
</Steps>

View File

@@ -44,7 +44,7 @@ To sign in with Microsoft, you can use the `signIn.social` function provided by
- `provider`: The provider to use. It should be set to `microsoft`.
```ts title="auth-client.ts" /
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client";
const authClient = createAuthClient();

View File

@@ -34,7 +34,7 @@ description: Spotify provider setup and usage.
To sign in with Spotify, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties:
- `provider`: The provider to use. It should be set to `spotify`.
```ts title="auth-client.ts" /
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
const authClient = createAuthClient()

View File

@@ -5,7 +5,7 @@ description: Learn how to use plugins with Better Auth.
Plugins are a key part of Better Auth, they let you extend the base functionalities. You can use them to add new authentication methods, features, or customize behaviors.
Better Auth offers comes with many built-in plugins ready to use. Check the plugins section for details. You can also create your own plugins.
Better Auth comes with many built-in plugins ready to use. Check the plugins section for details. You can also create your own plugins.
## Using a Plugin
@@ -510,7 +510,7 @@ See built-in plugins for examples of how to use atoms properly.
### Path methods
by default, inferred paths use `GET` method if they don't require a body and `POST` if they do. You can override this by passing a `pathMethods` object. The key should be the path and the value should be the method ("POST" | "GET").
By default, inferred paths use `GET` method if they don't require a body and `POST` if they do. You can override this by passing a `pathMethods` object. The key should be the path and the value should be the method ("POST" | "GET").
```ts title="client-plugin.ts"
import type { BetterAuthClientPlugin } from "better-auth/client";

View File

@@ -71,7 +71,8 @@ export const auth = betterAuth({
user: {
additionalFields: {
role: {
type: "string"
type: "string",
input: false
}
}
}
@@ -83,6 +84,26 @@ type Session = typeof auth.$Infer.Session
In the example above, we added a `role` field to the user object. This field is now available on the `Session` type.
### The `input` property
The `input` property in an additional field configuration determines whether the field should be included in the user input. This property defaults to `true`, meaning the field will be part of the user input during operations like registration.
To prevent a field from being part of the user input, you must explicitly set `input: false`:
```ts
additionalFields: {
role: {
type: "string",
input: false
}
}
```
When `input` is set to `false`, the field will be excluded from user input, preventing users from passing a value for it.
By default, additional fields are included in the user input, which can lead to security vulnerabilities if not handled carefully. For fields that should not be set by the user, like a `role`, it is crucial to set `input: false` in the configuration.
### Inferring Additional Fields on Client
To make sure proper type inference for additional fields on the client side, you need to inform the client about these fields. There are two approaches to achieve this, depending on your project structure:

View File

@@ -357,6 +357,27 @@ Users already signed in can manually link their account to additional social pro
});
```
You can also link accounts using ID tokens directly, without redirecting to the provider's OAuth flow:
```ts
await authClient.linkSocial({
provider: "google",
idToken: {
token: "id_token_from_provider",
nonce: "nonce_used_for_token", // Optional
accessToken: "access_token", // Optional, may be required by some providers
refreshToken: "refresh_token" // Optional
}
});
```
This is useful when you already have valid tokens from the provider, for example:
- After signing in with a native SDK
- When using a mobile app that handles authentication
- When implementing custom OAuth flows
The ID token must be valid and the provider must support ID token verification.
If you want your users to be able to link a social account with a different email address than the user, or if you want to use a provider that does not return email addresses, you will need to enable this in the account linking settings.
```ts title="auth.ts"
export const auth = betterAuth({
@@ -368,6 +389,18 @@ Users already signed in can manually link their account to additional social pro
});
```
If you want the newly linked accounts to update the user information, you need to enable this in the account linking settings.
```ts title="auth.ts"
export const auth = betterAuth({
account: {
accountLinking: {
updateUserInfoOnLink: true
}
},
});
```
- **Linking Credential-Based Accounts:** To link a credential-based account (e.g., email and password), users can initiate a "forgot password" flow, or you can call the `setPassword` method on the server.
```ts

View File

@@ -142,6 +142,9 @@ import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
// THIS IS NOT SECURE!
// This is the recommended approach to optimistically redirect users
// We recommend handling auth checks in each page/route
if (!sessionCookie) {
return NextResponse.redirect(new URL("/", request.url));
}
@@ -178,6 +181,33 @@ export async function middleware(request: NextRequest) {
}
```
### How to handle auth checks in each page/route
In this example, we are using the `auth.api.getSession` function within a server component to get the session object,
then we are checking if the session is valid. If it's not, we are redirecting the user to the sign-in page.
```tsx title="app/dashboard/page.tsx"
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers()
})
if(!session) {
redirect("/sign-in")
}
return (
<div>
<h1>Welcome {session.user.name}</h1>
</div>
)
}
```
### For Next.js release `15.1.7` and below
If you need the full session object, you'll have to fetch it from the `/get-session` API route. Since Next.js middleware doesn't support running Node.js APIs directly, you must make an HTTP request.

View File

@@ -9,14 +9,14 @@ Before you start, make sure you have a Better Auth instance configured. If you h
### Mount the handler
We need to mount the handler to a TanStack API endpoint.
Create a new file: `/app/routes/api/auth/$.ts`
We need to mount the handler to a TanStack API endpoint/Server Route.
Create a new file: `/src/routes/api/auth/$.ts`
```ts title="routes/api/auth/$.ts"
```ts title="src/routes/api/auth/$.ts"
import { auth } from '@/lib/auth' // import your auth instance
import { createAPIFileRoute } from '@tanstack/react-start/api'
import { createServerFileRoute } from '@tanstack/react-start/server'
export const APIRoute = createAPIFileRoute('/api/auth/$')({
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
GET: ({ request }) => {
return auth.handler(request)
},
@@ -26,15 +26,18 @@ export const APIRoute = createAPIFileRoute('/api/auth/$')({
})
```
If you haven't defined an API Route yet, you can do so by creating a file: `/app/api.ts`
If you haven't created your server route handler yet, you can do so by creating a file: `/src/server.ts`
```ts title="app/api.ts"
```ts title="src/server.ts"
import {
createStartAPIHandler,
defaultAPIFileRouteHandler,
} from '@tanstack/react-start/api'
createStartHandler,
defaultStreamHandler,
} from '@tanstack/react-start/server'
import { createRouter } from './router'
export default createStartAPIHandler(defaultAPIFileRouteHandler)
export default createStartHandler({
createRouter,
})(defaultStreamHandler)
```
### Usage tips
@@ -42,7 +45,7 @@ export default createStartAPIHandler(defaultAPIFileRouteHandler)
- We recommend using the client SDK or `authClient` to handle authentication, rather than server actions with `auth.api`.
- When you call functions that need to set cookies (like `signInEmail` or `signUpEmail`), you'll need to handle cookie setting for TanStack Start. Better Auth provides a `reactStartCookies` plugin to automatically handle this for you.
```ts title="auth.ts"
```ts title="src/lib/auth.ts"
import { betterAuth } from "better-auth";
import { reactStartCookies } from "better-auth/react-start";

View File

@@ -66,6 +66,6 @@ To share cookies between the proxy server and your main server it uses URL query
## Options
**currentURL**: The application's current URL is automatically determined by the plugin. It first it check for the request URL if invoked by a client, then it checks the base URL from popular hosting providers, and finally falls back to the `baseURL` in your auth config. If the URL isnt inferred correctly, you can specify it manually here.
**currentURL**: The application's current URL is automatically determined by the plugin. It first checks for the request URL if invoked by a client, then it checks the base URL from popular hosting providers, and finally falls back to the `baseURL` in your auth config. If the URL isnt inferred correctly, you can specify it manually here.
**productionURL**: If this value matches the `baseURL` in your auth config, requests will not be proxied. Defaults to the `BETTER_AUTH_URL` environment variable.

View File

@@ -3,13 +3,9 @@ title: Single Sign-On (SSO)
description: Integrate Single Sign-On (SSO) with your application.
---
`OIDC` `OAuth2` `SSO`
`OIDC` `OAuth2` `SSO` `SAML`
Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC) and OAuth2 providers.
<Callout>
SAML support is coming soon. Upvote the feature request on our [GitHub](https://github.com/better-auth/better-auth/issues/96)
</Callout>
Single Sign-On (SSO) allows users to authenticate with multiple applications using a single set of credentials. This plugin supports OpenID Connect (OIDC), OAuth2 providers, and SAML 2.0.
## Installation
@@ -67,30 +63,30 @@ Single Sign-On (SSO) allows users to authenticate with multiple applications usi
### Register an OIDC Provider
To register an OIDC provider, use the `createOIDCProvider` endpoint and provide the necessary configuration details for the provider.
To register an OIDC provider, use the `registerSSOProvider` endpoint and provide the necessary configuration details for the provider.
A redirect URL will be automatically generated using the provider ID. For instance, if the provider ID is `hydra`, the redirect URL would be `{baseURL}/api/auth/sso/callback/hydra`. Note that `/api/auth` may vary depending on your base path configuration.
<Tabs items={["client", "server"]}>
<Tab value="client">
```ts title="register-provider.ts"
```ts title="register-oidc-provider.ts"
import { authClient } from "@/lib/auth-client";
// only with issuer if the provider supports discovery
// Register with OIDC configuration
await authClient.sso.register({
issuer: "https://idp.example.com",
providerId: "example-provider",
});
// with all fields
await authClient.sso.register({
issuer: "https://idp.example.com",
domain: "example.com",
clientId: "client-id",
clientSecret: "client-secret",
authorizationEndpoint: "https://idp.example.com/authorize",
tokenEndpoint: "https://idp.example.com/token",
jwksEndpoint: "https://idp.example.com/jwks",
oidcConfig: {
clientId: "client-id",
clientSecret: "client-secret",
authorizationEndpoint: "https://idp.example.com/authorize",
tokenEndpoint: "https://idp.example.com/token",
jwksEndpoint: "https://idp.example.com/jwks",
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true,
},
mapping: {
id: "sub",
email: "email",
@@ -98,23 +94,28 @@ await authClient.sso.register({
name: "name",
image: "picture",
},
providerId: "example-provider",
});
```
</Tab>
<Tab value="server">
```ts title="register-provider.ts"
```ts title="register-oidc-provider.ts"
const { headers } = await signInWithTestUser();
await auth.api.createOIDCProvider({
await auth.api.registerSSOProvider({
body: {
providerId: "example-provider",
issuer: "https://idp.example.com",
domain: "example.com",
clientId: "your-client-id",
clientSecret: "your-client-secret",
authorizationEndpoint: "https://idp.example.com/authorize",
tokenEndpoint: "https://idp.example.com/token",
jwksEndpoint: "https://idp.example.com/jwks",
oidcConfig: {
clientId: "your-client-id",
clientSecret: "your-client-secret",
authorizationEndpoint: "https://idp.example.com/authorize",
tokenEndpoint: "https://idp.example.com/token",
jwksEndpoint: "https://idp.example.com/jwks",
discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true,
},
mapping: {
id: "sub",
email: "email",
@@ -122,7 +123,6 @@ await auth.api.createOIDCProvider({
name: "name",
image: "picture",
},
providerId: "example-provider",
},
headers,
});
@@ -130,6 +130,130 @@ await auth.api.createOIDCProvider({
</Tab>
</Tabs>
### Register a SAML Provider
To register a SAML provider, use the `registerSSOProvider` endpoint with SAML configuration details. The provider will act as a Service Provider (SP) and integrate with your Identity Provider (IdP).
<Tabs items={["client", "server"]}>
<Tab value="client">
```ts title="register-saml-provider.ts"
import { authClient } from "@/lib/auth-client";
await authClient.sso.register({
providerId: "saml-provider",
issuer: "https://idp.example.com",
domain: "example.com",
samlConfig: {
entryPoint: "https://idp.example.com/sso",
cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
audience: "https://yourapp.com",
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
idpMetadata: {
metadata: "<!-- IdP Metadata XML -->",
privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
privateKeyPass: "your-private-key-password",
isAssertionEncrypted: true,
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
encPrivateKeyPass: "your-encryption-key-password"
},
spMetadata: {
metadata: "<!-- SP Metadata XML -->",
binding: "post",
privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
privateKeyPass: "your-sp-private-key-password",
isAssertionEncrypted: true,
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
encPrivateKeyPass: "your-sp-encryption-key-password"
}
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
extraFields: {
department: "department",
role: "role"
}
},
});
```
</Tab>
<Tab value="server">
```ts title="register-saml-provider.ts"
const { headers } = await signInWithTestUser();
await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider",
issuer: "https://idp.example.com",
domain: "example.com",
samlConfig: {
entryPoint: "https://idp.example.com/sso",
cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider",
audience: "https://yourapp.com",
wantAssertionsSigned: true,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
idpMetadata: {
metadata: "<!-- IdP Metadata XML -->",
privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
privateKeyPass: "your-private-key-password",
isAssertionEncrypted: true,
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
encPrivateKeyPass: "your-encryption-key-password"
},
spMetadata: {
metadata: "<!-- SP Metadata XML -->",
binding: "post",
privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
privateKeyPass: "your-sp-private-key-password",
isAssertionEncrypted: true,
encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
encPrivateKeyPass: "your-sp-encryption-key-password"
}
},
mapping: {
id: "nameID",
email: "email",
name: "displayName",
firstName: "givenName",
lastName: "surname",
extraFields: {
department: "department",
role: "role"
}
},
},
headers,
});
```
</Tab>
</Tabs>
### Get Service Provider Metadata
For SAML providers, you can retrieve the Service Provider metadata XML that needs to be configured in your Identity Provider:
```ts title="get-sp-metadata.ts"
const response = await auth.api.spMetadata({
query: {
providerId: "saml-provider",
format: "xml" // or "json"
}
});
const metadataXML = await response.text();
console.log(metadataXML);
```
### Sign In with SSO
To sign in with an SSO provider, you can call `signIn.sso`
@@ -183,7 +307,6 @@ const res = await auth.api.signInSSO({
When a user is authenticated, if the user does not exist, the user will be provisioned using the `provisionUser` function. If the organization provisioning is enabled and a provider is associated with an organization, the user will be added to the organization.
```ts title="auth.ts"
const auth = betterAuth({
plugins: [
@@ -203,6 +326,280 @@ const auth = betterAuth({
});
```
## Provisioning
The SSO plugin provides powerful provisioning capabilities to automatically set up users and manage their organization memberships when they sign in through SSO providers.
### User Provisioning
User provisioning allows you to run custom logic whenever a user signs in through an SSO provider. This is useful for:
- Setting up user profiles with additional data from the SSO provider
- Synchronizing user attributes with external systems
- Creating user-specific resources
- Logging SSO sign-ins
- Updating user information from the SSO provider
```ts title="auth.ts"
const auth = betterAuth({
plugins: [
sso({
provisionUser: async ({ user, userInfo, token, provider }) => {
// Update user profile with SSO data
await updateUserProfile(user.id, {
department: userInfo.attributes?.department,
jobTitle: userInfo.attributes?.jobTitle,
manager: userInfo.attributes?.manager,
lastSSOLogin: new Date(),
});
// Create user-specific resources
await createUserWorkspace(user.id);
// Sync with external systems
await syncUserWithCRM(user.id, userInfo);
// Log the SSO sign-in
await auditLog.create({
userId: user.id,
action: 'sso_signin',
provider: provider.providerId,
metadata: {
email: userInfo.email,
ssoProvider: provider.issuer,
},
});
},
}),
],
});
```
The `provisionUser` function receives:
- **user**: The user object from the database
- **userInfo**: User information from the SSO provider (includes attributes, email, name, etc.)
- **token**: OAuth2 tokens (for OIDC providers) - may be undefined for SAML
- **provider**: The SSO provider configuration
### Organization Provisioning
Organization provisioning automatically manages user memberships in organizations when SSO providers are linked to specific organizations. This is particularly useful for:
- Enterprise SSO where each company/domain maps to an organization
- Automatic role assignment based on SSO attributes
- Managing team memberships through SSO
#### Basic Organization Provisioning
```ts title="auth.ts"
const auth = betterAuth({
plugins: [
sso({
organizationProvisioning: {
disabled: false, // Enable org provisioning
defaultRole: "member", // Default role for new members
},
}),
],
});
```
#### Advanced Organization Provisioning with Custom Roles
```ts title="auth.ts"
const auth = betterAuth({
plugins: [
sso({
organizationProvisioning: {
disabled: false,
defaultRole: "member",
getRole: async ({ user, userInfo, provider }) => {
// Assign roles based on SSO attributes
const department = userInfo.attributes?.department;
const jobTitle = userInfo.attributes?.jobTitle;
// Admins based on job title
if (jobTitle?.toLowerCase().includes('manager') ||
jobTitle?.toLowerCase().includes('director') ||
jobTitle?.toLowerCase().includes('vp')) {
return "admin";
}
// Special roles for IT department
if (department?.toLowerCase() === 'it') {
return "admin";
}
// Default to member for everyone else
return "member";
},
},
}),
],
});
```
#### Linking SSO Providers to Organizations
When registering an SSO provider, you can link it to a specific organization:
```ts title="register-org-provider.ts"
await auth.api.registerSSOProvider({
body: {
providerId: "acme-corp-saml",
issuer: "https://acme-corp.okta.com",
domain: "acmecorp.com",
organizationId: "org_acme_corp_id", // Link to organization
samlConfig: {
// SAML configuration...
},
},
headers,
});
```
Now when users from `acmecorp.com` sign in through this provider, they'll automatically be added to the "Acme Corp" organization with the appropriate role.
#### Multiple Organizations Example
You can set up multiple SSO providers for different organizations:
```ts title="multi-org-setup.ts"
// Acme Corp SAML provider
await auth.api.registerSSOProvider({
body: {
providerId: "acme-corp",
issuer: "https://acme.okta.com",
domain: "acmecorp.com",
organizationId: "org_acme_id",
samlConfig: { /* ... */ },
},
headers,
});
// TechStart OIDC provider
await auth.api.registerSSOProvider({
body: {
providerId: "techstart-google",
issuer: "https://accounts.google.com",
domain: "techstart.io",
organizationId: "org_techstart_id",
oidcConfig: { /* ... */ },
},
headers,
});
```
#### Organization Provisioning Flow
1. **User signs in** through an SSO provider linked to an organization
2. **User is authenticated** and either found or created in the database
3. **Organization membership is checked** - if the user isn't already a member of the linked organization
4. **Role is determined** using either the `defaultRole` or `getRole` function
5. **User is added** to the organization with the determined role
6. **User provisioning runs** (if configured) for additional setup
### Provisioning Best Practices
#### 1. Idempotent Operations
Make sure your provisioning functions can be safely run multiple times:
```ts
provisionUser: async ({ user, userInfo }) => {
// Check if already provisioned
const existingProfile = await getUserProfile(user.id);
if (!existingProfile.ssoProvisioned) {
await createUserResources(user.id);
await markAsProvisioned(user.id);
}
// Always update attributes (they might change)
await updateUserAttributes(user.id, userInfo.attributes);
},
```
#### 2. Error Handling
Handle errors gracefully to avoid blocking user sign-in:
```ts
provisionUser: async ({ user, userInfo }) => {
try {
await syncWithExternalSystem(user, userInfo);
} catch (error) {
// Log error but don't throw - user can still sign in
console.error('Failed to sync user with external system:', error);
await logProvisioningError(user.id, error);
}
},
```
#### 3. Conditional Provisioning
Only run certain provisioning steps when needed:
```ts
organizationProvisioning: {
disabled: false,
getRole: async ({ user, userInfo, provider }) => {
// Only process role assignment for certain providers
if (provider.providerId.includes('enterprise')) {
return determineEnterpriseRole(userInfo);
}
return "member";
},
},
```
## SAML Configuration
### Service Provider Configuration
When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration:
- **metadata**: XML metadata for the Service Provider
- **binding**: The binding method, typically "post" or "redirect"
- **privateKey**: Private key for signing (optional)
- **privateKeyPass**: Password for the private key (if encrypted)
- **isAssertionEncrypted**: Whether assertions should be encrypted
- **encPrivateKey**: Private key for decryption (if encryption is enabled)
- **encPrivateKeyPass**: Password for the encryption private key
### Identity Provider Configuration
You also need to provide Identity Provider (IdP) configuration:
- **metadata**: XML metadata from your Identity Provider
- **privateKey**: Private key for the IdP communication (optional)
- **privateKeyPass**: Password for the IdP private key (if encrypted)
- **isAssertionEncrypted**: Whether assertions from IdP are encrypted
- **encPrivateKey**: Private key for IdP assertion decryption
- **encPrivateKeyPass**: Password for the IdP decryption key
### SAML Attribute Mapping
Configure how SAML attributes map to user fields:
```ts
mapping: {
id: "nameID", // Default: "nameID"
email: "email", // Default: "email" or "nameID"
name: "displayName", // Default: "displayName"
firstName: "givenName", // Default: "givenName"
lastName: "surname", // Default: "surname"
extraFields: {
department: "department",
role: "jobTitle",
phone: "telephoneNumber"
}
}
```
### SAML Endpoints
The plugin automatically creates the following SAML endpoints:
- **SP Metadata**: `/api/auth/sso/saml2/sp/metadata?providerId={providerId}`
- **SAML Callback**: `/api/auth/sso/saml2/callback/{providerId}`
## Schema
The plugin requires additional fields in the `ssoProvider` table to store the provider's configuration.
@@ -214,7 +611,8 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr
},
{ name: "issuer", type: "string", description: "The issuer identifier", isRequired: true },
{ name: "domain", type: "string", description: "The domain of the provider", isRequired: true },
{ name: "oidcConfig", type: "string", description: "The OIDC configuration", isRequired: false },
{ name: "oidcConfig", type: "string", description: "The OIDC configuration (JSON string)", isRequired: false },
{ name: "samlConfig", type: "string", description: "The SAML configuration (JSON string)", isRequired: false },
{ name: "userId", type: "string", description: "The user ID", isRequired: true, references: { model: "user", field: "id" } },
{ name: "providerId", type: "string", description: "The provider ID. Used to identify a provider and to generate a redirect URL.", isRequired: true, isUnique: true },
{ name: "organizationId", type: "string", description: "The organization Id. If provider is linked to an organization.", isRequired: false },
@@ -229,6 +627,10 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr
**organizationProvisioning**: Options for provisioning users to an organization.
**defaultOverrideUserInfo**: Override user info with the provider info by default.
**disableImplicitSignUp**: Disable implicit sign up for new users.
<TypeTable
type={{
provisionUser: {
@@ -256,5 +658,15 @@ The plugin requires additional fields in the `ssoProvider` table to store the pr
},
},
},
defaultOverrideUserInfo: {
description: "Override user info with the provider info by default.",
type: "boolean",
default: false,
},
disableImplicitSignUp: {
description: "Disable implicit sign up for new users. When set to true, sign-in needs to be called with requestSignUp as true to create new users.",
type: "boolean",
default: false,
},
}}
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,6 +1,6 @@
# Better Auth - MCP Demo
This is example repo on how to setup Better Auth for MCP Auth using Nextjs and Vercel MCP adapter.
This is an example repo on how to setup Better Auth for MCP Auth using Nextjs and Vercel MCP adapter.
## Usage
@@ -12,7 +12,7 @@ First, add the plugin to your auth instance
import { betterAuth } from "better-auth";
import { mcp } from "better-auth/plugins";
export cosnt auth = betterAuth({
export const auth = betterAuth({
plugins: [
mcp({
loginPage: "/sign-in" // path to a page where users login
@@ -46,7 +46,7 @@ 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
You can use the helper function `withMcpAuth` to get the session and handle unauthenticated calls automatically.
```ts
import { auth } from "@/lib/auth";
@@ -54,7 +54,7 @@ import { createMcpHandler } from "@vercel/mcp-adapter";
import { withMcpAuth } from "better-auth/plugins";
import { z } from "zod";
const handler = withMcpAuth(auth, (req, sesssion) => {
const handler = withMcpAuth(auth, (req, session) => {
//session => This isnt a typical Better Auth session - instead, it returns the access token record along with the scopes and user ID.
return createMcpHandler(
(server) => {

View File

@@ -110,5 +110,6 @@ export default defineBuildConfig({
"./src/plugins/username/index.ts",
"./src/plugins/haveibeenpwned/index.ts",
"./src/plugins/one-time-token/index.ts",
"./src/test-utils/index.ts",
],
});

View File

@@ -1,6 +1,6 @@
{
"name": "better-auth",
"version": "1.2.10-beta.1",
"version": "1.2.10",
"description": "The most comprehensive authentication library for TypeScript.",
"type": "module",
"license": "MIT",
@@ -135,6 +135,16 @@
"default": "./dist/client/solid/index.cjs"
}
},
"./test": {
"import": {
"types": "./dist/test-utils/index.d.ts",
"default": "./dist/test-utils/index.mjs"
},
"require": {
"types": "./dist/test-utils/index.d.cts",
"default": "./dist/test-utils/index.cjs"
}
},
"./api": {
"import": {
"types": "./dist/api/index.d.ts",

View File

@@ -318,7 +318,10 @@ export const createAdapter =
!config.disableIdGeneration &&
!options.advanced?.database?.useNumberId
) {
fields.id = idField({ customModelName: unsafe_model, forceAllowId });
fields.id = idField({
customModelName: unsafe_model,
forceAllowId: forceAllowId && "id" in data,
});
}
for (const field in fields) {
const value = data[field];

View File

@@ -1,326 +1,274 @@
import { ObjectId, type Db } from "mongodb";
import { getAuthTables } from "../../db";
import type { Adapter, BetterAuthOptions, Where } from "../../types";
import { withApplyDefault } from "../utils";
import type { Where } from "../../types";
import { createAdapter, type AdapterDebugLogs } from "../create-adapter";
const createTransform = (options: BetterAuthOptions) => {
const schema = getAuthTables(options);
export interface MongoDBAdapterConfig {
/**
* if custom id gen is provided we don't want to override with object id
* Enable debug logs for the adapter
*
* @default false
*/
const customIdGen =
options.advanced?.database?.generateId || options.advanced?.generateId;
debugLogs?: AdapterDebugLogs;
/**
* Use plural table names
*
* @default false
*/
usePlural?: boolean;
}
function serializeID(field: string, value: any, model: string) {
if (customIdGen) {
return value;
}
if (
field === "id" ||
field === "_id" ||
schema[model].fields[field].references?.field === "id"
) {
if (typeof value !== "string") {
if (value instanceof ObjectId) {
export const mongodbAdapter = (db: Db, config?: MongoDBAdapterConfig) =>
createAdapter({
config: {
adapterId: "mongodb-adapter",
adapterName: "MongoDB Adapter",
usePlural: config?.usePlural ?? false,
debugLogs: config?.debugLogs ?? false,
mapKeysTransformInput: {
id: "_id",
},
mapKeysTransformOutput: {
_id: "id",
},
supportsNumericIds: false,
customTransformInput({
action,
data,
field,
fieldAttributes,
schema,
model,
}) {
// Given the key transformation, we know that `id` is already mapped to `_id`
if (field === "_id" || fieldAttributes.references?.field === "id") {
if (action === "update") {
return data;
}
if (Array.isArray(data)) {
return data.map((v) => new ObjectId());
}
if (typeof data === "string") {
try {
return new ObjectId(data);
} catch (error) {
return new ObjectId();
}
}
return new ObjectId();
}
return data;
},
customTransformOutput({ data, field, fieldAttributes }) {
if (field === "id" || fieldAttributes.references?.field === "id") {
if (data instanceof ObjectId) {
return data.toHexString();
}
if (Array.isArray(data)) {
return data.map((v) => {
if (v instanceof ObjectId) {
return v.toHexString();
}
return v;
});
}
return data;
}
return data;
},
},
adapter: ({ options, getFieldName, schema, getDefaultModelName }) => {
/**
* if custom id gen is provided we don't want to override with object id
*/
const customIdGen = options.advanced?.database?.generateId;
function serializeID({
field,
value,
model,
}: { field: string; value: any; model: string }) {
if (customIdGen) {
return value;
}
if (Array.isArray(value)) {
return value.map((v) => {
if (typeof v === "string") {
try {
return new ObjectId(v);
} catch (e) {
return v;
}
model = getDefaultModelName(model);
if (
field === "id" ||
field === "_id" ||
schema[model].fields[field]?.references?.field === "id"
) {
if (typeof value !== "string") {
if (value instanceof ObjectId) {
return value;
}
if (v instanceof ObjectId) {
return v;
if (Array.isArray(value)) {
return value.map((v) => {
if (typeof v === "string") {
try {
return new ObjectId(v);
} catch (e) {
return v;
}
}
if (v instanceof ObjectId) {
return v;
}
throw new Error("Invalid id value");
});
}
throw new Error("Invalid id value");
});
}
try {
return new ObjectId(value);
} catch (e) {
return value;
}
}
throw new Error("Invalid id value");
}
try {
return new ObjectId(value);
} catch (e) {
return value;
}
}
return value;
}
function deserializeID(field: string, value: any, model: string) {
if (customIdGen) {
return value;
}
if (
field === "id" ||
schema[model].fields[field].references?.field === "id"
) {
if (value instanceof ObjectId) {
return value.toHexString();
}
if (Array.isArray(value)) {
return value.map((v) => {
if (v instanceof ObjectId) {
return v.toHexString();
}
return v;
});
}
return value;
}
return value;
}
function getField(field: string, model: string) {
if (field === "id") {
if (customIdGen) {
return "id";
}
return "_id";
}
const f = schema[model].fields[field];
return f.fieldName || field;
}
return {
transformInput(
data: Record<string, any>,
model: string,
action: "create" | "update",
) {
const transformedData: Record<string, any> =
action === "update"
? {}
: customIdGen
? {
id: customIdGen({ model }),
}
: {
_id: new ObjectId(),
function convertWhereClause({
where,
model,
}: { where: Where[]; model: string }) {
if (!where.length) return {};
const conditions = where.map((w) => {
const {
field: field_,
value,
operator = "eq",
connector = "AND",
} = w;
let condition: any;
let field = getFieldName({ model, field: field_ });
if (field === "id") field = "_id";
switch (operator.toLowerCase()) {
case "eq":
condition = {
[field]: serializeID({
field,
value,
model,
}),
};
const fields = schema[model].fields;
for (const field in fields) {
const value = data[field];
if (
value === undefined &&
(!fields[field].defaultValue || action === "update")
) {
continue;
}
transformedData[fields[field].fieldName || field] = withApplyDefault(
serializeID(field, value, model),
fields[field],
action,
);
}
return transformedData;
},
transformOutput(
data: Record<string, any>,
model: string,
select: string[] = [],
) {
const transformedData: Record<string, any> =
data.id || data._id
? select.length === 0 || select.includes("id")
? {
id: data.id ? data.id.toString() : data._id.toString(),
}
: {}
: {};
break;
case "in":
condition = {
[field]: {
$in: Array.isArray(value)
? value.map((v) => serializeID({ field, value: v, model }))
: [serializeID({ field, value, model })],
},
};
break;
case "gt":
condition = { [field]: { $gt: value } };
break;
case "gte":
condition = { [field]: { $gte: value } };
break;
case "lt":
condition = { [field]: { $lt: value } };
break;
case "lte":
condition = { [field]: { $lte: value } };
break;
case "ne":
condition = { [field]: { $ne: value } };
break;
const tableSchema = schema[model].fields;
for (const key in tableSchema) {
if (select.length && !select.includes(key)) {
continue;
case "contains":
condition = { [field]: { $regex: `.*${value}.*` } };
break;
case "starts_with":
condition = { [field]: { $regex: `${value}.*` } };
break;
case "ends_with":
condition = { [field]: { $regex: `.*${value}` } };
break;
default:
throw new Error(`Unsupported operator: ${operator}`);
}
return { condition, connector };
});
if (conditions.length === 1) {
return conditions[0].condition;
}
const field = tableSchema[key];
if (field) {
transformedData[key] = deserializeID(
key,
data[field.fieldName || key],
model,
const andConditions = conditions
.filter((c) => c.connector === "AND")
.map((c) => c.condition);
const orConditions = conditions
.filter((c) => c.connector === "OR")
.map((c) => c.condition);
let clause = {};
if (andConditions.length) {
clause = { ...clause, $and: andConditions };
}
if (orConditions.length) {
clause = { ...clause, $or: orConditions };
}
return clause;
}
return {
async create({ model, data: values }) {
const res = await db.collection(model).insertOne(values);
const insertedData = { _id: res.insertedId.toString(), ...values };
return insertedData as any;
},
async findOne({ model, where, select }) {
const clause = convertWhereClause({ where, model });
const res = await db.collection(model).findOne(clause);
if (!res) return null;
return res as any;
},
async findMany({ model, where, limit, offset, sortBy }) {
const clause = where ? convertWhereClause({ where, model }) : {};
const cursor = db.collection(model).find(clause);
if (limit) cursor.limit(limit);
if (offset) cursor.skip(offset);
if (sortBy)
cursor.sort(
getFieldName({ field: sortBy.field, model }),
sortBy.direction === "desc" ? -1 : 1,
);
const res = await cursor.toArray();
return res as any;
},
async count({ model }) {
const res = await db.collection(model).countDocuments();
return res;
},
async update({ model, where, update: values }) {
const clause = convertWhereClause({ where, model });
const res = await db.collection(model).findOneAndUpdate(
clause,
{ $set: values as any },
{
returnDocument: "after",
},
);
}
}
return transformedData as any;
},
convertWhereClause(where: Where[], model: string) {
if (!where.length) return {};
const conditions = where.map((w) => {
const { field: _field, value, operator = "eq", connector = "AND" } = w;
let condition: any;
const field = getField(_field, model);
switch (operator.toLowerCase()) {
case "eq":
condition = {
[field]: serializeID(_field, value, model),
};
break;
case "in":
condition = {
[field]: {
$in: Array.isArray(value)
? serializeID(_field, value, model)
: [serializeID(_field, value, model)],
},
};
break;
case "gt":
condition = { [field]: { $gt: value } };
break;
case "gte":
condition = { [field]: { $gte: value } };
break;
case "lt":
condition = { [field]: { $lt: value } };
break;
case "lte":
condition = { [field]: { $lte: value } };
break;
case "ne":
condition = { [field]: { $ne: value } };
break;
if (!res) return null;
return res as any;
},
async updateMany({ model, where, update: values }) {
const clause = convertWhereClause({ where, model });
case "contains":
condition = { [field]: { $regex: `.*${value}.*` } };
break;
case "starts_with":
condition = { [field]: { $regex: `${value}.*` } };
break;
case "ends_with":
condition = { [field]: { $regex: `.*${value}` } };
break;
default:
throw new Error(`Unsupported operator: ${operator}`);
}
return { condition, connector };
});
if (conditions.length === 1) {
return conditions[0].condition;
}
const andConditions = conditions
.filter((c) => c.connector === "AND")
.map((c) => c.condition);
const orConditions = conditions
.filter((c) => c.connector === "OR")
.map((c) => c.condition);
let clause = {};
if (andConditions.length) {
clause = { ...clause, $and: andConditions };
}
if (orConditions.length) {
clause = { ...clause, $or: orConditions };
}
return clause;
const res = await db.collection(model).updateMany(clause, {
$set: values as any,
});
return res.modifiedCount;
},
async delete({ model, where }) {
const clause = convertWhereClause({ where, model });
await db.collection(model).deleteOne(clause);
},
async deleteMany({ model, where }) {
const clause = convertWhereClause({ where, model });
const res = await db.collection(model).deleteMany(clause);
return res.deletedCount;
},
};
},
getModelName: (model: string) => {
return schema[model].modelName;
},
getField,
};
};
export const mongodbAdapter = (db: Db) => (options: BetterAuthOptions) => {
const transform = createTransform(options);
const hasCustomId = options.advanced?.generateId;
return {
id: "mongodb-adapter",
async create(data) {
const { model, data: values, select } = data;
const transformedData = transform.transformInput(values, model, "create");
if (transformedData.id && !hasCustomId) {
// biome-ignore lint/performance/noDelete: setting id to undefined will cause the id to be null in the database which is not what we want
delete transformedData.id;
}
const res = await db
.collection(transform.getModelName(model))
.insertOne(transformedData);
const id = res.insertedId;
const insertedData = { id: id.toString(), ...transformedData };
const t = transform.transformOutput(insertedData, model, select);
return t;
},
async findOne(data) {
const { model, where, select } = data;
const clause = transform.convertWhereClause(where, model);
const res = await db
.collection(transform.getModelName(model))
.findOne(clause);
if (!res) return null;
const transformedData = transform.transformOutput(res, model, select);
return transformedData;
},
async findMany(data) {
const { model, where, limit, offset, sortBy } = data;
const clause = where ? transform.convertWhereClause(where, model) : {};
const cursor = db.collection(transform.getModelName(model)).find(clause);
if (limit) cursor.limit(limit);
if (offset) cursor.skip(offset);
if (sortBy)
cursor.sort(
transform.getField(sortBy.field, model),
sortBy.direction === "desc" ? -1 : 1,
);
const res = await cursor.toArray();
return res.map((r) => transform.transformOutput(r, model));
},
async count(data) {
const { model } = data;
const res = await db
.collection(transform.getModelName(model))
.countDocuments();
return res;
},
async update(data) {
const { model, where, update: values } = data;
const clause = transform.convertWhereClause(where, model);
const transformedData = transform.transformInput(values, model, "update");
const res = await db
.collection(transform.getModelName(model))
.findOneAndUpdate(
clause,
{ $set: transformedData },
{
returnDocument: "after",
},
);
const output = res?.value ?? res;
if (!output) return null;
return transform.transformOutput(output, model);
},
async updateMany(data) {
const { model, where, update: values } = data;
const clause = transform.convertWhereClause(where, model);
const transformedData = transform.transformInput(values, model, "update");
const res = await db
.collection(transform.getModelName(model))
.updateMany(clause, { $set: transformedData });
return res.modifiedCount;
},
async delete(data) {
const { model, where } = data;
const clause = transform.convertWhereClause(where, model);
const res = await db
.collection(transform.getModelName(model))
.findOneAndDelete(clause);
const output = res?.value ?? res;
if (!output) return null;
return transform.transformOutput(output, model);
},
async deleteMany(data) {
const { model, where } = data;
const clause = transform.convertWhereClause(where, model);
const res = await db
.collection(transform.getModelName(model))
.deleteMany(clause);
return res.deletedCount;
},
} satisfies Adapter;
};
});

View File

@@ -1,4 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import {
afterEach,
beforeAll,
describe,
expect,
it,
vi,
type MockInstance,
} from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { parseSetCookieHeader } from "../../cookies";
import type { GoogleProfile } from "../../social-providers";
@@ -61,6 +69,20 @@ describe("account", async () => {
const ctx = await auth.$context;
let googleVerifyIdTokenMock: MockInstance;
let googleGetUserInfoMock: MockInstance;
beforeAll(() => {
const googleProvider = ctx.socialProviders.find((v) => v.id === "google")!;
expect(googleProvider).toBeTruthy();
googleVerifyIdTokenMock = vi.spyOn(googleProvider, "verifyIdToken");
googleGetUserInfoMock = vi.spyOn(googleProvider, "getUserInfo");
});
afterEach(() => {
googleVerifyIdTokenMock.mockClear();
googleGetUserInfoMock.mockClear();
});
const { headers } = await signInWithTestUser();
it("should list all accounts", async () => {
@@ -96,7 +118,9 @@ describe("account", async () => {
redirect: true,
});
const state =
new URL(linkAccountRes.data!.url).searchParams.get("state") || "";
linkAccountRes.data && "url" in linkAccountRes.data
? new URL(linkAccountRes.data.url).searchParams.get("state") || ""
: "";
email = "test@test.com";
await client.$fetch("/callback/google", {
query: {
@@ -139,7 +163,10 @@ describe("account", async () => {
redirect: true,
});
const url = new URL(linkAccountRes.data!.url);
const url =
linkAccountRes.data && "url" in linkAccountRes.data
? new URL(linkAccountRes.data.url)
: new URL("");
const scopesParam = url.searchParams.get("scope");
expect(scopesParam).toContain(customScope);
});
@@ -169,7 +196,9 @@ describe("account", async () => {
redirect: true,
});
const state =
new URL(linkAccountRes.data!.url).searchParams.get("state") || "";
linkAccountRes.data && "url" in linkAccountRes.data
? new URL(linkAccountRes.data.url).searchParams.get("state") || ""
: "";
email = "test2@test.com";
await client.$fetch("/callback/google", {
query: {
@@ -192,6 +221,53 @@ describe("account", async () => {
});
expect(accounts.data?.length).toBe(2);
});
it("should link third account with idToken", async () => {
googleVerifyIdTokenMock.mockResolvedValueOnce(true);
const user = {
id: "0987654321",
name: "test2",
email: "test2@gmail.com",
sub: "test2",
emailVerified: true,
};
const userInfo = {
user,
data: user,
};
googleGetUserInfoMock.mockResolvedValueOnce(userInfo);
const { headers: headers2 } = await signInWithTestUser();
const linkAccountRes = await client.linkSocial(
{
provider: "google",
callbackURL: "/callback",
idToken: { token: "test" },
},
{
headers: headers2,
onSuccess(context) {
const cookies = parseSetCookieHeader(
context.response.headers.get("set-cookie") || "",
);
headers.set(
"cookie",
`better-auth.state=${cookies.get("better-auth.state")?.value}`,
);
},
},
);
expect(googleVerifyIdTokenMock).toHaveBeenCalledOnce();
expect(googleGetUserInfoMock).toHaveBeenCalledOnce();
const { headers: headers3 } = await signInWithTestUser();
const accounts = await client.listAccounts({
fetchOptions: { headers: headers3 },
});
expect(accounts.data?.length).toBe(3);
});
it("should unlink account", async () => {
const { headers } = await signInWithTestUser();
const previousAccounts = await client.listAccounts({
@@ -199,7 +275,7 @@ describe("account", async () => {
headers,
},
});
expect(previousAccounts.data?.length).toBe(2);
expect(previousAccounts.data?.length).toBe(3);
const unlinkAccountId = previousAccounts.data![1].accountId;
const unlinkRes = await client.unlinkAccount({
providerId: "google",
@@ -214,7 +290,7 @@ describe("account", async () => {
headers,
},
});
expect(accounts.data?.length).toBe(1);
expect(accounts.data?.length).toBe(2);
});
it("should fail to unlink the last account of a provider", async () => {

View File

@@ -104,6 +104,22 @@ export const linkSocialAccount = createAuthEndpoint(
* OAuth2 provider to use
*/
provider: SocialProviderListEnum,
/**
* ID Token for direct authentication without redirect
*/
idToken: z
.object({
token: z.string(),
nonce: z.string().optional(),
accessToken: z.string().optional(),
refreshToken: z.string().optional(),
scopes: z.array(z.string()).optional(),
})
.optional(),
/**
* Whether to allow sign up for new users
*/
requestSignUp: z.boolean().optional(),
/**
* Additional scopes to request when linking the account.
* This is useful for requesting additional permissions when
@@ -146,8 +162,11 @@ export const linkSocialAccount = createAuthEndpoint(
description:
"Indicates if the user should be redirected to the authorization URL",
},
status: {
type: "boolean",
},
},
required: ["url", "redirect"],
required: ["redirect"],
},
},
},
@@ -175,6 +194,133 @@ export const linkSocialAccount = createAuthEndpoint(
});
}
// Handle ID Token flow if provided
if (c.body.idToken) {
if (!provider.verifyIdToken) {
c.context.logger.error(
"Provider does not support id token verification",
{
provider: c.body.provider,
},
);
throw new APIError("NOT_FOUND", {
message: BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED,
});
}
const { token, nonce } = c.body.idToken;
const valid = await provider.verifyIdToken(token, nonce);
if (!valid) {
c.context.logger.error("Invalid id token", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: BASE_ERROR_CODES.INVALID_TOKEN,
});
}
const linkingUserInfo = await provider.getUserInfo({
idToken: token,
accessToken: c.body.idToken.accessToken,
refreshToken: c.body.idToken.refreshToken,
});
if (!linkingUserInfo || !linkingUserInfo?.user) {
c.context.logger.error("Failed to get user info", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO,
});
}
if (!linkingUserInfo.user.email) {
c.context.logger.error("User email not found", {
provider: c.body.provider,
});
throw new APIError("UNAUTHORIZED", {
message: BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND,
});
}
const existingAccounts = await c.context.internalAdapter.findAccounts(
session.user.id,
);
const hasBeenLinked = existingAccounts.find(
(a) =>
a.providerId === provider.id &&
a.accountId === linkingUserInfo.user.id,
);
if (hasBeenLinked) {
return c.json({
redirect: false,
status: true,
});
}
const trustedProviders =
c.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders?.includes(provider.id);
if (
(!isTrustedProvider && !linkingUserInfo.user.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
throw new APIError("UNAUTHORIZED", {
message: "Account not linked - linking not allowed",
});
}
if (
linkingUserInfo.user.email !== session.user.email &&
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
) {
throw new APIError("UNAUTHORIZED", {
message: "Account not linked - different emails not allowed",
});
}
try {
await c.context.internalAdapter.createAccount(
{
userId: session.user.id,
providerId: provider.id,
accountId: linkingUserInfo.user.id.toString(),
accessToken: c.body.idToken.accessToken,
idToken: token,
refreshToken: c.body.idToken.refreshToken,
scope: c.body.idToken.scopes?.join(","),
},
c,
);
} catch (e: any) {
throw new APIError("EXPECTATION_FAILED", {
message: "Account not linked - unable to create account",
});
}
if (
c.context.options.account?.accountLinking?.updateUserInfoOnLink === true
) {
try {
await c.context.internalAdapter.updateUser(session.user.id, {
name: linkingUserInfo.user?.name,
image: linkingUserInfo.user?.image,
});
} catch (e: any) {
console.warn("Could not update user - " + e.toString());
}
}
return c.json({
redirect: false,
status: true,
});
}
// Handle OAuth flow
const state = await generateState(c, {
userId: session.user.id,
email: session.user.email,

View File

@@ -126,6 +126,19 @@ export const callbackOAuth = createAuthEndpoint(
}
if (link) {
const trustedProviders =
c.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders?.includes(
provider.id as "apple",
);
if (
(!isTrustedProvider && !userInfo.emailVerified) ||
c.context.options.account?.accountLinking?.enabled === false
) {
c.context.logger.error("Unable to link account - untrusted provider");
return redirectOnError("unable_to_link_account");
}
const existingAccount = await c.context.internalAdapter.findAccount(
userInfo.id,
);

View File

@@ -421,17 +421,14 @@ export const deleteUser = createAuthEndpoint(
throw new APIError("NOT_FOUND");
}
const session = ctx.context.session;
let canDelete = false;
const accounts = await ctx.context.internalAdapter.findAccounts(
session.user.id,
);
const account = accounts.find(
(account) => account.providerId === "credential" && account.password,
);
// If the user has a password, we can try to delete the account
if (ctx.body.password) {
const accounts = await ctx.context.internalAdapter.findAccounts(
session.user.id,
);
const account = accounts.find(
(account) => account.providerId === "credential" && account.password,
);
if (!account || !account.password) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.CREDENTIAL_ACCOUNT_NOT_FOUND,
@@ -446,10 +443,8 @@ export const deleteUser = createAuthEndpoint(
message: BASE_ERROR_CODES.INVALID_PASSWORD,
});
}
canDelete = true;
}
// If the user has a token, we can try to delete the account
if (ctx.body.token) {
//@ts-expect-error
await deleteUserCallback({
@@ -464,15 +459,7 @@ export const deleteUser = createAuthEndpoint(
});
}
// if user didn't provide a password or token, try sending email verification
if (ctx.context.options.user.deleteUser?.sendDeleteAccountVerification) {
// if the user has a password but it was not provided, we can't delete the account
if (account && account.password && !canDelete) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.USER_ALREADY_HAS_PASSWORD,
});
}
const token = generateRandomString(32, "0-9", "a-z");
await ctx.context.internalAdapter.createVerificationValue(
{
@@ -506,25 +493,15 @@ export const deleteUser = createAuthEndpoint(
});
}
// if the user didn't provide a password or token, or email verification is not enabled
// we can check if the session is fresh and delete based on that
if (ctx.context.options.session?.freshAge) {
if (!ctx.body.password && ctx.context.sessionConfig.freshAge !== 0) {
const currentAge = session.session.createdAt.getTime();
const freshAge = ctx.context.options.session.freshAge;
const freshAge = ctx.context.sessionConfig.freshAge * 1000;
const now = Date.now();
if (now - currentAge > freshAge * 1000) {
throw new APIError("BAD_REQUEST", {
message: BASE_ERROR_CODES.SESSION_EXPIRED,
});
}
canDelete = true;
}
// if password/fresh session didn't work, we can't delete the account
if (!canDelete) {
throw new APIError("BAD_REQUEST", {
message: "User cannot be deleted. please provide a password or token",
});
}
const beforeDelete = ctx.context.options.user.deleteUser?.beforeDelete;

View File

@@ -1,4 +1,4 @@
import type { BetterAuthPlugin } from "../types";
import type { BetterAuthOptions, BetterAuthPlugin } from "../types";
import type { BetterAuthClientPlugin } from "./types";
export * from "./vanilla";
export * from "./query";
@@ -11,6 +11,10 @@ export const InferPlugin = <T extends BetterAuthPlugin>() => {
} satisfies BetterAuthClientPlugin;
};
export function InferAuth<O extends { options: BetterAuthOptions }>() {
return {} as O["options"];
}
//@ts-expect-error
export type * from "nanostores";
export type * from "@better-fetch/fetch";

View File

@@ -12,7 +12,7 @@ import type {
} from "../types/helper";
import type { Auth } from "../auth";
import type { InferRoutes } from "./path-to-object";
import type { Session, User } from "../types";
import type { BetterAuthOptions, Session, User } from "../types";
import type { InferFieldsInputClient, InferFieldsOutput } from "../db";
export type AtomListener = {
@@ -72,6 +72,7 @@ export interface ClientOptions {
baseURL?: string;
basePath?: string;
disableDefaultFetchPlugins?: boolean;
$InferAuth?: BetterAuthOptions;
}
export type InferClientAPI<O extends ClientOptions> = InferRoutes<

View File

@@ -16,6 +16,7 @@ import type {
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { BASE_ERROR_CODES } from "../error/codes";
import type { InferRoutes } from "./path-to-object";
type InferResolvedHooks<O extends ClientOptions> = O["plugins"] extends Array<
infer Plugin
@@ -89,5 +90,18 @@ export function createAuthClient<Option extends ClientOptions>(
$ERROR_CODES: PrettifyDeep<
InferErrorCodes<Option> & typeof BASE_ERROR_CODES
>;
};
} & InferRoutes<
Option["$InferAuth"] extends {
plugins: infer Plugins;
}
? Plugins extends Array<infer Plugin>
? Plugin extends {
endpoints: infer Endpoints;
}
? Endpoints
: {}
: {}
: {},
Option
>;
}

View File

@@ -53,6 +53,7 @@ export function getWithHooks(
? await adapter.create<T>({
model,
data: actualData as any,
forceAllowId: true,
})
: customCreated;

View File

@@ -3,6 +3,8 @@ import type { BetterAuthPlugin } from "../types";
import { createAuthMiddleware } from "../api";
import { parseSetCookieHeader } from "../cookies";
let isBuilding: boolean | undefined;
export const toSvelteKitHandler = (auth: {
handler: (request: Request) => any;
options: BetterAuthOptions;
@@ -22,11 +24,17 @@ export const svelteKitHandler = async ({
event: { request: Request; url: URL };
resolve: (event: any) => any;
}) => {
//@ts-expect-error
const { building } = await import("$app/environment")
.catch((e) => {})
.then((m) => m || {});
if (building) {
// Only check building state once and cache it
if (isBuilding === undefined) {
//@ts-expect-error
const { building } = await import("$app/environment")
.catch((e) => {})
.then((m) => m || {});
isBuilding = building || false;
}
if (isBuilding) {
return resolve(event);
}
const { request, url } = event;

View File

@@ -4,3 +4,4 @@ export * from "./refresh-access-token";
export * from "./utils";
export * from "./state";
export * from "./types";
export * from "./link-account";

View File

@@ -178,7 +178,8 @@ export const anonymous = (options?: AnonymousOptions) => {
ctx.path.startsWith("/callback") ||
ctx.path.startsWith("/oauth2/callback") ||
ctx.path.startsWith("/magic-link/verify") ||
ctx.path.startsWith("/email-otp/verify-email")
ctx.path.startsWith("/email-otp/verify-email") ||
ctx.path.startsWith("/phone-number/verify")
);
},
handler: createAuthMiddleware(async (ctx) => {

View File

@@ -389,10 +389,6 @@ export function updateApiKey({
field: "id",
value: apiKey.id,
},
{
field: "userId",
value: user.id,
},
],
update: {
lastRequest: new Date(),

View File

@@ -767,6 +767,16 @@ export const emailOTP = (options: EmailOTPOptions) => {
);
}
if (!user.user.emailVerified) {
await ctx.context.internalAdapter.updateUser(
user.user.id,
{
emailVerified: true,
},
ctx,
);
}
return ctx.json({
success: true,
});

View File

@@ -388,8 +388,22 @@ export const jwt = (options?: JwtOptions) => {
const session = ctx.context.session || ctx.context.newSession;
if (session && session.session) {
const jwt = await getJwtToken(ctx, options);
const exposedHeaders =
ctx.context.responseHeaders?.get(
"access-control-expose-headers",
) || "";
const headersSet = new Set(
exposedHeaders
.split(",")
.map((header) => header.trim())
.filter(Boolean),
);
headersSet.add("set-auth-jwt");
ctx.setHeader("set-auth-jwt", jwt);
ctx.setHeader("Access-Control-Expose-Headers", "set-auth-jwt");
ctx.setHeader(
"Access-Control-Expose-Headers",
Array.from(headersSet).join(", "),
);
}
}),
},

View File

@@ -194,7 +194,7 @@ export async function authorizeMCPOAuth(
* This means the code now needs to be treated as a
* consent request.
*
* once the user consents, teh code will be updated
* once the user consents, the code will be updated
* with the actual code. This is to prevent the
* client from using the code before the user
* consents.

View File

@@ -446,6 +446,10 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
} satisfies AuthPluginSchema)
: undefined;
/**
* the orgMiddleware type-asserts an empty object representing org options, roles, and a getSession function.
* This `shimContext` function is used to add those missing properties to the context object.
*/
const api = shimContext(endpoints, {
orgOptions: options || {},
roles,

View File

@@ -140,7 +140,9 @@ export const username = (options?: UsernameOptions) => {
// Hash password to prevent timing attacks from revealing valid usernames
// By hashing passwords for invalid usernames, we ensure consistent response times
await ctx.context.password.hash(ctx.body.password);
ctx.context.logger.error("User not found", { username });
ctx.context.logger.error("User not found", {
username: ctx.body.username,
});
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD,
});
@@ -176,7 +178,9 @@ export const username = (options?: UsernameOptions) => {
}
const currentPassword = account?.password;
if (!currentPassword) {
ctx.context.logger.error("Password not found", { username });
ctx.context.logger.error("Password not found", {
username: ctx.body.username,
});
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD,
});

View File

@@ -94,7 +94,7 @@ export const github = (options: GithubOptions) => {
clientKey: options.clientKey,
clientSecret: options.clientSecret,
},
tokenEndpoint: "https://github.com/login/oauth/token",
tokenEndpoint: "https://github.com/login/oauth/access_token",
});
},
async getUserInfo(token) {

View File

@@ -0,0 +1,114 @@
import { betterFetch } from "@better-fetch/fetch";
import type { OAuthProvider, ProviderOptions } from "../oauth2";
import {
createAuthorizationURL,
validateAuthorizationCode,
refreshAccessToken,
} from "../oauth2";
export interface HuggingFaceProfile {
sub: string;
name: string;
preferred_username: string;
profile: string;
picture: string;
website?: string;
email?: string;
email_verified?: boolean;
isPro: boolean;
canPay?: boolean;
orgs?: {
sub: string;
name: string;
picture: string;
preferred_username: string;
isEnterprise: boolean | "plus";
canPay?: boolean;
roleInOrg?: "admin" | "write" | "contributor" | "read";
pendingSSO?: boolean;
missingMFA?: boolean;
resourceGroups?: {
sub: string;
name: string;
role: "admin" | "write" | "contributor" | "read";
}[];
};
}
export interface HuggingFaceOptions
extends ProviderOptions<HuggingFaceProfile> {}
export const huggingface = (options: HuggingFaceOptions) => {
return {
id: "huggingface",
name: "Hugging Face",
createAuthorizationURL({ state, scopes, codeVerifier, redirectURI }) {
const _scopes = options.disableDefaultScope
? []
: ["openid", "profile", "email"];
options.scope && _scopes.push(...options.scope);
scopes && _scopes.push(...scopes);
return createAuthorizationURL({
id: "huggingface",
options,
authorizationEndpoint: "https://huggingface.co/oauth/authorize",
scopes: _scopes,
state,
codeVerifier,
redirectURI,
});
},
validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
return validateAuthorizationCode({
code,
codeVerifier,
redirectURI,
options,
tokenEndpoint: "https://huggingface.co/oauth/token",
});
},
refreshAccessToken: options.refreshAccessToken
? options.refreshAccessToken
: async (refreshToken) => {
return refreshAccessToken({
refreshToken,
options: {
clientId: options.clientId,
clientKey: options.clientKey,
clientSecret: options.clientSecret,
},
tokenEndpoint: "https://huggingface.co/oauth/token",
});
},
async getUserInfo(token) {
if (options.getUserInfo) {
return options.getUserInfo(token);
}
const { data: profile, error } = await betterFetch<HuggingFaceProfile>(
"https://huggingface.co/oauth/userinfo",
{
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
},
);
if (error) {
return null;
}
const userMap = await options.mapProfileToUser?.(profile);
return {
user: {
id: profile.sub,
name: profile.name || profile.preferred_username,
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified ?? false,
...userMap,
},
data: profile,
};
},
options,
} satisfies OAuthProvider<HuggingFaceProfile>;
};

View File

@@ -4,6 +4,7 @@ import { discord } from "./discord";
import { facebook } from "./facebook";
import { github } from "./github";
import { google } from "./google";
import { huggingface } from "./huggingface";
import { microsoft } from "./microsoft-entra-id";
import { spotify } from "./spotify";
import { twitch } from "./twitch";
@@ -25,6 +26,7 @@ export const socialProviders = {
github,
microsoft,
google,
huggingface,
spotify,
twitch,
twitter,
@@ -76,5 +78,6 @@ export * from "./roblox";
export * from "./vk";
export * from "./zoom";
export * from "./kick";
export * from "./huggingface";
export type SocialProviderList = typeof socialProviderList;

View File

@@ -0,0 +1,244 @@
import { afterAll } from "vitest";
import { betterAuth } from "../auth";
import { createAuthClient } from "../client/vanilla";
import type { BetterAuthOptions, ClientOptions, Session, User } from "../types";
import { getMigrations } from "../db/get-migration";
import { parseSetCookieHeader, setCookieToHeader } from "../cookies";
import type { SuccessContext } from "@better-fetch/fetch";
import { getAdapter } from "../db/utils";
import { getBaseURL } from "../utils/url";
import { Kysely, MysqlDialect, PostgresDialect, sql } from "kysely";
import { Pool } from "pg";
import { MongoClient } from "mongodb";
import { mongodbAdapter } from "../adapters/mongodb-adapter";
import { createPool } from "mysql2/promise";
import { bearer } from "../plugins";
export async function getTestInstanceMemory<
O extends Partial<BetterAuthOptions>,
C extends ClientOptions,
>(
options?: O,
config?: {
clientOptions?: C;
port?: number;
disableTestUser?: boolean;
testUser?: Partial<User>;
testWith?: "sqlite" | "postgres" | "mongodb" | "mysql" | "memory";
},
) {
const testWith = config?.testWith || "memory";
const postgres = new Kysely({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: "postgres://user:password@localhost:5432/better_auth",
}),
}),
});
const mysql = new Kysely({
dialect: new MysqlDialect(
createPool("mysql://user:password@localhost:3306/better_auth"),
),
});
async function mongodbClient() {
const dbClient = async (connectionString: string, dbName: string) => {
const client = new MongoClient(connectionString);
await client.connect();
const db = client.db(dbName);
return db;
};
const db = await dbClient("mongodb://127.0.0.1:27017", "better-auth");
return db;
}
const opts = {
socialProviders: {
github: {
clientId: "test",
clientSecret: "test",
},
google: {
clientId: "test",
clientSecret: "test",
},
},
secret: "better-auth.secret",
database:
testWith === "postgres"
? { db: postgres, type: "postgres" }
: testWith === "mongodb"
? mongodbAdapter(await mongodbClient())
: testWith === "mysql"
? { db: mysql, type: "mysql" }
: undefined,
emailAndPassword: {
enabled: true,
},
rateLimit: {
enabled: false,
},
advanced: {
cookies: {},
},
} satisfies BetterAuthOptions;
const auth = betterAuth({
baseURL: "http://localhost:" + (config?.port || 3000),
...opts,
...options,
advanced: {
disableCSRFCheck: true,
...options?.advanced,
},
plugins: [bearer(), ...(options?.plugins || [])],
} as O extends undefined ? typeof opts : O & typeof opts);
const testUser = {
email: "test@test.com",
password: "test123456",
name: "test user",
...config?.testUser,
};
async function createTestUser() {
if (config?.disableTestUser) {
return;
}
//@ts-expect-error
const res = await auth.api.signUpEmail({
body: testUser,
});
}
if (testWith !== "mongodb" && testWith !== "memory") {
const { runMigrations } = await getMigrations({
...auth.options,
database: opts.database,
});
await runMigrations();
}
await createTestUser();
afterAll(async () => {
if (testWith === "mongodb") {
const db = await mongodbClient();
await db.dropDatabase();
return;
}
if (testWith === "postgres") {
await sql`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`.execute(
postgres,
);
await postgres.destroy();
return;
}
if (testWith === "mysql") {
await sql`SET FOREIGN_KEY_CHECKS = 0;`.execute(mysql);
const tables = await mysql.introspection.getTables();
for (const table of tables) {
// @ts-expect-error
await mysql.deleteFrom(table.name).execute();
}
await sql`SET FOREIGN_KEY_CHECKS = 1;`.execute(mysql);
return;
}
});
async function signInWithTestUser() {
if (config?.disableTestUser) {
throw new Error("Test user is disabled");
}
let headers = new Headers();
const setCookie = (name: string, value: string) => {
const current = headers.get("cookie");
headers.set("cookie", `${current || ""}; ${name}=${value}`);
};
//@ts-expect-error
const { data, error } = await client.signIn.email({
email: testUser.email,
password: testUser.password,
fetchOptions: {
//@ts-expect-error
onSuccess(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
const signedCookie = cookies.get("better-auth.session_token")?.value;
headers.set("cookie", `better-auth.session_token=${signedCookie}`);
},
},
});
return {
session: data.session as Session,
user: data.user as User,
headers,
setCookie,
};
}
async function signInWithUser(email: string, password: string) {
let headers = new Headers();
//@ts-expect-error
const { data } = await client.signIn.email({
email,
password,
fetchOptions: {
//@ts-expect-error
onSuccess(context) {
const header = context.response.headers.get("set-cookie");
const cookies = parseSetCookieHeader(header || "");
const signedCookie = cookies.get("better-auth.session_token")?.value;
headers.set("cookie", `better-auth.session_token=${signedCookie}`);
},
},
});
return {
res: data as {
user: User;
session: Session;
},
headers,
};
}
const customFetchImpl = async (
url: string | URL | Request,
init?: RequestInit,
) => {
return auth.handler(new Request(url, init));
};
function sessionSetter(headers: Headers) {
return (context: SuccessContext) => {
const header = context.response.headers.get("set-cookie");
if (header) {
const cookies = parseSetCookieHeader(header || "");
const signedCookie = cookies.get("better-auth.session_token")?.value;
headers.set("cookie", `better-auth.session_token=${signedCookie}`);
}
};
}
const client = createAuthClient({
...(config?.clientOptions as C extends undefined ? {} : C),
baseURL: getBaseURL(
options?.baseURL || "http://localhost:" + (config?.port || 3000),
options?.basePath || "/api/auth",
),
fetchOptions: {
customFetchImpl,
},
});
return {
auth,
client,
testUser,
signInWithTestUser,
signInWithUser,
cookieSetter: setCookieToHeader,
customFetchImpl,
sessionSetter,
db: await getAdapter(auth.options),
};
}

View File

@@ -498,6 +498,12 @@ export type BetterAuthOptions = {
* @default false
*/
allowUnlinkingAll?: boolean;
/**
* If enabled (true), this will update the user information based on the newly linked account
*
* @default false
*/
updateUserInfoOnLink?: boolean;
};
};
/**

View File

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

View File

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

View File

@@ -224,4 +224,15 @@ describe("expo with cookieCache", async () => {
expires: expect.any(String),
});
});
it("should add `exp://` to trusted origins", async () => {
vi.stubEnv("NODE_ENV", "development");
const auth = betterAuth({
plugins: [expo()],
trustedOrigins: ["http://localhost:3000"],
});
const ctx = await auth.$context;
expect(ctx.options.trustedOrigins).toContain("exp://");
expect(ctx.options.trustedOrigins).toContain("http://localhost:3000");
});
});

View File

@@ -13,9 +13,8 @@ export const expo = (options?: ExpoOptions) => {
id: "expo",
init: (ctx) => {
const trustedOrigins =
process.env.NODE_ENV === "development"
? [...(ctx.trustedOrigins || []), "exp://"]
: ctx.trustedOrigins;
process.env.NODE_ENV === "development" ? ["exp://"] : [];
return {
options: {
trustedOrigins,

View File

@@ -0,0 +1,12 @@
import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
declaration: true,
rollup: {
emitCJS: true,
},
outDir: "dist",
clean: false,
failOnWarn: false,
externals: ["better-auth", "better-call", "@better-fetch/fetch", "stripe"],
});

64
packages/sso/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@better-auth/sso",
"author": "Bereket Engida",
"version": "1.2.10",
"main": "dist/index.cjs",
"license": "MIT",
"keywords": [
"sso",
"auth",
"sso",
"saml",
"oauth",
"oidc",
"openid",
"openid connect",
"openid connect",
"single sign on"
],
"module": "dist/index.mjs",
"description": "SSO plugin for Better Auth",
"scripts": {
"test": "vitest",
"build": "unbuild",
"typecheck": "tsc --noEmit",
"dev": "unbuild --watch"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.mjs",
"require": "./dist/client.cjs"
}
},
"typesVersions": {
"*": {
"*": [
"./dist/index.d.ts"
],
"client": [
"./dist/client.d.ts"
]
}
},
"dependencies": {
"@better-fetch/fetch": "^1.1.18",
"better-auth": "workspace:^",
"fast-xml-parser": "^5.2.5",
"jose": "^5.9.6",
"oauth2-mock-server": "^7.2.0",
"samlify": "^2.10.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.3",
"better-call": "catalog:",
"vitest": "^1.6.0"
}
}

View File

@@ -0,0 +1,8 @@
import type { BetterAuthClientPlugin } from "better-auth";
import { sso } from "./index";
export const ssoClient = () => {
return {
id: "sso-client",
$InferServerPlugin: {} as ReturnType<typeof sso>,
} satisfies BetterAuthClientPlugin;
};

1377
packages/sso/src/index.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,488 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { sso } from ".";
import { OAuth2Server } from "oauth2-mock-server";
import { betterFetch } from "@better-fetch/fetch";
import { organization } from "better-auth/plugins/organization";
import { getTestInstanceMemory } from "better-auth/test";
let server = new OAuth2Server();
describe("SSO", async () => {
const { auth, signInWithTestUser, customFetchImpl } =
await getTestInstanceMemory({
plugins: [sso(), organization()],
});
beforeAll(async () => {
await server.issuer.keys.generate("RS256");
server.issuer.on;
await server.start(8080, "localhost");
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
});
afterAll(async () => {
await server.stop().catch(() => {});
});
server.service.on("beforeUserinfo", (userInfoResponse, req) => {
userInfoResponse.body = {
email: "oauth2@test.com",
name: "OAuth2 Test",
sub: "oauth2",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
server.service.on("beforeTokenSigning", (token, req) => {
token.payload.email = "sso-user@localhost:8000.com";
token.payload.email_verified = true;
token.payload.name = "Test User";
token.payload.picture = "https://test.com/picture.png";
});
async function simulateOAuthFlow(
authUrl: string,
headers: Headers,
fetchImpl?: (...args: any) => any,
) {
let location: string | null = null;
await betterFetch(authUrl, {
method: "GET",
redirect: "manual",
onError(context) {
location = context.response.headers.get("location");
},
});
if (!location) throw new Error("No redirect location found");
let callbackURL = "";
await betterFetch(location, {
method: "GET",
customFetchImpl: fetchImpl || customFetchImpl,
headers,
onError(context) {
callbackURL = context.response.headers.get("location") || "";
},
});
return callbackURL;
}
it("should register a new SSO provider", async () => {
const { headers } = await signInWithTestUser();
const provider = await auth.api.registerSSOProvider({
body: {
issuer: server.issuer.url!,
domain: "localhost.com",
oidcConfig: {
clientId: "test",
clientSecret: "test",
authorizationEndpoint: `${server.issuer.url}/authorize`,
tokenEndpoint: `${server.issuer.url}/token`,
jwksEndpoint: `${server.issuer.url}/jwks`,
discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`,
},
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
providerId: "test",
},
headers,
});
expect(provider).toMatchObject({
id: expect.any(String),
issuer: "http://localhost:8080",
oidcConfig: {
issuer: "http://localhost:8080",
clientId: "test",
clientSecret: "test",
authorizationEndpoint: "http://localhost:8080/authorize",
tokenEndpoint: "http://localhost:8080/token",
jwksEndpoint: "http://localhost:8080/jwks",
discoveryEndpoint:
"http://localhost:8080/.well-known/openid-configuration",
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
},
userId: expect.any(String),
});
});
it("should fail to register a new SSO provider with invalid issuer", async () => {
const { headers } = await signInWithTestUser();
try {
await auth.api.registerSSOProvider({
body: {
issuer: "invalid",
domain: "localhost",
providerId: "test",
oidcConfig: {
clientId: "test",
clientSecret: "test",
},
},
headers,
});
} catch (e) {
expect(e).toMatchObject({
status: "BAD_REQUEST",
body: {
message: "Invalid issuer. Must be a valid URL",
},
});
}
});
it("should sign in with SSO provider with email matching", async () => {
const res = await auth.api.signInSSO({
body: {
email: "my-email@localhost.com",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const headers = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, headers);
expect(callbackURL).toContain("/dashboard");
});
it("should sign in with SSO provider with domain", async () => {
const res = await auth.api.signInSSO({
body: {
email: "my-email@test.com",
domain: "localhost.com",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const headers = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, headers);
expect(callbackURL).toContain("/dashboard");
});
it("should sign in with SSO provider with providerId", async () => {
const res = await auth.api.signInSSO({
body: {
providerId: "test",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const headers = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, headers);
expect(callbackURL).toContain("/dashboard");
});
});
describe("SSO disable implicit sign in", async () => {
const { auth, signInWithTestUser, customFetchImpl } =
await getTestInstanceMemory({
plugins: [sso({ disableImplicitSignUp: true }), organization()],
});
beforeAll(async () => {
await server.issuer.keys.generate("RS256");
server.issuer.on;
await server.start(8080, "localhost");
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
});
afterAll(async () => {
await server.stop();
});
server.service.on("beforeUserinfo", (userInfoResponse, req) => {
userInfoResponse.body = {
email: "oauth2@test.com",
name: "OAuth2 Test",
sub: "oauth2",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
server.service.on("beforeTokenSigning", (token, req) => {
token.payload.email = "sso-user@localhost:8000.com";
token.payload.email_verified = true;
token.payload.name = "Test User";
token.payload.picture = "https://test.com/picture.png";
});
async function simulateOAuthFlow(
authUrl: string,
headers: Headers,
fetchImpl?: (...args: any) => any,
) {
let location: string | null = null;
await betterFetch(authUrl, {
method: "GET",
redirect: "manual",
onError(context) {
location = context.response.headers.get("location");
},
});
if (!location) throw new Error("No redirect location found");
let callbackURL = "";
await betterFetch(location, {
method: "GET",
customFetchImpl: fetchImpl || customFetchImpl,
headers,
onError(context) {
callbackURL = context.response.headers.get("location") || "";
},
});
return callbackURL;
}
it("should register a new SSO provider", async () => {
const { headers } = await signInWithTestUser();
const provider = await auth.api.registerSSOProvider({
body: {
issuer: server.issuer.url!,
domain: "localhost.com",
oidcConfig: {
clientId: "test",
clientSecret: "test",
authorizationEndpoint: `${server.issuer.url}/authorize`,
tokenEndpoint: `${server.issuer.url}/token`,
jwksEndpoint: `${server.issuer.url}/jwks`,
},
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
providerId: "test",
},
headers,
});
expect(provider).toMatchObject({
id: expect.any(String),
issuer: "http://localhost:8080",
oidcConfig: {
issuer: "http://localhost:8080",
clientId: "test",
clientSecret: "test",
authorizationEndpoint: "http://localhost:8080/authorize",
tokenEndpoint: "http://localhost:8080/token",
jwksEndpoint: "http://localhost:8080/jwks",
discoveryEndpoint:
"http://localhost:8080/.well-known/openid-configuration",
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
},
userId: expect.any(String),
});
});
it("should not create user with SSO provider when sign ups are disabled", async () => {
const res = await auth.api.signInSSO({
body: {
email: "my-email@localhost.com",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const headers = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, headers);
expect(callbackURL).toContain(
"/api/auth/error/error?error=signup disabled",
);
});
it("should create user with SSO provider when sign ups are disabled but sign up is requested", async () => {
const res = await auth.api.signInSSO({
body: {
email: "my-email@localhost.com",
callbackURL: "/dashboard",
requestSignUp: true,
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const headers = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, headers);
expect(callbackURL).toContain("/dashboard");
});
});
describe("provisioning", async (ctx) => {
const { auth, signInWithTestUser, customFetchImpl } =
await getTestInstanceMemory({
plugins: [sso(), organization()],
});
beforeAll(async () => {
await server.issuer.keys.generate("RS256");
server.issuer.on;
await server.start(8080, "localhost");
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
});
afterAll(async () => {
await server.stop();
});
async function simulateOAuthFlow(
authUrl: string,
headers: Headers,
fetchImpl?: (...args: any) => any,
) {
let location: string | null = null;
await betterFetch(authUrl, {
method: "GET",
redirect: "manual",
onError(context) {
location = context.response.headers.get("location");
},
});
if (!location) throw new Error("No redirect location found");
let callbackURL = "";
await betterFetch(location, {
method: "GET",
customFetchImpl: fetchImpl || customFetchImpl,
headers,
onError(context) {
callbackURL = context.response.headers.get("location") || "";
},
});
return callbackURL;
}
server.service.on("beforeUserinfo", (userInfoResponse, req) => {
userInfoResponse.body = {
email: "test@localhost.com",
name: "OAuth2 Test",
sub: "oauth2",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
server.service.on("beforeTokenSigning", (token, req) => {
token.payload.email = "sso-user@localhost:8000.com";
token.payload.email_verified = true;
token.payload.name = "Test User";
token.payload.picture = "https://test.com/picture.png";
});
it("should provision user", async () => {
const { headers } = await signInWithTestUser();
const organization = await auth.api.createOrganization({
body: {
name: "Localhost",
slug: "localhost",
},
headers,
});
const provider = await auth.api.registerSSOProvider({
body: {
issuer: server.issuer.url!,
domain: "localhost.com",
oidcConfig: {
clientId: "test",
clientSecret: "test",
authorizationEndpoint: `${server.issuer.url}/authorize`,
tokenEndpoint: `${server.issuer.url}/token`,
jwksEndpoint: `${server.issuer.url}/jwks`,
},
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
providerId: "test2",
organizationId: organization?.id,
},
headers,
});
expect(provider).toMatchObject({
organizationId: organization?.id,
});
const res = await auth.api.signInSSO({
body: {
email: "my-email@localhost.com",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest",
);
const newHeaders = new Headers();
const callbackURL = await simulateOAuthFlow(res.url, newHeaders);
expect(callbackURL).toContain("/dashboard");
const org = await auth.api.getFullOrganization({
query: {
organizationId: organization?.id || "",
},
headers,
});
const member = org?.members.find(
(m: any) => m.user.email === "sso-user@localhost:8000.com",
);
expect(member).toMatchObject({
role: "member",
user: {
id: expect.any(String),
name: "Test User",
email: "sso-user@localhost:8000.com",
image: "https://test.com/picture.png",
},
});
});
it("should sign in with SSO provide with org slug", async () => {
const res = await auth.api.signInSSO({
body: {
organizationSlug: "localhost",
callbackURL: "/dashboard",
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
});
});

View File

@@ -0,0 +1,733 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";
import { createAuthClient } from "better-auth/client";
import { setCookieToHeader } from "better-auth/cookies";
import { bearer } from "better-auth/plugins";
import { IdentityProvider, ServiceProvider } from "samlify";
import { sso } from ".";
import { ssoClient } from "./client";
import { createServer } from "http";
import * as saml from "samlify";
import express from "express";
import bodyParser from "body-parser";
import { randomUUID } from "crypto";
const spMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:3001/api/sso/saml2/sp/metadata">
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIE3jCCAsYCCQDE5FzoAkixzzANBgkqhkiG9w0BAQsFADAxMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzAeFw0yMzExMTkxMjUyMTVaFw0zMzExMTYxMjUyMTVaMDExCzAJBgNVBAYTAlVTMRAwDgYDVQQIDAdGbG9yaWRhMRAwDgYDVQQHDAdPcmxhbmRvMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA2ELJsLZs4yBH7a2U5pA7xw+Oiut7b/ROKh2BqSTKRbEG4xy7WwljT02Mh7GTjLvswtZSUObWFO5v14HNORa3+J9JT2DH+9F+FJ770HX8a3cKYBNQt3xP4IeUyjI3QWzrGtkYPwSZ74tDpAUtuqPAxtoCaZXFDtX6lvCJDqiPnfxRZrKkepYWINSwu4DRpg6KoiPWRCYTsEcCzImInzlACdM97jpG1gLGA6a4dmjalQbRtvC56N0Z56gIhYq2F5JdzB2a10pqoIY8ggXZGIJS9I++8mmdTj6So5pPxLwnCYUhwDew1/DMbi9xIwYozs9pEtHCTn1l34jldDwTziVAxGQZO7QUuoMl997zqcPS7pVWRnfz5odKuytLvQDA0lRVfzOxtqbM3qVhoLT2iDmnuEtlZzgfbt4WEuT2538qxZJkFRpZQIrTj3ybqmWAv36Cp49dfeMwaqjhfX7/mVfbsPMSC653DSZBB+n+Uz0FC3QhH+vIdNhXNAQ5tBseHUR6pXiMnLtI/WVbMvpvFwK2faFTcx1oaP/Qk6yCq66tJvPbnatT9qGF8rdBJmAk9aBdQTI+hAh5mDtDweCrgVL+Tm/+Q85hSl4HGzH/LhLVS478tZVX+o+0yorZ35LCW3e4v8iX+1VEGSdg2ooOWtbSSXK2cYZr8ilyUQp0KueenR0CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAsonAahruWuHlYbDNQVD0ryhL/b+ttKKqVeT87XYDkvVhlSSSVAKcCwK/UU6z8Ty9dODUkd93Qsbof8fGMlXeYCtDHMRanvWLtk4wVkAMyNkDYHzJ1FbO7v44ZBbqNzSLy2kosbRELlcz+P3/42xumlDqAw/k13tWUdlLDxb0pd8R5yBev6HkIdJBIWtKmUuI+e8F/yTNf5kY7HO1p0NeKdVeZw4Ydw33+BwVxVNmhIxzdP5ZFQv0XRFWhCMo/6RLEepCvWUp/T1WRFqgwAdURaQrvvfpjO/Ls+neht1SWDeP8RRgsDrXIc3gZfaD8q4liIDTZ6HsFi7FmLbZatU8jJ4pCstxQLCvmix+1zF6Fwa9V5OApSTbVqBOsDZbJxeAoSzy5Wx28wufAZT4Kc/OaViXPV5o/ordPs4EYKgd/eNFCgIsZYXe75rYXqnieAIfJEGddsLBpqlgLkwvf5KVS4QNqqX+2YubP63y+3sICq2ScdhO3LZs3nlqQ/SgMiJnCBbDUDZ9GGgJNJVVytcSz5IDQHeflrq/zTt1c4q1DO3CS7mimAnTCjetERRQ3mgY/2hRiuCDFj3Cy7QMjFs3vBsbWrjNWlqyveFmHDRkq34Om7eA2jl3LZ5u7vSm0/ylp/vtoysMjwEmw/0NA3hZPTG3OJxcvFcXBsz0SiFcd1U=</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/sls"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:3001/api/sso/saml2/sp/acs" index="1"/>
</md:SPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en-US">Organization Name</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en-US">Organization DisplayName</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en-US">http://localhost:3001/</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:GivenName>Technical Contact Name</md:GivenName>
<md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
<md:ContactPerson contactType="support">
<md:GivenName>Support Contact Name</md:GivenName>
<md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
`;
const idpMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://localhost:8081/api/sso/saml2/idp/metadata">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIFOjCCAyICCQCqP5DN+xQZDjANBgkqhkiG9w0BAQsFADBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wHhcNMjMxMTE5MTIzNzE3WhcNMzMxMTE2MTIzNzE3WjBfMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRmxvcmlkYTEQMA4GA1UEBwwHT3JsYW5kbzENMAsGA1UECgwEVGVzdDEdMBsGCSqGSIb3DQEJARYOdGVzdEBnbWFpbC5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD5giLoLyED41IHt0RxB/k6x4K0vzAKiGecPyedRNR1oyiv3OYkuG5jgTE2wcPZc7kD1Eg5d6th0BWHy/ovaNS5mkgnOV6jKkMaWW4sCMSnLnaWy0seftPK3O4mNeZpM5e9amj2gXnZvKrK8cqnJ/bsUUQvXxttXNVVmOHWg/t3c2vJ4XuUfph6wIKbrj297ILzuAFRNvAVxeS0tElwepvZ5Wbf7Hc1MORAqTpw/mp8cRjHRzYCA9y6OM4hgVs1gvTJS8WGoMmsdAZHaOnv9vLJvW3jDLQQecOheYIJncWgcESzJFIkmXadorYCEfWhwwBdVphknmeLr4BMpJBclAYaFjYDLIKpMcXYO5k/2r3BgSPlw4oqbxbR5geD05myKYtZ/wNUtku118NjhIfJFulU/kfDcp1rYYkvzgBfqr80wgNps4oQzVr1mnpgHsSTAhXMuZbaTByJRmPqecyvyQqRQcRIN0oTLJNGyzoUf0RkH6DKJ4+7qDhlq4Zhlfso9OFMv9xeONfIrJo5HtTfFZfidkXZqir2ZqwqNlNOMfK5DsYq37x2Gkgqig4nqLpITXyxfnQpL2HsaoFrlctt/OL+Zqba7NT4heYk9GX8qlAS+Ipsv6T2HSANbah55oSS3uvcrDOug2Zq7+GYMLKS1IKUKhwX+wLMxmMwSJQ9ZgFwfQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCkGPZdflocTSXIe5bbehsBn/IPdyb38eH2HaAvWqO2XNcDcq+6/uLc8BVK4JMa3AFS9xtBza7MOXN/lw/Ccb8uJGVNUE31+rTvsJaDtMCQkp+9aG04I1BonEHfSB0ANcTy/Gp+4hKyFCd6x35uyPO7CWX5Z8I87q9LF6Dte3/v1j7VZgDjAi9yHpBJv9Xje33AK1vF+WmEfDUOi8y2B8htVeoyS3owln3ZUbnmJdCmMp2BMRq63ymINwklEaYaNrp1L201bSqNdKZF2sNwROWyDX+WFYgufrnzPYb6HS8gYb4oEZmaG5cBM7Hs730/3BlbHKhxNTy1Io2TVCYcMQD+ieiVg5e5eGTwaPYGuVvY3NVhO8FaYBG7K2NT2hqutdCMaQpGyHEzbbbTY1afhbeMmWWqivRnVJNDv4kgBc2SE8JO82qHikIW9Om0cghC5xwTT+1JTtxxD1KeC1M1IwLzzuuMmwJSKAsv4duDqN+YRIP78J2SlrssqlsmoF8+48e7Vzr7JRT/Ya274P8RpUPNtxTR7WDmZ4tunqXjiBpz6l0uTtVXnj5UBo4HCyRjWJOGf15OCuQX03qz8tKn1IbZUf723qrmSF+cxBwHqpAywqhTSsaLjIXKnQ0UlMov7QWb0a5N07JZMdMSerbHvbXd/z9S1Ssea2+EGuTYuQur3A==</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/slo"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8081/api/sso/saml2/idp/redirect"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8081/api/sso/saml2/idp/post"/>
</md:IDPSSODescriptor>
<md:Organization>
<md:OrganizationName xml:lang="en-US">Your Organization Name</md:OrganizationName>
<md:OrganizationDisplayName xml:lang="en-US">Your Organization DisplayName</md:OrganizationDisplayName>
<md:OrganizationURL xml:lang="en-US">http://localhost:8081</md:OrganizationURL>
</md:Organization>
<md:ContactPerson contactType="technical">
<md:GivenName>Technical Contact Name</md:GivenName>
<md:EmailAddress>technical_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
<md:ContactPerson contactType="support">
<md:GivenName>Support Contact Name</md:GivenName>
<md:EmailAddress>support_contact@gmail.com</md:EmailAddress>
</md:ContactPerson>
</md:EntityDescriptor>
`;
const idPk = `
-----BEGIN RSA PRIVATE KEY-----
MIIJKgIBAAKCAgEA+YIi6C8hA+NSB7dEcQf5OseCtL8wCohnnD8nnUTUdaMor9zm
JLhuY4ExNsHD2XO5A9RIOXerYdAVh8v6L2jUuZpIJzleoypDGlluLAjEpy52lstL
Hn7TytzuJjXmaTOXvWpo9oF52byqyvHKpyf27FFEL18bbVzVVZjh1oP7d3NryeF7
lH6YesCCm649veyC87gBUTbwFcXktLRJcHqb2eVm3+x3NTDkQKk6cP5qfHEYx0c2
AgPcujjOIYFbNYL0yUvFhqDJrHQGR2jp7/byyb1t4wy0EHnDoXmCCZ3FoHBEsyRS
JJl2naK2AhH1ocMAXVaYZJ5ni6+ATKSQXJQGGhY2AyyCqTHF2DuZP9q9wYEj5cOK
Km8W0eYHg9OZsimLWf8DVLZLtdfDY4SHyRbpVP5Hw3Kda2GJL84AX6q/NMIDabOK
EM1a9Zp6YB7EkwIVzLmW2kwciUZj6nnMr8kKkUHESDdKEyyTRss6FH9EZB+gyieP
u6g4ZauGYZX7KPThTL/cXjjXyKyaOR7U3xWX4nZF2aoq9masKjZTTjHyuQ7GKt+8
dhpIKooOJ6i6SE18sX50KS9h7GqBa5XLbfzi/mam2uzU+IXmJPRl/KpQEviKbL+k
9h0gDW2oeeaEkt7r3KwzroNmau/hmDCyktSClCocF/sCzMZjMEiUPWYBcH0CAwEA
AQKCAgABJVzdriG7r9aXnHre/gdiArqR8/LXiYrYR935tfA33hj4vc38yzAOmvBL
7RXmMMbfwqDWSrtpxpfiuMgcYaHgfFnqfDP4EeCfBVwhLaUhk3AN/z8IE9MLMnqR
iFvXjdobj5qNz0hs/JXYOsYQgHl82l6yzQAGP4/nRb17y71i7g/HrJZxtyciITI4
XtN/xM9RKT4wTk1J/E+xmMZhkt6WYJxZWO+vOdtChMR08mYwziAsAiK4XaYs4Mfp
lXuCwmg3aHauyJxEg3/n4g55AKxaytjvWwaUsMp6OmGjg6r9sqZOIFOUQXQvAylM
1yJGrOuagiRPCf81wAeZ0oOrOS7R+4fF4Ypa+V7Cp6Ty3VPcw8BFpXJ6fRtf92kh
ix00DnFEK/TdndyBpFKdmf8f2SSFBLrPlmTfjdMAvShE5yFpeWyXQjftI5q/0d3U
Ug0MBby66yT/TZtTKVPdK6bG3fYvzgKCpZGrKgn+umq4XR+gh9S0ptmwNF5mzJy4
mol5CkazGPlOSwlBc4oKeepcqZ0TKCJwonub90CJeH8IKoyRsswShRl6YTRza1SB
Fx4Gis5xcaNp7eXnLBDgKV/1bhCUSvQ886r+Xo4nfhk9n8WrtaQFC4tFID1e8TAM
jYxZIBpCHOZHX/+BpC3FyqD4RbI12iudyz4KwS5Ps/wlIpVMQQKCAQEA/70X3Fz2
SJyPP9UdiiqLot1ppbagQGjG20yFnfRDhNY+q2U8N77yJUXWvE7YQ6OUTOaPuJX2
X7vulTSQ0YyFYp0B5G4QiFtvPOpBvn7OxrFKBKxwbOU7L2rAuXWYEIRuKuwBRMFU
oaar8gkKlnsUtUxrLM827gmL13i3GX2bmm6NhhGCKbSCoD51+UUGo7Ix5ZLznKmX
G1mq4IxtJe8vLk/9RT9CzRV7VO61EgEh7Iji7g4cDIiZV+B9gG8YMlTOcALPpgud
nF7SEvDuMH3dgOj+iSO9piJ53okU59Mk4Nyka3p3v6RABMcDYO1/wkbE83+Oobrx
RiRQHtBgo1r9cQKCAQEA+cNpxVCi/BkatlzKentRabnQjfxrEQdIdc9xqzr5k2xK
w9n+XGzeNT+HKI/S1KkfvJTQC0j9WBQ3uupf8Zg6/mNF84YCXpun3JXpvzc+4ya3
i1AXtdul/JYU5qhMrJI+I1WXrWAls5zbIs23iz1Fq530Mb7FUQ5jmO0p123AmMGG
hSTJDqvKDMpQXdUYQMqrSL/aNh8u7wpw2S052uj2bdbdgq1FboLzbwWTOsVYs3aS
HABb95263Cf3OdRr4lyN6khFMLhQPUhYnn6l2ob0kIZ7V2f8fxKvJoTTDTxWpUgF
FrdHigaDo09WYkIukj+YdSZY/ZEAu7lyMmY0l8HNzQKCAQEA7HE3jlWknp2hE7NG
DGgpkfqDouKmZuZ4dGjbYJ5ljntGldCTTDcOSce4MYH0ERU8F51TY6XCk+B9RRXE
jvkMmY/wH/Ji9q8SuY8cGbPEGY/wj0Ge8A9AGSbp6I4AecT21lg9FARq6sneT3hs
gZRqIPT2YgdzEcFhuWWyY67uHmn4DuxBG634147oI/7dlJs75rVm5oElY/QTOGic
wWXSiU8LKurCKDqkPHI2lt7VLougw9fntu7UV5sGbahJBr/B3W277hjvL5O7Rifb
EJpOINFKBCE3RlK5ujWjTnK4te1JVtVzwYtqZQBa71KlvEkR7s8QYBcm22LXcKXX
szB9AQKCAQEAwUua8DoX6UMEiV4G1gPaXhiQb1KLCgK48XQ6ZGqf/JgyxKBRWvZm
go9H6vxkDnFVPn1tBU7XwvLirqX02uUVwwrReEaeTtnob68V2AbJhMLSCd9Sekwj
ifgc9OYLcQM9U9tKJ8PhacBbV/QduIUTBl6YPmeGDdU0/4WMfE1UYORlV2XAtLn/
BScOS5A/1OUE6qiQGJLJn/ZUn7+ApwrkrN09UYUH1x9BhwqphzJ0E3AQY9tjUZ+g
ngHQM9FSLT20Fz0XTz1V3BfBfehGM3l+jNuHWX4Ay9eJ9iWVsQihhgjW512w4AFq
n1knYaQWptjRBNlIxfUSvDYpSxgOW+SBgQKCAQEA7ikfNUZDmhyShcmIl9Hgcral
o2M/ggUVwWd9AaJD+Y/WcGoR0DPGt8UGLGTBNBwbyTgHdDzsWA+02r5r+5ArhhnP
iWQ1soQI9FpZIUCyzAjTQpfpzo5dGqpQbW9LuHJOEbDyY2wG+lFhIm4JJBJ/vws1
yt9Y170VbPXmDdLevDLmlFOILdMJWWl3hrtlU3KEogqWKDOXciYtG5Ji0+512BqH
yY9+uVNb1eu6MLU5R5U9GdvOFZZjShIhOlpZVR1K21dg5frBCWBZ0pvu4fZf2FAV
lX6+ORENSjqJsQWTaeiMoAPOj8QxQuOwUCajbVkrCZV6D49E0D9XxmZcuKCAXg==
-----END RSA PRIVATE KEY-----
`;
const spPrivateKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,9C86371F0420A091
77TqgiK/IYRgO5w3ZMyV81/gk0zN5wPTGWxoztNFjQKXCySFnrL30kUqlGituBxX
VgxwXbkoYMrd5MoDZKL5EJuf0H59hq92O0+3uwJA8QyZjOm4brQcjXKmIrkvihgs
FvpaJiGzp6kS/O7vFBDNTQWr9yY9Y3FBPcmOUWufpRp4Q5nhpSlqnMmIqZyWQUL/
YJSJETtzJVsk38lCqIxxDT3LtbGySahj0jRuRqspAZQeLTpnJqzNMC4vnJew9luC
R+UffrX7gVsnwOhNtyRzYaMsLnbRfXT8Jqx2gRHg36GxkOVgyU7e62nk9CzeC0WA
kHHCNVqqivRx9/EC0mQkkRgRzo3BZWp0o671sUsGTy57JhktiGfTnWMrl7ZfhAza
SZnjyTwuI1bTQipIkNI3aJBTP/o/gNUE1sj5D5FZlFdpq5ks2Vxww3GNx1FRrvWd
98z5CNt78ZR0ihLmdz/EakEBKBUteQu/5zPLUlwmGuou4wPuEHG2BsjGzb/d5Zfc
ElIjUV+yrMmGHvBfPyPnDUrCUyLn18S1NZiCMCdN5PqCybjhk8oMPYZhWBqp8Ymr
yHIC7BCnTJhIvgQZR6M68NwVv0aBBgH/I/DB0jADo6/B5Eajwus9i6zSv8QIbqhw
fusKtI04vxc91aP0GWRr0J/O4mkxXYNPfa3a/I7sGTXGl0k0CygckE3fLXRy/WEk
ikZt4UHqg5ZQ8vc5NSAM5f5Yx/72CU1I6ehFtxHsyE5yndpZXWp2X2S4l31e8fLs
ddOoybroJgbyLrh7JT3Yac3XOEsKATWIvqU+hNYq6KwqLWev9jInHVgjzfyOKbmF
hkrzDDHaKULYZuTsUq5mLc1SzSu98lXYfXp1WE4XsH0X0VicPzf8ZH4Kutuig0VG
5Kg9HB/Cin65VMm0ffEiTraO6johIlwFGRrtAs38ONKgsPCQUv7ee9SEGOHViNZq
NpWPr1KOzbI4wEB1ueKoZuEQ0a+tzfJgszJrM48bM82J6iEjN/PSOTsdTKJq9e47
dlUp+tqQsvGkbBOIOt5OOpkr8Z+8qbEd21ojF9Q0p0T4WMThRP6YBRKvt8mmFwRs
DjEhMiPa4L70Eqldfu2lWdI6ietfHrK97WXwQO1gF73LOnA+EdMXNxr1iLd0Tdke
z6fUSw3hKZL+I7nX6O40+KgkhXVSZOsRz5CEvo2iChIUrYGEGDl94K/ofqGu71Y+
G8KBvbha6EC7xcUrTYP5Gek5wsrw7cGgDZJjMsyXYFBZjQO1N6g9fncLmc5pB5Ix
W3gLfQS/My4daWNTvrYOgfA08J4M4ZWd0v5TglxOSV78psG4J4slppDySNFB2d/3
7JiwWVm5SMk0StLWwb2azmTvBoinnrZJzPnPlOytxvE5uGJ/i0WAik7C99YgVJkS
9hO3FJGasrOnHeiOvMZEdRuIVspKz9iMFx7hWHpVHTTyjwceEpaiEkhmqLM9QkKh
kCZqeWyVsKBIc0sse+CKNK8ik9eTeUlCklGMV1Q4kKjR6uuHUOLyjk/xhqslV4TS
jnnjCjsK5YzTa4hmbHhPZIW262KoFV9TqxYKkhP5ab7AXRSakrdrY2cwACWN4AMT
-----END RSA PRIVATE KEY-----
`;
const idpPrivateKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,116B0EBB2F2F0A9D
HMmUsJPVPTsq1e06yrrskfinY21OOHosfRzibLueBg9ByFFZ7+/oW/DKy1GcDeBc
ycL+3gylIoGUYuZ+DPC11ArjdxFqLFnHJb96rwy5h4sTP0lE+qHy+06AwsowUgp3
pdD2unPFeydpu5h/dqgoDzkGSucz0Ty/spHXNBvns0vJO18B7XlzXUtfH5aHco22
DyVY6FrJwMts9E4Rzs9JsxJJ7mi/6+Qsc0rOr8/6KKsRo1sKD6cvQIQ05dEvGrE9
/2fubHkRTl+zBqOVyQvC6iUtocwxlMP4KfmyYrD1wlQAnP/+smq2G+xf7uGc4X4P
8q0jEy2P9n5ASlwZ3XCS9hZgp8VRAcXWOYjzzNouQp3NEP9d5D3wN4aFKa/JW6pk
a6VwraEweuyJqvZ7nnam1emW0ge0z7hJabR0+j0PnUxFIwkI5jO3HI5UiuUzuQFe
2bTLA3XnJ7QD08ZKom0rmApbFrmm9BWBRTmt46NlQDy49VODPY4gFuQ/mpaFjaBy
fSNJaOSS/MDuAdPabNEh3l+yCGKtHIbPVIms76PxYf6o0VVxW96/Q25hrvyOJCxn
dVQyyJbQ1jGenu4ViDNrW9ZQfw4aJCPpY7lUQd09BGz2NMKgkrSl8bKSan4lvlF3
ok8BjfIw+pIrTyesPU5tF0YudDxwi8fbIG70iwrpsSt2wVIMa+Nz2lwFT1dV8be7
NARkkkhLWJYAsxsyVfdl+ucNSqhvo8xLITuG8CZnzKf0T2HMKnMNegFx/ipfM7ff
Mx5CjayN5Oy99MWsagYEutUGzCGPAuVpqYpJuuYa3lWbFk2XWihWkAiUwgRqIluE
M6LpO8l3LVXVjN1+6bK1GZpbfLay+E6vy4W38XMuXZSNpyhy6e+XggTPH2xbbwoi
OcAzcojhMaxVGpxm/aXyRxg9zBdrQjtqM/aCN91ri55bvOKxELVi+D/VcZKpd2CR
X/vWcqoGaK/6+vlPWMZSHCJkPa4KBT0aUcnEdeFWx2nmrwdrHvETzCYLAzVBSECV
ZoYH0xTkFr/RI2AOAzx701LSuYbnPoCq+w7TXtjPaooZdYVVgrYuI+j4JOlseFS7
1c9iRiJVPBfnpUNIZdHLw19+k81IJ/FmumiuDhfLS5pwQmtuXkO3DWZDa3UPlV8e
6dmZeP1XGwRLL9VpOKx7NCqZM+CdEt87CXpFFWXdw8tL+3K/2r8w4lHIzBKaVPSS
5uFqXc1vzfP6Qeov31IjeLPE1pWTHNqRPdmvt9Scq9tKS3o18wmLBxOVinOE0cxQ
oddzPd0z5NxNYVayqZORwDdVv6CVXKnrvBSnOFFslZqv1G8/diE5BXxeaAPEMcZE
3lD7MzdoEHK5oL2MXofLWZbNtMkOZLaLqY80zKT1UG3Gs8U44d44aLXO1dBL0HGX
dNfNUaH+IGZf2ccS6OR1RhwIazDZ8qk0XeUwQV588adwC3FUvscVA3eHZa95z4kX
xvHg+ylzRtKRfpSPzB2IVwgV9/rsOg0OmvwhV8+5IQpdcFr+hf2Bn6AVn6H9aX8A
JjycN6KMcHaFa0EUqagGm9tsQLmf/MGCj8sy9am1IbRmFCz5lB5A7P/YLPM2Csjg
-----END RSA PRIVATE KEY-----`;
const certificate = `
-----BEGIN CERTIFICATE-----
MIIDlzCCAn+gAwIBAgIJAO1ymQc33+bWMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV
BAYTAkhLMRMwEQYDVQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQ
cm92aWRlcjEUMBIGA1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDAeFw0x
NTA3MDUxODAyMjdaFw0xODA3MDQxODAyMjdaMGIxCzAJBgNVBAYTAkhLMRMwEQYD
VQQIDApTb21lLVN0YXRlMRowGAYDVQQKDBFJZGVudGl0eSBQcm92aWRlcjEUMBIG
A1UECwwLRGV2ZWxvcG1lbnQxDDAKBgNVBAMMA0lEUDCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBAODZsWhCe+yG0PalQPTUoD7yko5MTWMCRxJ8hSm2k7mG
3Eg/Y2v0EBdCmTw7iDCevRqUmbmFnq7MROyV4eriJzh0KabAdZf7/k6koghst3ZU
tWOwzshyxkBtWDwGmBpQGTGsKxJ8M1js3aSqNRXBT4OBWM9w2Glt1+8ty30RhYv3
pSF+/HHLH7Ac+vLSIAlokaFW34RWTcJ/8rADuRWlXih4GfnIu0W/ncm5nTSaJiRA
vr3dGDRO/khiXoJdbbOj7dHPULxVGbH9IbPK76TCwLbF7ikIMsPovVbTrpyL6vsb
VUKeEl/5GKppTwp9DLAOeoSYpCYkkDkYKu9TRQjF02MCAwEAAaNQME4wHQYDVR0O
BBYEFP2ut2AQdy6D1dwdwK740IHmbh38MB8GA1UdIwQYMBaAFP2ut2AQdy6D1dwd
wK740IHmbh38MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBANMZUoPN
mHzgja2PYkbvBYMHmpvUkVoiuvQ9cJPlqGTB2CRfG68BNNs/Clz8P7cIrAdkhCUw
i1rSBhDuslGFNrSaIpv6B10FpBuKwef3G7YrPWFNEN6khY7aHNWSTHqKgs1DrGef
2B9hvkrnHWbQVSVXrBFKe1wTCqcgGcOpYoSK7L8C6iX6uIA/uZYnVQ4NgBrizJ0a
zkjdegz3hwO/gt4malEURy8D85/AAVt6PAzhpb9VJUGxSXr/EfntVUEz3L2gUFWW
k1CnZFyz0rIOEt/zPmeAY8BLyd/Tjxm4Y+gwNazKq5y9AJS+m858b/nM4QdCnUE4
yyoWAJDUHiAmvFA=
-----END CERTIFICATE-----
`;
const idpEncyptionKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
-----END RSA PRIVATE KEY-----
`;
const spEncyptionKey = `
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: DES-EDE3-CBC,860FDB9F3BE14699
bMpTdWaAEqNciUFQhHYNv1F9N12aqOQd6cFbMozfRnNR19HW6QIPDmEOPSSCaaRy
QCnJhbpcSnaz9pvI7EzeJzdykDmR8Boos+0NSK9qIX0buBO55mfPr7hjx7bLFEVl
kkHk+k9F1rLyjyAGJrVoTNoWjyuMOFUCWR7ZxoYticwM/sL+Rbhn1FsfdkdfhFW0
08OHTouRK33Aifx0A3MWxR0ILvw49E6urtbbIrskEzKzfWQug8gY1TJhI3sbsMsI
1bS5Vg88TvilFFBGn0Yv6GEJjgOrsrKDGKtYGhuBfK4fd4rwnQKKvC6gTKeNXIfV
7Qm1R20LUJXC8zv35pdKoVk+NdS/MGNXJRFgO3Kkp01aVf3n1oo2+AllS02AYyWt
1svHecsRwbibXip8gSQsOtDdpqQrEDyqZlFHXEw/IcJE9vQWEJmpHD5GFhbKtttp
E0B3ZtNl6YcyUz0rSf9zjuMx/wReWdRb6H2WoIqoRS7vAUONDRPt7wvfjtLlDRVi
bc2RTN8yce/57lGnA1n8bxPV5+9VxCJOEipV3io/nrj+uNO8i/0rUpkKdZy8wy2C
Rksoxq4TxwegONz1HQcJVpJu0iBdu7B+BXVjxQQScvMQlOTbua8k+YdaCeZAb83j
JVX89/PFy+Xj7eGyzzBTqz7dV0Xkxq9mpiMYUCoyNL5Iq1jD9Xb5TzVW1Gbh8zCZ
YXjcZEQKeartaBC4/fRWyxqK3gJRX4SJkl4gYMQrPS2pbTzVCO+WLxSwIh3dOZpo
eErXLSrylIv9cE2Xrs0McXAR+hfGrqgtILBWwgbh2NhmUiFfLwUTUxU51eu7QZ2T
V1VFBX0QTmn2kM0JLSSC96mDUzbs6qfURUaXbuffF5cqdUjXgtzZj5SFEbIv4UFS
0DAS+6i/jTGSz7aAp/uofOxhYkCqK/s2Cex2jQbDpcKXKiWzPdULOCjAh3fdCAp0
3ua3fdAI7H8PslSDiPFrcY78OxZaWXzazEiun77WKbzrMloLMP5dpCPlUCOqxbZ0
ykSuo0M7p/UPY34yi3AMHS9grvQQ1DykMPoqKKEheI6nUGcQ1AFcdr307ILWRsPO
T6gHOLXZaR4+UEeYfkTKsjrMUhozx7JIyuLgTXA9TWC+tZ9WZpbJ7i3bpQ+RNwX2
AxQSwc9ZOcNxg8YCbGlJgJHnRVhA202kNT5ORplcRKqaOaO9LK7491gaaShjaspg
4THDnH+HHFORmbgwyO9P74wuw+n6tI40Ia3qzRLVz6sJBQMtLEN+cvNoNi3KYkNj
GJM1iWfSz6PjrEGxbzQZKoFPPiZrVRnVfPhBNyT2OZj+TJii9CaukhmkkA2/AJmS
5XoO3GNIaqOGYV9HLyh1++cn3NhjgFYe/Q3ORCTIg2Ltd8Qr6mYe0LcONQFgiv4c
AUOZtOq05fJDXE74R1JjYHPaQF6uZEbTF98jN9QZIfCEvDdv1nC83MvSwATi0j5S
LvdU/MSPaZ0VKzPc4JPwv72dveEPME6QyswKx9izioJVrQJr36YtmrhDlKR1WBny
ISbutnQPUN5fsaIsgKDIV3T7n6519t6brobcW5bdigmf5ebFeZJ16/lYy6V77UM5
-----END RSA PRIVATE KEY-----
`;
const generateRequestID = () => {
return "_" + randomUUID();
};
const createTemplateCallback =
(idp: any, sp: any, email: string) => (template: any) => {
const assertionConsumerServiceUrl =
sp.entityMeta.getAssertionConsumerService(
saml.Constants.wording.binding.post,
);
const nameIDFormat = idp.entitySetting.nameIDFormat;
const selectedNameIDFormat = Array.isArray(nameIDFormat)
? nameIDFormat[0]
: nameIDFormat;
const id = generateRequestID();
const now = new Date();
const fiveMinutesLater = new Date(now.getTime() + 5 * 60 * 1000);
const tagValues = {
ID: id,
AssertionID: generateRequestID(),
Destination: assertionConsumerServiceUrl,
Audience: sp.entityMeta.getEntityID(),
EntityID: sp.entityMeta.getEntityID(),
SubjectRecipient: assertionConsumerServiceUrl,
Issuer: idp.entityMeta.getEntityID(),
IssueInstant: now.toISOString(),
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
StatusCode: "urn:oasis:names:tc:SAML:2.0:status:Success",
ConditionsNotBefore: now.toISOString(),
ConditionsNotOnOrAfter: fiveMinutesLater.toISOString(),
SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater.toISOString(),
NameIDFormat: selectedNameIDFormat,
NameID: email,
InResponseTo: "null",
AuthnStatement: "",
attrFirstName: "Test",
attrLastName: "User",
attrEmail: "test@email.com",
};
return {
id,
context: saml.SamlLib.replaceTagsByValue(template, tagValues),
};
};
class MockSAMLIdP {
private app: express.Application;
private server: ReturnType<typeof createServer> | undefined;
private port: number;
private idp: ReturnType<typeof IdentityProvider>;
private sp: ReturnType<typeof ServiceProvider>;
constructor(port: number) {
this.port = port;
this.app = express();
this.app.use(bodyParser.urlencoded({ extended: true }));
this.app.use(bodyParser.json());
this.idp = IdentityProvider({
metadata: idpMetadata,
privateKey: idPk,
isAssertionEncrypted: false,
privateKeyPass: "jXmKf9By6ruLnUdRo90G",
loginResponseTemplate: {
context:
'<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="{ID}" Version="2.0" IssueInstant="{IssueInstant}" Destination="{Destination}" InResponseTo="{InResponseTo}"><saml:Issuer>{Issuer}</saml:Issuer><samlp:Status><samlp:StatusCode Value="{StatusCode}"/></samlp:Status><saml:Assertion ID="{AssertionID}" Version="2.0" IssueInstant="{IssueInstant}" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"><saml:Issuer>{Issuer}</saml:Issuer><saml:Subject><saml:NameID Format="{NameIDFormat}">{NameID}</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="{SubjectConfirmationDataNotOnOrAfter}" Recipient="{SubjectRecipient}" InResponseTo="{InResponseTo}"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="{ConditionsNotBefore}" NotOnOrAfter="{ConditionsNotOnOrAfter}"><saml:AudienceRestriction><saml:Audience>{Audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions>{AttributeStatement}</saml:Assertion></samlp:Response>',
attributes: [
{
name: "firstName",
valueTag: "firstName",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
{
name: "lastName",
valueTag: "lastName",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
{
name: "email",
valueTag: "email",
nameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:basic",
valueXsiType: "xs:string",
},
],
},
});
this.sp = ServiceProvider({
metadata: spMetadata,
});
this.app.get("/api/sso/saml2/idp/post", async (req, res) => {
const user = { emailAddress: "test@email.com", famName: "hello world" };
const { context, entityEndpoint } = await this.idp.createLoginResponse(
this.sp,
{} as any,
saml.Constants.wording.binding.post,
user,
createTemplateCallback(this.idp, this.sp, user.emailAddress),
);
res.status(200).send({ samlResponse: context, entityEndpoint });
});
this.app.get("/api/sso/saml2/idp/redirect", async (req, res) => {
const user = { emailAddress: "test@email.com", famName: "hello world" };
const { context, entityEndpoint } = await this.idp.createLoginResponse(
this.sp,
{} as any,
saml.Constants.wording.binding.post,
user,
createTemplateCallback(this.idp, this.sp, user.emailAddress),
);
res.status(200).send({ samlResponse: context, entityEndpoint });
});
// @ts-ignore
this.app.post("/api/sso/saml2/sp/acs", async (req, res) => {
try {
const parseResult = await this.sp.parseLoginResponse(
this.idp,
saml.Constants.wording.binding.post,
req,
);
const { extract } = parseResult;
const { attributes } = extract;
const relayState = req.body.RelayState;
if (relayState) {
return res.status(200).send({ relayState, attributes });
} else {
return res
.status(200)
.send({ extract, message: "RelayState is missing." });
}
} catch (error) {
console.error("Error handling SAML ACS endpoint:", error);
res.status(500).send({ error: "Failed to process SAML response." });
}
});
}
start() {
return new Promise<void>((resolve) => {
this.app.use(bodyParser.urlencoded({ extended: true }));
this.server = this.app.listen(this.port, () => {
console.log(`Mock SAML IdP running on port ${this.port}`);
resolve();
});
});
}
stop() {
return new Promise<void>((resolve, reject) => {
this.app.use(bodyParser.urlencoded({ extended: true }));
this.server?.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
get metadataUrl() {
return `http://localhost:${this.port}/idp/metadata`;
}
}
describe("SAML SSO", async () => {
const data = {
user: [],
session: [],
verification: [],
account: [],
ssoProvider: [],
};
const memory = memoryAdapter(data);
const mockIdP = new MockSAMLIdP(8081); // Different port from your main app
const ssoOptions = {
provisionUser: vi
.fn()
.mockImplementation(async ({ user, userInfo, token, provider }) => {
return {
id: "provisioned-user-id",
email: userInfo.email,
name: userInfo.name,
attributes: userInfo.attributes,
};
}),
};
const auth = betterAuth({
database: memory,
baseURL: "http://localhost:3000",
emailAndPassword: {
enabled: true,
},
plugins: [sso(ssoOptions)],
});
const ctx = await auth.$context;
const authClient = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [bearer(), ssoClient()],
fetchOptions: {
customFetchImpl: async (url, init) => {
return auth.handler(new Request(url, init));
},
},
});
const testUser = {
email: "test@email.com",
password: "password",
name: "Test User",
};
beforeAll(async () => {
await mockIdP.start();
const res = await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
});
afterAll(async () => {
await mockIdP.stop();
});
beforeEach(() => {
data.user = [];
data.session = [];
data.verification = [];
data.account = [];
data.ssoProvider = [];
vi.clearAllMocks();
});
async function getAuthHeaders() {
const headers = new Headers();
await authClient.signUp.email({
email: testUser.email,
password: testUser.password,
name: testUser.name,
});
const res = await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
return headers;
}
it("should register a new SAML provider", async () => {
const headers = await getAuthHeaders();
const res = await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: mockIdP.metadataUrl,
cert: certificate,
callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncyptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: idpMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncyptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
expect(provider).toMatchObject({
id: expect.any(String),
issuer: "http://localhost:8081",
samlConfig: {
entryPoint: mockIdP.metadataUrl,
cert: expect.any(String),
callbackUrl: "http://localhost:8081/api/sso/saml2/callback",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
});
});
it("Should fetch sp metadata", async () => {
const headers = await getAuthHeaders();
await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: mockIdP.metadataUrl,
cert: certificate,
callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncyptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: spMetadata,
binding: "post",
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncyptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const spMetadataRes = await auth.api.spMetadata({
query: {
providerId: provider.providerId,
},
});
const spMetadataResResValue = await spMetadataRes.text();
expect(spMetadataRes.status).toBe(200);
expect(spMetadataResResValue).toBe(spMetadata);
});
it("should initiate SAML login and handle response", async () => {
const headers = await getAuthHeaders();
const res = await authClient.signIn.email(testUser, {
throw: true,
onSuccess: setCookieToHeader(headers),
});
const provider = await auth.api.registerSSOProvider({
body: {
providerId: "saml-provider-1",
issuer: "http://localhost:8081",
domain: "http://localhost:8081",
samlConfig: {
entryPoint: mockIdP.metadataUrl,
cert: certificate,
callbackUrl: "http://localhost:8081/api/sso/saml2/sp/acs",
wantAssertionsSigned: false,
signatureAlgorithm: "sha256",
digestAlgorithm: "sha256",
idpMetadata: {
metadata: idpMetadata,
privateKey: idpPrivateKey,
privateKeyPass: "q9ALNhGT5EhfcRmp8Pg7e9zTQeP2x1bW",
isAssertionEncrypted: true,
encPrivateKey: idpEncyptionKey,
encPrivateKeyPass: "g7hGcRmp8PxT5QeP2q9Ehf1bWe9zTALN",
},
spMetadata: {
metadata: idpMetadata,
binding: "post",
// we can do a mapping of property here
privateKey: spPrivateKey,
privateKeyPass: "VHOSp5RUiBcrsjrcAuXFwU1NKCkGA8px",
isAssertionEncrypted: true,
encPrivateKey: spEncyptionKey,
encPrivateKeyPass: "BXFNKpxrsjrCkGA8cAu5wUVHOSpci1RU",
},
identifierFormat:
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
},
},
headers,
});
const signInResponse = await auth.api.signInSSO({
body: {
providerId: "saml-provider-1",
callbackURL: "http://localhost:3000/dashboard",
},
});
expect(signInResponse).toEqual({
url: expect.stringContaining("http://localhost:8081"),
redirect: true,
});
const loginResponse = await fetch(signInResponse?.url as string);
const resultValue = await loginResponse.json();
const result = await auth.api.callbackSSOSAML({
body: {
SAMLResponse: resultValue.samlResponse,
RelayState: "http://localhost:3001/dashboard",
},
params: {
providerId: provider.providerId,
},
});
expect(result).toEqual({
redirect: true,
url: "http://localhost:3001/dashboard",
});
});
});

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"module": "ESNext",
"noEmit": true,
"moduleResolution": "Bundler",
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true
},
"exclude": ["node_modules", "dist"],
"include": ["src"]
}

View File

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

782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff