This is page 16 of 51. 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 ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── 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 │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /packages/cli/src/commands/login.ts: -------------------------------------------------------------------------------- ```typescript import { Command } from "commander"; import { logger } from "better-auth"; import { createAuthClient } from "better-auth/client"; import { deviceAuthorizationClient } from "better-auth/client/plugins"; import chalk from "chalk"; import open from "open"; import yoctoSpinner from "yocto-spinner"; import * as z from "zod/v4"; import { intro, outro, confirm, isCancel, cancel } from "@clack/prompts"; import fs from "fs/promises"; import path from "path"; import os from "os"; const DEMO_URL = "https://demo.better-auth.com"; const CLIENT_ID = "better-auth-cli"; const CONFIG_DIR = path.join(os.homedir(), ".better-auth"); const TOKEN_FILE = path.join(CONFIG_DIR, "token.json"); export async function loginAction(opts: any) { const options = z .object({ serverUrl: z.string().optional(), clientId: z.string().optional(), }) .parse(opts); const serverUrl = options.serverUrl || DEMO_URL; const clientId = options.clientId || CLIENT_ID; intro(chalk.bold("🔐 Better Auth CLI Login (Demo)")); console.log( chalk.yellow( "⚠️ This is a demo feature for testing device authorization flow.", ), ); console.log( chalk.gray( " It connects to the Better Auth demo server for testing purposes.\n", ), ); // Check if already logged in const existingToken = await getStoredToken(); if (existingToken) { const shouldReauth = await confirm({ message: "You're already logged in. Do you want to log in again?", initialValue: false, }); if (isCancel(shouldReauth) || !shouldReauth) { cancel("Login cancelled"); process.exit(0); } } // Create the auth client const authClient = createAuthClient({ baseURL: serverUrl, plugins: [deviceAuthorizationClient()], }); const spinner = yoctoSpinner({ text: "Requesting device authorization..." }); spinner.start(); try { // Request device code const { data, error } = await authClient.device.code({ client_id: clientId, scope: "openid profile email", }); spinner.stop(); if (error || !data) { logger.error( `Failed to request device authorization: ${error?.error_description || "Unknown error"}`, ); process.exit(1); } const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in, } = data; // Display authorization instructions console.log(""); console.log(chalk.cyan("📱 Device Authorization Required")); console.log(""); console.log(`Please visit: ${chalk.underline.blue(verification_uri)}`); console.log(`Enter code: ${chalk.bold.green(user_code)}`); console.log(""); // Ask if user wants to open browser const shouldOpen = await confirm({ message: "Open browser automatically?", initialValue: true, }); if (!isCancel(shouldOpen) && shouldOpen) { const urlToOpen = verification_uri_complete || verification_uri; await open(urlToOpen); } // Start polling console.log( chalk.gray( `Waiting for authorization (expires in ${Math.floor(expires_in / 60)} minutes)...`, ), ); const token = await pollForToken( authClient, device_code, clientId, interval, ); if (token) { // Store the token await storeToken(token); // Get user info const { data: session } = await authClient.getSession({ fetchOptions: { headers: { Authorization: `Bearer ${token.access_token}`, }, }, }); outro( chalk.green( `✅ Demo login successful! Logged in as ${session?.user?.name || session?.user?.email || "User"}`, ), ); console.log( chalk.gray( "\n📝 Note: This was a demo authentication for testing purposes.", ), ); console.log( chalk.blue( "\nFor more information, visit: https://better-auth.com/docs/plugins/device-authorization", ), ); } } catch (err) { spinner.stop(); logger.error( `Login failed: ${err instanceof Error ? err.message : "Unknown error"}`, ); process.exit(1); } } async function pollForToken( authClient: any, deviceCode: string, clientId: string, initialInterval: number, ): Promise<any> { let pollingInterval = initialInterval; const spinner = yoctoSpinner({ text: "", color: "cyan" }); let dots = 0; return new Promise((resolve, reject) => { const poll = async () => { // Update spinner text with animated dots dots = (dots + 1) % 4; spinner.text = chalk.gray( `Polling for authorization${".".repeat(dots)}${" ".repeat(3 - dots)}`, ); if (!spinner.isSpinning) spinner.start(); try { const { data, error } = await authClient.device.token({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: deviceCode, client_id: clientId, fetchOptions: { headers: { "user-agent": `Better Auth CLI`, }, }, }); if (data?.access_token) { spinner.stop(); resolve(data); return; } else if (error) { switch (error.error) { case "authorization_pending": // Continue polling break; case "slow_down": pollingInterval += 5; spinner.text = chalk.yellow( `Slowing down polling to ${pollingInterval}s`, ); break; case "access_denied": spinner.stop(); logger.error("Access was denied by the user"); process.exit(1); break; case "expired_token": spinner.stop(); logger.error("The device code has expired. Please try again."); process.exit(1); break; default: spinner.stop(); logger.error(`Error: ${error.error_description}`); process.exit(1); } } } catch (err) { spinner.stop(); logger.error( `Network error: ${err instanceof Error ? err.message : "Unknown error"}`, ); process.exit(1); } setTimeout(poll, pollingInterval * 1000); }; // Start polling after initial interval setTimeout(poll, pollingInterval * 1000); }); } async function storeToken(token: any): Promise<void> { try { // Ensure config directory exists await fs.mkdir(CONFIG_DIR, { recursive: true }); // Store token with metadata const tokenData = { access_token: token.access_token, token_type: token.token_type || "Bearer", scope: token.scope, created_at: new Date().toISOString(), }; await fs.writeFile(TOKEN_FILE, JSON.stringify(tokenData, null, 2), "utf-8"); } catch (error) { logger.warn("Failed to store authentication token locally"); } } async function getStoredToken(): Promise<any> { try { const data = await fs.readFile(TOKEN_FILE, "utf-8"); return JSON.parse(data); } catch { return null; } } export const login = new Command("login") .description( "Demo: Test device authorization flow with Better Auth demo server", ) .option("--server-url <url>", "The Better Auth server URL", DEMO_URL) .option("--client-id <id>", "The OAuth client ID", CLIENT_ID) .action(loginAction); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oauth-proxy/index.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { originCheck } from "../../api"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; import { symmetricDecrypt, symmetricEncrypt } from "../../crypto"; import type { BetterAuthPlugin } from "@better-auth/core"; import { env } from "@better-auth/core/env"; import { getOrigin } from "../../utils/url"; import type { EndpointContext } from "better-call"; function getVenderBaseURL() { const vercel = env.VERCEL_URL ? `https://${env.VERCEL_URL}` : undefined; const netlify = env.NETLIFY_URL; const render = env.RENDER_URL; const aws = env.AWS_LAMBDA_FUNCTION_NAME; const google = env.GOOGLE_CLOUD_FUNCTION_NAME; const azure = env.AZURE_FUNCTION_NAME; return vercel || netlify || render || aws || google || azure; } export interface OAuthProxyOptions { /** * The current URL of the application. * The plugin will attempt to infer the current URL from your environment * by checking the base URL from popular hosting providers, * from the request URL if invoked by a client, * or as a fallback, from the `baseURL` in your auth config. * If the URL is not inferred correctly, you can provide a value here." */ currentURL?: string; /** * If a request in a production url it won't be proxied. * * default to `BETTER_AUTH_URL` */ productionURL?: string; } /** * A proxy plugin, that allows you to proxy OAuth requests. * Useful for development and preview deployments where * the redirect URL can't be known in advance to add to the OAuth provider. */ export const oAuthProxy = (opts?: OAuthProxyOptions) => { const resolveCurrentURL = (ctx: EndpointContext<string, any>) => { return new URL( opts?.currentURL || ctx.request?.url || getVenderBaseURL() || ctx.context.baseURL, ); }; const checkSkipProxy = (ctx: EndpointContext<string, any>) => { // if skip proxy header is set, we don't need to proxy const skipProxy = ctx.request?.headers.get("x-skip-oauth-proxy"); if (skipProxy) { return true; } const productionURL = opts?.productionURL || env.BETTER_AUTH_URL; if (productionURL === ctx.context.options.baseURL) { return true; } return false; }; return { id: "oauth-proxy", options: opts, endpoints: { oAuthProxy: createAuthEndpoint( "/oauth-proxy-callback", { method: "GET", query: z.object({ callbackURL: z.string().meta({ description: "The URL to redirect to after the proxy", }), cookies: z.string().meta({ description: "The cookies to set after the proxy", }), }), use: [originCheck((ctx) => ctx.query.callbackURL)], metadata: { openapi: { description: "OAuth Proxy Callback", parameters: [ { in: "query", name: "callbackURL", required: true, description: "The URL to redirect to after the proxy", }, { in: "query", name: "cookies", required: true, description: "The cookies to set after the proxy", }, ], responses: { 302: { description: "Redirect", headers: { Location: { description: "The URL to redirect to", schema: { type: "string", }, }, }, }, }, }, }, }, async (ctx) => { const cookies = ctx.query.cookies; const decryptedCookies = await symmetricDecrypt({ key: ctx.context.secret, data: cookies, }).catch((e) => { ctx.context.logger.error(e); return null; }); const error = ctx.context.options.onAPIError?.errorURL || `${ctx.context.options.baseURL}/api/auth/error`; if (!decryptedCookies) { throw ctx.redirect( `${error}?error=OAuthProxy - Invalid cookies or secret`, ); } const isSecureContext = resolveCurrentURL(ctx).protocol === "https:"; const prefix = ctx.context.options.advanced?.cookiePrefix || "better-auth"; const cookieToSet = isSecureContext ? decryptedCookies : decryptedCookies .replace("Secure;", "") .replace(`__Secure-${prefix}`, prefix); ctx.setHeader("set-cookie", cookieToSet); throw ctx.redirect(ctx.query.callbackURL); }, ), }, hooks: { after: [ { matcher(context) { return !!( context.path?.startsWith("/callback") || context.path?.startsWith("/oauth2/callback") ); }, handler: createAuthMiddleware(async (ctx) => { const headers = ctx.context.responseHeaders; const location = headers?.get("location"); if (location?.includes("/oauth-proxy-callback?callbackURL")) { if (!location.startsWith("http")) { return; } const locationURL = new URL(location); const origin = locationURL.origin; /** * We don't want to redirect to the proxy URL if the origin is the same * as the current URL */ const productionURL = opts?.productionURL || ctx.context.options.baseURL || ctx.context.baseURL; if (origin === getOrigin(productionURL)) { const newLocation = locationURL.searchParams.get("callbackURL"); if (!newLocation) { return; } ctx.setHeader("location", newLocation); return; } const setCookies = headers?.get("set-cookie"); if (!setCookies) { return; } const encryptedCookies = await symmetricEncrypt({ key: ctx.context.secret, data: setCookies, }); const locationWithCookies = `${location}&cookies=${encodeURIComponent( encryptedCookies, )}`; ctx.setHeader("location", locationWithCookies); } }), }, ], before: [ { matcher() { return true; }, handler: createAuthMiddleware(async (ctx) => { const skipProxy = checkSkipProxy(ctx); if (skipProxy || ctx.path !== "/callback/:id") { return; } return { context: { context: { oauthConfig: { skipStateCookieCheck: true, }, }, }, }; }), }, { matcher(context) { return !!( context.path?.startsWith("/sign-in/social") || context.path?.startsWith("/sign-in/oauth2") ); }, handler: createAuthMiddleware(async (ctx) => { const skipProxy = checkSkipProxy(ctx); if (skipProxy) { return; } const url = resolveCurrentURL(ctx); if (!ctx.body) { return; } ctx.body.callbackURL = `${url.origin}${ ctx.context.options.basePath || "/api/auth" }/oauth-proxy-callback?callbackURL=${encodeURIComponent( ctx.body.callbackURL || ctx.context.baseURL, )}`; return { context: ctx, }; }), }, ], }, } satisfies BetterAuthPlugin; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/db/get-tables.ts: -------------------------------------------------------------------------------- ```typescript import type { BetterAuthOptions } from "@better-auth/core"; import type { BetterAuthDBSchema, DBFieldAttribute, } from "@better-auth/core/db"; export const getAuthTables = ( options: BetterAuthOptions, ): BetterAuthDBSchema => { const pluginSchema = (options.plugins ?? []).reduce( (acc, plugin) => { const schema = plugin.schema; if (!schema) return acc; for (const [key, value] of Object.entries(schema)) { acc[key] = { fields: { ...acc[key]?.fields, ...value.fields, }, modelName: value.modelName || key, }; } return acc; }, {} as Record< string, { fields: Record<string, DBFieldAttribute>; modelName: string } >, ); const shouldAddRateLimitTable = options.rateLimit?.storage === "database"; const rateLimitTable = { rateLimit: { modelName: options.rateLimit?.modelName || "rateLimit", fields: { key: { type: "string", fieldName: options.rateLimit?.fields?.key || "key", }, count: { type: "number", fieldName: options.rateLimit?.fields?.count || "count", }, lastRequest: { type: "number", bigint: true, fieldName: options.rateLimit?.fields?.lastRequest || "lastRequest", }, }, }, } satisfies BetterAuthDBSchema; const { user, session, account, ...pluginTables } = pluginSchema; const sessionTable = { session: { modelName: options.session?.modelName || "session", fields: { expiresAt: { type: "date", required: true, fieldName: options.session?.fields?.expiresAt || "expiresAt", }, token: { type: "string", required: true, fieldName: options.session?.fields?.token || "token", unique: true, }, createdAt: { type: "date", required: true, fieldName: options.session?.fields?.createdAt || "createdAt", defaultValue: () => new Date(), }, updatedAt: { type: "date", required: true, fieldName: options.session?.fields?.updatedAt || "updatedAt", onUpdate: () => new Date(), }, ipAddress: { type: "string", required: false, fieldName: options.session?.fields?.ipAddress || "ipAddress", }, userAgent: { type: "string", required: false, fieldName: options.session?.fields?.userAgent || "userAgent", }, userId: { type: "string", fieldName: options.session?.fields?.userId || "userId", references: { model: options.user?.modelName || "user", field: "id", onDelete: "cascade", }, required: true, }, ...session?.fields, ...options.session?.additionalFields, }, order: 2, }, } satisfies BetterAuthDBSchema; return { user: { modelName: options.user?.modelName || "user", fields: { name: { type: "string", required: true, fieldName: options.user?.fields?.name || "name", sortable: true, }, email: { type: "string", unique: true, required: true, fieldName: options.user?.fields?.email || "email", sortable: true, }, emailVerified: { type: "boolean", defaultValue: false, required: true, fieldName: options.user?.fields?.emailVerified || "emailVerified", }, image: { type: "string", required: false, fieldName: options.user?.fields?.image || "image", }, createdAt: { type: "date", defaultValue: () => new Date(), required: true, fieldName: options.user?.fields?.createdAt || "createdAt", }, updatedAt: { type: "date", defaultValue: () => new Date(), onUpdate: () => new Date(), required: true, fieldName: options.user?.fields?.updatedAt || "updatedAt", }, ...user?.fields, ...options.user?.additionalFields, }, order: 1, }, //only add session table if it's not stored in secondary storage ...(!options.secondaryStorage || options.session?.storeSessionInDatabase ? sessionTable : {}), account: { modelName: options.account?.modelName || "account", fields: { accountId: { type: "string", required: true, fieldName: options.account?.fields?.accountId || "accountId", }, providerId: { type: "string", required: true, fieldName: options.account?.fields?.providerId || "providerId", }, userId: { type: "string", references: { model: options.user?.modelName || "user", field: "id", onDelete: "cascade", }, required: true, fieldName: options.account?.fields?.userId || "userId", }, accessToken: { type: "string", required: false, fieldName: options.account?.fields?.accessToken || "accessToken", }, refreshToken: { type: "string", required: false, fieldName: options.account?.fields?.refreshToken || "refreshToken", }, idToken: { type: "string", required: false, fieldName: options.account?.fields?.idToken || "idToken", }, accessTokenExpiresAt: { type: "date", required: false, fieldName: options.account?.fields?.accessTokenExpiresAt || "accessTokenExpiresAt", }, refreshTokenExpiresAt: { type: "date", required: false, fieldName: options.account?.fields?.refreshTokenExpiresAt || "refreshTokenExpiresAt", }, scope: { type: "string", required: false, fieldName: options.account?.fields?.scope || "scope", }, password: { type: "string", required: false, fieldName: options.account?.fields?.password || "password", }, createdAt: { type: "date", required: true, fieldName: options.account?.fields?.createdAt || "createdAt", defaultValue: () => new Date(), }, updatedAt: { type: "date", required: true, fieldName: options.account?.fields?.updatedAt || "updatedAt", onUpdate: () => new Date(), }, ...account?.fields, ...options.account?.additionalFields, }, order: 3, }, verification: { modelName: options.verification?.modelName || "verification", fields: { identifier: { type: "string", required: true, fieldName: options.verification?.fields?.identifier || "identifier", }, value: { type: "string", required: true, fieldName: options.verification?.fields?.value || "value", }, expiresAt: { type: "date", required: true, fieldName: options.verification?.fields?.expiresAt || "expiresAt", }, createdAt: { type: "date", required: true, defaultValue: () => new Date(), fieldName: options.verification?.fields?.createdAt || "createdAt", }, updatedAt: { type: "date", required: true, defaultValue: () => new Date(), onUpdate: () => new Date(), fieldName: options.verification?.fields?.updatedAt || "updatedAt", }, }, order: 4, }, ...pluginTables, ...(shouldAddRateLimitTable ? rateLimitTable : {}), } satisfies BetterAuthDBSchema; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/last-login-method/last-login-method.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { getTestInstance } from "../../test-utils/test-instance"; import { lastLoginMethod } from "."; import { lastLoginMethodClient } from "./client"; import { parseCookies, parseSetCookieHeader } from "../../cookies"; import { DEFAULT_SECRET } from "../../utils/constants"; import type { GoogleProfile } from "@better-auth/core/social-providers"; import { signJWT } from "../../crypto"; let testIdToken: string; let handlers: ReturnType<typeof http.post>[]; const server = setupServer(); beforeAll(async () => { const data: GoogleProfile = { email: "[email protected]", email_verified: true, name: "OAuth Test User", picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", exp: 1234567890, sub: "1234567890", iat: 1234567890, aud: "test", azp: "test", nbf: 1234567890, iss: "test", locale: "en", jti: "test", given_name: "OAuth", family_name: "Test", }; testIdToken = await signJWT(data, DEFAULT_SECRET); handlers = [ http.post("https://oauth2.googleapis.com/token", () => { return HttpResponse.json({ access_token: "test-access-token", refresh_token: "test-refresh-token", id_token: testIdToken, }); }), ]; server.listen({ onUnhandledRequest: "bypass" }); server.use(...handlers); }); afterEach(() => { server.resetHandlers(); server.use(...handlers); }); afterAll(() => server.close()); describe("lastLoginMethod", async () => { const { client, cookieSetter, testUser } = await getTestInstance( { plugins: [lastLoginMethod()], }, { clientOptions: { plugins: [lastLoginMethodClient()], }, }, ); it("should set the last login method cookie", async () => { const headers = new Headers(); await client.signIn.email( { email: testUser.email, password: testUser.password, }, { onSuccess(context) { cookieSetter(headers)(context); }, }, ); const cookies = parseCookies(headers.get("cookie") || ""); expect(cookies.get("better-auth.last_used_login_method")).toBe("email"); }); it("should set the last login method in the database", async () => { const { client, auth } = await getTestInstance({ plugins: [lastLoginMethod({ storeInDatabase: true })], }); const data = await client.signIn.email( { email: testUser.email, password: testUser.password, }, { throw: true }, ); const session = await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${data.token}`, }), }); expect(session?.user.lastLoginMethod).toBe("email"); }); it("should NOT set the last login method cookie on failed authentication", async () => { const headers = new Headers(); const response = await client.signIn.email( { email: testUser.email, password: "wrong-password", }, { onError(context) { cookieSetter(headers)(context); }, }, ); expect(response.error).toBeDefined(); const cookies = parseCookies(headers.get("cookie") || ""); expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); }); it("should NOT set the last login method cookie on failed OAuth callback", async () => { const headers = new Headers(); const response = await client.$fetch("/callback/google", { method: "GET", query: { code: "invalid-code", state: "invalid-state", }, onError(context) { cookieSetter(headers)(context); }, }); expect(response.error).toBeDefined(); const cookies = parseCookies(headers.get("cookie") || ""); expect(cookies.get("better-auth.last_used_login_method")).toBeUndefined(); }); it("should update the last login method in the database on subsequent logins", async () => { const { client, auth } = await getTestInstance({ plugins: [lastLoginMethod({ storeInDatabase: true })], }); await client.signUp.email( { email: "[email protected]", password: "password123", name: "Test User", }, { throw: true }, ); const emailSignInData = await client.signIn.email( { email: "[email protected]", password: "password123", }, { throw: true }, ); let session = await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${emailSignInData.token}`, }), }); expect((session?.user as any).lastLoginMethod).toBe("email"); await client.signOut(); const emailSignInData2 = await client.signIn.email( { email: "[email protected]", password: "password123", }, { throw: true }, ); session = await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${emailSignInData2.token}`, }), }); expect((session?.user as any).lastLoginMethod).toBe("email"); }); it("should update the last login method in the database on subsequent logins with email and OAuth", async () => { const { client, auth, cookieSetter } = await getTestInstance({ plugins: [lastLoginMethod({ storeInDatabase: true })], account: { accountLinking: { enabled: true, trustedProviders: ["google"], }, }, }); await client.signUp.email( { email: "[email protected]", password: "password123", name: "GitHub Issue Demo User", }, { throw: true }, ); const emailSignInData = await client.signIn.email( { email: "[email protected]", password: "password123", }, { throw: true }, ); let session = await auth.api.getSession({ headers: new Headers({ authorization: `Bearer ${emailSignInData.token}`, }), }); expect((session?.user as any).lastLoginMethod).toBe("email"); await client.signOut(); const oAuthHeaders = new Headers(); const signInRes = await client.signIn.social({ provider: "google", callbackURL: "/callback", fetchOptions: { onSuccess: cookieSetter(oAuthHeaders), }, }); expect(signInRes.data).toMatchObject({ url: expect.stringContaining("google.com"), redirect: true, }); const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; const headers = new Headers(); await client.$fetch("/callback/google", { query: { state, code: "test", }, headers: oAuthHeaders, method: "GET", onError(context) { expect(context.response.status).toBe(302); const location = context.response.headers.get("location"); expect(location).toBeDefined(); cookieSetter(headers)(context as any); const cookies = parseSetCookieHeader( context.response.headers.get("set-cookie") || "", ); const lastLoginMethod = cookies.get( "better-auth.last_used_login_method", )?.value; if (lastLoginMethod) { expect(lastLoginMethod).toBe("google"); } }, }); const oauthSession = await client.getSession({ fetchOptions: { headers: headers, }, }); expect((oauthSession?.data?.user as any).lastLoginMethod).toBe("google"); }); }); ``` -------------------------------------------------------------------------------- /docs/content/docs/guides/browser-extension-guide.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Browser Extension Guide description: A step-by-step guide to creating a browser extension with Better Auth. --- In this guide, we'll walk you through the steps of creating a browser extension using <Link href="https://docs.plasmo.com/">Plasmo</Link> with Better Auth for authentication. If you would like to view a completed example, you can check out the <Link href="https://github.com/better-auth/examples/tree/main/browser-extension-example">browser extension example</Link>. <Callout type="warn"> The Plasmo framework does not provide a backend for the browser extension. This guide assumes you have{" "} <Link href="/docs/integrations/hono">a backend setup</Link> of Better Auth and are ready to create a browser extension to connect to it. </Callout> <Steps> <Step> ## Setup & Installations Initialize a new Plasmo project with TailwindCSS and a src directory. ```bash pnpm create plasmo --with-tailwindcss --with-src ``` Then, install the Better Auth package. ```bash pnpm add better-auth ``` To start the Plasmo development server, run the following command. ```bash pnpm dev ``` </Step> <Step> ## Configure tsconfig Configure the `tsconfig.json` file to include `strict` mode. For this demo, we have also changed the import alias from `~` to `@` and set it to the `src` directory. ```json title="tsconfig.json" { "compilerOptions": { "paths": { "@/_": [ "./src/_" ] }, "strict": true, "baseUrl": "." } } ``` </Step> <Step> ## Create the client auth instance Create a new file at `src/auth/auth-client.ts` and add the following code. <Files> <Folder name="src" defaultOpen> <Folder name="auth" defaultOpen> <File name="auth-client.ts" /> </Folder> </Folder> </Files> ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/react" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" /* Base URL of your Better Auth backend. */, plugins: [], }); ``` </Step> <Step> ## Configure the manifest We must ensure the extension knows the URL to the Better Auth backend. Head to your package.json file, and add the following code. ```json title="package.json" { //... "manifest": { "host_permissions": [ "https://URL_TO_YOUR_BACKEND" // localhost works too (e.g. http://localhost:3000) ] } } ``` </Step> <Step> ## You're now ready! You have now set up Better Auth for your browser extension. Add your desired UI and create your dream extension! To learn more about the client Better Auth API, check out the <Link href="/docs/concepts/client">client documentation</Link>. Here's a quick example 😎 ```tsx title="src/popup.tsx" import { authClient } from "./auth/auth-client" function IndexPopup() { const {data, isPending, error} = authClient.useSession(); if(isPending){ return <>Loading...</> } if(error){ return <>Error: {error.message}</> } if(data){ return <>Signed in as {data.user.name}</> } } export default IndexPopup; ``` </Step> <Step> ## Bundle your extension To get a production build, run the following command. ```bash pnpm build ``` Head over to <Link href="chrome://extensions" target="_blank">chrome://extensions</Link> and enable developer mode. <img src="https://docs.plasmo.com/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Fdeveloper_mode.76f090f7.png&w=1920&q=75" /> Click on "Load Unpacked" and navigate to your extension's `build/chrome-mv3-dev` (or `build/chrome-mv3-prod`) directory. To see your popup, click on the puzzle piece icon on the Chrome toolbar, and click on your extension. Learn more about <Link href="https://docs.plasmo.com/framework#loading-the-extension-in-chrome">bundling your extension here.</Link> </Step> <Step> ## Configure the server auth instance First, we will need your extension URL. An extension URL formed like this: `chrome-extension://YOUR_EXTENSION_ID`. You can find your extension ID at <Link href="chrome://extensions" target="_blank">chrome://extensions</Link>. <img src="/extension-id.png" width={500} /> Head to your server's auth file, and make sure that your extension's URL is added to the `trustedOrigins` list. ```ts title="server.ts" import { betterAuth } from "better-auth" import { auth } from "@/auth/auth" export const auth = betterAuth({ trustedOrigins: ["chrome-extension://YOUR_EXTENSION_ID"], }) ``` If you're developing multiple extensions or need to support different browser extensions with different IDs, you can use wildcard patterns: ```ts title="server.ts" export const auth = betterAuth({ trustedOrigins: [ // Support a specific extension ID "chrome-extension://YOUR_EXTENSION_ID", // Or support multiple extensions with wildcard (less secure) "chrome-extension://*" ], }) ``` <Callout type="warn"> Using wildcards for extension origins (`chrome-extension://*`) reduces security by trusting all extensions. It's safer to explicitly list each extension ID you trust. Only use wildcards for development and testing. </Callout> </Step> <Step> ## That's it! Everything is set up! You can now start developing your extension. 🎉 </Step> </Steps> ## Wrapping Up Congratulations! You've successfully created a browser extension using Better Auth and Plasmo. We highly recommend you visit the <Link href="https://docs.plasmo.com/">Plasmo documentation</Link> to learn more about the framework. If you would like to view a completed example, you can check out the <Link href="https://github.com/better-auth/examples/tree/main/browser-extension-example">browser extension example</Link>. If you have any questions, feel free to open an issue on our <Link href="https://github.com/better-auth/better-auth/issues">GitHub repo</Link>, or join our <Link href="https://discord.gg/better-auth">Discord server</Link> for support. ``` -------------------------------------------------------------------------------- /packages/stripe/src/types.ts: -------------------------------------------------------------------------------- ```typescript import type { GenericEndpointContext, InferOptionSchema, Session, User, } from "better-auth"; import type Stripe from "stripe"; import type { subscriptions, user } from "./schema"; export type StripePlan = { /** * Monthly price id */ priceId?: string; /** * To use lookup key instead of price id * * https://docs.stripe.com/products-prices/ * manage-prices#lookup-keys */ lookupKey?: string; /** * A yearly discount price id * * useful when you want to offer a discount for * yearly subscription */ annualDiscountPriceId?: string; /** * To use lookup key instead of price id * * https://docs.stripe.com/products-prices/ * manage-prices#lookup-keys */ annualDiscountLookupKey?: string; /** * Plan name */ name: string; /** * Limits for the plan */ limits?: Record<string, number>; /** * Plan group name * * useful when you want to group plans or * when a user can subscribe to multiple plans. */ group?: string; /** * Free trial days */ freeTrial?: { /** * Number of days */ days: number; /** * A function that will be called when the trial * starts. * * @param subscription * @returns */ onTrialStart?: (subscription: Subscription) => Promise<void>; /** * A function that will be called when the trial * ends * * @param subscription - Subscription * @returns */ onTrialEnd?: ( data: { subscription: Subscription; }, ctx: GenericEndpointContext, ) => Promise<void>; /** * A function that will be called when the trial * expired. * @param subscription - Subscription * @returns */ onTrialExpired?: ( subscription: Subscription, ctx: GenericEndpointContext, ) => Promise<void>; }; }; export interface Subscription { /** * Database identifier */ id: string; /** * The plan name */ plan: string; /** * Stripe customer id */ stripeCustomerId?: string; /** * Stripe subscription id */ stripeSubscriptionId?: string; /** * Trial start date */ trialStart?: Date; /** * Trial end date */ trialEnd?: Date; /** * Price Id for the subscription */ priceId?: string; /** * To what reference id the subscription belongs to * @example * - userId for a user * - workspace id for a saas platform * - website id for a hosting platform * * @default - userId */ referenceId: string; /** * Subscription status */ status: | "active" | "canceled" | "incomplete" | "incomplete_expired" | "past_due" | "paused" | "trialing" | "unpaid"; /** * The billing cycle start date */ periodStart?: Date; /** * The billing cycle end date */ periodEnd?: Date; /** * Cancel at period end */ cancelAtPeriodEnd?: boolean; /** * A field to group subscriptions so you can have multiple subscriptions * for one reference id */ groupId?: string; /** * Number of seats for the subscription (useful for team plans) */ seats?: number; } export interface StripeOptions { /** * Stripe Client */ stripeClient: Stripe; /** * Stripe Webhook Secret * * @description Stripe webhook secret key */ stripeWebhookSecret: string; /** * Enable customer creation when a user signs up */ createCustomerOnSignUp?: boolean; /** * A callback to run after a customer has been created * @param customer - Customer Data * @param stripeCustomer - Stripe Customer Data * @returns */ onCustomerCreate?: ( data: { stripeCustomer: Stripe.Customer; user: User & { stripeCustomerId: string }; }, ctx: GenericEndpointContext, ) => Promise<void>; /** * A custom function to get the customer create * params * @param data - data containing user and session * @returns */ getCustomerCreateParams?: ( user: User, ctx: GenericEndpointContext, ) => Promise<Partial<Stripe.CustomerCreateParams>>; /** * Subscriptions */ subscription?: { enabled: boolean; /** * Subscription Configuration */ /** * List of plan */ plans: StripePlan[] | (() => StripePlan[] | Promise<StripePlan[]>); /** * Require email verification before a user is allowed to upgrade * their subscriptions * * @default false */ requireEmailVerification?: boolean; /** * A callback to run after a user has subscribed to a package * @param event - Stripe Event * @param subscription - Subscription Data * @returns */ onSubscriptionComplete?: ( data: { event: Stripe.Event; stripeSubscription: Stripe.Subscription; subscription: Subscription; plan: StripePlan; }, ctx: GenericEndpointContext, ) => Promise<void>; /** * A callback to run after a user is about to cancel their subscription * @returns */ onSubscriptionUpdate?: (data: { event: Stripe.Event; subscription: Subscription; }) => Promise<void>; /** * A callback to run after a user is about to cancel their subscription * @returns */ onSubscriptionCancel?: (data: { event?: Stripe.Event; subscription: Subscription; stripeSubscription: Stripe.Subscription; cancellationDetails?: Stripe.Subscription.CancellationDetails | null; }) => Promise<void>; /** * A function to check if the reference id is valid * and belongs to the user * * @param data - data containing user, session and referenceId * @param ctx - the context object * @returns */ authorizeReference?: ( data: { user: User & Record<string, any>; session: Session & Record<string, any>; referenceId: string; action: | "upgrade-subscription" | "list-subscription" | "cancel-subscription" | "restore-subscription" | "billing-portal"; }, ctx: GenericEndpointContext, ) => Promise<boolean>; /** * A callback to run after a user has deleted their subscription * @returns */ onSubscriptionDeleted?: (data: { event: Stripe.Event; stripeSubscription: Stripe.Subscription; subscription: Subscription; }) => Promise<void>; /** * parameters for session create params * * @param data - data containing user, session and plan * @param ctx - the context object */ getCheckoutSessionParams?: ( data: { user: User & Record<string, any>; session: Session & Record<string, any>; plan: StripePlan; subscription: Subscription; }, ctx: GenericEndpointContext, ) => | Promise<{ params?: Stripe.Checkout.SessionCreateParams; options?: Stripe.RequestOptions; }> | { params?: Stripe.Checkout.SessionCreateParams; options?: Stripe.RequestOptions; }; /** * Enable organization subscription */ organization?: { enabled: boolean; }; }; /** * A callback to run after a stripe event is received * @param event - Stripe Event * @returns */ onEvent?: (event: Stripe.Event) => Promise<void>; /** * Schema for the stripe plugin */ schema?: InferOptionSchema<typeof subscriptions & typeof user>; } export interface InputSubscription extends Omit<Subscription, "id"> {} ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/rate-limit.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Rate Limit description: How to limit the number of requests a user can make to the server in a given time period. --- Better Auth includes a built-in rate limiter to help manage traffic and prevent abuse. By default, in production mode, the rate limiter is set to: - Window: 60 seconds - Max Requests: 100 requests <Callout type="warning"> Server-side requests made using `auth.api` aren't affected by rate limiting. Rate limits only apply to client-initiated requests. </Callout> You can easily customize these settings by passing the rateLimit object to the betterAuth function. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ rateLimit: { window: 10, // time window in seconds max: 100, // max requests in the window }, }) ``` Rate limiting is disabled in development mode by default. In order to enable it, set `enabled` to `true`: ```ts title="auth.ts" export const auth = betterAuth({ rateLimit: { enabled: true, //...other options }, }) ``` In addition to the default settings, Better Auth provides custom rules for specific paths. For example: - `/sign-in/email`: Is limited to 3 requests within 10 seconds. In addition, plugins also define custom rules for specific paths. For example, `twoFactor` plugin has custom rules: - `/two-factor/verify`: Is limited to 3 requests within 10 seconds. These custom rules ensure that sensitive operations are protected with stricter limits. ## Configuring Rate Limit ### Connecting IP Address Rate limiting uses the connecting IP address to track the number of requests made by a user. The default header checked is `x-forwarded-for`, which is commonly used in production environments. If you are using a different header to track the user's IP address, you'll need to specify it. ```ts title="auth.ts" export const auth = betterAuth({ //...other options advanced: { ipAddress: { ipAddressHeaders: ["cf-connecting-ip"], // Cloudflare specific header example }, }, rateLimit: { enabled: true, window: 60, // time window in seconds max: 100, // max requests in the window }, }) ``` ### Rate Limit Window ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { window: 60, // time window in seconds max: 100, // max requests in the window }, }) ``` You can also pass custom rules for specific paths. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { window: 60, // time window in seconds max: 100, // max requests in the window customRules: { "/sign-in/email": { window: 10, max: 3, }, "/two-factor/*": async (request)=> { // custom function to return rate limit window and max return { window: 10, max: 3, } } }, }, }) ``` If you like to disable rate limiting for a specific path, you can set it to `false` or return `false` from the custom rule function. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { customRules: { "/get-session": false, }, }, }) ``` ### Storage By default, rate limit data is stored in memory, which may not be suitable for many use cases, particularly in serverless environments. To address this, you can use a database, secondary storage, or custom storage for storing rate limit data. **Using Database** ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { storage: "database", modelName: "rateLimit", //optional by default "rateLimit" is used }, }) ``` Make sure to run `migrate` to create the rate limit table in your database. ```bash npx @better-auth/cli migrate ``` **Using Secondary Storage** If a [Secondary Storage](/docs/concepts/database#secondary-storage) has been configured you can use that to store rate limit data. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { storage: "secondary-storage" }, }) ``` **Custom Storage** If none of the above solutions suits your use case you can implement a `customStorage`. ```ts title="auth.ts" import { betterAuth } from "better-auth"; export const auth = betterAuth({ //...other options rateLimit: { customStorage: { get: async (key) => { // get rate limit data }, set: async (key, value) => { // set rate limit data }, }, }, }) ``` ## Handling Rate Limit Errors When a request exceeds the rate limit, Better Auth returns the following header: - `X-Retry-After`: The number of seconds until the user can make another request. To handle rate limit errors on the client side, you can manage them either globally or on a per-request basis. Since Better Auth clients wrap over Better Fetch, you can pass `fetchOptions` to handle rate limit errors **Global Handling** ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client"; export const authClient = createAuthClient({ fetchOptions: { onError: async (context) => { const { response } = context; if (response.status === 429) { const retryAfter = response.headers.get("X-Retry-After"); console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`); } }, } }) ``` **Per Request Handling** ```ts title="auth-client.ts" import { authClient } from "./auth-client"; await authClient.signIn.email({ fetchOptions: { onError: async (context) => { const { response } = context; if (response.status === 429) { const retryAfter = response.headers.get("X-Retry-After"); console.log(`Rate limit exceeded. Retry after ${retryAfter} seconds`); } }, } }) ``` ### Schema If you are using a database to store rate limit data you need this schema: Table Name: `rateLimit` <DatabaseTable fields={[ { name: "id", type: "string", description: "Database ID", isPrimaryKey: true }, { name: "key", type: "string", description: "Unique identifier for each rate limit key", }, { name: "count", type: "integer", description: "Time window in seconds" }, { name: "lastRequest", type: "bigint", description: "Max requests in the window" }]} /> ``` -------------------------------------------------------------------------------- /docs/content/docs/concepts/hooks.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Hooks description: Better Auth Hooks let you customize BetterAuth's behavior --- Hooks in Better Auth let you "hook into" the lifecycle and execute custom logic. They provide a way to customize Better Auth's behavior without writing a full plugin. <Callout> We highly recommend using hooks if you need to make custom adjustments to an endpoint rather than making another endpoint outside of Better Auth. </Callout> ## Before Hooks **Before hooks** run *before* an endpoint is executed. Use them to modify requests, pre validate data, or return early. ### Example: Enforce Email Domain Restriction This hook ensures that users can only sign up if their email ends with `@example.com`: ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { createAuthMiddleware, APIError } from "better-auth/api"; export const auth = betterAuth({ hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path !== "/sign-up/email") { return; } if (!ctx.body?.email.endsWith("@example.com")) { throw new APIError("BAD_REQUEST", { message: "Email must end with @example.com", }); } }), }, }); ``` ### Example: Modify Request Context To adjust the request context before proceeding: ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { createAuthMiddleware } from "better-auth/api"; export const auth = betterAuth({ hooks: { before: createAuthMiddleware(async (ctx) => { if (ctx.path === "/sign-up/email") { return { context: { ...ctx, body: { ...ctx.body, name: "John Doe", }, } }; } }), }, }); ``` ## After Hooks **After hooks** run *after* an endpoint is executed. Use them to modify responses. ### Example: Send a notification to your channel when a new user is registered ```ts title="auth.ts" import { betterAuth } from "better-auth"; import { createAuthMiddleware } from "better-auth/api"; import { sendMessage } from "@/lib/notification" export const auth = betterAuth({ hooks: { after: createAuthMiddleware(async (ctx) => { if(ctx.path.startsWith("/sign-up")){ const newSession = ctx.context.newSession; if(newSession){ sendMessage({ type: "user-register", name: newSession.user.name, }) } } }), }, }); ``` ## Ctx When you call `createAuthMiddleware` a `ctx` object is passed that provides a lot of useful properties. Including: - **Path:** `ctx.path` to get the current endpoint path. - **Body:** `ctx.body` for parsed request body (available for POST requests). - **Headers:** `ctx.headers` to access request headers. - **Request:** `ctx.request` to access the request object (may not exist in server-only endpoints). - **Query Parameters:** `ctx.query` to access query parameters. - **Context**: `ctx.context` auth related context, useful for accessing new session, auth cookies configuration, password hashing, config... and more. ### Request Response This utilities allows you to get request information and to send response from a hook. #### JSON Responses Use `ctx.json` to send JSON responses: ```ts const hook = createAuthMiddleware(async (ctx) => { return ctx.json({ message: "Hello World", }); }); ``` #### Redirects Use `ctx.redirect` to redirect users: ```ts import { createAuthMiddleware } from "better-auth/api"; const hook = createAuthMiddleware(async (ctx) => { throw ctx.redirect("/sign-up/name"); }); ``` #### Cookies - Set cookies: `ctx.setCookies` or `ctx.setSignedCookie`. - Get cookies: `ctx.getCookies` or `ctx.getSignedCookie`. Example: ```ts import { createAuthMiddleware } from "better-auth/api"; const hook = createAuthMiddleware(async (ctx) => { ctx.setCookies("my-cookie", "value"); await ctx.setSignedCookie("my-signed-cookie", "value", ctx.context.secret, { maxAge: 1000, }); const cookie = ctx.getCookies("my-cookie"); const signedCookie = await ctx.getSignedCookie("my-signed-cookie"); }); ``` #### Errors Throw errors with `APIError` for a specific status code and message: ```ts import { createAuthMiddleware, APIError } from "better-auth/api"; const hook = createAuthMiddleware(async (ctx) => { throw new APIError("BAD_REQUEST", { message: "Invalid request", }); }); ``` ### Context The `ctx` object contains another `context` object inside that's meant to hold contexts related to auth. Including a newly created session on after hook, cookies configuration, password hasher and so on. #### New Session The newly created session after an endpoint is run. This only exist in after hook. ```ts title="auth.ts" createAuthMiddleware(async (ctx) => { const newSession = ctx.context.newSession }); ``` #### Returned The returned value from the hook is passed to the next hook in the chain. ```ts title="auth.ts" createAuthMiddleware(async (ctx) => { const returned = ctx.context.returned; //this could be a successful response or an APIError }); ``` #### Response Headers The response headers added by endpoints and hooks that run before this hook. ```ts title="auth.ts" createAuthMiddleware(async (ctx) => { const responseHeaders = ctx.context.responseHeaders; }); ``` #### Predefined Auth Cookies Access BetterAuth’s predefined cookie properties: ```ts title="auth.ts" createAuthMiddleware(async (ctx) => { const cookieName = ctx.context.authCookies.sessionToken.name; }); ``` #### Secret You can access the `secret` for your auth instance on `ctx.context.secret` #### Password The password object provider `hash` and `verify` - `ctx.context.password.hash`: let's you hash a given password. - `ctx.context.password.verify`: let's you verify given `password` and a `hash`. #### Adapter Adapter exposes the adapter methods used by Better Auth. Including `findOne`, `findMany`, `create`, `delete`, `update` and `updateMany`. You generally should use your actually `db` instance from your orm rather than this adapter. #### Internal Adapter These are calls to your db that perform specific actions. `createUser`, `createSession`, `updateSession`... This may be useful to use instead of using your db directly to get access to `databaseHooks`, proper `secondaryStorage` support and so on. If you're make a query similar to what exist in this internal adapter actions it's worth a look. #### generateId You can use `ctx.context.generateId` to generate Id for various reasons. ## Reusable Hooks If you need to reuse a hook across multiple endpoints, consider creating a plugin. Learn more in the [Plugins Documentation](/docs/concepts/plugins). ``` -------------------------------------------------------------------------------- /docs/components/generate-apple-jwt.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; import { KJUR } from "jsrsasign"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; // Zod schema for validation const appleJwtSchema = z.object({ teamId: z.string().min(1, { message: "Team ID is required." }), clientId: z .string() .min(1, { message: "Client ID (Service ID) is required." }), keyId: z.string().min(1, { message: "Key ID is required." }), privateKey: z .string() .min(1, { message: "Private Key content is required." }) .refine( (key) => key.startsWith("-----BEGIN PRIVATE KEY-----"), "Private key must be in PKCS#8 PEM format (starting with -----BEGIN PRIVATE KEY-----)", ) .refine( (key) => key.includes("-----END PRIVATE KEY-----"), "Private key must be in PKCS#8 PEM format (ending with -----END PRIVATE KEY-----)", ), }); type AppleJwtFormValues = z.infer<typeof appleJwtSchema>; export const GenerateAppleJwt = () => { const [generatedJwt, setGeneratedJwt] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const [isLoading, startTransition] = useTransition(); const form = useForm<AppleJwtFormValues>({ resolver: zodResolver(appleJwtSchema), defaultValues: { teamId: "", clientId: "", keyId: "", privateKey: "", }, }); const onSubmit = async (data: AppleJwtFormValues) => { setGeneratedJwt(null); setError(null); startTransition(() => { try { //normalize the private key by replacing \r\n with \n and trimming whitespace just incase lol const normalizedKey = data.privateKey.replace(/\r\n/g, "\n").trim(); //since jose is not working with safari, we are using jsrsasign const header = { alg: "ES256", kid: data.keyId, typ: "JWT", }; const issuedAtSeconds = Math.floor(Date.now() / 1000); /** * Apple allows a maximum expiration of 6 months (180 days) for the client secret JWT. * * @see {@link https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret} */ const expirationSeconds = issuedAtSeconds + 180 * 24 * 60 * 60; // 180 days. Should we let the user choose this ? MAX is 6 months const payload = { iss: data.teamId, // Issuer (Team ID) aud: "https://appleid.apple.com", // Audience sub: data.clientId, // Subject (Client ID -> Service ID) iat: issuedAtSeconds, // Issued At timestamp exp: expirationSeconds, // Expiration timestamp }; const sHeader = JSON.stringify(header); const sPayload = JSON.stringify(payload); const jwt = KJUR.jws.JWS.sign( "ES256", sHeader, sPayload, normalizedKey, ); setGeneratedJwt(jwt); } catch (err: any) { console.error("JWT Generation Error:", err); setError( `Failed to generate JWT: ${ err.message || "Unknown error" }. Check key format and details.`, ); } }); }; const copyToClipboard = () => { if (generatedJwt) { navigator.clipboard.writeText(generatedJwt); } }; return ( <div className="my-4 space-y-6"> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <FormField control={form.control} name="teamId" render={({ field }) => ( <FormItem> <FormLabel>Apple Team ID</FormLabel> <FormControl> <Input placeholder="e.g., A1B2C3D4E5" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="clientId" render={({ field }) => ( <FormItem> <FormLabel>Client ID (Service ID)</FormLabel> <FormControl> <Input placeholder="e.g., com.yourdomain.app" {...field} /> </FormControl> <FormDescription> The identifier for the service you created in Apple Developer. </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="keyId" render={({ field }) => ( <FormItem> <FormLabel>Key ID</FormLabel> <FormControl> <Input placeholder="e.g., F6G7H8I9J0" {...field} /> </FormControl> <FormDescription> The ID associated with your private key (.p8 file). </FormDescription> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="privateKey" render={({ field }) => ( <FormItem> <FormLabel>Private Key Content (.p8 file content)</FormLabel> <FormControl> <Textarea placeholder="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" className="min-h-[150px] font-mono text-sm" {...field} /> </FormControl> <FormDescription> Paste the entire content of your .p8 private key file here. Ensure it's in PKCS#8 format. </FormDescription> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isLoading}> {isLoading ? "Generating..." : "Generate Apple Client Secret (JWT)"} </Button> </form> </Form> {error && ( <div className="mt-4 rounded-md border border-red-400 bg-red-50 p-3 text-red-700"> <p className="font-semibold">Error:</p> <p className="text-sm">{error}</p> </div> )} {generatedJwt && ( <div className="mt-6 space-y-2"> <h3 className="text-lg font-semibold">Generated Client Secret:</h3> <div className="relative rounded-md bg-muted p-4 font-mono text-sm"> <pre className="overflow-x-auto whitespace-pre-wrap break-all"> <code>{generatedJwt}</code> </pre> <Button variant="ghost" size="icon" className="absolute right-2 top-2 h-7 w-7" onClick={copyToClipboard} title="Copy to clipboard" > {/* I used gpt for this lol. Should we change to another icon or is this ok ? */} <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-copy" > <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> </svg> </Button> </div> <p className="text-xs text-muted-foreground"> This is the client secret (JWT) required for 'Sign in with Apple'. It expires in 180 days. </p> </div> )} </div> ); }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/bun-sqlite-dialect.ts: -------------------------------------------------------------------------------- ```typescript /** * @see {@link https://github.com/dylanblokhuis/kysely-bun-sqlite} - Fork of the original kysely-bun-sqlite package by @dylanblokhuis */ import { Kysely, CompiledQuery, DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql, type DatabaseConnection, type QueryResult, type DatabaseIntrospector, type SchemaMetadata, type DatabaseMetadataOptions, type TableMetadata, type DatabaseMetadata, type Driver, type Dialect, type QueryCompiler, type DialectAdapter, } from "kysely"; import { DefaultQueryCompiler } from "kysely"; import { DialectAdapterBase } from "kysely"; import type { Database } from "bun:sqlite"; export class BunSqliteAdapter implements DialectAdapterBase { get supportsCreateIfNotExists(): boolean { return true; } get supportsTransactionalDdl(): boolean { return false; } get supportsReturning(): boolean { return true; } async acquireMigrationLock(): Promise<void> { // SQLite only has one connection that's reserved by the migration system // for the whole time between acquireMigrationLock and releaseMigrationLock. // We don't need to do anything here. } async releaseMigrationLock(): Promise<void> { // SQLite only has one connection that's reserved by the migration system // for the whole time between acquireMigrationLock and releaseMigrationLock. // We don't need to do anything here. } get supportsOutput(): boolean { return true; } } /** * Config for the SQLite dialect. */ export interface BunSqliteDialectConfig { /** * An sqlite Database instance or a function that returns one. */ database: Database; /** * Called once when the first query is executed. */ onCreateConnection?: (connection: DatabaseConnection) => Promise<void>; } export class BunSqliteDriver implements Driver { readonly #config: BunSqliteDialectConfig; readonly #connectionMutex = new ConnectionMutex(); #db?: Database; #connection?: DatabaseConnection; constructor(config: BunSqliteDialectConfig) { this.#config = { ...config }; } async init(): Promise<void> { this.#db = this.#config.database; this.#connection = new BunSqliteConnection(this.#db); if (this.#config.onCreateConnection) { await this.#config.onCreateConnection(this.#connection); } } async acquireConnection(): Promise<DatabaseConnection> { // SQLite only has one single connection. We use a mutex here to wait // until the single connection has been released. await this.#connectionMutex.lock(); return this.#connection!; } async beginTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("begin")); } async commitTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("commit")); } async rollbackTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("rollback")); } async releaseConnection(): Promise<void> { this.#connectionMutex.unlock(); } async destroy(): Promise<void> { this.#db?.close(); } } class BunSqliteConnection implements DatabaseConnection { readonly #db: Database; constructor(db: Database) { this.#db = db; } executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> { const { sql, parameters } = compiledQuery; const stmt = this.#db.prepare(sql); return Promise.resolve({ rows: stmt.all(parameters as any) as O[], }); } async *streamQuery() { throw new Error("Streaming query is not supported by SQLite driver."); } } class ConnectionMutex { #promise?: Promise<void>; #resolve?: () => void; async lock(): Promise<void> { while (this.#promise) { await this.#promise; } this.#promise = new Promise((resolve) => { this.#resolve = resolve; }); } unlock(): void { const resolve = this.#resolve; this.#promise = undefined; this.#resolve = undefined; resolve?.(); } } export class BunSqliteIntrospector implements DatabaseIntrospector { readonly #db: Kysely<unknown>; constructor(db: Kysely<unknown>) { this.#db = db; } async getSchemas(): Promise<SchemaMetadata[]> { // Sqlite doesn't support schemas. return []; } async getTables( options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, ): Promise<TableMetadata[]> { let query = this.#db // @ts-expect-error .selectFrom("sqlite_schema") // @ts-expect-error .where("type", "=", "table") // @ts-expect-error .where("name", "not like", "sqlite_%") .select("name") .$castTo<{ name: string }>(); if (!options.withInternalKyselyTables) { query = query // @ts-expect-error .where("name", "!=", DEFAULT_MIGRATION_TABLE) // @ts-expect-error .where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE); } const tables = await query.execute(); return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name))); } async getMetadata( options?: DatabaseMetadataOptions, ): Promise<DatabaseMetadata> { return { tables: await this.getTables(options), }; } async #getTableMetadata(table: string): Promise<TableMetadata> { const db = this.#db; // Get the SQL that was used to create the table. const createSql = await db // @ts-expect-error .selectFrom("sqlite_master") // @ts-expect-error .where("name", "=", table) .select("sql") .$castTo<{ sql: string | undefined }>() .execute(); // Try to find the name of the column that has `autoincrement` 🤦 const autoIncrementCol = createSql[0]?.sql ?.split(/[\(\),]/) ?.find((it) => it.toLowerCase().includes("autoincrement")) ?.split(/\s+/)?.[0] ?.replace(/["`]/g, ""); const columns = await db .selectFrom( sql<{ name: string; type: string; notnull: 0 | 1; dflt_value: any; }>`pragma_table_info(${table})`.as("table_info"), ) .select(["name", "type", "notnull", "dflt_value"]) .execute(); return { name: table, columns: columns.map((col) => ({ name: col.name, dataType: col.type, isNullable: !col.notnull, isAutoIncrementing: col.name === autoIncrementCol, hasDefaultValue: col.dflt_value != null, })), isView: true, }; } } export class BunSqliteQueryCompiler extends DefaultQueryCompiler { protected override getCurrentParameterPlaceholder() { return "?"; } protected override getLeftIdentifierWrapper(): string { return '"'; } protected override getRightIdentifierWrapper(): string { return '"'; } protected override getAutoIncrement() { return "autoincrement"; } } export class BunSqliteDialect implements Dialect { readonly #config: BunSqliteDialectConfig; constructor(config: BunSqliteDialectConfig) { this.#config = { ...config }; } createDriver(): Driver { return new BunSqliteDriver(this.#config); } createQueryCompiler(): QueryCompiler { return new BunSqliteQueryCompiler(); } createAdapter(): DialectAdapter { return new BunSqliteAdapter(); } createIntrospector(db: Kysely<any>): DatabaseIntrospector { return new BunSqliteIntrospector(db); } } ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/zoom.ts: -------------------------------------------------------------------------------- ```typescript import { betterFetch } from "@better-fetch/fetch"; import { generateCodeChallenge, validateAuthorizationCode } from "../oauth2"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; export type LoginType = | 0 /** Facebook OAuth */ | 1 /** Google OAuth */ | 24 /** Apple OAuth */ | 27 /** Microsoft OAuth */ | 97 /** Mobile device */ | 98 /** RingCentral OAuth */ | 99 /** API user */ | 100 /** Zoom Work email */ | 101; /** Single Sign-On (SSO) */ export type AccountStatus = "pending" | "active" | "inactive"; export type PronounOption = | 1 /** Ask the user every time */ | 2 /** Always display */ | 3; /** Do not display */ export interface PhoneNumber { /** The country code of the phone number (Example: "+1") */ code: string; /** The country of the phone number (Example: "US") */ country: string; /** The label for the phone number (Example: "Mobile") */ label: string; /** The phone number itself (Example: "800000000") */ number: string; /** Whether the phone number has been verified (Example: true) */ verified: boolean; } /** * See the full documentation below: * https://developers.zoom.us/docs/api/users/#tag/users/GET/users/{userId} */ export interface ZoomProfile extends Record<string, any> { /** The user's account ID (Example: "q6gBJVO5TzexKYTb_I2rpg") */ account_id: string; /** The user's account number (Example: 10009239) */ account_number: number; /** The user's cluster (Example: "us04") */ cluster: string; /** The user's CMS ID. Only enabled for Kaltura integration (Example: "KDcuGIm1QgePTO8WbOqwIQ") */ cms_user_id: string; /** The user's cost center (Example: "cost center") */ cost_center: string; /** User create time (Example: "2018-10-31T04:32:37Z") */ created_at: string; /** Department (Example: "Developers") */ dept: string; /** User's display name (Example: "Jill Chill") */ display_name: string; /** User's email address (Example: "[email protected]") */ email: string; /** User's first name (Example: "Jill") */ first_name: string; /** IDs of the web groups that the user belongs to (Example: ["RSMaSp8sTEGK0_oamiA2_w"]) */ group_ids: string[]; /** User ID (Example: "zJKyaiAyTNC-MWjiWC18KQ") */ id: string; /** IM IDs of the groups that the user belongs to (Example: ["t-_-d56CSWG-7BF15LLrOw"]) */ im_group_ids: string[]; /** The user's JID (Example: "[email protected]") */ jid: string; /** The user's job title (Example: "API Developer") */ job_title: string; /** Default language for the Zoom Web Portal (Example: "en-US") */ language: string; /** User last login client version (Example: "5.9.6.4993(mac)") */ last_client_version: string; /** User last login time (Example: "2021-05-05T20:40:30Z") */ last_login_time: string; /** User's last name (Example: "Chill") */ last_name: string; /** The time zone of the user (Example: "Asia/Shanghai") */ timezone: string; /** User's location (Example: "Paris") */ location: string; /** The user's login method (Example: 101) */ login_types: LoginType[]; /** User's personal meeting URL (Example: "example.com") */ personal_meeting_url: string; /** This field has been deprecated and will not be supported in the future. * Use the phone_numbers field instead of this field. * The user's phone number (Example: "+1 800000000") */ // @deprecated true phone_number?: string; /** The URL for user's profile picture (Example: "example.com") */ pic_url: string; /** Personal Meeting ID (PMI) (Example: 3542471135) */ pmi: number; /** Unique identifier of the user's assigned role (Example: "0") */ role_id: string; /** User's role name (Example: "Admin") */ role_name: string; /** Status of user's account (Example: "pending") */ status: AccountStatus; /** Use the personal meeting ID (PMI) for instant meetings (Example: false) */ use_pmi: boolean; /** The time and date when the user was created (Example: "2018-10-31T04:32:37Z") */ user_created_at: string; /** Displays whether user is verified or not (Example: 1) */ verified: number; /** The user's Zoom Workplace plan option (Example: 64) */ zoom_one_type: number; /** The user's company (Example: "Jill") */ company?: string; /** Custom attributes that have been assigned to the user (Example: [{ "key": "cbf_cywdkexrtqc73f97gd4w6g", "name": "A1", "value": "1" }]) */ custom_attributes?: { key: string; name: string; value: string }[]; /** The employee's unique ID. This field only returns when SAML single sign-on (SSO) is enabled. * The `login_type` value is `101` (SSO) (Example: "HqDyI037Qjili1kNsSIrIg") */ employee_unique_id?: string; /** The manager for the user (Example: "[email protected]") */ manager?: string; /** The user's country for the company phone number (Example: "US") * @deprecated true */ phone_country?: string; /** The phone number's ISO country code (Example: "+1") */ phone_numbers?: PhoneNumber[]; /** The user's plan type (Example: "1") */ plan_united_type?: string; /** The user's pronouns (Example: "3123") */ pronouns?: string; /** The user's display pronouns setting (Example: 1) */ pronouns_option?: PronounOption; /** Personal meeting room URL, if the user has one (Example: "example.com") */ vanity_url?: string; } export interface ZoomOptions extends ProviderOptions<ZoomProfile> { clientId: string; pkce?: boolean; } export const zoom = (userOptions: ZoomOptions) => { const options = { pkce: true, ...userOptions, }; return { id: "zoom", name: "Zoom", createAuthorizationURL: async ({ state, redirectURI, codeVerifier }) => { const params = new URLSearchParams({ response_type: "code", redirect_uri: options.redirectURI ? options.redirectURI : redirectURI, client_id: options.clientId, state, }); if (options.pkce) { const codeChallenge = await generateCodeChallenge(codeVerifier); params.set("code_challenge_method", "S256"); params.set("code_challenge", codeChallenge); } const url = new URL("https://zoom.us/oauth/authorize"); url.search = params.toString(); return url; }, validateAuthorizationCode: async ({ code, redirectURI, codeVerifier }) => { return validateAuthorizationCode({ code, redirectURI: options.redirectURI || redirectURI, codeVerifier, options, tokenEndpoint: "https://zoom.us/oauth/token", authentication: "post", }); }, async getUserInfo(token) { if (options.getUserInfo) { return options.getUserInfo(token); } const { data: profile, error } = await betterFetch<ZoomProfile>( "https://api.zoom.us/v2/users/me", { headers: { authorization: `Bearer ${token.accessToken}`, }, }, ); if (error) { return null; } const userMap = await options.mapProfileToUser?.(profile); return { user: { id: profile.id, name: profile.display_name, image: profile.pic_url, email: profile.email, emailVerified: Boolean(profile.verified), ...userMap, }, data: { ...profile, }, }; }, } satisfies OAuthProvider<ZoomProfile>; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/node-sqlite-dialect.ts: -------------------------------------------------------------------------------- ```typescript /** * @see {@link https://nodejs.org/api/sqlite.html} - Node.js SQLite API documentation */ import { Kysely, CompiledQuery, DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql, type DatabaseConnection, type QueryResult, type DatabaseIntrospector, type SchemaMetadata, type DatabaseMetadataOptions, type TableMetadata, type DatabaseMetadata, type Driver, type Dialect, type QueryCompiler, type DialectAdapter, } from "kysely"; import { DefaultQueryCompiler } from "kysely"; import { DialectAdapterBase } from "kysely"; import type { DatabaseSync } from "node:sqlite"; export class NodeSqliteAdapter implements DialectAdapterBase { get supportsCreateIfNotExists(): boolean { return true; } get supportsTransactionalDdl(): boolean { return false; } get supportsReturning(): boolean { return true; } async acquireMigrationLock(): Promise<void> { // SQLite only has one connection that's reserved by the migration system // for the whole time between acquireMigrationLock and releaseMigrationLock. // We don't need to do anything here. } async releaseMigrationLock(): Promise<void> { // SQLite only has one connection that's reserved by the migration system // for the whole time between acquireMigrationLock and releaseMigrationLock. // We don't need to do anything here. } get supportsOutput(): boolean { return true; } } /** * Config for the SQLite dialect. */ export interface NodeSqliteDialectConfig { /** * A sqlite DatabaseSync instance or a function that returns one. */ database: DatabaseSync; /** * Called once when the first query is executed. */ onCreateConnection?: (connection: DatabaseConnection) => Promise<void>; } export class NodeSqliteDriver implements Driver { readonly #config: NodeSqliteDialectConfig; readonly #connectionMutex = new ConnectionMutex(); #db?: DatabaseSync; #connection?: DatabaseConnection; constructor(config: NodeSqliteDialectConfig) { this.#config = { ...config }; } async init(): Promise<void> { this.#db = this.#config.database; this.#connection = new NodeSqliteConnection(this.#db); if (this.#config.onCreateConnection) { await this.#config.onCreateConnection(this.#connection); } } async acquireConnection(): Promise<DatabaseConnection> { // SQLite only has one single connection. We use a mutex here to wait // until the single connection has been released. await this.#connectionMutex.lock(); return this.#connection!; } async beginTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("begin")); } async commitTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("commit")); } async rollbackTransaction(connection: DatabaseConnection): Promise<void> { await connection.executeQuery(CompiledQuery.raw("rollback")); } async releaseConnection(): Promise<void> { this.#connectionMutex.unlock(); } async destroy(): Promise<void> { this.#db?.close(); } } class NodeSqliteConnection implements DatabaseConnection { readonly #db: DatabaseSync; constructor(db: DatabaseSync) { this.#db = db; } executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> { const { sql, parameters } = compiledQuery; const stmt = this.#db.prepare(sql); const rows = stmt.all(...(parameters as any[])) as O[]; return Promise.resolve({ rows, }); } async *streamQuery() { throw new Error("Streaming query is not supported by SQLite driver."); } } class ConnectionMutex { #promise?: Promise<void>; #resolve?: () => void; async lock(): Promise<void> { while (this.#promise) { await this.#promise; } this.#promise = new Promise((resolve) => { this.#resolve = resolve; }); } unlock(): void { const resolve = this.#resolve; this.#promise = undefined; this.#resolve = undefined; resolve?.(); } } export class NodeSqliteIntrospector implements DatabaseIntrospector { readonly #db: Kysely<unknown>; constructor(db: Kysely<unknown>) { this.#db = db; } async getSchemas(): Promise<SchemaMetadata[]> { // Sqlite doesn't support schemas. return []; } async getTables( options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, ): Promise<TableMetadata[]> { let query = this.#db // @ts-expect-error .selectFrom("sqlite_schema") // @ts-expect-error .where("type", "=", "table") // @ts-expect-error .where("name", "not like", "sqlite_%") .select("name") .$castTo<{ name: string }>(); if (!options.withInternalKyselyTables) { query = query // @ts-expect-error .where("name", "!=", DEFAULT_MIGRATION_TABLE) // @ts-expect-error .where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE); } const tables = await query.execute(); return Promise.all(tables.map(({ name }) => this.#getTableMetadata(name))); } async getMetadata( options?: DatabaseMetadataOptions, ): Promise<DatabaseMetadata> { return { tables: await this.getTables(options), }; } async #getTableMetadata(table: string): Promise<TableMetadata> { const db = this.#db; // Get the SQL that was used to create the table. const createSql = await db // @ts-expect-error .selectFrom("sqlite_master") // @ts-expect-error .where("name", "=", table) .select("sql") .$castTo<{ sql: string | undefined }>() .execute(); // Try to find the name of the column that has `autoincrement` >& const autoIncrementCol = createSql[0]?.sql ?.split(/[\(\),]/) ?.find((it) => it.toLowerCase().includes("autoincrement")) ?.split(/\s+/)?.[0] ?.replace(/["`]/g, ""); const columns = await db .selectFrom( sql<{ name: string; type: string; notnull: 0 | 1; dflt_value: any; }>`pragma_table_info(${table})`.as("table_info"), ) .select(["name", "type", "notnull", "dflt_value"]) .execute(); return { name: table, columns: columns.map((col) => ({ name: col.name, dataType: col.type, isNullable: !col.notnull, isAutoIncrementing: col.name === autoIncrementCol, hasDefaultValue: col.dflt_value != null, })), isView: true, }; } } export class NodeSqliteQueryCompiler extends DefaultQueryCompiler { protected override getCurrentParameterPlaceholder() { return "?"; } protected override getLeftIdentifierWrapper(): string { return '"'; } protected override getRightIdentifierWrapper(): string { return '"'; } protected override getAutoIncrement() { return "autoincrement"; } } export class NodeSqliteDialect implements Dialect { readonly #config: NodeSqliteDialectConfig; constructor(config: NodeSqliteDialectConfig) { this.#config = { ...config }; } createDriver(): Driver { return new NodeSqliteDriver(this.#config); } createQueryCompiler(): QueryCompiler { return new NodeSqliteQueryCompiler(); } createAdapter(): DialectAdapter { return new NodeSqliteAdapter(); } createIntrospector(db: Kysely<any>): DatabaseIntrospector { return new NodeSqliteIntrospector(db); } } ``` -------------------------------------------------------------------------------- /docs/content/docs/authentication/apple.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Apple description: Apple provider setup and usage. --- <Steps> <Step> ### Get your OAuth credentials To use Apple sign in, you need a client ID and client secret. You can get them from the [Apple Developer Portal](https://developer.apple.com/account/resources/authkeys/list). You will need an active **Apple Developer account** to access the developer portal and generate these credentials. Follow these steps to set up your App ID, Service ID, and generate the key needed for your client secret: 1. **Navigate to Certificates, Identifiers & Profiles:** In the Apple Developer Portal, go to the "Certificates, Identifiers & Profiles" section. 2. **Create an App ID:** * Go to the `Identifiers` tab. * Click the `+` icon next to Identifiers. * Select `App IDs`, then click `Continue`. * Select `App` as the type, then click `Continue`. * **Description:** Enter a name for your app (e.g., "My Awesome App"). This name may be displayed to users when they sign in. * **Bundle ID:** Set a bundle ID. The recommended format is a reverse domain name (e.g., `com.yourcompany.yourapp`). Using a suffix like `.ai` (for app identifier) can help with organization but is not required (e.g., `com.yourcompany.yourapp.ai`). * Scroll down to **Capabilities**. Select the checkbox for `Sign In with Apple`. * Click `Continue`, then `Register`. 3. **Create a Service ID:** * Go back to the `Identifiers` tab. * Click the `+` icon. * Select `Service IDs`, then click `Continue`. * **Description:** Enter a description for this service (e.g., your app name again). * **Identifier:** Set a unique identifier for the service. Use a reverse domain format, distinct from your App ID (e.g., `com.yourcompany.yourapp.si`, where `.si` indicates service identifier - this is for your organization and not required). **This Service ID will be your `clientId`.** * Click `Continue`, then `Register`. 4. **Configure the Service ID:** * Find the Service ID you just created in the `Identifiers` list and click on it. * Check the `Sign In with Apple` capability, then click `Configure`. * Under **Primary App ID**, select the App ID you created earlier (e.g., `com.yourcompany.yourapp.ai`). * Under **Domains and Subdomains**, list all the root domains you will use for Sign In with Apple (e.g., `example.com`, `anotherdomain.com`). * Under **Return URLs**, enter the callback URL. `https://yourdomain.com/api/auth/callback/apple`. Add all necessary return URLs. * Click `Next`, then `Done`. * Click `Continue`, then `Save`. 5. **Create a Client Secret Key:** * Go to the `Keys` tab. * Click the `+` icon to create a new key. * **Key Name:** Enter a name for the key (e.g., "Sign In with Apple Key"). * Scroll down and select the checkbox for `Sign In with Apple`. * Click the `Configure` button next to `Sign In with Apple`. * Select the **Primary App ID** you created earlier. * Click `Save`, then `Continue`, then `Register`. * **Download the Key:** Immediately download the `.p8` key file. **This file is only available for download once.** Note the Key ID (available on the Keys page after creation) and your Team ID (available in your Apple Developer Account settings). 6. **Generate the Client Secret (JWT):** Apple requires a JSON Web Token (JWT) to be generated dynamically using the downloaded `.p8` key, the Key ID, and your Team ID. This JWT serves as your `clientSecret`. You can use the guide below from [Apple's documentation](https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret) to understand how to generate this client secret. You can also use our built in generator [below](#generate-apple-client-secret-jwt) to generate the client secret JWT required for 'Sign in with Apple'. **Note:** Apple allows a maximum expiration of 6 months (180 days) for the client secret JWT. You will need to regenerate the client secret before it expires to maintain uninterrupted authentication. </Step> <Step> ### Configure the provider To configure the provider, you need to add it to the `socialProviders` option of the auth instance. You also need to add `https://appleid.apple.com` to the `trustedOrigins` array in your auth instance configuration to allow communication with Apple's authentication servers. ```ts title="auth.ts" import { betterAuth } from "better-auth" export const auth = betterAuth({ socialProviders: { apple: { // [!code highlight] clientId: process.env.APPLE_CLIENT_ID as string, // [!code highlight] clientSecret: process.env.APPLE_CLIENT_SECRET as string, // [!code highlight] // Optional appBundleIdentifier: process.env.APPLE_APP_BUNDLE_IDENTIFIER as string, // [!code highlight] }, // [!code highlight] }, // Add appleid.apple.com to trustedOrigins for Sign In with Apple flows trustedOrigins: ["https://appleid.apple.com"], // [!code highlight] }) ``` On native iOS, it doesn't use the service ID but the app ID (bundle ID) as client ID, so if using the service ID as `clientId` in `signIn.social` with `idToken`, it throws an error: `JWTClaimValidationFailed: unexpected "aud" claim value`. So you need to provide the `appBundleIdentifier` when you want to sign in with Apple using the ID Token. </Step> </Steps> ## Usage ### Sign In with Apple To sign in with Apple, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties: - `provider`: The provider to use. It should be set to `apple`. ```ts title="auth-client.ts" / import { createAuthClient } from "better-auth/client" const authClient = createAuthClient() const signIn = async () => { const data = await authClient.signIn.social({ provider: "apple" }) } ``` ### Sign In with Apple With ID Token To sign in with Apple using the ID Token, you can use the `signIn.social` function to pass the ID Token. This is useful when you have the ID Token from Apple on the client-side and want to use it to sign in on the server. <Callout> If ID token is provided no redirection will happen, and the user will be signed in directly. </Callout> ```ts title="auth-client.ts" await authClient.signIn.social({ provider: "apple", idToken: { token: // Apple ID Token, nonce: // Nonce (optional) accessToken: // Access Token (optional) } }) ``` ## Generate Apple Client Secret (JWT) <GenerateAppleJwt /> ``` -------------------------------------------------------------------------------- /packages/better-auth/src/db/with-hooks.ts: -------------------------------------------------------------------------------- ```typescript import type { DBPreservedModels } from "@better-auth/core/db"; import type { BetterAuthOptions } from "@better-auth/core"; import type { DBAdapter, Where } from "@better-auth/core/db/adapter"; import { getCurrentAdapter } from "@better-auth/core/context"; import { getCurrentAuthContext } from "@better-auth/core/context"; export function getWithHooks( adapter: DBAdapter<BetterAuthOptions>, ctx: { options: BetterAuthOptions; hooks: Exclude<BetterAuthOptions["databaseHooks"], undefined>[]; }, ) { const hooks = ctx.hooks; type BaseModels = Extract< DBPreservedModels, "user" | "account" | "session" | "verification" >; async function createWithHooks<T extends Record<string, any>>( data: T, model: BaseModels, customCreateFn?: { fn: (data: Record<string, any>) => void | Promise<any>; executeMainFn?: boolean; }, ) { const context = await getCurrentAuthContext(); let actualData = data; for (const hook of hooks || []) { const toRun = hook[model]?.create?.before; if (toRun) { // @ts-expect-error context type mismatch const result = await toRun(actualData as any, context); if (result === false) { return null; } const isObject = typeof result === "object" && "data" in result; if (isObject) { actualData = { ...actualData, ...result.data, }; } } } const customCreated = customCreateFn ? await customCreateFn.fn(actualData) : null; const created = !customCreateFn || customCreateFn.executeMainFn ? await (await getCurrentAdapter(adapter)).create<T>({ model, data: actualData as any, forceAllowId: true, }) : customCreated; for (const hook of hooks || []) { const toRun = hook[model]?.create?.after; if (toRun) { // @ts-expect-error context type mismatch await toRun(created as any, context); } } return created; } async function updateWithHooks<T extends Record<string, any>>( data: any, where: Where[], model: BaseModels, customUpdateFn?: { fn: (data: Record<string, any>) => void | Promise<any>; executeMainFn?: boolean; }, ) { const context = await getCurrentAuthContext(); let actualData = data; for (const hook of hooks || []) { const toRun = hook[model]?.update?.before; if (toRun) { // @ts-expect-error context type mismatch const result = await toRun(data as any, context); if (result === false) { return null; } const isObject = typeof result === "object"; actualData = isObject ? (result as any).data : result; } } const customUpdated = customUpdateFn ? await customUpdateFn.fn(actualData) : null; const updated = !customUpdateFn || customUpdateFn.executeMainFn ? await (await getCurrentAdapter(adapter)).update<T>({ model, update: actualData, where, }) : customUpdated; for (const hook of hooks || []) { const toRun = hook[model]?.update?.after; if (toRun) { // @ts-expect-error context type mismatch await toRun(updated as any, context); } } return updated; } async function updateManyWithHooks<T extends Record<string, any>>( data: any, where: Where[], model: BaseModels, customUpdateFn?: { fn: (data: Record<string, any>) => void | Promise<any>; executeMainFn?: boolean; }, ) { const context = await getCurrentAuthContext(); let actualData = data; for (const hook of hooks || []) { const toRun = hook[model]?.update?.before; if (toRun) { // @ts-expect-error context type mismatch const result = await toRun(data as any, context); if (result === false) { return null; } const isObject = typeof result === "object"; actualData = isObject ? (result as any).data : result; } } const customUpdated = customUpdateFn ? await customUpdateFn.fn(actualData) : null; const updated = !customUpdateFn || customUpdateFn.executeMainFn ? await (await getCurrentAdapter(adapter)).updateMany({ model, update: actualData, where, }) : customUpdated; for (const hook of hooks || []) { const toRun = hook[model]?.update?.after; if (toRun) { // @ts-expect-error context type mismatch await toRun(updated as any, context); } } return updated; } async function deleteWithHooks<T extends Record<string, any>>( where: Where[], model: BaseModels, customDeleteFn?: { fn: (where: Where[]) => void | Promise<any>; executeMainFn?: boolean; }, ) { const context = await getCurrentAuthContext(); let entityToDelete: T | null = null; try { const entities = await (await getCurrentAdapter(adapter)).findMany<T>({ model, where, limit: 1, }); entityToDelete = entities[0] || null; } catch (error) { // If we can't find the entity, we'll still proceed with deletion } if (entityToDelete) { for (const hook of hooks || []) { const toRun = hook[model]?.delete?.before; if (toRun) { // @ts-expect-error context type mismatch const result = await toRun(entityToDelete as any, context); if (result === false) { return null; } } } } const customDeleted = customDeleteFn ? await customDeleteFn.fn(where) : null; const deleted = !customDeleteFn || customDeleteFn.executeMainFn ? await (await getCurrentAdapter(adapter)).delete({ model, where, }) : customDeleted; if (entityToDelete) { for (const hook of hooks || []) { const toRun = hook[model]?.delete?.after; if (toRun) { // @ts-expect-error context type mismatch await toRun(entityToDelete as any, context); } } } return deleted; } async function deleteManyWithHooks<T extends Record<string, any>>( where: Where[], model: BaseModels, customDeleteFn?: { fn: (where: Where[]) => void | Promise<any>; executeMainFn?: boolean; }, ) { const context = await getCurrentAuthContext(); let entitiesToDelete: T[] = []; try { entitiesToDelete = await (await getCurrentAdapter(adapter)).findMany<T>({ model, where, }); } catch (error) { // If we can't find the entities, we'll still proceed with deletion } for (const entity of entitiesToDelete) { for (const hook of hooks || []) { const toRun = hook[model]?.delete?.before; if (toRun) { // @ts-expect-error context type mismatch const result = await toRun(entity as any, context); if (result === false) { return null; } } } } const customDeleted = customDeleteFn ? await customDeleteFn.fn(where) : null; const deleted = !customDeleteFn || customDeleteFn.executeMainFn ? await (await getCurrentAdapter(adapter)).deleteMany({ model, where, }) : customDeleted; for (const entity of entitiesToDelete) { for (const hook of hooks || []) { const toRun = hook[model]?.delete?.after; if (toRun) { // @ts-expect-error context type mismatch await toRun(entity as any, context); } } } return deleted; } return { createWithHooks, updateWithHooks, updateManyWithHooks, deleteWithHooks, deleteManyWithHooks, }; } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/verify-api-key.ts: -------------------------------------------------------------------------------- ```typescript import * as z from "zod"; import { createAuthEndpoint } from "@better-auth/core/api"; import { APIError } from "../../../api"; import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; import type { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import { isRateLimited } from "../rate-limit"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import { role } from "../../access"; import { defaultKeyHasher } from "../"; import type { AuthContext, GenericEndpointContext } from "@better-auth/core"; export async function validateApiKey({ hashedKey, ctx, opts, schema, permissions, }: { hashedKey: string; opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; permissions?: Record<string, string[]>; ctx: GenericEndpointContext; }) { const apiKey = await ctx.context.adapter.findOne<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "key", value: hashedKey, }, ], }); if (!apiKey) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.INVALID_API_KEY, }); } if (apiKey.enabled === false) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.KEY_DISABLED, code: "KEY_DISABLED" as const, }); } if (apiKey.expiresAt) { const now = new Date().getTime(); const expiresAt = new Date(apiKey.expiresAt).getTime(); if (now > expiresAt) { try { ctx.context.adapter.delete({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: apiKey.id, }, ], }); } catch (error) { ctx.context.logger.error(`Failed to delete expired API keys:`, error); } throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.KEY_EXPIRED, code: "KEY_EXPIRED" as const, }); } } if (permissions) { const apiKeyPermissions = apiKey.permissions ? safeJSONParse<{ [key: string]: string[]; }>(apiKey.permissions) : null; if (!apiKeyPermissions) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.KEY_NOT_FOUND, code: "KEY_NOT_FOUND" as const, }); } const r = role(apiKeyPermissions as any); const result = r.authorize(permissions); if (!result.success) { throw new APIError("UNAUTHORIZED", { message: ERROR_CODES.KEY_NOT_FOUND, code: "KEY_NOT_FOUND" as const, }); } } let remaining = apiKey.remaining; let lastRefillAt = apiKey.lastRefillAt; if (apiKey.remaining === 0 && apiKey.refillAmount === null) { // if there is no more remaining requests, and there is no refill amount, than the key is revoked try { ctx.context.adapter.delete({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: apiKey.id, }, ], }); } catch (error) { ctx.context.logger.error(`Failed to delete expired API keys:`, error); } throw new APIError("TOO_MANY_REQUESTS", { message: ERROR_CODES.USAGE_EXCEEDED, code: "USAGE_EXCEEDED" as const, }); } else if (remaining !== null) { let now = new Date().getTime(); const refillInterval = apiKey.refillInterval; const refillAmount = apiKey.refillAmount; let lastTime = new Date(lastRefillAt ?? apiKey.createdAt).getTime(); if (refillInterval && refillAmount) { // if they provide refill info, then we should refill once the interval is reached. const timeSinceLastRequest = now - lastTime; if (timeSinceLastRequest > refillInterval) { remaining = refillAmount; lastRefillAt = new Date(); } } if (remaining === 0) { // if there are no more remaining requests, than the key is invalid throw new APIError("TOO_MANY_REQUESTS", { message: ERROR_CODES.USAGE_EXCEEDED, code: "USAGE_EXCEEDED" as const, }); } else { remaining--; } } const { message, success, update, tryAgainIn } = isRateLimited(apiKey, opts); const newApiKey = await ctx.context.adapter.update<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: apiKey.id, }, ], update: { ...update, remaining, lastRefillAt, }, }); if (!newApiKey) { throw new APIError("INTERNAL_SERVER_ERROR", { message: ERROR_CODES.FAILED_TO_UPDATE_API_KEY, code: "INTERNAL_SERVER_ERROR" as const, }); } if (success === false) { throw new APIError("UNAUTHORIZED", { message: message ?? undefined, code: "RATE_LIMITED" as const, details: { tryAgainIn, }, }); } return newApiKey; } export function verifyApiKey({ opts, schema, deleteAllExpiredApiKeys, }: { opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; deleteAllExpiredApiKeys( ctx: AuthContext, byPassLastCheckTime?: boolean, ): void; }) { return createAuthEndpoint( "/api-key/verify", { method: "POST", body: z.object({ key: z.string().meta({ description: "The key to verify", }), permissions: z .record(z.string(), z.array(z.string())) .meta({ description: "The permissions to verify.", }) .optional(), }), metadata: { SERVER_ONLY: true, }, }, async (ctx) => { const { key } = ctx.body; if (key.length < opts.defaultKeyLength) { // if the key is shorter than the default key length, than we know the key is invalid. // we can't check if the key is exactly equal to the default key length, because // a prefix may be added to the key. return ctx.json({ valid: false, error: { message: ERROR_CODES.INVALID_API_KEY, code: "KEY_NOT_FOUND" as const, }, key: null, }); } if (opts.customAPIKeyValidator) { const isValid = await opts.customAPIKeyValidator({ ctx, key }); if (!isValid) { return ctx.json({ valid: false, error: { message: ERROR_CODES.INVALID_API_KEY, code: "KEY_NOT_FOUND" as const, }, key: null, }); } } const hashed = opts.disableKeyHashing ? key : await defaultKeyHasher(key); let apiKey: ApiKey | null = null; try { apiKey = await validateApiKey({ hashedKey: hashed, permissions: ctx.body.permissions, ctx, opts, schema, }); await deleteAllExpiredApiKeys(ctx.context); } catch (error) { if (error instanceof APIError) { return ctx.json({ valid: false, error: { message: error.body?.message, code: error.body?.code as string, }, key: null, }); } return ctx.json({ valid: false, error: { message: ERROR_CODES.INVALID_API_KEY, code: "INVALID_API_KEY" as const, }, key: null, }); } const { key: _, ...returningApiKey } = apiKey ?? { key: 1, permissions: undefined, }; if ("metadata" in returningApiKey) { returningApiKey.metadata = schema.apikey.fields.metadata.transform.output( returningApiKey.metadata as never as string, ); } returningApiKey.permissions = returningApiKey.permissions ? safeJSONParse<{ [key: string]: string[]; }>(returningApiKey.permissions) : null; return ctx.json({ valid: true, error: null, key: apiKey === null ? null : (returningApiKey as Omit<ApiKey, "key">), }); }, ); } ``` -------------------------------------------------------------------------------- /packages/stripe/src/hooks.ts: -------------------------------------------------------------------------------- ```typescript import { type GenericEndpointContext, logger } from "better-auth"; import type Stripe from "stripe"; import type { InputSubscription, StripeOptions, Subscription } from "./types"; import { getPlanByPriceInfo } from "./utils"; export async function onCheckoutSessionCompleted( ctx: GenericEndpointContext, options: StripeOptions, event: Stripe.Event, ) { try { const client = options.stripeClient; const checkoutSession = event.data.object as Stripe.Checkout.Session; if (checkoutSession.mode === "setup" || !options.subscription?.enabled) { return; } const subscription = await client.subscriptions.retrieve( checkoutSession.subscription as string, ); const priceId = subscription.items.data[0]?.price.id; const priceLookupKey = subscription.items.data[0]?.price.lookup_key || null; const plan = await getPlanByPriceInfo( options, priceId as string, priceLookupKey, ); if (plan) { const referenceId = checkoutSession?.client_reference_id || checkoutSession?.metadata?.referenceId; const subscriptionId = checkoutSession?.metadata?.subscriptionId; const seats = subscription.items.data[0]!.quantity; if (referenceId && subscriptionId) { const trial = subscription.trial_start && subscription.trial_end ? { trialStart: new Date(subscription.trial_start * 1000), trialEnd: new Date(subscription.trial_end * 1000), } : {}; let dbSubscription = await ctx.context.adapter.update<InputSubscription>({ model: "subscription", update: { plan: plan.name.toLowerCase(), status: subscription.status, updatedAt: new Date(), periodStart: new Date( subscription.items.data[0]!.current_period_start * 1000, ), periodEnd: new Date( subscription.items.data[0]!.current_period_end * 1000, ), stripeSubscriptionId: checkoutSession.subscription as string, seats, ...trial, }, where: [ { field: "id", value: subscriptionId, }, ], }); if (trial.trialStart && plan.freeTrial?.onTrialStart) { await plan.freeTrial.onTrialStart(dbSubscription as Subscription); } if (!dbSubscription) { dbSubscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "id", value: subscriptionId, }, ], }); } await options.subscription?.onSubscriptionComplete?.( { event, subscription: dbSubscription as Subscription, stripeSubscription: subscription, plan, }, ctx, ); return; } } } catch (e: any) { logger.error(`Stripe webhook failed. Error: ${e.message}`); } } export async function onSubscriptionUpdated( ctx: GenericEndpointContext, options: StripeOptions, event: Stripe.Event, ) { try { if (!options.subscription?.enabled) { return; } const subscriptionUpdated = event.data.object as Stripe.Subscription; const priceId = subscriptionUpdated.items.data[0]!.price.id; const priceLookupKey = subscriptionUpdated.items.data[0]!.price.lookup_key || null; const plan = await getPlanByPriceInfo(options, priceId, priceLookupKey); const subscriptionId = subscriptionUpdated.metadata?.subscriptionId; const customerId = subscriptionUpdated.customer?.toString(); let subscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: subscriptionId ? [{ field: "id", value: subscriptionId }] : [{ field: "stripeSubscriptionId", value: subscriptionUpdated.id }], }); if (!subscription) { const subs = await ctx.context.adapter.findMany<Subscription>({ model: "subscription", where: [{ field: "stripeCustomerId", value: customerId }], }); if (subs.length > 1) { const activeSub = subs.find( (sub: Subscription) => sub.status === "active" || sub.status === "trialing", ); if (!activeSub) { logger.warn( `Stripe webhook error: Multiple subscriptions found for customerId: ${customerId} and no active subscription is found`, ); return; } subscription = activeSub; } else { subscription = subs[0]!; } } const seats = subscriptionUpdated.items.data[0]!.quantity; await ctx.context.adapter.update({ model: "subscription", update: { ...(plan ? { plan: plan.name.toLowerCase(), limits: plan.limits, } : {}), updatedAt: new Date(), status: subscriptionUpdated.status, periodStart: new Date( subscriptionUpdated.items.data[0]!.current_period_start * 1000, ), periodEnd: new Date( subscriptionUpdated.items.data[0]!.current_period_end * 1000, ), cancelAtPeriodEnd: subscriptionUpdated.cancel_at_period_end, seats, stripeSubscriptionId: subscriptionUpdated.id, }, where: [ { field: "id", value: subscription.id, }, ], }); const subscriptionCanceled = subscriptionUpdated.status === "active" && subscriptionUpdated.cancel_at_period_end && !subscription.cancelAtPeriodEnd; //if this is true, it means the subscription was canceled before the event was triggered if (subscriptionCanceled) { await options.subscription.onSubscriptionCancel?.({ subscription, cancellationDetails: subscriptionUpdated.cancellation_details || undefined, stripeSubscription: subscriptionUpdated, event, }); } await options.subscription.onSubscriptionUpdate?.({ event, subscription, }); if (plan) { if ( subscriptionUpdated.status === "active" && subscription.status === "trialing" && plan.freeTrial?.onTrialEnd ) { await plan.freeTrial.onTrialEnd({ subscription }, ctx); } if ( subscriptionUpdated.status === "incomplete_expired" && subscription.status === "trialing" && plan.freeTrial?.onTrialExpired ) { await plan.freeTrial.onTrialExpired(subscription, ctx); } } } catch (error: any) { logger.error(`Stripe webhook failed. Error: ${error}`); } } export async function onSubscriptionDeleted( ctx: GenericEndpointContext, options: StripeOptions, event: Stripe.Event, ) { if (!options.subscription?.enabled) { return; } try { const subscriptionDeleted = event.data.object as Stripe.Subscription; const subscriptionId = subscriptionDeleted.id; const subscription = await ctx.context.adapter.findOne<Subscription>({ model: "subscription", where: [ { field: "stripeSubscriptionId", value: subscriptionId, }, ], }); if (subscription) { await ctx.context.adapter.update({ model: "subscription", where: [ { field: "id", value: subscription.id, }, ], update: { status: "canceled", updatedAt: new Date(), }, }); await options.subscription.onSubscriptionDeleted?.({ event, stripeSubscription: subscriptionDeleted, subscription, }); } else { logger.warn( `Stripe webhook error: Subscription not found for subscriptionId: ${subscriptionId}`, ); } } catch (error: any) { logger.error(`Stripe webhook failed. Error: ${error}`); } } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/organization/client.ts: -------------------------------------------------------------------------------- ```typescript import { atom } from "nanostores"; import type { InferInvitation, InferMember, Invitation, Member, Organization, Team, } from "../../plugins/organization/schema"; import type { Prettify } from "../../types/helper"; import { type AccessControl, type Role } from "../access"; import type { BetterAuthClientPlugin } from "@better-auth/core"; import { type OrganizationPlugin } from "./organization"; import { useAuthQuery } from "../../client"; import { defaultStatements, adminAc, memberAc, ownerAc, defaultRoles, } from "./access"; import type { DBFieldAttribute } from "@better-auth/core/db"; import type { BetterAuthOptions, BetterAuthPlugin } from "../../types"; import type { OrganizationOptions } from "./types"; import type { HasPermissionBaseInput } from "./permission"; import { hasPermissionFn } from "./permission"; /** * Using the same `hasPermissionFn` function, but without the need for a `ctx` parameter or the `organizationId` parameter. */ export const clientSideHasPermission = (input: HasPermissionBaseInput) => { const acRoles: { [x: string]: Role<any> | undefined; } = input.options.roles || defaultRoles; return hasPermissionFn(input, acRoles); }; interface OrganizationClientOptions { ac?: AccessControl; roles?: { [key in string]: Role; }; teams?: { enabled: boolean; }; schema?: { organization?: { additionalFields?: { [key: string]: DBFieldAttribute; }; }; member?: { additionalFields?: { [key: string]: DBFieldAttribute; }; }; invitation?: { additionalFields?: { [key: string]: DBFieldAttribute; }; }; team?: { additionalFields?: { [key: string]: DBFieldAttribute; }; }; organizationRole?: { additionalFields?: { [key: string]: DBFieldAttribute; }; }; }; dynamicAccessControl?: { enabled: boolean; }; } export const organizationClient = <CO extends OrganizationClientOptions>( options?: CO, ) => { const $listOrg = atom<boolean>(false); const $activeOrgSignal = atom<boolean>(false); const $activeMemberSignal = atom<boolean>(false); const $activeMemberRoleSignal = atom<boolean>(false); type DefaultStatements = typeof defaultStatements; type Statements = CO["ac"] extends AccessControl<infer S> ? S : DefaultStatements; type PermissionType = { [key in keyof Statements]?: Array< Statements[key] extends readonly unknown[] ? Statements[key][number] : never >; }; type PermissionExclusive = | { /** * @deprecated Use `permissions` instead */ permission: PermissionType; permissions?: never; } | { permissions: PermissionType; permission?: never; }; const roles = { admin: adminAc, member: memberAc, owner: ownerAc, ...options?.roles, }; type OrganizationReturn = CO["teams"] extends { enabled: true } ? { members: InferMember<CO>[]; invitations: InferInvitation<CO>[]; teams: Team[]; } & Organization : { members: InferMember<CO>[]; invitations: InferInvitation<CO>[]; } & Organization; type Schema = CO["schema"]; return { id: "organization", $InferServerPlugin: {} as OrganizationPlugin<{ ac: CO["ac"] extends AccessControl ? CO["ac"] : AccessControl<DefaultStatements>; roles: CO["roles"] extends Record<string, Role> ? CO["roles"] : { admin: Role; member: Role; owner: Role; }; teams: { enabled: CO["teams"] extends { enabled: true } ? true : false; }; schema: Schema; dynamicAccessControl: { enabled: CO["dynamicAccessControl"] extends { enabled: true } ? true : false; }; }>, getActions: ($fetch, _$store, co) => ({ $Infer: { ActiveOrganization: {} as OrganizationReturn, Organization: {} as Organization, Invitation: {} as InferInvitation<CO>, Member: {} as InferMember<CO>, Team: {} as Team, }, organization: { checkRolePermission: < R extends CO extends { roles: any } ? keyof CO["roles"] : "admin" | "member" | "owner", >( data: PermissionExclusive & { role: R; }, ) => { const isAuthorized = clientSideHasPermission({ role: data.role as string, options: { ac: options?.ac, roles: roles, }, permissions: (data.permissions ?? data.permission) as any, }); return isAuthorized; }, }, }), getAtoms: ($fetch) => { const listOrganizations = useAuthQuery<Organization[]>( $listOrg, "/organization/list", $fetch, { method: "GET", }, ); const activeOrganization = useAuthQuery< Prettify< Organization & { members: (Member & { user: { id: string; name: string; email: string; image: string | undefined; }; })[]; invitations: Invitation[]; } > >( [$activeOrgSignal], "/organization/get-full-organization", $fetch, () => ({ method: "GET", }), ); const activeMember = useAuthQuery<Member>( [$activeMemberSignal], "/organization/get-active-member", $fetch, { method: "GET", }, ); const activeMemberRole = useAuthQuery<{ role: string }>( [$activeMemberRoleSignal], "/organization/get-active-member-role", $fetch, { method: "GET", }, ); return { $listOrg, $activeOrgSignal, $activeMemberSignal, $activeMemberRoleSignal, activeOrganization, listOrganizations, activeMember, activeMemberRole, }; }, pathMethods: { "/organization/get-full-organization": "GET", "/organization/list-user-teams": "GET", }, atomListeners: [ { matcher(path) { return ( path === "/organization/create" || path === "/organization/delete" || path === "/organization/update" ); }, signal: "$listOrg", }, { matcher(path) { return path.startsWith("/organization"); }, signal: "$activeOrgSignal", }, { matcher(path) { return path.startsWith("/organization/set-active"); }, signal: "$sessionSignal", }, { matcher(path) { return path.includes("/organization/update-member-role"); }, signal: "$activeMemberSignal", }, { matcher(path) { return path.includes("/organization/update-member-role"); }, signal: "$activeMemberRoleSignal", }, ], } satisfies BetterAuthClientPlugin; }; export const inferOrgAdditionalFields = < O extends { options: BetterAuthOptions; }, S extends OrganizationOptions["schema"] = undefined, >( schema?: S, ) => { type FindById< T extends readonly BetterAuthPlugin[], TargetId extends string, > = Extract<T[number], { id: TargetId }>; type Auth = O extends { options: any } ? O : { options: { plugins: [] } }; type OrganizationPlugin = FindById< // @ts-expect-error Auth["options"]["plugins"], "organization" >; type Schema = O extends Object ? O extends Exclude<OrganizationOptions["schema"], undefined> ? O : OrganizationPlugin extends { options: { schema: infer S } } ? S extends OrganizationOptions["schema"] ? S : undefined : undefined : undefined; return {} as undefined extends S ? Schema : S; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/jwt/index.ts: -------------------------------------------------------------------------------- ```typescript import type { BetterAuthPlugin } from "@better-auth/core"; import { schema } from "./schema"; import { getJwksAdapter } from "./adapter"; import { getJwtToken, signJWT } from "./sign"; import type { JSONWebKeySet, JWTPayload } from "jose"; import { APIError, sessionMiddleware } from "../../api"; import { createAuthEndpoint, createAuthMiddleware, } from "@better-auth/core/api"; import { mergeSchema } from "../../db/schema"; import * as z from "zod"; import { BetterAuthError } from "@better-auth/core/error"; import type { JwtOptions } from "./types"; import { createJwk } from "./utils"; export type * from "./types"; export { generateExportedKeyPair, createJwk } from "./utils"; export const jwt = (options?: JwtOptions) => { // Remote url must be set when using signing function if (options?.jwt?.sign && !options.jwks?.remoteUrl) { throw new BetterAuthError( "jwks_config", "jwks.remoteUrl must be set when using jwt.sign", ); } // Alg is required to be specified when using remote url (needed in openid metadata) if (options?.jwks?.remoteUrl && !options.jwks?.keyPairConfig?.alg) { throw new BetterAuthError( "jwks_config", "must specify alg when using the oidc plugin and jwks.remoteUrl", ); } return { id: "jwt", options, endpoints: { getJwks: createAuthEndpoint( "/jwks", { method: "GET", metadata: { openapi: { description: "Get the JSON Web Key Set", responses: { "200": { description: "JSON Web Key Set retrieved successfully", content: { "application/json": { schema: { type: "object", properties: { keys: { type: "array", description: "Array of public JSON Web Keys", items: { type: "object", properties: { kid: { type: "string", description: "Key ID uniquely identifying the key, corresponds to the 'id' from the stored Jwk", }, kty: { type: "string", description: "Key type (e.g., 'RSA', 'EC', 'OKP')", }, alg: { type: "string", description: "Algorithm intended for use with the key (e.g., 'EdDSA', 'RS256')", }, use: { type: "string", description: "Intended use of the public key (e.g., 'sig' for signature)", enum: ["sig"], nullable: true, }, n: { type: "string", description: "Modulus for RSA keys (base64url-encoded)", nullable: true, }, e: { type: "string", description: "Exponent for RSA keys (base64url-encoded)", nullable: true, }, crv: { type: "string", description: "Curve name for elliptic curve keys (e.g., 'Ed25519', 'P-256')", nullable: true, }, x: { type: "string", description: "X coordinate for elliptic curve keys (base64url-encoded)", nullable: true, }, y: { type: "string", description: "Y coordinate for elliptic curve keys (base64url-encoded)", nullable: true, }, }, required: ["kid", "kty", "alg"], }, }, }, required: ["keys"], }, }, }, }, }, }, }, }, async (ctx) => { // Disables endpoint if using remote url strategy if (options?.jwks?.remoteUrl) { throw new APIError("NOT_FOUND"); } const adapter = getJwksAdapter(ctx.context.adapter); const keySets = await adapter.getAllKeys(); if (keySets.length === 0) { const key = await createJwk(ctx, options); keySets.push(key); } const keyPairConfig = options?.jwks?.keyPairConfig; const defaultCrv = keyPairConfig ? "crv" in keyPairConfig ? (keyPairConfig as { crv: string }).crv : undefined : undefined; return ctx.json({ keys: keySets.map((keySet) => { return { alg: keySet.alg ?? options?.jwks?.keyPairConfig?.alg ?? "EdDSA", crv: keySet.crv ?? defaultCrv, ...JSON.parse(keySet.publicKey), kid: keySet.id, }; }), } satisfies JSONWebKeySet as JSONWebKeySet); }, ), getToken: createAuthEndpoint( "/token", { method: "GET", requireHeaders: true, use: [sessionMiddleware], metadata: { openapi: { description: "Get a JWT token", responses: { 200: { description: "Success", content: { "application/json": { schema: { type: "object", properties: { token: { type: "string", }, }, }, }, }, }, }, }, }, }, async (ctx) => { const jwt = await getJwtToken(ctx, options); return ctx.json({ token: jwt, }); }, ), signJWT: createAuthEndpoint( "/sign-jwt", { method: "POST", metadata: { SERVER_ONLY: true, $Infer: { body: {} as { payload: JWTPayload; overrideOptions?: JwtOptions; }, }, }, body: z.object({ payload: z.record(z.string(), z.any()), overrideOptions: z.record(z.string(), z.any()).optional(), }), }, async (c) => { const jwt = await signJWT(c, { options: { ...options, ...c.body.overrideOptions, }, payload: c.body.payload, }); return c.json({ token: jwt }); }, ), }, hooks: { after: [ { matcher(context) { return context.path === "/get-session"; }, handler: createAuthMiddleware(async (ctx) => { if (options?.disableSettingJwtHeader) { return; } const session = ctx.context.session || ctx.context.newSession; if (session && session.session) { const jwt = await getJwtToken(ctx, options); const exposedHeaders = ctx.context.responseHeaders?.get( "access-control-expose-headers", ) || ""; const headersSet = new Set( exposedHeaders .split(",") .map((header) => header.trim()) .filter(Boolean), ); headersSet.add("set-auth-jwt"); ctx.setHeader("set-auth-jwt", jwt); ctx.setHeader( "Access-Control-Expose-Headers", Array.from(headersSet).join(", "), ); } }), }, ], }, schema: mergeSchema(schema, options?.schema), } satisfies BetterAuthPlugin; }; export { getJwtToken }; ```