This is page 50 of 67. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── user-additional-fields.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/content/docs/plugins/sso.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Single Sign-On (SSO) 3 | description: Integrate Single Sign-On (SSO) with your application. 4 | --- 5 | 6 | `OIDC` `OAuth2` `SSO` `SAML` 7 | 8 | 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. 9 | 10 | ## Installation 11 | 12 | <Steps> 13 | <Step> 14 | ### Install the plugin 15 | 16 | ```bash 17 | npm install @better-auth/sso 18 | ``` 19 | </Step> 20 | <Step> 21 | ### Add Plugin to the server 22 | 23 | ```ts title="auth.ts" 24 | import { betterAuth } from "better-auth" 25 | import { sso } from "@better-auth/sso"; 26 | 27 | const auth = betterAuth({ 28 | plugins: [ // [!code highlight] 29 | sso() // [!code highlight] 30 | ] // [!code highlight] 31 | }) 32 | ``` 33 | </Step> 34 | <Step> 35 | ### Migrate the database 36 | 37 | Run the migration or generate the schema to add the necessary fields and tables to the database. 38 | 39 | <Tabs items={["migrate", "generate"]}> 40 | <Tab value="migrate"> 41 | ```bash 42 | npx @better-auth/cli migrate 43 | ``` 44 | </Tab> 45 | <Tab value="generate"> 46 | ```bash 47 | npx @better-auth/cli generate 48 | ``` 49 | </Tab> 50 | </Tabs> 51 | See the [Schema](#schema) section to add the fields manually. 52 | </Step> 53 | <Step> 54 | ### Add the client plugin 55 | 56 | ```ts title="auth-client.ts" 57 | import { createAuthClient } from "better-auth/client" 58 | import { ssoClient } from "@better-auth/sso/client" 59 | 60 | const authClient = createAuthClient({ 61 | plugins: [ // [!code highlight] 62 | ssoClient() // [!code highlight] 63 | ] // [!code highlight] 64 | }) 65 | ``` 66 | </Step> 67 | </Steps> 68 | 69 | ## Usage 70 | 71 | ### Register an OIDC Provider 72 | 73 | To register an OIDC provider, use the `registerSSOProvider` endpoint and provide the necessary configuration details for the provider. 74 | 75 | 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. 76 | 77 | #### Example 78 | 79 | <Tabs items={["client", "server"]}> 80 | <Tab value="client"> 81 | ```ts title="register-oidc-provider.ts" 82 | import { authClient } from "@/lib/auth-client"; 83 | 84 | // Register with OIDC configuration 85 | await authClient.sso.register({ 86 | providerId: "example-provider", 87 | issuer: "https://idp.example.com", 88 | domain: "example.com", 89 | oidcConfig: { 90 | clientId: "client-id", 91 | clientSecret: "client-secret", 92 | authorizationEndpoint: "https://idp.example.com/authorize", 93 | tokenEndpoint: "https://idp.example.com/token", 94 | jwksEndpoint: "https://idp.example.com/jwks", 95 | discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration", 96 | scopes: ["openid", "email", "profile"], 97 | pkce: true, 98 | mapping: { 99 | id: "sub", 100 | email: "email", 101 | emailVerified: "email_verified", 102 | name: "name", 103 | image: "picture", 104 | extraFields: { 105 | department: "department", 106 | role: "role" 107 | } 108 | } 109 | } 110 | }); 111 | ``` 112 | </Tab> 113 | 114 | <Tab value="server"> 115 | ```ts title="register-oidc-provider.ts" 116 | const { headers } = await signInWithTestUser(); 117 | await auth.api.registerSSOProvider({ 118 | body: { 119 | providerId: "example-provider", 120 | issuer: "https://idp.example.com", 121 | domain: "example.com", 122 | oidcConfig: { 123 | clientId: "your-client-id", 124 | clientSecret: "your-client-secret", 125 | authorizationEndpoint: "https://idp.example.com/authorize", 126 | tokenEndpoint: "https://idp.example.com/token", 127 | jwksEndpoint: "https://idp.example.com/jwks", 128 | discoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration", 129 | scopes: ["openid", "email", "profile"], 130 | pkce: true, 131 | mapping: { 132 | id: "sub", 133 | email: "email", 134 | emailVerified: "email_verified", 135 | name: "name", 136 | image: "picture", 137 | extraFields: { 138 | department: "department", 139 | role: "role" 140 | } 141 | } 142 | } 143 | }, 144 | headers, 145 | }); 146 | ``` 147 | </Tab> 148 | </Tabs> 149 | 150 | 151 | ### Register a SAML Provider 152 | 153 | 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). 154 | 155 | <Tabs items={["client", "server"]}> 156 | <Tab value="client"> 157 | ```ts title="register-saml-provider.ts" 158 | import { authClient } from "@/lib/auth-client"; 159 | 160 | await authClient.sso.register({ 161 | providerId: "saml-provider", 162 | issuer: "https://idp.example.com", 163 | domain: "example.com", 164 | samlConfig: { 165 | entryPoint: "https://idp.example.com/sso", 166 | cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", 167 | callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider", 168 | audience: "https://yourapp.com", 169 | wantAssertionsSigned: true, 170 | signatureAlgorithm: "sha256", 171 | digestAlgorithm: "sha256", 172 | identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", 173 | idpMetadata: { 174 | metadata: "<!-- IdP Metadata XML -->", 175 | privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 176 | privateKeyPass: "your-private-key-password", 177 | isAssertionEncrypted: true, 178 | encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 179 | encPrivateKeyPass: "your-encryption-key-password" 180 | }, 181 | spMetadata: { 182 | metadata: "<!-- SP Metadata XML -->", 183 | binding: "post", 184 | privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 185 | privateKeyPass: "your-sp-private-key-password", 186 | isAssertionEncrypted: true, 187 | encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 188 | encPrivateKeyPass: "your-sp-encryption-key-password" 189 | }, 190 | mapping: { 191 | id: "nameID", 192 | email: "email", 193 | name: "displayName", 194 | firstName: "givenName", 195 | lastName: "surname", 196 | emailVerified: "email_verified", 197 | extraFields: { 198 | department: "department", 199 | role: "role" 200 | } 201 | } 202 | } 203 | }); 204 | ``` 205 | </Tab> 206 | 207 | <Tab value="server"> 208 | ```ts title="register-saml-provider.ts" 209 | const { headers } = await signInWithTestUser(); 210 | await auth.api.registerSSOProvider({ 211 | body: { 212 | providerId: "saml-provider", 213 | issuer: "https://idp.example.com", 214 | domain: "example.com", 215 | samlConfig: { 216 | entryPoint: "https://idp.example.com/sso", 217 | cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", 218 | callbackUrl: "https://yourapp.com/api/auth/sso/saml2/callback/saml-provider", 219 | audience: "https://yourapp.com", 220 | wantAssertionsSigned: true, 221 | signatureAlgorithm: "sha256", 222 | digestAlgorithm: "sha256", 223 | identifierFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", 224 | idpMetadata: { 225 | metadata: "<!-- IdP Metadata XML -->", 226 | privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 227 | privateKeyPass: "your-private-key-password", 228 | isAssertionEncrypted: true, 229 | encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 230 | encPrivateKeyPass: "your-encryption-key-password" 231 | }, 232 | spMetadata: { 233 | metadata: "<!-- SP Metadata XML -->", 234 | binding: "post", 235 | privateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 236 | privateKeyPass: "your-sp-private-key-password", 237 | isAssertionEncrypted: true, 238 | encPrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", 239 | encPrivateKeyPass: "your-sp-encryption-key-password" 240 | }, 241 | mapping: { 242 | id: "nameID", 243 | email: "email", 244 | name: "displayName", 245 | firstName: "givenName", 246 | lastName: "surname", 247 | emailVerified: "email_verified", 248 | extraFields: { 249 | department: "department", 250 | role: "role" 251 | } 252 | } 253 | } 254 | }, 255 | headers, 256 | }); 257 | ``` 258 | </Tab> 259 | </Tabs> 260 | 261 | ### Get Service Provider Metadata 262 | 263 | For SAML providers, you can retrieve the Service Provider metadata XML that needs to be configured in your Identity Provider: 264 | 265 | ```ts title="get-sp-metadata.ts" 266 | const response = await auth.api.spMetadata({ 267 | query: { 268 | providerId: "saml-provider", 269 | format: "xml" // or "json" 270 | } 271 | }); 272 | 273 | const metadataXML = await response.text(); 274 | console.log(metadataXML); 275 | ``` 276 | 277 | ### Sign In with SSO 278 | 279 | To sign in with an SSO provider, you can call `signIn.sso` 280 | 281 | You can sign in using the email with domain matching: 282 | 283 | ```ts title="sign-in.ts" 284 | const res = await authClient.signIn.sso({ 285 | email: "[email protected]", 286 | callbackURL: "/dashboard", 287 | }); 288 | ``` 289 | 290 | or you can specify the domain: 291 | 292 | ```ts title="sign-in-domain.ts" 293 | const res = await authClient.signIn.sso({ 294 | domain: "example.com", 295 | callbackURL: "/dashboard", 296 | }); 297 | ``` 298 | 299 | You can also sign in using the organization slug if a provider is associated with an organization: 300 | 301 | ```ts title="sign-in-org.ts" 302 | const res = await authClient.signIn.sso({ 303 | organizationSlug: "example-org", 304 | callbackURL: "/dashboard", 305 | }); 306 | ``` 307 | 308 | Alternatively, you can sign in using the provider's ID: 309 | 310 | ```ts title="sign-in-provider-id.ts" 311 | const res = await authClient.signIn.sso({ 312 | providerId: "example-provider-id", 313 | callbackURL: "/dashboard", 314 | }); 315 | ``` 316 | 317 | To use the server API you can use `signInSSO` 318 | 319 | ```ts title="sign-in-org.ts" 320 | const res = await auth.api.signInSSO({ 321 | body: { 322 | organizationSlug: "example-org", 323 | callbackURL: "/dashboard", 324 | } 325 | }); 326 | ``` 327 | 328 | #### Full method 329 | 330 | <APIMethod path="/sign-in/sso" method="POST"> 331 | ```ts 332 | type signInSSO = { 333 | /** 334 | * The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided. 335 | */ 336 | email?: string = "[email protected]" 337 | /** 338 | * The slug of the organization to sign in with. 339 | */ 340 | organizationSlug?: string = "example-org" 341 | /** 342 | * The ID of the provider to sign in with. This can be provided instead of email or issuer. 343 | */ 344 | providerId?: string = "example-provider" 345 | /** 346 | * The domain of the provider. 347 | */ 348 | domain?: string = "example.com" 349 | /** 350 | * The URL to redirect to after login. 351 | */ 352 | callbackURL: string = "https://example.com/callback" 353 | /** 354 | * The URL to redirect to after login. 355 | */ 356 | errorCallbackURL?: string = "https://example.com/callback" 357 | /** 358 | * The URL to redirect to after login if the user is new. 359 | */ 360 | newUserCallbackURL?: string = "https://example.com/new-user" 361 | /** 362 | * Scopes to request from the provider. 363 | */ 364 | scopes?: string[] = ["openid", "email", "profile", "offline_access"] 365 | /** 366 | * Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider. 367 | */ 368 | requestSignUp?: boolean = true 369 | } 370 | ``` 371 | </APIMethod> 372 | 373 | 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. 374 | 375 | ```ts title="auth.ts" 376 | const auth = betterAuth({ 377 | plugins: [ 378 | sso({ 379 | provisionUser: async (user) => { 380 | // provision user 381 | }, 382 | organizationProvisioning: { 383 | disabled: false, 384 | defaultRole: "member", 385 | getRole: async (user) => { 386 | // get role if needed 387 | }, 388 | }, 389 | }), 390 | ], 391 | }); 392 | ``` 393 | 394 | ## Provisioning 395 | 396 | The SSO plugin provides powerful provisioning capabilities to automatically set up users and manage their organization memberships when they sign in through SSO providers. 397 | 398 | ### User Provisioning 399 | 400 | User provisioning allows you to run custom logic whenever a user signs in through an SSO provider. This is useful for: 401 | 402 | - Setting up user profiles with additional data from the SSO provider 403 | - Synchronizing user attributes with external systems 404 | - Creating user-specific resources 405 | - Logging SSO sign-ins 406 | - Updating user information from the SSO provider 407 | 408 | ```ts title="auth.ts" 409 | const auth = betterAuth({ 410 | plugins: [ 411 | sso({ 412 | provisionUser: async ({ user, userInfo, token, provider }) => { 413 | // Update user profile with SSO data 414 | await updateUserProfile(user.id, { 415 | department: userInfo.attributes?.department, 416 | jobTitle: userInfo.attributes?.jobTitle, 417 | manager: userInfo.attributes?.manager, 418 | lastSSOLogin: new Date(), 419 | }); 420 | 421 | // Create user-specific resources 422 | await createUserWorkspace(user.id); 423 | 424 | // Sync with external systems 425 | await syncUserWithCRM(user.id, userInfo); 426 | 427 | // Log the SSO sign-in 428 | await auditLog.create({ 429 | userId: user.id, 430 | action: 'sso_signin', 431 | provider: provider.providerId, 432 | metadata: { 433 | email: userInfo.email, 434 | ssoProvider: provider.issuer, 435 | }, 436 | }); 437 | }, 438 | }), 439 | ], 440 | }); 441 | ``` 442 | 443 | The `provisionUser` function receives: 444 | - **user**: The user object from the database 445 | - **userInfo**: User information from the SSO provider (includes attributes, email, name, etc.) 446 | - **token**: OAuth2 tokens (for OIDC providers) - may be undefined for SAML 447 | - **provider**: The SSO provider configuration 448 | 449 | ### Organization Provisioning 450 | 451 | Organization provisioning automatically manages user memberships in organizations when SSO providers are linked to specific organizations. This is particularly useful for: 452 | 453 | - Enterprise SSO where each company/domain maps to an organization 454 | - Automatic role assignment based on SSO attributes 455 | - Managing team memberships through SSO 456 | 457 | #### Basic Organization Provisioning 458 | 459 | ```ts title="auth.ts" 460 | const auth = betterAuth({ 461 | plugins: [ 462 | sso({ 463 | organizationProvisioning: { 464 | disabled: false, // Enable org provisioning 465 | defaultRole: "member", // Default role for new members 466 | }, 467 | }), 468 | ], 469 | }); 470 | ``` 471 | 472 | #### Advanced Organization Provisioning with Custom Roles 473 | 474 | ```ts title="auth.ts" 475 | const auth = betterAuth({ 476 | plugins: [ 477 | sso({ 478 | organizationProvisioning: { 479 | disabled: false, 480 | defaultRole: "member", 481 | getRole: async ({ user, userInfo, provider }) => { 482 | // Assign roles based on SSO attributes 483 | const department = userInfo.attributes?.department; 484 | const jobTitle = userInfo.attributes?.jobTitle; 485 | 486 | // Admins based on job title 487 | if (jobTitle?.toLowerCase().includes('manager') || 488 | jobTitle?.toLowerCase().includes('director') || 489 | jobTitle?.toLowerCase().includes('vp')) { 490 | return "admin"; 491 | } 492 | 493 | // Special roles for IT department 494 | if (department?.toLowerCase() === 'it') { 495 | return "admin"; 496 | } 497 | 498 | // Default to member for everyone else 499 | return "member"; 500 | }, 501 | }, 502 | }), 503 | ], 504 | }); 505 | ``` 506 | 507 | #### Linking SSO Providers to Organizations 508 | 509 | When registering an SSO provider, you can link it to a specific organization: 510 | 511 | ```ts title="register-org-provider.ts" 512 | await auth.api.registerSSOProvider({ 513 | body: { 514 | providerId: "acme-corp-saml", 515 | issuer: "https://acme-corp.okta.com", 516 | domain: "acmecorp.com", 517 | organizationId: "org_acme_corp_id", // Link to organization 518 | samlConfig: { 519 | // SAML configuration... 520 | }, 521 | }, 522 | headers, 523 | }); 524 | ``` 525 | 526 | 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. 527 | 528 | #### Multiple Organizations Example 529 | 530 | You can set up multiple SSO providers for different organizations: 531 | 532 | ```ts title="multi-org-setup.ts" 533 | // Acme Corp SAML provider 534 | await auth.api.registerSSOProvider({ 535 | body: { 536 | providerId: "acme-corp", 537 | issuer: "https://acme.okta.com", 538 | domain: "acmecorp.com", 539 | organizationId: "org_acme_id", 540 | samlConfig: { /* ... */ }, 541 | }, 542 | headers, 543 | }); 544 | 545 | // TechStart OIDC provider 546 | await auth.api.registerSSOProvider({ 547 | body: { 548 | providerId: "techstart-google", 549 | issuer: "https://accounts.google.com", 550 | domain: "techstart.io", 551 | organizationId: "org_techstart_id", 552 | oidcConfig: { /* ... */ }, 553 | }, 554 | headers, 555 | }); 556 | ``` 557 | 558 | #### Organization Provisioning Flow 559 | 560 | 1. **User signs in** through an SSO provider linked to an organization 561 | 2. **User is authenticated** and either found or created in the database 562 | 3. **Organization membership is checked** - if the user isn't already a member of the linked organization 563 | 4. **Role is determined** using either the `defaultRole` or `getRole` function 564 | 5. **User is added** to the organization with the determined role 565 | 6. **User provisioning runs** (if configured) for additional setup 566 | 567 | ### Provisioning Best Practices 568 | 569 | #### 1. Idempotent Operations 570 | Make sure your provisioning functions can be safely run multiple times: 571 | 572 | ```ts 573 | provisionUser: async ({ user, userInfo }) => { 574 | // Check if already provisioned 575 | const existingProfile = await getUserProfile(user.id); 576 | if (!existingProfile.ssoProvisioned) { 577 | await createUserResources(user.id); 578 | await markAsProvisioned(user.id); 579 | } 580 | 581 | // Always update attributes (they might change) 582 | await updateUserAttributes(user.id, userInfo.attributes); 583 | }, 584 | ``` 585 | 586 | #### 2. Error Handling 587 | Handle errors gracefully to avoid blocking user sign-in: 588 | 589 | ```ts 590 | provisionUser: async ({ user, userInfo }) => { 591 | try { 592 | await syncWithExternalSystem(user, userInfo); 593 | } catch (error) { 594 | // Log error but don't throw - user can still sign in 595 | console.error('Failed to sync user with external system:', error); 596 | await logProvisioningError(user.id, error); 597 | } 598 | }, 599 | ``` 600 | 601 | #### 3. Conditional Provisioning 602 | Only run certain provisioning steps when needed: 603 | 604 | ```ts 605 | organizationProvisioning: { 606 | disabled: false, 607 | getRole: async ({ user, userInfo, provider }) => { 608 | // Only process role assignment for certain providers 609 | if (provider.providerId.includes('enterprise')) { 610 | return determineEnterpriseRole(userInfo); 611 | } 612 | return "member"; 613 | }, 614 | }, 615 | ``` 616 | 617 | ## SAML Configuration 618 | 619 | 620 | ### Default SSO Provider 621 | 622 | ```ts title="auth.ts" 623 | const auth = betterAuth({ 624 | plugins: [ 625 | sso({ 626 | defaultSSO: { 627 | providerId: "default-saml", // Provider ID for the default provider 628 | samlConfig: { 629 | issuer: "https://your-app.com", 630 | entryPoint: "https://idp.example.com/sso", 631 | cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----", 632 | callbackUrl: "http://localhost:3000/api/auth/sso/saml2/sp/acs", 633 | spMetadata: { 634 | entityID: "http://localhost:3000/api/auth/sso/saml2/sp/metadata", 635 | metadata: "<!-- Your SP Metadata XML -->", 636 | } 637 | } 638 | } 639 | }) 640 | ] 641 | }); 642 | ``` 643 | 644 | The defaultSSO provider will be used when: 645 | 1. No matching provider is found in the database 646 | 647 | This allows you to test SAML authentication without setting up providers in the database. The defaultSSO provider supports all the same configuration options as regular SAML providers. 648 | 649 | ### Service Provider Configuration 650 | 651 | When registering a SAML provider, you need to provide Service Provider (SP) metadata configuration: 652 | 653 | - **metadata**: XML metadata for the Service Provider 654 | - **binding**: The binding method, typically "post" or "redirect" 655 | - **privateKey**: Private key for signing (optional) 656 | - **privateKeyPass**: Password for the private key (if encrypted) 657 | - **isAssertionEncrypted**: Whether assertions should be encrypted 658 | - **encPrivateKey**: Private key for decryption (if encryption is enabled) 659 | - **encPrivateKeyPass**: Password for the encryption private key 660 | 661 | ### Identity Provider Configuration 662 | 663 | You also need to provide Identity Provider (IdP) configuration: 664 | 665 | - **metadata**: XML metadata from your Identity Provider 666 | - **privateKey**: Private key for the IdP communication (optional) 667 | - **privateKeyPass**: Password for the IdP private key (if encrypted) 668 | - **isAssertionEncrypted**: Whether assertions from IdP are encrypted 669 | - **encPrivateKey**: Private key for IdP assertion decryption 670 | - **encPrivateKeyPass**: Password for the IdP decryption key 671 | 672 | ### SAML Attribute Mapping 673 | 674 | Configure how SAML attributes map to user fields: 675 | 676 | ```ts 677 | mapping: { 678 | id: "nameID", // Default: "nameID" 679 | email: "email", // Default: "email" or "nameID" 680 | name: "displayName", // Default: "displayName" 681 | firstName: "givenName", // Default: "givenName" 682 | lastName: "surname", // Default: "surname" 683 | extraFields: { 684 | department: "department", 685 | role: "jobTitle", 686 | phone: "telephoneNumber" 687 | } 688 | } 689 | ``` 690 | 691 | ### SAML Endpoints 692 | 693 | The plugin automatically creates the following SAML endpoints: 694 | 695 | - **SP Metadata**: `/api/auth/sso/saml2/sp/metadata?providerId={providerId}` 696 | - **SAML Callback**: `/api/auth/sso/saml2/callback/{providerId}` 697 | 698 | ## Schema 699 | 700 | The plugin requires additional fields in the `ssoProvider` table to store the provider's configuration. 701 | 702 | <DatabaseTable 703 | fields={[ 704 | { 705 | name: "id", type: "string", description: "A database identifier", isRequired: true, isPrimaryKey: true, 706 | }, 707 | { name: "issuer", type: "string", description: "The issuer identifier", isRequired: true }, 708 | { name: "domain", type: "string", description: "The domain of the provider", isRequired: true }, 709 | { name: "oidcConfig", type: "string", description: "The OIDC configuration (JSON string)", isRequired: false }, 710 | { name: "samlConfig", type: "string", description: "The SAML configuration (JSON string)", isRequired: false }, 711 | { name: "userId", type: "string", description: "The user ID", isRequired: true, references: { model: "user", field: "id" } }, 712 | { name: "providerId", type: "string", description: "The provider ID. Used to identify a provider and to generate a redirect URL.", isRequired: true, isUnique: true }, 713 | { name: "organizationId", type: "string", description: "The organization Id. If provider is linked to an organization.", isRequired: false }, 714 | ]} 715 | /> 716 | 717 | For a detailed guide on setting up SAML SSO with examples for Okta and testing with DummyIDP, see our [SAML SSO with Okta](/docs/guides/saml-sso-with-okta). 718 | 719 | ## Options 720 | 721 | ### Server 722 | 723 | **provisionUser**: A custom function to provision a user when they sign in with an SSO provider. 724 | 725 | **organizationProvisioning**: Options for provisioning users to an organization. 726 | 727 | **defaultOverrideUserInfo**: Override user info with the provider info by default. 728 | 729 | **disableImplicitSignUp**: Disable implicit sign up for new users. 730 | 731 | **trustEmailVerified**: Trust the email verified flag from the provider. 732 | 733 | <TypeTable 734 | type={{ 735 | provisionUser: { 736 | description: "A custom function to provision a user when they sign in with an SSO provider.", 737 | type: "function", 738 | }, 739 | organizationProvisioning: { 740 | description: "Options for provisioning users to an organization.", 741 | type: "object", 742 | properties: { 743 | disabled: { 744 | description: "Disable organization provisioning.", 745 | type: "boolean", 746 | default: false, 747 | }, 748 | defaultRole: { 749 | description: "The default role for new users.", 750 | type: "string", 751 | enum: ["member", "admin"], 752 | default: "member", 753 | }, 754 | getRole: { 755 | description: "A custom function to determine the role for new users.", 756 | type: "function", 757 | }, 758 | }, 759 | }, 760 | defaultOverrideUserInfo: { 761 | description: "Override user info with the provider info by default.", 762 | type: "boolean", 763 | default: false, 764 | }, 765 | disableImplicitSignUp: { 766 | 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.", 767 | type: "boolean", 768 | default: false, 769 | }, 770 | providersLimit: { 771 | description: "Configure the maximum number of SSO providers a user can register. Set to 0 to disable SSO provider registration.", 772 | type: "number | function", 773 | default: 10, 774 | }, 775 | defaultSSO: { 776 | description: "Configure a default SSO provider for testing and development. This provider will be used when no matching provider is found in the database.", 777 | type: "object", 778 | properties: { 779 | domain: { 780 | description: "The domain to match for this default provider.", 781 | type: "string", 782 | required: true, 783 | }, 784 | providerId: { 785 | description: "The provider ID to use for the default provider.", 786 | type: "string", 787 | required: true, 788 | }, 789 | samlConfig: { 790 | description: "SAML configuration for the default provider.", 791 | type: "SAMLConfig", 792 | required: false, 793 | }, 794 | oidcConfig: { 795 | description: "OIDC configuration for the default provider.", 796 | type: "OIDCConfig", 797 | required: false, 798 | }, 799 | }, 800 | }, 801 | }} 802 | /> 803 | ``` -------------------------------------------------------------------------------- /docs/content/docs/plugins/stripe.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Stripe 3 | description: Stripe plugin for Better Auth to manage subscriptions and payments. 4 | --- 5 | 6 | The Stripe plugin integrates Stripe's payment and subscription functionality with Better Auth. Since payment and authentication are often tightly coupled, this plugin simplifies the integration of Stripe into your application, handling customer creation, subscription management, and webhook processing. 7 | 8 | ## Features 9 | 10 | - Create Stripe Customers automatically when users sign up 11 | - Manage subscription plans and pricing 12 | - Process subscription lifecycle events (creation, updates, cancellations) 13 | - Handle Stripe webhooks securely with signature verification 14 | - Expose subscription data to your application 15 | - Support for trial periods and subscription upgrades 16 | - **Automatic trial abuse prevention** - Users can only get one trial per account across all plans 17 | - Flexible reference system to associate subscriptions with users or organizations 18 | - Team subscription support with seats management 19 | 20 | ## Installation 21 | 22 | <Steps> 23 | <Step> 24 | ### Install the plugin 25 | 26 | First, install the plugin: 27 | 28 | ```package-install 29 | @better-auth/stripe 30 | ``` 31 | <Callout> 32 | If you're using a separate client and server setup, make sure to install the plugin in both parts of your project. 33 | </Callout> 34 | </Step> 35 | <Step> 36 | ### Install the Stripe SDK 37 | 38 | Next, install the Stripe SDK on your server: 39 | 40 | ```package-install 41 | stripe@^18.0.0 42 | ``` 43 | </Step> 44 | <Step> 45 | ### Add the plugin to your auth config 46 | 47 | ```ts title="auth.ts" 48 | import { betterAuth } from "better-auth" 49 | import { stripe } from "@better-auth/stripe" 50 | import Stripe from "stripe" 51 | 52 | const stripeClient = new Stripe(process.env.STRIPE_SECRET_KEY!, { 53 | apiVersion: "2025-02-24.acacia", 54 | }) 55 | 56 | export const auth = betterAuth({ 57 | // ... your existing config 58 | plugins: [ 59 | stripe({ 60 | stripeClient, 61 | stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, 62 | createCustomerOnSignUp: true, 63 | }) 64 | ] 65 | }) 66 | ``` 67 | </Step> 68 | <Step> 69 | ### Add the client plugin 70 | 71 | ```ts title="auth-client.ts" 72 | import { createAuthClient } from "better-auth/client" 73 | import { stripeClient } from "@better-auth/stripe/client" 74 | 75 | export const client = createAuthClient({ 76 | // ... your existing config 77 | plugins: [ 78 | stripeClient({ 79 | subscription: true //if you want to enable subscription management 80 | }) 81 | ] 82 | }) 83 | ``` 84 | </Step> 85 | <Step> 86 | ### Migrate the database 87 | 88 | Run the migration or generate the schema to add the necessary tables to the database. 89 | 90 | <Tabs items={["migrate", "generate"]}> 91 | <Tab value="migrate"> 92 | ```bash 93 | npx @better-auth/cli migrate 94 | ``` 95 | </Tab> 96 | <Tab value="generate"> 97 | ```bash 98 | npx @better-auth/cli generate 99 | ``` 100 | </Tab> 101 | </Tabs> 102 | See the [Schema](#schema) section to add the tables manually. 103 | </Step> 104 | <Step> 105 | ### Set up Stripe webhooks 106 | 107 | Create a webhook endpoint in your Stripe dashboard pointing to: 108 | 109 | ``` 110 | https://your-domain.com/api/auth/stripe/webhook 111 | ``` 112 | `/api/auth` is the default path for the auth server. 113 | 114 | Make sure to select at least these events: 115 | - `checkout.session.completed` 116 | - `customer.subscription.updated` 117 | - `customer.subscription.deleted` 118 | 119 | Save the webhook signing secret provided by Stripe and add it to your environment variables as `STRIPE_WEBHOOK_SECRET`. 120 | </Step> 121 | </Steps> 122 | 123 | ## Usage 124 | 125 | ### Customer Management 126 | 127 | You can use this plugin solely for customer management without enabling subscriptions. This is useful if you just want to link Stripe customers to your users. 128 | 129 | By default, when a user signs up, a Stripe customer is automatically created if you set `createCustomerOnSignUp: true`. This customer is linked to the user in your database. 130 | You can customize the customer creation process: 131 | 132 | ```ts title="auth.ts" 133 | stripe({ 134 | // ... other options 135 | createCustomerOnSignUp: true, 136 | onCustomerCreate: async ({ customer, stripeCustomer, user }, request) => { 137 | // Do something with the newly created customer 138 | console.log(`Customer ${customer.id} created for user ${user.id}`); 139 | }, 140 | getCustomerCreateParams: async ({ user, session }, request) => { 141 | // Customize the Stripe customer creation parameters 142 | return { 143 | metadata: { 144 | referralSource: user.metadata?.referralSource 145 | } 146 | }; 147 | } 148 | }) 149 | ``` 150 | 151 | ### Subscription Management 152 | 153 | #### Defining Plans 154 | 155 | You can define your subscription plans either statically or dynamically: 156 | 157 | ```ts title="auth.ts" 158 | // Static plans 159 | subscription: { 160 | enabled: true, 161 | plans: [ 162 | { 163 | name: "basic", // the name of the plan, it'll be automatically lower cased when stored in the database 164 | priceId: "price_1234567890", // the price ID from stripe 165 | annualDiscountPriceId: "price_1234567890", // (optional) the price ID for annual billing with a discount 166 | limits: { 167 | projects: 5, 168 | storage: 10 169 | } 170 | }, 171 | { 172 | name: "pro", 173 | priceId: "price_0987654321", 174 | limits: { 175 | projects: 20, 176 | storage: 50 177 | }, 178 | freeTrial: { 179 | days: 14, 180 | } 181 | } 182 | ] 183 | } 184 | 185 | // Dynamic plans (fetched from database or API) 186 | subscription: { 187 | enabled: true, 188 | plans: async () => { 189 | const plans = await db.query("SELECT * FROM plans"); 190 | return plans.map(plan => ({ 191 | name: plan.name, 192 | priceId: plan.stripe_price_id, 193 | limits: JSON.parse(plan.limits) 194 | })); 195 | } 196 | } 197 | ``` 198 | 199 | see [plan configuration](#plan-configuration) for more. 200 | 201 | #### Creating a Subscription 202 | 203 | To create a subscription, use the `subscription.upgrade` method: 204 | 205 | <APIMethod 206 | path="/subscription/upgrade" 207 | method="POST" 208 | requireSession 209 | > 210 | ```ts 211 | type upgradeSubscription = { 212 | /** 213 | * The name of the plan to upgrade to. 214 | */ 215 | plan: string = "pro" 216 | /** 217 | * Whether to upgrade to an annual plan. 218 | */ 219 | annual?: boolean = true 220 | /** 221 | * Reference id of the subscription to upgrade. 222 | */ 223 | referenceId?: string = "123" 224 | /** 225 | * The id of the subscription to upgrade. 226 | */ 227 | subscriptionId?: string = "sub_123" 228 | metadata?: Record<string, any> 229 | /** 230 | * Number of seats to upgrade to (if applicable). 231 | */ 232 | seats?: number = 1 233 | /** 234 | * Callback URL to redirect back after successful subscription. 235 | */ 236 | successUrl: string 237 | /** 238 | * If set, checkout shows a back button and customers will be directed here if they cancel payment. 239 | */ 240 | cancelUrl: string 241 | /** 242 | * URL to take customers to when they click on the billing portal’s link to return to your website. 243 | */ 244 | returnUrl?: string 245 | /** 246 | * Disable redirect after successful subscription. 247 | */ 248 | disableRedirect: boolean = true 249 | } 250 | ``` 251 | </APIMethod> 252 | 253 | **Simple Example:** 254 | 255 | ```ts title="client.ts" 256 | await client.subscription.upgrade({ 257 | plan: "pro", 258 | successUrl: "/dashboard", 259 | cancelUrl: "/pricing", 260 | annual: true, // Optional: upgrade to an annual plan 261 | referenceId: "org_123", // Optional: defaults to the current logged in user ID 262 | seats: 5 // Optional: for team plans 263 | }); 264 | ``` 265 | 266 | This will create a Checkout Session and redirect the user to the Stripe Checkout page. 267 | 268 | <Callout type="warn"> 269 | If the user already has an active subscription, you *must* provide the `subscriptionId` parameter. Otherwise, the user will be subscribed to (and pay for) both plans. 270 | </Callout> 271 | 272 | > **Important:** The `successUrl` parameter will be internally modified to handle race conditions between checkout completion and webhook processing. The plugin creates an intermediate redirect that ensures subscription status is properly updated before redirecting to your success page. 273 | 274 | ```ts 275 | const { error } = await client.subscription.upgrade({ 276 | plan: "pro", 277 | successUrl: "/dashboard", 278 | cancelUrl: "/pricing", 279 | }); 280 | if(error) { 281 | alert(error.message); 282 | } 283 | ``` 284 | 285 | <Callout type="warn"> 286 | For each reference ID (user or organization), only one active or trialing subscription is supported at a time. The plugin doesn't currently support multiple concurrent active subscriptions for the same reference ID. 287 | </Callout> 288 | 289 | #### Switching Plans 290 | 291 | To switch a subscription to a different plan, use the `subscription.upgrade` method: 292 | ```ts title="client.ts" 293 | await client.subscription.upgrade({ 294 | plan: "pro", 295 | successUrl: "/dashboard", 296 | cancelUrl: "/pricing", 297 | subscriptionId: "sub_123", // the Stripe subscription ID of the user's current plan 298 | }); 299 | ``` 300 | This ensures that the user only pays for the new plan, and not both. 301 | 302 | #### Listing Active Subscriptions 303 | 304 | To get the user's active subscriptions: 305 | 306 | <APIMethod 307 | path="/subscription/list" 308 | method="GET" 309 | requireSession 310 | resultVariable="subscriptions" 311 | > 312 | ```ts 313 | type listActiveSubscriptions = { 314 | /** 315 | * Reference id of the subscription to list. 316 | */ 317 | referenceId?: string = '123' 318 | } 319 | 320 | // get the active subscription 321 | const activeSubscription = subscriptions.find( 322 | sub => sub.status === "active" || sub.status === "trialing" 323 | ); 324 | 325 | // Check subscription limits 326 | const projectLimit = subscriptions?.limits?.projects || 0; 327 | ``` 328 | </APIMethod> 329 | 330 | #### Canceling a Subscription 331 | 332 | To cancel a subscription: 333 | 334 | <APIMethod 335 | path="/subscription/cancel" 336 | method="POST" 337 | requireSession 338 | > 339 | ```ts 340 | type cancelSubscription = { 341 | /** 342 | * Reference id of the subscription to cancel. Defaults to the userId. 343 | */ 344 | referenceId?: string = 'org_123' 345 | /** 346 | * The id of the subscription to cancel. 347 | */ 348 | subscriptionId?: string = 'sub_123' 349 | /** 350 | * URL to take customers to when they click on the billing portal’s link to return to your website. 351 | */ 352 | returnUrl: string = '/account' 353 | } 354 | ``` 355 | </APIMethod> 356 | 357 | This will redirect the user to the Stripe Billing Portal where they can cancel their subscription. 358 | 359 | #### Restoring a Canceled Subscription 360 | 361 | If a user changes their mind after canceling a subscription (but before the subscription period ends), you can restore the subscription: 362 | 363 | 364 | <APIMethod 365 | path="/subscription/restore" 366 | method="POST" 367 | requireSession 368 | > 369 | ```ts 370 | type restoreSubscription = { 371 | /** 372 | * Reference id of the subscription to restore. Defaults to the userId. 373 | */ 374 | referenceId?: string = '123' 375 | /** 376 | * The id of the subscription to restore. 377 | */ 378 | subscriptionId?: string = 'sub_123' 379 | } 380 | ``` 381 | </APIMethod> 382 | 383 | 384 | This will reactivate a subscription that was previously set to cancel at the end of the billing period (`cancelAtPeriodEnd: true`). The subscription will continue to renew automatically. 385 | 386 | > **Note:** This only works for subscriptions that are still active but marked to cancel at the end of the period. It cannot restore subscriptions that have already ended. 387 | 388 | #### Creating Billing Portal Sessions 389 | 390 | To create a [Stripe billing portal session](https://docs.stripe.com/api/customer_portal/sessions/create) where customers can manage their subscriptions, update payment methods, and view billing history: 391 | 392 | <APIMethod 393 | path="/subscription/billing-portal" 394 | method="POST" 395 | requireSession 396 | > 397 | ```ts 398 | type createBillingPortal = { 399 | /** 400 | * The IETF language tag of the locale customer portal is displayed in. If blank or auto, browser's locale is used. 401 | */ 402 | locale?: string 403 | /** 404 | * Reference id of the subscription to upgrade. 405 | */ 406 | referenceId?: string = "123" 407 | /** 408 | * Return URL to redirect back after successful subscription. 409 | */ 410 | returnUrl?: string 411 | } 412 | ``` 413 | </APIMethod> 414 | <Callout type="info" > 415 | For supported locales, see the [IETF language tag documentation](https://docs.stripe.com/js/appendix/supported_locales). 416 | </Callout> 417 | 418 | This endpoint creates a Stripe billing portal session and returns a URL in the response as `data.url`. You can redirect users to this URL to allow them to manage their subscription, payment methods, and billing history. 419 | 420 | ### Reference System 421 | 422 | By default, subscriptions are associated with the user ID. However, you can use a custom reference ID to associate subscriptions with other entities, such as organizations: 423 | 424 | ```ts title="client.ts" 425 | // Create a subscription for an organization 426 | await client.subscription.upgrade({ 427 | plan: "pro", 428 | referenceId: "org_123456", 429 | successUrl: "/dashboard", 430 | cancelUrl: "/pricing", 431 | seats: 5 // Number of seats for team plans 432 | }); 433 | 434 | // List subscriptions for an organization 435 | const { data: subscriptions } = await client.subscription.list({ 436 | query: { 437 | referenceId: "org_123456" 438 | } 439 | }); 440 | ``` 441 | 442 | #### Team Subscriptions with Seats 443 | 444 | For team or organization plans, you can specify the number of seats: 445 | 446 | ```ts 447 | await client.subscription.upgrade({ 448 | plan: "team", 449 | referenceId: "org_123456", 450 | seats: 10, // 10 team members 451 | successUrl: "/org/billing/success", 452 | cancelUrl: "/org/billing" 453 | }); 454 | ``` 455 | 456 | The `seats` parameter is passed to Stripe as the quantity for the subscription item. You can use this value in your application logic to limit the number of members in a team or organization. 457 | 458 | To authorize reference IDs, implement the `authorizeReference` function: 459 | 460 | ```ts title="auth.ts" 461 | subscription: { 462 | // ... other options 463 | authorizeReference: async ({ user, session, referenceId, action }) => { 464 | // Check if the user has permission to manage subscriptions for this reference 465 | if (action === "upgrade-subscription" || action === "cancel-subscription" || action === "restore-subscription") { 466 | const org = await db.member.findFirst({ 467 | where: { 468 | organizationId: referenceId, 469 | userId: user.id 470 | } 471 | }); 472 | return org?.role === "owner" 473 | } 474 | return true; 475 | } 476 | } 477 | ``` 478 | 479 | ### Webhook Handling 480 | 481 | The plugin automatically handles common webhook events: 482 | 483 | - `checkout.session.completed`: Updates subscription status after checkout 484 | - `customer.subscription.updated`: Updates subscription details when changed 485 | - `customer.subscription.deleted`: Marks subscription as canceled 486 | 487 | You can also handle custom events: 488 | 489 | ```ts title="auth.ts" 490 | stripe({ 491 | // ... other options 492 | onEvent: async (event) => { 493 | // Handle any Stripe event 494 | switch (event.type) { 495 | case "invoice.paid": 496 | // Handle paid invoice 497 | break; 498 | case "payment_intent.succeeded": 499 | // Handle successful payment 500 | break; 501 | } 502 | } 503 | }) 504 | ``` 505 | 506 | ### Subscription Lifecycle Hooks 507 | 508 | You can hook into various subscription lifecycle events: 509 | 510 | ```ts title="auth.ts" 511 | subscription: { 512 | // ... other options 513 | onSubscriptionComplete: async ({ event, subscription, stripeSubscription, plan }) => { 514 | // Called when a subscription is successfully created 515 | await sendWelcomeEmail(subscription.referenceId, plan.name); 516 | }, 517 | onSubscriptionUpdate: async ({ event, subscription }) => { 518 | // Called when a subscription is updated 519 | console.log(`Subscription ${subscription.id} updated`); 520 | }, 521 | onSubscriptionCancel: async ({ event, subscription, stripeSubscription, cancellationDetails }) => { 522 | // Called when a subscription is canceled 523 | await sendCancellationEmail(subscription.referenceId); 524 | }, 525 | onSubscriptionDeleted: async ({ event, subscription, stripeSubscription }) => { 526 | // Called when a subscription is deleted 527 | console.log(`Subscription ${subscription.id} deleted`); 528 | } 529 | } 530 | ``` 531 | 532 | ### Trial Periods 533 | 534 | You can configure trial periods for your plans: 535 | 536 | ```ts title="auth.ts" 537 | { 538 | name: "pro", 539 | priceId: "price_0987654321", 540 | freeTrial: { 541 | days: 14, 542 | onTrialStart: async (subscription) => { 543 | // Called when a trial starts 544 | await sendTrialStartEmail(subscription.referenceId); 545 | }, 546 | onTrialEnd: async ({ subscription, user }, request) => { 547 | // Called when a trial ends 548 | await sendTrialEndEmail(user.email); 549 | }, 550 | onTrialExpired: async (subscription) => { 551 | // Called when a trial expires without conversion 552 | await sendTrialExpiredEmail(subscription.referenceId); 553 | } 554 | } 555 | } 556 | ``` 557 | 558 | ## Schema 559 | 560 | The Stripe plugin adds the following tables to your database: 561 | 562 | 563 | ### User 564 | 565 | Table Name: `user` 566 | 567 | <DatabaseTable 568 | fields={[ 569 | { 570 | name: "stripeCustomerId", 571 | type: "string", 572 | description: "The Stripe customer ID", 573 | isOptional: true 574 | }, 575 | ]} 576 | /> 577 | 578 | ### Subscription 579 | 580 | Table Name: `subscription` 581 | 582 | <DatabaseTable 583 | fields={[ 584 | { 585 | name: "id", 586 | type: "string", 587 | description: "Unique identifier for each subscription", 588 | isPrimaryKey: true 589 | }, 590 | { 591 | name: "plan", 592 | type: "string", 593 | description: "The name of the subscription plan" 594 | }, 595 | { 596 | name: "referenceId", 597 | type: "string", 598 | description: "The ID this subscription is associated with (user ID by default)", 599 | isUnique: true 600 | }, 601 | { 602 | name: "stripeCustomerId", 603 | type: "string", 604 | description: "The Stripe customer ID", 605 | isOptional: true 606 | }, 607 | { 608 | name: "stripeSubscriptionId", 609 | type: "string", 610 | description: "The Stripe subscription ID", 611 | isOptional: true 612 | }, 613 | { 614 | name: "status", 615 | type: "string", 616 | description: "The status of the subscription (active, canceled, etc.)", 617 | defaultValue: "incomplete" 618 | }, 619 | { 620 | name: "periodStart", 621 | type: "Date", 622 | description: "Start date of the current billing period", 623 | isOptional: true 624 | }, 625 | { 626 | name: "periodEnd", 627 | type: "Date", 628 | description: "End date of the current billing period", 629 | isOptional: true 630 | }, 631 | { 632 | name: "cancelAtPeriodEnd", 633 | type: "boolean", 634 | description: "Whether the subscription will be canceled at the end of the period", 635 | defaultValue: false, 636 | isOptional: true 637 | }, 638 | { 639 | name: "seats", 640 | type: "number", 641 | description: "Number of seats for team plans", 642 | isOptional: true 643 | }, 644 | { 645 | name: "trialStart", 646 | type: "Date", 647 | description: "Start date of the trial period", 648 | isOptional: true 649 | }, 650 | { 651 | name: "trialEnd", 652 | type: "Date", 653 | description: "End date of the trial period", 654 | isOptional: true 655 | } 656 | ]} 657 | /> 658 | 659 | ### Customizing the Schema 660 | 661 | To change the schema table names or fields, you can pass a `schema` option to the Stripe plugin: 662 | 663 | ```ts title="auth.ts" 664 | stripe({ 665 | // ... other options 666 | schema: { 667 | subscription: { 668 | modelName: "stripeSubscriptions", // map the subscription table to stripeSubscriptions 669 | fields: { 670 | plan: "planName" // map the plan field to planName 671 | } 672 | } 673 | } 674 | }) 675 | ``` 676 | 677 | ## Options 678 | 679 | ### Main Options 680 | 681 | **stripeClient**: `Stripe` - The Stripe client instance. Required. 682 | 683 | **stripeWebhookSecret**: `string` - The webhook signing secret from Stripe. Required. 684 | 685 | **createCustomerOnSignUp**: `boolean` - Whether to automatically create a Stripe customer when a user signs up. Default: `false`. 686 | 687 | **onCustomerCreate**: `(data: { customer: Customer, stripeCustomer: Stripe.Customer, user: User }, request?: Request) => Promise<void>` - A function called after a customer is created. 688 | 689 | **getCustomerCreateParams**: `(data: { user: User, session: Session }, request?: Request) => Promise<{}>` - A function to customize the Stripe customer creation parameters. 690 | 691 | **onEvent**: `(event: Stripe.Event) => Promise<void>` - A function called for any Stripe webhook event. 692 | 693 | ### Subscription Options 694 | 695 | **enabled**: `boolean` - Whether to enable subscription functionality. Required. 696 | 697 | **plans**: `Plan[] | (() => Promise<Plan[]>)` - An array of subscription plans or a function that returns plans. Required if subscriptions are enabled. 698 | 699 | **requireEmailVerification**: `boolean` - Whether to require email verification before allowing subscription upgrades. Default: `false`. 700 | 701 | **authorizeReference**: `(data: { user: User, session: Session, referenceId: string, action: "upgrade-subscription" | "list-subscription" | "cancel-subscription" | "restore-subscription"}, request?: Request) => Promise<boolean>` - A function to authorize reference IDs. 702 | 703 | ### Plan Configuration 704 | 705 | Each plan can have the following properties: 706 | 707 | **name**: `string` - The name of the plan. Required. 708 | 709 | **priceId**: `string` - The Stripe price ID. Required unless using `lookupKey`. 710 | 711 | **lookupKey**: `string` - The Stripe price lookup key. Alternative to `priceId`. 712 | 713 | **annualDiscountPriceId**: `string` - A price ID for annual billing. 714 | 715 | **annualDiscountLookupKey**: `string` - The Stripe price lookup key for annual billing. Alternative to `annualDiscountPriceId`. 716 | 717 | **limits**: `Record<string, number>` - Limits associated with the plan (e.g., `{ projects: 10, storage: 5 }`). 718 | 719 | **group**: `string` - A group name for the plan, useful for categorizing plans. 720 | 721 | **freeTrial**: Object containing trial configuration: 722 | - **days**: `number` - Number of trial days. 723 | - **onTrialStart**: `(subscription: Subscription) => Promise<void>` - Called when a trial starts. 724 | - **onTrialEnd**: `(data: { subscription: Subscription, user: User }, request?: Request) => Promise<void>` - Called when a trial ends. 725 | - **onTrialExpired**: `(subscription: Subscription) => Promise<void>` - Called when a trial expires without conversion. 726 | 727 | ## Advanced Usage 728 | 729 | ### Using with Organizations 730 | 731 | The Stripe plugin works well with the organization plugin. You can associate subscriptions with organizations instead of individual users: 732 | 733 | ```ts title="client.ts" 734 | // Get the active organization 735 | const { data: activeOrg } = client.useActiveOrganization(); 736 | 737 | // Create a subscription for the organization 738 | await client.subscription.upgrade({ 739 | plan: "team", 740 | referenceId: activeOrg.id, 741 | seats: 10, 742 | annual: true, // upgrade to an annual plan (optional) 743 | successUrl: "/org/billing/success", 744 | cancelUrl: "/org/billing" 745 | }); 746 | ``` 747 | 748 | Make sure to implement the `authorizeReference` function to verify that the user has permission to manage subscriptions for the organization: 749 | 750 | ```ts title="auth.ts" 751 | authorizeReference: async ({ user, referenceId, action }) => { 752 | const member = await db.members.findFirst({ 753 | where: { 754 | userId: user.id, 755 | organizationId: referenceId 756 | } 757 | }); 758 | 759 | return member?.role === "owner" || member?.role === "admin"; 760 | } 761 | ``` 762 | 763 | ### Custom Checkout Session Parameters 764 | 765 | You can customize the Stripe Checkout session with additional parameters: 766 | 767 | ```ts title="auth.ts" 768 | getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => { 769 | return { 770 | params: { 771 | allow_promotion_codes: true, 772 | tax_id_collection: { 773 | enabled: true 774 | }, 775 | billing_address_collection: "required", 776 | custom_text: { 777 | submit: { 778 | message: "We'll start your subscription right away" 779 | } 780 | }, 781 | metadata: { 782 | planType: "business", 783 | referralCode: user.metadata?.referralCode 784 | } 785 | }, 786 | options: { 787 | idempotencyKey: `sub_${user.id}_${plan.name}_${Date.now()}` 788 | } 789 | }; 790 | } 791 | ``` 792 | 793 | ### Tax Collection 794 | 795 | To collect tax IDs from the customer, set `tax_id_collection` to true: 796 | 797 | ```ts title="auth.ts" 798 | subscription: { 799 | // ... other options 800 | getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => { 801 | return { 802 | params: { 803 | tax_id_collection: { 804 | enabled: true 805 | } 806 | } 807 | }; 808 | } 809 | } 810 | ``` 811 | 812 | ### Automatic Tax Calculation 813 | 814 | To enable automatic tax calculation using the customer's location, set `automatic_tax` to true. Enabling this parameter causes Checkout to collect any billing address information necessary for tax calculation. You need to have tax registration setup and configured in the Stripe dashboard first for this to work. 815 | 816 | ```ts title="auth.ts" 817 | subscription: { 818 | // ... other options 819 | getCheckoutSessionParams: async ({ user, session, plan, subscription }, request) => { 820 | return { 821 | params: { 822 | automatic_tax: { 823 | enabled: true 824 | } 825 | } 826 | }; 827 | } 828 | } 829 | ``` 830 | 831 | ### Trial Period Management 832 | 833 | The Stripe plugin automatically prevents users from getting multiple free trials. Once a user has used a trial period (regardless of which plan), they will not be eligible for additional trials on any plan. 834 | 835 | **How it works:** 836 | - The system tracks trial usage across all plans for each user 837 | - When a user subscribes to a plan with a trial, the system checks their subscription history 838 | - If the user has ever had a trial (indicated by `trialStart`/`trialEnd` fields or `trialing` status), no new trial will be offered 839 | - This prevents abuse where users cancel subscriptions and resubscribe to get multiple free trials 840 | 841 | **Example scenario:** 842 | 1. User subscribes to "Starter" plan with 7-day trial 843 | 2. User cancels the subscription after the trial 844 | 3. User tries to subscribe to "Premium" plan - no trial will be offered 845 | 4. User will be charged immediately for the Premium plan 846 | 847 | This behavior is automatic and requires no additional configuration. The trial eligibility is determined at the time of subscription creation and cannot be overridden through configuration. 848 | 849 | ## Troubleshooting 850 | 851 | ### Webhook Issues 852 | 853 | If webhooks aren't being processed correctly: 854 | 855 | 1. Check that your webhook URL is correctly configured in the Stripe dashboard 856 | 2. Verify that the webhook signing secret is correct 857 | 3. Ensure you've selected all the necessary events in the Stripe dashboard 858 | 4. Check your server logs for any errors during webhook processing 859 | 860 | ### Subscription Status Issues 861 | 862 | If subscription statuses aren't updating correctly: 863 | 864 | 1. Make sure the webhook events are being received and processed 865 | 2. Check that the `stripeCustomerId` and `stripeSubscriptionId` fields are correctly populated 866 | 3. Verify that the reference IDs match between your application and Stripe 867 | 868 | ### Testing Webhooks Locally 869 | 870 | For local development, you can use the Stripe CLI to forward webhooks to your local environment: 871 | 872 | ```bash 873 | stripe listen --forward-to localhost:3000/api/auth/stripe/webhook 874 | ``` 875 | 876 | This will provide you with a webhook signing secret that you can use in your local environment. ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/routes/crud-invites.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { createAuthEndpoint } from "@better-auth/core/middleware"; 3 | import { getSessionFromCtx } from "../../../api/routes"; 4 | import { getOrgAdapter } from "../adapter"; 5 | import { orgMiddleware, orgSessionMiddleware } from "../call"; 6 | import { 7 | type InferOrganizationRolesFromOption, 8 | type Invitation, 9 | } from "../schema"; 10 | import { APIError } from "better-call"; 11 | import { parseRoles } from "../organization"; 12 | import { type OrganizationOptions } from "../types"; 13 | import { ORGANIZATION_ERROR_CODES } from "../error-codes"; 14 | import { hasPermission } from "../has-permission"; 15 | import { setSessionCookie } from "../../../cookies"; 16 | import { 17 | toZodSchema, 18 | type InferAdditionalFieldsFromPluginOptions, 19 | } from "../../../db"; 20 | import { getDate } from "../../../utils/date"; 21 | 22 | export const createInvitation = <O extends OrganizationOptions>(option: O) => { 23 | const additionalFieldsSchema = toZodSchema({ 24 | fields: option?.schema?.invitation?.additionalFields || {}, 25 | isClientSide: true, 26 | }); 27 | 28 | const baseSchema = z.object({ 29 | email: z.string().meta({ 30 | description: "The email address of the user to invite", 31 | }), 32 | role: z 33 | .union([ 34 | z.string().meta({ 35 | description: "The role to assign to the user", 36 | }), 37 | z.array( 38 | z.string().meta({ 39 | description: "The roles to assign to the user", 40 | }), 41 | ), 42 | ]) 43 | .meta({ 44 | description: 45 | 'The role(s) to assign to the user. It can be `admin`, `member`, or `guest`. Eg: "member"', 46 | }), 47 | organizationId: z 48 | .string() 49 | .meta({ 50 | description: "The organization ID to invite the user to", 51 | }) 52 | .optional(), 53 | resend: z 54 | .boolean() 55 | .meta({ 56 | description: 57 | "Resend the invitation email, if the user is already invited. Eg: true", 58 | }) 59 | .optional(), 60 | teamId: z.union([ 61 | z 62 | .string() 63 | .meta({ 64 | description: "The team ID to invite the user to", 65 | }) 66 | .optional(), 67 | z 68 | .array(z.string()) 69 | .meta({ 70 | description: "The team IDs to invite the user to", 71 | }) 72 | .optional(), 73 | ]), 74 | }); 75 | 76 | return createAuthEndpoint( 77 | "/organization/invite-member", 78 | { 79 | method: "POST", 80 | use: [orgMiddleware, orgSessionMiddleware], 81 | body: z.object({ 82 | ...baseSchema.shape, 83 | ...additionalFieldsSchema.shape, 84 | }), 85 | metadata: { 86 | $Infer: { 87 | body: {} as { 88 | /** 89 | * The email address of the user 90 | * to invite 91 | */ 92 | email: string; 93 | /** 94 | * The role to assign to the user 95 | */ 96 | role: 97 | | InferOrganizationRolesFromOption<O> 98 | | InferOrganizationRolesFromOption<O>[]; 99 | /** 100 | * The organization ID to invite 101 | * the user to 102 | */ 103 | organizationId?: string | undefined; 104 | /** 105 | * Resend the invitation email, if 106 | * the user is already invited 107 | */ 108 | resend?: boolean; 109 | } & (O extends { teams: { enabled: true } } 110 | ? { 111 | /** 112 | * The team the user is 113 | * being invited to. 114 | */ 115 | teamId?: string | string[]; 116 | } 117 | : {}) & 118 | InferAdditionalFieldsFromPluginOptions<"invitation", O, false>, 119 | }, 120 | openapi: { 121 | description: "Invite a user to an organization", 122 | responses: { 123 | "200": { 124 | description: "Success", 125 | content: { 126 | "application/json": { 127 | schema: { 128 | type: "object", 129 | properties: { 130 | id: { 131 | type: "string", 132 | }, 133 | email: { 134 | type: "string", 135 | }, 136 | role: { 137 | type: "string", 138 | }, 139 | organizationId: { 140 | type: "string", 141 | }, 142 | inviterId: { 143 | type: "string", 144 | }, 145 | status: { 146 | type: "string", 147 | }, 148 | expiresAt: { 149 | type: "string", 150 | }, 151 | createdAt: { 152 | type: "string", 153 | }, 154 | }, 155 | required: [ 156 | "id", 157 | "email", 158 | "role", 159 | "organizationId", 160 | "inviterId", 161 | "status", 162 | "expiresAt", 163 | "createdAt", 164 | ], 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | async (ctx) => { 174 | const session = ctx.context.session; 175 | const organizationId = 176 | ctx.body.organizationId || session.session.activeOrganizationId; 177 | if (!organizationId) { 178 | throw new APIError("BAD_REQUEST", { 179 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 180 | }); 181 | } 182 | const adapter = getOrgAdapter<O>(ctx.context, option as O); 183 | const member = await adapter.findMemberByOrgId({ 184 | userId: session.user.id, 185 | organizationId: organizationId, 186 | }); 187 | if (!member) { 188 | throw new APIError("BAD_REQUEST", { 189 | message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND, 190 | }); 191 | } 192 | const canInvite = await hasPermission( 193 | { 194 | role: member.role, 195 | options: ctx.context.orgOptions, 196 | permissions: { 197 | invitation: ["create"], 198 | }, 199 | organizationId, 200 | }, 201 | ctx, 202 | ); 203 | 204 | if (!canInvite) { 205 | throw new APIError("FORBIDDEN", { 206 | message: 207 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION, 208 | }); 209 | } 210 | 211 | const creatorRole = ctx.context.orgOptions.creatorRole || "owner"; 212 | 213 | const roles = parseRoles(ctx.body.role as string | string[]); 214 | 215 | if ( 216 | member.role !== creatorRole && 217 | roles.split(",").includes(creatorRole) 218 | ) { 219 | throw new APIError("FORBIDDEN", { 220 | message: 221 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_INVITE_USER_WITH_THIS_ROLE, 222 | }); 223 | } 224 | 225 | const alreadyMember = await adapter.findMemberByEmail({ 226 | email: ctx.body.email, 227 | organizationId: organizationId, 228 | }); 229 | if (alreadyMember) { 230 | throw new APIError("BAD_REQUEST", { 231 | message: 232 | ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION, 233 | }); 234 | } 235 | const alreadyInvited = await adapter.findPendingInvitation({ 236 | email: ctx.body.email, 237 | organizationId: organizationId, 238 | }); 239 | if (alreadyInvited.length && !ctx.body.resend) { 240 | throw new APIError("BAD_REQUEST", { 241 | message: 242 | ORGANIZATION_ERROR_CODES.USER_IS_ALREADY_INVITED_TO_THIS_ORGANIZATION, 243 | }); 244 | } 245 | 246 | const organization = await adapter.findOrganizationById(organizationId); 247 | if (!organization) { 248 | throw new APIError("BAD_REQUEST", { 249 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 250 | }); 251 | } 252 | 253 | // If resend is true and there's an existing invitation, reuse it 254 | if (alreadyInvited.length && ctx.body.resend) { 255 | const existingInvitation = alreadyInvited[0]; 256 | 257 | // Update the invitation's expiration date using the same logic as createInvitation 258 | const defaultExpiration = 60 * 60 * 48; // 48 hours in seconds 259 | const newExpiresAt = getDate( 260 | ctx.context.orgOptions.invitationExpiresIn || defaultExpiration, 261 | "sec", 262 | ); 263 | 264 | await ctx.context.adapter.update({ 265 | model: "invitation", 266 | where: [ 267 | { 268 | field: "id", 269 | value: existingInvitation!.id, 270 | }, 271 | ], 272 | update: { 273 | expiresAt: newExpiresAt, 274 | }, 275 | }); 276 | 277 | const updatedInvitation = { 278 | ...existingInvitation, 279 | expiresAt: newExpiresAt, 280 | }; 281 | 282 | await ctx.context.orgOptions.sendInvitationEmail?.( 283 | { 284 | id: updatedInvitation.id!, 285 | role: updatedInvitation.role! as string, 286 | email: updatedInvitation.email!.toLowerCase(), 287 | organization: organization, 288 | inviter: { 289 | ...member, 290 | user: session.user, 291 | }, 292 | invitation: updatedInvitation as unknown as Invitation, 293 | }, 294 | ctx.request, 295 | ); 296 | 297 | return ctx.json(updatedInvitation); 298 | } 299 | 300 | if ( 301 | alreadyInvited.length && 302 | ctx.context.orgOptions.cancelPendingInvitationsOnReInvite 303 | ) { 304 | await adapter.updateInvitation({ 305 | invitationId: alreadyInvited[0]!.id, 306 | status: "canceled", 307 | }); 308 | } 309 | 310 | const invitationLimit = 311 | typeof ctx.context.orgOptions.invitationLimit === "function" 312 | ? await ctx.context.orgOptions.invitationLimit( 313 | { 314 | user: session.user, 315 | organization, 316 | member: member, 317 | }, 318 | ctx.context, 319 | ) 320 | : (ctx.context.orgOptions.invitationLimit ?? 100); 321 | 322 | const pendingInvitations = await adapter.findPendingInvitations({ 323 | organizationId: organizationId, 324 | }); 325 | 326 | if (pendingInvitations.length >= invitationLimit) { 327 | throw new APIError("FORBIDDEN", { 328 | message: ORGANIZATION_ERROR_CODES.INVITATION_LIMIT_REACHED, 329 | }); 330 | } 331 | 332 | if ( 333 | ctx.context.orgOptions.teams && 334 | ctx.context.orgOptions.teams.enabled && 335 | typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== 336 | "undefined" && 337 | "teamId" in ctx.body && 338 | ctx.body.teamId 339 | ) { 340 | const teamIds = 341 | typeof ctx.body.teamId === "string" 342 | ? [ctx.body.teamId as string] 343 | : (ctx.body.teamId as string[]); 344 | 345 | for (const teamId of teamIds) { 346 | const team = await adapter.findTeamById({ 347 | teamId, 348 | organizationId: organizationId, 349 | includeTeamMembers: true, 350 | }); 351 | 352 | if (!team) { 353 | throw new APIError("BAD_REQUEST", { 354 | message: ORGANIZATION_ERROR_CODES.TEAM_NOT_FOUND, 355 | }); 356 | } 357 | 358 | const maximumMembersPerTeam = 359 | typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === 360 | "function" 361 | ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({ 362 | teamId, 363 | session: session, 364 | organizationId: organizationId, 365 | }) 366 | : ctx.context.orgOptions.teams.maximumMembersPerTeam; 367 | if (team.members.length >= maximumMembersPerTeam) { 368 | throw new APIError("FORBIDDEN", { 369 | message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, 370 | }); 371 | } 372 | } 373 | } 374 | 375 | const teamIds: string[] = 376 | "teamId" in ctx.body 377 | ? typeof ctx.body.teamId === "string" 378 | ? [ctx.body.teamId as string] 379 | : ((ctx.body.teamId as string[]) ?? []) 380 | : []; 381 | 382 | const { 383 | email: _, 384 | role: __, 385 | organizationId: ___, 386 | resend: ____, 387 | ...additionalFields 388 | } = ctx.body; 389 | 390 | let invitationData = { 391 | role: roles, 392 | email: ctx.body.email.toLowerCase(), 393 | organizationId: organizationId, 394 | teamIds, 395 | ...(additionalFields ? additionalFields : {}), 396 | }; 397 | 398 | // Run beforeCreateInvitation hook 399 | if (option?.organizationHooks?.beforeCreateInvitation) { 400 | const response = await option?.organizationHooks.beforeCreateInvitation( 401 | { 402 | invitation: { 403 | ...invitationData, 404 | inviterId: session.user.id, 405 | teamId: teamIds.length > 0 ? teamIds[0] : undefined, 406 | }, 407 | inviter: session.user, 408 | organization, 409 | }, 410 | ); 411 | if (response && typeof response === "object" && "data" in response) { 412 | invitationData = { 413 | ...invitationData, 414 | ...response.data, 415 | }; 416 | } 417 | } 418 | 419 | const invitation = await adapter.createInvitation({ 420 | invitation: invitationData, 421 | user: session.user, 422 | }); 423 | 424 | await ctx.context.orgOptions.sendInvitationEmail?.( 425 | { 426 | id: invitation.id, 427 | role: invitation.role as string, 428 | email: invitation.email.toLowerCase(), 429 | organization: organization, 430 | inviter: { 431 | ...member, 432 | user: session.user, 433 | }, 434 | //@ts-expect-error 435 | invitation, 436 | }, 437 | ctx.request, 438 | ); 439 | 440 | // Run afterCreateInvitation hook 441 | if (option?.organizationHooks?.afterCreateInvitation) { 442 | await option?.organizationHooks.afterCreateInvitation({ 443 | invitation: invitation as unknown as Invitation, 444 | inviter: session.user, 445 | organization, 446 | }); 447 | } 448 | 449 | return ctx.json(invitation); 450 | }, 451 | ); 452 | }; 453 | 454 | export const acceptInvitation = <O extends OrganizationOptions>(options: O) => 455 | createAuthEndpoint( 456 | "/organization/accept-invitation", 457 | { 458 | method: "POST", 459 | body: z.object({ 460 | invitationId: z.string().meta({ 461 | description: "The ID of the invitation to accept", 462 | }), 463 | }), 464 | use: [orgMiddleware, orgSessionMiddleware], 465 | metadata: { 466 | openapi: { 467 | description: "Accept an invitation to an organization", 468 | responses: { 469 | "200": { 470 | description: "Success", 471 | content: { 472 | "application/json": { 473 | schema: { 474 | type: "object", 475 | properties: { 476 | invitation: { 477 | type: "object", 478 | }, 479 | member: { 480 | type: "object", 481 | }, 482 | }, 483 | }, 484 | }, 485 | }, 486 | }, 487 | }, 488 | }, 489 | }, 490 | }, 491 | async (ctx) => { 492 | const session = ctx.context.session; 493 | const adapter = getOrgAdapter<O>(ctx.context, options); 494 | const invitation = await adapter.findInvitationById( 495 | ctx.body.invitationId, 496 | ); 497 | 498 | if ( 499 | !invitation || 500 | invitation.expiresAt < new Date() || 501 | invitation.status !== "pending" 502 | ) { 503 | throw new APIError("BAD_REQUEST", { 504 | message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND, 505 | }); 506 | } 507 | 508 | if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) { 509 | throw new APIError("FORBIDDEN", { 510 | message: 511 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION, 512 | }); 513 | } 514 | 515 | if ( 516 | ctx.context.orgOptions.requireEmailVerificationOnInvitation && 517 | !session.user.emailVerified 518 | ) { 519 | throw new APIError("FORBIDDEN", { 520 | message: 521 | ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION, 522 | }); 523 | } 524 | 525 | const membershipLimit = ctx.context.orgOptions?.membershipLimit || 100; 526 | const membersCount = await adapter.countMembers({ 527 | organizationId: invitation.organizationId, 528 | }); 529 | 530 | if (membersCount >= membershipLimit) { 531 | throw new APIError("FORBIDDEN", { 532 | message: 533 | ORGANIZATION_ERROR_CODES.ORGANIZATION_MEMBERSHIP_LIMIT_REACHED, 534 | }); 535 | } 536 | 537 | const organization = await adapter.findOrganizationById( 538 | invitation.organizationId, 539 | ); 540 | if (!organization) { 541 | throw new APIError("BAD_REQUEST", { 542 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 543 | }); 544 | } 545 | 546 | // Run beforeAcceptInvitation hook 547 | if (options?.organizationHooks?.beforeAcceptInvitation) { 548 | await options?.organizationHooks.beforeAcceptInvitation({ 549 | invitation: invitation as unknown as Invitation, 550 | user: session.user, 551 | organization, 552 | }); 553 | } 554 | 555 | const acceptedI = await adapter.updateInvitation({ 556 | invitationId: ctx.body.invitationId, 557 | status: "accepted", 558 | }); 559 | if (!acceptedI) { 560 | throw new APIError("BAD_REQUEST", { 561 | message: ORGANIZATION_ERROR_CODES.FAILED_TO_RETRIEVE_INVITATION, 562 | }); 563 | } 564 | if ( 565 | ctx.context.orgOptions.teams && 566 | ctx.context.orgOptions.teams.enabled && 567 | "teamId" in acceptedI && 568 | acceptedI.teamId 569 | ) { 570 | const teamIds = (acceptedI.teamId as string).split(","); 571 | const onlyOne = teamIds.length === 1; 572 | 573 | for (const teamId of teamIds) { 574 | await adapter.findOrCreateTeamMember({ 575 | teamId: teamId, 576 | userId: session.user.id, 577 | }); 578 | 579 | if ( 580 | typeof ctx.context.orgOptions.teams.maximumMembersPerTeam !== 581 | "undefined" 582 | ) { 583 | const members = await adapter.countTeamMembers({ teamId }); 584 | 585 | const maximumMembersPerTeam = 586 | typeof ctx.context.orgOptions.teams.maximumMembersPerTeam === 587 | "function" 588 | ? await ctx.context.orgOptions.teams.maximumMembersPerTeam({ 589 | teamId, 590 | session: session, 591 | organizationId: invitation.organizationId, 592 | }) 593 | : ctx.context.orgOptions.teams.maximumMembersPerTeam; 594 | 595 | if (members >= maximumMembersPerTeam) { 596 | throw new APIError("FORBIDDEN", { 597 | message: ORGANIZATION_ERROR_CODES.TEAM_MEMBER_LIMIT_REACHED, 598 | }); 599 | } 600 | } 601 | } 602 | 603 | if (onlyOne) { 604 | const teamId = teamIds[0]!; 605 | const updatedSession = await adapter.setActiveTeam( 606 | session.session.token, 607 | teamId, 608 | ctx, 609 | ); 610 | 611 | await setSessionCookie(ctx, { 612 | session: updatedSession, 613 | user: session.user, 614 | }); 615 | } 616 | } 617 | 618 | const member = await adapter.createMember({ 619 | organizationId: invitation.organizationId, 620 | userId: session.user.id, 621 | role: invitation.role as string, 622 | createdAt: new Date(), 623 | }); 624 | 625 | await adapter.setActiveOrganization( 626 | session.session.token, 627 | invitation.organizationId, 628 | ctx, 629 | ); 630 | if (!acceptedI) { 631 | return ctx.json(null, { 632 | status: 400, 633 | body: { 634 | message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND, 635 | }, 636 | }); 637 | } 638 | if (options?.organizationHooks?.afterAcceptInvitation) { 639 | await options?.organizationHooks.afterAcceptInvitation({ 640 | invitation: acceptedI as unknown as Invitation, 641 | member, 642 | user: session.user, 643 | organization, 644 | }); 645 | } 646 | return ctx.json({ 647 | invitation: acceptedI, 648 | member, 649 | }); 650 | }, 651 | ); 652 | 653 | export const rejectInvitation = <O extends OrganizationOptions>(options: O) => 654 | createAuthEndpoint( 655 | "/organization/reject-invitation", 656 | { 657 | method: "POST", 658 | body: z.object({ 659 | invitationId: z.string().meta({ 660 | description: "The ID of the invitation to reject", 661 | }), 662 | }), 663 | use: [orgMiddleware, orgSessionMiddleware], 664 | metadata: { 665 | openapi: { 666 | description: "Reject an invitation to an organization", 667 | responses: { 668 | "200": { 669 | description: "Success", 670 | content: { 671 | "application/json": { 672 | schema: { 673 | type: "object", 674 | properties: { 675 | invitation: { 676 | type: "object", 677 | }, 678 | member: { 679 | type: "null", 680 | }, 681 | }, 682 | }, 683 | }, 684 | }, 685 | }, 686 | }, 687 | }, 688 | }, 689 | }, 690 | async (ctx) => { 691 | const session = ctx.context.session; 692 | const adapter = getOrgAdapter(ctx.context, ctx.context.orgOptions); 693 | const invitation = await adapter.findInvitationById( 694 | ctx.body.invitationId, 695 | ); 696 | if ( 697 | !invitation || 698 | invitation.expiresAt < new Date() || 699 | invitation.status !== "pending" 700 | ) { 701 | throw new APIError("BAD_REQUEST", { 702 | message: "Invitation not found!", 703 | }); 704 | } 705 | if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) { 706 | throw new APIError("FORBIDDEN", { 707 | message: 708 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION, 709 | }); 710 | } 711 | 712 | if ( 713 | ctx.context.orgOptions.requireEmailVerificationOnInvitation && 714 | !session.user.emailVerified 715 | ) { 716 | throw new APIError("FORBIDDEN", { 717 | message: 718 | ORGANIZATION_ERROR_CODES.EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION, 719 | }); 720 | } 721 | 722 | const organization = await adapter.findOrganizationById( 723 | invitation.organizationId, 724 | ); 725 | if (!organization) { 726 | throw new APIError("BAD_REQUEST", { 727 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 728 | }); 729 | } 730 | 731 | // Run beforeRejectInvitation hook 732 | if (options?.organizationHooks?.beforeRejectInvitation) { 733 | await options?.organizationHooks.beforeRejectInvitation({ 734 | invitation: invitation as unknown as Invitation, 735 | user: session.user, 736 | organization, 737 | }); 738 | } 739 | 740 | const rejectedI = await adapter.updateInvitation({ 741 | invitationId: ctx.body.invitationId, 742 | status: "rejected", 743 | }); 744 | 745 | // Run afterRejectInvitation hook 746 | if (options?.organizationHooks?.afterRejectInvitation) { 747 | await options?.organizationHooks.afterRejectInvitation({ 748 | invitation: rejectedI || (invitation as unknown as Invitation), 749 | user: session.user, 750 | organization, 751 | }); 752 | } 753 | 754 | return ctx.json({ 755 | invitation: rejectedI, 756 | member: null, 757 | }); 758 | }, 759 | ); 760 | 761 | export const cancelInvitation = <O extends OrganizationOptions>(options: O) => 762 | createAuthEndpoint( 763 | "/organization/cancel-invitation", 764 | { 765 | method: "POST", 766 | body: z.object({ 767 | invitationId: z.string().meta({ 768 | description: "The ID of the invitation to cancel", 769 | }), 770 | }), 771 | use: [orgMiddleware, orgSessionMiddleware], 772 | openapi: { 773 | description: "Cancel an invitation to an organization", 774 | responses: { 775 | "200": { 776 | description: "Success", 777 | content: { 778 | "application/json": { 779 | schema: { 780 | type: "object", 781 | properties: { 782 | invitation: { 783 | type: "object", 784 | }, 785 | }, 786 | }, 787 | }, 788 | }, 789 | }, 790 | }, 791 | }, 792 | }, 793 | async (ctx) => { 794 | const session = ctx.context.session; 795 | const adapter = getOrgAdapter<O>(ctx.context, options); 796 | const invitation = await adapter.findInvitationById( 797 | ctx.body.invitationId, 798 | ); 799 | if (!invitation) { 800 | throw new APIError("BAD_REQUEST", { 801 | message: ORGANIZATION_ERROR_CODES.INVITATION_NOT_FOUND, 802 | }); 803 | } 804 | const member = await adapter.findMemberByOrgId({ 805 | userId: session.user.id, 806 | organizationId: invitation.organizationId, 807 | }); 808 | if (!member) { 809 | throw new APIError("BAD_REQUEST", { 810 | message: ORGANIZATION_ERROR_CODES.MEMBER_NOT_FOUND, 811 | }); 812 | } 813 | const canCancel = await hasPermission( 814 | { 815 | role: member.role, 816 | options: ctx.context.orgOptions, 817 | permissions: { 818 | invitation: ["cancel"], 819 | }, 820 | organizationId: invitation.organizationId, 821 | }, 822 | ctx, 823 | ); 824 | 825 | if (!canCancel) { 826 | throw new APIError("FORBIDDEN", { 827 | message: 828 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION, 829 | }); 830 | } 831 | 832 | const organization = await adapter.findOrganizationById( 833 | invitation.organizationId, 834 | ); 835 | if (!organization) { 836 | throw new APIError("BAD_REQUEST", { 837 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 838 | }); 839 | } 840 | 841 | // Run beforeCancelInvitation hook 842 | if (options?.organizationHooks?.beforeCancelInvitation) { 843 | await options?.organizationHooks.beforeCancelInvitation({ 844 | invitation: invitation as unknown as Invitation, 845 | cancelledBy: session.user, 846 | organization, 847 | }); 848 | } 849 | 850 | const canceledI = await adapter.updateInvitation({ 851 | invitationId: ctx.body.invitationId, 852 | status: "canceled", 853 | }); 854 | 855 | // Run afterCancelInvitation hook 856 | if (options?.organizationHooks?.afterCancelInvitation) { 857 | await options?.organizationHooks.afterCancelInvitation({ 858 | invitation: (canceledI as unknown as Invitation) || invitation, 859 | cancelledBy: session.user, 860 | organization, 861 | }); 862 | } 863 | 864 | return ctx.json(canceledI); 865 | }, 866 | ); 867 | 868 | export const getInvitation = <O extends OrganizationOptions>(options: O) => 869 | createAuthEndpoint( 870 | "/organization/get-invitation", 871 | { 872 | method: "GET", 873 | use: [orgMiddleware], 874 | requireHeaders: true, 875 | query: z.object({ 876 | id: z.string().meta({ 877 | description: "The ID of the invitation to get", 878 | }), 879 | }), 880 | metadata: { 881 | openapi: { 882 | description: "Get an invitation by ID", 883 | responses: { 884 | "200": { 885 | description: "Success", 886 | content: { 887 | "application/json": { 888 | schema: { 889 | type: "object", 890 | properties: { 891 | id: { 892 | type: "string", 893 | }, 894 | email: { 895 | type: "string", 896 | }, 897 | role: { 898 | type: "string", 899 | }, 900 | organizationId: { 901 | type: "string", 902 | }, 903 | inviterId: { 904 | type: "string", 905 | }, 906 | status: { 907 | type: "string", 908 | }, 909 | expiresAt: { 910 | type: "string", 911 | }, 912 | organizationName: { 913 | type: "string", 914 | }, 915 | organizationSlug: { 916 | type: "string", 917 | }, 918 | inviterEmail: { 919 | type: "string", 920 | }, 921 | }, 922 | required: [ 923 | "id", 924 | "email", 925 | "role", 926 | "organizationId", 927 | "inviterId", 928 | "status", 929 | "expiresAt", 930 | "organizationName", 931 | "organizationSlug", 932 | "inviterEmail", 933 | ], 934 | }, 935 | }, 936 | }, 937 | }, 938 | }, 939 | }, 940 | }, 941 | }, 942 | async (ctx) => { 943 | const session = await getSessionFromCtx(ctx); 944 | if (!session) { 945 | throw new APIError("UNAUTHORIZED", { 946 | message: "Not authenticated", 947 | }); 948 | } 949 | const adapter = getOrgAdapter<O>(ctx.context, options); 950 | const invitation = await adapter.findInvitationById(ctx.query.id); 951 | if ( 952 | !invitation || 953 | invitation.status !== "pending" || 954 | invitation.expiresAt < new Date() 955 | ) { 956 | throw new APIError("BAD_REQUEST", { 957 | message: "Invitation not found!", 958 | }); 959 | } 960 | if (invitation.email.toLowerCase() !== session.user.email.toLowerCase()) { 961 | throw new APIError("FORBIDDEN", { 962 | message: 963 | ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION, 964 | }); 965 | } 966 | const organization = await adapter.findOrganizationById( 967 | invitation.organizationId, 968 | ); 969 | if (!organization) { 970 | throw new APIError("BAD_REQUEST", { 971 | message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND, 972 | }); 973 | } 974 | const member = await adapter.findMemberByOrgId({ 975 | userId: invitation.inviterId, 976 | organizationId: invitation.organizationId, 977 | }); 978 | if (!member) { 979 | throw new APIError("BAD_REQUEST", { 980 | message: 981 | ORGANIZATION_ERROR_CODES.INVITER_IS_NO_LONGER_A_MEMBER_OF_THE_ORGANIZATION, 982 | }); 983 | } 984 | 985 | return ctx.json({ 986 | ...invitation, 987 | organizationName: organization.name, 988 | organizationSlug: organization.slug, 989 | inviterEmail: member.user.email, 990 | }); 991 | }, 992 | ); 993 | 994 | export const listInvitations = <O extends OrganizationOptions>(options: O) => 995 | createAuthEndpoint( 996 | "/organization/list-invitations", 997 | { 998 | method: "GET", 999 | use: [orgMiddleware, orgSessionMiddleware], 1000 | query: z 1001 | .object({ 1002 | organizationId: z 1003 | .string() 1004 | .meta({ 1005 | description: "The ID of the organization to list invitations for", 1006 | }) 1007 | .optional(), 1008 | }) 1009 | .optional(), 1010 | }, 1011 | async (ctx) => { 1012 | const session = await getSessionFromCtx(ctx); 1013 | if (!session) { 1014 | throw new APIError("UNAUTHORIZED", { 1015 | message: "Not authenticated", 1016 | }); 1017 | } 1018 | const orgId = 1019 | ctx.query?.organizationId || session.session.activeOrganizationId; 1020 | if (!orgId) { 1021 | throw new APIError("BAD_REQUEST", { 1022 | message: "Organization ID is required", 1023 | }); 1024 | } 1025 | const adapter = getOrgAdapter<O>(ctx.context, options); 1026 | const isMember = await adapter.findMemberByOrgId({ 1027 | userId: session.user.id, 1028 | organizationId: orgId, 1029 | }); 1030 | if (!isMember) { 1031 | throw new APIError("FORBIDDEN", { 1032 | message: "You are not a member of this organization", 1033 | }); 1034 | } 1035 | const invitations = await adapter.listInvitations({ 1036 | organizationId: orgId, 1037 | }); 1038 | return ctx.json(invitations); 1039 | }, 1040 | ); 1041 | 1042 | /** 1043 | * List all invitations a user has received 1044 | */ 1045 | export const listUserInvitations = <O extends OrganizationOptions>( 1046 | options: O, 1047 | ) => 1048 | createAuthEndpoint( 1049 | "/organization/list-user-invitations", 1050 | { 1051 | method: "GET", 1052 | use: [orgMiddleware], 1053 | query: z 1054 | .object({ 1055 | email: z 1056 | .string() 1057 | .meta({ 1058 | description: 1059 | "The email of the user to list invitations for. This only works for server side API calls.", 1060 | }) 1061 | .optional(), 1062 | }) 1063 | .optional(), 1064 | }, 1065 | async (ctx) => { 1066 | const session = await getSessionFromCtx(ctx); 1067 | 1068 | if (ctx.request && ctx.query?.email) { 1069 | throw new APIError("BAD_REQUEST", { 1070 | message: "User email cannot be passed for client side API calls.", 1071 | }); 1072 | } 1073 | 1074 | const userEmail = session?.user.email || ctx.query?.email; 1075 | if (!userEmail) { 1076 | throw new APIError("BAD_REQUEST", { 1077 | message: "Missing session headers, or email query parameter.", 1078 | }); 1079 | } 1080 | const adapter = getOrgAdapter<O>(ctx.context, options); 1081 | 1082 | const invitations = await adapter.listUserInvitations(userEmail); 1083 | return ctx.json(invitations); 1084 | }, 1085 | ); 1086 | ```