This is page 31 of 52. Use http://codebase.md/better-auth/better-auth?lines=false&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 │ └── stateless │ ├── .env.example │ ├── .gitignore │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── src │ │ ├── app │ │ │ ├── api │ │ │ │ ├── auth │ │ │ │ │ └── [...all] │ │ │ │ │ └── route.ts │ │ │ │ └── user │ │ │ │ └── route.ts │ │ │ ├── dashboard │ │ │ │ └── page.tsx │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ └── lib │ │ ├── auth-client.ts │ │ └── auth.ts │ ├── tailwind.config.ts │ └── tsconfig.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 │ │ │ ├── polar.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 │ ├── 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-declaration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── username.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-custom-schema.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-schema.test.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 │ │ └── vitest.setup.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 │ │ └── 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 │ │ │ │ ├── polar.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 │ │ │ └── index.ts │ │ ├── test │ │ │ └── expo.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.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.base.json ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /docs/components/landing/hero.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { useEffect, useId, useState } from "react"; import useMeasure from "react-use-measure"; import Link from "next/link"; import clsx from "clsx"; import { Button } from "@/components/ui/button"; import { Check, Copy } from "lucide-react"; import { useTheme } from "next-themes"; import { Highlight, themes } from "prism-react-renderer"; import { AnimatePresence, motion, MotionConfig } from "framer-motion"; import { Builder } from "../builder"; import { Spotlight } from "./spotlight"; import { GradientBG } from "./gradient-bg"; export default function Hero() { return ( <section className="relative w-full flex md:items-center md:justify-center bg-white/96 dark:bg-black/[0.96] antialiased min-h-[40rem] md:min-h-[50rem] lg:min-h-[40rem]"> {/* Spotlight Effect */} <Spotlight /> {/* Background Grid */} <div className="absolute inset-0 left-5 right-5 lg:left-16 lg:right-14 xl:left-16 xl:right-14"> <div className="absolute inset-0 bg-grid text-muted/50 dark:text-white/[0.02]" /> <div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-background" /> </div> {/* Content */} <div className="px-4 py-8 md:w-10/12 mx-auto relative z-10"> <div className="mx-auto grid lg:max-w-8xl xl:max-w-full grid-cols-1 items-center gap-x-8 gap-y-16 px-4 py-2 lg:grid-cols-2 lg:px-8 lg:py-4 xl:gap-x-16 xl:px-0"> <div className="relative z-10 text-left lg:mt-0"> <div className="relative space-y-4"> <div className="space-y-2"> <div className="flex flex-col gap-2"> <div className="flex items-end gap-1 mt-2"> <div className="flex items-center gap-1"> <svg xmlns="http://www.w3.org/2000/svg" width="0.8em" height="0.8em" viewBox="0 0 24 24" > <path fill="currentColor" d="M13 4V2c4.66.5 8.33 4.19 8.85 8.85c.6 5.49-3.35 10.43-8.85 11.03v-2c3.64-.45 6.5-3.32 6.96-6.96A7.994 7.994 0 0 0 13 4m-7.33.2A9.8 9.8 0 0 1 11 2v2.06c-1.43.2-2.78.78-3.9 1.68zM2.05 11a9.8 9.8 0 0 1 2.21-5.33L5.69 7.1A8 8 0 0 0 4.05 11zm2.22 7.33A10.04 10.04 0 0 1 2.06 13h2c.18 1.42.75 2.77 1.63 3.9zm1.4 1.41l1.39-1.37h.04c1.13.88 2.48 1.45 3.9 1.63v2c-1.96-.21-3.82-1-5.33-2.26M12 17l1.56-3.42L17 12l-3.44-1.56L12 7l-1.57 3.44L7 12l3.43 1.58z" ></path> </svg> <span className="text-xs text-opacity-75"> Own Your Auth </span> </div> </div> </div> <p className="text-zinc-800 dark:text-zinc-300 tracking-tight text-2xl md:text-3xl text-pretty"> The most comprehensive authentication framework for TypeScript. </p> </div> <div className="relative flex items-center gap-2 w-full sm:w-[90%] border border-white/10"> <GradientBG className="w-full flex items-center justify-between"> <div className="w-full flex items-center gap-2"> <p className="relative inline tracking-tight opacity-90 text-sm dark:text-white font-mono text-black"> npm install{" "} <span className="relative dark:text-fuchsia-200 text-fuchsia-900"> better-auth <span className="absolute h-2 bg-gradient-to-tr from-white via-stone-200 to-stone-300 blur-3xl w-full top-0 left-2"></span> </span> </p> </div> <div className="flex gap-2 items-center"> <Link href="https://www.npmjs.com/package/better-auth" target="_blank" > <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 128 128" > <path fill="#cb3837" d="M0 7.062C0 3.225 3.225 0 7.062 0h113.88c3.838 0 7.063 3.225 7.063 7.062v113.88c0 3.838-3.225 7.063-7.063 7.063H7.062c-3.837 0-7.062-3.225-7.062-7.063zm23.69 97.518h40.395l.05-58.532h19.494l-.05 58.581h19.543l.05-78.075l-78.075-.1l-.1 78.126z" ></path> <path fill="#fff" d="M25.105 65.52V26.512H40.96c8.72 0 26.274.034 39.008.075l23.153.075v77.866H83.645v-58.54H64.057v58.54H25.105z" ></path> </svg> </Link> <Link href="https://github.com/better-auth/better-auth" target="_blank" > <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256" > <g fill="none"> <rect width="256" height="256" fill="#242938" rx="60" ></rect> <path fill="#fff" d="M128.001 30C72.779 30 28 74.77 28 130.001c0 44.183 28.653 81.667 68.387 94.89c4.997.926 6.832-2.169 6.832-4.81c0-2.385-.093-10.262-.136-18.618c-27.82 6.049-33.69-11.799-33.69-11.799c-4.55-11.559-11.104-14.632-11.104-14.632c-9.073-6.207.684-6.079.684-6.079c10.042.705 15.33 10.305 15.33 10.305c8.919 15.288 23.394 10.868 29.1 8.313c.898-6.464 3.489-10.875 6.349-13.372c-22.211-2.529-45.56-11.104-45.56-49.421c0-10.918 3.906-19.839 10.303-26.842c-1.039-2.519-4.462-12.69.968-26.464c0 0 8.398-2.687 27.508 10.25c7.977-2.215 16.531-3.326 25.03-3.364c8.498.038 17.06 1.149 25.051 3.365c19.087-12.939 27.473-10.25 27.473-10.25c5.443 13.773 2.019 23.945.98 26.463c6.412 7.003 10.292 15.924 10.292 26.842c0 38.409-23.394 46.866-45.662 49.341c3.587 3.104 6.783 9.189 6.783 18.519c0 13.38-.116 24.149-.116 27.443c0 2.661 1.8 5.779 6.869 4.797C199.383 211.64 228 174.169 228 130.001C228 74.771 183.227 30 128.001 30M65.454 172.453c-.22.497-1.002.646-1.714.305c-.726-.326-1.133-1.004-.898-1.502c.215-.512.999-.654 1.722-.311c.727.326 1.141 1.01.89 1.508m4.919 4.389c-.477.443-1.41.237-2.042-.462c-.654-.697-.777-1.629-.293-2.078c.491-.442 1.396-.235 2.051.462c.654.706.782 1.631.284 2.078m3.374 5.616c-.613.426-1.615.027-2.234-.863c-.613-.889-.613-1.955.013-2.383c.621-.427 1.608-.043 2.236.84c.611.904.611 1.971-.015 2.406m5.707 6.504c-.548.604-1.715.442-2.57-.383c-.874-.806-1.118-1.95-.568-2.555c.555-.606 1.729-.435 2.59.383c.868.804 1.133 1.957.548 2.555m7.376 2.195c-.242.784-1.366 1.14-2.499.807c-1.13-.343-1.871-1.26-1.642-2.052c.235-.788 1.364-1.159 2.505-.803c1.13.341 1.871 1.252 1.636 2.048m8.394.932c.028.824-.932 1.508-2.121 1.523c-1.196.027-2.163-.641-2.176-1.452c0-.833.939-1.51 2.134-1.53c1.19-.023 2.163.639 2.163 1.459m8.246-.316c.143.804-.683 1.631-1.864 1.851c-1.161.212-2.236-.285-2.383-1.083c-.144-.825.697-1.651 1.856-1.865c1.183-.205 2.241.279 2.391 1.097" ></path> </g> </svg> </Link> </div> </GradientBG> </div> <div className="flex w-fit flex-col gap-4 font-sans md:flex-row md:justify-center lg:justify-start items-center"> <Link href="/docs" className="hover:shadow-sm dark:border-stone-100 dark:hover:shadow-sm border-2 border-black bg-white px-4 py-1.5 text-sm uppercase text-black shadow-[1px_1px_rgba(0,0,0),2px_2px_rgba(0,0,0),3px_3px_rgba(0,0,0),4px_4px_rgba(0,0,0),5px_5px_0px_0px_rgba(0,0,0)] transition duration-200 md:px-8" > Get Started </Link> <Builder /> </div> </div> </div> <div className="relative md:block lg:static xl:pl-10"> <div className="relative"> <div className="from-sky-300 via-sky-300/70 to-blue-300 absolute inset-0 rounded-none bg-gradient-to-tr opacity-5 blur-lg" /> <div className="from-stone-300 via-stone-300/70 to-blue-300 absolute inset-0 rounded-none bg-gradient-to-tr opacity-5" /> <CodePreview /> </div> </div> </div> </div> </section> ); } const tabs: { name: "auth.ts" | "client.ts"; code: string }[] = [ { name: "auth.ts", code: `export const auth = betterAuth({ database: new Pool({ connectionString: DATABASE_URL, }), emailAndPassword: { enabled: true, }, plugins: [ organization(), twoFactor(), ] })`, }, { name: "client.ts", code: `const client = createAuthClient({ plugins: [passkeyClient()] });`, }, ]; function TrafficLightsIcon(props: React.ComponentPropsWithoutRef<"svg">) { return ( <svg aria-hidden="true" viewBox="0 0 42 10" fill="none" {...props}> <circle cx="5" cy="5" r="4.5" /> <circle cx="21" cy="5" r="4.5" /> <circle cx="37" cy="5" r="4.5" /> </svg> ); } function CodePreview() { const { resolvedTheme } = useTheme(); const [ref, { height }] = useMeasure(); const [copyState, setCopyState] = useState(false); const [codeTheme, setCodeTheme] = useState(themes.oneDark); const [currentTab, setCurrentTab] = useState<"auth.ts" | "client.ts">( "auth.ts", ); useEffect(() => { setCodeTheme(resolvedTheme === "light" ? themes.oneLight : themes.oneDark); }, [resolvedTheme]); const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text).then(() => { setCopyState(true); setTimeout(() => { setCopyState(false); }, 2000); }); }; const code = tabs.find((tab) => tab.name === currentTab)?.code ?? ""; return ( <AnimatePresence initial={false}> <MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}> <motion.div animate={{ height: height > 0 ? height : undefined }} className="from-stone-100 to-stone-200 dark:to-black/90 dark:via-black dark:from-stone-950/90 relative overflow-hidden rounded-sm bg-gradient-to-tr ring-1 ring-white/10 backdrop-blur-lg" > <div ref={ref}> <div className="absolute -top-px left-0 right-0 h-px" /> <div className="absolute -bottom-px left-11 right-20 h-px" /> <div className="pl-4 pt-4"> <TrafficLightsIcon className="stroke-slate-500/30 h-2.5 w-auto" /> <div className="mt-4 flex space-x-2 text-xs"> {tabs.map((tab) => ( <button key={tab.name} onClick={() => setCurrentTab(tab.name)} className={clsx( "relative isolate flex h-6 cursor-pointer items-center justify-center rounded-full px-2.5", currentTab === tab.name ? "text-stone-300" : "text-slate-500", )} > {tab.name} {tab.name === currentTab && ( <motion.div layoutId="tab-code-preview" className="bg-stone-800 absolute inset-0 -z-10 rounded-full" /> )} </button> ))} </div> <div className="flex flex-col items-start px-1 text-sm"> <div className="absolute top-2 right-4"> <Button variant="outline" size="icon" className="absolute w-5 border-none bg-transparent h-5 top-2 right-0" onClick={() => copyToClipboard(code)} > {copyState ? ( <Check className="h-3 w-3" /> ) : ( <Copy className="h-3 w-3" /> )} <span className="sr-only">Copy code</span> </Button> </div> <div className="w-full overflow-x-auto"> <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }} key={currentTab} className="relative flex items-center px-1 text-sm min-w-max" > <div aria-hidden="true" className="border-slate-300/5 text-slate-600 select-none border-r pr-4 font-mono" > {Array.from({ length: code.split("\n").length, }).map((_, index) => ( <div key={index}> {(index + 1).toString().padStart(2, "0")} <br /> </div> ))} </div> <Highlight key={resolvedTheme} code={code} language={"javascript"} theme={{ ...codeTheme, plain: { backgroundColor: "transparent", }, }} > {({ className, style, tokens, getLineProps, getTokenProps, }) => ( <pre className={clsx(className)} style={style}> <code className="px-4 font-mono whitespace-pre"> {tokens.map((line, lineIndex) => ( <div key={lineIndex} {...getLineProps({ line })}> {line.map((token, tokenIndex) => ( <span key={tokenIndex} {...getTokenProps({ token })} /> ))} </div> ))} </code> </pre> )} </Highlight> </motion.div> </div> <motion.div layout className="self-end mt-3"> <Link href="https://demo.better-auth.com" target="_blank" className="shadow-md border shadow-primary-foreground mb-4 ml-auto mr-4 mt-auto flex cursor-pointer items-center gap-2 px-3 py-1 transition-all ease-in-out hover:opacity-70" > <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" > <path fill="currentColor" d="M10 20H8V4h2v2h2v3h2v2h2v2h-2v2h-2v3h-2z" ></path> </svg> <p className="text-sm">Demo</p> </Link> </motion.div> </div> </div> </div> </motion.div> </MotionConfig> </AnimatePresence> ); } export function HeroBackground(props: React.ComponentPropsWithoutRef<"svg">) { const id = useId(); return ( <svg aria-hidden="true" viewBox="0 0 668 1069" width={668} height={1069} fill="none" {...props} > <defs> <clipPath id={`${id}-clip-path`}> <path fill="#fff" transform="rotate(-180 334 534.4)" d="M0 0h668v1068.8H0z" /> </clipPath> </defs> <g opacity=".4" clipPath={`url(#${id}-clip-path)`} strokeWidth={4}> <path opacity=".3" d="M584.5 770.4v-474M484.5 770.4v-474M384.5 770.4v-474M283.5 769.4v-474M183.5 768.4v-474M83.5 767.4v-474" stroke="#334155" /> <path d="M83.5 221.275v6.587a50.1 50.1 0 0 0 22.309 41.686l55.581 37.054a50.102 50.102 0 0 1 22.309 41.686v6.587M83.5 716.012v6.588a50.099 50.099 0 0 0 22.309 41.685l55.581 37.054a50.102 50.102 0 0 1 22.309 41.686v6.587M183.7 584.5v6.587a50.1 50.1 0 0 0 22.31 41.686l55.581 37.054a50.097 50.097 0 0 1 22.309 41.685v6.588M384.101 277.637v6.588a50.1 50.1 0 0 0 22.309 41.685l55.581 37.054a50.1 50.1 0 0 1 22.31 41.686v6.587M384.1 770.288v6.587a50.1 50.1 0 0 1-22.309 41.686l-55.581 37.054A50.099 50.099 0 0 0 283.9 897.3v6.588" stroke="#334155" /> <path d="M384.1 770.288v6.587a50.1 50.1 0 0 1-22.309 41.686l-55.581 37.054A50.099 50.099 0 0 0 283.9 897.3v6.588M484.3 594.937v6.587a50.1 50.1 0 0 1-22.31 41.686l-55.581 37.054A50.1 50.1 0 0 0 384.1 721.95v6.587M484.3 872.575v6.587a50.1 50.1 0 0 1-22.31 41.686l-55.581 37.054a50.098 50.098 0 0 0-22.309 41.686v6.582M584.501 663.824v39.988a50.099 50.099 0 0 1-22.31 41.685l-55.581 37.054a50.102 50.102 0 0 0-22.309 41.686v6.587M283.899 945.637v6.588a50.1 50.1 0 0 1-22.309 41.685l-55.581 37.05a50.12 50.12 0 0 0-22.31 41.69v6.59M384.1 277.637c0 19.946 12.763 37.655 31.686 43.962l137.028 45.676c18.923 6.308 31.686 24.016 31.686 43.962M183.7 463.425v30.69c0 21.564 13.799 40.709 34.257 47.529l134.457 44.819c18.922 6.307 31.686 24.016 31.686 43.962M83.5 102.288c0 19.515 13.554 36.412 32.604 40.645l235.391 52.309c19.05 4.234 32.605 21.13 32.605 40.646M83.5 463.425v-58.45M183.699 542.75V396.625M283.9 1068.8V945.637M83.5 363.225v-141.95M83.5 179.524v-77.237M83.5 60.537V0M384.1 630.425V277.637M484.301 830.824V594.937M584.5 1068.8V663.825M484.301 555.275V452.988M584.5 622.075V452.988M384.1 728.537v-56.362M384.1 1068.8v-20.88M384.1 1006.17V770.287M283.9 903.888V759.85M183.699 1066.71V891.362M83.5 1068.8V716.012M83.5 674.263V505.175" stroke="#334155" /> <circle cx="83.5" cy="384.1" r="10.438" transform="rotate(-180 83.5 384.1)" fill="#1E293B" stroke="#334155" /> <circle cx="83.5" cy="200.399" r="10.438" transform="rotate(-180 83.5 200.399)" stroke="#334155" /> <circle cx="83.5" cy="81.412" r="10.438" transform="rotate(-180 83.5 81.412)" stroke="#334155" /> <circle cx="183.699" cy="375.75" r="10.438" transform="rotate(-180 183.699 375.75)" fill="#1E293B" stroke="#334155" /> <circle cx="183.699" cy="563.625" r="10.438" transform="rotate(-180 183.699 563.625)" fill="#1E293B" stroke="#334155" /> <circle cx="384.1" cy="651.3" r="10.438" transform="rotate(-180 384.1 651.3)" fill="#1E293B" stroke="#334155" /> <circle cx="484.301" cy="574.062" r="10.438" transform="rotate(-180 484.301 574.062)" fill="#0EA5E9" fillOpacity=".42" stroke="#0EA5E9" /> <circle cx="384.1" cy="749.412" r="10.438" transform="rotate(-180 384.1 749.412)" fill="#1E293B" stroke="#334155" /> <circle cx="384.1" cy="1027.05" r="10.438" transform="rotate(-180 384.1 1027.05)" stroke="#334155" /> <circle cx="283.9" cy="924.763" r="10.438" transform="rotate(-180 283.9 924.763)" stroke="#334155" /> <circle cx="183.699" cy="870.487" r="10.438" transform="rotate(-180 183.699 870.487)" stroke="#334155" /> <circle cx="283.9" cy="738.975" r="10.438" transform="rotate(-180 283.9 738.975)" fill="#1E293B" stroke="#334155" /> <circle cx="83.5" cy="695.138" r="10.438" transform="rotate(-180 83.5 695.138)" fill="#1E293B" stroke="#334155" /> <circle cx="83.5" cy="484.3" r="10.438" transform="rotate(-180 83.5 484.3)" fill="#0EA5E9" fillOpacity=".42" stroke="#0EA5E9" /> <circle cx="484.301" cy="432.112" r="10.438" transform="rotate(-180 484.301 432.112)" fill="#1E293B" stroke="#334155" /> <circle cx="584.5" cy="432.112" r="10.438" transform="rotate(-180 584.5 432.112)" fill="#1E293B" stroke="#334155" /> <circle cx="584.5" cy="642.95" r="10.438" transform="rotate(-180 584.5 642.95)" fill="#1E293B" stroke="#334155" /> <circle cx="484.301" cy="851.699" r="10.438" transform="rotate(-180 484.301 851.699)" stroke="#334155" /> <circle cx="384.1" cy="256.763" r="10.438" transform="rotate(-180 384.1 256.763)" stroke="#334155" /> </g> </svg> ); } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/types.ts: -------------------------------------------------------------------------------- ```typescript import type { DBFieldAttribute } from "@better-auth/core/db"; import type { User, Session } from "../../types"; import type { AccessControl, Role } from "../access"; import type { Invitation, Member, Organization, OrganizationRole, Team, TeamMember, } from "./schema"; import type { AuthContext } from "@better-auth/core"; export interface OrganizationOptions { /** * Configure whether new users are able to create new organizations. * You can also pass a function that returns a boolean. * * @example * ```ts * allowUserToCreateOrganization: async (user) => { * const plan = await getUserPlan(user); * return plan.name === "pro"; * } * ``` * @default true */ allowUserToCreateOrganization?: | boolean | ((user: User & Record<string, any>) => Promise<boolean> | boolean); /** * The maximum number of organizations a user can create. * * You can also pass a function that returns a boolean */ organizationLimit?: number | ((user: User) => Promise<boolean> | boolean); /** * The role that is assigned to the creator of the * organization. * * @default "owner" */ creatorRole?: string; /** * The maximum number of members allowed in an organization. * * @default 100 */ membershipLimit?: number; /** * Configure the roles and permissions for the * organization plugin. */ ac?: AccessControl; /** * Custom permissions for roles. */ roles?: { [key in string]?: Role<any>; }; /** * Dynamic access control for the organization plugin. */ dynamicAccessControl?: { /** * Whether to enable dynamic access control for the organization plugin. * * @default false */ enabled?: boolean; /** * The maximum number of roles that can be created for an organization. * * @default Infinite */ maximumRolesPerOrganization?: | number | ((organizationId: string) => Promise<number> | number); }; /** * Support for team. */ teams?: { /** * Enable team features. */ enabled: boolean; /** * Default team configuration */ defaultTeam?: { /** * Enable creating a default team when an organization is created * * @default true */ enabled: boolean; /** * Pass a custom default team creator function */ customCreateDefaultTeam?: ( organization: Organization & Record<string, any>, request?: Request, ) => Promise<Team & Record<string, any>>; }; /** * Maximum number of teams an organization can have. * * You can pass a number or a function that returns a number * * @default "unlimited" * * @param organization * @param request * @returns */ maximumTeams?: | (( data: { organizationId: string; session: { user: User; session: Session; } | null; }, request?: Request, ) => number | Promise<number>) | number; /** * The maximum number of members per team. * * if `undefined`, there is no limit. * * @default undefined */ maximumMembersPerTeam?: | number | ((data: { teamId: string; session: { user: User; session: Session }; organizationId: string; }) => Promise<number> | number) | undefined; /** * By default, if an organization does only have one team, they'll not be able to remove it. * * You can disable this behavior by setting this to `false. * * @default false */ allowRemovingAllTeams?: boolean; }; /** * The expiration time for the invitation link. * * @default 48 hours */ invitationExpiresIn?: number; /** * The maximum invitation a user can send. * * @default 100 */ invitationLimit?: | number | (( data: { user: User; organization: Organization; member: Member; }, ctx: AuthContext, ) => Promise<number> | number); /** * Cancel pending invitations on re-invite. * * @default false */ cancelPendingInvitationsOnReInvite?: boolean; /** * Require email verification on accepting or rejecting an invitation * * @default false */ requireEmailVerificationOnInvitation?: boolean; /** * Send an email with the * invitation link to the user. * * Note: Better Auth doesn't * generate invitation URLs. * You'll need to construct the * URL using the invitation ID * and pass it to the * acceptInvitation endpoint for * the user to accept the * invitation. * * @example * ```ts * sendInvitationEmail: async (data) => { * const url = `https://yourapp.com/organization/ * accept-invitation?id=${data.id}`; * await sendEmail(data.email, "Invitation to join * organization", `Click the link to join the * organization: ${url}`); * } * ``` */ sendInvitationEmail?: ( data: { /** * the invitation id */ id: string; /** * the role of the user */ role: string; /** * the email of the user */ email: string; /** * the organization the user is invited to join */ organization: Organization; /** * the invitation object */ invitation: Invitation; /** * the member who is inviting the user */ inviter: Member & { user: User; }; }, /** * The request object */ request?: Request, ) => Promise<void>; /** * The schema for the organization plugin. */ schema?: { session?: { fields?: { activeOrganizationId?: string; activeTeamId?: string; }; }; organization?: { modelName?: string; fields?: { [key in keyof Omit<Organization, "id">]?: string; }; additionalFields?: { [key in string]: DBFieldAttribute; }; }; member?: { modelName?: string; fields?: { [key in keyof Omit<Member, "id">]?: string; }; additionalFields?: { [key in string]: DBFieldAttribute; }; }; invitation?: { modelName?: string; fields?: { [key in keyof Omit<Invitation, "id">]?: string; }; additionalFields?: { [key in string]: DBFieldAttribute; }; }; team?: { modelName?: string; fields?: { [key in keyof Omit<Team, "id">]?: string; }; additionalFields?: { [key in string]: DBFieldAttribute; }; }; teamMember?: { modelName?: string; fields?: { [key in keyof Omit<TeamMember, "id">]?: string; }; }; organizationRole?: { modelName?: string; fields?: { [key in keyof Omit<OrganizationRole, "id">]?: string; }; additionalFields?: { [key in string]: DBFieldAttribute; }; }; }; /** * Disable organization deletion * * @default false */ disableOrganizationDeletion?: boolean; /** * Configure how organization deletion is handled * * @deprecated Use `organizationHooks` instead */ organizationDeletion?: { /** * disable deleting organization * * @deprecated Use `disableOrganizationDeletion` instead */ disabled?: boolean; /** * A callback that runs before the organization is * deleted * * @deprecated Use `organizationHooks` instead * @param data - organization and user object * @param request - the request object * @returns */ beforeDelete?: ( data: { organization: Organization; user: User; }, request?: Request, ) => Promise<void>; /** * A callback that runs after the organization is * deleted * * @deprecated Use `organizationHooks` instead * @param data - organization and user object * @param request - the request object * @returns */ afterDelete?: ( data: { organization: Organization; user: User; }, request?: Request, ) => Promise<void>; }; /** * @deprecated Use `organizationHooks` instead */ organizationCreation?: { disabled?: boolean; beforeCreate?: ( data: { organization: Omit<Organization, "id"> & Record<string, any>; user: User & Record<string, any>; }, request?: Request, ) => Promise<void | { data: Record<string, any>; }>; afterCreate?: ( data: { organization: Organization & Record<string, any>; member: Member & Record<string, any>; user: User & Record<string, any>; }, request?: Request, ) => Promise<void>; }; /** * Hooks for organization */ organizationHooks?: { /** * A callback that runs before the organization is created * * You can return a `data` object to override the default data. * * @example * ```ts * beforeCreateOrganization: async (data) => { * return { * data: { * ...data.organization, * }, * }; * } * ``` * * You can also throw `new APIError` to stop the organization creation. * * @example * ```ts * beforeCreateOrganization: async (data) => { * throw new APIError("BAD_REQUEST", { * message: "Organization creation is disabled", * }); * } */ beforeCreateOrganization?: (data: { organization: { name?: string; slug?: string; logo?: string; metadata?: Record<string, any>; [key: string]: any; }; user: User & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after the organization is created */ afterCreateOrganization?: (data: { organization: Organization & Record<string, any>; member: Member & Record<string, any>; user: User & Record<string, any>; }) => Promise<void>; /** * A callback that runs before the organization is updated * * You can return a `data` object to override the default data. * * @example * ```ts * beforeUpdateOrganization: async (data) => { * return { data: { ...data.organization } }; * } */ beforeUpdateOrganization?: (data: { organization: { name?: string; slug?: string; logo?: string; metadata?: Record<string, any>; [key: string]: any; }; user: User & Record<string, any>; member: Member & Record<string, any>; }) => Promise<void | { data: { name?: string; slug?: string; logo?: string; metadata?: Record<string, any>; [key: string]: any; }; }>; /** * A callback that runs after the organization is updated * * @example * ```ts * afterUpdateOrganization: async (data) => { * console.log(data.organization); * } * ``` */ afterUpdateOrganization?: (data: { /** * Updated organization object * * This could be `null` if an adapter doesn't return updated organization. */ organization: (Organization & Record<string, any>) | null; user: User & Record<string, any>; member: Member & Record<string, any>; }) => Promise<void>; /** * A callback that runs before the organization is deleted */ beforeDeleteOrganization?: (data: { organization: Organization & Record<string, any>; user: User & Record<string, any>; }) => Promise<void>; /** * A callback that runs after the organization is deleted */ afterDeleteOrganization?: (data: { organization: Organization & Record<string, any>; user: User & Record<string, any>; }) => Promise<void>; /** * Member hooks */ /** * A callback that runs before a member is added to an organization * * You can return a `data` object to override the default data. * * @example * ```ts * beforeAddMember: async (data) => { * return { * data: { * ...data.member, * role: "custom-role" * } * }; * } * ``` */ beforeAddMember?: (data: { member: { userId: string; organizationId: string; role: string; [key: string]: any; }; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after a member is added to an organization */ afterAddMember?: (data: { member: Member & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a member is removed from an organization */ beforeRemoveMember?: (data: { member: Member & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after a member is removed from an organization */ afterRemoveMember?: (data: { member: Member & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a member's role is updated * * You can return a `data` object to override the default data. */ beforeUpdateMemberRole?: (data: { member: Member & Record<string, any>; newRole: string; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: { role: string; [key: string]: any; }; }>; /** * A callback that runs after a member's role is updated */ afterUpdateMemberRole?: (data: { member: Member & Record<string, any>; previousRole: string; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * Invitation hooks */ /** * A callback that runs before an invitation is created * * You can return a `data` object to override the default data. * * @example * ```ts * beforeCreateInvitation: async (data) => { * return { * data: { * ...data.invitation, * expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) // 7 days * } * }; * } * ``` */ beforeCreateInvitation?: (data: { invitation: { email: string; role: string; organizationId: string; inviterId: string; teamId?: string; [key: string]: any; }; inviter: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after an invitation is created */ afterCreateInvitation?: (data: { invitation: Invitation & Record<string, any>; inviter: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before an invitation is accepted */ beforeAcceptInvitation?: (data: { invitation: Invitation & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after an invitation is accepted */ afterAcceptInvitation?: (data: { invitation: Invitation & Record<string, any>; member: Member & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before an invitation is rejected */ beforeRejectInvitation?: (data: { invitation: Invitation & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after an invitation is rejected */ afterRejectInvitation?: (data: { invitation: Invitation & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before an invitation is cancelled */ beforeCancelInvitation?: (data: { invitation: Invitation & Record<string, any>; cancelledBy: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after an invitation is cancelled */ afterCancelInvitation?: (data: { invitation: Invitation & Record<string, any>; cancelledBy: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * Team hooks (when teams are enabled) */ /** * A callback that runs before a team is created * * You can return a `data` object to override the default data. */ beforeCreateTeam?: (data: { team: { name: string; organizationId: string; [key: string]: any; }; user?: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after a team is created */ afterCreateTeam?: (data: { team: Team & Record<string, any>; user?: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a team is updated * * You can return a `data` object to override the default data. */ beforeUpdateTeam?: (data: { team: Team & Record<string, any>; updates: { name?: string; [key: string]: any; }; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after a team is updated */ afterUpdateTeam?: (data: { team: (Team & Record<string, any>) | null; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a team is deleted */ beforeDeleteTeam?: (data: { team: Team & Record<string, any>; user?: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after a team is deleted */ afterDeleteTeam?: (data: { team: Team & Record<string, any>; user?: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a member is added to a team */ beforeAddTeamMember?: (data: { teamMember: { teamId: string; userId: string; [key: string]: any; }; team: Team & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void | { data: Record<string, any>; }>; /** * A callback that runs after a member is added to a team */ afterAddTeamMember?: (data: { teamMember: TeamMember & Record<string, any>; team: Team & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs before a member is removed from a team */ beforeRemoveTeamMember?: (data: { teamMember: TeamMember & Record<string, any>; team: Team & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; /** * A callback that runs after a member is removed from a team */ afterRemoveTeamMember?: (data: { teamMember: TeamMember & Record<string, any>; team: Team & Record<string, any>; user: User & Record<string, any>; organization: Organization & Record<string, any>; }) => Promise<void>; }; } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/siwe/siwe.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { siwe } from "./index"; import { siweClient } from "./client"; describe("siwe", async (it) => { const walletAddress = "0x000000000000000000000000000000000000dEaD"; const domain = "example.com"; const chainId = 1; // Ethereum mainnet it("should generate a valid nonce for a valid public key", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { data } = await client.siwe.nonce({ walletAddress, chainId }); // to be of type string expect(typeof data?.nonce).toBe("string"); // to be 17 alphanumeric characters (96 bits of entropy) expect(data?.nonce).toMatch(/^[a-zA-Z0-9]{17}$/); }); it("should generate a valid nonce with default chainId", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); // Test without chainId (should default to 1) const { data } = await client.siwe.nonce({ walletAddress }); expect(typeof data?.nonce).toBe("string"); expect(data?.nonce).toMatch(/^[a-zA-Z0-9]{17}$/); }); it("should reject verification if nonce is missing", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, }); expect(error).toBeDefined(); expect(error?.status).toBe(401); expect(error?.code).toBe("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"); expect(error?.message).toMatch(/nonce/i); }); it("should reject invalid public key", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.nonce({ walletAddress: "invalid" }); expect(error).toBeDefined(); expect(error?.status).toBe(400); expect(error?.message).toBe("Invalid body parameters"); }); it("should reject verification with invalid signature", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.verify({ message: "Sign in with Ethereum.", signature: "invalid_signature", walletAddress, }); expect(error).toBeDefined(); expect(error?.status).toBe(401); }); it("should reject invalid walletAddress format", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.nonce({ walletAddress: "not_a_valid_key", }); expect(error).toBeDefined(); expect(error?.status).toBe(400); }); it("should reject invalid message", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.verify({ message: "invalid_message", signature: "valid_signature", walletAddress, }); expect(error).toBeDefined(); expect(error?.status).toBe(401); }); it("should reject verification without email when anonymous is false", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, anonymous: false, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, email: undefined, }); expect(error).toBeDefined(); expect(error?.status).toBe(400); expect(error?.message).toBe("Invalid body parameters"); }); it("should accept verification with email when anonymous is false", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, anonymous: false, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); await client.siwe.nonce({ walletAddress, chainId }); const { data, error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, email: "[email protected]", }); expect(error).toBeNull(); expect(data?.success).toBe(true); }); it("should reject invalid email format when anonymous is false", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, anonymous: false, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); const { error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, email: "not-an-email", }); expect(error).toBeDefined(); expect(error?.status).toBe(400); expect(error?.message).toBe("Invalid body parameters"); }); it("should allow verification without email when anonymous is true", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, // anonymous: true by default async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()], }, }, ); await client.siwe.nonce({ walletAddress, chainId }); const { data, error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, }); expect(error).toBeNull(); expect(data?.success).toBe(true); }); it("should not allow nonce reuse", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()] }, }, ); await client.siwe.nonce({ walletAddress, chainId }); const first = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, }); expect(first.error).toBeNull(); expect(first.data?.success).toBe(true); // Try to verify again with the same nonce const second = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, }); expect(second.error).toBeDefined(); expect(second.error?.status).toBe(401); expect(second.error?.code).toBe("UNAUTHORIZED_INVALID_OR_EXPIRED_NONCE"); }); it("should reject empty string email when anonymous is false", async () => { const { client } = await getTestInstance( { plugins: [ siwe({ domain, anonymous: false, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()] }, }, ); await client.siwe.nonce({ walletAddress, chainId }); const { error } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress, chainId, email: "", }); expect(error).toBeDefined(); expect(error?.status).toBe(400); expect(error?.message).toBe("Invalid body parameters"); }); it("should store and return the wallet address in checksum format", async () => { const { client, auth } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()] }, }, ); // Use lowercase address await client.siwe.nonce({ walletAddress: walletAddress.toLowerCase(), chainId, }); const { data } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: walletAddress.toLowerCase(), chainId, }); expect(data?.success).toBe(true); // Fetch wallet address from the adapter const walletAddresses: any[] = await (await auth.$context).adapter.findMany( { model: "walletAddress", where: [{ field: "address", operator: "eq", value: walletAddress }], }, ); expect(walletAddresses.length).toBe(1); expect(walletAddresses[0]?.address).toBe(walletAddress); // checksummed // Try with uppercase address, should not create a new wallet address entry await client.siwe.nonce({ walletAddress: walletAddress.toUpperCase(), chainId, }); const { data: data2, error: error2 } = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: walletAddress.toUpperCase(), chainId, }); expect(data2?.success).toBe(true); // Should succeed with existing address const walletAddressesAfter = await (await auth.$context).adapter.findMany({ model: "walletAddress", where: [{ field: "address", operator: "eq", value: walletAddress }], }); expect(walletAddressesAfter.length).toBe(1); // Still only one wallet address entry }); it("should reject duplicate wallet address entries", async () => { const { client, auth } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()] } }, ); const testAddress = "0x000000000000000000000000000000000000dEaD"; const testChainId = 1; // First user successfully creates account with wallet address await client.siwe.nonce({ walletAddress: testAddress, chainId: testChainId, }); const firstUser = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: testAddress, chainId: testChainId, }); expect(firstUser.error).toBeNull(); expect(firstUser.data?.success).toBe(true); // Verify wallet address record was created const walletAddresses: any[] = await (await auth.$context).adapter.findMany( { model: "walletAddress", where: [ { field: "address", operator: "eq", value: testAddress }, { field: "chainId", operator: "eq", value: testChainId }, ], }, ); expect(walletAddresses.length).toBe(1); expect(walletAddresses[0]?.address).toBe(testAddress); expect(walletAddresses[0]?.chainId).toBe(testChainId); expect(walletAddresses[0]?.isPrimary).toBe(true); // Second attempt with same address + chainId should use existing user await client.siwe.nonce({ walletAddress: testAddress, chainId: testChainId, }); const secondUser = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: testAddress, chainId: testChainId, }); expect(secondUser.error).toBeNull(); expect(secondUser.data?.success).toBe(true); expect(secondUser.data?.user.id).toBe(firstUser.data?.user.id); // Same user ID // Verify no duplicate wallet address records were created const walletAddressesAfter: any[] = await ( await auth.$context ).adapter.findMany({ model: "walletAddress", where: [ { field: "address", operator: "eq", value: testAddress }, { field: "chainId", operator: "eq", value: testChainId }, ], }); expect(walletAddressesAfter.length).toBe(1); // Still only one record // Verify total user count (should be only 1 user created) const allUsers: any[] = await (await auth.$context).adapter.findMany({ model: "user", }); const usersWithTestAddress = allUsers.filter((user) => walletAddressesAfter.some((wa) => wa.userId === user.id), ); expect(usersWithTestAddress.length).toBe(1); // Only one user should have this address }); it("should support custom schema with mergeSchema", async () => { const { client, auth } = await getTestInstance( { logger: { level: "debug", }, plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, schema: { walletAddress: { modelName: "wallet_address", fields: { userId: "user_id", address: "wallet_address", chainId: "chain_id", isPrimary: "is_primary", createdAt: "created_at", }, }, }, }), ], }, { clientOptions: { plugins: [siweClient()] } }, ); const testAddress = "0x000000000000000000000000000000000000dEaD"; const testChainId = 1; // Create account with custom schema await client.siwe.nonce({ walletAddress: testAddress, chainId: testChainId, }); const result = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: testAddress, chainId: testChainId, }); expect(result.error).toBeNull(); expect(result.data?.success).toBe(true); const context = await auth.$context; const walletAddresses: any[] = await context.adapter.findMany({ model: "walletAddress", where: [ { field: "address", operator: "eq", value: testAddress }, { field: "chainId", operator: "eq", value: testChainId }, ], }); expect(walletAddresses.length).toBe(1); expect(walletAddresses[0]?.address).toBe(testAddress); expect(walletAddresses[0]?.chainId).toBe(testChainId); expect(walletAddresses[0]?.isPrimary).toBe(true); expect(walletAddresses[0]?.userId).toBeDefined(); expect(walletAddresses[0]?.createdAt).toBeDefined(); }); it("should allow same address on different chains for same user", async () => { const { client, auth } = await getTestInstance( { plugins: [ siwe({ domain, async getNonce() { return "A1b2C3d4E5f6G7h8J"; }, async verifyMessage({ message, signature }) { return ( signature === "valid_signature" && message === "valid_message" ); }, }), ], }, { clientOptions: { plugins: [siweClient()] } }, ); const testAddress = "0x000000000000000000000000000000000000dEaD"; const chainId1 = 1; // Ethereum const chainId2 = 137; // Polygon // First authentication on Ethereum await client.siwe.nonce({ walletAddress: testAddress, chainId: chainId1 }); const ethereumAuth = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: testAddress, chainId: chainId1, }); expect(ethereumAuth.error).toBeNull(); expect(ethereumAuth.data?.success).toBe(true); // Second authentication on Polygon with same address await client.siwe.nonce({ walletAddress: testAddress, chainId: chainId2 }); const polygonAuth = await client.siwe.verify({ message: "valid_message", signature: "valid_signature", walletAddress: testAddress, chainId: chainId2, }); expect(polygonAuth.error).toBeNull(); expect(polygonAuth.data?.success).toBe(true); expect(polygonAuth.data?.user.id).toBe(ethereumAuth.data?.user.id); // Same user // Verify both wallet address records exist const allWalletAddresses: any[] = await ( await auth.$context ).adapter.findMany({ model: "walletAddress", where: [{ field: "address", operator: "eq", value: testAddress }], }); expect(allWalletAddresses.length).toBe(2); const ethereumRecord = allWalletAddresses.find( (wa) => wa.chainId === chainId1, ); const polygonRecord = allWalletAddresses.find( (wa) => wa.chainId === chainId2, ); expect(ethereumRecord).toBeDefined(); expect(polygonRecord).toBeDefined(); expect(ethereumRecord?.isPrimary).toBe(true); // First address is primary expect(polygonRecord?.isPrimary).toBe(false); // Second address is not primary expect(ethereumRecord?.userId).toBe(polygonRecord?.userId); // Same user ID }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/account.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { APIError } from "better-call"; import type { OAuth2Tokens } from "@better-auth/core/oauth2"; import { freshSessionMiddleware, getSessionFromCtx, sessionMiddleware, } from "./session"; import { BASE_ERROR_CODES } from "@better-auth/core/error"; import { SocialProviderListEnum } from "@better-auth/core/social-providers"; import { generateState } from "../../oauth2/state"; import { decryptOAuthToken, setTokenUtil } from "../../oauth2/utils"; export const listUserAccounts = createAuthEndpoint( "/list-accounts", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { description: "List all accounts linked to the user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { id: { type: "string", }, providerId: { type: "string", }, createdAt: { type: "string", format: "date-time", }, updatedAt: { type: "string", format: "date-time", }, accountId: { type: "string", }, scopes: { type: "array", items: { type: "string", }, }, }, required: [ "id", "providerId", "createdAt", "updatedAt", "accountId", "scopes", ], }, }, }, }, }, }, }, }, }, async (c) => { const session = c.context.session; const accounts = await c.context.internalAdapter.findAccounts( session.user.id, ); return c.json( accounts.map((a) => ({ id: a.id, providerId: a.providerId, createdAt: a.createdAt, updatedAt: a.updatedAt, accountId: a.accountId, scopes: a.scope?.split(",") || [], })), ); }, ); export const linkSocialAccount = createAuthEndpoint( "/link-social", { method: "POST", requireHeaders: true, body: z.object({ /** * Callback URL to redirect to after the user has signed in. */ callbackURL: z .string() .meta({ description: "The URL to redirect to after the user has signed in", }) .optional(), /** * OAuth2 provider to use */ provider: SocialProviderListEnum, /** * ID Token for direct authentication without redirect */ idToken: z .object({ token: z.string(), nonce: z.string().optional(), accessToken: z.string().optional(), refreshToken: z.string().optional(), scopes: z.array(z.string()).optional(), }) .optional(), /** * Whether to allow sign up for new users */ requestSignUp: z.boolean().optional(), /** * Additional scopes to request when linking the account. * This is useful for requesting additional permissions when * linking a social account compared to the initial authentication. */ scopes: z .array(z.string()) .meta({ description: "Additional scopes to request from the provider", }) .optional(), /** * The URL to redirect to if there is an error during the link process. */ errorCallbackURL: z .string() .meta({ description: "The URL to redirect to if there is an error during the link process", }) .optional(), /** * Disable automatic redirection to the provider * * This is useful if you want to handle the redirection * yourself like in a popup or a different tab. */ disableRedirect: z .boolean() .meta({ description: "Disable automatic redirection to the provider. Useful for handling the redirection yourself", }) .optional(), }), use: [sessionMiddleware], metadata: { openapi: { description: "Link a social account to the user", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { url: { type: "string", description: "The authorization URL to redirect the user to", }, redirect: { type: "boolean", description: "Indicates if the user should be redirected to the authorization URL", }, status: { type: "boolean", }, }, required: ["redirect"], }, }, }, }, }, }, }, }, async (c) => { const session = c.context.session; const provider = c.context.socialProviders.find( (p) => p.id === c.body.provider, ); if (!provider) { c.context.logger.error( "Provider not found. Make sure to add the provider in your auth config", { provider: c.body.provider, }, ); throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.PROVIDER_NOT_FOUND, }); } // Handle ID Token flow if provided if (c.body.idToken) { if (!provider.verifyIdToken) { c.context.logger.error( "Provider does not support id token verification", { provider: c.body.provider, }, ); throw new APIError("NOT_FOUND", { message: BASE_ERROR_CODES.ID_TOKEN_NOT_SUPPORTED, }); } const { token, nonce } = c.body.idToken; const valid = await provider.verifyIdToken(token, nonce); if (!valid) { c.context.logger.error("Invalid id token", { provider: c.body.provider, }); throw new APIError("UNAUTHORIZED", { message: BASE_ERROR_CODES.INVALID_TOKEN, }); } const linkingUserInfo = await provider.getUserInfo({ idToken: token, accessToken: c.body.idToken.accessToken, refreshToken: c.body.idToken.refreshToken, }); if (!linkingUserInfo || !linkingUserInfo?.user) { c.context.logger.error("Failed to get user info", { provider: c.body.provider, }); throw new APIError("UNAUTHORIZED", { message: BASE_ERROR_CODES.FAILED_TO_GET_USER_INFO, }); } const linkingUserId = String(linkingUserInfo.user.id); if (!linkingUserInfo.user.email) { c.context.logger.error("User email not found", { provider: c.body.provider, }); throw new APIError("UNAUTHORIZED", { message: BASE_ERROR_CODES.USER_EMAIL_NOT_FOUND, }); } const existingAccounts = await c.context.internalAdapter.findAccounts( session.user.id, ); const hasBeenLinked = existingAccounts.find( (a) => a.providerId === provider.id && a.accountId === linkingUserId, ); if (hasBeenLinked) { return c.json({ url: "", // this is for type inference status: true, redirect: false, }); } const trustedProviders = c.context.options.account?.accountLinking?.trustedProviders; const isTrustedProvider = trustedProviders?.includes(provider.id); if ( (!isTrustedProvider && !linkingUserInfo.user.emailVerified) || c.context.options.account?.accountLinking?.enabled === false ) { throw new APIError("UNAUTHORIZED", { message: "Account not linked - linking not allowed", }); } if ( linkingUserInfo.user.email !== session.user.email && c.context.options.account?.accountLinking?.allowDifferentEmails !== true ) { throw new APIError("UNAUTHORIZED", { message: "Account not linked - different emails not allowed", }); } try { await c.context.internalAdapter.createAccount({ userId: session.user.id, providerId: provider.id, accountId: linkingUserId, accessToken: c.body.idToken.accessToken, idToken: token, refreshToken: c.body.idToken.refreshToken, scope: c.body.idToken.scopes?.join(","), }); } catch (e: any) { throw new APIError("EXPECTATION_FAILED", { message: "Account not linked - unable to create account", }); } if ( c.context.options.account?.accountLinking?.updateUserInfoOnLink === true ) { try { await c.context.internalAdapter.updateUser(session.user.id, { name: linkingUserInfo.user?.name, image: linkingUserInfo.user?.image, }); } catch (e: any) { console.warn("Could not update user - " + e.toString()); } } return c.json({ url: "", // this is for type inference status: true, redirect: false, }); } // Handle OAuth flow const state = await generateState(c, { userId: session.user.id, email: session.user.email, }); const url = await provider.createAuthorizationURL({ state: state.state, codeVerifier: state.codeVerifier, redirectURI: `${c.context.baseURL}/callback/${provider.id}`, scopes: c.body.scopes, }); return c.json({ url: url.toString(), redirect: !c.body.disableRedirect, }); }, ); export const unlinkAccount = createAuthEndpoint( "/unlink-account", { method: "POST", body: z.object({ providerId: z.string(), accountId: z.string().optional(), }), use: [freshSessionMiddleware], metadata: { openapi: { description: "Unlink an account", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { status: { type: "boolean", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const { providerId, accountId } = ctx.body; const accounts = await ctx.context.internalAdapter.findAccounts( ctx.context.session.user.id, ); if ( accounts.length === 1 && !ctx.context.options.account?.accountLinking?.allowUnlinkingAll ) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.FAILED_TO_UNLINK_LAST_ACCOUNT, }); } const accountExist = accounts.find((account) => accountId ? account.accountId === accountId && account.providerId === providerId : account.providerId === providerId, ); if (!accountExist) { throw new APIError("BAD_REQUEST", { message: BASE_ERROR_CODES.ACCOUNT_NOT_FOUND, }); } await ctx.context.internalAdapter.deleteAccount(accountExist.id); return ctx.json({ status: true, }); }, ); export const getAccessToken = createAuthEndpoint( "/get-access-token", { method: "POST", body: z.object({ providerId: z.string().meta({ description: "The provider ID for the OAuth provider", }), accountId: z .string() .meta({ description: "The account ID associated with the refresh token", }) .optional(), userId: z .string() .meta({ description: "The user ID associated with the account", }) .optional(), }), metadata: { openapi: { description: "Get a valid access token, doing a refresh if needed", responses: { 200: { description: "A Valid access token", content: { "application/json": { schema: { type: "object", properties: { tokenType: { type: "string", }, idToken: { type: "string", }, accessToken: { type: "string", }, refreshToken: { type: "string", }, accessTokenExpiresAt: { type: "string", format: "date-time", }, refreshTokenExpiresAt: { type: "string", format: "date-time", }, }, }, }, }, }, 400: { description: "Invalid refresh token or provider configuration", }, }, }, }, }, async (ctx) => { const { providerId, accountId, userId } = ctx.body; const req = ctx.request; const session = await getSessionFromCtx(ctx); if (req && !session) { throw ctx.error("UNAUTHORIZED"); } let resolvedUserId = session?.user?.id || userId; if (!resolvedUserId) { throw new APIError("BAD_REQUEST", { message: `Either userId or session is required`, }); } if (!ctx.context.socialProviders.find((p) => p.id === providerId)) { throw new APIError("BAD_REQUEST", { message: `Provider ${providerId} is not supported.`, }); } const accounts = await ctx.context.internalAdapter.findAccounts(resolvedUserId); const account = accounts.find((acc) => accountId ? acc.id === accountId && acc.providerId === providerId : acc.providerId === providerId, ); if (!account) { throw new APIError("BAD_REQUEST", { message: "Account not found", }); } const provider = ctx.context.socialProviders.find( (p) => p.id === providerId, ); if (!provider) { throw new APIError("BAD_REQUEST", { message: `Provider ${providerId} not found.`, }); } try { let newTokens: OAuth2Tokens | null = null; const accessTokenExpired = account.accessTokenExpiresAt && new Date(account.accessTokenExpiresAt).getTime() - Date.now() < 5_000; if ( account.refreshToken && accessTokenExpired && provider.refreshAccessToken ) { const refreshToken = await decryptOAuthToken( account.refreshToken, ctx.context, ); newTokens = await provider.refreshAccessToken(refreshToken); await ctx.context.internalAdapter.updateAccount(account.id, { accessToken: await setTokenUtil(newTokens.accessToken, ctx.context), accessTokenExpiresAt: newTokens.accessTokenExpiresAt, refreshToken: await setTokenUtil(newTokens.refreshToken, ctx.context), refreshTokenExpiresAt: newTokens.refreshTokenExpiresAt, }); } const tokens = { accessToken: newTokens?.accessToken ?? (await decryptOAuthToken(account.accessToken ?? "", ctx.context)), accessTokenExpiresAt: newTokens?.accessTokenExpiresAt ?? account.accessTokenExpiresAt ?? undefined, scopes: account.scope?.split(",") ?? [], idToken: newTokens?.idToken ?? account.idToken ?? undefined, }; return ctx.json(tokens); } catch (error) { throw new APIError("BAD_REQUEST", { message: "Failed to get a valid access token", cause: error, }); } }, ); export const refreshToken = createAuthEndpoint( "/refresh-token", { method: "POST", body: z.object({ providerId: z.string().meta({ description: "The provider ID for the OAuth provider", }), accountId: z .string() .meta({ description: "The account ID associated with the refresh token", }) .optional(), userId: z .string() .meta({ description: "The user ID associated with the account", }) .optional(), }), metadata: { openapi: { description: "Refresh the access token using a refresh token", responses: { 200: { description: "Access token refreshed successfully", content: { "application/json": { schema: { type: "object", properties: { tokenType: { type: "string", }, idToken: { type: "string", }, accessToken: { type: "string", }, refreshToken: { type: "string", }, accessTokenExpiresAt: { type: "string", format: "date-time", }, refreshTokenExpiresAt: { type: "string", format: "date-time", }, }, }, }, }, }, 400: { description: "Invalid refresh token or provider configuration", }, }, }, }, }, async (ctx) => { const { providerId, accountId, userId } = ctx.body; const req = ctx.request; const session = await getSessionFromCtx(ctx); if (req && !session) { throw ctx.error("UNAUTHORIZED"); } let resolvedUserId = session?.user?.id || userId; if (!resolvedUserId) { throw new APIError("BAD_REQUEST", { message: `Either userId or session is required`, }); } const accounts = await ctx.context.internalAdapter.findAccounts(resolvedUserId); const account = accounts.find((acc) => accountId ? acc.id === accountId && acc.providerId === providerId : acc.providerId === providerId, ); if (!account) { throw new APIError("BAD_REQUEST", { message: "Account not found", }); } const provider = ctx.context.socialProviders.find( (p) => p.id === providerId, ); if (!provider) { throw new APIError("BAD_REQUEST", { message: `Provider ${providerId} not found.`, }); } if (!provider.refreshAccessToken) { throw new APIError("BAD_REQUEST", { message: `Provider ${providerId} does not support token refreshing.`, }); } try { const tokens: OAuth2Tokens = await provider.refreshAccessToken( account.refreshToken as string, ); await ctx.context.internalAdapter.updateAccount(account.id, { accessToken: await setTokenUtil(tokens.accessToken, ctx.context), refreshToken: await setTokenUtil(tokens.refreshToken, ctx.context), accessTokenExpiresAt: tokens.accessTokenExpiresAt, refreshTokenExpiresAt: tokens.refreshTokenExpiresAt, }); return ctx.json(tokens); } catch (error) { throw new APIError("BAD_REQUEST", { message: "Failed to refresh access token", cause: error, }); } }, ); export const accountInfo = createAuthEndpoint( "/account-info", { method: "POST", use: [sessionMiddleware], metadata: { openapi: { description: "Get the account info provided by the provider", responses: { "200": { description: "Success", content: { "application/json": { schema: { type: "object", properties: { user: { type: "object", properties: { id: { type: "string", }, name: { type: "string", }, email: { type: "string", }, image: { type: "string", }, emailVerified: { type: "boolean", }, }, required: ["id", "emailVerified"], }, data: { type: "object", properties: {}, additionalProperties: true, }, }, required: ["user", "data"], additionalProperties: false, }, }, }, }, }, }, }, body: z.object({ accountId: z.string().meta({ description: "The provider given account id for which to get the account info", }), }), }, async (ctx) => { const account = await ctx.context.internalAdapter.findAccount( ctx.body.accountId, ); if (!account || account.userId !== ctx.context.session.user.id) { throw new APIError("BAD_REQUEST", { message: "Account not found", }); } const provider = ctx.context.socialProviders.find( (p) => p.id === account.providerId, ); if (!provider) { throw new APIError("INTERNAL_SERVER_ERROR", { message: `Provider account provider is ${account.providerId} but it is not configured`, }); } const tokens = await getAccessToken({ ...ctx, body: { accountId: account.id, providerId: account.providerId, }, returnHeaders: false, }); if (!tokens.accessToken) { throw new APIError("BAD_REQUEST", { message: "Access token not found", }); } const info = await provider.getUserInfo({ ...tokens, accessToken: tokens.accessToken as string, }); return ctx.json(info); }, ); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/api/routes/session-api.test.ts: -------------------------------------------------------------------------------- ```typescript import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; import { getTestInstance } from "../../test-utils/test-instance"; import { parseSetCookieHeader } from "../../cookies"; import { getDate } from "../../utils/date"; import { memoryAdapter, type MemoryDB } from "../../adapters/memory-adapter"; import { runWithEndpointContext } from "@better-auth/core/context"; import type { GenericEndpointContext } from "@better-auth/core"; describe("session", async () => { const { client, testUser, sessionSetter, cookieSetter, auth } = await getTestInstance(); it("should set cookies correctly on sign in", async () => { const headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); cookieSetter(headers)(context); const cookie = cookies.get("better-auth.session_token"); expect(cookie).toMatchObject({ value: expect.any(String), "max-age": 60 * 60 * 24 * 7, path: "/", samesite: "lax", httponly: true, }); }, }, ); const { data } = await client.getSession({ fetchOptions: { headers, }, }); const expiresAt = new Date(data?.session.expiresAt || ""); const now = new Date(); expect(expiresAt.getTime()).toBeGreaterThan( now.getTime() + 6 * 24 * 60 * 60 * 1000, ); }); it("should return null when not authenticated", async () => { const response = await client.getSession(); expect(response.data).toBeNull(); }); it("should update session when update age is reached", async () => { const { client, testUser } = await getTestInstance({ session: { updateAge: 60, expiresIn: 60 * 2, }, }); let headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); const signedCookie = cookies.get("better-auth.session_token")?.value; headers.set("cookie", `better-auth.session_token=${signedCookie}`); }, }, ); const data = await client.getSession({ fetchOptions: { headers, throw: true, }, }); if (!data) { throw new Error("No session found"); } expect(new Date(data?.session.expiresAt).getTime()).toBeGreaterThan( new Date(Date.now() + 1000 * 2 * 59).getTime(), ); expect(new Date(data?.session.expiresAt).getTime()).toBeLessThan( new Date(Date.now() + 1000 * 2 * 60).getTime(), ); for (const t of [60, 80, 100, 121]) { const span = new Date(); span.setSeconds(span.getSeconds() + t); vi.setSystemTime(span); const response = await client.getSession({ fetchOptions: { headers, onSuccess(context) { const parsed = parseSetCookieHeader( context.response.headers.get("set-cookie") || "", ); const maxAge = parsed.get("better-auth.session_token")?.["max-age"]; expect(maxAge).toBe(t === 121 ? 0 : 60 * 2); }, }, }); if (t === 121) { //expired expect(response.data).toBeNull(); } else { expect( new Date(response.data?.session.expiresAt!).getTime(), ).toBeGreaterThan(new Date(Date.now() + 1000 * 2 * 59).getTime()); } } vi.useRealTimers(); }); it("should update the session every time when set to 0", async () => { const { client, signInWithTestUser } = await getTestInstance({ session: { updateAge: 0, }, }); const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const session = await client.getSession(); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(1000 * 60 * 5); const session2 = await client.getSession(); expect(session2.data?.session.expiresAt).not.toBe( session.data?.session.expiresAt, ); expect( new Date(session2.data!.session.expiresAt).getTime(), ).toBeGreaterThan(new Date(session.data!.session.expiresAt).getTime()); }); }); it("should handle 'don't remember me' option", async () => { let headers = new Headers(); const res = await client.signIn.email( { email: testUser.email, password: testUser.password, rememberMe: false, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); const signedCookie = cookies.get("better-auth.session_token")?.value; const dontRememberMe = cookies.get( "better-auth.dont_remember", )?.value; headers.set( "cookie", `better-auth.session_token=${signedCookie};better-auth.dont_remember=${dontRememberMe}`, ); }, }, ); const data = await client.getSession({ fetchOptions: { headers, throw: true, }, }); if (!data) { throw new Error("No session found"); } const expiresAt = data.session.expiresAt; expect(new Date(expiresAt).valueOf()).toBeLessThanOrEqual( getDate(1000 * 60 * 60 * 24).valueOf(), ); const response = await client.getSession({ fetchOptions: { headers, }, }); if (!response.data?.session) { throw new Error("No session found"); } // Check that the session wasn't update expect( new Date(response.data.session.expiresAt).valueOf(), ).toBeLessThanOrEqual(getDate(1000 * 60 * 60 * 24).valueOf()); }); it("should set cookies correctly on sign in after changing config", async () => { const headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); expect(cookies.get("better-auth.session_token")).toMatchObject({ value: expect.any(String), "max-age": 60 * 60 * 24 * 7, path: "/", httponly: true, samesite: "lax", }); headers.set( "cookie", `better-auth.session_token=${ cookies.get("better-auth.session_token")?.value }`, ); }, }, ); const data = await client.getSession({ fetchOptions: { headers, throw: true, }, }); if (!data) { throw new Error("No session found"); } const expiresAt = new Date(data?.session?.expiresAt || ""); const now = new Date(); expect(expiresAt.getTime()).toBeGreaterThan( now.getTime() + 6 * 24 * 60 * 60 * 1000, ); }); it("should clear session on sign out", async () => { let headers = new Headers(); const res = await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); const signedCookie = cookies.get("better-auth.session_token")?.value; headers.set("cookie", `better-auth.session_token=${signedCookie}`); }, }, ); const data = await client.getSession({ fetchOptions: { headers, throw: true, }, }); expect(data).not.toBeNull(); await client.signOut({ fetchOptions: { headers, }, }); const response = await client.getSession({ fetchOptions: { headers, }, }); expect(response.data); }); it("should list sessions", async () => { const headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess: sessionSetter(headers), }, ); const response = await client.listSessions({ fetchOptions: { headers, }, }); expect(response.data?.length).toBeGreaterThan(1); }); it("should revoke session", async () => { const headers = new Headers(); const headers2 = new Headers(); const res = await client.signIn.email({ email: testUser.email, password: testUser.password, fetchOptions: { onSuccess: sessionSetter(headers), }, }); await client.signIn.email({ email: testUser.email, password: testUser.password, fetchOptions: { onSuccess: sessionSetter(headers2), }, }); const session = await client.getSession({ fetchOptions: { headers, throw: true, }, }); await client.revokeSession({ fetchOptions: { headers, }, token: session?.session?.token || "", }); const newSession = await client.getSession({ fetchOptions: { headers, }, }); expect(newSession.data).toBeNull(); const revokeRes = await client.revokeSessions({ fetchOptions: { headers: headers2, }, }); expect(revokeRes.data?.status).toBe(true); }); it("should return session headers", async () => { const context = await auth.$context; await runWithEndpointContext( { context, } as unknown as GenericEndpointContext, async () => { const signInRes = await auth.api.signInEmail({ body: { email: testUser.email, password: testUser.password, }, returnHeaders: true, }); const signInHeaders = new Headers(); signInHeaders.set("cookie", signInRes.headers.getSetCookie()[0]!); const sessionResWithoutHeaders = await auth.api.getSession({ headers: signInHeaders, }); const sessionResWithHeaders = await auth.api.getSession({ headers: signInHeaders, returnHeaders: true, }); expect(sessionResWithHeaders.headers).toBeDefined(); expect(sessionResWithHeaders.response?.user).toBeDefined(); expect(sessionResWithHeaders.response?.session).toBeDefined(); expectTypeOf({ headers: sessionResWithHeaders.headers, }).toMatchObjectType<{ headers: Headers; }>(); // @ts-expect-error: headers should not exist on sessionResWithoutHeaders expect(sessionResWithoutHeaders.headers).toBeUndefined(); const sessionResWithHeadersAndAsResponse = await auth.api.getSession({ headers: signInHeaders, returnHeaders: true, asResponse: true, }); expectTypeOf({ res: sessionResWithHeadersAndAsResponse, }).toMatchObjectType<{ res: Response }>(); expect(sessionResWithHeadersAndAsResponse.ok).toBe(true); expect(sessionResWithHeadersAndAsResponse.status).toBe(200); }, ); }); }); describe("session storage", async () => { let store = new Map<string, string>(); const { client, signInWithTestUser, db } = await getTestInstance({ secondaryStorage: { set(key, value, ttl) { store.set(key, value); }, get(key) { return store.get(key) || null; }, delete(key) { store.delete(key); }, }, rateLimit: { enabled: false, }, }); beforeEach(() => { store.clear(); }); it("should store session in secondary storage", async () => { //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) expect(store.size).toBe(0); const { runWithUser } = await signInWithTestUser(); expect(store.size).toBe(2); await runWithUser(async () => { const session = await client.getSession(); expect(session.data).toMatchObject({ session: { userId: expect.any(String), token: expect.any(String), expiresAt: expect.any(Date), ipAddress: expect.any(String), userAgent: expect.any(String), }, user: { id: expect.any(String), name: "test user", email: "[email protected]", emailVerified: false, image: null, createdAt: expect.any(Date), updatedAt: expect.any(Date), }, }); }); }); it("should list sessions", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const response = await client.listSessions(); expect(response.data?.length).toBe(1); }); }); it("revoke session and list sessions", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const session = await client.getSession(); expect(session.data).not.toBeNull(); expect(session.data?.session?.token).toBeDefined(); const userId = session.data!.session.userId; const sessions = JSON.parse(store.get(`active-sessions-${userId}`)!); expect(sessions.length).toBe(1); const res = await client.revokeSession({ token: session.data?.session?.token!, }); expect(res.data?.status).toBe(true); const response = await client.listSessions(); expect(response.data).toBe(null); expect(store.size).toBe(0); }); }); it("should revoke session", async () => { const { runWithUser } = await signInWithTestUser(); await runWithUser(async () => { const session = await client.getSession(); expect(session.data).not.toBeNull(); const res = await client.revokeSession({ token: session.data?.session?.token || "", }); const revokedSession = await client.getSession(); expect(revokedSession.data).toBeNull(); }); }); }); describe("cookie cache", async () => { const database: MemoryDB = { user: [], account: [], session: [], verification: [], }; const adapter = memoryAdapter(database); const { client, testUser, auth, cookieSetter } = await getTestInstance({ database: adapter, session: { additionalFields: { sensitiveData: { type: "string", returned: false, defaultValue: "sensitive-data", }, }, cookieCache: { enabled: true, strategy: "base64-hmac", // Use legacy strategy for these tests }, }, }); const ctx = await auth.$context; it("should cache cookies", async () => {}); const fn = vi.spyOn(ctx.adapter, "findOne"); const headers = new Headers(); it("should cache cookies", async () => { await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); headers.set( "cookie", `better-auth.session_token=${ cookies.get("better-auth.session_token")?.value };better-auth.session_data=${ cookies.get("better-auth.session_data")?.value }`, ); }, }, ); expect(fn).toHaveBeenCalledTimes(1); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.session).not.toHaveProperty("sensitiveData"); expect(session.data).not.toBeNull(); expect(fn).toHaveBeenCalledTimes(1); }); it("should disable cookie cache", async () => { const ctx = await auth.$context; const s = await client.getSession({ fetchOptions: { headers, }, }); expect(s.data?.user.emailVerified).toBe(false); await runWithEndpointContext( { context: ctx, } as unknown as GenericEndpointContext, async () => { await ctx.internalAdapter.updateUser(s.data?.user.id || "", { emailVerified: true, }); }, ); expect(fn).toHaveBeenCalledTimes(1); const session = await client.getSession({ query: { disableCookieCache: true, }, fetchOptions: { headers, }, }); expect(session.data?.user.emailVerified).toBe(true); expect(session.data).not.toBeNull(); expect(fn).toHaveBeenCalledTimes(3); }); it("should reset cache when expires", async () => { expect(fn).toHaveBeenCalledTimes(3); await client.getSession({ fetchOptions: { headers, }, }); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(1000 * 60 * 10); // 10 minutes await client.getSession({ fetchOptions: { headers, onSuccess(context) { cookieSetter(headers)(context); }, }, }); expect(fn).toHaveBeenCalledTimes(5); await client.getSession({ fetchOptions: { headers, onSuccess(context) { cookieSetter(headers)(context); }, }, }); expect(fn).toHaveBeenCalledTimes(5); }); }); describe("cookie cache with JWT strategy", async () => { const { auth, client, testUser, cookieSetter } = await getTestInstance({ session: { additionalFields: { sensitiveData: { type: "string", returned: false, defaultValue: "sensitive-data", }, }, cookieCache: { enabled: true, strategy: "jwt", // Use JWT (JWE with A256CBC-HS512 + HKDF) }, }, }); const ctx = await auth.$context; it("should cache cookies with JWT strategy", async () => {}); const fn = vi.spyOn(ctx.adapter, "findOne"); const headers = new Headers(); it("should cache cookies with JWT strategy", async () => { await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); headers.set( "cookie", `better-auth.session_token=${ cookies.get("better-auth.session_token")?.value };better-auth.session_data=${ cookies.get("better-auth.session_data")?.value }`, ); }, }, ); expect(fn).toHaveBeenCalledTimes(1); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.session).not.toHaveProperty("sensitiveData"); expect(session.data).not.toBeNull(); expect(fn).toHaveBeenCalledTimes(1); // Should still be 1 (cache hit) }); it("should disable cookie cache with JWT strategy", async () => { const ctx = await auth.$context; const s = await client.getSession({ fetchOptions: { headers, }, }); expect(s.data?.user.emailVerified).toBe(false); await runWithEndpointContext( { context: ctx, } as unknown as GenericEndpointContext, async () => { await ctx.internalAdapter.updateUser(s.data?.user.id || "", { emailVerified: true, }); }, ); expect(fn).toHaveBeenCalledTimes(1); const session = await client.getSession({ query: { disableCookieCache: true, }, fetchOptions: { headers, }, }); expect(session.data?.user.emailVerified).toBe(true); expect(session.data).not.toBeNull(); expect(fn).toHaveBeenCalledTimes(3); // Database hit when cache disabled }); it("should reset JWT cache when expires", async () => { expect(fn).toHaveBeenCalledTimes(3); await client.getSession({ fetchOptions: { headers, }, }); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(1000 * 60 * 10); // 10 minutes await client.getSession({ fetchOptions: { headers, onSuccess(context) { cookieSetter(headers)(context); }, }, }); expect(fn).toHaveBeenCalledTimes(5); await client.getSession({ fetchOptions: { headers, onSuccess(context) { cookieSetter(headers)(context); }, }, }); expect(fn).toHaveBeenCalledTimes(5); // Should use refreshed cache }); it("should handle multiple concurrent requests with JWT cache", async () => { vi.useRealTimers(); const headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { const header = context.response.headers.get("set-cookie"); const cookies = parseSetCookieHeader(header || ""); headers.set( "cookie", `better-auth.session_token=${ cookies.get("better-auth.session_token")?.value };better-auth.session_data=${ cookies.get("better-auth.session_data")?.value }`, ); }, }, ); // Make multiple concurrent requests const promises = Array(5) .fill(0) .map(() => client.getSession({ fetchOptions: { headers, }, }), ); const results = await Promise.all(promises); // All should return valid sessions results.forEach((result) => { expect(result.data).not.toBeNull(); expect(result.data?.user.email).toBe(testUser.email); }); }); }); ```