This is page 49 of 67. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── middleware.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ └── user-additional-fields.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── sso │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── sso.test.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── middleware │ │ │ │ └── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/passkey/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | generateAuthenticationOptions, 3 | generateRegistrationOptions, 4 | verifyAuthenticationResponse, 5 | verifyRegistrationResponse, 6 | } from "@simplewebauthn/server"; 7 | import type { 8 | AuthenticationResponseJSON, 9 | AuthenticatorTransportFuture, 10 | CredentialDeviceType, 11 | PublicKeyCredentialCreationOptionsJSON, 12 | } from "@simplewebauthn/server"; 13 | import { APIError } from "better-call"; 14 | import { generateRandomString } from "../../crypto/random"; 15 | import * as z from "zod"; 16 | import { createAuthEndpoint } from "@better-auth/core/middleware"; 17 | import { sessionMiddleware } from "../../api"; 18 | import { freshSessionMiddleware, getSessionFromCtx } from "../../api/routes"; 19 | import type { InferOptionSchema } from "../../types/plugins"; 20 | import type { BetterAuthPlugin } from "@better-auth/core"; 21 | import { setSessionCookie } from "../../cookies"; 22 | import { generateId } from "../../utils"; 23 | import { mergeSchema } from "../../db/schema"; 24 | import { base64 } from "@better-auth/utils/base64"; 25 | import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; 26 | import { defineErrorCodes } from "@better-auth/core/utils"; 27 | 28 | interface WebAuthnChallengeValue { 29 | expectedChallenge: string; 30 | userData: { 31 | id: string; 32 | }; 33 | } 34 | 35 | const ERROR_CODES = defineErrorCodes({ 36 | CHALLENGE_NOT_FOUND: "Challenge not found", 37 | YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY: 38 | "You are not allowed to register this passkey", 39 | FAILED_TO_VERIFY_REGISTRATION: "Failed to verify registration", 40 | PASSKEY_NOT_FOUND: "Passkey not found", 41 | AUTHENTICATION_FAILED: "Authentication failed", 42 | UNABLE_TO_CREATE_SESSION: "Unable to create session", 43 | FAILED_TO_UPDATE_PASSKEY: "Failed to update passkey", 44 | }); 45 | 46 | function getRpID(options: PasskeyOptions, baseURL?: string) { 47 | return ( 48 | options.rpID || (baseURL ? new URL(baseURL).hostname : "localhost") // default rpID 49 | ); 50 | } 51 | 52 | export interface PasskeyOptions { 53 | /** 54 | * A unique identifier for your website. 'localhost' is okay for 55 | * local dev 56 | * 57 | * @default "localhost" 58 | */ 59 | rpID?: string; 60 | /** 61 | * Human-readable title for your website 62 | * 63 | * @default "Better Auth" 64 | */ 65 | rpName?: string; 66 | /** 67 | * The URL at which registrations and authentications should occur. 68 | * `http://localhost` and `http://localhost:PORT` are also valid. 69 | * Do NOT include any trailing / 70 | * 71 | * if this isn't provided. The client itself will 72 | * pass this value. 73 | */ 74 | origin?: string | string[] | null; 75 | 76 | /** 77 | * Allow customization of the authenticatorSelection options 78 | * during passkey registration. 79 | */ 80 | authenticatorSelection?: AuthenticatorSelectionCriteria; 81 | 82 | /** 83 | * Advanced options 84 | */ 85 | advanced?: { 86 | webAuthnChallengeCookie?: string; 87 | }; 88 | /** 89 | * Schema for the passkey model 90 | */ 91 | schema?: InferOptionSchema<typeof schema>; 92 | } 93 | 94 | export type Passkey = { 95 | id: string; 96 | name?: string; 97 | publicKey: string; 98 | userId: string; 99 | credentialID: string; 100 | counter: number; 101 | deviceType: CredentialDeviceType; 102 | backedUp: boolean; 103 | transports?: string; 104 | createdAt: Date; 105 | aaguid?: string; 106 | }; 107 | 108 | export const passkey = (options?: PasskeyOptions) => { 109 | const opts = { 110 | origin: null, 111 | ...options, 112 | advanced: { 113 | webAuthnChallengeCookie: "better-auth-passkey", 114 | ...options?.advanced, 115 | }, 116 | }; 117 | const expirationTime = new Date(Date.now() + 1000 * 60 * 5); 118 | const currentTime = new Date(); 119 | const maxAgeInSeconds = Math.floor( 120 | (expirationTime.getTime() - currentTime.getTime()) / 1000, 121 | ); 122 | 123 | return { 124 | id: "passkey", 125 | endpoints: { 126 | generatePasskeyRegistrationOptions: createAuthEndpoint( 127 | "/passkey/generate-register-options", 128 | { 129 | method: "GET", 130 | use: [freshSessionMiddleware], 131 | query: z 132 | .object({ 133 | authenticatorAttachment: z 134 | .enum(["platform", "cross-platform"]) 135 | .optional(), 136 | name: z.string().optional(), 137 | }) 138 | .optional(), 139 | metadata: { 140 | client: false, 141 | openapi: { 142 | description: "Generate registration options for a new passkey", 143 | responses: { 144 | 200: { 145 | description: "Success", 146 | parameters: { 147 | query: { 148 | authenticatorAttachment: { 149 | description: `Type of authenticator to use for registration. 150 | "platform" for device-specific authenticators, 151 | "cross-platform" for authenticators that can be used across devices.`, 152 | required: false, 153 | }, 154 | name: { 155 | description: `Optional custom name for the passkey. 156 | This can help identify the passkey when managing multiple credentials.`, 157 | required: false, 158 | }, 159 | }, 160 | }, 161 | content: { 162 | "application/json": { 163 | schema: { 164 | type: "object", 165 | properties: { 166 | challenge: { 167 | type: "string", 168 | }, 169 | rp: { 170 | type: "object", 171 | properties: { 172 | name: { 173 | type: "string", 174 | }, 175 | id: { 176 | type: "string", 177 | }, 178 | }, 179 | }, 180 | user: { 181 | type: "object", 182 | properties: { 183 | id: { 184 | type: "string", 185 | }, 186 | name: { 187 | type: "string", 188 | }, 189 | displayName: { 190 | type: "string", 191 | }, 192 | }, 193 | }, 194 | pubKeyCredParams: { 195 | type: "array", 196 | items: { 197 | type: "object", 198 | properties: { 199 | type: { 200 | type: "string", 201 | }, 202 | alg: { 203 | type: "number", 204 | }, 205 | }, 206 | }, 207 | }, 208 | timeout: { 209 | type: "number", 210 | }, 211 | excludeCredentials: { 212 | type: "array", 213 | items: { 214 | type: "object", 215 | properties: { 216 | id: { 217 | type: "string", 218 | }, 219 | type: { 220 | type: "string", 221 | }, 222 | transports: { 223 | type: "array", 224 | items: { 225 | type: "string", 226 | }, 227 | }, 228 | }, 229 | }, 230 | }, 231 | authenticatorSelection: { 232 | type: "object", 233 | properties: { 234 | authenticatorAttachment: { 235 | type: "string", 236 | }, 237 | requireResidentKey: { 238 | type: "boolean", 239 | }, 240 | userVerification: { 241 | type: "string", 242 | }, 243 | }, 244 | }, 245 | attestation: { 246 | type: "string", 247 | }, 248 | 249 | extensions: { 250 | type: "object", 251 | }, 252 | }, 253 | }, 254 | }, 255 | }, 256 | }, 257 | }, 258 | }, 259 | }, 260 | }, 261 | async (ctx) => { 262 | const { session } = ctx.context; 263 | const userPasskeys = await ctx.context.adapter.findMany<Passkey>({ 264 | model: "passkey", 265 | where: [ 266 | { 267 | field: "userId", 268 | value: session.user.id, 269 | }, 270 | ], 271 | }); 272 | const userID = new TextEncoder().encode( 273 | generateRandomString(32, "a-z", "0-9"), 274 | ); 275 | let options: PublicKeyCredentialCreationOptionsJSON; 276 | options = await generateRegistrationOptions({ 277 | rpName: opts.rpName || ctx.context.appName, 278 | rpID: getRpID(opts, ctx.context.options.baseURL), 279 | userID, 280 | userName: ctx.query?.name || session.user.email || session.user.id, 281 | userDisplayName: session.user.email || session.user.id, 282 | attestationType: "none", 283 | excludeCredentials: userPasskeys.map((passkey) => ({ 284 | id: passkey.credentialID, 285 | transports: passkey.transports?.split( 286 | ",", 287 | ) as AuthenticatorTransportFuture[], 288 | })), 289 | authenticatorSelection: { 290 | residentKey: "preferred", 291 | userVerification: "preferred", 292 | ...(opts.authenticatorSelection || {}), 293 | ...(ctx.query?.authenticatorAttachment 294 | ? { 295 | authenticatorAttachment: ctx.query.authenticatorAttachment, 296 | } 297 | : {}), 298 | }, 299 | }); 300 | const id = generateId(32); 301 | const webAuthnCookie = ctx.context.createAuthCookie( 302 | opts.advanced.webAuthnChallengeCookie, 303 | ); 304 | await ctx.setSignedCookie( 305 | webAuthnCookie.name, 306 | id, 307 | ctx.context.secret, 308 | { 309 | ...webAuthnCookie.attributes, 310 | maxAge: maxAgeInSeconds, 311 | }, 312 | ); 313 | await ctx.context.internalAdapter.createVerificationValue( 314 | { 315 | identifier: id, 316 | value: JSON.stringify({ 317 | expectedChallenge: options.challenge, 318 | userData: { 319 | id: session.user.id, 320 | }, 321 | }), 322 | expiresAt: expirationTime, 323 | }, 324 | ctx, 325 | ); 326 | return ctx.json(options, { 327 | status: 200, 328 | }); 329 | }, 330 | ), 331 | generatePasskeyAuthenticationOptions: createAuthEndpoint( 332 | "/passkey/generate-authenticate-options", 333 | { 334 | method: "POST", 335 | metadata: { 336 | openapi: { 337 | description: "Generate authentication options for a passkey", 338 | responses: { 339 | 200: { 340 | description: "Success", 341 | content: { 342 | "application/json": { 343 | schema: { 344 | type: "object", 345 | properties: { 346 | challenge: { 347 | type: "string", 348 | }, 349 | rp: { 350 | type: "object", 351 | properties: { 352 | name: { 353 | type: "string", 354 | }, 355 | id: { 356 | type: "string", 357 | }, 358 | }, 359 | }, 360 | user: { 361 | type: "object", 362 | properties: { 363 | id: { 364 | type: "string", 365 | }, 366 | name: { 367 | type: "string", 368 | }, 369 | displayName: { 370 | type: "string", 371 | }, 372 | }, 373 | }, 374 | timeout: { 375 | type: "number", 376 | }, 377 | allowCredentials: { 378 | type: "array", 379 | items: { 380 | type: "object", 381 | properties: { 382 | id: { 383 | type: "string", 384 | }, 385 | type: { 386 | type: "string", 387 | }, 388 | transports: { 389 | type: "array", 390 | items: { 391 | type: "string", 392 | }, 393 | }, 394 | }, 395 | }, 396 | }, 397 | userVerification: { 398 | type: "string", 399 | }, 400 | authenticatorSelection: { 401 | type: "object", 402 | properties: { 403 | authenticatorAttachment: { 404 | type: "string", 405 | }, 406 | requireResidentKey: { 407 | type: "boolean", 408 | }, 409 | userVerification: { 410 | type: "string", 411 | }, 412 | }, 413 | }, 414 | extensions: { 415 | type: "object", 416 | }, 417 | }, 418 | }, 419 | }, 420 | }, 421 | }, 422 | }, 423 | }, 424 | }, 425 | }, 426 | async (ctx) => { 427 | const session = await getSessionFromCtx(ctx); 428 | let userPasskeys: Passkey[] = []; 429 | if (session) { 430 | userPasskeys = await ctx.context.adapter.findMany<Passkey>({ 431 | model: "passkey", 432 | where: [ 433 | { 434 | field: "userId", 435 | value: session.user.id, 436 | }, 437 | ], 438 | }); 439 | } 440 | const options = await generateAuthenticationOptions({ 441 | rpID: getRpID(opts, ctx.context.options.baseURL), 442 | userVerification: "preferred", 443 | ...(userPasskeys.length 444 | ? { 445 | allowCredentials: userPasskeys.map((passkey) => ({ 446 | id: passkey.credentialID, 447 | transports: passkey.transports?.split( 448 | ",", 449 | ) as AuthenticatorTransportFuture[], 450 | })), 451 | } 452 | : {}), 453 | }); 454 | const data = { 455 | expectedChallenge: options.challenge, 456 | userData: { 457 | id: session?.user.id || "", 458 | }, 459 | }; 460 | const id = generateId(32); 461 | const webAuthnCookie = ctx.context.createAuthCookie( 462 | opts.advanced.webAuthnChallengeCookie, 463 | ); 464 | await ctx.setSignedCookie( 465 | webAuthnCookie.name, 466 | id, 467 | ctx.context.secret, 468 | { 469 | ...webAuthnCookie.attributes, 470 | maxAge: maxAgeInSeconds, 471 | }, 472 | ); 473 | await ctx.context.internalAdapter.createVerificationValue( 474 | { 475 | identifier: id, 476 | value: JSON.stringify(data), 477 | expiresAt: expirationTime, 478 | }, 479 | ctx, 480 | ); 481 | return ctx.json(options, { 482 | status: 200, 483 | }); 484 | }, 485 | ), 486 | verifyPasskeyRegistration: createAuthEndpoint( 487 | "/passkey/verify-registration", 488 | { 489 | method: "POST", 490 | body: z.object({ 491 | response: z.any(), 492 | name: z 493 | .string() 494 | .meta({ 495 | description: "Name of the passkey", 496 | }) 497 | .optional(), 498 | }), 499 | use: [freshSessionMiddleware], 500 | metadata: { 501 | openapi: { 502 | description: "Verify registration of a new passkey", 503 | responses: { 504 | 200: { 505 | description: "Success", 506 | content: { 507 | "application/json": { 508 | schema: { 509 | $ref: "#/components/schemas/Passkey", 510 | }, 511 | }, 512 | }, 513 | }, 514 | 400: { 515 | description: "Bad request", 516 | }, 517 | }, 518 | }, 519 | }, 520 | }, 521 | async (ctx) => { 522 | const origin = options?.origin || ctx.headers?.get("origin") || ""; 523 | if (!origin) { 524 | return ctx.json(null, { 525 | status: 400, 526 | }); 527 | } 528 | const resp = ctx.body.response; 529 | const webAuthnCookie = ctx.context.createAuthCookie( 530 | opts.advanced.webAuthnChallengeCookie, 531 | ); 532 | const challengeId = await ctx.getSignedCookie( 533 | webAuthnCookie.name, 534 | ctx.context.secret, 535 | ); 536 | if (!challengeId) { 537 | throw new APIError("BAD_REQUEST", { 538 | message: ERROR_CODES.CHALLENGE_NOT_FOUND, 539 | }); 540 | } 541 | 542 | const data = 543 | await ctx.context.internalAdapter.findVerificationValue( 544 | challengeId, 545 | ); 546 | if (!data) { 547 | return ctx.json(null, { 548 | status: 400, 549 | }); 550 | } 551 | const { expectedChallenge, userData } = JSON.parse( 552 | data.value, 553 | ) as WebAuthnChallengeValue; 554 | 555 | if (userData.id !== ctx.context.session.user.id) { 556 | throw new APIError("UNAUTHORIZED", { 557 | message: ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY, 558 | }); 559 | } 560 | 561 | try { 562 | const verification = await verifyRegistrationResponse({ 563 | response: resp, 564 | expectedChallenge, 565 | expectedOrigin: origin, 566 | expectedRPID: getRpID(opts, ctx.context.options.baseURL), 567 | requireUserVerification: false, 568 | }); 569 | const { verified, registrationInfo } = verification; 570 | if (!verified || !registrationInfo) { 571 | return ctx.json(null, { 572 | status: 400, 573 | }); 574 | } 575 | const { 576 | aaguid, 577 | // credentialID, 578 | // credentialPublicKey, 579 | // counter, 580 | credentialDeviceType, 581 | credentialBackedUp, 582 | credential, 583 | credentialType, 584 | } = registrationInfo; 585 | const pubKey = base64.encode(credential.publicKey); 586 | const newPasskey: Omit<Passkey, "id"> = { 587 | name: ctx.body.name, 588 | userId: userData.id, 589 | credentialID: credential.id, 590 | publicKey: pubKey, 591 | counter: credential.counter, 592 | deviceType: credentialDeviceType, 593 | transports: resp.response.transports.join(","), 594 | backedUp: credentialBackedUp, 595 | createdAt: new Date(), 596 | aaguid: aaguid, 597 | }; 598 | const newPasskeyRes = await ctx.context.adapter.create< 599 | Omit<Passkey, "id">, 600 | Passkey 601 | >({ 602 | model: "passkey", 603 | data: newPasskey, 604 | }); 605 | return ctx.json(newPasskeyRes, { 606 | status: 200, 607 | }); 608 | } catch (e) { 609 | console.log(e); 610 | throw new APIError("INTERNAL_SERVER_ERROR", { 611 | message: ERROR_CODES.FAILED_TO_VERIFY_REGISTRATION, 612 | }); 613 | } 614 | }, 615 | ), 616 | verifyPasskeyAuthentication: createAuthEndpoint( 617 | "/passkey/verify-authentication", 618 | { 619 | method: "POST", 620 | body: z.object({ 621 | response: z.record(z.any(), z.any()), 622 | }), 623 | metadata: { 624 | openapi: { 625 | description: "Verify authentication of a passkey", 626 | responses: { 627 | 200: { 628 | description: "Success", 629 | content: { 630 | "application/json": { 631 | schema: { 632 | type: "object", 633 | properties: { 634 | session: { 635 | $ref: "#/components/schemas/Session", 636 | }, 637 | user: { 638 | $ref: "#/components/schemas/User", 639 | }, 640 | }, 641 | }, 642 | }, 643 | }, 644 | }, 645 | }, 646 | }, 647 | $Infer: { 648 | body: {} as { 649 | response: AuthenticationResponseJSON; 650 | }, 651 | }, 652 | }, 653 | }, 654 | async (ctx) => { 655 | const origin = options?.origin || ctx.headers?.get("origin") || ""; 656 | if (!origin) { 657 | throw new APIError("BAD_REQUEST", { 658 | message: "origin missing", 659 | }); 660 | } 661 | const resp = ctx.body.response; 662 | const webAuthnCookie = ctx.context.createAuthCookie( 663 | opts.advanced.webAuthnChallengeCookie, 664 | ); 665 | const challengeId = await ctx.getSignedCookie( 666 | webAuthnCookie.name, 667 | ctx.context.secret, 668 | ); 669 | if (!challengeId) { 670 | throw new APIError("BAD_REQUEST", { 671 | message: ERROR_CODES.CHALLENGE_NOT_FOUND, 672 | }); 673 | } 674 | 675 | const data = 676 | await ctx.context.internalAdapter.findVerificationValue( 677 | challengeId, 678 | ); 679 | if (!data) { 680 | throw new APIError("BAD_REQUEST", { 681 | message: ERROR_CODES.CHALLENGE_NOT_FOUND, 682 | }); 683 | } 684 | const { expectedChallenge } = JSON.parse( 685 | data.value, 686 | ) as WebAuthnChallengeValue; 687 | const passkey = await ctx.context.adapter.findOne<Passkey>({ 688 | model: "passkey", 689 | where: [ 690 | { 691 | field: "credentialID", 692 | value: resp.id, 693 | }, 694 | ], 695 | }); 696 | if (!passkey) { 697 | throw new APIError("UNAUTHORIZED", { 698 | message: ERROR_CODES.PASSKEY_NOT_FOUND, 699 | }); 700 | } 701 | try { 702 | const verification = await verifyAuthenticationResponse({ 703 | response: resp as AuthenticationResponseJSON, 704 | expectedChallenge, 705 | expectedOrigin: origin, 706 | expectedRPID: getRpID(opts, ctx.context.options.baseURL), 707 | credential: { 708 | id: passkey.credentialID, 709 | publicKey: base64.decode(passkey.publicKey), 710 | counter: passkey.counter, 711 | transports: passkey.transports?.split( 712 | ",", 713 | ) as AuthenticatorTransportFuture[], 714 | }, 715 | requireUserVerification: false, 716 | }); 717 | const { verified } = verification; 718 | if (!verified) 719 | throw new APIError("UNAUTHORIZED", { 720 | message: ERROR_CODES.AUTHENTICATION_FAILED, 721 | }); 722 | 723 | await ctx.context.adapter.update<Passkey>({ 724 | model: "passkey", 725 | where: [ 726 | { 727 | field: "id", 728 | value: passkey.id, 729 | }, 730 | ], 731 | update: { 732 | counter: verification.authenticationInfo.newCounter, 733 | }, 734 | }); 735 | const s = await ctx.context.internalAdapter.createSession( 736 | passkey.userId, 737 | ctx, 738 | ); 739 | if (!s) { 740 | throw new APIError("INTERNAL_SERVER_ERROR", { 741 | message: ERROR_CODES.UNABLE_TO_CREATE_SESSION, 742 | }); 743 | } 744 | const user = await ctx.context.internalAdapter.findUserById( 745 | passkey.userId, 746 | ); 747 | if (!user) { 748 | throw new APIError("INTERNAL_SERVER_ERROR", { 749 | message: "User not found", 750 | }); 751 | } 752 | await setSessionCookie(ctx, { 753 | session: s, 754 | user, 755 | }); 756 | return ctx.json( 757 | { 758 | session: s, 759 | }, 760 | { 761 | status: 200, 762 | }, 763 | ); 764 | } catch (e) { 765 | ctx.context.logger.error("Failed to verify authentication", e); 766 | throw new APIError("BAD_REQUEST", { 767 | message: ERROR_CODES.AUTHENTICATION_FAILED, 768 | }); 769 | } 770 | }, 771 | ), 772 | /** 773 | * ### Endpoint 774 | * 775 | * GET `/passkey/list-user-passkeys` 776 | * 777 | * ### API Methods 778 | * 779 | * **server:** 780 | * `auth.api.listPasskeys` 781 | * 782 | * **client:** 783 | * `authClient.passkey.listUserPasskeys` 784 | * 785 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-list-user-passkeys) 786 | */ 787 | listPasskeys: createAuthEndpoint( 788 | "/passkey/list-user-passkeys", 789 | { 790 | method: "GET", 791 | use: [sessionMiddleware], 792 | metadata: { 793 | openapi: { 794 | description: "List all passkeys for the authenticated user", 795 | responses: { 796 | "200": { 797 | description: "Passkeys retrieved successfully", 798 | content: { 799 | "application/json": { 800 | schema: { 801 | type: "array", 802 | items: { 803 | $ref: "#/components/schemas/Passkey", 804 | required: [ 805 | "id", 806 | "userId", 807 | "publicKey", 808 | "createdAt", 809 | "updatedAt", 810 | ], 811 | }, 812 | description: 813 | "Array of passkey objects associated with the user", 814 | }, 815 | }, 816 | }, 817 | }, 818 | }, 819 | }, 820 | }, 821 | }, 822 | async (ctx) => { 823 | const passkeys = await ctx.context.adapter.findMany<Passkey>({ 824 | model: "passkey", 825 | where: [{ field: "userId", value: ctx.context.session.user.id }], 826 | }); 827 | return ctx.json(passkeys, { 828 | status: 200, 829 | }); 830 | }, 831 | ), 832 | /** 833 | * ### Endpoint 834 | * 835 | * POST `/passkey/delete-passkey` 836 | * 837 | * ### API Methods 838 | * 839 | * **server:** 840 | * `auth.api.deletePasskey` 841 | * 842 | * **client:** 843 | * `authClient.passkey.deletePasskey` 844 | * 845 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-delete-passkey) 846 | */ 847 | deletePasskey: createAuthEndpoint( 848 | "/passkey/delete-passkey", 849 | { 850 | method: "POST", 851 | body: z.object({ 852 | id: z.string().meta({ 853 | description: 854 | 'The ID of the passkey to delete. Eg: "some-passkey-id"', 855 | }), 856 | }), 857 | use: [sessionMiddleware], 858 | metadata: { 859 | openapi: { 860 | description: "Delete a specific passkey", 861 | responses: { 862 | "200": { 863 | description: "Passkey deleted successfully", 864 | content: { 865 | "application/json": { 866 | schema: { 867 | type: "object", 868 | properties: { 869 | status: { 870 | type: "boolean", 871 | description: 872 | "Indicates whether the deletion was successful", 873 | }, 874 | }, 875 | required: ["status"], 876 | }, 877 | }, 878 | }, 879 | }, 880 | }, 881 | }, 882 | }, 883 | }, 884 | async (ctx) => { 885 | await ctx.context.adapter.delete<Passkey>({ 886 | model: "passkey", 887 | where: [ 888 | { 889 | field: "id", 890 | value: ctx.body.id, 891 | }, 892 | ], 893 | }); 894 | return ctx.json(null, { 895 | status: 200, 896 | }); 897 | }, 898 | ), 899 | /** 900 | * ### Endpoint 901 | * 902 | * POST `/passkey/update-passkey` 903 | * 904 | * ### API Methods 905 | * 906 | * **server:** 907 | * `auth.api.updatePasskey` 908 | * 909 | * **client:** 910 | * `authClient.passkey.updatePasskey` 911 | * 912 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/passkey#api-method-passkey-update-passkey) 913 | */ 914 | updatePasskey: createAuthEndpoint( 915 | "/passkey/update-passkey", 916 | { 917 | method: "POST", 918 | body: z.object({ 919 | id: z.string().meta({ 920 | description: `The ID of the passkey which will be updated. Eg: \"passkey-id\"`, 921 | }), 922 | name: z.string().meta({ 923 | description: `The new name which the passkey will be updated to. Eg: \"my-new-passkey-name\"`, 924 | }), 925 | }), 926 | use: [sessionMiddleware], 927 | metadata: { 928 | openapi: { 929 | description: "Update a specific passkey's name", 930 | responses: { 931 | "200": { 932 | description: "Passkey updated successfully", 933 | content: { 934 | "application/json": { 935 | schema: { 936 | type: "object", 937 | properties: { 938 | passkey: { 939 | $ref: "#/components/schemas/Passkey", 940 | }, 941 | }, 942 | required: ["passkey"], 943 | }, 944 | }, 945 | }, 946 | }, 947 | }, 948 | }, 949 | }, 950 | }, 951 | async (ctx) => { 952 | const passkey = await ctx.context.adapter.findOne<Passkey>({ 953 | model: "passkey", 954 | where: [ 955 | { 956 | field: "id", 957 | value: ctx.body.id, 958 | }, 959 | ], 960 | }); 961 | 962 | if (!passkey) { 963 | throw new APIError("NOT_FOUND", { 964 | message: ERROR_CODES.PASSKEY_NOT_FOUND, 965 | }); 966 | } 967 | 968 | if (passkey.userId !== ctx.context.session.user.id) { 969 | throw new APIError("UNAUTHORIZED", { 970 | message: ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY, 971 | }); 972 | } 973 | 974 | const updatedPasskey = await ctx.context.adapter.update<Passkey>({ 975 | model: "passkey", 976 | where: [ 977 | { 978 | field: "id", 979 | value: ctx.body.id, 980 | }, 981 | ], 982 | update: { 983 | name: ctx.body.name, 984 | }, 985 | }); 986 | 987 | if (!updatedPasskey) { 988 | throw new APIError("INTERNAL_SERVER_ERROR", { 989 | message: ERROR_CODES.FAILED_TO_UPDATE_PASSKEY, 990 | }); 991 | } 992 | return ctx.json( 993 | { 994 | passkey: updatedPasskey, 995 | }, 996 | { 997 | status: 200, 998 | }, 999 | ); 1000 | }, 1001 | ), 1002 | }, 1003 | schema: mergeSchema(schema, options?.schema), 1004 | $ERROR_CODES: ERROR_CODES, 1005 | } satisfies BetterAuthPlugin; 1006 | }; 1007 | 1008 | const schema = { 1009 | passkey: { 1010 | fields: { 1011 | name: { 1012 | type: "string", 1013 | required: false, 1014 | }, 1015 | publicKey: { 1016 | type: "string", 1017 | required: true, 1018 | }, 1019 | userId: { 1020 | type: "string", 1021 | references: { 1022 | model: "user", 1023 | field: "id", 1024 | }, 1025 | required: true, 1026 | }, 1027 | credentialID: { 1028 | type: "string", 1029 | required: true, 1030 | }, 1031 | counter: { 1032 | type: "number", 1033 | required: true, 1034 | }, 1035 | deviceType: { 1036 | type: "string", 1037 | required: true, 1038 | }, 1039 | backedUp: { 1040 | type: "boolean", 1041 | required: true, 1042 | }, 1043 | transports: { 1044 | type: "string", 1045 | required: false, 1046 | }, 1047 | createdAt: { 1048 | type: "date", 1049 | required: false, 1050 | }, 1051 | aaguid: { 1052 | type: "string", 1053 | required: false, 1054 | }, 1055 | }, 1056 | }, 1057 | } satisfies BetterAuthPluginDBSchema; 1058 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/organization.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError } from "better-call"; 2 | import * as z from "zod"; 3 | import type { BetterAuthPluginDBSchema } from "@better-auth/core/db"; 4 | import { createAuthEndpoint } from "@better-auth/core/middleware"; 5 | import { getSessionFromCtx } from "../../api/routes"; 6 | import type { BetterAuthPlugin } from "@better-auth/core"; 7 | import { shimContext } from "../../utils/shim"; 8 | import { type AccessControl } from "../access"; 9 | import { getOrgAdapter } from "./adapter"; 10 | import { orgSessionMiddleware } from "./call"; 11 | import { 12 | acceptInvitation, 13 | cancelInvitation, 14 | createInvitation, 15 | getInvitation, 16 | listInvitations, 17 | rejectInvitation, 18 | listUserInvitations, 19 | } from "./routes/crud-invites"; 20 | import { 21 | addMember, 22 | getActiveMember, 23 | leaveOrganization, 24 | listMembers, 25 | removeMember, 26 | updateMemberRole, 27 | getActiveMemberRole, 28 | } from "./routes/crud-members"; 29 | import { 30 | checkOrganizationSlug, 31 | createOrganization, 32 | deleteOrganization, 33 | getFullOrganization, 34 | listOrganizations, 35 | setActiveOrganization, 36 | updateOrganization, 37 | } from "./routes/crud-org"; 38 | import { 39 | createTeam, 40 | listOrganizationTeams, 41 | removeTeam, 42 | updateTeam, 43 | setActiveTeam, 44 | listUserTeams, 45 | listTeamMembers, 46 | addTeamMember, 47 | removeTeamMember, 48 | } from "./routes/crud-team"; 49 | import type { 50 | InferInvitation, 51 | InferMember, 52 | InferOrganization, 53 | Team, 54 | TeamMember, 55 | } from "./schema"; 56 | import { 57 | createOrgRole, 58 | deleteOrgRole, 59 | listOrgRoles, 60 | getOrgRole, 61 | updateOrgRole, 62 | } from "./routes/crud-access-control"; 63 | import { ORGANIZATION_ERROR_CODES } from "./error-codes"; 64 | import { defaultRoles, defaultStatements } from "./access"; 65 | import { hasPermission } from "./has-permission"; 66 | import type { OrganizationOptions } from "./types"; 67 | import type { AuthContext } from "@better-auth/core"; 68 | 69 | export function parseRoles(roles: string | string[]): string { 70 | return Array.isArray(roles) ? roles.join(",") : roles; 71 | } 72 | 73 | /** 74 | * Organization plugin for Better Auth. Organization allows you to create teams, members, 75 | * and manage access control for your users. 76 | * 77 | * @example 78 | * ```ts 79 | * const auth = betterAuth({ 80 | * plugins: [ 81 | * organization({ 82 | * allowUserToCreateOrganization: true, 83 | * }), 84 | * ], 85 | * }); 86 | * ``` 87 | */ 88 | export const organization = <O extends OrganizationOptions>(options?: O) => { 89 | let endpoints = { 90 | /** 91 | * ### Endpoint 92 | * 93 | * POST `/organization/create` 94 | * 95 | * ### API Methods 96 | * 97 | * **server:** 98 | * `auth.api.createOrganization` 99 | * 100 | * **client:** 101 | * `authClient.organization.create` 102 | * 103 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-create) 104 | */ 105 | createOrganization: createOrganization(options as O), 106 | /** 107 | * ### Endpoint 108 | * 109 | * POST `/organization/update` 110 | * 111 | * ### API Methods 112 | * 113 | * **server:** 114 | * `auth.api.updateOrganization` 115 | * 116 | * **client:** 117 | * `authClient.organization.update` 118 | * 119 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-update) 120 | */ 121 | updateOrganization: updateOrganization(options as O), 122 | /** 123 | * ### Endpoint 124 | * 125 | * POST `/organization/delete` 126 | * 127 | * ### API Methods 128 | * 129 | * **server:** 130 | * `auth.api.deleteOrganization` 131 | * 132 | * **client:** 133 | * `authClient.organization.delete` 134 | * 135 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-delete) 136 | */ 137 | deleteOrganization: deleteOrganization(options as O), 138 | /** 139 | * ### Endpoint 140 | * 141 | * POST `/organization/set-active` 142 | * 143 | * ### API Methods 144 | * 145 | * **server:** 146 | * `auth.api.setActiveOrganization` 147 | * 148 | * **client:** 149 | * `authClient.organization.setActive` 150 | * 151 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-set-active) 152 | */ 153 | setActiveOrganization: setActiveOrganization(options as O), 154 | /** 155 | * ### Endpoint 156 | * 157 | * GET `/organization/get-full-organization` 158 | * 159 | * ### API Methods 160 | * 161 | * **server:** 162 | * `auth.api.getFullOrganization` 163 | * 164 | * **client:** 165 | * `authClient.organization.getFullOrganization` 166 | * 167 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-get-full-organization) 168 | */ 169 | getFullOrganization: getFullOrganization(options as O), 170 | /** 171 | * ### Endpoint 172 | * 173 | * GET `/organization/list` 174 | * 175 | * ### API Methods 176 | * 177 | * **server:** 178 | * `auth.api.listOrganizations` 179 | * 180 | * **client:** 181 | * `authClient.organization.list` 182 | * 183 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-list) 184 | */ 185 | listOrganizations: listOrganizations(options as O), 186 | /** 187 | * ### Endpoint 188 | * 189 | * POST `/organization/invite-member` 190 | * 191 | * ### API Methods 192 | * 193 | * **server:** 194 | * `auth.api.createInvitation` 195 | * 196 | * **client:** 197 | * `authClient.organization.inviteMember` 198 | * 199 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-invite-member) 200 | */ 201 | createInvitation: createInvitation(options as O), 202 | /** 203 | * ### Endpoint 204 | * 205 | * POST `/organization/cancel-invitation` 206 | * 207 | * ### API Methods 208 | * 209 | * **server:** 210 | * `auth.api.cancelInvitation` 211 | * 212 | * **client:** 213 | * `authClient.organization.cancelInvitation` 214 | * 215 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-cancel-invitation) 216 | */ 217 | cancelInvitation: cancelInvitation(options as O), 218 | /** 219 | * ### Endpoint 220 | * 221 | * POST `/organization/accept-invitation` 222 | * 223 | * ### API Methods 224 | * 225 | * **server:** 226 | * `auth.api.acceptInvitation` 227 | * 228 | * **client:** 229 | * `authClient.organization.acceptInvitation` 230 | * 231 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-accept-invitation) 232 | */ 233 | acceptInvitation: acceptInvitation(options as O), 234 | /** 235 | * ### Endpoint 236 | * 237 | * GET `/organization/get-invitation` 238 | * 239 | * ### API Methods 240 | * 241 | * **server:** 242 | * `auth.api.getInvitation` 243 | * 244 | * **client:** 245 | * `authClient.organization.getInvitation` 246 | * 247 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-get-invitation) 248 | */ 249 | getInvitation: getInvitation(options as O), 250 | /** 251 | * ### Endpoint 252 | * 253 | * POST `/organization/reject-invitation` 254 | * 255 | * ### API Methods 256 | * 257 | * **server:** 258 | * `auth.api.rejectInvitation` 259 | * 260 | * **client:** 261 | * `authClient.organization.rejectInvitation` 262 | * 263 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-reject-invitation) 264 | */ 265 | rejectInvitation: rejectInvitation(options as O), 266 | /** 267 | * ### Endpoint 268 | * 269 | * GET `/organization/list-invitations` 270 | * 271 | * ### API Methods 272 | * 273 | * **server:** 274 | * `auth.api.listInvitations` 275 | * 276 | * **client:** 277 | * `authClient.organization.listInvitations` 278 | * 279 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-list-invitations) 280 | */ 281 | listInvitations: listInvitations(options as O), 282 | /** 283 | * ### Endpoint 284 | * 285 | * GET `/organization/get-active-member` 286 | * 287 | * ### API Methods 288 | * 289 | * **server:** 290 | * `auth.api.getActiveMember` 291 | * 292 | * **client:** 293 | * `authClient.organization.getActiveMember` 294 | * 295 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-get-active-member) 296 | */ 297 | getActiveMember: getActiveMember(options as O), 298 | /** 299 | * ### Endpoint 300 | * 301 | * POST `/organization/check-slug` 302 | * 303 | * ### API Methods 304 | * 305 | * **server:** 306 | * `auth.api.checkOrganizationSlug` 307 | * 308 | * **client:** 309 | * `authClient.organization.checkSlug` 310 | * 311 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-check-slug) 312 | */ 313 | checkOrganizationSlug: checkOrganizationSlug(options as O), 314 | /** 315 | * ### Endpoint 316 | * 317 | * POST `/organization/add-member` 318 | * 319 | * ### API Methods 320 | * 321 | * **server:** 322 | * `auth.api.addMember` 323 | * 324 | * **client:** 325 | * `authClient.organization.addMember` 326 | * 327 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-add-member) 328 | */ 329 | 330 | addMember: addMember<O>(options as O), 331 | /** 332 | * ### Endpoint 333 | * 334 | * POST `/organization/remove-member` 335 | * 336 | * ### API Methods 337 | * 338 | * **server:** 339 | * `auth.api.removeMember` 340 | * 341 | * **client:** 342 | * `authClient.organization.removeMember` 343 | * 344 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-remove-member) 345 | */ 346 | removeMember: removeMember(options as O), 347 | /** 348 | * ### Endpoint 349 | * 350 | * POST `/organization/update-member-role` 351 | * 352 | * ### API Methods 353 | * 354 | * **server:** 355 | * `auth.api.updateMemberRole` 356 | * 357 | * **client:** 358 | * `authClient.organization.updateMemberRole` 359 | * 360 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-update-member-role) 361 | */ 362 | updateMemberRole: updateMemberRole(options as O), 363 | /** 364 | * ### Endpoint 365 | * 366 | * POST `/organization/leave` 367 | * 368 | * ### API Methods 369 | * 370 | * **server:** 371 | * `auth.api.leaveOrganization` 372 | * 373 | * **client:** 374 | * `authClient.organization.leave` 375 | * 376 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-leave) 377 | */ 378 | leaveOrganization: leaveOrganization(options as O), 379 | /** 380 | * ### Endpoint 381 | * 382 | * GET `/organization/list-members` 383 | * 384 | * ### API Methods 385 | * 386 | * **server:** 387 | * `auth.api.listMembers` 388 | * 389 | * **client:** 390 | * `authClient.organization.listMembers` 391 | * 392 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-list-members) 393 | */ 394 | listUserInvitations: listUserInvitations(options as O), 395 | /** 396 | * ### Endpoint 397 | * 398 | * GET `/organization/list-members` 399 | * 400 | * ### API Methods 401 | * 402 | * **server:** 403 | * `auth.api.listMembers` 404 | * 405 | * **client:** 406 | * `authClient.organization.listMembers` 407 | */ 408 | listMembers: listMembers(options as O), 409 | /** 410 | * ### Endpoint 411 | * 412 | * GET `/organization/get-active-member-role` 413 | * 414 | * ### API Methods 415 | * 416 | * **server:** 417 | * `auth.api.getActiveMemberRole` 418 | * 419 | * **client:** 420 | * `authClient.organization.getActiveMemberRole` 421 | * 422 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-get-active-member-role) 423 | */ 424 | getActiveMemberRole: getActiveMemberRole(options as O), 425 | }; 426 | const teamSupport = options?.teams?.enabled; 427 | const teamEndpoints = { 428 | /** 429 | * ### Endpoint 430 | * 431 | * POST `/organization/create-team` 432 | * 433 | * ### API Methods 434 | * 435 | * **server:** 436 | * `auth.api.createTeam` 437 | * 438 | * **client:** 439 | * `authClient.organization.createTeam` 440 | * 441 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-create-team) 442 | */ 443 | createTeam: createTeam(options as O), 444 | /** 445 | * ### Endpoint 446 | * 447 | * GET `/organization/list-teams` 448 | * 449 | * ### API Methods 450 | * 451 | * **server:** 452 | * `auth.api.listOrganizationTeams` 453 | * 454 | * **client:** 455 | * `authClient.organization.listTeams` 456 | * 457 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-list-teams) 458 | */ 459 | listOrganizationTeams: listOrganizationTeams(options as O), 460 | /** 461 | * ### Endpoint 462 | * 463 | * POST `/organization/remove-team` 464 | * 465 | * ### API Methods 466 | * 467 | * **server:** 468 | * `auth.api.removeTeam` 469 | * 470 | * **client:** 471 | * `authClient.organization.removeTeam` 472 | * 473 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-remove-team) 474 | */ 475 | removeTeam: removeTeam(options as O), 476 | /** 477 | * ### Endpoint 478 | * 479 | * POST `/organization/update-team` 480 | * 481 | * ### API Methods 482 | * 483 | * **server:** 484 | * `auth.api.updateTeam` 485 | * 486 | * **client:** 487 | * `authClient.organization.updateTeam` 488 | * 489 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-method-organization-update-team) 490 | */ 491 | updateTeam: updateTeam(options as O), 492 | /** 493 | * ### Endpoint 494 | * 495 | * POST `/organization/set-active-team` 496 | * 497 | * ### API Methods 498 | * 499 | * **server:** 500 | * `auth.api.setActiveTeam` 501 | * 502 | * **client:** 503 | * `authClient.organization.setActiveTeam` 504 | * 505 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team) 506 | */ 507 | setActiveTeam: setActiveTeam(options as O), 508 | /** 509 | * ### Endpoint 510 | * 511 | * GET `/organization/list-user-teams` 512 | * 513 | * ### API Methods 514 | * 515 | * **server:** 516 | * `auth.api.listUserTeams` 517 | * 518 | * **client:** 519 | * `authClient.organization.listUserTeams` 520 | * 521 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team) 522 | */ 523 | listUserTeams: listUserTeams(options as O), 524 | /** 525 | * ### Endpoint 526 | * 527 | * POST `/organization/list-team-members` 528 | * 529 | * ### API Methods 530 | * 531 | * **server:** 532 | * `auth.api.listTeamMembers` 533 | * 534 | * **client:** 535 | * `authClient.organization.listTeamMembers` 536 | * 537 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-set-active-team) 538 | */ 539 | listTeamMembers: listTeamMembers(options as O), 540 | /** 541 | * ### Endpoint 542 | * 543 | * POST `/organization/add-team-member` 544 | * 545 | * ### API Methods 546 | * 547 | * **server:** 548 | * `auth.api.addTeamMember` 549 | * 550 | * **client:** 551 | * `authClient.organization.addTeamMember` 552 | * 553 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-add-team-member) 554 | */ 555 | addTeamMember: addTeamMember(options as O), 556 | /** 557 | * ### Endpoint 558 | * 559 | * POST `/organization/remove-team-member` 560 | * 561 | * ### API Methods 562 | * 563 | * **server:** 564 | * `auth.api.removeTeamMember` 565 | * 566 | * **client:** 567 | * `authClient.organization.removeTeamMember` 568 | * 569 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/organization#api-remove-team-member) 570 | */ 571 | removeTeamMember: removeTeamMember(options as O), 572 | }; 573 | if (teamSupport) { 574 | endpoints = { 575 | ...endpoints, 576 | ...teamEndpoints, 577 | }; 578 | } 579 | 580 | const dynamicAccessControlEndpoints = { 581 | createOrgRole: createOrgRole(options as O), 582 | deleteOrgRole: deleteOrgRole(options as O), 583 | listOrgRoles: listOrgRoles(options as O), 584 | getOrgRole: getOrgRole(options as O), 585 | updateOrgRole: updateOrgRole(options as O), 586 | }; 587 | if (options?.dynamicAccessControl?.enabled) { 588 | endpoints = { 589 | ...endpoints, 590 | ...dynamicAccessControlEndpoints, 591 | }; 592 | } 593 | const roles = { 594 | ...defaultRoles, 595 | ...options?.roles, 596 | }; 597 | 598 | // Build team schema in a way that never introduces undefined values when spreading 599 | const teamSchema = teamSupport 600 | ? ({ 601 | team: { 602 | modelName: options?.schema?.team?.modelName, 603 | fields: { 604 | name: { 605 | type: "string", 606 | required: true, 607 | fieldName: options?.schema?.team?.fields?.name, 608 | }, 609 | organizationId: { 610 | type: "string", 611 | required: true, 612 | references: { 613 | model: "organization", 614 | field: "id", 615 | }, 616 | fieldName: options?.schema?.team?.fields?.organizationId, 617 | }, 618 | createdAt: { 619 | type: "date", 620 | required: true, 621 | fieldName: options?.schema?.team?.fields?.createdAt, 622 | }, 623 | updatedAt: { 624 | type: "date", 625 | required: false, 626 | fieldName: options?.schema?.team?.fields?.updatedAt, 627 | onUpdate: () => new Date(), 628 | }, 629 | ...(options?.schema?.team?.additionalFields || {}), 630 | }, 631 | }, 632 | teamMember: { 633 | modelName: options?.schema?.teamMember?.modelName, 634 | fields: { 635 | teamId: { 636 | type: "string", 637 | required: true, 638 | references: { 639 | model: "team", 640 | field: "id", 641 | }, 642 | fieldName: options?.schema?.teamMember?.fields?.teamId, 643 | }, 644 | userId: { 645 | type: "string", 646 | required: true, 647 | references: { 648 | model: "user", 649 | field: "id", 650 | }, 651 | fieldName: options?.schema?.teamMember?.fields?.userId, 652 | }, 653 | createdAt: { 654 | type: "date", 655 | required: false, 656 | fieldName: options?.schema?.teamMember?.fields?.createdAt, 657 | }, 658 | }, 659 | }, 660 | } satisfies BetterAuthPluginDBSchema) 661 | : {}; 662 | 663 | const organizationRoleSchema = options?.dynamicAccessControl?.enabled 664 | ? ({ 665 | organizationRole: { 666 | fields: { 667 | organizationId: { 668 | type: "string", 669 | required: true, 670 | references: { 671 | model: "organization", 672 | field: "id", 673 | }, 674 | fieldName: 675 | options?.schema?.organizationRole?.fields?.organizationId, 676 | }, 677 | role: { 678 | type: "string", 679 | required: true, 680 | fieldName: options?.schema?.organizationRole?.fields?.role, 681 | }, 682 | permission: { 683 | type: "string", 684 | required: true, 685 | fieldName: options?.schema?.organizationRole?.fields?.permission, 686 | }, 687 | createdAt: { 688 | type: "date", 689 | required: true, 690 | defaultValue: () => new Date(), 691 | fieldName: options?.schema?.organizationRole?.fields?.createdAt, 692 | }, 693 | updatedAt: { 694 | type: "date", 695 | required: false, 696 | fieldName: options?.schema?.organizationRole?.fields?.updatedAt, 697 | onUpdate: () => new Date(), 698 | }, 699 | ...(options?.schema?.organizationRole?.additionalFields || {}), 700 | }, 701 | modelName: options?.schema?.organizationRole?.modelName, 702 | }, 703 | } satisfies BetterAuthPluginDBSchema) 704 | : {}; 705 | 706 | const schema = { 707 | ...organizationRoleSchema, 708 | ...teamSchema, 709 | ...({ 710 | organization: { 711 | modelName: options?.schema?.organization?.modelName, 712 | fields: { 713 | name: { 714 | type: "string", 715 | required: true, 716 | sortable: true, 717 | fieldName: options?.schema?.organization?.fields?.name, 718 | }, 719 | slug: { 720 | type: "string", 721 | required: true, 722 | unique: true, 723 | sortable: true, 724 | fieldName: options?.schema?.organization?.fields?.slug, 725 | }, 726 | logo: { 727 | type: "string", 728 | required: false, 729 | fieldName: options?.schema?.organization?.fields?.logo, 730 | }, 731 | createdAt: { 732 | type: "date", 733 | required: true, 734 | fieldName: options?.schema?.organization?.fields?.createdAt, 735 | }, 736 | metadata: { 737 | type: "string", 738 | required: false, 739 | fieldName: options?.schema?.organization?.fields?.metadata, 740 | }, 741 | ...(options?.schema?.organization?.additionalFields || {}), 742 | }, 743 | }, 744 | member: { 745 | modelName: options?.schema?.member?.modelName, 746 | fields: { 747 | organizationId: { 748 | type: "string", 749 | required: true, 750 | references: { 751 | model: "organization", 752 | field: "id", 753 | }, 754 | fieldName: options?.schema?.member?.fields?.organizationId, 755 | }, 756 | userId: { 757 | type: "string", 758 | required: true, 759 | fieldName: options?.schema?.member?.fields?.userId, 760 | references: { 761 | model: "user", 762 | field: "id", 763 | }, 764 | }, 765 | role: { 766 | type: "string", 767 | required: true, 768 | sortable: true, 769 | defaultValue: "member", 770 | fieldName: options?.schema?.member?.fields?.role, 771 | }, 772 | createdAt: { 773 | type: "date", 774 | required: true, 775 | fieldName: options?.schema?.member?.fields?.createdAt, 776 | }, 777 | ...(options?.schema?.member?.additionalFields || {}), 778 | }, 779 | }, 780 | invitation: { 781 | modelName: options?.schema?.invitation?.modelName, 782 | fields: { 783 | organizationId: { 784 | type: "string", 785 | required: true, 786 | references: { 787 | model: "organization", 788 | field: "id", 789 | }, 790 | fieldName: options?.schema?.invitation?.fields?.organizationId, 791 | }, 792 | email: { 793 | type: "string", 794 | required: true, 795 | sortable: true, 796 | fieldName: options?.schema?.invitation?.fields?.email, 797 | }, 798 | role: { 799 | type: "string", 800 | required: false, 801 | sortable: true, 802 | fieldName: options?.schema?.invitation?.fields?.role, 803 | }, 804 | ...(teamSupport 805 | ? { 806 | teamId: { 807 | type: "string", 808 | required: false, 809 | sortable: true, 810 | fieldName: options?.schema?.invitation?.fields?.teamId, 811 | }, 812 | } 813 | : {}), 814 | status: { 815 | type: "string", 816 | required: true, 817 | sortable: true, 818 | defaultValue: "pending", 819 | fieldName: options?.schema?.invitation?.fields?.status, 820 | }, 821 | expiresAt: { 822 | type: "date", 823 | required: true, 824 | fieldName: options?.schema?.invitation?.fields?.expiresAt, 825 | }, 826 | createdAt: { 827 | type: "date", 828 | required: true, 829 | fieldName: options?.schema?.invitation?.fields?.createdAt, 830 | defaultValue: () => new Date(), 831 | }, 832 | inviterId: { 833 | type: "string", 834 | references: { 835 | model: "user", 836 | field: "id", 837 | }, 838 | fieldName: options?.schema?.invitation?.fields?.inviterId, 839 | required: true, 840 | }, 841 | ...(options?.schema?.invitation?.additionalFields || {}), 842 | }, 843 | }, 844 | } satisfies BetterAuthPluginDBSchema), 845 | }; 846 | 847 | /** 848 | * the orgMiddleware type-asserts an empty object representing org options, roles, and a getSession function. 849 | * This `shimContext` function is used to add those missing properties to the context object. 850 | */ 851 | const api = shimContext(endpoints, { 852 | orgOptions: options || {}, 853 | roles, 854 | getSession: async (context: AuthContext) => { 855 | //@ts-expect-error 856 | return await getSessionFromCtx(context); 857 | }, 858 | }); 859 | 860 | type DefaultStatements = typeof defaultStatements; 861 | type Statements = O["ac"] extends AccessControl<infer S> 862 | ? S 863 | : DefaultStatements; 864 | type PermissionType = { 865 | [key in keyof Statements]?: Array< 866 | Statements[key] extends readonly unknown[] 867 | ? Statements[key][number] 868 | : never 869 | >; 870 | }; 871 | type PermissionExclusive = 872 | | { 873 | /** 874 | * @deprecated Use `permissions` instead 875 | */ 876 | permission: PermissionType; 877 | permissions?: never; 878 | } 879 | | { 880 | permissions: PermissionType; 881 | permission?: never; 882 | }; 883 | 884 | type IncludeTeamEndpoints<ExistingEndpoints extends Record<string, any>> = 885 | O["teams"] extends { enabled: true } 886 | ? ExistingEndpoints & typeof teamEndpoints 887 | : ExistingEndpoints; 888 | 889 | type IncludeDynamicAccessControlEndpoints< 890 | ExistingEndpoints extends Record<string, any>, 891 | > = O["dynamicAccessControl"] extends { enabled: true } 892 | ? ExistingEndpoints & typeof dynamicAccessControlEndpoints 893 | : ExistingEndpoints; 894 | 895 | type AllEndpoints = IncludeDynamicAccessControlEndpoints< 896 | IncludeTeamEndpoints<typeof endpoints> 897 | >; 898 | 899 | return { 900 | id: "organization", 901 | endpoints: { 902 | ...(api as AllEndpoints), 903 | hasPermission: createAuthEndpoint( 904 | "/organization/has-permission", 905 | { 906 | method: "POST", 907 | requireHeaders: true, 908 | body: z 909 | .object({ 910 | organizationId: z.string().optional(), 911 | }) 912 | .and( 913 | z.union([ 914 | z.object({ 915 | permission: z.record(z.string(), z.array(z.string())), 916 | permissions: z.undefined(), 917 | }), 918 | z.object({ 919 | permission: z.undefined(), 920 | permissions: z.record(z.string(), z.array(z.string())), 921 | }), 922 | ]), 923 | ), 924 | use: [orgSessionMiddleware], 925 | metadata: { 926 | $Infer: { 927 | body: {} as PermissionExclusive & { 928 | organizationId?: string; 929 | }, 930 | }, 931 | openapi: { 932 | description: "Check if the user has permission", 933 | requestBody: { 934 | content: { 935 | "application/json": { 936 | schema: { 937 | type: "object", 938 | properties: { 939 | permission: { 940 | type: "object", 941 | description: "The permission to check", 942 | deprecated: true, 943 | }, 944 | permissions: { 945 | type: "object", 946 | description: "The permission to check", 947 | }, 948 | }, 949 | required: ["permissions"], 950 | }, 951 | }, 952 | }, 953 | }, 954 | responses: { 955 | "200": { 956 | description: "Success", 957 | content: { 958 | "application/json": { 959 | schema: { 960 | type: "object", 961 | properties: { 962 | error: { 963 | type: "string", 964 | }, 965 | success: { 966 | type: "boolean", 967 | }, 968 | }, 969 | required: ["success"], 970 | }, 971 | }, 972 | }, 973 | }, 974 | }, 975 | }, 976 | }, 977 | }, 978 | async (ctx) => { 979 | const activeOrganizationId = 980 | ctx.body.organizationId || 981 | ctx.context.session.session.activeOrganizationId; 982 | if (!activeOrganizationId) { 983 | throw new APIError("BAD_REQUEST", { 984 | message: ORGANIZATION_ERROR_CODES.NO_ACTIVE_ORGANIZATION, 985 | }); 986 | } 987 | const adapter = getOrgAdapter<O>(ctx.context, options); 988 | const member = await adapter.findMemberByOrgId({ 989 | userId: ctx.context.session.user.id, 990 | organizationId: activeOrganizationId, 991 | }); 992 | if (!member) { 993 | throw new APIError("UNAUTHORIZED", { 994 | message: 995 | ORGANIZATION_ERROR_CODES.USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, 996 | }); 997 | } 998 | const result = await hasPermission( 999 | { 1000 | role: member.role, 1001 | options: options || {}, 1002 | permissions: (ctx.body.permissions ?? ctx.body.permission) as any, 1003 | organizationId: activeOrganizationId, 1004 | }, 1005 | ctx, 1006 | ); 1007 | 1008 | return ctx.json({ 1009 | error: null, 1010 | success: result, 1011 | }); 1012 | }, 1013 | ), 1014 | }, 1015 | schema: { 1016 | ...(schema as BetterAuthPluginDBSchema), 1017 | session: { 1018 | fields: { 1019 | activeOrganizationId: { 1020 | type: "string", 1021 | required: false, 1022 | fieldName: options?.schema?.session?.fields?.activeOrganizationId, 1023 | }, 1024 | ...(teamSupport 1025 | ? { 1026 | activeTeamId: { 1027 | type: "string", 1028 | required: false, 1029 | fieldName: options?.schema?.session?.fields?.activeTeamId, 1030 | }, 1031 | } 1032 | : {}), 1033 | } as unknown as O["teams"] extends { 1034 | enabled: true; 1035 | } 1036 | ? { 1037 | activeTeamId: { 1038 | type: "string"; 1039 | required: false; 1040 | }; 1041 | activeOrganizationId: { 1042 | type: "string"; 1043 | required: false; 1044 | }; 1045 | } 1046 | : { 1047 | activeOrganizationId: { 1048 | type: "string"; 1049 | required: false; 1050 | }; 1051 | }, 1052 | }, 1053 | }, 1054 | $Infer: { 1055 | Organization: {} as InferOrganization<O>, 1056 | Invitation: {} as InferInvitation<O>, 1057 | Member: {} as InferMember<O>, 1058 | Team: teamSupport ? ({} as Team) : ({} as any), 1059 | TeamMember: teamSupport ? ({} as TeamMember) : ({} as any), 1060 | ActiveOrganization: {} as Awaited< 1061 | ReturnType<ReturnType<typeof getFullOrganization<O>>> 1062 | >, 1063 | }, 1064 | $ERROR_CODES: ORGANIZATION_ERROR_CODES, 1065 | options: options as O, 1066 | } satisfies BetterAuthPlugin; 1067 | }; 1068 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/generic-oauth/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import { APIError } from "better-call"; 3 | import { decodeJwt } from "jose"; 4 | import * as z from "zod"; 5 | import { createAuthEndpoint } from "@better-auth/core/middleware"; 6 | import { setSessionCookie } from "../../cookies"; 7 | import { BASE_ERROR_CODES } from "@better-auth/core/error"; 8 | import { 9 | createAuthorizationURL, 10 | validateAuthorizationCode, 11 | } from "@better-auth/core/oauth2"; 12 | import type { 13 | OAuth2Tokens, 14 | OAuth2UserInfo, 15 | OAuthProvider, 16 | } from "@better-auth/core/oauth2"; 17 | import { handleOAuthUserInfo } from "../../oauth2/link-account"; 18 | import { refreshAccessToken } from "@better-auth/core/oauth2"; 19 | import { generateState, parseState } from "../../oauth2/state"; 20 | import type { User } from "../../types"; 21 | import type { BetterAuthPlugin } from "@better-auth/core"; 22 | import type { GenericEndpointContext } from "@better-auth/core"; 23 | import { sessionMiddleware } from "../../api"; 24 | import { defineErrorCodes } from "@better-auth/core/utils"; 25 | 26 | /** 27 | * Configuration interface for generic OAuth providers. 28 | */ 29 | export interface GenericOAuthConfig { 30 | /** Unique identifier for the OAuth provider */ 31 | providerId: string; 32 | /** 33 | * URL to fetch OAuth 2.0 configuration. 34 | * If provided, the authorization and token endpoints will be fetched from this URL. 35 | */ 36 | discoveryUrl?: string; 37 | /** 38 | * URL for the authorization endpoint. 39 | * Optional if using discoveryUrl. 40 | */ 41 | authorizationUrl?: string; 42 | /** 43 | * URL for the token endpoint. 44 | * Optional if using discoveryUrl. 45 | */ 46 | tokenUrl?: string; 47 | /** 48 | * URL for the user info endpoint. 49 | * Optional if using discoveryUrl. 50 | */ 51 | userInfoUrl?: string; 52 | /** OAuth client ID */ 53 | clientId: string; 54 | /** OAuth client secret */ 55 | clientSecret?: string; 56 | /** 57 | * Array of OAuth scopes to request. 58 | * @default [] 59 | */ 60 | scopes?: string[]; 61 | /** 62 | * Custom redirect URI. 63 | * If not provided, a default URI will be constructed. 64 | */ 65 | redirectURI?: string; 66 | /** 67 | * OAuth response type. 68 | * @default "code" 69 | */ 70 | responseType?: string; 71 | /** 72 | * The response mode to use for the authorization code request. 73 | 74 | */ 75 | responseMode?: "query" | "form_post"; 76 | /** 77 | * Prompt parameter for the authorization request. 78 | * Controls the authentication experience for the user. 79 | */ 80 | prompt?: "none" | "login" | "consent" | "select_account"; 81 | /** 82 | * Whether to use PKCE (Proof Key for Code Exchange) 83 | * @default false 84 | */ 85 | pkce?: boolean; 86 | /** 87 | * Access type for the authorization request. 88 | * Use "offline" to request a refresh token. 89 | */ 90 | accessType?: string; 91 | /** 92 | * Custom function to fetch user info. 93 | * If provided, this function will be used instead of the default user info fetching logic. 94 | * @param tokens - The OAuth tokens received after successful authentication 95 | * @returns A promise that resolves to a User object or null 96 | */ 97 | getUserInfo?: (tokens: OAuth2Tokens) => Promise<OAuth2UserInfo | null>; 98 | /** 99 | * Custom function to map the user profile to a User object. 100 | */ 101 | mapProfileToUser?: ( 102 | profile: Record<string, any>, 103 | ) => Partial<Partial<User>> | Promise<Partial<User>>; 104 | /** 105 | * Additional search-params to add to the authorizationUrl. 106 | * Warning: Search-params added here overwrite any default params. 107 | */ 108 | authorizationUrlParams?: 109 | | Record<string, string> 110 | | ((ctx: GenericEndpointContext) => Record<string, string>); 111 | /** 112 | * Additional search-params to add to the tokenUrl. 113 | * Warning: Search-params added here overwrite any default params. 114 | */ 115 | tokenUrlParams?: 116 | | Record<string, string> 117 | | ((ctx: GenericEndpointContext) => Record<string, string>); 118 | /** 119 | * Disable implicit sign up for new users. When set to true for the provider, 120 | * sign-in need to be called with with requestSignUp as true to create new users. 121 | */ 122 | disableImplicitSignUp?: boolean; 123 | /** 124 | * Disable sign up for new users. 125 | */ 126 | disableSignUp?: boolean; 127 | /** 128 | * Authentication method for token requests. 129 | * @default "post" 130 | */ 131 | authentication?: "basic" | "post"; 132 | /** 133 | * Custom headers to include in the discovery request. 134 | * Useful for providers like Epic that require specific headers (e.g., Epic-Client-ID). 135 | */ 136 | discoveryHeaders?: Record<string, string>; 137 | /** 138 | * Custom headers to include in the authorization request. 139 | * Useful for providers like Qonto that require specific headers (e.g., X-Qonto-Staging-Token for local development). 140 | */ 141 | authorizationHeaders?: Record<string, string>; 142 | /** 143 | * Override user info with the provider info. 144 | * 145 | * This will update the user info with the provider info, 146 | * when the user signs in with the provider. 147 | * @default false 148 | */ 149 | overrideUserInfo?: boolean; 150 | } 151 | 152 | interface GenericOAuthOptions { 153 | /** 154 | * Array of OAuth provider configurations. 155 | */ 156 | config: GenericOAuthConfig[]; 157 | } 158 | 159 | async function getUserInfo( 160 | tokens: OAuth2Tokens, 161 | finalUserInfoUrl: string | undefined, 162 | ): Promise<OAuth2UserInfo | null> { 163 | if (tokens.idToken) { 164 | const decoded = decodeJwt(tokens.idToken) as { 165 | sub: string; 166 | email_verified: boolean; 167 | email: string; 168 | name: string; 169 | picture: string; 170 | }; 171 | if (decoded) { 172 | if (decoded.sub && decoded.email) { 173 | return { 174 | id: decoded.sub, 175 | emailVerified: decoded.email_verified, 176 | image: decoded.picture, 177 | ...decoded, 178 | }; 179 | } 180 | } 181 | } 182 | 183 | if (!finalUserInfoUrl) { 184 | return null; 185 | } 186 | 187 | const userInfo = await betterFetch<{ 188 | email: string; 189 | sub?: string; 190 | name: string; 191 | email_verified: boolean; 192 | picture: string; 193 | }>(finalUserInfoUrl, { 194 | method: "GET", 195 | headers: { 196 | Authorization: `Bearer ${tokens.accessToken}`, 197 | }, 198 | }); 199 | return { 200 | // @ts-expect-error sub is optional in the type 201 | id: userInfo.data?.sub, 202 | emailVerified: userInfo.data?.email_verified ?? false, 203 | email: userInfo.data?.email, 204 | image: userInfo.data?.picture, 205 | name: userInfo.data?.name, 206 | ...userInfo.data, 207 | }; 208 | } 209 | 210 | const ERROR_CODES = defineErrorCodes({ 211 | INVALID_OAUTH_CONFIGURATION: "Invalid OAuth configuration", 212 | }); 213 | 214 | /** 215 | * A generic OAuth plugin that can be used to add OAuth support to any provider 216 | */ 217 | export const genericOAuth = (options: GenericOAuthOptions) => { 218 | return { 219 | id: "generic-oauth", 220 | init: (ctx) => { 221 | const genericProviders = options.config.map((c) => { 222 | let finalUserInfoUrl = c.userInfoUrl; 223 | return { 224 | id: c.providerId, 225 | name: c.providerId, 226 | createAuthorizationURL(data) { 227 | return createAuthorizationURL({ 228 | id: c.providerId, 229 | options: { 230 | clientId: c.clientId, 231 | clientSecret: c.clientSecret, 232 | redirectURI: c.redirectURI, 233 | }, 234 | authorizationEndpoint: c.authorizationUrl!, 235 | state: data.state, 236 | codeVerifier: c.pkce ? data.codeVerifier : undefined, 237 | scopes: c.scopes || [], 238 | redirectURI: `${ctx.baseURL}/oauth2/callback/${c.providerId}`, 239 | }); 240 | }, 241 | async validateAuthorizationCode(data) { 242 | let finalTokenUrl = c.tokenUrl; 243 | if (c.discoveryUrl) { 244 | const discovery = await betterFetch<{ 245 | token_endpoint: string; 246 | userinfo_endpoint: string; 247 | }>(c.discoveryUrl, { 248 | method: "GET", 249 | headers: c.discoveryHeaders, 250 | }); 251 | if (discovery.data) { 252 | finalTokenUrl = discovery.data.token_endpoint; 253 | finalUserInfoUrl = discovery.data.userinfo_endpoint; 254 | } 255 | } 256 | if (!finalTokenUrl) { 257 | throw new APIError("BAD_REQUEST", { 258 | message: "Invalid OAuth configuration. Token URL not found.", 259 | }); 260 | } 261 | return validateAuthorizationCode({ 262 | headers: c.authorizationHeaders, 263 | code: data.code, 264 | codeVerifier: data.codeVerifier, 265 | redirectURI: data.redirectURI, 266 | options: { 267 | clientId: c.clientId, 268 | clientSecret: c.clientSecret, 269 | redirectURI: c.redirectURI, 270 | }, 271 | tokenEndpoint: finalTokenUrl, 272 | authentication: c.authentication, 273 | }); 274 | }, 275 | async refreshAccessToken( 276 | refreshToken: string, 277 | ): Promise<OAuth2Tokens> { 278 | let finalTokenUrl = c.tokenUrl; 279 | if (c.discoveryUrl) { 280 | const discovery = await betterFetch<{ 281 | token_endpoint: string; 282 | }>(c.discoveryUrl, { 283 | method: "GET", 284 | headers: c.discoveryHeaders, 285 | }); 286 | if (discovery.data) { 287 | finalTokenUrl = discovery.data.token_endpoint; 288 | } 289 | } 290 | if (!finalTokenUrl) { 291 | throw new APIError("BAD_REQUEST", { 292 | message: "Invalid OAuth configuration. Token URL not found.", 293 | }); 294 | } 295 | return refreshAccessToken({ 296 | refreshToken, 297 | options: { 298 | clientId: c.clientId, 299 | clientSecret: c.clientSecret, 300 | }, 301 | authentication: c.authentication, 302 | tokenEndpoint: finalTokenUrl, 303 | }); 304 | }, 305 | 306 | async getUserInfo(tokens) { 307 | const userInfo = c.getUserInfo 308 | ? await c.getUserInfo(tokens) 309 | : await getUserInfo(tokens, finalUserInfoUrl); 310 | if (!userInfo) { 311 | return null; 312 | } 313 | return { 314 | user: { 315 | id: userInfo?.id, 316 | email: userInfo?.email, 317 | emailVerified: userInfo?.emailVerified, 318 | image: userInfo?.image, 319 | name: userInfo?.name, 320 | ...c.mapProfileToUser?.(userInfo), 321 | }, 322 | data: userInfo, 323 | }; 324 | }, 325 | } as OAuthProvider; 326 | }); 327 | return { 328 | context: { 329 | socialProviders: genericProviders.concat(ctx.socialProviders), 330 | }, 331 | }; 332 | }, 333 | endpoints: { 334 | /** 335 | * ### Endpoint 336 | * 337 | * POST `/sign-in/oauth2` 338 | * 339 | * ### API Methods 340 | * 341 | * **server:** 342 | * `auth.api.signInWithOAuth2` 343 | * 344 | * **client:** 345 | * `authClient.signIn.oauth2` 346 | * 347 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/sign-in#api-method-sign-in-oauth2) 348 | */ 349 | signInWithOAuth2: createAuthEndpoint( 350 | "/sign-in/oauth2", 351 | { 352 | method: "POST", 353 | body: z.object({ 354 | providerId: z.string().meta({ 355 | description: "The provider ID for the OAuth provider", 356 | }), 357 | callbackURL: z 358 | .string() 359 | .meta({ 360 | description: "The URL to redirect to after sign in", 361 | }) 362 | .optional(), 363 | errorCallbackURL: z 364 | .string() 365 | .meta({ 366 | description: "The URL to redirect to if an error occurs", 367 | }) 368 | .optional(), 369 | newUserCallbackURL: z 370 | .string() 371 | .meta({ 372 | description: 373 | 'The URL to redirect to after login if the user is new. Eg: "/welcome"', 374 | }) 375 | .optional(), 376 | disableRedirect: z 377 | .boolean() 378 | .meta({ 379 | description: "Disable redirect", 380 | }) 381 | .optional(), 382 | scopes: z 383 | .array(z.string()) 384 | .meta({ 385 | description: 386 | "Scopes to be passed to the provider authorization request.", 387 | }) 388 | .optional(), 389 | requestSignUp: z 390 | .boolean() 391 | .meta({ 392 | description: 393 | "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider. Eg: false", 394 | }) 395 | .optional(), 396 | }), 397 | metadata: { 398 | openapi: { 399 | description: "Sign in with OAuth2", 400 | responses: { 401 | 200: { 402 | description: "Sign in with OAuth2", 403 | content: { 404 | "application/json": { 405 | schema: { 406 | type: "object", 407 | properties: { 408 | url: { 409 | type: "string", 410 | }, 411 | redirect: { 412 | type: "boolean", 413 | }, 414 | }, 415 | }, 416 | }, 417 | }, 418 | }, 419 | }, 420 | }, 421 | }, 422 | }, 423 | async (ctx) => { 424 | const { providerId } = ctx.body; 425 | const config = options.config.find( 426 | (c) => c.providerId === providerId, 427 | ); 428 | if (!config) { 429 | throw new APIError("BAD_REQUEST", { 430 | message: `No config found for provider ${providerId}`, 431 | }); 432 | } 433 | const { 434 | discoveryUrl, 435 | authorizationUrl, 436 | tokenUrl, 437 | clientId, 438 | clientSecret, 439 | scopes, 440 | redirectURI, 441 | responseType, 442 | pkce, 443 | prompt, 444 | accessType, 445 | authorizationUrlParams, 446 | responseMode, 447 | authentication, 448 | } = config; 449 | let finalAuthUrl = authorizationUrl; 450 | let finalTokenUrl = tokenUrl; 451 | if (discoveryUrl) { 452 | const discovery = await betterFetch<{ 453 | authorization_endpoint: string; 454 | token_endpoint: string; 455 | }>(discoveryUrl, { 456 | method: "GET", 457 | headers: config.discoveryHeaders, 458 | onError(context) { 459 | ctx.context.logger.error(context.error.message, context.error, { 460 | discoveryUrl, 461 | }); 462 | }, 463 | }); 464 | if (discovery.data) { 465 | finalAuthUrl = discovery.data.authorization_endpoint; 466 | finalTokenUrl = discovery.data.token_endpoint; 467 | } 468 | } 469 | if (!finalAuthUrl || !finalTokenUrl) { 470 | throw new APIError("BAD_REQUEST", { 471 | message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION, 472 | }); 473 | } 474 | if (authorizationUrlParams) { 475 | const withAdditionalParams = new URL(finalAuthUrl); 476 | for (const [paramName, paramValue] of Object.entries( 477 | authorizationUrlParams, 478 | )) { 479 | withAdditionalParams.searchParams.set(paramName, paramValue); 480 | } 481 | finalAuthUrl = withAdditionalParams.toString(); 482 | } 483 | const additionalParams = 484 | typeof authorizationUrlParams === "function" 485 | ? authorizationUrlParams(ctx) 486 | : authorizationUrlParams; 487 | 488 | const { state, codeVerifier } = await generateState(ctx); 489 | const authUrl = await createAuthorizationURL({ 490 | id: providerId, 491 | options: { 492 | clientId, 493 | clientSecret, 494 | redirectURI, 495 | }, 496 | authorizationEndpoint: finalAuthUrl, 497 | state, 498 | codeVerifier: pkce ? codeVerifier : undefined, 499 | scopes: ctx.body.scopes 500 | ? [...ctx.body.scopes, ...(scopes || [])] 501 | : scopes || [], 502 | redirectURI: `${ctx.context.baseURL}/oauth2/callback/${providerId}`, 503 | prompt, 504 | accessType, 505 | responseType, 506 | responseMode, 507 | additionalParams, 508 | }); 509 | return ctx.json({ 510 | url: authUrl.toString(), 511 | redirect: !ctx.body.disableRedirect, 512 | }); 513 | }, 514 | ), 515 | oAuth2Callback: createAuthEndpoint( 516 | "/oauth2/callback/:providerId", 517 | { 518 | method: "GET", 519 | query: z.object({ 520 | code: z 521 | .string() 522 | .meta({ 523 | description: "The OAuth2 code", 524 | }) 525 | .optional(), 526 | error: z 527 | .string() 528 | .meta({ 529 | description: "The error message, if any", 530 | }) 531 | .optional(), 532 | error_description: z 533 | .string() 534 | .meta({ 535 | description: "The error description, if any", 536 | }) 537 | .optional(), 538 | state: z 539 | .string() 540 | .meta({ 541 | description: "The state parameter from the OAuth2 request", 542 | }) 543 | .optional(), 544 | }), 545 | metadata: { 546 | client: false, 547 | openapi: { 548 | description: "OAuth2 callback", 549 | responses: { 550 | 200: { 551 | description: "OAuth2 callback", 552 | content: { 553 | "application/json": { 554 | schema: { 555 | type: "object", 556 | properties: { 557 | url: { 558 | type: "string", 559 | }, 560 | }, 561 | }, 562 | }, 563 | }, 564 | }, 565 | }, 566 | }, 567 | }, 568 | }, 569 | async (ctx) => { 570 | const defaultErrorURL = 571 | ctx.context.options.onAPIError?.errorURL || 572 | `${ctx.context.baseURL}/error`; 573 | if (ctx.query.error || !ctx.query.code) { 574 | throw ctx.redirect( 575 | `${defaultErrorURL}?error=${ 576 | ctx.query.error || "oAuth_code_missing" 577 | }&error_description=${ctx.query.error_description}`, 578 | ); 579 | } 580 | const provider = options.config.find( 581 | (p) => p.providerId === ctx.params.providerId, 582 | ); 583 | 584 | if (!provider) { 585 | throw new APIError("BAD_REQUEST", { 586 | message: `No config found for provider ${ctx.params.providerId}`, 587 | }); 588 | } 589 | let tokens: OAuth2Tokens | undefined = undefined; 590 | const parsedState = await parseState(ctx); 591 | 592 | const { 593 | callbackURL, 594 | codeVerifier, 595 | errorURL, 596 | requestSignUp, 597 | newUserURL, 598 | link, 599 | } = parsedState; 600 | const code = ctx.query.code; 601 | 602 | function redirectOnError(error: string) { 603 | const defaultErrorURL = 604 | ctx.context.options.onAPIError?.errorURL || 605 | `${ctx.context.baseURL}/error`; 606 | let url = errorURL || defaultErrorURL; 607 | if (url.includes("?")) { 608 | url = `${url}&error=${error}`; 609 | } else { 610 | url = `${url}?error=${error}`; 611 | } 612 | throw ctx.redirect(url); 613 | } 614 | 615 | let finalTokenUrl = provider.tokenUrl; 616 | let finalUserInfoUrl = provider.userInfoUrl; 617 | if (provider.discoveryUrl) { 618 | const discovery = await betterFetch<{ 619 | token_endpoint: string; 620 | userinfo_endpoint: string; 621 | }>(provider.discoveryUrl, { 622 | method: "GET", 623 | headers: provider.discoveryHeaders, 624 | }); 625 | if (discovery.data) { 626 | finalTokenUrl = discovery.data.token_endpoint; 627 | finalUserInfoUrl = discovery.data.userinfo_endpoint; 628 | } 629 | } 630 | try { 631 | if (!finalTokenUrl) { 632 | throw new APIError("BAD_REQUEST", { 633 | message: "Invalid OAuth configuration.", 634 | }); 635 | } 636 | const additionalParams = 637 | typeof provider.tokenUrlParams === "function" 638 | ? provider.tokenUrlParams(ctx) 639 | : provider.tokenUrlParams; 640 | tokens = await validateAuthorizationCode({ 641 | headers: provider.authorizationHeaders, 642 | code, 643 | codeVerifier: provider.pkce ? codeVerifier : undefined, 644 | redirectURI: `${ctx.context.baseURL}/oauth2/callback/${provider.providerId}`, 645 | options: { 646 | clientId: provider.clientId, 647 | clientSecret: provider.clientSecret, 648 | redirectURI: provider.redirectURI, 649 | }, 650 | tokenEndpoint: finalTokenUrl, 651 | authentication: provider.authentication, 652 | additionalParams, 653 | }); 654 | } catch (e) { 655 | ctx.context.logger.error( 656 | e && typeof e === "object" && "name" in e 657 | ? (e.name as string) 658 | : "", 659 | e, 660 | ); 661 | throw redirectOnError("oauth_code_verification_failed"); 662 | } 663 | 664 | if (!tokens) { 665 | throw new APIError("BAD_REQUEST", { 666 | message: "Invalid OAuth configuration.", 667 | }); 668 | } 669 | const userInfo: Omit<User, "createdAt" | "updatedAt"> = 670 | await (async function handleUserInfo() { 671 | const userInfo = ( 672 | provider.getUserInfo 673 | ? await provider.getUserInfo(tokens) 674 | : await getUserInfo(tokens, finalUserInfoUrl) 675 | ) as OAuth2UserInfo | null; 676 | if (!userInfo) { 677 | throw redirectOnError("user_info_is_missing"); 678 | } 679 | const mapUser = provider.mapProfileToUser 680 | ? await provider.mapProfileToUser(userInfo) 681 | : userInfo; 682 | const email = mapUser.email 683 | ? mapUser.email.toLowerCase() 684 | : userInfo.email?.toLowerCase(); 685 | if (!email) { 686 | ctx.context.logger.error("Unable to get user info", userInfo); 687 | throw redirectOnError("email_is_missing"); 688 | } 689 | const id = mapUser.id ? String(mapUser.id) : String(userInfo.id); 690 | const name = mapUser.name ? mapUser.name : userInfo.name; 691 | if (!name) { 692 | ctx.context.logger.error("Unable to get user info", userInfo); 693 | throw redirectOnError("name_is_missing"); 694 | } 695 | return { 696 | ...userInfo, 697 | ...mapUser, 698 | email, 699 | id, 700 | name, 701 | }; 702 | })(); 703 | if (link) { 704 | if ( 705 | ctx.context.options.account?.accountLinking 706 | ?.allowDifferentEmails !== true && 707 | link.email !== userInfo.email 708 | ) { 709 | return redirectOnError("email_doesn't_match"); 710 | } 711 | const existingAccount = 712 | await ctx.context.internalAdapter.findAccountByProviderId( 713 | String(userInfo.id), 714 | provider.providerId, 715 | ); 716 | if (existingAccount) { 717 | if (existingAccount.userId !== link.userId) { 718 | return redirectOnError( 719 | "account_already_linked_to_different_user", 720 | ); 721 | } 722 | const updateData = Object.fromEntries( 723 | Object.entries({ 724 | accessToken: tokens.accessToken, 725 | idToken: tokens.idToken, 726 | refreshToken: tokens.refreshToken, 727 | accessTokenExpiresAt: tokens.accessTokenExpiresAt, 728 | refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, 729 | scope: tokens.scopes?.join(","), 730 | }).filter(([_, value]) => value !== undefined), 731 | ); 732 | await ctx.context.internalAdapter.updateAccount( 733 | existingAccount.id, 734 | updateData, 735 | ); 736 | } else { 737 | const newAccount = 738 | await ctx.context.internalAdapter.createAccount({ 739 | userId: link.userId, 740 | providerId: provider.providerId, 741 | accountId: userInfo.id, 742 | accessToken: tokens.accessToken, 743 | accessTokenExpiresAt: tokens.accessTokenExpiresAt, 744 | refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, 745 | scope: tokens.scopes?.join(","), 746 | refreshToken: tokens.refreshToken, 747 | idToken: tokens.idToken, 748 | }); 749 | if (!newAccount) { 750 | return redirectOnError("unable_to_link_account"); 751 | } 752 | } 753 | let toRedirectTo: string; 754 | try { 755 | const url = callbackURL; 756 | toRedirectTo = url.toString(); 757 | } catch { 758 | toRedirectTo = callbackURL; 759 | } 760 | throw ctx.redirect(toRedirectTo); 761 | } 762 | 763 | const result = await handleOAuthUserInfo(ctx, { 764 | userInfo, 765 | account: { 766 | providerId: provider.providerId, 767 | accountId: userInfo.id, 768 | ...tokens, 769 | scope: tokens.scopes?.join(","), 770 | }, 771 | callbackURL: callbackURL, 772 | disableSignUp: 773 | (provider.disableImplicitSignUp && !requestSignUp) || 774 | provider.disableSignUp, 775 | overrideUserInfo: provider.overrideUserInfo, 776 | }); 777 | 778 | if (result.error) { 779 | return redirectOnError(result.error.split(" ").join("_")); 780 | } 781 | const { session, user } = result.data!; 782 | await setSessionCookie(ctx, { 783 | session, 784 | user, 785 | }); 786 | let toRedirectTo: string; 787 | try { 788 | const url = result.isRegister 789 | ? newUserURL || callbackURL 790 | : callbackURL; 791 | toRedirectTo = url.toString(); 792 | } catch { 793 | toRedirectTo = result.isRegister 794 | ? newUserURL || callbackURL 795 | : callbackURL; 796 | } 797 | throw ctx.redirect(toRedirectTo); 798 | }, 799 | ), 800 | /** 801 | * ### Endpoint 802 | * 803 | * POST `/oauth2/link` 804 | * 805 | * ### API Methods 806 | * 807 | * **server:** 808 | * `auth.api.oAuth2LinkAccount` 809 | * 810 | * **client:** 811 | * `authClient.oauth2.link` 812 | * 813 | * @see [Read our docs to learn more.](https://better-auth.com/docs/plugins/generic-oauth#api-method-oauth2-link) 814 | */ 815 | oAuth2LinkAccount: createAuthEndpoint( 816 | "/oauth2/link", 817 | { 818 | method: "POST", 819 | body: z.object({ 820 | providerId: z.string(), 821 | /** 822 | * Callback URL to redirect to after the user has signed in. 823 | */ 824 | callbackURL: z.string(), 825 | /** 826 | * Additional scopes to request when linking the account. 827 | * This is useful for requesting additional permissions when 828 | * linking a social account compared to the initial authentication. 829 | */ 830 | scopes: z 831 | .array(z.string()) 832 | .meta({ 833 | description: 834 | "Additional scopes to request when linking the account", 835 | }) 836 | .optional(), 837 | /** 838 | * The URL to redirect to if there is an error during the link process. 839 | */ 840 | errorCallbackURL: z 841 | .string() 842 | .meta({ 843 | description: 844 | "The URL to redirect to if there is an error during the link process", 845 | }) 846 | .optional(), 847 | }), 848 | use: [sessionMiddleware], 849 | metadata: { 850 | openapi: { 851 | description: "Link an OAuth2 account to the current user session", 852 | responses: { 853 | "200": { 854 | description: 855 | "Authorization URL generated successfully for linking an OAuth2 account", 856 | content: { 857 | "application/json": { 858 | schema: { 859 | type: "object", 860 | properties: { 861 | url: { 862 | type: "string", 863 | format: "uri", 864 | description: 865 | "The authorization URL to redirect the user to for linking the OAuth2 account", 866 | }, 867 | redirect: { 868 | type: "boolean", 869 | description: 870 | "Indicates that the client should redirect to the provided URL", 871 | enum: [true], 872 | }, 873 | }, 874 | required: ["url", "redirect"], 875 | }, 876 | }, 877 | }, 878 | }, 879 | }, 880 | }, 881 | }, 882 | }, 883 | async (c) => { 884 | const session = c.context.session; 885 | const provider = options.config.find( 886 | (p) => p.providerId === c.body.providerId, 887 | ); 888 | if (!provider) { 889 | throw new APIError("NOT_FOUND", { 890 | message: BASE_ERROR_CODES.PROVIDER_NOT_FOUND, 891 | }); 892 | } 893 | const { 894 | providerId, 895 | clientId, 896 | clientSecret, 897 | redirectURI, 898 | authorizationUrl, 899 | discoveryUrl, 900 | pkce, 901 | scopes, 902 | prompt, 903 | accessType, 904 | authorizationUrlParams, 905 | } = provider; 906 | 907 | let finalAuthUrl = authorizationUrl; 908 | if (!finalAuthUrl) { 909 | if (!discoveryUrl) { 910 | throw new APIError("BAD_REQUEST", { 911 | message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION, 912 | }); 913 | } 914 | const discovery = await betterFetch<{ 915 | authorization_endpoint: string; 916 | token_endpoint: string; 917 | }>(discoveryUrl, { 918 | method: "GET", 919 | headers: provider.discoveryHeaders, 920 | onError(context) { 921 | c.context.logger.error(context.error.message, context.error, { 922 | discoveryUrl, 923 | }); 924 | }, 925 | }); 926 | if (discovery.data) { 927 | finalAuthUrl = discovery.data.authorization_endpoint; 928 | } 929 | } 930 | 931 | if (!finalAuthUrl) { 932 | throw new APIError("BAD_REQUEST", { 933 | message: ERROR_CODES.INVALID_OAUTH_CONFIGURATION, 934 | }); 935 | } 936 | 937 | const state = await generateState(c, { 938 | userId: session.user.id, 939 | email: session.user.email, 940 | }); 941 | 942 | const additionalParams = 943 | typeof authorizationUrlParams === "function" 944 | ? authorizationUrlParams(c) 945 | : authorizationUrlParams; 946 | 947 | const url = await createAuthorizationURL({ 948 | id: providerId, 949 | options: { 950 | clientId, 951 | clientSecret, 952 | redirectURI: 953 | redirectURI || 954 | `${c.context.baseURL}/oauth2/callback/${providerId}`, 955 | }, 956 | authorizationEndpoint: finalAuthUrl, 957 | state: state.state, 958 | codeVerifier: pkce ? state.codeVerifier : undefined, 959 | scopes: c.body.scopes || scopes || [], 960 | redirectURI: 961 | redirectURI || 962 | `${c.context.baseURL}/oauth2/callback/${providerId}`, 963 | prompt, 964 | accessType, 965 | additionalParams, 966 | }); 967 | 968 | return c.json({ 969 | url: url.toString(), 970 | redirect: true, 971 | }); 972 | }, 973 | ), 974 | }, 975 | $ERROR_CODES: ERROR_CODES, 976 | } satisfies BetterAuthPlugin; 977 | }; 978 | ```