This is page 37 of 68. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/username/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { 3 | createAuthEndpoint, 4 | createAuthMiddleware, 5 | } from "@better-auth/core/api"; 6 | import type { BetterAuthPlugin } from "@better-auth/core"; 7 | import { APIError } from "better-call"; 8 | import type { Account, InferOptionSchema, User } from "../../types"; 9 | import { setSessionCookie } from "../../cookies"; 10 | import { BASE_ERROR_CODES } from "@better-auth/core/error"; 11 | import { getSchema, type UsernameSchema } from "./schema"; 12 | import { mergeSchema } from "../../db"; 13 | import { USERNAME_ERROR_CODES as ERROR_CODES } from "./error-codes"; 14 | import { createEmailVerificationToken } from "../../api"; 15 | 16 | export { USERNAME_ERROR_CODES } from "./error-codes"; 17 | 18 | export type UsernameOptions = { 19 | schema?: InferOptionSchema<UsernameSchema>; 20 | /** 21 | * The minimum length of the username 22 | * 23 | * @default 3 24 | */ 25 | minUsernameLength?: number; 26 | /** 27 | * The maximum length of the username 28 | * 29 | * @default 30 30 | */ 31 | maxUsernameLength?: number; 32 | /** 33 | * A function to validate the username 34 | * 35 | * By default, the username should only contain alphanumeric characters and underscores 36 | */ 37 | usernameValidator?: (username: string) => boolean | Promise<boolean>; 38 | /** 39 | * A function to validate the display username 40 | * 41 | * By default, no validation is applied to display username 42 | */ 43 | displayUsernameValidator?: ( 44 | displayUsername: string, 45 | ) => boolean | Promise<boolean>; 46 | /** 47 | * A function to normalize the username 48 | * 49 | * @default (username) => username.toLowerCase() 50 | */ 51 | usernameNormalization?: ((username: string) => string) | false; 52 | /** 53 | * A function to normalize the display username 54 | * 55 | * @default false 56 | */ 57 | displayUsernameNormalization?: ((displayUsername: string) => string) | false; 58 | /** 59 | * The order of validation 60 | * 61 | * @default { username: "pre-normalization", displayUsername: "pre-normalization" } 62 | */ 63 | validationOrder?: { 64 | /** 65 | * The order of username validation 66 | * 67 | * @default "pre-normalization" 68 | */ 69 | username?: "pre-normalization" | "post-normalization"; 70 | /** 71 | * The order of display username validation 72 | * 73 | * @default "pre-normalization" 74 | */ 75 | displayUsername?: "pre-normalization" | "post-normalization"; 76 | }; 77 | }; 78 | 79 | function defaultUsernameValidator(username: string) { 80 | return /^[a-zA-Z0-9_.]+$/.test(username); 81 | } 82 | 83 | export const username = (options?: UsernameOptions) => { 84 | const normalizer = (username: string) => { 85 | if (options?.usernameNormalization === false) { 86 | return username; 87 | } 88 | if (options?.usernameNormalization) { 89 | return options.usernameNormalization(username); 90 | } 91 | return username.toLowerCase(); 92 | }; 93 | 94 | const displayUsernameNormalizer = (displayUsername: string) => { 95 | return options?.displayUsernameNormalization 96 | ? options.displayUsernameNormalization(displayUsername) 97 | : displayUsername; 98 | }; 99 | 100 | return { 101 | id: "username", 102 | init(ctx) { 103 | return { 104 | options: { 105 | databaseHooks: { 106 | user: { 107 | create: { 108 | async before(user, context) { 109 | const username = 110 | "username" in user ? (user.username as string) : null; 111 | const displayUsername = 112 | "displayUsername" in user 113 | ? (user.displayUsername as string) 114 | : null; 115 | 116 | return { 117 | data: { 118 | ...user, 119 | ...(username ? { username: normalizer(username) } : {}), 120 | ...(displayUsername 121 | ? { 122 | displayUsername: 123 | displayUsernameNormalizer(displayUsername), 124 | } 125 | : {}), 126 | }, 127 | }; 128 | }, 129 | }, 130 | update: { 131 | async before(user, context) { 132 | const username = 133 | "username" in user ? (user.username as string) : null; 134 | const displayUsername = 135 | "displayUsername" in user 136 | ? (user.displayUsername as string) 137 | : null; 138 | 139 | return { 140 | data: { 141 | ...user, 142 | ...(username ? { username: normalizer(username) } : {}), 143 | ...(displayUsername 144 | ? { 145 | displayUsername: 146 | displayUsernameNormalizer(displayUsername), 147 | } 148 | : {}), 149 | }, 150 | }; 151 | }, 152 | }, 153 | }, 154 | }, 155 | }, 156 | }; 157 | }, 158 | endpoints: { 159 | signInUsername: createAuthEndpoint( 160 | "/sign-in/username", 161 | { 162 | method: "POST", 163 | body: z.object({ 164 | username: z 165 | .string() 166 | .meta({ description: "The username of the user" }), 167 | password: z 168 | .string() 169 | .meta({ description: "The password of the user" }), 170 | rememberMe: z 171 | .boolean() 172 | .meta({ 173 | description: "Remember the user session", 174 | }) 175 | .optional(), 176 | callbackURL: z 177 | .string() 178 | .meta({ 179 | description: "The URL to redirect to after email verification", 180 | }) 181 | .optional(), 182 | }), 183 | metadata: { 184 | openapi: { 185 | summary: "Sign in with username", 186 | description: "Sign in with username", 187 | responses: { 188 | 200: { 189 | description: "Success", 190 | content: { 191 | "application/json": { 192 | schema: { 193 | type: "object", 194 | properties: { 195 | token: { 196 | type: "string", 197 | description: 198 | "Session token for the authenticated session", 199 | }, 200 | user: { 201 | $ref: "#/components/schemas/User", 202 | }, 203 | }, 204 | required: ["token", "user"], 205 | }, 206 | }, 207 | }, 208 | }, 209 | 422: { 210 | description: "Unprocessable Entity. Validation error", 211 | content: { 212 | "application/json": { 213 | schema: { 214 | type: "object", 215 | properties: { 216 | message: { 217 | type: "string", 218 | }, 219 | }, 220 | }, 221 | }, 222 | }, 223 | }, 224 | }, 225 | }, 226 | }, 227 | }, 228 | async (ctx) => { 229 | if (!ctx.body.username || !ctx.body.password) { 230 | ctx.context.logger.error("Username or password not found"); 231 | throw new APIError("UNAUTHORIZED", { 232 | message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, 233 | }); 234 | } 235 | 236 | const username = 237 | options?.validationOrder?.username === "pre-normalization" 238 | ? normalizer(ctx.body.username) 239 | : ctx.body.username; 240 | 241 | const minUsernameLength = options?.minUsernameLength || 3; 242 | const maxUsernameLength = options?.maxUsernameLength || 30; 243 | 244 | if (username.length < minUsernameLength) { 245 | ctx.context.logger.error("Username too short", { 246 | username, 247 | }); 248 | throw new APIError("UNPROCESSABLE_ENTITY", { 249 | message: ERROR_CODES.USERNAME_TOO_SHORT, 250 | }); 251 | } 252 | 253 | if (username.length > maxUsernameLength) { 254 | ctx.context.logger.error("Username too long", { 255 | username, 256 | }); 257 | throw new APIError("UNPROCESSABLE_ENTITY", { 258 | message: ERROR_CODES.USERNAME_TOO_LONG, 259 | }); 260 | } 261 | 262 | const validator = 263 | options?.usernameValidator || defaultUsernameValidator; 264 | 265 | if (!validator(username)) { 266 | throw new APIError("UNPROCESSABLE_ENTITY", { 267 | message: ERROR_CODES.INVALID_USERNAME, 268 | }); 269 | } 270 | 271 | const user = await ctx.context.adapter.findOne< 272 | User & { username: string; displayUsername: string } 273 | >({ 274 | model: "user", 275 | where: [ 276 | { 277 | field: "username", 278 | value: normalizer(username), 279 | }, 280 | ], 281 | }); 282 | if (!user) { 283 | // Hash password to prevent timing attacks from revealing valid usernames 284 | // By hashing passwords for invalid usernames, we ensure consistent response times 285 | await ctx.context.password.hash(ctx.body.password); 286 | ctx.context.logger.error("User not found", { 287 | username, 288 | }); 289 | throw new APIError("UNAUTHORIZED", { 290 | message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, 291 | }); 292 | } 293 | 294 | const account = await ctx.context.adapter.findOne<Account>({ 295 | model: "account", 296 | where: [ 297 | { 298 | field: "userId", 299 | value: user.id, 300 | }, 301 | { 302 | field: "providerId", 303 | value: "credential", 304 | }, 305 | ], 306 | }); 307 | if (!account) { 308 | throw new APIError("UNAUTHORIZED", { 309 | message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, 310 | }); 311 | } 312 | const currentPassword = account?.password; 313 | if (!currentPassword) { 314 | ctx.context.logger.error("Password not found", { 315 | username, 316 | }); 317 | throw new APIError("UNAUTHORIZED", { 318 | message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, 319 | }); 320 | } 321 | const validPassword = await ctx.context.password.verify({ 322 | hash: currentPassword, 323 | password: ctx.body.password, 324 | }); 325 | if (!validPassword) { 326 | ctx.context.logger.error("Invalid password"); 327 | throw new APIError("UNAUTHORIZED", { 328 | message: ERROR_CODES.INVALID_USERNAME_OR_PASSWORD, 329 | }); 330 | } 331 | 332 | if ( 333 | ctx.context.options?.emailAndPassword?.requireEmailVerification && 334 | !user.emailVerified 335 | ) { 336 | if ( 337 | !ctx.context.options?.emailVerification?.sendVerificationEmail 338 | ) { 339 | throw new APIError("FORBIDDEN", { 340 | message: ERROR_CODES.EMAIL_NOT_VERIFIED, 341 | }); 342 | } 343 | 344 | if (ctx.context.options?.emailVerification?.sendOnSignIn) { 345 | const token = await createEmailVerificationToken( 346 | ctx.context.secret, 347 | user.email, 348 | undefined, 349 | ctx.context.options.emailVerification?.expiresIn, 350 | ); 351 | const url = `${ctx.context.baseURL}/verify-email?token=${token}&callbackURL=${ 352 | ctx.body.callbackURL || "/" 353 | }`; 354 | await ctx.context.options.emailVerification.sendVerificationEmail( 355 | { 356 | user: user, 357 | url, 358 | token, 359 | }, 360 | ctx.request, 361 | ); 362 | } 363 | 364 | throw new APIError("FORBIDDEN", { 365 | message: ERROR_CODES.EMAIL_NOT_VERIFIED, 366 | }); 367 | } 368 | 369 | const session = await ctx.context.internalAdapter.createSession( 370 | user.id, 371 | ctx.body.rememberMe === false, 372 | ); 373 | if (!session) { 374 | return ctx.json(null, { 375 | status: 500, 376 | body: { 377 | message: BASE_ERROR_CODES.FAILED_TO_CREATE_SESSION, 378 | }, 379 | }); 380 | } 381 | await setSessionCookie( 382 | ctx, 383 | { session, user }, 384 | ctx.body.rememberMe === false, 385 | ); 386 | return ctx.json({ 387 | token: session.token, 388 | user: { 389 | id: user.id, 390 | email: user.email, 391 | emailVerified: user.emailVerified, 392 | username: user.username, 393 | displayUsername: user.displayUsername, 394 | name: user.name, 395 | image: user.image, 396 | createdAt: user.createdAt, 397 | updatedAt: user.updatedAt, 398 | }, 399 | }); 400 | }, 401 | ), 402 | isUsernameAvailable: createAuthEndpoint( 403 | "/is-username-available", 404 | { 405 | method: "POST", 406 | body: z.object({ 407 | username: z.string().meta({ 408 | description: "The username to check", 409 | }), 410 | }), 411 | }, 412 | async (ctx) => { 413 | const username = ctx.body.username; 414 | if (!username) { 415 | throw new APIError("UNPROCESSABLE_ENTITY", { 416 | message: ERROR_CODES.INVALID_USERNAME, 417 | }); 418 | } 419 | 420 | const minUsernameLength = options?.minUsernameLength || 3; 421 | const maxUsernameLength = options?.maxUsernameLength || 30; 422 | 423 | if (username.length < minUsernameLength) { 424 | throw new APIError("UNPROCESSABLE_ENTITY", { 425 | message: ERROR_CODES.USERNAME_TOO_SHORT, 426 | }); 427 | } 428 | 429 | if (username.length > maxUsernameLength) { 430 | throw new APIError("UNPROCESSABLE_ENTITY", { 431 | message: ERROR_CODES.USERNAME_TOO_LONG, 432 | }); 433 | } 434 | 435 | const validator = 436 | options?.usernameValidator || defaultUsernameValidator; 437 | 438 | if (!(await validator(username))) { 439 | throw new APIError("UNPROCESSABLE_ENTITY", { 440 | message: ERROR_CODES.INVALID_USERNAME, 441 | }); 442 | } 443 | const user = await ctx.context.adapter.findOne<User>({ 444 | model: "user", 445 | where: [ 446 | { 447 | field: "username", 448 | value: normalizer(username), 449 | }, 450 | ], 451 | }); 452 | if (user) { 453 | return ctx.json({ 454 | available: false, 455 | }); 456 | } 457 | return ctx.json({ 458 | available: true, 459 | }); 460 | }, 461 | ), 462 | }, 463 | schema: mergeSchema( 464 | getSchema({ 465 | username: normalizer, 466 | displayUsername: displayUsernameNormalizer, 467 | }), 468 | options?.schema, 469 | ), 470 | hooks: { 471 | before: [ 472 | { 473 | matcher(context) { 474 | return ( 475 | context.path === "/sign-up/email" || 476 | context.path === "/update-user" 477 | ); 478 | }, 479 | handler: createAuthMiddleware(async (ctx) => { 480 | const username = 481 | typeof ctx.body.username === "string" && 482 | options?.validationOrder?.username === "post-normalization" 483 | ? normalizer(ctx.body.username) 484 | : ctx.body.username; 485 | 486 | if (username !== undefined && typeof username === "string") { 487 | const minUsernameLength = options?.minUsernameLength || 3; 488 | const maxUsernameLength = options?.maxUsernameLength || 30; 489 | if (username.length < minUsernameLength) { 490 | throw new APIError("BAD_REQUEST", { 491 | message: ERROR_CODES.USERNAME_TOO_SHORT, 492 | }); 493 | } 494 | 495 | if (username.length > maxUsernameLength) { 496 | throw new APIError("BAD_REQUEST", { 497 | message: ERROR_CODES.USERNAME_TOO_LONG, 498 | }); 499 | } 500 | 501 | const validator = 502 | options?.usernameValidator || defaultUsernameValidator; 503 | 504 | const valid = await validator(username); 505 | if (!valid) { 506 | throw new APIError("BAD_REQUEST", { 507 | message: ERROR_CODES.INVALID_USERNAME, 508 | }); 509 | } 510 | const user = await ctx.context.adapter.findOne<User>({ 511 | model: "user", 512 | where: [ 513 | { 514 | field: "username", 515 | value: username, 516 | }, 517 | ], 518 | }); 519 | 520 | const blockChangeSignUp = ctx.path === "/sign-up/email" && user; 521 | const blockChangeUpdateUser = 522 | ctx.path === "/update-user" && 523 | user && 524 | ctx.context.session && 525 | user.id !== ctx.context.session.session.userId; 526 | if (blockChangeSignUp || blockChangeUpdateUser) { 527 | throw new APIError("BAD_REQUEST", { 528 | message: ERROR_CODES.USERNAME_IS_ALREADY_TAKEN, 529 | }); 530 | } 531 | } 532 | 533 | const displayUsername = 534 | typeof ctx.body.displayUsername === "string" && 535 | options?.validationOrder?.displayUsername === "post-normalization" 536 | ? displayUsernameNormalizer(ctx.body.displayUsername) 537 | : ctx.body.displayUsername; 538 | 539 | if ( 540 | displayUsername !== undefined && 541 | typeof displayUsername === "string" 542 | ) { 543 | if (options?.displayUsernameValidator) { 544 | const valid = 545 | await options.displayUsernameValidator(displayUsername); 546 | if (!valid) { 547 | throw new APIError("BAD_REQUEST", { 548 | message: ERROR_CODES.INVALID_DISPLAY_USERNAME, 549 | }); 550 | } 551 | } 552 | } 553 | }), 554 | }, 555 | { 556 | matcher(context) { 557 | return ( 558 | context.path === "/sign-up/email" || 559 | context.path === "/update-user" 560 | ); 561 | }, 562 | handler: createAuthMiddleware(async (ctx) => { 563 | if (ctx.body.username && !ctx.body.displayUsername) { 564 | ctx.body.displayUsername = ctx.body.username; 565 | } 566 | if (ctx.body.displayUsername && !ctx.body.username) { 567 | ctx.body.username = ctx.body.displayUsername; 568 | } 569 | }), 570 | }, 571 | ], 572 | }, 573 | $ERROR_CODES: ERROR_CODES, 574 | } satisfies BetterAuthPlugin; 575 | }; 576 | ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/users-accounts.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: User & Accounts 3 | description: User and account management. 4 | --- 5 | 6 | Beyond authenticating users, Better Auth also provides a set of methods to manage users. This includes, updating user information, changing passwords, and more. 7 | 8 | The user table stores the authentication data of the user [Click here to view the schema](/docs/concepts/database#user). 9 | 10 | The user table can be extended using [additional fields](/docs/concepts/database#extending-core-schema) or by plugins to store additional data. 11 | 12 | ## Update User 13 | 14 | ### Update User Information 15 | 16 | To update user information, you can use the `updateUser` function provided by the client. The `updateUser` function takes an object with the following properties: 17 | 18 | ```ts 19 | await authClient.updateUser({ 20 | image: "https://example.com/image.jpg", 21 | name: "John Doe", 22 | }) 23 | ``` 24 | ### Change Email 25 | 26 | To allow users to change their email, first enable the `changeEmail` feature, which is disabled by default. Set `changeEmail.enabled` to `true`: 27 | 28 | ```ts 29 | export const auth = betterAuth({ 30 | user: { 31 | changeEmail: { 32 | enabled: true, 33 | } 34 | } 35 | }) 36 | ``` 37 | 38 | For users with a verified email, provide the `sendChangeEmailVerification` function. This function triggers when a user changes their email, sending a verification email with a URL and token. If the current email isn't verified, the change happens immediately without verification. 39 | 40 | ```ts 41 | export const auth = betterAuth({ 42 | user: { 43 | changeEmail: { 44 | enabled: true, 45 | sendChangeEmailVerification: async ({ user, newEmail, url, token }, request) => { 46 | await sendEmail({ 47 | to: user.email, // verification email must be sent to the current user email to approve the change 48 | subject: 'Approve email change', 49 | text: `Click the link to approve the change: ${url}` 50 | }) 51 | } 52 | } 53 | } 54 | }) 55 | ``` 56 | 57 | Once enabled, use the `changeEmail` function on the client to update a user’s email. The user must verify their current email before changing it. 58 | 59 | ```ts 60 | await authClient.changeEmail({ 61 | newEmail: "[email protected]", 62 | callbackURL: "/dashboard", //to redirect after verification 63 | }); 64 | ``` 65 | 66 | After verification, the new email is updated in the user table, and a confirmation is sent to the new address. 67 | 68 | <Callout type="warn"> 69 | If the current email is unverified, the new email is updated without the verification step. 70 | </Callout> 71 | 72 | ### Change Password 73 | A user's password isn't stored in the user table. Instead, it's stored in the account table. To change the password of a user, you can use one of the following approaches: 74 | 75 | 76 | <APIMethod path="/change-password" method="POST" requireSession> 77 | ```ts 78 | type changePassword = { 79 | /** 80 | * The new password to set 81 | */ 82 | newPassword: string = "newpassword1234" 83 | /** 84 | * The current user password 85 | */ 86 | currentPassword: string = "oldpassword1234" 87 | /** 88 | * When set to true, all other active sessions for this user will be invalidated 89 | */ 90 | revokeOtherSessions?: boolean = true 91 | } 92 | ``` 93 | </APIMethod> 94 | 95 | ### Set Password 96 | 97 | If a user was registered using OAuth or other providers, they won't have a password or a credential account. In this case, you can use the `setPassword` action to set a password for the user. For security reasons, this function can only be called from the server. We recommend having users go through a 'forgot password' flow to set a password for their account. 98 | 99 | ```ts 100 | await auth.api.setPassword({ 101 | body: { newPassword: "password" }, 102 | headers: // headers containing the user's session token 103 | }); 104 | ``` 105 | 106 | ## Delete User 107 | 108 | Better Auth provides a utility to hard delete a user from your database. It's disabled by default, but you can enable it easily by passing `enabled:true` 109 | 110 | ```ts 111 | export const auth = betterAuth({ 112 | //...other config 113 | user: { 114 | deleteUser: { // [!code highlight] 115 | enabled: true // [!code highlight] 116 | } // [!code highlight] 117 | } 118 | }) 119 | ``` 120 | 121 | Once enabled, you can call `authClient.deleteUser` to permanently delete user data from your database. 122 | 123 | ### Adding Verification Before Deletion 124 | 125 | For added security, you’ll likely want to confirm the user’s intent before deleting their account. A common approach is to send a verification email. Better Auth provides a `sendDeleteAccountVerification` utility for this purpose. 126 | This is especially needed if you have OAuth setup and want them to be able to delete their account without forcing them to login again for a fresh session. 127 | 128 | Here’s how you can set it up: 129 | 130 | ```ts 131 | export const auth = betterAuth({ 132 | user: { 133 | deleteUser: { 134 | enabled: true, 135 | sendDeleteAccountVerification: async ( 136 | { 137 | user, // The user object 138 | url, // The auto-generated URL for deletion 139 | token // The verification token (can be used to generate custom URL) 140 | }, 141 | request // The original request object (optional) 142 | ) => { 143 | // Your email sending logic here 144 | // Example: sendEmail(data.user.email, "Verify Deletion", data.url); 145 | }, 146 | }, 147 | }, 148 | }); 149 | ``` 150 | 151 | **How callback verification works:** 152 | 153 | - **Callback URL**: The URL provided in `sendDeleteAccountVerification` is a pre-generated link that deletes the user data when accessed. 154 | 155 | ```ts title="delete-user.ts" 156 | await authClient.deleteUser({ 157 | callbackURL: "/goodbye" // you can provide a callback URL to redirect after deletion 158 | }); 159 | ``` 160 | 161 | - **Authentication Check**: The user must be signed in to the account they’re attempting to delete. 162 | If they aren’t signed in, the deletion process will fail. 163 | 164 | If you have sent a custom URL, you can use the `deleteUser` method with the token to delete the user. 165 | 166 | ```ts title="delete-user.ts" 167 | await authClient.deleteUser({ 168 | token 169 | }); 170 | ``` 171 | 172 | ### Authentication Requirements 173 | 174 | To delete a user, the user must meet one of the following requirements: 175 | 176 | 1. A valid password 177 | 178 | if the user has a password, they can delete their account by providing the password. 179 | 180 | ```ts title="delete-user.ts" 181 | await authClient.deleteUser({ 182 | password: "password" 183 | }); 184 | ``` 185 | 186 | 2. Fresh session 187 | 188 | The user must have a `fresh` session token, meaning the user must have signed in recently. This is checked if the password is not provided. 189 | 190 | <Callout type="warn"> 191 | By default `session.freshAge` is set to `60 * 60 * 24` (1 day). You can change this value by passing the `session` object to the `auth` configuration. If it is set to `0`, the freshness check is disabled. It is recommended not to disable this check if you are not using email verification for deleting the account. 192 | </Callout> 193 | 194 | ```ts title="delete-user.ts" 195 | await authClient.deleteUser(); 196 | ``` 197 | 198 | 3. Enabled email verification (needed for OAuth users) 199 | 200 | As OAuth users don't have a password, we need to send a verification email to confirm the user's intent to delete their account. If you have already added the `sendDeleteAccountVerification` callback, you can just call the `deleteUser` method without providing any other information. 201 | 202 | ```ts title="delete-user.ts" 203 | await authClient.deleteUser(); 204 | ``` 205 | 206 | 4. If you have a custom delete account page and sent that url via the `sendDeleteAccountVerification` callback. 207 | Then you need to call the `deleteUser` method with the token to complete the deletion. 208 | 209 | ```ts title="delete-user.ts" 210 | await authClient.deleteUser({ 211 | token 212 | }); 213 | ``` 214 | 215 | ### Callbacks 216 | 217 | **beforeDelete**: This callback is called before the user is deleted. You can use this callback to perform any cleanup or additional checks before deleting the user. 218 | 219 | ```ts title="auth.ts" 220 | export const auth = betterAuth({ 221 | user: { 222 | deleteUser: { 223 | enabled: true, 224 | beforeDelete: async (user) => { 225 | // Perform any cleanup or additional checks here 226 | }, 227 | }, 228 | }, 229 | }); 230 | ``` 231 | you can also throw `APIError` to interrupt the deletion process. 232 | 233 | ```ts title="auth.ts" 234 | import { betterAuth } from "better-auth"; 235 | import { APIError } from "better-auth/api"; 236 | 237 | export const auth = betterAuth({ 238 | user: { 239 | deleteUser: { 240 | enabled: true, 241 | beforeDelete: async (user, request) => { 242 | if (user.email.includes("admin")) { 243 | throw new APIError("BAD_REQUEST", { 244 | message: "Admin accounts can't be deleted", 245 | }); 246 | } 247 | }, 248 | }, 249 | }, 250 | }); 251 | ``` 252 | 253 | **afterDelete**: This callback is called after the user is deleted. You can use this callback to perform any cleanup or additional actions after the user is deleted. 254 | 255 | ```ts title="auth.ts" 256 | export const auth = betterAuth({ 257 | user: { 258 | deleteUser: { 259 | enabled: true, 260 | afterDelete: async (user, request) => { 261 | // Perform any cleanup or additional actions here 262 | }, 263 | }, 264 | }, 265 | }); 266 | ``` 267 | 268 | ## Accounts 269 | 270 | Better Auth supports multiple authentication methods. Each authentication method is called a provider. For example, email and password authentication is a provider, Google authentication is a provider, etc. 271 | 272 | When a user signs in using a provider, an account is created for the user. The account stores the authentication data returned by the provider. This data includes the access token, refresh token, and other information returned by the provider. 273 | 274 | The account table stores the authentication data of the user [Click here to view the schema](/docs/concepts/database#account) 275 | 276 | 277 | ### List User Accounts 278 | 279 | To list user accounts you can use `client.user.listAccounts` method. Which will return all accounts associated with a user. 280 | 281 | ```ts 282 | const accounts = await authClient.listAccounts(); 283 | ``` 284 | 285 | ### Token Encryption 286 | 287 | Better Auth doesn’t encrypt tokens by default and that’s intentional. We want you to have full control over how encryption and decryption are handled, rather than baking in behavior that could be confusing or limiting. If you need to store encrypted tokens (like accessToken or refreshToken), you can use databaseHooks to encrypt them before they’re saved to your database. 288 | 289 | ```ts 290 | export const auth = betterAuth({ 291 | databaseHooks: { 292 | account: { 293 | create: { 294 | before(account, context) { 295 | const withEncryptedTokens = { ...account }; 296 | if (account.accessToken) { 297 | const encryptedAccessToken = encrypt(account.accessToken) // [!code highlight] 298 | withEncryptedTokens.accessToken = encryptedAccessToken; 299 | } 300 | if (account.refreshToken) { 301 | const encryptedRefreshToken = encrypt(account.refreshToken); // [!code highlight] 302 | withEncryptedTokens.refreshToken = encryptedRefreshToken; 303 | } 304 | return { 305 | data: withEncryptedTokens 306 | } 307 | }, 308 | } 309 | } 310 | } 311 | }) 312 | ``` 313 | 314 | Then whenever you retrieve back the account make sure to decrypt the tokens before using them. 315 | 316 | ### Account Linking 317 | 318 | Account linking enables users to associate multiple authentication methods with a single account. With Better Auth, users can connect additional social sign-ons or OAuth providers to their existing accounts if the provider confirms the user's email as verified. 319 | 320 | If account linking is disabled, no accounts can be linked, regardless of the provider or email verification status. 321 | 322 | ```ts title="auth.ts" 323 | export const auth = betterAuth({ 324 | account: { 325 | accountLinking: { 326 | enabled: true, 327 | } 328 | }, 329 | }); 330 | ``` 331 | 332 | #### Forced Linking 333 | 334 | You can specify a list of "trusted providers." When a user logs in using a trusted provider, their account will be automatically linked even if the provider doesn’t confirm the email verification status. Use this with caution as it may increase the risk of account takeover. 335 | 336 | ```ts title="auth.ts" 337 | export const auth = betterAuth({ 338 | account: { 339 | accountLinking: { 340 | enabled: true, 341 | trustedProviders: ["google", "github"] 342 | } 343 | }, 344 | }); 345 | ``` 346 | 347 | #### Manually Linking Accounts 348 | 349 | Users already signed in can manually link their account to additional social providers or credential-based accounts. 350 | 351 | - **Linking Social Accounts:** Use the `linkSocial` method on the client to link a social provider to the user's account. 352 | 353 | ```ts 354 | await authClient.linkSocial({ 355 | provider: "google", // Provider to link 356 | callbackURL: "/callback" // Callback URL after linking completes 357 | }); 358 | ``` 359 | 360 | You can also request specific scopes when linking a social account, which can be different from the scopes used during the initial authentication: 361 | 362 | ```ts 363 | await authClient.linkSocial({ 364 | provider: "google", 365 | callbackURL: "/callback", 366 | scopes: ["https://www.googleapis.com/auth/drive.readonly"] // Request additional scopes 367 | }); 368 | ``` 369 | 370 | You can also link accounts using ID tokens directly, without redirecting to the provider's OAuth flow: 371 | 372 | ```ts 373 | await authClient.linkSocial({ 374 | provider: "google", 375 | idToken: { 376 | token: "id_token_from_provider", 377 | nonce: "nonce_used_for_token", // Optional 378 | accessToken: "access_token", // Optional, may be required by some providers 379 | refreshToken: "refresh_token" // Optional 380 | } 381 | }); 382 | ``` 383 | 384 | This is useful when you already have valid tokens from the provider, for example: 385 | - After signing in with a native SDK 386 | - When using a mobile app that handles authentication 387 | - When implementing custom OAuth flows 388 | 389 | The ID token must be valid and the provider must support ID token verification. 390 | 391 | If you want your users to be able to link a social account with a different email address than the user, or if you want to use a provider that does not return email addresses, you will need to enable this in the account linking settings. 392 | ```ts title="auth.ts" 393 | export const auth = betterAuth({ 394 | account: { 395 | accountLinking: { 396 | allowDifferentEmails: true 397 | } 398 | }, 399 | }); 400 | ``` 401 | 402 | If you want the newly linked accounts to update the user information, you need to enable this in the account linking settings. 403 | 404 | ```ts title="auth.ts" 405 | export const auth = betterAuth({ 406 | account: { 407 | accountLinking: { 408 | updateUserInfoOnLink: true 409 | } 410 | }, 411 | }); 412 | ``` 413 | 414 | - **Linking Credential-Based Accounts:** To link a credential-based account (e.g., email and password), users can initiate a "forgot password" flow, or you can call the `setPassword` method on the server. 415 | 416 | ```ts 417 | await auth.api.setPassword({ 418 | headers: /* headers containing the user's session token */, 419 | password: /* new password */ 420 | }); 421 | ``` 422 | 423 | <Callout> 424 | `setPassword` can't be called from the client for security reasons. 425 | </Callout> 426 | 427 | ### Account Unlinking 428 | 429 | You can unlink a user account by providing a `providerId`. 430 | 431 | ```ts 432 | await authClient.unlinkAccount({ 433 | providerId: "google" 434 | }); 435 | 436 | // Unlink a specific account 437 | await authClient.unlinkAccount({ 438 | providerId: "google", 439 | accountId: "123" 440 | }); 441 | ``` 442 | 443 | If the account doesn't exist, it will throw an error. Additionally, if the user only has one account, unlinking will be prevented to stop account lockout (unless `allowUnlinkingAll` is set to `true`). 444 | 445 | ```ts title="auth.ts" 446 | export const auth = betterAuth({ 447 | account: { 448 | accountLinking: { 449 | allowUnlinkingAll: true 450 | } 451 | }, 452 | }); 453 | ``` 454 | 455 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/two-factor/two-factor.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it, vi } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import { TWO_FACTOR_ERROR_CODES, twoFactor, twoFactorClient } from "."; 4 | import { createAuthClient } from "../../client"; 5 | import { parseSetCookieHeader } from "../../cookies"; 6 | import type { TwoFactorTable, UserWithTwoFactor } from "./types"; 7 | import { DEFAULT_SECRET } from "../../utils/constants"; 8 | import { symmetricDecrypt } from "../../crypto"; 9 | import { convertSetCookieToCookie } from "../../test-utils/headers"; 10 | import { createOTP } from "@better-auth/utils/otp"; 11 | 12 | describe("two factor", async () => { 13 | let OTP = ""; 14 | const { testUser, customFetchImpl, sessionSetter, db, auth } = 15 | await getTestInstance({ 16 | secret: DEFAULT_SECRET, 17 | plugins: [ 18 | twoFactor({ 19 | otpOptions: { 20 | sendOTP({ otp }) { 21 | OTP = otp; 22 | }, 23 | }, 24 | }), 25 | ], 26 | }); 27 | 28 | const headers = new Headers(); 29 | 30 | const client = createAuthClient({ 31 | plugins: [twoFactorClient()], 32 | fetchOptions: { 33 | customFetchImpl, 34 | baseURL: "http://localhost:3000/api/auth", 35 | }, 36 | }); 37 | const session = await client.signIn.email({ 38 | email: testUser.email, 39 | password: testUser.password, 40 | fetchOptions: { 41 | onSuccess: sessionSetter(headers), 42 | }, 43 | }); 44 | if (!session) { 45 | throw new Error("No session"); 46 | } 47 | 48 | it("should return uri and backup codes and shouldn't enable twoFactor yet", async () => { 49 | const res = await client.twoFactor.enable({ 50 | password: testUser.password, 51 | fetchOptions: { 52 | headers, 53 | }, 54 | }); 55 | expect(res.data?.backupCodes.length).toEqual(10); 56 | expect(res.data?.totpURI).toBeDefined(); 57 | const dbUser = await db.findOne<UserWithTwoFactor>({ 58 | model: "user", 59 | where: [ 60 | { 61 | field: "id", 62 | value: session.data?.user.id as string, 63 | }, 64 | ], 65 | }); 66 | const twoFactor = await db.findOne<TwoFactorTable>({ 67 | model: "twoFactor", 68 | where: [ 69 | { 70 | field: "userId", 71 | value: session.data?.user.id as string, 72 | }, 73 | ], 74 | }); 75 | expect(dbUser?.twoFactorEnabled).toBe(false); 76 | expect(twoFactor?.secret).toBeDefined(); 77 | expect(twoFactor?.backupCodes).toBeDefined(); 78 | }); 79 | 80 | it("should use custom issuer from request parameter", async () => { 81 | const CUSTOM_ISSUER = "Custom App Name"; 82 | const res = await client.twoFactor.enable({ 83 | password: testUser.password, 84 | issuer: CUSTOM_ISSUER, 85 | fetchOptions: { 86 | headers, 87 | }, 88 | }); 89 | 90 | const totpURI = res.data?.totpURI; 91 | expect(totpURI).toMatch( 92 | new RegExp(`^otpauth://totp/${encodeURIComponent(CUSTOM_ISSUER)}:`), 93 | ); 94 | expect(totpURI).toContain(`&issuer=Custom+App+Name&`); 95 | }); 96 | 97 | it("should fallback to appName when no issuer provided", async () => { 98 | const res = await client.twoFactor.enable({ 99 | password: testUser.password, 100 | fetchOptions: { 101 | headers, 102 | }, 103 | }); 104 | 105 | const totpURI = res.data?.totpURI; 106 | expect(totpURI).toMatch(/^otpauth:\/\/totp\/Better%20Auth:/); 107 | expect(totpURI).toContain("&issuer=Better+Auth&"); 108 | }); 109 | 110 | it("should enable twoFactor", async () => { 111 | const twoFactor = await db.findOne<TwoFactorTable>({ 112 | model: "twoFactor", 113 | where: [ 114 | { 115 | field: "userId", 116 | value: session.data?.user.id as string, 117 | }, 118 | ], 119 | }); 120 | if (!twoFactor) { 121 | throw new Error("No two factor"); 122 | } 123 | 124 | const decrypted = await symmetricDecrypt({ 125 | key: DEFAULT_SECRET, 126 | data: twoFactor.secret, 127 | }); 128 | const code = await createOTP(decrypted).totp(); 129 | 130 | const res = await client.twoFactor.verifyTotp({ 131 | code, 132 | fetchOptions: { 133 | headers, 134 | onSuccess: sessionSetter(headers), 135 | }, 136 | }); 137 | expect(res.data?.token).toBeDefined(); 138 | }); 139 | 140 | it("should require two factor", async () => { 141 | const headers = new Headers(); 142 | const res = await client.signIn.email({ 143 | email: testUser.email, 144 | password: testUser.password, 145 | rememberMe: false, 146 | fetchOptions: { 147 | onResponse(context) { 148 | const parsed = parseSetCookieHeader( 149 | context.response.headers.get("Set-Cookie") || "", 150 | ); 151 | expect(parsed.get("better-auth.session_token")?.value).toBe(""); 152 | expect(parsed.get("better-auth.two_factor")?.value).toBeDefined(); 153 | expect(parsed.get("better-auth.dont_remember")?.value).toBeDefined(); 154 | headers.append( 155 | "cookie", 156 | `better-auth.two_factor=${ 157 | parsed.get("better-auth.two_factor")?.value 158 | }`, 159 | ); 160 | headers.append( 161 | "cookie", 162 | `better-auth.dont_remember=${ 163 | parsed.get("better-auth.dont_remember")?.value 164 | }`, 165 | ); 166 | }, 167 | }, 168 | }); 169 | expect((res.data as any)?.twoFactorRedirect).toBe(true); 170 | await client.twoFactor.sendOtp({ 171 | fetchOptions: { 172 | headers, 173 | }, 174 | }); 175 | 176 | const verifyRes = await client.twoFactor.verifyOtp({ 177 | code: OTP, 178 | fetchOptions: { 179 | headers, 180 | onResponse(context) { 181 | const parsed = parseSetCookieHeader( 182 | context.response.headers.get("Set-Cookie") || "", 183 | ); 184 | expect(parsed.get("better-auth.session_token")?.value).toBeDefined(); 185 | // max age should be undefined because we are not using remember me 186 | expect( 187 | parsed.get("better-auth.session_token")?.["max-age"], 188 | ).not.toBeDefined(); 189 | }, 190 | }, 191 | }); 192 | expect(verifyRes.data?.token).toBeDefined(); 193 | }); 194 | 195 | it("should fail if two factor cookie is missing", async () => { 196 | const res = await client.twoFactor.verifyTotp({ 197 | code: "123456", 198 | fetchOptions: { 199 | headers, 200 | }, 201 | }); 202 | expect(res.error?.message).toBe( 203 | TWO_FACTOR_ERROR_CODES.INVALID_TWO_FACTOR_COOKIE, 204 | ); 205 | }); 206 | 207 | let backupCodes: string[] = []; 208 | it("should generate backup codes", async () => { 209 | await client.twoFactor.enable({ 210 | password: testUser.password, 211 | fetchOptions: { 212 | headers, 213 | }, 214 | }); 215 | const backupCodesRes = await client.twoFactor.generateBackupCodes({ 216 | fetchOptions: { 217 | headers, 218 | }, 219 | password: testUser.password, 220 | }); 221 | expect(backupCodesRes.data?.backupCodes).toBeDefined(); 222 | backupCodes = backupCodesRes.data?.backupCodes || []; 223 | }); 224 | 225 | it("should allow sign in with backup code", async () => { 226 | const headers = new Headers(); 227 | await client.signIn.email({ 228 | email: testUser.email, 229 | password: testUser.password, 230 | fetchOptions: { 231 | onSuccess(context) { 232 | const parsed = parseSetCookieHeader( 233 | context.response.headers.get("Set-Cookie") || "", 234 | ); 235 | const token = parsed.get("better-auth.session_token")?.value; 236 | expect(token).toBe(""); 237 | headers.append( 238 | "cookie", 239 | `better-auth.two_factor=${ 240 | parsed.get("better-auth.two_factor")?.value 241 | }`, 242 | ); 243 | }, 244 | }, 245 | }); 246 | const backupCode = backupCodes[0]!; 247 | 248 | let parsedCookies = new Map(); 249 | await client.twoFactor.verifyBackupCode({ 250 | code: backupCode, 251 | fetchOptions: { 252 | headers, 253 | onSuccess(context) { 254 | parsedCookies = parseSetCookieHeader( 255 | context.response.headers.get("Set-Cookie") || "", 256 | ); 257 | }, 258 | }, 259 | }); 260 | const token = parsedCookies.get("better-auth.session_token")?.value; 261 | expect(token?.length).toBeGreaterThan(0); 262 | const currentBackupCodes = await auth.api.viewBackupCodes({ 263 | body: { 264 | userId: session.data?.user.id!, 265 | }, 266 | }); 267 | expect(currentBackupCodes.backupCodes).toBeDefined(); 268 | expect(currentBackupCodes.backupCodes).not.toContain(backupCode); 269 | 270 | const res = await client.twoFactor.verifyBackupCode({ 271 | code: "invalid-code", 272 | fetchOptions: { 273 | headers, 274 | onSuccess(context) { 275 | const parsed = parseSetCookieHeader( 276 | context.response.headers.get("Set-Cookie") || "", 277 | ); 278 | const token = parsed.get("better-auth.session_token")?.value; 279 | expect(token?.length).toBeGreaterThan(0); 280 | }, 281 | }, 282 | }); 283 | expect(res.error?.message).toBe("Invalid backup code"); 284 | }); 285 | 286 | it("should trust device", async () => { 287 | const headers = new Headers(); 288 | const res = await client.signIn.email({ 289 | email: testUser.email, 290 | password: testUser.password, 291 | fetchOptions: { 292 | onSuccess(context) { 293 | const parsed = parseSetCookieHeader( 294 | context.response.headers.get("Set-Cookie") || "", 295 | ); 296 | headers.append( 297 | "cookie", 298 | `better-auth.two_factor=${ 299 | parsed.get("better-auth.two_factor")?.value 300 | }`, 301 | ); 302 | }, 303 | }, 304 | }); 305 | expect((res.data as any)?.twoFactorRedirect).toBe(true); 306 | const otpRes = await client.twoFactor.sendOtp({ 307 | fetchOptions: { 308 | headers, 309 | onSuccess(context) { 310 | const parsed = parseSetCookieHeader( 311 | context.response.headers.get("Set-Cookie") || "", 312 | ); 313 | headers.append( 314 | "cookie", 315 | `better-auth.otp.counter=${ 316 | parsed.get("better-auth.otp_counter")?.value 317 | }`, 318 | ); 319 | }, 320 | }, 321 | }); 322 | const newHeaders = new Headers(); 323 | await client.twoFactor.verifyOtp({ 324 | trustDevice: true, 325 | code: OTP, 326 | fetchOptions: { 327 | headers, 328 | onSuccess(context) { 329 | const parsed = parseSetCookieHeader( 330 | context.response.headers.get("Set-Cookie") || "", 331 | ); 332 | newHeaders.set( 333 | "cookie", 334 | `better-auth.trust_device=${ 335 | parsed.get("better-auth.trust_device")?.value 336 | }`, 337 | ); 338 | }, 339 | }, 340 | }); 341 | 342 | const signInRes = await client.signIn.email({ 343 | email: testUser.email, 344 | password: testUser.password, 345 | fetchOptions: { 346 | headers: newHeaders, 347 | }, 348 | }); 349 | expect(signInRes.data?.user).toBeDefined(); 350 | }); 351 | 352 | it("should limit OTP verification attempts", async () => { 353 | const headers = new Headers(); 354 | // Sign in to trigger 2FA 355 | await client.signIn.email({ 356 | email: testUser.email, 357 | password: testUser.password, 358 | fetchOptions: { 359 | onSuccess(context) { 360 | const parsed = parseSetCookieHeader( 361 | context.response.headers.get("Set-Cookie") || "", 362 | ); 363 | headers.append( 364 | "cookie", 365 | `better-auth.two_factor=${ 366 | parsed.get("better-auth.two_factor")?.value 367 | }`, 368 | ); 369 | }, 370 | }, 371 | }); 372 | await client.twoFactor.sendOtp({ 373 | fetchOptions: { 374 | headers, 375 | }, 376 | }); 377 | for (let i = 0; i < 5; i++) { 378 | const res = await client.twoFactor.verifyOtp({ 379 | code: "000000", // Invalid code 380 | fetchOptions: { 381 | headers, 382 | }, 383 | }); 384 | expect(res.error?.message).toBe("Invalid code"); 385 | } 386 | 387 | // Next attempt should be blocked 388 | const res = await client.twoFactor.verifyOtp({ 389 | code: OTP, // Even with correct code 390 | fetchOptions: { 391 | headers, 392 | }, 393 | }); 394 | expect(res.error?.message).toBe( 395 | "Too many attempts. Please request a new code.", 396 | ); 397 | }); 398 | 399 | it("should disable two factor", async () => { 400 | const res = await client.twoFactor.disable({ 401 | password: testUser.password, 402 | fetchOptions: { 403 | headers, 404 | }, 405 | }); 406 | 407 | expect(res.data?.status).toBe(true); 408 | const dbUser = await db.findOne<UserWithTwoFactor>({ 409 | model: "user", 410 | where: [ 411 | { 412 | field: "id", 413 | value: session.data?.user.id as string, 414 | }, 415 | ], 416 | }); 417 | expect(dbUser?.twoFactorEnabled).toBe(false); 418 | 419 | const signInRes = await client.signIn.email({ 420 | email: testUser.email, 421 | password: testUser.password, 422 | }); 423 | expect(signInRes.data?.user).toBeDefined(); 424 | }); 425 | }); 426 | 427 | describe("two factor auth API", async () => { 428 | let OTP = ""; 429 | const sendOTP = vi.fn(); 430 | const { auth, signInWithTestUser, testUser } = await getTestInstance({ 431 | secret: DEFAULT_SECRET, 432 | plugins: [ 433 | twoFactor({ 434 | otpOptions: { 435 | sendOTP({ otp }) { 436 | OTP = otp; 437 | sendOTP(otp); 438 | }, 439 | }, 440 | skipVerificationOnEnable: true, 441 | }), 442 | ], 443 | }); 444 | let { headers } = await signInWithTestUser(); 445 | 446 | it("enable two factor", async () => { 447 | const res = await auth.api.enableTwoFactor({ 448 | body: { 449 | password: testUser.password, 450 | }, 451 | headers, 452 | asResponse: true, 453 | }); 454 | headers = convertSetCookieToCookie(res.headers); 455 | 456 | const json = (await res.json()) as { 457 | status: boolean; 458 | backupCodes: string[]; 459 | totpURI: string; 460 | }; 461 | expect(json.backupCodes.length).toBe(10); 462 | expect(json.totpURI).toBeDefined(); 463 | const session = await auth.api.getSession({ 464 | headers, 465 | }); 466 | expect(session?.user.twoFactorEnabled).toBe(true); 467 | }); 468 | 469 | it("should get totp uri", async () => { 470 | const res = await auth.api.getTOTPURI({ 471 | headers, 472 | body: { 473 | password: testUser.password, 474 | }, 475 | }); 476 | expect(res.totpURI).toBeDefined(); 477 | }); 478 | 479 | it("should request second factor", async () => { 480 | const signInRes = await auth.api.signInEmail({ 481 | body: { 482 | email: testUser.email, 483 | password: testUser.password, 484 | }, 485 | asResponse: true, 486 | }); 487 | 488 | headers = convertSetCookieToCookie(signInRes.headers); 489 | 490 | expect(signInRes).toBeInstanceOf(Response); 491 | expect(signInRes.status).toBe(200); 492 | const parsed = parseSetCookieHeader( 493 | signInRes.headers.get("Set-Cookie") || "", 494 | ); 495 | const twoFactorCookie = parsed.get("better-auth.two_factor"); 496 | expect(twoFactorCookie).toBeDefined(); 497 | const sessionToken = parsed.get("better-auth.session_token"); 498 | expect(sessionToken?.value).toBeFalsy(); 499 | }); 500 | 501 | it("should send otp", async () => { 502 | await auth.api.sendTwoFactorOTP({ 503 | headers, 504 | body: { 505 | trustDevice: false, 506 | }, 507 | }); 508 | expect(OTP.length).toBe(6); 509 | expect(sendOTP).toHaveBeenCalledWith(OTP); 510 | }); 511 | 512 | it("should verify otp", async () => { 513 | const res = await auth.api.verifyTwoFactorOTP({ 514 | headers, 515 | body: { 516 | code: OTP, 517 | }, 518 | asResponse: true, 519 | }); 520 | expect(res.status).toBe(200); 521 | expect(res.headers.get("Set-Cookie")).toBeDefined(); 522 | headers = convertSetCookieToCookie(res.headers); 523 | }); 524 | 525 | it("should disable two factor", async () => { 526 | const res = await auth.api.disableTwoFactor({ 527 | headers, 528 | body: { 529 | password: testUser.password, 530 | }, 531 | asResponse: true, 532 | }); 533 | headers = convertSetCookieToCookie(res.headers); 534 | expect(res.status).toBe(200); 535 | const session = await auth.api.getSession({ 536 | headers, 537 | }); 538 | expect(session?.user.twoFactorEnabled).toBe(false); 539 | }); 540 | }); 541 | 542 | describe("view backup codes", async () => { 543 | const sendOTP = vi.fn(); 544 | const { auth, signInWithTestUser, testUser, db } = await getTestInstance({ 545 | secret: DEFAULT_SECRET, 546 | plugins: [ 547 | twoFactor({ 548 | otpOptions: { 549 | sendOTP({ otp }) { 550 | sendOTP(otp); 551 | }, 552 | }, 553 | skipVerificationOnEnable: true, 554 | }), 555 | ], 556 | }); 557 | let { headers } = await signInWithTestUser(); 558 | 559 | let session = await auth.api.getSession({ headers }); 560 | const userId = session?.user.id!; 561 | 562 | it("should return parsed array of backup codes, not JSON string", async () => { 563 | const enableRes = await auth.api.enableTwoFactor({ 564 | body: { password: testUser.password }, 565 | headers, 566 | asResponse: true, 567 | }); 568 | 569 | expect(enableRes.status).toBe(200); 570 | headers = convertSetCookieToCookie(enableRes.headers); 571 | 572 | const enableJson = (await enableRes.json()) as { 573 | backupCodes: string[]; 574 | }; 575 | 576 | const viewResult = await auth.api.viewBackupCodes({ 577 | body: { userId }, 578 | }); 579 | 580 | expect(typeof viewResult.backupCodes).not.toBe("string"); 581 | expect(Array.isArray(viewResult.backupCodes)).toBe(true); 582 | expect(viewResult.backupCodes.length).toBe(10); 583 | viewResult.backupCodes.forEach((code) => { 584 | expect(typeof code).toBe("string"); 585 | expect(code.length).toBeGreaterThan(0); 586 | }); 587 | expect(viewResult.backupCodes).toEqual(enableJson.backupCodes); 588 | expect(viewResult.status).toBe(true); 589 | }); 590 | 591 | it("should return array after generating new backup codes", async () => { 592 | const generateResult = await auth.api.generateBackupCodes({ 593 | body: { password: testUser.password }, 594 | headers, 595 | }); 596 | 597 | expect(generateResult.backupCodes).toBeDefined(); 598 | expect(generateResult.backupCodes.length).toBe(10); 599 | 600 | const viewResult = await auth.api.viewBackupCodes({ 601 | body: { userId }, 602 | }); 603 | 604 | expect(viewResult.status).toBe(true); 605 | expect(typeof viewResult.backupCodes).not.toBe("string"); 606 | expect(Array.isArray(viewResult.backupCodes)).toBe(true); 607 | expect(viewResult.backupCodes.length).toBe(10); 608 | viewResult.backupCodes.forEach((code) => { 609 | expect(typeof code).toBe("string"); 610 | expect(code.length).toBeGreaterThan(0); 611 | }); 612 | expect(viewResult.backupCodes).toEqual(generateResult.backupCodes); 613 | }); 614 | }); 615 | ``` -------------------------------------------------------------------------------- /packages/sso/src/oidc.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterAll, beforeAll, describe, expect, it } from "vitest"; 2 | import { getTestInstanceMemory as getTestInstance } from "better-auth/test"; 3 | import { sso } from "."; 4 | import { OAuth2Server } from "oauth2-mock-server"; 5 | import { betterFetch } from "@better-fetch/fetch"; 6 | import { organization } from "better-auth/plugins"; 7 | import { createAuthClient } from "better-auth/client"; 8 | import { ssoClient } from "./client"; 9 | 10 | let server = new OAuth2Server(); 11 | 12 | describe("SSO", async () => { 13 | const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = 14 | await getTestInstance({ 15 | plugins: [sso(), organization()], 16 | }); 17 | 18 | const authClient = createAuthClient({ 19 | plugins: [ssoClient()], 20 | baseURL: "http://localhost:3000", 21 | fetchOptions: { 22 | customFetchImpl, 23 | }, 24 | }); 25 | 26 | beforeAll(async () => { 27 | await server.issuer.keys.generate("RS256"); 28 | server.issuer.on; 29 | await server.start(8080, "localhost"); 30 | console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080 31 | }); 32 | 33 | afterAll(async () => { 34 | await server.stop().catch(() => {}); 35 | }); 36 | 37 | server.service.on("beforeUserinfo", (userInfoResponse, req) => { 38 | userInfoResponse.body = { 39 | email: "[email protected]", 40 | name: "OAuth2 Test", 41 | sub: "oauth2", 42 | picture: "https://test.com/picture.png", 43 | email_verified: true, 44 | }; 45 | userInfoResponse.statusCode = 200; 46 | }); 47 | 48 | server.service.on("beforeTokenSigning", (token, req) => { 49 | token.payload.email = "sso-user@localhost:8000.com"; 50 | token.payload.email_verified = true; 51 | token.payload.name = "Test User"; 52 | token.payload.picture = "https://test.com/picture.png"; 53 | }); 54 | 55 | async function simulateOAuthFlow( 56 | authUrl: string, 57 | headers: Headers, 58 | fetchImpl?: (...args: any) => any, 59 | ) { 60 | let location: string | null = null; 61 | await betterFetch(authUrl, { 62 | method: "GET", 63 | redirect: "manual", 64 | onError(context) { 65 | location = context.response.headers.get("location"); 66 | }, 67 | }); 68 | 69 | if (!location) throw new Error("No redirect location found"); 70 | const newHeaders = new Headers(); 71 | let callbackURL = ""; 72 | await betterFetch(location, { 73 | method: "GET", 74 | customFetchImpl: fetchImpl || customFetchImpl, 75 | headers, 76 | onError(context) { 77 | callbackURL = context.response.headers.get("location") || ""; 78 | cookieSetter(newHeaders)(context); 79 | }, 80 | }); 81 | 82 | return { callbackURL, headers: newHeaders }; 83 | } 84 | 85 | it("should register a new SSO provider", async () => { 86 | const { headers } = await signInWithTestUser(); 87 | const provider = await auth.api.registerSSOProvider({ 88 | body: { 89 | issuer: server.issuer.url!, 90 | domain: "localhost.com", 91 | oidcConfig: { 92 | clientId: "test", 93 | clientSecret: "test", 94 | authorizationEndpoint: `${server.issuer.url}/authorize`, 95 | tokenEndpoint: `${server.issuer.url}/token`, 96 | jwksEndpoint: `${server.issuer.url}/jwks`, 97 | discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, 98 | mapping: { 99 | id: "sub", 100 | email: "email", 101 | emailVerified: "email_verified", 102 | name: "name", 103 | image: "picture", 104 | }, 105 | }, 106 | providerId: "test", 107 | }, 108 | headers, 109 | }); 110 | expect(provider).toMatchObject({ 111 | id: expect.any(String), 112 | issuer: "http://localhost:8080", 113 | oidcConfig: { 114 | issuer: "http://localhost:8080", 115 | clientId: "test", 116 | clientSecret: "test", 117 | authorizationEndpoint: "http://localhost:8080/authorize", 118 | tokenEndpoint: "http://localhost:8080/token", 119 | jwksEndpoint: "http://localhost:8080/jwks", 120 | discoveryEndpoint: 121 | "http://localhost:8080/.well-known/openid-configuration", 122 | mapping: { 123 | id: "sub", 124 | email: "email", 125 | emailVerified: "email_verified", 126 | name: "name", 127 | image: "picture", 128 | }, 129 | }, 130 | userId: expect.any(String), 131 | }); 132 | }); 133 | 134 | it("should fail to register a new SSO provider with invalid issuer", async () => { 135 | const { headers } = await signInWithTestUser(); 136 | 137 | try { 138 | await auth.api.registerSSOProvider({ 139 | body: { 140 | issuer: "invalid", 141 | domain: "localhost", 142 | providerId: "test", 143 | oidcConfig: { 144 | clientId: "test", 145 | clientSecret: "test", 146 | }, 147 | }, 148 | headers, 149 | }); 150 | } catch (e) { 151 | expect(e).toMatchObject({ 152 | status: "BAD_REQUEST", 153 | body: { 154 | message: "Invalid issuer. Must be a valid URL", 155 | }, 156 | }); 157 | } 158 | }); 159 | 160 | it("should not allow creating a provider with duplicate providerId", async () => { 161 | const { headers } = await signInWithTestUser(); 162 | 163 | await auth.api.registerSSOProvider({ 164 | body: { 165 | issuer: server.issuer.url!, 166 | domain: "duplicate.com", 167 | providerId: "duplicate-oidc-provider", 168 | oidcConfig: { 169 | clientId: "test", 170 | clientSecret: "test", 171 | }, 172 | }, 173 | headers, 174 | }); 175 | 176 | await expect( 177 | auth.api.registerSSOProvider({ 178 | body: { 179 | issuer: server.issuer.url!, 180 | domain: "another-duplicate.com", 181 | providerId: "duplicate-oidc-provider", 182 | oidcConfig: { 183 | clientId: "test2", 184 | clientSecret: "test2", 185 | }, 186 | }, 187 | headers, 188 | }), 189 | ).rejects.toMatchObject({ 190 | status: "UNPROCESSABLE_ENTITY", 191 | body: { 192 | message: "SSO provider with this providerId already exists", 193 | }, 194 | }); 195 | }); 196 | 197 | it("should sign in with SSO provider with email matching", async () => { 198 | const headers = new Headers(); 199 | const res = await authClient.signIn.sso({ 200 | email: "[email protected]", 201 | callbackURL: "/dashboard", 202 | fetchOptions: { 203 | throw: true, 204 | onSuccess: cookieSetter(headers), 205 | }, 206 | }); 207 | expect(res.url).toContain("http://localhost:8080/authorize"); 208 | expect(res.url).toContain( 209 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 210 | ); 211 | expect(res.url).toContain("login_hint=my-email%40localhost.com"); 212 | const { callbackURL } = await simulateOAuthFlow(res.url, headers); 213 | expect(callbackURL).toContain("/dashboard"); 214 | }); 215 | 216 | it("should sign in with SSO provider with domain", async () => { 217 | const headers = new Headers(); 218 | const res = await authClient.signIn.sso({ 219 | email: "[email protected]", 220 | domain: "localhost.com", 221 | callbackURL: "/dashboard", 222 | fetchOptions: { 223 | throw: true, 224 | onSuccess: cookieSetter(headers), 225 | }, 226 | }); 227 | expect(res.url).toContain("http://localhost:8080/authorize"); 228 | expect(res.url).toContain( 229 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 230 | ); 231 | const { callbackURL } = await simulateOAuthFlow(res.url, headers); 232 | expect(callbackURL).toContain("/dashboard"); 233 | }); 234 | 235 | it("should sign in with SSO provider with providerId", async () => { 236 | const headers = new Headers(); 237 | const res = await authClient.signIn.sso({ 238 | providerId: "test", 239 | loginHint: "[email protected]", 240 | callbackURL: "/dashboard", 241 | fetchOptions: { 242 | throw: true, 243 | onSuccess: cookieSetter(headers), 244 | }, 245 | }); 246 | expect(res.url).toContain("http://localhost:8080/authorize"); 247 | expect(res.url).toContain( 248 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 249 | ); 250 | expect(res.url).toContain("login_hint=user%40example.com"); 251 | 252 | const { callbackURL } = await simulateOAuthFlow(res.url, headers); 253 | expect(callbackURL).toContain("/dashboard"); 254 | }); 255 | }); 256 | 257 | describe("SSO disable implicit sign in", async () => { 258 | const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = 259 | await getTestInstance({ 260 | plugins: [sso({ disableImplicitSignUp: true }), organization()], 261 | }); 262 | 263 | const authClient = createAuthClient({ 264 | plugins: [ssoClient()], 265 | baseURL: "http://localhost:3000", 266 | fetchOptions: { 267 | customFetchImpl, 268 | }, 269 | }); 270 | 271 | beforeAll(async () => { 272 | await server.issuer.keys.generate("RS256"); 273 | server.issuer.on; 274 | await server.start(8080, "localhost"); 275 | console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080 276 | }); 277 | 278 | afterAll(async () => { 279 | await server.stop(); 280 | }); 281 | 282 | server.service.on("beforeUserinfo", (userInfoResponse, req) => { 283 | userInfoResponse.body = { 284 | email: "[email protected]", 285 | name: "OAuth2 Test", 286 | sub: "oauth2", 287 | picture: "https://test.com/picture.png", 288 | email_verified: true, 289 | }; 290 | userInfoResponse.statusCode = 200; 291 | }); 292 | 293 | server.service.on("beforeTokenSigning", (token, req) => { 294 | token.payload.email = "sso-user@localhost:8000.com"; 295 | token.payload.email_verified = true; 296 | token.payload.name = "Test User"; 297 | token.payload.picture = "https://test.com/picture.png"; 298 | }); 299 | 300 | async function simulateOAuthFlow( 301 | authUrl: string, 302 | headers: Headers, 303 | fetchImpl?: (...args: any) => any, 304 | ) { 305 | let location: string | null = null; 306 | await betterFetch(authUrl, { 307 | method: "GET", 308 | redirect: "manual", 309 | onError(context) { 310 | location = context.response.headers.get("location"); 311 | }, 312 | }); 313 | 314 | if (!location) throw new Error("No redirect location found"); 315 | const newHeaders = new Headers(headers); 316 | let callbackURL = ""; 317 | await betterFetch(location, { 318 | method: "GET", 319 | customFetchImpl: fetchImpl || customFetchImpl, 320 | headers, 321 | onError(context) { 322 | callbackURL = context.response.headers.get("location") || ""; 323 | cookieSetter(newHeaders)(context); 324 | }, 325 | }); 326 | 327 | return { callbackURL, headers: newHeaders }; 328 | } 329 | 330 | it("should register a new SSO provider", async () => { 331 | const { headers } = await signInWithTestUser(); 332 | const provider = await auth.api.registerSSOProvider({ 333 | body: { 334 | issuer: server.issuer.url!, 335 | domain: "localhost.com", 336 | oidcConfig: { 337 | clientId: "test", 338 | clientSecret: "test", 339 | authorizationEndpoint: `${server.issuer.url}/authorize`, 340 | tokenEndpoint: `${server.issuer.url}/token`, 341 | jwksEndpoint: `${server.issuer.url}/jwks`, 342 | discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, 343 | mapping: { 344 | id: "sub", 345 | email: "email", 346 | emailVerified: "email_verified", 347 | name: "name", 348 | image: "picture", 349 | }, 350 | }, 351 | providerId: "test", 352 | }, 353 | headers, 354 | }); 355 | expect(provider).toMatchObject({ 356 | id: expect.any(String), 357 | issuer: "http://localhost:8080", 358 | oidcConfig: { 359 | issuer: "http://localhost:8080", 360 | clientId: "test", 361 | clientSecret: "test", 362 | authorizationEndpoint: "http://localhost:8080/authorize", 363 | tokenEndpoint: "http://localhost:8080/token", 364 | jwksEndpoint: "http://localhost:8080/jwks", 365 | discoveryEndpoint: 366 | "http://localhost:8080/.well-known/openid-configuration", 367 | mapping: { 368 | id: "sub", 369 | email: "email", 370 | emailVerified: "email_verified", 371 | name: "name", 372 | image: "picture", 373 | }, 374 | }, 375 | userId: expect.any(String), 376 | }); 377 | }); 378 | 379 | it("should not create user with SSO provider when sign ups are disabled", async () => { 380 | const headers = new Headers(); 381 | const res = await authClient.signIn.sso({ 382 | email: "[email protected]", 383 | callbackURL: "/dashboard", 384 | fetchOptions: { 385 | throw: true, 386 | onSuccess: cookieSetter(headers), 387 | }, 388 | }); 389 | expect(res.url).toContain("http://localhost:8080/authorize"); 390 | expect(res.url).toContain( 391 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 392 | ); 393 | const { callbackURL } = await simulateOAuthFlow(res.url, headers); 394 | expect(callbackURL).toContain( 395 | "/api/auth/error/error?error=signup disabled", 396 | ); 397 | }); 398 | 399 | it("should create user with SSO provider when sign ups are disabled but sign up is requested", async () => { 400 | const headers = new Headers(); 401 | const res = await authClient.signIn.sso({ 402 | email: "[email protected]", 403 | callbackURL: "/dashboard", 404 | requestSignUp: true, 405 | fetchOptions: { 406 | throw: true, 407 | onSuccess: cookieSetter(headers), 408 | }, 409 | }); 410 | expect(res.url).toContain("http://localhost:8080/authorize"); 411 | expect(res.url).toContain( 412 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 413 | ); 414 | const { callbackURL } = await simulateOAuthFlow(res.url, headers); 415 | expect(callbackURL).toContain("/dashboard"); 416 | }); 417 | }); 418 | 419 | describe("provisioning", async (ctx) => { 420 | const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = 421 | await getTestInstance({ 422 | plugins: [sso(), organization()], 423 | }); 424 | 425 | const authClient = createAuthClient({ 426 | plugins: [ssoClient()], 427 | baseURL: "http://localhost:3000", 428 | fetchOptions: { 429 | customFetchImpl, 430 | }, 431 | }); 432 | 433 | beforeAll(async () => { 434 | await server.issuer.keys.generate("RS256"); 435 | server.issuer.on; 436 | await server.start(8080, "localhost"); 437 | console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080 438 | }); 439 | 440 | afterAll(async () => { 441 | await server.stop(); 442 | }); 443 | async function simulateOAuthFlow( 444 | authUrl: string, 445 | headers: Headers, 446 | fetchImpl?: (...args: any) => any, 447 | ) { 448 | let location: string | null = null; 449 | await betterFetch(authUrl, { 450 | method: "GET", 451 | redirect: "manual", 452 | onError(context) { 453 | location = context.response.headers.get("location"); 454 | }, 455 | }); 456 | 457 | if (!location) throw new Error("No redirect location found"); 458 | 459 | let callbackURL = ""; 460 | const newHeaders = new Headers(); 461 | await betterFetch(location, { 462 | method: "GET", 463 | customFetchImpl: fetchImpl || customFetchImpl, 464 | headers, 465 | onError(context) { 466 | callbackURL = context.response.headers.get("location") || ""; 467 | cookieSetter(newHeaders)(context); 468 | }, 469 | }); 470 | 471 | return callbackURL; 472 | } 473 | 474 | server.service.on("beforeUserinfo", (userInfoResponse, req) => { 475 | userInfoResponse.body = { 476 | email: "[email protected]", 477 | name: "OAuth2 Test", 478 | sub: "oauth2", 479 | picture: "https://test.com/picture.png", 480 | email_verified: true, 481 | }; 482 | userInfoResponse.statusCode = 200; 483 | }); 484 | 485 | server.service.on("beforeTokenSigning", (token, req) => { 486 | token.payload.email = "sso-user@localhost:8000.com"; 487 | token.payload.email_verified = true; 488 | token.payload.name = "Test User"; 489 | token.payload.picture = "https://test.com/picture.png"; 490 | }); 491 | it("should provision user", async () => { 492 | const { headers } = await signInWithTestUser(); 493 | const organization = await auth.api.createOrganization({ 494 | body: { 495 | name: "Localhost", 496 | slug: "localhost", 497 | }, 498 | headers, 499 | }); 500 | const provider = await auth.api.registerSSOProvider({ 501 | body: { 502 | issuer: server.issuer.url!, 503 | domain: "localhost.com", 504 | oidcConfig: { 505 | clientId: "test", 506 | clientSecret: "test", 507 | authorizationEndpoint: `${server.issuer.url}/authorize`, 508 | tokenEndpoint: `${server.issuer.url}/token`, 509 | jwksEndpoint: `${server.issuer.url}/jwks`, 510 | discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, 511 | mapping: { 512 | id: "sub", 513 | email: "email", 514 | emailVerified: "email_verified", 515 | name: "name", 516 | image: "picture", 517 | }, 518 | }, 519 | providerId: "test2", 520 | organizationId: organization?.id, 521 | }, 522 | headers, 523 | }); 524 | expect(provider).toMatchObject({ 525 | organizationId: organization?.id, 526 | }); 527 | const newHeaders = new Headers(); 528 | const res = await authClient.signIn.sso({ 529 | email: "[email protected]", 530 | callbackURL: "/dashboard", 531 | fetchOptions: { 532 | onSuccess: cookieSetter(newHeaders), 533 | throw: true, 534 | }, 535 | }); 536 | expect(res.url).toContain("http://localhost:8080/authorize"); 537 | expect(res.url).toContain( 538 | "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Ftest", 539 | ); 540 | 541 | const callbackURL = await simulateOAuthFlow(res.url, newHeaders); 542 | expect(callbackURL).toContain("/dashboard"); 543 | const org = await auth.api.getFullOrganization({ 544 | query: { 545 | organizationId: organization?.id || "", 546 | }, 547 | headers, 548 | }); 549 | const member = org?.members.find( 550 | (m: any) => m.user.email === "sso-user@localhost:8000.com", 551 | ); 552 | expect(member).toMatchObject({ 553 | role: "member", 554 | user: { 555 | id: expect.any(String), 556 | name: "Test User", 557 | email: "sso-user@localhost:8000.com", 558 | image: "https://test.com/picture.png", 559 | }, 560 | }); 561 | }); 562 | 563 | it("should sign in with SSO provide with org slug", async () => { 564 | const res = await auth.api.signInSSO({ 565 | body: { 566 | organizationSlug: "localhost", 567 | callbackURL: "/dashboard", 568 | }, 569 | }); 570 | 571 | expect(res.url).toContain("http://localhost:8080/authorize"); 572 | }); 573 | }); 574 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/session-api.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; 2 | import { getTestInstance } from "../../test-utils/test-instance"; 3 | import { parseSetCookieHeader } from "../../cookies"; 4 | import { getDate } from "../../utils/date"; 5 | import { memoryAdapter, type MemoryDB } from "../../adapters/memory-adapter"; 6 | import { runWithEndpointContext } from "@better-auth/core/context"; 7 | import type { GenericEndpointContext } from "@better-auth/core"; 8 | 9 | describe("session", async () => { 10 | const { client, testUser, sessionSetter, cookieSetter, auth } = 11 | await getTestInstance(); 12 | 13 | it("should set cookies correctly on sign in", async () => { 14 | const headers = new Headers(); 15 | await client.signIn.email( 16 | { 17 | email: testUser.email, 18 | password: testUser.password, 19 | }, 20 | { 21 | onSuccess(context) { 22 | const header = context.response.headers.get("set-cookie"); 23 | const cookies = parseSetCookieHeader(header || ""); 24 | cookieSetter(headers)(context); 25 | const cookie = cookies.get("better-auth.session_token"); 26 | expect(cookie).toMatchObject({ 27 | value: expect.any(String), 28 | "max-age": 60 * 60 * 24 * 7, 29 | path: "/", 30 | samesite: "lax", 31 | httponly: true, 32 | }); 33 | }, 34 | }, 35 | ); 36 | const { data } = await client.getSession({ 37 | fetchOptions: { 38 | headers, 39 | }, 40 | }); 41 | const expiresAt = new Date(data?.session.expiresAt || ""); 42 | const now = new Date(); 43 | 44 | expect(expiresAt.getTime()).toBeGreaterThan( 45 | now.getTime() + 6 * 24 * 60 * 60 * 1000, 46 | ); 47 | }); 48 | 49 | it("should return null when not authenticated", async () => { 50 | const response = await client.getSession(); 51 | expect(response.data).toBeNull(); 52 | }); 53 | 54 | it("should update session when update age is reached", async () => { 55 | const { client, testUser } = await getTestInstance({ 56 | session: { 57 | updateAge: 60, 58 | expiresIn: 60 * 2, 59 | }, 60 | }); 61 | let headers = new Headers(); 62 | 63 | await client.signIn.email( 64 | { 65 | email: testUser.email, 66 | password: testUser.password, 67 | }, 68 | { 69 | onSuccess(context) { 70 | const header = context.response.headers.get("set-cookie"); 71 | const cookies = parseSetCookieHeader(header || ""); 72 | const signedCookie = cookies.get("better-auth.session_token")?.value; 73 | headers.set("cookie", `better-auth.session_token=${signedCookie}`); 74 | }, 75 | }, 76 | ); 77 | 78 | const data = await client.getSession({ 79 | fetchOptions: { 80 | headers, 81 | throw: true, 82 | }, 83 | }); 84 | 85 | if (!data) { 86 | throw new Error("No session found"); 87 | } 88 | expect(new Date(data?.session.expiresAt).getTime()).toBeGreaterThan( 89 | new Date(Date.now() + 1000 * 2 * 59).getTime(), 90 | ); 91 | 92 | expect(new Date(data?.session.expiresAt).getTime()).toBeLessThan( 93 | new Date(Date.now() + 1000 * 2 * 60).getTime(), 94 | ); 95 | for (const t of [60, 80, 100, 121]) { 96 | const span = new Date(); 97 | span.setSeconds(span.getSeconds() + t); 98 | vi.setSystemTime(span); 99 | const response = await client.getSession({ 100 | fetchOptions: { 101 | headers, 102 | onSuccess(context) { 103 | const parsed = parseSetCookieHeader( 104 | context.response.headers.get("set-cookie") || "", 105 | ); 106 | const maxAge = parsed.get("better-auth.session_token")?.["max-age"]; 107 | expect(maxAge).toBe(t === 121 ? 0 : 60 * 2); 108 | }, 109 | }, 110 | }); 111 | if (t === 121) { 112 | //expired 113 | expect(response.data).toBeNull(); 114 | } else { 115 | expect( 116 | new Date(response.data?.session.expiresAt!).getTime(), 117 | ).toBeGreaterThan(new Date(Date.now() + 1000 * 2 * 59).getTime()); 118 | } 119 | } 120 | vi.useRealTimers(); 121 | }); 122 | 123 | it("should update the session every time when set to 0", async () => { 124 | const { client, signInWithTestUser } = await getTestInstance({ 125 | session: { 126 | updateAge: 0, 127 | }, 128 | }); 129 | const { runWithUser } = await signInWithTestUser(); 130 | 131 | await runWithUser(async () => { 132 | const session = await client.getSession(); 133 | 134 | vi.useFakeTimers(); 135 | await vi.advanceTimersByTimeAsync(1000 * 60 * 5); 136 | const session2 = await client.getSession(); 137 | expect(session2.data?.session.expiresAt).not.toBe( 138 | session.data?.session.expiresAt, 139 | ); 140 | expect( 141 | new Date(session2.data!.session.expiresAt).getTime(), 142 | ).toBeGreaterThan(new Date(session.data!.session.expiresAt).getTime()); 143 | }); 144 | }); 145 | 146 | it("should handle 'don't remember me' option", async () => { 147 | let headers = new Headers(); 148 | const res = await client.signIn.email( 149 | { 150 | email: testUser.email, 151 | password: testUser.password, 152 | rememberMe: false, 153 | }, 154 | { 155 | onSuccess(context) { 156 | const header = context.response.headers.get("set-cookie"); 157 | const cookies = parseSetCookieHeader(header || ""); 158 | const signedCookie = cookies.get("better-auth.session_token")?.value; 159 | const dontRememberMe = cookies.get( 160 | "better-auth.dont_remember", 161 | )?.value; 162 | headers.set( 163 | "cookie", 164 | `better-auth.session_token=${signedCookie};better-auth.dont_remember=${dontRememberMe}`, 165 | ); 166 | }, 167 | }, 168 | ); 169 | const data = await client.getSession({ 170 | fetchOptions: { 171 | headers, 172 | throw: true, 173 | }, 174 | }); 175 | if (!data) { 176 | throw new Error("No session found"); 177 | } 178 | const expiresAt = data.session.expiresAt; 179 | expect(new Date(expiresAt).valueOf()).toBeLessThanOrEqual( 180 | getDate(1000 * 60 * 60 * 24).valueOf(), 181 | ); 182 | const response = await client.getSession({ 183 | fetchOptions: { 184 | headers, 185 | }, 186 | }); 187 | 188 | if (!response.data?.session) { 189 | throw new Error("No session found"); 190 | } 191 | // Check that the session wasn't update 192 | expect( 193 | new Date(response.data.session.expiresAt).valueOf(), 194 | ).toBeLessThanOrEqual(getDate(1000 * 60 * 60 * 24).valueOf()); 195 | }); 196 | 197 | it("should set cookies correctly on sign in after changing config", async () => { 198 | const headers = new Headers(); 199 | await client.signIn.email( 200 | { 201 | email: testUser.email, 202 | password: testUser.password, 203 | }, 204 | { 205 | onSuccess(context) { 206 | const header = context.response.headers.get("set-cookie"); 207 | const cookies = parseSetCookieHeader(header || ""); 208 | expect(cookies.get("better-auth.session_token")).toMatchObject({ 209 | value: expect.any(String), 210 | "max-age": 60 * 60 * 24 * 7, 211 | path: "/", 212 | httponly: true, 213 | samesite: "lax", 214 | }); 215 | headers.set( 216 | "cookie", 217 | `better-auth.session_token=${ 218 | cookies.get("better-auth.session_token")?.value 219 | }`, 220 | ); 221 | }, 222 | }, 223 | ); 224 | const data = await client.getSession({ 225 | fetchOptions: { 226 | headers, 227 | throw: true, 228 | }, 229 | }); 230 | if (!data) { 231 | throw new Error("No session found"); 232 | } 233 | const expiresAt = new Date(data?.session?.expiresAt || ""); 234 | const now = new Date(); 235 | 236 | expect(expiresAt.getTime()).toBeGreaterThan( 237 | now.getTime() + 6 * 24 * 60 * 60 * 1000, 238 | ); 239 | }); 240 | 241 | it("should clear session on sign out", async () => { 242 | let headers = new Headers(); 243 | const res = await client.signIn.email( 244 | { 245 | email: testUser.email, 246 | password: testUser.password, 247 | }, 248 | { 249 | onSuccess(context) { 250 | const header = context.response.headers.get("set-cookie"); 251 | const cookies = parseSetCookieHeader(header || ""); 252 | const signedCookie = cookies.get("better-auth.session_token")?.value; 253 | headers.set("cookie", `better-auth.session_token=${signedCookie}`); 254 | }, 255 | }, 256 | ); 257 | const data = await client.getSession({ 258 | fetchOptions: { 259 | headers, 260 | throw: true, 261 | }, 262 | }); 263 | 264 | expect(data).not.toBeNull(); 265 | await client.signOut({ 266 | fetchOptions: { 267 | headers, 268 | }, 269 | }); 270 | const response = await client.getSession({ 271 | fetchOptions: { 272 | headers, 273 | }, 274 | }); 275 | expect(response.data); 276 | }); 277 | 278 | it("should list sessions", async () => { 279 | const headers = new Headers(); 280 | await client.signIn.email( 281 | { 282 | email: testUser.email, 283 | password: testUser.password, 284 | }, 285 | { 286 | onSuccess: sessionSetter(headers), 287 | }, 288 | ); 289 | 290 | const response = await client.listSessions({ 291 | fetchOptions: { 292 | headers, 293 | }, 294 | }); 295 | 296 | expect(response.data?.length).toBeGreaterThan(1); 297 | }); 298 | 299 | it("should revoke session", async () => { 300 | const headers = new Headers(); 301 | const headers2 = new Headers(); 302 | const res = await client.signIn.email({ 303 | email: testUser.email, 304 | password: testUser.password, 305 | fetchOptions: { 306 | onSuccess: sessionSetter(headers), 307 | }, 308 | }); 309 | await client.signIn.email({ 310 | email: testUser.email, 311 | password: testUser.password, 312 | fetchOptions: { 313 | onSuccess: sessionSetter(headers2), 314 | }, 315 | }); 316 | const session = await client.getSession({ 317 | fetchOptions: { 318 | headers, 319 | throw: true, 320 | }, 321 | }); 322 | await client.revokeSession({ 323 | fetchOptions: { 324 | headers, 325 | }, 326 | token: session?.session?.token || "", 327 | }); 328 | const newSession = await client.getSession({ 329 | fetchOptions: { 330 | headers, 331 | }, 332 | }); 333 | expect(newSession.data).toBeNull(); 334 | const revokeRes = await client.revokeSessions({ 335 | fetchOptions: { 336 | headers: headers2, 337 | }, 338 | }); 339 | expect(revokeRes.data?.status).toBe(true); 340 | }); 341 | 342 | it("should return session headers", async () => { 343 | const context = await auth.$context; 344 | await runWithEndpointContext( 345 | { 346 | context, 347 | } as unknown as GenericEndpointContext, 348 | async () => { 349 | const signInRes = await auth.api.signInEmail({ 350 | body: { 351 | email: testUser.email, 352 | password: testUser.password, 353 | }, 354 | returnHeaders: true, 355 | }); 356 | 357 | const signInHeaders = new Headers(); 358 | signInHeaders.set("cookie", signInRes.headers.getSetCookie()[0]!); 359 | 360 | const sessionResWithoutHeaders = await auth.api.getSession({ 361 | headers: signInHeaders, 362 | }); 363 | 364 | const sessionResWithHeaders = await auth.api.getSession({ 365 | headers: signInHeaders, 366 | returnHeaders: true, 367 | }); 368 | 369 | expect(sessionResWithHeaders.headers).toBeDefined(); 370 | expect(sessionResWithHeaders.response?.user).toBeDefined(); 371 | expect(sessionResWithHeaders.response?.session).toBeDefined(); 372 | expectTypeOf({ 373 | headers: sessionResWithHeaders.headers, 374 | }).toMatchObjectType<{ 375 | headers: Headers; 376 | }>(); 377 | 378 | // @ts-expect-error: headers should not exist on sessionResWithoutHeaders 379 | expect(sessionResWithoutHeaders.headers).toBeUndefined(); 380 | 381 | const sessionResWithHeadersAndAsResponse = await auth.api.getSession({ 382 | headers: signInHeaders, 383 | returnHeaders: true, 384 | asResponse: true, 385 | }); 386 | 387 | expectTypeOf({ 388 | res: sessionResWithHeadersAndAsResponse, 389 | }).toMatchObjectType<{ res: Response }>(); 390 | 391 | expect(sessionResWithHeadersAndAsResponse.ok).toBe(true); 392 | expect(sessionResWithHeadersAndAsResponse.status).toBe(200); 393 | }, 394 | ); 395 | }); 396 | }); 397 | 398 | describe("session storage", async () => { 399 | let store = new Map<string, string>(); 400 | const { client, signInWithTestUser, db } = await getTestInstance({ 401 | secondaryStorage: { 402 | set(key, value, ttl) { 403 | store.set(key, value); 404 | }, 405 | get(key) { 406 | return store.get(key) || null; 407 | }, 408 | delete(key) { 409 | store.delete(key); 410 | }, 411 | }, 412 | rateLimit: { 413 | enabled: false, 414 | }, 415 | }); 416 | 417 | beforeEach(() => { 418 | store.clear(); 419 | }); 420 | 421 | it("should store session in secondary storage", async () => { 422 | //since the instance creates a session on init, we expect the store to have 2 item (1 for session and 1 for active sessions record for the user) 423 | expect(store.size).toBe(0); 424 | const { runWithUser } = await signInWithTestUser(); 425 | expect(store.size).toBe(2); 426 | await runWithUser(async () => { 427 | const session = await client.getSession(); 428 | expect(session.data).toMatchObject({ 429 | session: { 430 | userId: expect.any(String), 431 | token: expect.any(String), 432 | expiresAt: expect.any(Date), 433 | ipAddress: expect.any(String), 434 | userAgent: expect.any(String), 435 | }, 436 | user: { 437 | id: expect.any(String), 438 | name: "test user", 439 | email: "[email protected]", 440 | emailVerified: false, 441 | image: null, 442 | createdAt: expect.any(Date), 443 | updatedAt: expect.any(Date), 444 | }, 445 | }); 446 | }); 447 | }); 448 | 449 | it("should list sessions", async () => { 450 | const { runWithUser } = await signInWithTestUser(); 451 | await runWithUser(async () => { 452 | const response = await client.listSessions(); 453 | expect(response.data?.length).toBe(1); 454 | }); 455 | }); 456 | 457 | it("revoke session and list sessions", async () => { 458 | const { runWithUser } = await signInWithTestUser(); 459 | await runWithUser(async () => { 460 | const session = await client.getSession(); 461 | expect(session.data).not.toBeNull(); 462 | expect(session.data?.session?.token).toBeDefined(); 463 | const userId = session.data!.session.userId; 464 | const sessions = JSON.parse(store.get(`active-sessions-${userId}`)!); 465 | expect(sessions.length).toBe(1); 466 | const res = await client.revokeSession({ 467 | token: session.data?.session?.token!, 468 | }); 469 | expect(res.data?.status).toBe(true); 470 | const response = await client.listSessions(); 471 | expect(response.data).toBe(null); 472 | expect(store.size).toBe(0); 473 | }); 474 | }); 475 | 476 | it("should revoke session", async () => { 477 | const { runWithUser } = await signInWithTestUser(); 478 | await runWithUser(async () => { 479 | const session = await client.getSession(); 480 | expect(session.data).not.toBeNull(); 481 | const res = await client.revokeSession({ 482 | token: session.data?.session?.token || "", 483 | }); 484 | const revokedSession = await client.getSession(); 485 | expect(revokedSession.data).toBeNull(); 486 | }); 487 | }); 488 | }); 489 | 490 | describe("cookie cache", async () => { 491 | const database: MemoryDB = { 492 | user: [], 493 | account: [], 494 | session: [], 495 | verification: [], 496 | }; 497 | const adapter = memoryAdapter(database); 498 | 499 | const { client, testUser, auth, cookieSetter } = await getTestInstance({ 500 | database: adapter, 501 | session: { 502 | additionalFields: { 503 | sensitiveData: { 504 | type: "string", 505 | returned: false, 506 | defaultValue: "sensitive-data", 507 | }, 508 | }, 509 | cookieCache: { 510 | enabled: true, 511 | }, 512 | }, 513 | }); 514 | const ctx = await auth.$context; 515 | 516 | it("should cache cookies", async () => {}); 517 | const fn = vi.spyOn(ctx.adapter, "findOne"); 518 | 519 | const headers = new Headers(); 520 | it("should cache cookies", async () => { 521 | await client.signIn.email( 522 | { 523 | email: testUser.email, 524 | password: testUser.password, 525 | }, 526 | { 527 | onSuccess(context) { 528 | const header = context.response.headers.get("set-cookie"); 529 | const cookies = parseSetCookieHeader(header || ""); 530 | headers.set( 531 | "cookie", 532 | `better-auth.session_token=${ 533 | cookies.get("better-auth.session_token")?.value 534 | };better-auth.session_data=${ 535 | cookies.get("better-auth.session_data")?.value 536 | }`, 537 | ); 538 | }, 539 | }, 540 | ); 541 | expect(fn).toHaveBeenCalledTimes(1); 542 | const session = await client.getSession({ 543 | fetchOptions: { 544 | headers, 545 | }, 546 | }); 547 | expect(session.data?.session).not.toHaveProperty("sensitiveData"); 548 | expect(session.data).not.toBeNull(); 549 | expect(fn).toHaveBeenCalledTimes(1); 550 | }); 551 | 552 | it("should disable cookie cache", async () => { 553 | const ctx = await auth.$context; 554 | 555 | const s = await client.getSession({ 556 | fetchOptions: { 557 | headers, 558 | }, 559 | }); 560 | expect(s.data?.user.emailVerified).toBe(false); 561 | await runWithEndpointContext( 562 | { 563 | context: ctx, 564 | } as unknown as GenericEndpointContext, 565 | async () => { 566 | await ctx.internalAdapter.updateUser(s.data?.user.id || "", { 567 | emailVerified: true, 568 | }); 569 | }, 570 | ); 571 | expect(fn).toHaveBeenCalledTimes(1); 572 | 573 | const session = await client.getSession({ 574 | query: { 575 | disableCookieCache: true, 576 | }, 577 | fetchOptions: { 578 | headers, 579 | }, 580 | }); 581 | expect(session.data?.user.emailVerified).toBe(true); 582 | expect(session.data).not.toBeNull(); 583 | expect(fn).toHaveBeenCalledTimes(3); 584 | }); 585 | 586 | it("should reset cache when expires", async () => { 587 | expect(fn).toHaveBeenCalledTimes(3); 588 | await client.getSession({ 589 | fetchOptions: { 590 | headers, 591 | }, 592 | }); 593 | vi.useFakeTimers(); 594 | await vi.advanceTimersByTimeAsync(1000 * 60 * 10); // 10 minutes 595 | await client.getSession({ 596 | fetchOptions: { 597 | headers, 598 | onSuccess(context) { 599 | cookieSetter(headers)(context); 600 | }, 601 | }, 602 | }); 603 | expect(fn).toHaveBeenCalledTimes(5); 604 | await client.getSession({ 605 | fetchOptions: { 606 | headers, 607 | onSuccess(context) { 608 | cookieSetter(headers)(context); 609 | }, 610 | }, 611 | }); 612 | expect(fn).toHaveBeenCalledTimes(5); 613 | }); 614 | }); 615 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oidc-provider/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { InferOptionSchema, User } from "../../types"; 2 | import type { schema } from "./schema"; 3 | 4 | export interface OIDCOptions { 5 | /** 6 | * The amount of time in seconds that the access token is valid for. 7 | * 8 | * @default 3600 (1 hour) - Recommended by the OIDC spec 9 | */ 10 | accessTokenExpiresIn?: number; 11 | /** 12 | * Allow dynamic client registration. 13 | */ 14 | allowDynamicClientRegistration?: boolean; 15 | /** 16 | * The metadata for the OpenID Connect provider. 17 | */ 18 | metadata?: Partial<OIDCMetadata>; 19 | /** 20 | * The amount of time in seconds that the refresh token is valid for. 21 | * 22 | * @default 604800 (7 days) - Recommended by the OIDC spec 23 | */ 24 | refreshTokenExpiresIn?: number; 25 | /** 26 | * The amount of time in seconds that the authorization code is valid for. 27 | * 28 | * @default 600 (10 minutes) - Recommended by the OIDC spec 29 | */ 30 | codeExpiresIn?: number; 31 | /** 32 | * The scopes that the client is allowed to request. 33 | * 34 | * @see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims 35 | * @default 36 | * ```ts 37 | * ["openid", "profile", "email", "offline_access"] 38 | * ``` 39 | */ 40 | scopes?: string[]; 41 | /** 42 | * The default scope to use if the client does not provide one. 43 | * 44 | * @default "openid" 45 | */ 46 | defaultScope?: string; 47 | /** 48 | * A URL to the consent page where the user will be redirected if the client 49 | * requests consent. 50 | * 51 | * After the user consents, they should be redirected by the client to the 52 | * `redirect_uri` with the authorization code. 53 | * 54 | * When the server redirects the user to the consent page, it will include the 55 | * following query parameters: 56 | * - `consent_code` - The consent code to identify the authorization request. 57 | * - `client_id` - The ID of the client. 58 | * - `scope` - The requested scopes. 59 | * 60 | * Once the user consents, you need to call the `/oauth2/consent` endpoint 61 | * with `accept: true` and optionally the `consent_code` (if using URL parameter flow) 62 | * to complete the authorization. This will return the client to the `redirect_uri` 63 | * with the authorization code. 64 | * 65 | * @example 66 | * ```ts 67 | * consentPage: "/oauth/authorize" 68 | * ``` 69 | */ 70 | consentPage?: string; 71 | /** 72 | * The HTML for the consent page. This is used if `consentPage` is not 73 | * provided. This should be a function that returns an HTML string. 74 | * The function will be called with the following props: 75 | */ 76 | getConsentHTML?: (props: { 77 | clientId: string; 78 | clientName: string; 79 | clientIcon?: string; 80 | clientMetadata: Record<string, any> | null; 81 | code: string; 82 | scopes: string[]; 83 | }) => string; 84 | /** 85 | * The URL to the login page. This is used if the client requests the `login` 86 | * prompt. 87 | */ 88 | loginPage: string; 89 | /** 90 | * Whether to require PKCE (proof key code exchange) or not 91 | * 92 | * According to OAuth2.1 spec this should be required. But in any 93 | * case if you want to disable this you can use this options. 94 | * 95 | * @default true 96 | */ 97 | requirePKCE?: boolean; 98 | /** 99 | * Allow plain to be used as a code challenge method. 100 | * 101 | * @default true 102 | */ 103 | allowPlainCodeChallengeMethod?: boolean; 104 | /** 105 | * Custom function to generate a client ID. 106 | */ 107 | generateClientId?: () => string; 108 | /** 109 | * Custom function to generate a client secret. 110 | */ 111 | generateClientSecret?: () => string; 112 | /** 113 | * Get the additional user info claims 114 | * 115 | * This applies to the `userinfo` endpoint and the `id_token`. 116 | * 117 | * @param user - The user object. 118 | * @param scopes - The scopes that the client requested. 119 | * @param client - The client object. 120 | * @returns The user info claim. 121 | */ 122 | getAdditionalUserInfoClaim?: ( 123 | user: User & Record<string, any>, 124 | scopes: string[], 125 | client: Client, 126 | ) => Record<string, any> | Promise<Record<string, any>>; 127 | /** 128 | * Trusted clients that are configured directly in the provider options. 129 | * These clients bypass database lookups and can optionally skip consent screens. 130 | */ 131 | trustedClients?: Client[]; 132 | /** 133 | * Store the client secret in your database in a secure way 134 | * Note: This will not affect the client secret sent to the user, it will only affect the client secret stored in your database 135 | * 136 | * - "hashed" - The client secret is hashed using the `hash` function. 137 | * - "plain" - The client secret is stored in the database in plain text. 138 | * - "encrypted" - The client secret is encrypted using the `encrypt` function. 139 | * - { hash: (clientSecret: string) => Promise<string> } - A function that hashes the client secret. 140 | * - { encrypt: (clientSecret: string) => Promise<string>, decrypt: (clientSecret: string) => Promise<string> } - A function that encrypts and decrypts the client secret. 141 | * 142 | * @default "plain" 143 | */ 144 | storeClientSecret?: 145 | | "hashed" 146 | | "plain" 147 | | "encrypted" 148 | | { hash: (clientSecret: string) => Promise<string> } 149 | | { 150 | encrypt: (clientSecret: string) => Promise<string>; 151 | decrypt: (clientSecret: string) => Promise<string>; 152 | }; 153 | /** 154 | * Whether to use the JWT plugin to sign the ID token. 155 | * 156 | * @default false 157 | */ 158 | useJWTPlugin?: boolean; 159 | /** 160 | * Custom schema for the OIDC plugin 161 | */ 162 | schema?: InferOptionSchema<typeof schema>; 163 | } 164 | 165 | export interface AuthorizationQuery { 166 | /** 167 | * The response type. Must be 'code' or 'token'. Code is for authorization code flow, token is 168 | * for implicit flow. 169 | */ 170 | response_type: "code" | "token"; 171 | /** 172 | * The redirect URI for the client. Must be one of the registered redirect URLs for the client. 173 | */ 174 | redirect_uri?: string; 175 | /** 176 | * The scope of the request. Must be a space-separated list of case sensitive strings. 177 | * 178 | * - "openid" is required for all requests 179 | * - "profile" is required for requests that require user profile information. 180 | * - "email" is required for requests that require user email information. 181 | * - "offline_access" is required for requests that require a refresh token. 182 | */ 183 | scope?: string; 184 | /** 185 | * Opaque value used to maintain state between the request and the callback. Typically, 186 | * Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the 187 | * value of this parameter with a browser cookie. 188 | * 189 | * Note: Better Auth stores the state in a database instead of a cookie. - This is to minimize 190 | * the complication with native apps and other clients that may not have access to cookies. 191 | */ 192 | state: string; 193 | /** 194 | * The client ID. Must be the ID of a registered client. 195 | */ 196 | client_id: string; 197 | /** 198 | * The prompt parameter is used to specify the type of user interaction that is required. 199 | */ 200 | prompt?: "none" | "consent" | "login" | "select_account"; 201 | /** 202 | * The display parameter is used to specify how the authorization server displays the 203 | * authentication and consent user interface pages to the end user. 204 | */ 205 | display?: "page" | "popup" | "touch" | "wap"; 206 | /** 207 | * End-User's preferred languages and scripts for the user interface, represented as a 208 | * space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For 209 | * instance, the value "fr-CA fr en" represents a preference for French as spoken in Canada, 210 | * then French (without a region designation), followed by English (without a region 211 | * designation). 212 | * 213 | * Better Auth does not support this parameter yet. It'll not throw an error if it's provided, 214 | * 215 | * 🏗️ currently not implemented 216 | */ 217 | ui_locales?: string; 218 | /** 219 | * The maximum authentication age. 220 | * 221 | * Specifies the allowable elapsed time in seconds since the last time the End-User was 222 | * actively authenticated by the provider. If the elapsed time is greater than this value, the 223 | * provider MUST attempt to actively re-authenticate the End-User. 224 | * 225 | * Note that max_age=0 is equivalent to prompt=login. 226 | */ 227 | max_age?: number; 228 | /** 229 | * Requested Authentication Context Class Reference values. 230 | * 231 | * Space-separated string that 232 | * specifies the acr values that the Authorization Server is being requested to use for 233 | * processing this Authentication Request, with the values appearing in order of preference. 234 | * The Authentication Context Class satisfied by the authentication performed is returned as 235 | * the acr Claim Value, as specified in Section 2. The acr Claim is requested as a Voluntary 236 | * Claim by this parameter. 237 | */ 238 | acr_values?: string; 239 | /** 240 | * Hint to the Authorization Server about the login identifier the End-User might use to log in 241 | * (if necessary). This hint can be used by an RP if it first asks the End-User for their 242 | * e-mail address (or other identifier) and then wants to pass that value as a hint to the 243 | * discovered authorization service. It is RECOMMENDED that the hint value match the value used 244 | * for discovery. This value MAY also be a phone number in the format specified for the 245 | * phone_number Claim. The use of this parameter is left to the OP's discretion. 246 | */ 247 | login_hint?: string; 248 | /** 249 | * ID Token previously issued by the Authorization Server being passed as a hint about the 250 | * End-User's current or past authenticated session with the Client. 251 | * 252 | * 🏗️ currently not implemented 253 | */ 254 | id_token_hint?: string; 255 | /** 256 | * Code challenge 257 | */ 258 | code_challenge?: string; 259 | /** 260 | * Code challenge method used 261 | */ 262 | code_challenge_method?: "plain" | "s256"; 263 | /** 264 | * String value used to associate a Client session with an ID Token, and to mitigate replay 265 | * attacks. The value is passed through unmodified from the Authentication Request to the ID Token. 266 | * If present in the ID Token, Clients MUST verify that the nonce Claim Value is equal to the 267 | * value of the nonce parameter sent in the Authentication Request. If present in the 268 | * Authentication Request, Authorization Servers MUST include a nonce Claim in the ID Token 269 | * with the Claim Value being the nonce value sent in the Authentication Request. 270 | */ 271 | nonce?: string; 272 | } 273 | 274 | export interface Client { 275 | /** 276 | * Client ID 277 | * 278 | * size 32 279 | * 280 | * as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2 281 | */ 282 | clientId: string; 283 | /** 284 | * Client Secret 285 | * 286 | * A secret for the client, if required by the authorization server. 287 | * Optional for public clients using PKCE. 288 | * 289 | * size 32 290 | */ 291 | clientSecret?: string; 292 | /** 293 | * The client type 294 | * 295 | * as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.1 296 | * 297 | * - web - A web application 298 | * - native - A mobile application 299 | * - user-agent-based - A user-agent-based application 300 | * - public - A public client (PKCE-enabled, no client_secret) 301 | */ 302 | type: "web" | "native" | "user-agent-based" | "public"; 303 | /** 304 | * List of registered redirect URLs. Must include the whole URL, including the protocol, port, 305 | * and path. 306 | * 307 | * For example, `https://example.com/auth/callback` 308 | */ 309 | redirectURLs: string[]; 310 | /** 311 | * The name of the client. 312 | */ 313 | name: string; 314 | /** 315 | * The icon of the client. 316 | */ 317 | icon?: string; 318 | /** 319 | * Additional metadata about the client. 320 | */ 321 | metadata: { 322 | [key: string]: any; 323 | } | null; 324 | /** 325 | * Whether the client is disabled or not. 326 | */ 327 | disabled: boolean; 328 | /** 329 | * Whether to skip the consent screen for this client. 330 | * Only applies to trusted clients. 331 | */ 332 | skipConsent?: boolean; 333 | } 334 | 335 | export interface TokenBody { 336 | /** 337 | * The grant type. Must be 'authorization_code' or 'refresh_token'. 338 | */ 339 | grant_type: "authorization_code" | "refresh_token"; 340 | /** 341 | * The authorization code received from the authorization server. 342 | */ 343 | code?: string; 344 | /** 345 | * The redirect URI of the client. 346 | */ 347 | redirect_uri?: string; 348 | /** 349 | * The client ID. 350 | */ 351 | client_id?: string; 352 | /** 353 | * The client secret. 354 | */ 355 | client_secret?: string; 356 | /** 357 | * The refresh token received from the authorization server. 358 | */ 359 | refresh_token?: string; 360 | } 361 | 362 | export interface CodeVerificationValue { 363 | /** 364 | * The client ID 365 | */ 366 | clientId: string; 367 | /** 368 | * The redirect URI for the client 369 | */ 370 | redirectURI: string; 371 | /** 372 | * The scopes that the client requested 373 | */ 374 | scope: string[]; 375 | /** 376 | * The user ID 377 | */ 378 | userId: string; 379 | /** 380 | * The time that the user authenticated 381 | */ 382 | authTime: number; 383 | /** 384 | * Whether the user needs to consent to the scopes 385 | * before the code can be exchanged for an access token. 386 | * 387 | * If this is true, then the code is treated as a consent 388 | * request. Once the user consents, the code will be updated 389 | * with the actual code. 390 | */ 391 | requireConsent: boolean; 392 | /** 393 | * The state parameter from the request 394 | * 395 | * If the prompt is set to `consent`, then the state 396 | * parameter is saved here. This is to prevent the client 397 | * from using the code before the user consents. 398 | */ 399 | state: string | null; 400 | /** 401 | * Code challenge 402 | */ 403 | codeChallenge?: string; 404 | /** 405 | * Code Challenge Method 406 | */ 407 | codeChallengeMethod?: "sha256" | "plain"; 408 | /** 409 | * Nonce 410 | */ 411 | nonce?: string; 412 | } 413 | 414 | export interface OAuthAccessToken { 415 | /** 416 | * The access token 417 | */ 418 | accessToken: string; 419 | /** 420 | * The refresh token 421 | */ 422 | refreshToken: string; 423 | /** 424 | * The time that the access token expires 425 | */ 426 | accessTokenExpiresAt: Date; 427 | /** 428 | * The time that the refresh token expires 429 | */ 430 | refreshTokenExpiresAt: Date; 431 | /** 432 | * The client ID 433 | */ 434 | clientId: string; 435 | /** 436 | * The user ID 437 | */ 438 | userId: string; 439 | /** 440 | * The scopes that the access token has access to 441 | */ 442 | scopes: string; 443 | } 444 | 445 | export interface OIDCMetadata { 446 | /** 447 | * The issuer identifier, this is the URL of the provider and can be used to verify 448 | * the `iss` claim in the ID token. 449 | * 450 | * default: the base URL of the server (e.g. `https://example.com`) 451 | */ 452 | issuer: string; 453 | /** 454 | * The URL of the authorization endpoint. 455 | * 456 | * @default `/oauth2/authorize` 457 | */ 458 | authorization_endpoint: string; 459 | /** 460 | * The URL of the token endpoint. 461 | * 462 | * @default `/oauth2/token` 463 | */ 464 | token_endpoint: string; 465 | /** 466 | * The URL of the userinfo endpoint. 467 | * 468 | * @default `/oauth2/userinfo` 469 | */ 470 | userinfo_endpoint: string; 471 | /** 472 | * The URL of the jwks_uri endpoint. 473 | * 474 | * For JWKS to work, you must install the `jwt` plugin. 475 | * 476 | * This value is automatically set to `/jwks` if the `jwt` plugin is installed. 477 | * 478 | * @default `/jwks` 479 | */ 480 | jwks_uri: string; 481 | /** 482 | * The URL of the dynamic client registration endpoint. 483 | * 484 | * @default `/oauth2/register` 485 | */ 486 | registration_endpoint: string; 487 | /** 488 | * Supported scopes. 489 | */ 490 | scopes_supported: string[]; 491 | /** 492 | * Supported response types. 493 | * 494 | * only `code` is supported. 495 | */ 496 | response_types_supported: ["code"]; 497 | /** 498 | * Supported response modes. 499 | * 500 | * `query`: the authorization code is returned in the query string 501 | * 502 | * only `query` is supported. 503 | */ 504 | response_modes_supported: ["query"]; 505 | /** 506 | * Supported grant types. 507 | * 508 | * The first element MUST be "authorization_code"; additional grant types like 509 | * "refresh_token" can follow. Guarantees a non-empty array at the type level. 510 | */ 511 | grant_types_supported: [ 512 | "authorization_code", 513 | ...("authorization_code" | "refresh_token")[], 514 | ]; 515 | /** 516 | * acr_values supported. 517 | * 518 | * - `urn:mace:incommon:iap:silver`: Silver level of assurance 519 | * - `urn:mace:incommon:iap:bronze`: Bronze level of assurance 520 | * 521 | * only `urn:mace:incommon:iap:silver` and `urn:mace:incommon:iap:bronze` are supported. 522 | * 523 | * 524 | * @default 525 | * ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"] 526 | * @see https://incommon.org/federation/attributes.html 527 | */ 528 | acr_values_supported: string[]; 529 | /** 530 | * Supported subject types. 531 | * 532 | * pairwise: the subject identifier is unique to the client 533 | * public: the subject identifier is unique to the server 534 | * 535 | * only `public` is supported. 536 | */ 537 | subject_types_supported: ["public"]; 538 | /** 539 | * Supported ID token signing algorithms. 540 | */ 541 | id_token_signing_alg_values_supported: string[]; 542 | /** 543 | * Supported token endpoint authentication methods. 544 | * 545 | * only `client_secret_basic` and `client_secret_post` are supported. 546 | * 547 | * @default 548 | * ["client_secret_basic", "client_secret_post"] 549 | */ 550 | token_endpoint_auth_methods_supported: [ 551 | "client_secret_basic", 552 | "client_secret_post", 553 | "none", 554 | ]; 555 | /** 556 | * Supported claims. 557 | * 558 | * @default 559 | * ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"] 560 | */ 561 | claims_supported: string[]; 562 | /** 563 | * Supported code challenge methods. 564 | * 565 | * only `S256` is supported. 566 | * 567 | * @default ["S256"] 568 | */ 569 | code_challenge_methods_supported: ["S256"]; 570 | } 571 | ```