#
tokens: 41083/50000 1/1094 files (page 64/67)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 64 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
│       ├── next.config.ts
│       ├── package.json
│       ├── postcss.config.mjs
│       ├── proxy.ts
│       ├── 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-decelration
│       │   │   │   ├── package.json
│       │   │   │   ├── src
│       │   │   │   │   └── index.ts
│       │   │   │   └── tsconfig.json
│       │   │   ├── tsconfig-exact-optional-property-types
│       │   │   │   ├── package.json
│       │   │   │   ├── src
│       │   │   │   │   ├── index.ts
│       │   │   │   │   ├── organization.ts
│       │   │   │   │   ├── user-additional-fields.ts
│       │   │   │   │   └── username.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
│   │   │   │   ├── 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
│   │   │   ├── api
│   │   │   │   └── index.ts
│   │   │   ├── async_hooks
│   │   │   │   └── index.ts
│   │   │   ├── context
│   │   │   │   ├── endpoint-context.ts
│   │   │   │   ├── 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
│   │   │   ├── 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

--------------------------------------------------------------------------------
/packages/sso/src/index.ts:
--------------------------------------------------------------------------------

```typescript
   1 | import {
   2 | 	generateState,
   3 | 	type Account,
   4 | 	type BetterAuthPlugin,
   5 | 	type OAuth2Tokens,
   6 | 	type Session,
   7 | 	type User,
   8 | } from "better-auth";
   9 | import { APIError, sessionMiddleware } from "better-auth/api";
  10 | import {
  11 | 	createAuthorizationURL,
  12 | 	handleOAuthUserInfo,
  13 | 	parseState,
  14 | 	validateAuthorizationCode,
  15 | 	validateToken,
  16 | } from "better-auth/oauth2";
  17 | 
  18 | import { createAuthEndpoint } from "better-auth/plugins";
  19 | import * as z from "zod/v4";
  20 | import * as saml from "samlify";
  21 | import type { BindingContext } from "samlify/types/src/entity";
  22 | import { betterFetch, BetterFetchError } from "@better-fetch/fetch";
  23 | import { decodeJwt } from "jose";
  24 | import { setSessionCookie } from "better-auth/cookies";
  25 | import type { FlowResult } from "samlify/types/src/flow";
  26 | import { XMLValidator } from "fast-xml-parser";
  27 | import type { IdentityProvider } from "samlify/types/src/entity-idp";
  28 | 
  29 | const fastValidator = {
  30 | 	async validate(xml: string) {
  31 | 		const isValid = XMLValidator.validate(xml, {
  32 | 			allowBooleanAttributes: true,
  33 | 		});
  34 | 		if (isValid === true) return "SUCCESS_VALIDATE_XML";
  35 | 		throw "ERR_INVALID_XML";
  36 | 	},
  37 | };
  38 | 
  39 | saml.setSchemaValidator(fastValidator);
  40 | 
  41 | /**
  42 |  * Safely parses a value that might be a JSON string or already a parsed object
  43 |  * This handles cases where ORMs like Drizzle might return already parsed objects
  44 |  * instead of JSON strings from TEXT/JSON columns
  45 |  */
  46 | function safeJsonParse<T>(value: string | T | null | undefined): T | null {
  47 | 	if (!value) return null;
  48 | 
  49 | 	// If it's already an object (not a string), return it as-is
  50 | 	if (typeof value === "object") {
  51 | 		return value as T;
  52 | 	}
  53 | 
  54 | 	// If it's a string, try to parse it
  55 | 	if (typeof value === "string") {
  56 | 		try {
  57 | 			return JSON.parse(value) as T;
  58 | 		} catch (error) {
  59 | 			// If parsing fails, this might indicate the string is not valid JSON
  60 | 			throw new Error(
  61 | 				`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
  62 | 			);
  63 | 		}
  64 | 	}
  65 | 
  66 | 	return null;
  67 | }
  68 | 
  69 | export interface OIDCMapping {
  70 | 	id?: string;
  71 | 	email?: string;
  72 | 	emailVerified?: string;
  73 | 	name?: string;
  74 | 	image?: string;
  75 | 	extraFields?: Record<string, string>;
  76 | }
  77 | 
  78 | export interface SAMLMapping {
  79 | 	id?: string;
  80 | 	email?: string;
  81 | 	emailVerified?: string;
  82 | 	name?: string;
  83 | 	firstName?: string;
  84 | 	lastName?: string;
  85 | 	extraFields?: Record<string, string>;
  86 | }
  87 | 
  88 | export interface OIDCConfig {
  89 | 	issuer: string;
  90 | 	pkce: boolean;
  91 | 	clientId: string;
  92 | 	clientSecret: string;
  93 | 	authorizationEndpoint?: string;
  94 | 	discoveryEndpoint: string;
  95 | 	userInfoEndpoint?: string;
  96 | 	scopes?: string[];
  97 | 	overrideUserInfo?: boolean;
  98 | 	tokenEndpoint?: string;
  99 | 	tokenEndpointAuthentication?: "client_secret_post" | "client_secret_basic";
 100 | 	jwksEndpoint?: string;
 101 | 	mapping?: OIDCMapping;
 102 | }
 103 | 
 104 | export interface SAMLConfig {
 105 | 	issuer: string;
 106 | 	entryPoint: string;
 107 | 	cert: string;
 108 | 	callbackUrl: string;
 109 | 	audience?: string;
 110 | 	idpMetadata?: {
 111 | 		metadata?: string;
 112 | 		entityID?: string;
 113 | 		entityURL?: string;
 114 | 		redirectURL?: string;
 115 | 		cert?: string;
 116 | 		privateKey?: string;
 117 | 		privateKeyPass?: string;
 118 | 		isAssertionEncrypted?: boolean;
 119 | 		encPrivateKey?: string;
 120 | 		encPrivateKeyPass?: string;
 121 | 		singleSignOnService?: Array<{
 122 | 			Binding: string;
 123 | 			Location: string;
 124 | 		}>;
 125 | 	};
 126 | 	spMetadata: {
 127 | 		metadata?: string;
 128 | 		entityID?: string;
 129 | 		binding?: string;
 130 | 		privateKey?: string;
 131 | 		privateKeyPass?: string;
 132 | 		isAssertionEncrypted?: boolean;
 133 | 		encPrivateKey?: string;
 134 | 		encPrivateKeyPass?: string;
 135 | 	};
 136 | 	wantAssertionsSigned?: boolean;
 137 | 	signatureAlgorithm?: string;
 138 | 	digestAlgorithm?: string;
 139 | 	identifierFormat?: string;
 140 | 	privateKey?: string;
 141 | 	decryptionPvk?: string;
 142 | 	additionalParams?: Record<string, any>;
 143 | 	mapping?: SAMLMapping;
 144 | }
 145 | 
 146 | export interface SSOProvider {
 147 | 	issuer: string;
 148 | 	oidcConfig?: OIDCConfig;
 149 | 	samlConfig?: SAMLConfig;
 150 | 	userId: string;
 151 | 	providerId: string;
 152 | 	organizationId?: string;
 153 | }
 154 | 
 155 | export interface SSOOptions {
 156 | 	/**
 157 | 	 * custom function to provision a user when they sign in with an SSO provider.
 158 | 	 */
 159 | 	provisionUser?: (data: {
 160 | 		/**
 161 | 		 * The user object from the database
 162 | 		 */
 163 | 		user: User & Record<string, any>;
 164 | 		/**
 165 | 		 * The user info object from the provider
 166 | 		 */
 167 | 		userInfo: Record<string, any>;
 168 | 		/**
 169 | 		 * The OAuth2 tokens from the provider
 170 | 		 */
 171 | 		token?: OAuth2Tokens;
 172 | 		/**
 173 | 		 * The SSO provider
 174 | 		 */
 175 | 		provider: SSOProvider;
 176 | 	}) => Promise<void>;
 177 | 	/**
 178 | 	 * Organization provisioning options
 179 | 	 */
 180 | 	organizationProvisioning?: {
 181 | 		disabled?: boolean;
 182 | 		defaultRole?: "member" | "admin";
 183 | 		getRole?: (data: {
 184 | 			/**
 185 | 			 * The user object from the database
 186 | 			 */
 187 | 			user: User & Record<string, any>;
 188 | 			/**
 189 | 			 * The user info object from the provider
 190 | 			 */
 191 | 			userInfo: Record<string, any>;
 192 | 			/**
 193 | 			 * The OAuth2 tokens from the provider
 194 | 			 */
 195 | 			token?: OAuth2Tokens;
 196 | 			/**
 197 | 			 * The SSO provider
 198 | 			 */
 199 | 			provider: SSOProvider;
 200 | 		}) => Promise<"member" | "admin">;
 201 | 	};
 202 | 	/**
 203 | 	 * Default SSO provider configurations for testing.
 204 | 	 * These will take the precedence over the database providers.
 205 | 	 */
 206 | 	defaultSSO?: Array<{
 207 | 		/**
 208 | 		 * The domain to match for this default provider.
 209 | 		 * This is only used to match incoming requests to this default provider.
 210 | 		 */
 211 | 		domain: string;
 212 | 		/**
 213 | 		 * The provider ID to use
 214 | 		 */
 215 | 		providerId: string;
 216 | 		/**
 217 | 		 * SAML configuration
 218 | 		 */
 219 | 		samlConfig?: SAMLConfig;
 220 | 		/**
 221 | 		 * OIDC configuration
 222 | 		 */
 223 | 		oidcConfig?: OIDCConfig;
 224 | 	}>;
 225 | 	/**
 226 | 	 * Override user info with the provider info.
 227 | 	 * @default false
 228 | 	 */
 229 | 	defaultOverrideUserInfo?: boolean;
 230 | 	/**
 231 | 	 * Disable implicit sign up for new users. When set to true for the provider,
 232 | 	 * sign-in need to be called with with requestSignUp as true to create new users.
 233 | 	 */
 234 | 	disableImplicitSignUp?: boolean;
 235 | 	/**
 236 | 	 * Configure the maximum number of SSO providers a user can register.
 237 | 	 * You can also pass a function that returns a number.
 238 | 	 * Set to 0 to disable SSO provider registration.
 239 | 	 *
 240 | 	 * @example
 241 | 	 * ```ts
 242 | 	 * providersLimit: async (user) => {
 243 | 	 *   const plan = await getUserPlan(user);
 244 | 	 *   return plan.name === "pro" ? 10 : 1;
 245 | 	 * }
 246 | 	 * ```
 247 | 	 * @default 10
 248 | 	 */
 249 | 	providersLimit?: number | ((user: User) => Promise<number> | number);
 250 | 	/**
 251 | 	 * Trust the email verified flag from the provider.
 252 | 	 * @default false
 253 | 	 */
 254 | 	trustEmailVerified?: boolean;
 255 | }
 256 | 
 257 | export const sso = (options?: SSOOptions) => {
 258 | 	return {
 259 | 		id: "sso",
 260 | 		endpoints: {
 261 | 			spMetadata: createAuthEndpoint(
 262 | 				"/sso/saml2/sp/metadata",
 263 | 				{
 264 | 					method: "GET",
 265 | 					query: z.object({
 266 | 						providerId: z.string(),
 267 | 						format: z.enum(["xml", "json"]).default("xml"),
 268 | 					}),
 269 | 					metadata: {
 270 | 						openapi: {
 271 | 							summary: "Get Service Provider metadata",
 272 | 							description: "Returns the SAML metadata for the Service Provider",
 273 | 							responses: {
 274 | 								"200": {
 275 | 									description: "SAML metadata in XML format",
 276 | 								},
 277 | 							},
 278 | 						},
 279 | 					},
 280 | 				},
 281 | 				async (ctx) => {
 282 | 					const provider = await ctx.context.adapter.findOne<{
 283 | 						id: string;
 284 | 						samlConfig: string;
 285 | 					}>({
 286 | 						model: "ssoProvider",
 287 | 						where: [
 288 | 							{
 289 | 								field: "providerId",
 290 | 								value: ctx.query.providerId,
 291 | 							},
 292 | 						],
 293 | 					});
 294 | 					if (!provider) {
 295 | 						throw new APIError("NOT_FOUND", {
 296 | 							message: "No provider found for the given providerId",
 297 | 						});
 298 | 					}
 299 | 
 300 | 					const parsedSamlConfig = safeJsonParse<SAMLConfig>(
 301 | 						provider.samlConfig,
 302 | 					);
 303 | 					if (!parsedSamlConfig) {
 304 | 						throw new APIError("BAD_REQUEST", {
 305 | 							message: "Invalid SAML configuration",
 306 | 						});
 307 | 					}
 308 | 					const sp = parsedSamlConfig.spMetadata.metadata
 309 | 						? saml.ServiceProvider({
 310 | 								metadata: parsedSamlConfig.spMetadata.metadata,
 311 | 							})
 312 | 						: saml.SPMetadata({
 313 | 								entityID:
 314 | 									parsedSamlConfig.spMetadata?.entityID ||
 315 | 									parsedSamlConfig.issuer,
 316 | 								assertionConsumerService: [
 317 | 									{
 318 | 										Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
 319 | 										Location:
 320 | 											parsedSamlConfig.callbackUrl ||
 321 | 											`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
 322 | 									},
 323 | 								],
 324 | 								wantMessageSigned:
 325 | 									parsedSamlConfig.wantAssertionsSigned || false,
 326 | 								nameIDFormat: parsedSamlConfig.identifierFormat
 327 | 									? [parsedSamlConfig.identifierFormat]
 328 | 									: undefined,
 329 | 							});
 330 | 					return new Response(sp.getMetadata(), {
 331 | 						headers: {
 332 | 							"Content-Type": "application/xml",
 333 | 						},
 334 | 					});
 335 | 				},
 336 | 			),
 337 | 			registerSSOProvider: createAuthEndpoint(
 338 | 				"/sso/register",
 339 | 				{
 340 | 					method: "POST",
 341 | 					body: z.object({
 342 | 						providerId: z.string({}).meta({
 343 | 							description:
 344 | 								"The ID of the provider. This is used to identify the provider during login and callback",
 345 | 						}),
 346 | 						issuer: z.string({}).meta({
 347 | 							description: "The issuer of the provider",
 348 | 						}),
 349 | 						domain: z.string({}).meta({
 350 | 							description:
 351 | 								"The domain of the provider. This is used for email matching",
 352 | 						}),
 353 | 						oidcConfig: z
 354 | 							.object({
 355 | 								clientId: z.string({}).meta({
 356 | 									description: "The client ID",
 357 | 								}),
 358 | 								clientSecret: z.string({}).meta({
 359 | 									description: "The client secret",
 360 | 								}),
 361 | 								authorizationEndpoint: z
 362 | 									.string({})
 363 | 									.meta({
 364 | 										description: "The authorization endpoint",
 365 | 									})
 366 | 									.optional(),
 367 | 								tokenEndpoint: z
 368 | 									.string({})
 369 | 									.meta({
 370 | 										description: "The token endpoint",
 371 | 									})
 372 | 									.optional(),
 373 | 								userInfoEndpoint: z
 374 | 									.string({})
 375 | 									.meta({
 376 | 										description: "The user info endpoint",
 377 | 									})
 378 | 									.optional(),
 379 | 								tokenEndpointAuthentication: z
 380 | 									.enum(["client_secret_post", "client_secret_basic"])
 381 | 									.optional(),
 382 | 								jwksEndpoint: z
 383 | 									.string({})
 384 | 									.meta({
 385 | 										description: "The JWKS endpoint",
 386 | 									})
 387 | 									.optional(),
 388 | 								discoveryEndpoint: z.string().optional(),
 389 | 								scopes: z
 390 | 									.array(z.string(), {})
 391 | 									.meta({
 392 | 										description:
 393 | 											"The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
 394 | 									})
 395 | 									.optional(),
 396 | 								pkce: z
 397 | 									.boolean({})
 398 | 									.meta({
 399 | 										description:
 400 | 											"Whether to use PKCE for the authorization flow",
 401 | 									})
 402 | 									.default(true)
 403 | 									.optional(),
 404 | 								mapping: z
 405 | 									.object({
 406 | 										id: z.string({}).meta({
 407 | 											description:
 408 | 												"Field mapping for user ID (defaults to 'sub')",
 409 | 										}),
 410 | 										email: z.string({}).meta({
 411 | 											description:
 412 | 												"Field mapping for email (defaults to 'email')",
 413 | 										}),
 414 | 										emailVerified: z
 415 | 											.string({})
 416 | 											.meta({
 417 | 												description:
 418 | 													"Field mapping for email verification (defaults to 'email_verified')",
 419 | 											})
 420 | 											.optional(),
 421 | 										name: z.string({}).meta({
 422 | 											description:
 423 | 												"Field mapping for name (defaults to 'name')",
 424 | 										}),
 425 | 										image: z
 426 | 											.string({})
 427 | 											.meta({
 428 | 												description:
 429 | 													"Field mapping for image (defaults to 'picture')",
 430 | 											})
 431 | 											.optional(),
 432 | 										extraFields: z.record(z.string(), z.any()).optional(),
 433 | 									})
 434 | 									.optional(),
 435 | 							})
 436 | 							.optional(),
 437 | 						samlConfig: z
 438 | 							.object({
 439 | 								entryPoint: z.string({}).meta({
 440 | 									description: "The entry point of the provider",
 441 | 								}),
 442 | 								cert: z.string({}).meta({
 443 | 									description: "The certificate of the provider",
 444 | 								}),
 445 | 								callbackUrl: z.string({}).meta({
 446 | 									description: "The callback URL of the provider",
 447 | 								}),
 448 | 								audience: z.string().optional(),
 449 | 								idpMetadata: z
 450 | 									.object({
 451 | 										metadata: z.string().optional(),
 452 | 										entityID: z.string().optional(),
 453 | 										cert: z.string().optional(),
 454 | 										privateKey: z.string().optional(),
 455 | 										privateKeyPass: z.string().optional(),
 456 | 										isAssertionEncrypted: z.boolean().optional(),
 457 | 										encPrivateKey: z.string().optional(),
 458 | 										encPrivateKeyPass: z.string().optional(),
 459 | 										singleSignOnService: z
 460 | 											.array(
 461 | 												z.object({
 462 | 													Binding: z.string().meta({
 463 | 														description: "The binding type for the SSO service",
 464 | 													}),
 465 | 													Location: z.string().meta({
 466 | 														description: "The URL for the SSO service",
 467 | 													}),
 468 | 												}),
 469 | 											)
 470 | 											.optional()
 471 | 											.meta({
 472 | 												description: "Single Sign-On service configuration",
 473 | 											}),
 474 | 									})
 475 | 									.optional(),
 476 | 								spMetadata: z.object({
 477 | 									metadata: z.string().optional(),
 478 | 									entityID: z.string().optional(),
 479 | 									binding: z.string().optional(),
 480 | 									privateKey: z.string().optional(),
 481 | 									privateKeyPass: z.string().optional(),
 482 | 									isAssertionEncrypted: z.boolean().optional(),
 483 | 									encPrivateKey: z.string().optional(),
 484 | 									encPrivateKeyPass: z.string().optional(),
 485 | 								}),
 486 | 								wantAssertionsSigned: z.boolean().optional(),
 487 | 								signatureAlgorithm: z.string().optional(),
 488 | 								digestAlgorithm: z.string().optional(),
 489 | 								identifierFormat: z.string().optional(),
 490 | 								privateKey: z.string().optional(),
 491 | 								decryptionPvk: z.string().optional(),
 492 | 								additionalParams: z.record(z.string(), z.any()).optional(),
 493 | 								mapping: z
 494 | 									.object({
 495 | 										id: z.string({}).meta({
 496 | 											description:
 497 | 												"Field mapping for user ID (defaults to 'nameID')",
 498 | 										}),
 499 | 										email: z.string({}).meta({
 500 | 											description:
 501 | 												"Field mapping for email (defaults to 'email')",
 502 | 										}),
 503 | 										emailVerified: z
 504 | 											.string({})
 505 | 											.meta({
 506 | 												description: "Field mapping for email verification",
 507 | 											})
 508 | 											.optional(),
 509 | 										name: z.string({}).meta({
 510 | 											description:
 511 | 												"Field mapping for name (defaults to 'displayName')",
 512 | 										}),
 513 | 										firstName: z
 514 | 											.string({})
 515 | 											.meta({
 516 | 												description:
 517 | 													"Field mapping for first name (defaults to 'givenName')",
 518 | 											})
 519 | 											.optional(),
 520 | 										lastName: z
 521 | 											.string({})
 522 | 											.meta({
 523 | 												description:
 524 | 													"Field mapping for last name (defaults to 'surname')",
 525 | 											})
 526 | 											.optional(),
 527 | 										extraFields: z.record(z.string(), z.any()).optional(),
 528 | 									})
 529 | 									.optional(),
 530 | 							})
 531 | 							.optional(),
 532 | 						organizationId: z
 533 | 							.string({})
 534 | 							.meta({
 535 | 								description:
 536 | 									"If organization plugin is enabled, the organization id to link the provider to",
 537 | 							})
 538 | 							.optional(),
 539 | 						overrideUserInfo: z
 540 | 							.boolean({})
 541 | 							.meta({
 542 | 								description:
 543 | 									"Override user info with the provider info. Defaults to false",
 544 | 							})
 545 | 							.default(false)
 546 | 							.optional(),
 547 | 					}),
 548 | 					use: [sessionMiddleware],
 549 | 					metadata: {
 550 | 						openapi: {
 551 | 							summary: "Register an OIDC provider",
 552 | 							description:
 553 | 								"This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
 554 | 							responses: {
 555 | 								"200": {
 556 | 									description: "OIDC provider created successfully",
 557 | 									content: {
 558 | 										"application/json": {
 559 | 											schema: {
 560 | 												type: "object",
 561 | 												properties: {
 562 | 													issuer: {
 563 | 														type: "string",
 564 | 														format: "uri",
 565 | 														description: "The issuer URL of the provider",
 566 | 													},
 567 | 													domain: {
 568 | 														type: "string",
 569 | 														description:
 570 | 															"The domain of the provider, used for email matching",
 571 | 													},
 572 | 													oidcConfig: {
 573 | 														type: "object",
 574 | 														properties: {
 575 | 															issuer: {
 576 | 																type: "string",
 577 | 																format: "uri",
 578 | 																description: "The issuer URL of the provider",
 579 | 															},
 580 | 															pkce: {
 581 | 																type: "boolean",
 582 | 																description:
 583 | 																	"Whether PKCE is enabled for the authorization flow",
 584 | 															},
 585 | 															clientId: {
 586 | 																type: "string",
 587 | 																description: "The client ID for the provider",
 588 | 															},
 589 | 															clientSecret: {
 590 | 																type: "string",
 591 | 																description:
 592 | 																	"The client secret for the provider",
 593 | 															},
 594 | 															authorizationEndpoint: {
 595 | 																type: "string",
 596 | 																format: "uri",
 597 | 																nullable: true,
 598 | 																description: "The authorization endpoint URL",
 599 | 															},
 600 | 															discoveryEndpoint: {
 601 | 																type: "string",
 602 | 																format: "uri",
 603 | 																description: "The discovery endpoint URL",
 604 | 															},
 605 | 															userInfoEndpoint: {
 606 | 																type: "string",
 607 | 																format: "uri",
 608 | 																nullable: true,
 609 | 																description: "The user info endpoint URL",
 610 | 															},
 611 | 															scopes: {
 612 | 																type: "array",
 613 | 																items: { type: "string" },
 614 | 																nullable: true,
 615 | 																description:
 616 | 																	"The scopes requested from the provider",
 617 | 															},
 618 | 															tokenEndpoint: {
 619 | 																type: "string",
 620 | 																format: "uri",
 621 | 																nullable: true,
 622 | 																description: "The token endpoint URL",
 623 | 															},
 624 | 															tokenEndpointAuthentication: {
 625 | 																type: "string",
 626 | 																enum: [
 627 | 																	"client_secret_post",
 628 | 																	"client_secret_basic",
 629 | 																],
 630 | 																nullable: true,
 631 | 																description:
 632 | 																	"Authentication method for the token endpoint",
 633 | 															},
 634 | 															jwksEndpoint: {
 635 | 																type: "string",
 636 | 																format: "uri",
 637 | 																nullable: true,
 638 | 																description: "The JWKS endpoint URL",
 639 | 															},
 640 | 															mapping: {
 641 | 																type: "object",
 642 | 																nullable: true,
 643 | 																properties: {
 644 | 																	id: {
 645 | 																		type: "string",
 646 | 																		description:
 647 | 																			"Field mapping for user ID (defaults to 'sub')",
 648 | 																	},
 649 | 																	email: {
 650 | 																		type: "string",
 651 | 																		description:
 652 | 																			"Field mapping for email (defaults to 'email')",
 653 | 																	},
 654 | 																	emailVerified: {
 655 | 																		type: "string",
 656 | 																		nullable: true,
 657 | 																		description:
 658 | 																			"Field mapping for email verification (defaults to 'email_verified')",
 659 | 																	},
 660 | 																	name: {
 661 | 																		type: "string",
 662 | 																		description:
 663 | 																			"Field mapping for name (defaults to 'name')",
 664 | 																	},
 665 | 																	image: {
 666 | 																		type: "string",
 667 | 																		nullable: true,
 668 | 																		description:
 669 | 																			"Field mapping for image (defaults to 'picture')",
 670 | 																	},
 671 | 																	extraFields: {
 672 | 																		type: "object",
 673 | 																		additionalProperties: { type: "string" },
 674 | 																		nullable: true,
 675 | 																		description: "Additional field mappings",
 676 | 																	},
 677 | 																},
 678 | 																required: ["id", "email", "name"],
 679 | 															},
 680 | 														},
 681 | 														required: [
 682 | 															"issuer",
 683 | 															"pkce",
 684 | 															"clientId",
 685 | 															"clientSecret",
 686 | 															"discoveryEndpoint",
 687 | 														],
 688 | 														description: "OIDC configuration for the provider",
 689 | 													},
 690 | 													organizationId: {
 691 | 														type: "string",
 692 | 														nullable: true,
 693 | 														description:
 694 | 															"ID of the linked organization, if any",
 695 | 													},
 696 | 													userId: {
 697 | 														type: "string",
 698 | 														description:
 699 | 															"ID of the user who registered the provider",
 700 | 													},
 701 | 													providerId: {
 702 | 														type: "string",
 703 | 														description: "Unique identifier for the provider",
 704 | 													},
 705 | 													redirectURI: {
 706 | 														type: "string",
 707 | 														format: "uri",
 708 | 														description:
 709 | 															"The redirect URI for the provider callback",
 710 | 													},
 711 | 												},
 712 | 												required: [
 713 | 													"issuer",
 714 | 													"domain",
 715 | 													"oidcConfig",
 716 | 													"userId",
 717 | 													"providerId",
 718 | 													"redirectURI",
 719 | 												],
 720 | 											},
 721 | 										},
 722 | 									},
 723 | 								},
 724 | 							},
 725 | 						},
 726 | 					},
 727 | 				},
 728 | 				async (ctx) => {
 729 | 					const user = ctx.context.session?.user;
 730 | 					if (!user) {
 731 | 						throw new APIError("UNAUTHORIZED");
 732 | 					}
 733 | 
 734 | 					const limit =
 735 | 						typeof options?.providersLimit === "function"
 736 | 							? await options.providersLimit(user)
 737 | 							: (options?.providersLimit ?? 10);
 738 | 
 739 | 					if (!limit) {
 740 | 						throw new APIError("FORBIDDEN", {
 741 | 							message: "SSO provider registration is disabled",
 742 | 						});
 743 | 					}
 744 | 
 745 | 					const providers = await ctx.context.adapter.findMany({
 746 | 						model: "ssoProvider",
 747 | 						where: [{ field: "userId", value: user.id }],
 748 | 					});
 749 | 
 750 | 					if (providers.length >= limit) {
 751 | 						throw new APIError("FORBIDDEN", {
 752 | 							message: "You have reached the maximum number of SSO providers",
 753 | 						});
 754 | 					}
 755 | 
 756 | 					const body = ctx.body;
 757 | 					const issuerValidator = z.string().url();
 758 | 					if (issuerValidator.safeParse(body.issuer).error) {
 759 | 						throw new APIError("BAD_REQUEST", {
 760 | 							message: "Invalid issuer. Must be a valid URL",
 761 | 						});
 762 | 					}
 763 | 					if (ctx.body.organizationId) {
 764 | 						const organization = await ctx.context.adapter.findOne({
 765 | 							model: "member",
 766 | 							where: [
 767 | 								{
 768 | 									field: "userId",
 769 | 									value: user.id,
 770 | 								},
 771 | 								{
 772 | 									field: "organizationId",
 773 | 									value: ctx.body.organizationId,
 774 | 								},
 775 | 							],
 776 | 						});
 777 | 						if (!organization) {
 778 | 							throw new APIError("BAD_REQUEST", {
 779 | 								message: "You are not a member of the organization",
 780 | 							});
 781 | 						}
 782 | 					}
 783 | 
 784 | 					const existingProvider = await ctx.context.adapter.findOne({
 785 | 						model: "ssoProvider",
 786 | 						where: [
 787 | 							{
 788 | 								field: "providerId",
 789 | 								value: body.providerId,
 790 | 							},
 791 | 						],
 792 | 					});
 793 | 
 794 | 					if (existingProvider) {
 795 | 						ctx.context.logger.info(
 796 | 							`SSO provider creation attempt with existing providerId: ${body.providerId}`,
 797 | 						);
 798 | 						throw new APIError("UNPROCESSABLE_ENTITY", {
 799 | 							message: "SSO provider with this providerId already exists",
 800 | 						});
 801 | 					}
 802 | 
 803 | 					const provider = await ctx.context.adapter.create<
 804 | 						Record<string, any>,
 805 | 						SSOProvider
 806 | 					>({
 807 | 						model: "ssoProvider",
 808 | 						data: {
 809 | 							issuer: body.issuer,
 810 | 							domain: body.domain,
 811 | 							oidcConfig: body.oidcConfig
 812 | 								? JSON.stringify({
 813 | 										issuer: body.issuer,
 814 | 										clientId: body.oidcConfig.clientId,
 815 | 										clientSecret: body.oidcConfig.clientSecret,
 816 | 										authorizationEndpoint:
 817 | 											body.oidcConfig.authorizationEndpoint,
 818 | 										tokenEndpoint: body.oidcConfig.tokenEndpoint,
 819 | 										tokenEndpointAuthentication:
 820 | 											body.oidcConfig.tokenEndpointAuthentication,
 821 | 										jwksEndpoint: body.oidcConfig.jwksEndpoint,
 822 | 										pkce: body.oidcConfig.pkce,
 823 | 										discoveryEndpoint:
 824 | 											body.oidcConfig.discoveryEndpoint ||
 825 | 											`${body.issuer}/.well-known/openid-configuration`,
 826 | 										mapping: body.oidcConfig.mapping,
 827 | 										scopes: body.oidcConfig.scopes,
 828 | 										userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
 829 | 										overrideUserInfo:
 830 | 											ctx.body.overrideUserInfo ||
 831 | 											options?.defaultOverrideUserInfo ||
 832 | 											false,
 833 | 									})
 834 | 								: null,
 835 | 							samlConfig: body.samlConfig
 836 | 								? JSON.stringify({
 837 | 										issuer: body.issuer,
 838 | 										entryPoint: body.samlConfig.entryPoint,
 839 | 										cert: body.samlConfig.cert,
 840 | 										callbackUrl: body.samlConfig.callbackUrl,
 841 | 										audience: body.samlConfig.audience,
 842 | 										idpMetadata: body.samlConfig.idpMetadata,
 843 | 										spMetadata: body.samlConfig.spMetadata,
 844 | 										wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
 845 | 										signatureAlgorithm: body.samlConfig.signatureAlgorithm,
 846 | 										digestAlgorithm: body.samlConfig.digestAlgorithm,
 847 | 										identifierFormat: body.samlConfig.identifierFormat,
 848 | 										privateKey: body.samlConfig.privateKey,
 849 | 										decryptionPvk: body.samlConfig.decryptionPvk,
 850 | 										additionalParams: body.samlConfig.additionalParams,
 851 | 										mapping: body.samlConfig.mapping,
 852 | 									})
 853 | 								: null,
 854 | 							organizationId: body.organizationId,
 855 | 							userId: ctx.context.session.user.id,
 856 | 							providerId: body.providerId,
 857 | 						},
 858 | 					});
 859 | 
 860 | 					return ctx.json({
 861 | 						...provider,
 862 | 						oidcConfig: JSON.parse(
 863 | 							provider.oidcConfig as unknown as string,
 864 | 						) as OIDCConfig,
 865 | 						samlConfig: JSON.parse(
 866 | 							provider.samlConfig as unknown as string,
 867 | 						) as SAMLConfig,
 868 | 						redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
 869 | 					});
 870 | 				},
 871 | 			),
 872 | 			signInSSO: createAuthEndpoint(
 873 | 				"/sign-in/sso",
 874 | 				{
 875 | 					method: "POST",
 876 | 					body: z.object({
 877 | 						email: z
 878 | 							.string({})
 879 | 							.meta({
 880 | 								description:
 881 | 									"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",
 882 | 							})
 883 | 							.optional(),
 884 | 						organizationSlug: z
 885 | 							.string({})
 886 | 							.meta({
 887 | 								description: "The slug of the organization to sign in with",
 888 | 							})
 889 | 							.optional(),
 890 | 						providerId: z
 891 | 							.string({})
 892 | 							.meta({
 893 | 								description:
 894 | 									"The ID of the provider to sign in with. This can be provided instead of email or issuer",
 895 | 							})
 896 | 							.optional(),
 897 | 						domain: z
 898 | 							.string({})
 899 | 							.meta({
 900 | 								description: "The domain of the provider.",
 901 | 							})
 902 | 							.optional(),
 903 | 						callbackURL: z.string({}).meta({
 904 | 							description: "The URL to redirect to after login",
 905 | 						}),
 906 | 						errorCallbackURL: z
 907 | 							.string({})
 908 | 							.meta({
 909 | 								description: "The URL to redirect to after login",
 910 | 							})
 911 | 							.optional(),
 912 | 						newUserCallbackURL: z
 913 | 							.string({})
 914 | 							.meta({
 915 | 								description:
 916 | 									"The URL to redirect to after login if the user is new",
 917 | 							})
 918 | 							.optional(),
 919 | 						scopes: z
 920 | 							.array(z.string(), {})
 921 | 							.meta({
 922 | 								description: "Scopes to request from the provider.",
 923 | 							})
 924 | 							.optional(),
 925 | 						loginHint: z
 926 | 							.string({})
 927 | 							.meta({
 928 | 								description:
 929 | 									"Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.",
 930 | 							})
 931 | 							.optional(),
 932 | 						requestSignUp: z
 933 | 							.boolean({})
 934 | 							.meta({
 935 | 								description:
 936 | 									"Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
 937 | 							})
 938 | 							.optional(),
 939 | 						providerType: z.enum(["oidc", "saml"]).optional(),
 940 | 					}),
 941 | 					metadata: {
 942 | 						openapi: {
 943 | 							summary: "Sign in with SSO provider",
 944 | 							description:
 945 | 								"This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
 946 | 							requestBody: {
 947 | 								content: {
 948 | 									"application/json": {
 949 | 										schema: {
 950 | 											type: "object",
 951 | 											properties: {
 952 | 												email: {
 953 | 													type: "string",
 954 | 													description:
 955 | 														"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",
 956 | 												},
 957 | 												issuer: {
 958 | 													type: "string",
 959 | 													description:
 960 | 														"The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided",
 961 | 												},
 962 | 												providerId: {
 963 | 													type: "string",
 964 | 													description:
 965 | 														"The ID of the provider to sign in with. This can be provided instead of email or issuer",
 966 | 												},
 967 | 												callbackURL: {
 968 | 													type: "string",
 969 | 													description: "The URL to redirect to after login",
 970 | 												},
 971 | 												errorCallbackURL: {
 972 | 													type: "string",
 973 | 													description: "The URL to redirect to after login",
 974 | 												},
 975 | 												newUserCallbackURL: {
 976 | 													type: "string",
 977 | 													description:
 978 | 														"The URL to redirect to after login if the user is new",
 979 | 												},
 980 | 												loginHint: {
 981 | 													type: "string",
 982 | 													description:
 983 | 														"Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.",
 984 | 												},
 985 | 											},
 986 | 											required: ["callbackURL"],
 987 | 										},
 988 | 									},
 989 | 								},
 990 | 							},
 991 | 							responses: {
 992 | 								"200": {
 993 | 									description:
 994 | 										"Authorization URL generated successfully for SSO sign-in",
 995 | 									content: {
 996 | 										"application/json": {
 997 | 											schema: {
 998 | 												type: "object",
 999 | 												properties: {
1000 | 													url: {
1001 | 														type: "string",
1002 | 														format: "uri",
1003 | 														description:
1004 | 															"The authorization URL to redirect the user to for SSO sign-in",
1005 | 													},
1006 | 													redirect: {
1007 | 														type: "boolean",
1008 | 														description:
1009 | 															"Indicates that the client should redirect to the provided URL",
1010 | 														enum: [true],
1011 | 													},
1012 | 												},
1013 | 												required: ["url", "redirect"],
1014 | 											},
1015 | 										},
1016 | 									},
1017 | 								},
1018 | 							},
1019 | 						},
1020 | 					},
1021 | 				},
1022 | 				async (ctx) => {
1023 | 					const body = ctx.body;
1024 | 					let { email, organizationSlug, providerId, domain } = body;
1025 | 					if (
1026 | 						!options?.defaultSSO?.length &&
1027 | 						!email &&
1028 | 						!organizationSlug &&
1029 | 						!domain &&
1030 | 						!providerId
1031 | 					) {
1032 | 						throw new APIError("BAD_REQUEST", {
1033 | 							message:
1034 | 								"email, organizationSlug, domain or providerId is required",
1035 | 						});
1036 | 					}
1037 | 					domain = body.domain || email?.split("@")[1];
1038 | 					let orgId = "";
1039 | 					if (organizationSlug) {
1040 | 						orgId = await ctx.context.adapter
1041 | 							.findOne<{ id: string }>({
1042 | 								model: "organization",
1043 | 								where: [
1044 | 									{
1045 | 										field: "slug",
1046 | 										value: organizationSlug,
1047 | 									},
1048 | 								],
1049 | 							})
1050 | 							.then((res) => {
1051 | 								if (!res) {
1052 | 									return "";
1053 | 								}
1054 | 								return res.id;
1055 | 							});
1056 | 					}
1057 | 					let provider: SSOProvider | null = null;
1058 | 					if (options?.defaultSSO?.length) {
1059 | 						// Find matching default SSO provider by providerId
1060 | 						const matchingDefault = providerId
1061 | 							? options.defaultSSO.find(
1062 | 									(defaultProvider) =>
1063 | 										defaultProvider.providerId === providerId,
1064 | 								)
1065 | 							: options.defaultSSO.find(
1066 | 									(defaultProvider) => defaultProvider.domain === domain,
1067 | 								);
1068 | 
1069 | 						if (matchingDefault) {
1070 | 							provider = {
1071 | 								issuer:
1072 | 									matchingDefault.samlConfig?.issuer ||
1073 | 									matchingDefault.oidcConfig?.issuer ||
1074 | 									"",
1075 | 								providerId: matchingDefault.providerId,
1076 | 								userId: "default",
1077 | 								oidcConfig: matchingDefault.oidcConfig,
1078 | 								samlConfig: matchingDefault.samlConfig,
1079 | 							};
1080 | 						}
1081 | 					}
1082 | 					if (!providerId && !orgId && !domain) {
1083 | 						throw new APIError("BAD_REQUEST", {
1084 | 							message: "providerId, orgId or domain is required",
1085 | 						});
1086 | 					}
1087 | 					// Try to find provider in database
1088 | 					if (!provider) {
1089 | 						provider = await ctx.context.adapter
1090 | 							.findOne<SSOProvider>({
1091 | 								model: "ssoProvider",
1092 | 								where: [
1093 | 									{
1094 | 										field: providerId
1095 | 											? "providerId"
1096 | 											: orgId
1097 | 												? "organizationId"
1098 | 												: "domain",
1099 | 										value: providerId || orgId || domain!,
1100 | 									},
1101 | 								],
1102 | 							})
1103 | 							.then((res) => {
1104 | 								if (!res) {
1105 | 									return null;
1106 | 								}
1107 | 								return {
1108 | 									...res,
1109 | 									oidcConfig: res.oidcConfig
1110 | 										? safeJsonParse<OIDCConfig>(
1111 | 												res.oidcConfig as unknown as string,
1112 | 											) || undefined
1113 | 										: undefined,
1114 | 									samlConfig: res.samlConfig
1115 | 										? safeJsonParse<SAMLConfig>(
1116 | 												res.samlConfig as unknown as string,
1117 | 											) || undefined
1118 | 										: undefined,
1119 | 								};
1120 | 							});
1121 | 					}
1122 | 
1123 | 					if (!provider) {
1124 | 						throw new APIError("NOT_FOUND", {
1125 | 							message: "No provider found for the issuer",
1126 | 						});
1127 | 					}
1128 | 					if (body.providerType) {
1129 | 						if (body.providerType === "oidc" && !provider.oidcConfig) {
1130 | 							throw new APIError("BAD_REQUEST", {
1131 | 								message: "OIDC provider is not configured",
1132 | 							});
1133 | 						}
1134 | 						if (body.providerType === "saml" && !provider.samlConfig) {
1135 | 							throw new APIError("BAD_REQUEST", {
1136 | 								message: "SAML provider is not configured",
1137 | 							});
1138 | 						}
1139 | 					}
1140 | 					if (provider.oidcConfig && body.providerType !== "saml") {
1141 | 						const state = await generateState(ctx);
1142 | 						const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
1143 | 						const authorizationURL = await createAuthorizationURL({
1144 | 							id: provider.issuer,
1145 | 							options: {
1146 | 								clientId: provider.oidcConfig.clientId,
1147 | 								clientSecret: provider.oidcConfig.clientSecret,
1148 | 							},
1149 | 							redirectURI,
1150 | 							state: state.state,
1151 | 							codeVerifier: provider.oidcConfig.pkce
1152 | 								? state.codeVerifier
1153 | 								: undefined,
1154 | 							scopes: ctx.body.scopes ||
1155 | 								provider.oidcConfig.scopes || [
1156 | 									"openid",
1157 | 									"email",
1158 | 									"profile",
1159 | 									"offline_access",
1160 | 								],
1161 | 							loginHint: ctx.body.loginHint || email,
1162 | 							authorizationEndpoint: provider.oidcConfig.authorizationEndpoint!,
1163 | 						});
1164 | 						return ctx.json({
1165 | 							url: authorizationURL.toString(),
1166 | 							redirect: true,
1167 | 						});
1168 | 					}
1169 | 					if (provider.samlConfig) {
1170 | 						const parsedSamlConfig =
1171 | 							typeof provider.samlConfig === "object"
1172 | 								? provider.samlConfig
1173 | 								: safeJsonParse<SAMLConfig>(
1174 | 										provider.samlConfig as unknown as string,
1175 | 									);
1176 | 						if (!parsedSamlConfig) {
1177 | 							throw new APIError("BAD_REQUEST", {
1178 | 								message: "Invalid SAML configuration",
1179 | 							});
1180 | 						}
1181 | 						const sp = saml.ServiceProvider({
1182 | 							metadata: parsedSamlConfig.spMetadata.metadata,
1183 | 							allowCreate: true,
1184 | 						});
1185 | 
1186 | 						const idp = saml.IdentityProvider({
1187 | 							metadata: parsedSamlConfig.idpMetadata?.metadata,
1188 | 							entityID: parsedSamlConfig.idpMetadata?.entityID,
1189 | 							encryptCert: parsedSamlConfig.idpMetadata?.cert,
1190 | 							singleSignOnService:
1191 | 								parsedSamlConfig.idpMetadata?.singleSignOnService,
1192 | 						});
1193 | 						const loginRequest = sp.createLoginRequest(
1194 | 							idp,
1195 | 							"redirect",
1196 | 						) as BindingContext & { entityEndpoint: string; type: string };
1197 | 						if (!loginRequest) {
1198 | 							throw new APIError("BAD_REQUEST", {
1199 | 								message: "Invalid SAML request",
1200 | 							});
1201 | 						}
1202 | 						return ctx.json({
1203 | 							url: `${loginRequest.context}&RelayState=${encodeURIComponent(
1204 | 								body.callbackURL,
1205 | 							)}`,
1206 | 							redirect: true,
1207 | 						});
1208 | 					}
1209 | 					throw new APIError("BAD_REQUEST", {
1210 | 						message: "Invalid SSO provider",
1211 | 					});
1212 | 				},
1213 | 			),
1214 | 			callbackSSO: createAuthEndpoint(
1215 | 				"/sso/callback/:providerId",
1216 | 				{
1217 | 					method: "GET",
1218 | 					query: z.object({
1219 | 						code: z.string().optional(),
1220 | 						state: z.string(),
1221 | 						error: z.string().optional(),
1222 | 						error_description: z.string().optional(),
1223 | 					}),
1224 | 					metadata: {
1225 | 						isAction: false,
1226 | 						openapi: {
1227 | 							summary: "Callback URL for SSO provider",
1228 | 							description:
1229 | 								"This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
1230 | 							responses: {
1231 | 								"302": {
1232 | 									description: "Redirects to the callback URL",
1233 | 								},
1234 | 							},
1235 | 						},
1236 | 					},
1237 | 				},
1238 | 				async (ctx) => {
1239 | 					const { code, state, error, error_description } = ctx.query;
1240 | 					const stateData = await parseState(ctx);
1241 | 					if (!stateData) {
1242 | 						const errorURL =
1243 | 							ctx.context.options.onAPIError?.errorURL ||
1244 | 							`${ctx.context.baseURL}/error`;
1245 | 						throw ctx.redirect(`${errorURL}?error=invalid_state`);
1246 | 					}
1247 | 					const { callbackURL, errorURL, newUserURL, requestSignUp } =
1248 | 						stateData;
1249 | 					if (!code || error) {
1250 | 						throw ctx.redirect(
1251 | 							`${
1252 | 								errorURL || callbackURL
1253 | 							}?error=${error}&error_description=${error_description}`,
1254 | 						);
1255 | 					}
1256 | 					let provider: SSOProvider | null = null;
1257 | 					if (options?.defaultSSO?.length) {
1258 | 						const matchingDefault = options.defaultSSO.find(
1259 | 							(defaultProvider) =>
1260 | 								defaultProvider.providerId === ctx.params.providerId,
1261 | 						);
1262 | 						if (matchingDefault) {
1263 | 							provider = {
1264 | 								...matchingDefault,
1265 | 								issuer: matchingDefault.oidcConfig?.issuer || "",
1266 | 								userId: "default",
1267 | 							};
1268 | 						}
1269 | 					}
1270 | 					if (!provider) {
1271 | 						provider = await ctx.context.adapter
1272 | 							.findOne<{
1273 | 								oidcConfig: string;
1274 | 							}>({
1275 | 								model: "ssoProvider",
1276 | 								where: [
1277 | 									{
1278 | 										field: "providerId",
1279 | 										value: ctx.params.providerId,
1280 | 									},
1281 | 								],
1282 | 							})
1283 | 							.then((res) => {
1284 | 								if (!res) {
1285 | 									return null;
1286 | 								}
1287 | 								return {
1288 | 									...res,
1289 | 									oidcConfig:
1290 | 										safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
1291 | 								} as SSOProvider;
1292 | 							});
1293 | 					}
1294 | 					if (!provider) {
1295 | 						throw ctx.redirect(
1296 | 							`${
1297 | 								errorURL || callbackURL
1298 | 							}/error?error=invalid_provider&error_description=provider not found`,
1299 | 						);
1300 | 					}
1301 | 					let config = provider.oidcConfig;
1302 | 
1303 | 					if (!config) {
1304 | 						throw ctx.redirect(
1305 | 							`${
1306 | 								errorURL || callbackURL
1307 | 							}/error?error=invalid_provider&error_description=provider not found`,
1308 | 						);
1309 | 					}
1310 | 
1311 | 					const discovery = await betterFetch<{
1312 | 						token_endpoint: string;
1313 | 						userinfo_endpoint: string;
1314 | 						token_endpoint_auth_method:
1315 | 							| "client_secret_basic"
1316 | 							| "client_secret_post";
1317 | 					}>(config.discoveryEndpoint);
1318 | 
1319 | 					if (discovery.data) {
1320 | 						config = {
1321 | 							tokenEndpoint: discovery.data.token_endpoint,
1322 | 							tokenEndpointAuthentication:
1323 | 								discovery.data.token_endpoint_auth_method,
1324 | 							userInfoEndpoint: discovery.data.userinfo_endpoint,
1325 | 							scopes: ["openid", "email", "profile", "offline_access"],
1326 | 							...config,
1327 | 						};
1328 | 					}
1329 | 
1330 | 					if (!config.tokenEndpoint) {
1331 | 						throw ctx.redirect(
1332 | 							`${
1333 | 								errorURL || callbackURL
1334 | 							}/error?error=invalid_provider&error_description=token_endpoint_not_found`,
1335 | 						);
1336 | 					}
1337 | 
1338 | 					const tokenResponse = await validateAuthorizationCode({
1339 | 						code,
1340 | 						codeVerifier: config.pkce ? stateData.codeVerifier : undefined,
1341 | 						redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
1342 | 						options: {
1343 | 							clientId: config.clientId,
1344 | 							clientSecret: config.clientSecret,
1345 | 						},
1346 | 						tokenEndpoint: config.tokenEndpoint,
1347 | 						authentication:
1348 | 							config.tokenEndpointAuthentication === "client_secret_post"
1349 | 								? "post"
1350 | 								: "basic",
1351 | 					}).catch((e) => {
1352 | 						if (e instanceof BetterFetchError) {
1353 | 							throw ctx.redirect(
1354 | 								`${
1355 | 									errorURL || callbackURL
1356 | 								}?error=invalid_provider&error_description=${e.message}`,
1357 | 							);
1358 | 						}
1359 | 						return null;
1360 | 					});
1361 | 					if (!tokenResponse) {
1362 | 						throw ctx.redirect(
1363 | 							`${
1364 | 								errorURL || callbackURL
1365 | 							}/error?error=invalid_provider&error_description=token_response_not_found`,
1366 | 						);
1367 | 					}
1368 | 					let userInfo: {
1369 | 						id?: string;
1370 | 						email?: string;
1371 | 						name?: string;
1372 | 						image?: string;
1373 | 						emailVerified?: boolean;
1374 | 						[key: string]: any;
1375 | 					} | null = null;
1376 | 					if (tokenResponse.idToken) {
1377 | 						const idToken = decodeJwt(tokenResponse.idToken);
1378 | 						if (!config.jwksEndpoint) {
1379 | 							throw ctx.redirect(
1380 | 								`${
1381 | 									errorURL || callbackURL
1382 | 								}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1383 | 							);
1384 | 						}
1385 | 						const verified = await validateToken(
1386 | 							tokenResponse.idToken,
1387 | 							config.jwksEndpoint,
1388 | 						).catch((e) => {
1389 | 							ctx.context.logger.error(e);
1390 | 							return null;
1391 | 						});
1392 | 						if (!verified) {
1393 | 							throw ctx.redirect(
1394 | 								`${
1395 | 									errorURL || callbackURL
1396 | 								}/error?error=invalid_provider&error_description=token_not_verified`,
1397 | 							);
1398 | 						}
1399 | 						if (verified.payload.iss !== provider.issuer) {
1400 | 							throw ctx.redirect(
1401 | 								`${
1402 | 									errorURL || callbackURL
1403 | 								}/error?error=invalid_provider&error_description=issuer_mismatch`,
1404 | 							);
1405 | 						}
1406 | 
1407 | 						const mapping = config.mapping || {};
1408 | 						userInfo = {
1409 | 							...Object.fromEntries(
1410 | 								Object.entries(mapping.extraFields || {}).map(
1411 | 									([key, value]) => [key, verified.payload[value]],
1412 | 								),
1413 | 							),
1414 | 							id: idToken[mapping.id || "sub"],
1415 | 							email: idToken[mapping.email || "email"],
1416 | 							emailVerified: options?.trustEmailVerified
1417 | 								? idToken[mapping.emailVerified || "email_verified"]
1418 | 								: false,
1419 | 							name: idToken[mapping.name || "name"],
1420 | 							image: idToken[mapping.image || "picture"],
1421 | 						} as {
1422 | 							id?: string;
1423 | 							email?: string;
1424 | 							name?: string;
1425 | 							image?: string;
1426 | 							emailVerified?: boolean;
1427 | 						};
1428 | 					}
1429 | 
1430 | 					if (!userInfo) {
1431 | 						if (!config.userInfoEndpoint) {
1432 | 							throw ctx.redirect(
1433 | 								`${
1434 | 									errorURL || callbackURL
1435 | 								}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1436 | 							);
1437 | 						}
1438 | 						const userInfoResponse = await betterFetch<{
1439 | 							email?: string;
1440 | 							name?: string;
1441 | 							id?: string;
1442 | 							image?: string;
1443 | 							emailVerified?: boolean;
1444 | 						}>(config.userInfoEndpoint, {
1445 | 							headers: {
1446 | 								Authorization: `Bearer ${tokenResponse.accessToken}`,
1447 | 							},
1448 | 						});
1449 | 						if (userInfoResponse.error) {
1450 | 							throw ctx.redirect(
1451 | 								`${
1452 | 									errorURL || callbackURL
1453 | 								}/error?error=invalid_provider&error_description=${
1454 | 									userInfoResponse.error.message
1455 | 								}`,
1456 | 							);
1457 | 						}
1458 | 						userInfo = userInfoResponse.data;
1459 | 					}
1460 | 
1461 | 					if (!userInfo.email || !userInfo.id) {
1462 | 						throw ctx.redirect(
1463 | 							`${
1464 | 								errorURL || callbackURL
1465 | 							}/error?error=invalid_provider&error_description=missing_user_info`,
1466 | 						);
1467 | 					}
1468 | 					const linked = await handleOAuthUserInfo(ctx, {
1469 | 						userInfo: {
1470 | 							email: userInfo.email,
1471 | 							name: userInfo.name || userInfo.email,
1472 | 							id: userInfo.id,
1473 | 							image: userInfo.image,
1474 | 							emailVerified: options?.trustEmailVerified
1475 | 								? userInfo.emailVerified || false
1476 | 								: false,
1477 | 						},
1478 | 						account: {
1479 | 							idToken: tokenResponse.idToken,
1480 | 							accessToken: tokenResponse.accessToken,
1481 | 							refreshToken: tokenResponse.refreshToken,
1482 | 							accountId: userInfo.id,
1483 | 							providerId: provider.providerId,
1484 | 							accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
1485 | 							refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
1486 | 							scope: tokenResponse.scopes?.join(","),
1487 | 						},
1488 | 						callbackURL,
1489 | 						disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
1490 | 						overrideUserInfo: config.overrideUserInfo,
1491 | 					});
1492 | 					if (linked.error) {
1493 | 						throw ctx.redirect(
1494 | 							`${errorURL || callbackURL}/error?error=${linked.error}`,
1495 | 						);
1496 | 					}
1497 | 					const { session, user } = linked.data!;
1498 | 
1499 | 					if (options?.provisionUser) {
1500 | 						await options.provisionUser({
1501 | 							user,
1502 | 							userInfo,
1503 | 							token: tokenResponse,
1504 | 							provider,
1505 | 						});
1506 | 					}
1507 | 					if (
1508 | 						provider.organizationId &&
1509 | 						!options?.organizationProvisioning?.disabled
1510 | 					) {
1511 | 						const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1512 | 							(plugin) => plugin.id === "organization",
1513 | 						);
1514 | 						if (isOrgPluginEnabled) {
1515 | 							const isAlreadyMember = await ctx.context.adapter.findOne({
1516 | 								model: "member",
1517 | 								where: [
1518 | 									{ field: "organizationId", value: provider.organizationId },
1519 | 									{ field: "userId", value: user.id },
1520 | 								],
1521 | 							});
1522 | 							if (!isAlreadyMember) {
1523 | 								const role = options?.organizationProvisioning?.getRole
1524 | 									? await options.organizationProvisioning.getRole({
1525 | 											user,
1526 | 											userInfo,
1527 | 											token: tokenResponse,
1528 | 											provider,
1529 | 										})
1530 | 									: options?.organizationProvisioning?.defaultRole || "member";
1531 | 								await ctx.context.adapter.create({
1532 | 									model: "member",
1533 | 									data: {
1534 | 										organizationId: provider.organizationId,
1535 | 										userId: user.id,
1536 | 										role,
1537 | 										createdAt: new Date(),
1538 | 										updatedAt: new Date(),
1539 | 									},
1540 | 								});
1541 | 							}
1542 | 						}
1543 | 					}
1544 | 					await setSessionCookie(ctx, {
1545 | 						session,
1546 | 						user,
1547 | 					});
1548 | 					let toRedirectTo: string;
1549 | 					try {
1550 | 						const url = linked.isRegister
1551 | 							? newUserURL || callbackURL
1552 | 							: callbackURL;
1553 | 						toRedirectTo = url.toString();
1554 | 					} catch {
1555 | 						toRedirectTo = linked.isRegister
1556 | 							? newUserURL || callbackURL
1557 | 							: callbackURL;
1558 | 					}
1559 | 					throw ctx.redirect(toRedirectTo);
1560 | 				},
1561 | 			),
1562 | 			callbackSSOSAML: createAuthEndpoint(
1563 | 				"/sso/saml2/callback/:providerId",
1564 | 				{
1565 | 					method: "POST",
1566 | 					body: z.object({
1567 | 						SAMLResponse: z.string(),
1568 | 						RelayState: z.string().optional(),
1569 | 					}),
1570 | 					metadata: {
1571 | 						isAction: false,
1572 | 						openapi: {
1573 | 							summary: "Callback URL for SAML provider",
1574 | 							description:
1575 | 								"This endpoint is used as the callback URL for SAML providers.",
1576 | 							responses: {
1577 | 								"302": {
1578 | 									description: "Redirects to the callback URL",
1579 | 								},
1580 | 								"400": {
1581 | 									description: "Invalid SAML response",
1582 | 								},
1583 | 								"401": {
1584 | 									description: "Unauthorized - SAML authentication failed",
1585 | 								},
1586 | 							},
1587 | 						},
1588 | 					},
1589 | 				},
1590 | 				async (ctx) => {
1591 | 					const { SAMLResponse, RelayState } = ctx.body;
1592 | 					const { providerId } = ctx.params;
1593 | 					let provider: SSOProvider | null = null;
1594 | 					if (options?.defaultSSO?.length) {
1595 | 						const matchingDefault = options.defaultSSO.find(
1596 | 							(defaultProvider) => defaultProvider.providerId === providerId,
1597 | 						);
1598 | 						if (matchingDefault) {
1599 | 							provider = {
1600 | 								...matchingDefault,
1601 | 								userId: "default",
1602 | 								issuer: matchingDefault.samlConfig?.issuer || "",
1603 | 							};
1604 | 						}
1605 | 					}
1606 | 					if (!provider) {
1607 | 						provider = await ctx.context.adapter
1608 | 							.findOne<SSOProvider>({
1609 | 								model: "ssoProvider",
1610 | 								where: [{ field: "providerId", value: providerId }],
1611 | 							})
1612 | 							.then((res) => {
1613 | 								if (!res) return null;
1614 | 								return {
1615 | 									...res,
1616 | 									samlConfig: res.samlConfig
1617 | 										? safeJsonParse<SAMLConfig>(
1618 | 												res.samlConfig as unknown as string,
1619 | 											) || undefined
1620 | 										: undefined,
1621 | 								};
1622 | 							});
1623 | 					}
1624 | 
1625 | 					if (!provider) {
1626 | 						throw new APIError("NOT_FOUND", {
1627 | 							message: "No provider found for the given providerId",
1628 | 						});
1629 | 					}
1630 | 					const parsedSamlConfig = safeJsonParse<SAMLConfig>(
1631 | 						provider.samlConfig as unknown as string,
1632 | 					);
1633 | 					if (!parsedSamlConfig) {
1634 | 						throw new APIError("BAD_REQUEST", {
1635 | 							message: "Invalid SAML configuration",
1636 | 						});
1637 | 					}
1638 | 					const idpData = parsedSamlConfig.idpMetadata;
1639 | 					let idp: IdentityProvider | null = null;
1640 | 
1641 | 					// Construct IDP with fallback to manual configuration
1642 | 					if (!idpData?.metadata) {
1643 | 						idp = saml.IdentityProvider({
1644 | 							entityID: idpData?.entityID || parsedSamlConfig.issuer,
1645 | 							singleSignOnService: [
1646 | 								{
1647 | 									Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1648 | 									Location: parsedSamlConfig.entryPoint,
1649 | 								},
1650 | 							],
1651 | 							signingCert: idpData?.cert || parsedSamlConfig.cert,
1652 | 							wantAuthnRequestsSigned:
1653 | 								parsedSamlConfig.wantAssertionsSigned || false,
1654 | 							isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1655 | 							encPrivateKey: idpData?.encPrivateKey,
1656 | 							encPrivateKeyPass: idpData?.encPrivateKeyPass,
1657 | 						});
1658 | 					} else {
1659 | 						idp = saml.IdentityProvider({
1660 | 							metadata: idpData.metadata,
1661 | 							privateKey: idpData.privateKey,
1662 | 							privateKeyPass: idpData.privateKeyPass,
1663 | 							isAssertionEncrypted: idpData.isAssertionEncrypted,
1664 | 							encPrivateKey: idpData.encPrivateKey,
1665 | 							encPrivateKeyPass: idpData.encPrivateKeyPass,
1666 | 						});
1667 | 					}
1668 | 
1669 | 					// Construct SP with fallback to manual configuration
1670 | 					const spData = parsedSamlConfig.spMetadata;
1671 | 					const sp = saml.ServiceProvider({
1672 | 						metadata: spData?.metadata,
1673 | 						entityID: spData?.entityID || parsedSamlConfig.issuer,
1674 | 						assertionConsumerService: spData?.metadata
1675 | 							? undefined
1676 | 							: [
1677 | 									{
1678 | 										Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1679 | 										Location: parsedSamlConfig.callbackUrl,
1680 | 									},
1681 | 								],
1682 | 						privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
1683 | 						privateKeyPass: spData?.privateKeyPass,
1684 | 						isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1685 | 						encPrivateKey: spData?.encPrivateKey,
1686 | 						encPrivateKeyPass: spData?.encPrivateKeyPass,
1687 | 						wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1688 | 						nameIDFormat: parsedSamlConfig.identifierFormat
1689 | 							? [parsedSamlConfig.identifierFormat]
1690 | 							: undefined,
1691 | 					});
1692 | 
1693 | 					let parsedResponse: FlowResult;
1694 | 					try {
1695 | 						const decodedResponse = Buffer.from(
1696 | 							SAMLResponse,
1697 | 							"base64",
1698 | 						).toString("utf-8");
1699 | 
1700 | 						try {
1701 | 							parsedResponse = await sp.parseLoginResponse(idp, "post", {
1702 | 								body: {
1703 | 									SAMLResponse,
1704 | 									RelayState: RelayState || undefined,
1705 | 								},
1706 | 							});
1707 | 						} catch (parseError) {
1708 | 							const nameIDMatch = decodedResponse.match(
1709 | 								/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
1710 | 							);
1711 | 							if (!nameIDMatch) throw parseError;
1712 | 							parsedResponse = {
1713 | 								extract: {
1714 | 									nameID: nameIDMatch[1],
1715 | 									attributes: { nameID: nameIDMatch[1] },
1716 | 									sessionIndex: {},
1717 | 									conditions: {},
1718 | 								},
1719 | 							} as FlowResult;
1720 | 						}
1721 | 
1722 | 						if (!parsedResponse?.extract) {
1723 | 							throw new Error("Invalid SAML response structure");
1724 | 						}
1725 | 					} catch (error) {
1726 | 						ctx.context.logger.error("SAML response validation failed", {
1727 | 							error,
1728 | 							decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1729 | 								"utf-8",
1730 | 							),
1731 | 						});
1732 | 						throw new APIError("BAD_REQUEST", {
1733 | 							message: "Invalid SAML response",
1734 | 							details: error instanceof Error ? error.message : String(error),
1735 | 						});
1736 | 					}
1737 | 
1738 | 					const { extract } = parsedResponse!;
1739 | 					const attributes = extract.attributes || {};
1740 | 					const mapping = parsedSamlConfig.mapping ?? {};
1741 | 
1742 | 					const userInfo = {
1743 | 						...Object.fromEntries(
1744 | 							Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1745 | 								key,
1746 | 								attributes[value as string],
1747 | 							]),
1748 | 						),
1749 | 						id: attributes[mapping.id || "nameID"] || extract.nameID,
1750 | 						email: attributes[mapping.email || "email"] || extract.nameID,
1751 | 						name:
1752 | 							[
1753 | 								attributes[mapping.firstName || "givenName"],
1754 | 								attributes[mapping.lastName || "surname"],
1755 | 							]
1756 | 								.filter(Boolean)
1757 | 								.join(" ") ||
1758 | 							attributes[mapping.name || "displayName"] ||
1759 | 							extract.nameID,
1760 | 						emailVerified:
1761 | 							options?.trustEmailVerified && mapping.emailVerified
1762 | 								? ((attributes[mapping.emailVerified] || false) as boolean)
1763 | 								: false,
1764 | 					};
1765 | 					if (!userInfo.id || !userInfo.email) {
1766 | 						ctx.context.logger.error(
1767 | 							"Missing essential user info from SAML response",
1768 | 							{
1769 | 								attributes: Object.keys(attributes),
1770 | 								mapping,
1771 | 								extractedId: userInfo.id,
1772 | 								extractedEmail: userInfo.email,
1773 | 							},
1774 | 						);
1775 | 						throw new APIError("BAD_REQUEST", {
1776 | 							message: "Unable to extract user ID or email from SAML response",
1777 | 						});
1778 | 					}
1779 | 
1780 | 					// Find or create user
1781 | 					let user: User;
1782 | 					const existingUser = await ctx.context.adapter.findOne<User>({
1783 | 						model: "user",
1784 | 						where: [
1785 | 							{
1786 | 								field: "email",
1787 | 								value: userInfo.email,
1788 | 							},
1789 | 						],
1790 | 					});
1791 | 
1792 | 					if (existingUser) {
1793 | 						user = existingUser;
1794 | 					} else {
1795 | 						user = await ctx.context.adapter.create({
1796 | 							model: "user",
1797 | 							data: {
1798 | 								email: userInfo.email,
1799 | 								name: userInfo.name,
1800 | 								emailVerified: userInfo.emailVerified,
1801 | 								createdAt: new Date(),
1802 | 								updatedAt: new Date(),
1803 | 							},
1804 | 						});
1805 | 					}
1806 | 
1807 | 					// Create or update account link
1808 | 					const account = await ctx.context.adapter.findOne<Account>({
1809 | 						model: "account",
1810 | 						where: [
1811 | 							{ field: "userId", value: user.id },
1812 | 							{ field: "providerId", value: provider.providerId },
1813 | 							{ field: "accountId", value: userInfo.id },
1814 | 						],
1815 | 					});
1816 | 
1817 | 					if (!account) {
1818 | 						await ctx.context.adapter.create<Account>({
1819 | 							model: "account",
1820 | 							data: {
1821 | 								userId: user.id,
1822 | 								providerId: provider.providerId,
1823 | 								accountId: userInfo.id,
1824 | 								createdAt: new Date(),
1825 | 								updatedAt: new Date(),
1826 | 								accessToken: "",
1827 | 								refreshToken: "",
1828 | 							},
1829 | 						});
1830 | 					}
1831 | 
1832 | 					// Run provision hooks
1833 | 					if (options?.provisionUser) {
1834 | 						await options.provisionUser({
1835 | 							user: user as User & Record<string, any>,
1836 | 							userInfo,
1837 | 							provider,
1838 | 						});
1839 | 					}
1840 | 
1841 | 					// Handle organization provisioning
1842 | 					if (
1843 | 						provider.organizationId &&
1844 | 						!options?.organizationProvisioning?.disabled
1845 | 					) {
1846 | 						const isOrgPluginEnabled = ctx.context.options.plugins?.find(
1847 | 							(plugin) => plugin.id === "organization",
1848 | 						);
1849 | 						if (isOrgPluginEnabled) {
1850 | 							const isAlreadyMember = await ctx.context.adapter.findOne({
1851 | 								model: "member",
1852 | 								where: [
1853 | 									{ field: "organizationId", value: provider.organizationId },
1854 | 									{ field: "userId", value: user.id },
1855 | 								],
1856 | 							});
1857 | 							if (!isAlreadyMember) {
1858 | 								const role = options?.organizationProvisioning?.getRole
1859 | 									? await options.organizationProvisioning.getRole({
1860 | 											user,
1861 | 											userInfo,
1862 | 											provider,
1863 | 										})
1864 | 									: options?.organizationProvisioning?.defaultRole || "member";
1865 | 								await ctx.context.adapter.create({
1866 | 									model: "member",
1867 | 									data: {
1868 | 										organizationId: provider.organizationId,
1869 | 										userId: user.id,
1870 | 										role,
1871 | 										createdAt: new Date(),
1872 | 										updatedAt: new Date(),
1873 | 									},
1874 | 								});
1875 | 							}
1876 | 						}
1877 | 					}
1878 | 
1879 | 					// Create session and set cookie
1880 | 					let session: Session =
1881 | 						await ctx.context.internalAdapter.createSession(user.id);
1882 | 					await setSessionCookie(ctx, { session, user });
1883 | 
1884 | 					// Redirect to callback URL
1885 | 					const callbackUrl =
1886 | 						RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1887 | 					throw ctx.redirect(callbackUrl);
1888 | 				},
1889 | 			),
1890 | 			acsEndpoint: createAuthEndpoint(
1891 | 				"/sso/saml2/sp/acs/:providerId",
1892 | 				{
1893 | 					method: "POST",
1894 | 					params: z.object({
1895 | 						providerId: z.string().optional(),
1896 | 					}),
1897 | 					body: z.object({
1898 | 						SAMLResponse: z.string(),
1899 | 						RelayState: z.string().optional(),
1900 | 					}),
1901 | 					metadata: {
1902 | 						isAction: false,
1903 | 						openapi: {
1904 | 							summary: "SAML Assertion Consumer Service",
1905 | 							description:
1906 | 								"Handles SAML responses from IdP after successful authentication",
1907 | 							responses: {
1908 | 								"302": {
1909 | 									description:
1910 | 										"Redirects to the callback URL after successful authentication",
1911 | 								},
1912 | 							},
1913 | 						},
1914 | 					},
1915 | 				},
1916 | 				async (ctx) => {
1917 | 					const { SAMLResponse, RelayState = "" } = ctx.body;
1918 | 					const { providerId } = ctx.params;
1919 | 
1920 | 					// If defaultSSO is configured, use it as the provider
1921 | 					let provider: SSOProvider | null = null;
1922 | 
1923 | 					if (options?.defaultSSO?.length) {
1924 | 						// For ACS endpoint, we can use the first default provider or try to match by providerId
1925 | 						const matchingDefault = providerId
1926 | 							? options.defaultSSO.find(
1927 | 									(defaultProvider) =>
1928 | 										defaultProvider.providerId === providerId,
1929 | 								)
1930 | 							: options.defaultSSO[0]; // Use first default provider if no specific providerId
1931 | 
1932 | 						if (matchingDefault) {
1933 | 							provider = {
1934 | 								issuer: matchingDefault.samlConfig?.issuer || "",
1935 | 								providerId: matchingDefault.providerId,
1936 | 								userId: "default",
1937 | 								samlConfig: matchingDefault.samlConfig,
1938 | 							};
1939 | 						}
1940 | 					} else {
1941 | 						provider = await ctx.context.adapter
1942 | 							.findOne<SSOProvider>({
1943 | 								model: "ssoProvider",
1944 | 								where: [
1945 | 									{
1946 | 										field: "providerId",
1947 | 										value: providerId ?? "sso",
1948 | 									},
1949 | 								],
1950 | 							})
1951 | 							.then((res) => {
1952 | 								if (!res) return null;
1953 | 								return {
1954 | 									...res,
1955 | 									samlConfig: res.samlConfig
1956 | 										? safeJsonParse<SAMLConfig>(
1957 | 												res.samlConfig as unknown as string,
1958 | 											) || undefined
1959 | 										: undefined,
1960 | 								};
1961 | 							});
1962 | 					}
1963 | 
1964 | 					if (!provider?.samlConfig) {
1965 | 						throw new APIError("NOT_FOUND", {
1966 | 							message: "No SAML provider found",
1967 | 						});
1968 | 					}
1969 | 
1970 | 					const parsedSamlConfig = provider.samlConfig;
1971 | 					// Configure SP and IdP
1972 | 					const sp = saml.ServiceProvider({
1973 | 						entityID:
1974 | 							parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1975 | 						assertionConsumerService: [
1976 | 							{
1977 | 								Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1978 | 								Location:
1979 | 									parsedSamlConfig.callbackUrl ||
1980 | 									`${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
1981 | 							},
1982 | 						],
1983 | 						wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1984 | 						metadata: parsedSamlConfig.spMetadata?.metadata,
1985 | 						privateKey:
1986 | 							parsedSamlConfig.spMetadata?.privateKey ||
1987 | 							parsedSamlConfig.privateKey,
1988 | 						privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1989 | 						nameIDFormat: parsedSamlConfig.identifierFormat
1990 | 							? [parsedSamlConfig.identifierFormat]
1991 | 							: undefined,
1992 | 					});
1993 | 
1994 | 					// Update where we construct the IdP
1995 | 					const idpData = parsedSamlConfig.idpMetadata;
1996 | 					const idp = !idpData?.metadata
1997 | 						? saml.IdentityProvider({
1998 | 								entityID: idpData?.entityID || parsedSamlConfig.issuer,
1999 | 								singleSignOnService: idpData?.singleSignOnService || [
2000 | 									{
2001 | 										Binding:
2002 | 											"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2003 | 										Location: parsedSamlConfig.entryPoint,
2004 | 									},
2005 | 								],
2006 | 								signingCert: idpData?.cert || parsedSamlConfig.cert,
2007 | 							})
2008 | 						: saml.IdentityProvider({
2009 | 								metadata: idpData.metadata,
2010 | 							});
2011 | 
2012 | 					// Parse and validate SAML response
2013 | 					let parsedResponse: FlowResult;
2014 | 					try {
2015 | 						let decodedResponse = Buffer.from(SAMLResponse, "base64").toString(
2016 | 							"utf-8",
2017 | 						);
2018 | 
2019 | 						// Patch the SAML response if status is missing or not success
2020 | 						if (!decodedResponse.includes("StatusCode")) {
2021 | 							// Insert a success status if missing
2022 | 							const insertPoint = decodedResponse.indexOf("</saml2:Issuer>");
2023 | 							if (insertPoint !== -1) {
2024 | 								decodedResponse =
2025 | 									decodedResponse.slice(0, insertPoint + 14) +
2026 | 									'<saml2:Status><saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2:Status>' +
2027 | 									decodedResponse.slice(insertPoint + 14);
2028 | 							}
2029 | 						} else if (!decodedResponse.includes("saml2:Success")) {
2030 | 							// Replace existing non-success status with success
2031 | 							decodedResponse = decodedResponse.replace(
2032 | 								/<saml2:StatusCode Value="[^"]+"/,
2033 | 								'<saml2:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"',
2034 | 							);
2035 | 						}
2036 | 
2037 | 						try {
2038 | 							parsedResponse = await sp.parseLoginResponse(idp, "post", {
2039 | 								body: {
2040 | 									SAMLResponse,
2041 | 									RelayState: RelayState || undefined,
2042 | 								},
2043 | 							});
2044 | 						} catch (parseError) {
2045 | 							const nameIDMatch = decodedResponse.match(
2046 | 								/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/,
2047 | 							);
2048 | 							// due to different spec. we have to make sure to handle that.
2049 | 							if (!nameIDMatch) throw parseError;
2050 | 							parsedResponse = {
2051 | 								extract: {
2052 | 									nameID: nameIDMatch[1],
2053 | 									attributes: { nameID: nameIDMatch[1] },
2054 | 									sessionIndex: {},
2055 | 									conditions: {},
2056 | 								},
2057 | 							} as FlowResult;
2058 | 						}
2059 | 
2060 | 						if (!parsedResponse?.extract) {
2061 | 							throw new Error("Invalid SAML response structure");
2062 | 						}
2063 | 					} catch (error) {
2064 | 						ctx.context.logger.error("SAML response validation failed", {
2065 | 							error,
2066 | 							decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2067 | 								"utf-8",
2068 | 							),
2069 | 						});
2070 | 						throw new APIError("BAD_REQUEST", {
2071 | 							message: "Invalid SAML response",
2072 | 							details: error instanceof Error ? error.message : String(error),
2073 | 						});
2074 | 					}
2075 | 
2076 | 					const { extract } = parsedResponse!;
2077 | 					const attributes = extract.attributes || {};
2078 | 					const mapping = parsedSamlConfig.mapping ?? {};
2079 | 
2080 | 					const userInfo = {
2081 | 						...Object.fromEntries(
2082 | 							Object.entries(mapping.extraFields || {}).map(([key, value]) => [
2083 | 								key,
2084 | 								attributes[value as string],
2085 | 							]),
2086 | 						),
2087 | 						id: attributes[mapping.id || "nameID"] || extract.nameID,
2088 | 						email: attributes[mapping.email || "email"] || extract.nameID,
2089 | 						name:
2090 | 							[
2091 | 								attributes[mapping.firstName || "givenName"],
2092 | 								attributes[mapping.lastName || "surname"],
2093 | 							]
2094 | 								.filter(Boolean)
2095 | 								.join(" ") ||
2096 | 							attributes[mapping.name || "displayName"] ||
2097 | 							extract.nameID,
2098 | 						emailVerified:
2099 | 							options?.trustEmailVerified && mapping.emailVerified
2100 | 								? ((attributes[mapping.emailVerified] || false) as boolean)
2101 | 								: false,
2102 | 					};
2103 | 
2104 | 					if (!userInfo.id || !userInfo.email) {
2105 | 						ctx.context.logger.error(
2106 | 							"Missing essential user info from SAML response",
2107 | 							{
2108 | 								attributes: Object.keys(attributes),
2109 | 								mapping,
2110 | 								extractedId: userInfo.id,
2111 | 								extractedEmail: userInfo.email,
2112 | 							},
2113 | 						);
2114 | 						throw new APIError("BAD_REQUEST", {
2115 | 							message: "Unable to extract user ID or email from SAML response",
2116 | 						});
2117 | 					}
2118 | 
2119 | 					// Find or create user
2120 | 					let user: User;
2121 | 					const existingUser = await ctx.context.adapter.findOne<User>({
2122 | 						model: "user",
2123 | 						where: [
2124 | 							{
2125 | 								field: "email",
2126 | 								value: userInfo.email,
2127 | 							},
2128 | 						],
2129 | 					});
2130 | 
2131 | 					if (existingUser) {
2132 | 						const account = await ctx.context.adapter.findOne<Account>({
2133 | 							model: "account",
2134 | 							where: [
2135 | 								{ field: "userId", value: existingUser.id },
2136 | 								{ field: "providerId", value: provider.providerId },
2137 | 								{ field: "accountId", value: userInfo.id },
2138 | 							],
2139 | 						});
2140 | 						if (!account) {
2141 | 							const isTrustedProvider =
2142 | 								ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
2143 | 									provider.providerId,
2144 | 								);
2145 | 							if (!isTrustedProvider) {
2146 | 								throw ctx.redirect(
2147 | 									`${parsedSamlConfig.callbackUrl}?error=account_not_found`,
2148 | 								);
2149 | 							}
2150 | 							await ctx.context.adapter.create<Account>({
2151 | 								model: "account",
2152 | 								data: {
2153 | 									userId: existingUser.id,
2154 | 									providerId: provider.providerId,
2155 | 									accountId: userInfo.id,
2156 | 									createdAt: new Date(),
2157 | 									updatedAt: new Date(),
2158 | 									accessToken: "",
2159 | 									refreshToken: "",
2160 | 								},
2161 | 							});
2162 | 						}
2163 | 						user = existingUser;
2164 | 					} else {
2165 | 						user = await ctx.context.adapter.create({
2166 | 							model: "user",
2167 | 							data: {
2168 | 								email: userInfo.email,
2169 | 								name: userInfo.name,
2170 | 								emailVerified: options?.trustEmailVerified
2171 | 									? userInfo.emailVerified || false
2172 | 									: false,
2173 | 								createdAt: new Date(),
2174 | 								updatedAt: new Date(),
2175 | 							},
2176 | 						});
2177 | 						await ctx.context.adapter.create<Account>({
2178 | 							model: "account",
2179 | 							data: {
2180 | 								userId: user.id,
2181 | 								providerId: provider.providerId,
2182 | 								accountId: userInfo.id,
2183 | 								accessToken: "",
2184 | 								refreshToken: "",
2185 | 								accessTokenExpiresAt: new Date(),
2186 | 								refreshTokenExpiresAt: new Date(),
2187 | 								scope: "",
2188 | 								createdAt: new Date(),
2189 | 								updatedAt: new Date(),
2190 | 							},
2191 | 						});
2192 | 					}
2193 | 
2194 | 					if (options?.provisionUser) {
2195 | 						await options.provisionUser({
2196 | 							user: user as User & Record<string, any>,
2197 | 							userInfo,
2198 | 							provider,
2199 | 						});
2200 | 					}
2201 | 
2202 | 					if (
2203 | 						provider.organizationId &&
2204 | 						!options?.organizationProvisioning?.disabled
2205 | 					) {
2206 | 						const isOrgPluginEnabled = ctx.context.options.plugins?.find(
2207 | 							(plugin) => plugin.id === "organization",
2208 | 						);
2209 | 						if (isOrgPluginEnabled) {
2210 | 							const isAlreadyMember = await ctx.context.adapter.findOne({
2211 | 								model: "member",
2212 | 								where: [
2213 | 									{ field: "organizationId", value: provider.organizationId },
2214 | 									{ field: "userId", value: user.id },
2215 | 								],
2216 | 							});
2217 | 							if (!isAlreadyMember) {
2218 | 								const role = options?.organizationProvisioning?.getRole
2219 | 									? await options.organizationProvisioning.getRole({
2220 | 											user,
2221 | 											userInfo,
2222 | 											provider,
2223 | 										})
2224 | 									: options?.organizationProvisioning?.defaultRole || "member";
2225 | 								await ctx.context.adapter.create({
2226 | 									model: "member",
2227 | 									data: {
2228 | 										organizationId: provider.organizationId,
2229 | 										userId: user.id,
2230 | 										role,
2231 | 										createdAt: new Date(),
2232 | 										updatedAt: new Date(),
2233 | 									},
2234 | 								});
2235 | 							}
2236 | 						}
2237 | 					}
2238 | 
2239 | 					let session: Session =
2240 | 						await ctx.context.internalAdapter.createSession(user.id);
2241 | 					await setSessionCookie(ctx, { session, user });
2242 | 
2243 | 					const callbackUrl =
2244 | 						RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2245 | 					throw ctx.redirect(callbackUrl);
2246 | 				},
2247 | 			),
2248 | 		},
2249 | 		schema: {
2250 | 			ssoProvider: {
2251 | 				fields: {
2252 | 					issuer: {
2253 | 						type: "string",
2254 | 						required: true,
2255 | 					},
2256 | 					oidcConfig: {
2257 | 						type: "string",
2258 | 						required: false,
2259 | 					},
2260 | 					samlConfig: {
2261 | 						type: "string",
2262 | 						required: false,
2263 | 					},
2264 | 					userId: {
2265 | 						type: "string",
2266 | 						references: {
2267 | 							model: "user",
2268 | 							field: "id",
2269 | 						},
2270 | 					},
2271 | 					providerId: {
2272 | 						type: "string",
2273 | 						required: true,
2274 | 						unique: true,
2275 | 					},
2276 | 					organizationId: {
2277 | 						type: "string",
2278 | 						required: false,
2279 | 					},
2280 | 					domain: {
2281 | 						type: "string",
2282 | 						required: true,
2283 | 					},
2284 | 				},
2285 | 			},
2286 | 		},
2287 | 	} satisfies BetterAuthPlugin;
2288 | };
2289 | 
```
Page 64/67FirstPrevNextLast