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 | ```