This is page 19 of 69. Use http://codebase.md/better-auth/better-auth?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── 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 -------------------------------------------------------------------------------- /docs/content/docs/integrations/lynx.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Lynx Integration 3 | description: Integrate Better Auth with Lynx cross-platform framework. 4 | --- 5 | 6 | This integration guide is for using Better Auth with [Lynx](https://lynxjs.org), a cross-platform rendering framework that enables developers to build applications for Android, iOS, and Web platforms with native rendering performance. 7 | 8 | Before you start, make sure you have a Better Auth instance configured. If you haven't done that yet, check out the [installation](/docs/installation). 9 | 10 | ## Installation 11 | 12 | Install Better Auth and the Lynx React dependency: 13 | 14 | ```package-install 15 | better-auth @lynx-js/react 16 | ``` 17 | 18 | ## Create Client Instance 19 | 20 | Import `createAuthClient` from `better-auth/lynx` to create your client instance: 21 | 22 | ```ts title="lib/auth-client.ts" 23 | import { createAuthClient } from "better-auth/lynx" 24 | 25 | export const authClient = createAuthClient({ 26 | baseURL: "http://localhost:3000" // The base URL of your auth server 27 | }) 28 | ``` 29 | 30 | ## Usage 31 | 32 | The Lynx client provides the same API as other Better Auth clients, with optimized integration for Lynx's reactive system. 33 | 34 | ### Authentication Methods 35 | 36 | ```ts 37 | import { authClient } from "./lib/auth-client" 38 | 39 | // Sign in with email and password 40 | await authClient.signIn.email({ 41 | email: "[email protected]", 42 | password: "password1234" 43 | }) 44 | 45 | // Sign up 46 | await authClient.signUp.email({ 47 | email: "[email protected]", 48 | password: "password1234", 49 | name: "John Doe" 50 | }) 51 | 52 | // Sign out 53 | await authClient.signOut() 54 | ``` 55 | 56 | ### Hooks 57 | 58 | The Lynx client includes reactive hooks that integrate seamlessly with Lynx's component system: 59 | 60 | #### useSession 61 | 62 | ```tsx title="components/user.tsx" 63 | import { authClient } from "../lib/auth-client" 64 | 65 | export function User() { 66 | const { 67 | data: session, 68 | isPending, // loading state 69 | error // error object 70 | } = authClient.useSession() 71 | 72 | if (isPending) return <div>Loading...</div> 73 | if (error) return <div>Error: {error.message}</div> 74 | 75 | return ( 76 | <div> 77 | {session ? ( 78 | <div> 79 | <p>Welcome, {session.user.name}!</p> 80 | <button onClick={() => authClient.signOut()}> 81 | Sign Out 82 | </button> 83 | </div> 84 | ) : ( 85 | <button onClick={() => authClient.signIn.social({ 86 | provider: 'github' 87 | })}> 88 | Sign In with GitHub 89 | </button> 90 | )} 91 | </div> 92 | ) 93 | } 94 | ``` 95 | 96 | ### Store Integration 97 | 98 | The Lynx client uses [nanostores](https://github.com/nanostores/nanostores) for state management and provides a `useStore` hook for accessing reactive state: 99 | 100 | ```tsx title="components/session-info.tsx" 101 | import { useStore } from "better-auth/lynx" 102 | import { authClient } from "../lib/auth-client" 103 | 104 | export function SessionInfo() { 105 | // Access the session store directly 106 | const session = useStore(authClient.$store.session) 107 | 108 | return ( 109 | <div> 110 | {session && ( 111 | <pre>{JSON.stringify(session, null, 2)}</pre> 112 | )} 113 | </div> 114 | ) 115 | } 116 | ``` 117 | 118 | ### Advanced Store Usage 119 | 120 | You can use the store with selective key watching for optimized re-renders: 121 | 122 | ```tsx title="components/optimized-user.tsx" 123 | import { useStore } from "better-auth/lynx" 124 | import { authClient } from "../lib/auth-client" 125 | 126 | export function OptimizedUser() { 127 | // Only re-render when specific keys change 128 | const session = useStore(authClient.$store.session, { 129 | keys: ['user.name', 'user.email'] // Only watch these specific keys 130 | }) 131 | 132 | return ( 133 | <div> 134 | {session?.user && ( 135 | <div> 136 | <h2>{session.user.name}</h2> 137 | <p>{session.user.email}</p> 138 | </div> 139 | )} 140 | </div> 141 | ) 142 | } 143 | ``` 144 | 145 | ## Plugin Support 146 | 147 | The Lynx client supports all Better Auth plugins: 148 | 149 | ```ts title="lib/auth-client.ts" 150 | import { createAuthClient } from "better-auth/lynx" 151 | import { magicLinkClient } from "better-auth/client/plugins" 152 | 153 | const authClient = createAuthClient({ 154 | plugins: [ 155 | magicLinkClient() 156 | ] 157 | }) 158 | 159 | // Use plugin methods 160 | await authClient.signIn.magicLink({ 161 | email: "[email protected]" 162 | }) 163 | ``` 164 | 165 | ## Error Handling 166 | 167 | Error handling works the same as other Better Auth clients: 168 | 169 | ```tsx title="components/login-form.tsx" 170 | import { authClient } from "../lib/auth-client" 171 | 172 | export function LoginForm() { 173 | const signIn = async (email: string, password: string) => { 174 | const { data, error } = await authClient.signIn.email({ 175 | email, 176 | password 177 | }) 178 | 179 | if (error) { 180 | console.error('Login failed:', error.message) 181 | return 182 | } 183 | 184 | console.log('Login successful:', data) 185 | } 186 | 187 | return ( 188 | <form onSubmit={(e) => { 189 | e.preventDefault() 190 | const formData = new FormData(e.target) 191 | signIn(formData.get('email'), formData.get('password')) 192 | }}> 193 | <input name="email" type="email" placeholder="Email" /> 194 | <input name="password" type="password" placeholder="Password" /> 195 | <button type="submit">Sign In</button> 196 | </form> 197 | ) 198 | } 199 | ``` 200 | 201 | ## Features 202 | 203 | The Lynx client provides: 204 | 205 | - **Cross-Platform Support**: Works across Android, iOS, and Web platforms 206 | - **Optimized Performance**: Built specifically for Lynx's reactive system 207 | - **Nanostores Integration**: Uses nanostores for efficient state management 208 | - **Selective Re-rendering**: Watch specific store keys to minimize unnecessary updates 209 | - **Full API Compatibility**: All Better Auth methods and plugins work seamlessly 210 | - **TypeScript Support**: Full type safety with TypeScript inference 211 | 212 | The Lynx integration maintains all the features and benefits of Better Auth while providing optimal performance and developer experience within Lynx's cross-platform ecosystem. ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/get-api-key.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as z from "zod"; 2 | import { APIError, sessionMiddleware } from "../../../api"; 3 | import { createAuthEndpoint } from "@better-auth/core/api"; 4 | import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; 5 | import type { apiKeySchema } from "../schema"; 6 | import type { ApiKey } from "../types"; 7 | import type { PredefinedApiKeyOptions } from "."; 8 | import { safeJSONParse } from "../../../utils/json"; 9 | import type { AuthContext } from "@better-auth/core"; 10 | 11 | export function getApiKey({ 12 | opts, 13 | schema, 14 | deleteAllExpiredApiKeys, 15 | }: { 16 | opts: PredefinedApiKeyOptions; 17 | schema: ReturnType<typeof apiKeySchema>; 18 | deleteAllExpiredApiKeys( 19 | ctx: AuthContext, 20 | byPassLastCheckTime?: boolean, 21 | ): void; 22 | }) { 23 | return createAuthEndpoint( 24 | "/api-key/get", 25 | { 26 | method: "GET", 27 | query: z.object({ 28 | id: z.string().meta({ 29 | description: "The id of the Api Key", 30 | }), 31 | }), 32 | use: [sessionMiddleware], 33 | metadata: { 34 | openapi: { 35 | description: "Retrieve an existing API key by ID", 36 | responses: { 37 | "200": { 38 | description: "API key retrieved successfully", 39 | content: { 40 | "application/json": { 41 | schema: { 42 | type: "object", 43 | properties: { 44 | id: { 45 | type: "string", 46 | description: "ID", 47 | }, 48 | name: { 49 | type: "string", 50 | nullable: true, 51 | description: "The name of the key", 52 | }, 53 | start: { 54 | type: "string", 55 | nullable: true, 56 | description: 57 | "Shows the first few characters of the API key, including the prefix. This allows you to show those few characters in the UI to make it easier for users to identify the API key.", 58 | }, 59 | prefix: { 60 | type: "string", 61 | nullable: true, 62 | description: 63 | "The API Key prefix. Stored as plain text.", 64 | }, 65 | userId: { 66 | type: "string", 67 | description: "The owner of the user id", 68 | }, 69 | refillInterval: { 70 | type: "number", 71 | nullable: true, 72 | description: 73 | "The interval in milliseconds between refills of the `remaining` count. Example: 3600000 // refill every hour (3600000ms = 1h)", 74 | }, 75 | refillAmount: { 76 | type: "number", 77 | nullable: true, 78 | description: "The amount to refill", 79 | }, 80 | lastRefillAt: { 81 | type: "string", 82 | format: "date-time", 83 | nullable: true, 84 | description: "The last refill date", 85 | }, 86 | enabled: { 87 | type: "boolean", 88 | description: "Sets if key is enabled or disabled", 89 | default: true, 90 | }, 91 | rateLimitEnabled: { 92 | type: "boolean", 93 | description: 94 | "Whether the key has rate limiting enabled", 95 | }, 96 | rateLimitTimeWindow: { 97 | type: "number", 98 | nullable: true, 99 | description: "The duration in milliseconds", 100 | }, 101 | rateLimitMax: { 102 | type: "number", 103 | nullable: true, 104 | description: 105 | "Maximum amount of requests allowed within a window", 106 | }, 107 | requestCount: { 108 | type: "number", 109 | description: 110 | "The number of requests made within the rate limit time window", 111 | }, 112 | remaining: { 113 | type: "number", 114 | nullable: true, 115 | description: 116 | "Remaining requests (every time api key is used this should updated and should be updated on refill as well)", 117 | }, 118 | lastRequest: { 119 | type: "string", 120 | format: "date-time", 121 | nullable: true, 122 | description: "When last request occurred", 123 | }, 124 | expiresAt: { 125 | type: "string", 126 | format: "date-time", 127 | nullable: true, 128 | description: "Expiry date of a key", 129 | }, 130 | createdAt: { 131 | type: "string", 132 | format: "date-time", 133 | description: "created at", 134 | }, 135 | updatedAt: { 136 | type: "string", 137 | format: "date-time", 138 | description: "updated at", 139 | }, 140 | metadata: { 141 | type: "object", 142 | nullable: true, 143 | additionalProperties: true, 144 | description: "Extra metadata about the apiKey", 145 | }, 146 | permissions: { 147 | type: "string", 148 | nullable: true, 149 | description: 150 | "Permissions for the api key (stored as JSON string)", 151 | }, 152 | }, 153 | required: [ 154 | "id", 155 | "userId", 156 | "enabled", 157 | "rateLimitEnabled", 158 | "requestCount", 159 | "createdAt", 160 | "updatedAt", 161 | ], 162 | }, 163 | }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | }, 170 | async (ctx) => { 171 | const { id } = ctx.query; 172 | 173 | const session = ctx.context.session; 174 | 175 | let apiKey = await ctx.context.adapter.findOne<ApiKey>({ 176 | model: API_KEY_TABLE_NAME, 177 | where: [ 178 | { 179 | field: "id", 180 | value: id, 181 | }, 182 | { 183 | field: "userId", 184 | value: session.user.id, 185 | }, 186 | ], 187 | }); 188 | 189 | if (!apiKey) { 190 | throw new APIError("NOT_FOUND", { 191 | message: ERROR_CODES.KEY_NOT_FOUND, 192 | }); 193 | } 194 | 195 | deleteAllExpiredApiKeys(ctx.context); 196 | 197 | // convert metadata string back to object 198 | apiKey.metadata = schema.apikey.fields.metadata.transform.output( 199 | apiKey.metadata as never as string, 200 | ); 201 | 202 | const { key, ...returningApiKey } = apiKey; 203 | 204 | return ctx.json({ 205 | ...returningApiKey, 206 | permissions: returningApiKey.permissions 207 | ? safeJSONParse<{ 208 | [key: string]: string[]; 209 | }>(returningApiKey.permissions) 210 | : null, 211 | }); 212 | }, 213 | ); 214 | } 215 | ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/nitro.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Nitro Integration 3 | description: Integrate Better Auth with Nitro. 4 | --- 5 | 6 | Better Auth can be integrated with your [Nitro Application](https://nitro.build/) (an open source framework to build web servers). 7 | 8 | This guide aims to help you integrate Better Auth with your Nitro application in a few simple steps. 9 | 10 | ## Create a new Nitro Application 11 | 12 | Start by scaffolding a new Nitro application using the following command: 13 | 14 | ```bash title="Terminal" 15 | npx giget@latest nitro nitro-app --install 16 | ``` 17 | 18 | This will create the `nitro-app` directory and install all the dependencies. You can now open the `nitro-app` directory in your code editor. 19 | 20 | ### Prisma Adapter Setup 21 | 22 | <Callout> 23 | This guide assumes that you have a basic understanding of Prisma. If you are new to Prisma, you can check out the [Prisma documentation](https://www.prisma.io/docs/getting-started). 24 | 25 | The `sqlite` database used in this guide will not work in a production environment. You should replace it with a production-ready database like `PostgreSQL`. 26 | </Callout> 27 | 28 | For this guide, we will be using the Prisma adapter. You can install prisma client by running the following command: 29 | 30 | ```package-install 31 | @prisma/client 32 | ``` 33 | 34 | `prisma` can be installed as a dev dependency using the following command: 35 | 36 | ```package-install 37 | -D prisma 38 | ``` 39 | 40 | Generate a `schema.prisma` file in the `prisma` directory by running the following command: 41 | 42 | ```bash title="Terminal" 43 | npx prisma init 44 | ``` 45 | 46 | You can now replace the contents of the `schema.prisma` file with the following: 47 | 48 | ```prisma title="prisma/schema.prisma" 49 | generator client { 50 | provider = "prisma-client-js" 51 | } 52 | 53 | datasource db { 54 | provider = "sqlite" 55 | url = env("DATABASE_URL") 56 | } 57 | 58 | // Will be deleted. Just need it to generate the prisma client 59 | model Test { 60 | id Int @id @default(autoincrement()) 61 | name String 62 | } 63 | ``` 64 | 65 | Ensure that you update the `DATABASE_URL` in your `.env` file to point to the location of your database. 66 | 67 | ```txt title=".env" 68 | DATABASE_URL="file:./dev.db" 69 | ``` 70 | 71 | Run the following command to generate the Prisma client & sync the database: 72 | 73 | ```bash title="Terminal" 74 | npx prisma db push 75 | ``` 76 | 77 | ### Install & Configure Better Auth 78 | 79 | Follow steps 1 & 2 from the [installation guide](/docs/installation) to install Better Auth in your Nitro application & set up the environment variables. 80 | 81 | Once that is done, create your Better Auth instance within the `server/utils/auth.ts` file. 82 | 83 | ```ts title="server/utils/auth.ts" 84 | import { betterAuth } from "better-auth"; 85 | import { prismaAdapter } from "better-auth/adapters/prisma"; 86 | import { PrismaClient } from "@prisma/client"; 87 | 88 | const prisma = new PrismaClient(); 89 | export const auth = betterAuth({ 90 | database: prismaAdapter(prisma, { provider: "sqlite" }), 91 | emailAndPassword: { enabled: true }, 92 | }); 93 | ``` 94 | 95 | ### Update Prisma Schema 96 | 97 | Use the Better Auth CLI to update your Prisma schema with the required models by running the following command: 98 | 99 | ```bash title="Terminal" 100 | npx @better-auth/cli generate --config server/utils/auth.ts 101 | ``` 102 | 103 | <Callout> 104 | The `--config` flag is used to specify the path to the file where you have created your Better Auth instance. 105 | </Callout> 106 | 107 | Head over to the `prisma/schema.prisma` file & save the file to trigger the format on save. 108 | 109 | After saving the file, you can run the `npx prisma db push` command to update the database schema. 110 | 111 | ## Mount The Handler 112 | 113 | You can now mount the Better Auth handler in your Nitro application. You can do this by adding the following code to your `server/routes/api/auth/[...all].ts` file: 114 | 115 | ```ts title="server/routes/api/auth/[...all].ts" 116 | export default defineEventHandler((event) => { 117 | return auth.handler(toWebRequest(event)); 118 | }); 119 | ``` 120 | <Callout> 121 | This is a [catch-all](https://nitro.build/guide/routing#catch-all-route) route that will handle all requests to `/api/auth/*`. 122 | </Callout> 123 | 124 | ### CORS 125 | 126 | You can configure CORS for your Nitro app by creating a plugin. 127 | 128 | Start by installing the cors package: 129 | 130 | ```package-install 131 | cors 132 | ``` 133 | 134 | You can now create a new file `server/plugins/cors.ts` and add the following code: 135 | 136 | ```ts title="server/plugins/cors.ts" 137 | import cors from "cors"; 138 | export default defineNitroPlugin((plugin) => { 139 | plugin.h3App.use( 140 | fromNodeMiddleware( 141 | cors({ 142 | origin: "*", 143 | }), 144 | ), 145 | ); 146 | }); 147 | ``` 148 | <Callout> 149 | This will enable CORS for all routes. You can customize the `origin` property to allow requests from specific domains. Ensure that the config is in sync with your frontend application. 150 | </Callout> 151 | 152 | ### Auth Guard/Middleware 153 | 154 | You can add an auth guard to your Nitro application to protect routes that require authentication. You can do this by creating a new file `server/utils/require-auth.ts` and adding the following code: 155 | 156 | ```ts title="server/utils/require-auth.ts" 157 | import { EventHandler, H3Event } from "h3"; 158 | import { fromNodeHeaders } from "better-auth/node"; 159 | 160 | /** 161 | * Middleware used to require authentication for a route. 162 | * 163 | * Can be extended to check for specific roles or permissions. 164 | */ 165 | export const requireAuth: EventHandler = async (event: H3Event) => { 166 | const headers = event.headers; 167 | 168 | const session = await auth.api.getSession({ 169 | headers: headers, 170 | }); 171 | if (!session) 172 | throw createError({ 173 | statusCode: 401, 174 | statusMessage: "Unauthorized", 175 | }); 176 | // You can save the session to the event context for later use 177 | event.context.auth = session; 178 | }; 179 | ``` 180 | 181 | You can now use this event handler/middleware in your routes to protect them: 182 | 183 | ```ts title="server/routes/api/secret.get.ts" 184 | // Object syntax of the route handler 185 | export default defineEventHandler({ 186 | // The user has to be logged in to access this route 187 | onRequest: [requireAuth], 188 | handler: async (event) => { 189 | setResponseStatus(event, 201, "Secret data"); 190 | return { message: "Secret data" }; 191 | }, 192 | }); 193 | ``` 194 | 195 | ### Example 196 | 197 | You can find an example of a Nitro application integrated with Better Auth & Prisma [here](https://github.com/BayBreezy/nitrojs-better-auth-prisma). 198 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/twitter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 3 | import { 4 | createAuthorizationURL, 5 | refreshAccessToken, 6 | validateAuthorizationCode, 7 | } from "../oauth2"; 8 | 9 | export interface TwitterProfile { 10 | data: { 11 | /** 12 | * Unique identifier of this user. This is returned as a string in order to avoid complications with languages and tools 13 | * that cannot handle large integers. 14 | */ 15 | id: string; 16 | /** The friendly name of this user, as shown on their profile. */ 17 | name: string; 18 | /** The email address of this user. */ 19 | email?: string; 20 | /** The Twitter handle (screen name) of this user. */ 21 | username: string; 22 | /** 23 | * The location specified in the user's profile, if the user provided one. 24 | * As this is a freeform value, it may not indicate a valid location, but it may be fuzzily evaluated when performing searches with location queries. 25 | * 26 | * To return this field, add `user.fields=location` in the authorization request's query parameter. 27 | */ 28 | location?: string; 29 | /** 30 | * This object and its children fields contain details about text that has a special meaning in the user's description. 31 | * 32 | *To return this field, add `user.fields=entities` in the authorization request's query parameter. 33 | */ 34 | entities?: { 35 | /** Contains details about the user's profile website. */ 36 | url: { 37 | /** Contains details about the user's profile website. */ 38 | urls: Array<{ 39 | /** The start position (zero-based) of the recognized user's profile website. All start indices are inclusive. */ 40 | start: number; 41 | /** The end position (zero-based) of the recognized user's profile website. This end index is exclusive. */ 42 | end: number; 43 | /** The URL in the format entered by the user. */ 44 | url: string; 45 | /** The fully resolved URL. */ 46 | expanded_url: string; 47 | /** The URL as displayed in the user's profile. */ 48 | display_url: string; 49 | }>; 50 | }; 51 | /** Contains details about URLs, Hashtags, Cashtags, or mentions located within a user's description. */ 52 | description: { 53 | hashtags: Array<{ 54 | start: number; 55 | end: number; 56 | tag: string; 57 | }>; 58 | }; 59 | }; 60 | /** 61 | * Indicate if this user is a verified Twitter user. 62 | * 63 | * To return this field, add `user.fields=verified` in the authorization request's query parameter. 64 | */ 65 | verified?: boolean; 66 | /** 67 | * The text of this user's profile description (also known as bio), if the user provided one. 68 | * 69 | * To return this field, add `user.fields=description` in the authorization request's query parameter. 70 | */ 71 | description?: string; 72 | /** 73 | * The URL specified in the user's profile, if present. 74 | * 75 | * To return this field, add `user.fields=url` in the authorization request's query parameter. 76 | */ 77 | url?: string; 78 | /** The URL to the profile image for this user, as shown on the user's profile. */ 79 | profile_image_url?: string; 80 | protected?: boolean; 81 | /** 82 | * Unique identifier of this user's pinned Tweet. 83 | * 84 | * You can obtain the expanded object in `includes.tweets` by adding `expansions=pinned_tweet_id` in the authorization request's query parameter. 85 | */ 86 | pinned_tweet_id?: string; 87 | created_at?: string; 88 | }; 89 | includes?: { 90 | tweets?: Array<{ 91 | id: string; 92 | text: string; 93 | }>; 94 | }; 95 | [claims: string]: unknown; 96 | } 97 | 98 | export interface TwitterOption extends ProviderOptions<TwitterProfile> { 99 | clientId: string; 100 | } 101 | 102 | export const twitter = (options: TwitterOption) => { 103 | return { 104 | id: "twitter", 105 | name: "Twitter", 106 | createAuthorizationURL(data) { 107 | const _scopes = options.disableDefaultScope 108 | ? [] 109 | : ["users.read", "tweet.read", "offline.access", "users.email"]; 110 | options.scope && _scopes.push(...options.scope); 111 | data.scopes && _scopes.push(...data.scopes); 112 | return createAuthorizationURL({ 113 | id: "twitter", 114 | options, 115 | authorizationEndpoint: "https://x.com/i/oauth2/authorize", 116 | scopes: _scopes, 117 | state: data.state, 118 | codeVerifier: data.codeVerifier, 119 | redirectURI: data.redirectURI, 120 | }); 121 | }, 122 | validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { 123 | return validateAuthorizationCode({ 124 | code, 125 | codeVerifier, 126 | authentication: "basic", 127 | redirectURI, 128 | options, 129 | tokenEndpoint: "https://api.x.com/2/oauth2/token", 130 | }); 131 | }, 132 | 133 | refreshAccessToken: options.refreshAccessToken 134 | ? options.refreshAccessToken 135 | : async (refreshToken) => { 136 | return refreshAccessToken({ 137 | refreshToken, 138 | options: { 139 | clientId: options.clientId, 140 | clientKey: options.clientKey, 141 | clientSecret: options.clientSecret, 142 | }, 143 | authentication: "basic", 144 | tokenEndpoint: "https://api.x.com/2/oauth2/token", 145 | }); 146 | }, 147 | async getUserInfo(token) { 148 | if (options.getUserInfo) { 149 | return options.getUserInfo(token); 150 | } 151 | const { data: profile, error: profileError } = 152 | await betterFetch<TwitterProfile>( 153 | "https://api.x.com/2/users/me?user.fields=profile_image_url", 154 | { 155 | method: "GET", 156 | headers: { 157 | Authorization: `Bearer ${token.accessToken}`, 158 | }, 159 | }, 160 | ); 161 | 162 | if (profileError) { 163 | return null; 164 | } 165 | 166 | const { data: emailData, error: emailError } = await betterFetch<{ 167 | data: { confirmed_email: string }; 168 | }>("https://api.x.com/2/users/me?user.fields=confirmed_email", { 169 | method: "GET", 170 | headers: { 171 | Authorization: `Bearer ${token.accessToken}`, 172 | }, 173 | }); 174 | let emailVerified = false; 175 | if (!emailError && emailData?.data?.confirmed_email) { 176 | profile.data.email = emailData.data.confirmed_email; 177 | emailVerified = true; 178 | } 179 | const userMap = await options.mapProfileToUser?.(profile); 180 | return { 181 | user: { 182 | id: profile.data.id, 183 | name: profile.data.name, 184 | email: profile.data.email || profile.data.username || null, 185 | image: profile.data.profile_image_url, 186 | emailVerified: emailVerified, 187 | ...userMap, 188 | }, 189 | data: profile, 190 | }; 191 | }, 192 | options, 193 | } satisfies OAuthProvider<TwitterProfile>; 194 | }; 195 | ``` -------------------------------------------------------------------------------- /docs/components/nav-mobile.tsx: -------------------------------------------------------------------------------- ```typescript 1 | "use client"; 2 | import { ChevronRight, Menu } from "lucide-react"; 3 | import Link from "next/link"; 4 | import { Fragment, createContext, useContext, useState } from "react"; 5 | import { 6 | Accordion, 7 | AccordionContent, 8 | AccordionItem, 9 | AccordionTrigger, 10 | } from "@/components/ui/accordion"; 11 | import { contents, examples } from "./sidebar-content"; 12 | import { usePathname } from "next/navigation"; 13 | import { cn } from "@/lib/utils"; 14 | 15 | interface NavbarMobileContextProps { 16 | isOpen: boolean; 17 | toggleNavbar: () => void; 18 | isDocsOpen: boolean; 19 | toggleDocsNavbar: () => void; 20 | } 21 | 22 | const NavbarContext = createContext<NavbarMobileContextProps | undefined>( 23 | undefined, 24 | ); 25 | 26 | export const NavbarProvider = ({ children }: { children: React.ReactNode }) => { 27 | const [isOpen, setIsOpen] = useState(false); 28 | const [isDocsOpen, setIsDocsOpen] = useState(false); 29 | 30 | const toggleNavbar = () => { 31 | setIsOpen((prevIsOpen) => !prevIsOpen); 32 | }; 33 | const toggleDocsNavbar = () => { 34 | setIsDocsOpen((prevIsOpen) => !prevIsOpen); 35 | }; 36 | return ( 37 | <NavbarContext.Provider 38 | value={{ isOpen, toggleNavbar, isDocsOpen, toggleDocsNavbar }} 39 | > 40 | {children} 41 | </NavbarContext.Provider> 42 | ); 43 | }; 44 | 45 | export const useNavbarMobile = (): NavbarMobileContextProps => { 46 | const context = useContext(NavbarContext); 47 | if (!context) { 48 | throw new Error( 49 | "useNavbarMobile must be used within a NavbarMobileProvider", 50 | ); 51 | } 52 | return context; 53 | }; 54 | 55 | export const NavbarMobileBtn: React.FC = () => { 56 | const { toggleNavbar } = useNavbarMobile(); 57 | 58 | return ( 59 | <div className="flex items-center"> 60 | <button 61 | className="overflow-hidden px-2.5 block md:hidden" 62 | onClick={() => { 63 | toggleNavbar(); 64 | }} 65 | > 66 | <Menu className="size-5" /> 67 | </button> 68 | </div> 69 | ); 70 | }; 71 | 72 | export const NavbarMobile = () => { 73 | const { isOpen, toggleNavbar } = useNavbarMobile(); 74 | const pathname = usePathname(); 75 | const isDocs = pathname.startsWith("/docs"); 76 | 77 | return ( 78 | <div 79 | className={cn( 80 | "fixed top-[50px] inset-x-0 transform-gpu z-[100] bg-background grid grid-rows-[0fr] duration-300 transition-all md:hidden", 81 | isOpen && 82 | "shadow-lg border-b border-[rgba(255,255,255,.1)] grid-rows-[1fr]", 83 | )} 84 | > 85 | <div 86 | className={cn( 87 | "px-9 min-h-0 overflow-y-auto max-h-[80vh] divide-y [mask-image:linear-gradient(to_top,transparent,white_40px)] transition-all duration-300", 88 | isOpen ? "py-5" : "invisible", 89 | isDocs && "px-4", 90 | )} 91 | > 92 | {navMenu.map((menu) => ( 93 | <Fragment key={menu.name}> 94 | {menu.child ? ( 95 | <Accordion type="single" collapsible> 96 | <AccordionItem value={menu.name}> 97 | <AccordionTrigger 98 | className={cn( 99 | "font-normal text-foreground", 100 | !isDocs && "text-2xl", 101 | )} 102 | > 103 | {menu.name} 104 | </AccordionTrigger> 105 | <AccordionContent className="pl-5 divide-y"> 106 | {menu.child.map((child, j) => ( 107 | <Link 108 | href={child.path} 109 | key={child.name} 110 | className={cn( 111 | "block py-2 border-b first:pt-0 last:pb-0 last:border-0 text-muted-foreground", 112 | !isDocs && "text-xl", 113 | )} 114 | onClick={toggleNavbar} 115 | > 116 | {child.name} 117 | </Link> 118 | ))} 119 | </AccordionContent> 120 | </AccordionItem> 121 | </Accordion> 122 | ) : ( 123 | <Link 124 | href={menu.path} 125 | className={cn( 126 | "group flex items-center gap-2.5 first:pt-0 last:pb-0 text-2xl py-4", 127 | isDocs && "text-base py-2", 128 | )} 129 | onClick={toggleNavbar} 130 | > 131 | {isDocs && ( 132 | <ChevronRight className="ml-0.5 size-4 text-muted-foreground md:hidden" /> 133 | )} 134 | {menu.name} 135 | </Link> 136 | )} 137 | </Fragment> 138 | ))} 139 | <DocsNavBarContent /> 140 | </div> 141 | </div> 142 | ); 143 | }; 144 | 145 | function DocsNavBarContent() { 146 | const pathname = usePathname(); 147 | const { toggleNavbar } = useNavbarMobile(); 148 | if (!pathname.startsWith("/docs")) return null; 149 | 150 | const content = pathname.startsWith("/docs/examples") ? examples : contents; 151 | 152 | return ( 153 | <> 154 | {content.map((menu) => ( 155 | <Accordion type="single" collapsible key={menu.title}> 156 | <AccordionItem value={menu.title}> 157 | <AccordionTrigger className="font-normal text-foreground"> 158 | <div className="flex items-center gap-2"> 159 | {!!menu.Icon && <menu.Icon className="w-5 h-5" />} 160 | {menu.title} 161 | </div> 162 | </AccordionTrigger> 163 | <AccordionContent className="pl-5 divide-y"> 164 | {menu.list.map((child, index) => 165 | child.group ? ( 166 | // Group header rendered as div (just a divider) 167 | <div 168 | key={child.title} 169 | className="block py-2 text-sm text-muted-foreground border-none select-none" 170 | > 171 | <div className="flex flex-row items-center gap-2"> 172 | <p className="text-sm text-primary">{child.title}</p> 173 | <div className="flex-grow h-px bg-border" /> 174 | </div> 175 | </div> 176 | ) : ( 177 | // Regular menu item rendered as Link 178 | <Link 179 | href={child.href} 180 | key={child.title} 181 | className={`block py-2 text-sm text-muted-foreground ${ 182 | // Add border only when not last item 183 | // and next item is not a group header 184 | index === menu.list.length - 1 || 185 | menu.list[index + 1]?.group 186 | ? "border-none" 187 | : "border-b" 188 | }`} 189 | onClick={toggleNavbar} 190 | > 191 | <div className="flex items-center gap-2"> 192 | <child.icon /> 193 | {child.title} 194 | </div> 195 | </Link> 196 | ), 197 | )} 198 | </AccordionContent> 199 | </AccordionItem> 200 | </Accordion> 201 | ))} 202 | </> 203 | ); 204 | } 205 | 206 | export const navMenu: { 207 | name: string; 208 | path: string; 209 | child?: { 210 | name: string; 211 | path: string; 212 | }[]; 213 | }[] = [ 214 | { 215 | name: "_helo", 216 | path: "/", 217 | }, 218 | 219 | { 220 | name: "docs", 221 | path: "/docs", 222 | }, 223 | { 224 | name: "examples", 225 | path: "/docs/examples/next-js", 226 | }, 227 | { 228 | name: "changelogs", 229 | path: "/changelogs", 230 | }, 231 | { 232 | name: "blogs", 233 | path: "/blog", 234 | }, 235 | { 236 | name: "community", 237 | path: "/community", 238 | }, 239 | ]; 240 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/utils/wildcard.ts: -------------------------------------------------------------------------------- ```typescript 1 | //https://github.com/axtgr/wildcard-match 2 | 3 | /** 4 | * Escapes a character if it has a special meaning in regular expressions 5 | * and returns the character as is if it doesn't 6 | */ 7 | function escapeRegExpChar(char: string) { 8 | if ( 9 | char === "-" || 10 | char === "^" || 11 | char === "$" || 12 | char === "+" || 13 | char === "." || 14 | char === "(" || 15 | char === ")" || 16 | char === "|" || 17 | char === "[" || 18 | char === "]" || 19 | char === "{" || 20 | char === "}" || 21 | char === "*" || 22 | char === "?" || 23 | char === "\\" 24 | ) { 25 | return `\\${char}`; 26 | } else { 27 | return char; 28 | } 29 | } 30 | 31 | /** 32 | * Escapes all characters in a given string that have a special meaning in regular expressions 33 | */ 34 | function escapeRegExpString(str: string) { 35 | let result = ""; 36 | for (let i = 0; i < str.length; i++) { 37 | result += escapeRegExpChar(str[i]!); 38 | } 39 | return result; 40 | } 41 | 42 | /** 43 | * Transforms one or more glob patterns into a RegExp pattern 44 | */ 45 | function transform( 46 | pattern: string | string[], 47 | separator: string | boolean = true, 48 | ): string { 49 | if (Array.isArray(pattern)) { 50 | let regExpPatterns = pattern.map((p) => `^${transform(p, separator)}$`); 51 | return `(?:${regExpPatterns.join("|")})`; 52 | } 53 | 54 | let separatorSplitter = ""; 55 | let separatorMatcher = ""; 56 | let wildcard = "."; 57 | 58 | if (separator === true) { 59 | // In this case forward slashes in patterns match both forward and backslashes in samples: 60 | // 61 | // `foo/bar` will match `foo/bar` 62 | // will match `foo\bar` 63 | // 64 | separatorSplitter = "/"; 65 | separatorMatcher = "[/\\\\]"; 66 | wildcard = "[^/\\\\]"; 67 | } else if (separator) { 68 | separatorSplitter = separator; 69 | separatorMatcher = escapeRegExpString(separatorSplitter); 70 | 71 | if (separatorMatcher.length > 1) { 72 | separatorMatcher = `(?:${separatorMatcher})`; 73 | wildcard = `((?!${separatorMatcher}).)`; 74 | } else { 75 | wildcard = `[^${separatorMatcher}]`; 76 | } 77 | } 78 | 79 | // When a separator is explicitly specified in a pattern, 80 | // it MUST match ONE OR MORE separators in a sample: 81 | // 82 | // `foo/bar/` will match `foo//bar///` 83 | // won't match `foo/bar` 84 | // 85 | // When a pattern doesn't have a trailing separator, 86 | // a sample can still optionally have them: 87 | // 88 | // `foo/bar` will match `foo/bar//` 89 | // 90 | // So we use different quantifiers depending on the index of a segment. 91 | let requiredSeparator = separator ? `${separatorMatcher}+?` : ""; 92 | let optionalSeparator = separator ? `${separatorMatcher}*?` : ""; 93 | 94 | let segments = separator ? pattern.split(separatorSplitter) : [pattern]; 95 | let result = ""; 96 | 97 | for (let s = 0; s < segments.length; s++) { 98 | let segment = segments[s]!; 99 | let nextSegment = segments[s + 1]!; 100 | let currentSeparator = ""; 101 | 102 | if (!segment && s > 0) { 103 | continue; 104 | } 105 | 106 | if (separator) { 107 | if (s === segments.length - 1) { 108 | currentSeparator = optionalSeparator; 109 | } else if (nextSegment !== "**") { 110 | currentSeparator = requiredSeparator; 111 | } else { 112 | currentSeparator = ""; 113 | } 114 | } 115 | 116 | if (separator && segment === "**") { 117 | if (currentSeparator) { 118 | result += s === 0 ? "" : currentSeparator; 119 | result += `(?:${wildcard}*?${currentSeparator})*?`; 120 | } 121 | continue; 122 | } 123 | 124 | for (let c = 0; c < segment.length; c++) { 125 | let char = segment[c]!; 126 | 127 | if (char === "\\") { 128 | if (c < segment.length - 1) { 129 | result += escapeRegExpChar(segment[c + 1]!); 130 | c++; 131 | } 132 | } else if (char === "?") { 133 | result += wildcard; 134 | } else if (char === "*") { 135 | result += `${wildcard}*?`; 136 | } else { 137 | result += escapeRegExpChar(char); 138 | } 139 | } 140 | 141 | result += currentSeparator; 142 | } 143 | 144 | return result; 145 | } 146 | 147 | export default transform; 148 | 149 | interface WildcardMatchOptions { 150 | /** Separator to be used to split patterns and samples into segments */ 151 | separator?: string | boolean; 152 | 153 | /** Flags to pass to the RegExp */ 154 | flags?: string; 155 | } 156 | 157 | // This overrides the function's signature because for the end user 158 | // the function is always bound to a RegExp 159 | interface isMatch { 160 | /** 161 | * Tests if a sample string matches the pattern(s) 162 | * 163 | * ```js 164 | * isMatch('foo') //=> true 165 | * ``` 166 | */ 167 | (sample: string): boolean; 168 | 169 | /** Compiled regular expression */ 170 | regexp: RegExp; 171 | 172 | /** Original pattern or array of patterns that was used to compile the RegExp */ 173 | pattern: string | string[]; 174 | 175 | /** Options that were used to compile the RegExp */ 176 | options: WildcardMatchOptions; 177 | } 178 | 179 | function isMatch(regexp: RegExp, sample: string) { 180 | if (typeof sample !== "string") { 181 | throw new TypeError(`Sample must be a string, but ${typeof sample} given`); 182 | } 183 | 184 | return regexp.test(sample); 185 | } 186 | 187 | /** 188 | * Compiles one or more glob patterns into a RegExp and returns an isMatch function. 189 | * The isMatch function takes a sample string as its only argument and returns `true` 190 | * if the string matches the pattern(s). 191 | * 192 | * ```js 193 | * wildcardMatch('src/*.js')('src/index.js') //=> true 194 | * ``` 195 | * 196 | * ```js 197 | * const isMatch = wildcardMatch('*.example.com', '.') 198 | * isMatch('foo.example.com') //=> true 199 | * isMatch('foo.bar.com') //=> false 200 | * ``` 201 | */ 202 | function wildcardMatch( 203 | pattern: string | string[], 204 | options?: string | boolean | WildcardMatchOptions, 205 | ) { 206 | if (typeof pattern !== "string" && !Array.isArray(pattern)) { 207 | throw new TypeError( 208 | `The first argument must be a single pattern string or an array of patterns, but ${typeof pattern} given`, 209 | ); 210 | } 211 | 212 | if (typeof options === "string" || typeof options === "boolean") { 213 | options = { separator: options }; 214 | } 215 | 216 | if ( 217 | arguments.length === 2 && 218 | !( 219 | typeof options === "undefined" || 220 | (typeof options === "object" && 221 | options !== null && 222 | !Array.isArray(options)) 223 | ) 224 | ) { 225 | throw new TypeError( 226 | `The second argument must be an options object or a string/boolean separator, but ${typeof options} given`, 227 | ); 228 | } 229 | 230 | options = options || {}; 231 | 232 | if (options.separator === "\\") { 233 | throw new Error( 234 | "\\ is not a valid separator because it is used for escaping. Try setting the separator to `true` instead", 235 | ); 236 | } 237 | 238 | let regexpPattern = transform(pattern, options.separator); 239 | let regexp = new RegExp(`^${regexpPattern}$`, options.flags); 240 | 241 | let fn = isMatch.bind(null, regexp) as isMatch; 242 | fn.options = options; 243 | fn.pattern = pattern; 244 | fn.regexp = regexp; 245 | return fn; 246 | } 247 | 248 | export { wildcardMatch, isMatch }; 249 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/anonymous/anon.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | describe, 3 | expect, 4 | it, 5 | vi, 6 | beforeAll, 7 | afterAll, 8 | afterEach, 9 | } from "vitest"; 10 | import { setupServer } from "msw/node"; 11 | import { http, HttpResponse } from "msw"; 12 | import { anonymous } from "."; 13 | import { getTestInstance } from "../../test-utils/test-instance"; 14 | import { createAuthClient } from "../../client"; 15 | import { anonymousClient } from "./client"; 16 | import type { GoogleProfile } from "@better-auth/core/social-providers"; 17 | import { DEFAULT_SECRET } from "../../utils/constants"; 18 | import { signJWT } from "../../crypto"; 19 | 20 | let testIdToken: string; 21 | let handlers: ReturnType<typeof http.post>[]; 22 | 23 | const server = setupServer(); 24 | 25 | beforeAll(async () => { 26 | const data: GoogleProfile = { 27 | email: "[email protected]", 28 | email_verified: true, 29 | name: "First Last", 30 | picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", 31 | exp: 1234567890, 32 | sub: "1234567890", 33 | iat: 1234567890, 34 | aud: "test", 35 | azp: "test", 36 | nbf: 1234567890, 37 | iss: "test", 38 | locale: "en", 39 | jti: "test", 40 | given_name: "First", 41 | family_name: "Last", 42 | }; 43 | testIdToken = await signJWT(data, DEFAULT_SECRET); 44 | 45 | handlers = [ 46 | http.post("https://oauth2.googleapis.com/token", () => { 47 | return HttpResponse.json({ 48 | access_token: "test", 49 | refresh_token: "test", 50 | id_token: testIdToken, 51 | }); 52 | }), 53 | ]; 54 | 55 | server.listen({ onUnhandledRequest: "bypass" }); 56 | server.use(...handlers); 57 | }); 58 | 59 | afterEach(() => { 60 | server.resetHandlers(); 61 | server.use(...handlers); 62 | }); 63 | 64 | afterAll(() => server.close()); 65 | 66 | describe("anonymous", async () => { 67 | const linkAccountFn = vi.fn(); 68 | const { customFetchImpl, auth, sessionSetter, testUser, cookieSetter } = 69 | await getTestInstance({ 70 | plugins: [ 71 | anonymous({ 72 | async onLinkAccount(data) { 73 | linkAccountFn(data); 74 | }, 75 | schema: { 76 | user: { 77 | fields: { 78 | isAnonymous: "is_anon", 79 | }, 80 | }, 81 | }, 82 | }), 83 | ], 84 | socialProviders: { 85 | google: { 86 | clientId: "test", 87 | clientSecret: "test", 88 | }, 89 | }, 90 | }); 91 | const headers = new Headers(); 92 | const client = createAuthClient({ 93 | plugins: [anonymousClient()], 94 | fetchOptions: { 95 | customFetchImpl, 96 | }, 97 | baseURL: "http://localhost:3000", 98 | }); 99 | 100 | it("should sign in anonymously", async () => { 101 | await client.signIn.anonymous({ 102 | fetchOptions: { 103 | onSuccess: sessionSetter(headers), 104 | }, 105 | }); 106 | const session = await client.getSession({ 107 | fetchOptions: { 108 | headers, 109 | }, 110 | }); 111 | expect(session.data?.session).toBeDefined(); 112 | expect(session.data?.user.isAnonymous).toBe(true); 113 | }); 114 | 115 | it("link anonymous user account", async () => { 116 | expect(linkAccountFn).toHaveBeenCalledTimes(0); 117 | const res = await client.signIn.email(testUser, { 118 | headers, 119 | }); 120 | expect(linkAccountFn).toHaveBeenCalledWith(expect.any(Object)); 121 | linkAccountFn.mockClear(); 122 | }); 123 | 124 | it("should link in social sign on", async () => { 125 | const headers = new Headers(); 126 | await client.signIn.anonymous({ 127 | fetchOptions: { 128 | onSuccess: sessionSetter(headers), 129 | }, 130 | }); 131 | 132 | await client.getSession({ 133 | fetchOptions: { 134 | headers, 135 | }, 136 | }); 137 | 138 | const singInRes = await client.signIn.social({ 139 | provider: "google", 140 | callbackURL: "/dashboard", 141 | fetchOptions: { 142 | onSuccess: cookieSetter(headers), 143 | }, 144 | }); 145 | const state = new URL(singInRes.data?.url || "").searchParams.get("state"); 146 | await client.$fetch("/callback/google", { 147 | query: { 148 | state, 149 | code: "test", 150 | }, 151 | headers, 152 | }); 153 | expect(linkAccountFn).toHaveBeenCalledWith(expect.any(Object)); 154 | }); 155 | 156 | it("should work with generateName", async () => { 157 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 158 | plugins: [ 159 | anonymous({ 160 | generateName() { 161 | return "i-am-anonymous"; 162 | }, 163 | }), 164 | ], 165 | }); 166 | const client = createAuthClient({ 167 | plugins: [anonymousClient()], 168 | fetchOptions: { 169 | customFetchImpl, 170 | }, 171 | baseURL: "http://localhost:3000", 172 | }); 173 | const res = await client.signIn.anonymous({ 174 | fetchOptions: { 175 | onSuccess: sessionSetter(headers), 176 | }, 177 | }); 178 | expect(res.data?.user.name).toBe("i-am-anonymous"); 179 | }); 180 | 181 | it("should not reject first-time anonymous sign-in", async () => { 182 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 183 | plugins: [anonymous()], 184 | }); 185 | const client = createAuthClient({ 186 | plugins: [anonymousClient()], 187 | fetchOptions: { 188 | customFetchImpl, 189 | }, 190 | baseURL: "http://localhost:3000", 191 | }); 192 | const freshHeaders = new Headers(); 193 | 194 | // First-time anonymous sign-in should succeed without 400 error 195 | const res = await client.signIn.anonymous({ 196 | fetchOptions: { 197 | onSuccess: sessionSetter(freshHeaders), 198 | }, 199 | }); 200 | 201 | expect(res.data?.user).toBeDefined(); 202 | expect(res.error).toBeNull(); 203 | 204 | // Verify session is actually created and contains isAnonymous 205 | const session = await client.getSession({ 206 | fetchOptions: { 207 | headers: freshHeaders, 208 | }, 209 | }); 210 | expect(session.data?.session).toBeDefined(); 211 | expect(session.data?.user.isAnonymous).toBe(true); 212 | }); 213 | 214 | it("should reject subsequent anonymous sign-in attempts once signed in", async () => { 215 | const { customFetchImpl, sessionSetter } = await getTestInstance({ 216 | plugins: [anonymous()], 217 | }); 218 | const client = createAuthClient({ 219 | plugins: [anonymousClient()], 220 | fetchOptions: { 221 | customFetchImpl, 222 | }, 223 | baseURL: "http://localhost:3000", 224 | }); 225 | const persistentHeaders = new Headers(); 226 | 227 | // First sign-in should succeed 228 | await client.signIn.anonymous({ 229 | fetchOptions: { 230 | headers: persistentHeaders, 231 | onSuccess: sessionSetter(persistentHeaders), 232 | }, 233 | }); 234 | 235 | // Verify session is established before testing rejection 236 | const session = await client.getSession({ 237 | fetchOptions: { 238 | headers: persistentHeaders, 239 | }, 240 | }); 241 | expect(session.data?.session).toBeDefined(); 242 | expect(session.data?.user.isAnonymous).toBe(true); 243 | 244 | // Second attempt should be rejected at the endpoint level 245 | const secondAttempt = await client.signIn.anonymous({ 246 | fetchOptions: { 247 | headers: persistentHeaders, 248 | }, 249 | }); 250 | 251 | expect(secondAttempt.data).toBeNull(); 252 | expect(secondAttempt.error).toBeDefined(); 253 | expect(secondAttempt.error?.message).toBe( 254 | "Anonymous users cannot sign in again anonymously", 255 | ); 256 | }); 257 | }); 258 | ``` -------------------------------------------------------------------------------- /docs/app/api/og/route.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { ImageResponse } from "@vercel/og"; 2 | import { z } from "zod"; 3 | export const runtime = "edge"; 4 | 5 | const ogSchema = z.object({ 6 | heading: z.string(), 7 | mode: z.string(), 8 | type: z.string(), 9 | }); 10 | export async function GET(req: Request) { 11 | try { 12 | const geist = await fetch( 13 | new URL("../../../assets/Geist.ttf", import.meta.url), 14 | ).then((res) => res.arrayBuffer()); 15 | const geistMono = await fetch( 16 | new URL("../../../assets/GeistMono.ttf", import.meta.url), 17 | ).then((res) => res.arrayBuffer()); 18 | const url = new URL(req.url); 19 | const urlParamsValues = Object.fromEntries(url.searchParams); 20 | const validParams = ogSchema.parse(urlParamsValues); 21 | const { heading, type } = validParams; 22 | const trueHeading = 23 | heading.length > 140 ? `${heading.substring(0, 140)}...` : heading; 24 | 25 | const paint = "#fff"; 26 | 27 | const fontSize = trueHeading.length > 100 ? "30px" : "60px"; 28 | return new ImageResponse( 29 | <div 30 | tw="flex w-full relative flex-col p-12" 31 | style={{ 32 | color: paint, 33 | backgroundColor: "transparent", 34 | border: "1px solid rgba(255, 255, 255, 0.1)", 35 | boxShadow: "0 -20px 80px -20px rgba(28, 12, 12, 0.1) inset", 36 | background: "#0a0505", 37 | }} 38 | > 39 | <div 40 | tw={`relative flex flex-col w-full h-full border-2 border-[${paint}]/20 p-10}`} 41 | > 42 | <svg 43 | style={{ 44 | position: "absolute", 45 | top: "-9px", 46 | right: "-9px", 47 | }} 48 | width="17" 49 | height="17" 50 | fill="none" 51 | > 52 | <path 53 | d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" 54 | fill="#d0cfd1d3" 55 | /> 56 | </svg> 57 | 58 | <svg 59 | style={{ 60 | position: "absolute", 61 | top: "-9px", 62 | left: "-9px", 63 | }} 64 | width="17" 65 | height="17" 66 | fill="none" 67 | > 68 | <path 69 | d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" 70 | fill="#cacaca" 71 | /> 72 | </svg> 73 | <svg 74 | style={{ 75 | position: "absolute", 76 | bottom: "-9px", 77 | left: "-9px", 78 | }} 79 | width="17" 80 | height="17" 81 | fill="none" 82 | > 83 | <path 84 | d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" 85 | fill="#cacaca" 86 | /> 87 | </svg> 88 | <svg 89 | style={{ 90 | position: "absolute", 91 | bottom: "-9px", 92 | right: "-9px", 93 | }} 94 | width="17" 95 | height="17" 96 | fill="none" 97 | > 98 | <path 99 | d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" 100 | fill="#cacaca" 101 | /> 102 | </svg> 103 | <div tw="flex flex-col flex-1 py-10"> 104 | <svg 105 | width="100" 106 | height="95" 107 | viewBox="0 0 60 45" 108 | fill="none" 109 | className="mb-10" 110 | xmlns="http://www.w3.org/2000/svg" 111 | > 112 | <path 113 | fillRule="evenodd" 114 | stroke={paint} 115 | clipRule="evenodd" 116 | d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z" 117 | fill="white" 118 | /> 119 | </svg> 120 | <div 121 | style={{ fontFamily: "GeistMono", fontWeight: "normal" }} 122 | tw="relative flex mt-10 text-xl uppercase font-bold gap-2 items-center" 123 | > 124 | {type === "documentation" ? ( 125 | <svg 126 | xmlns="http://www.w3.org/2000/svg" 127 | width="1.2em" 128 | height="1.2em" 129 | viewBox="0 0 24 24" 130 | > 131 | <path 132 | fill="currentColor" 133 | fillRule="evenodd" 134 | d="M4.172 3.172C3 4.343 3 6.229 3 10v4c0 3.771 0 5.657 1.172 6.828S7.229 22 11 22h2c3.771 0 5.657 0 6.828-1.172S21 17.771 21 14v-4c0-3.771 0-5.657-1.172-6.828S16.771 2 13 2h-2C7.229 2 5.343 2 4.172 3.172M8 9.25a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5zm0 4a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5z" 135 | clipRule="evenodd" 136 | ></path> 137 | </svg> 138 | ) : null} 139 | {type} 140 | </div> 141 | <div 142 | tw="flex max-w-[70%] mt-5 tracking-tighter leading-[1.1] text-[30px] font-bold" 143 | style={{ 144 | fontWeight: "bold", 145 | marginLeft: "-3px", 146 | fontSize, 147 | 148 | fontFamily: "GeistMono", 149 | }} 150 | > 151 | {trueHeading} 152 | </div> 153 | </div> 154 | <div tw="flex items-center w-full justify-between"> 155 | <div 156 | tw="flex text-xl" 157 | style={{ fontFamily: "GeistSans", fontWeight: "semibold" }} 158 | > 159 | Better Auth. 160 | </div> 161 | <div tw="flex gap-2 items-center text-xl"> 162 | <svg 163 | xmlns="http://www.w3.org/2000/svg" 164 | width="1.2em" 165 | height="1.2em" 166 | viewBox="0 0 24 24" 167 | > 168 | <path 169 | fill="currentColor" 170 | d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" 171 | ></path> 172 | </svg> 173 | <span 174 | style={{ 175 | fontFamily: "GeistSans", 176 | }} 177 | tw="flex ml-2" 178 | > 179 | github.com/better-auth/better-auth 180 | </span> 181 | </div> 182 | </div> 183 | </div> 184 | </div>, 185 | { 186 | width: 1200, 187 | height: 630, 188 | fonts: [ 189 | { 190 | name: "Geist", 191 | data: geist, 192 | weight: 400, 193 | style: "normal", 194 | }, 195 | { 196 | name: "GeistMono", 197 | data: geistMono, 198 | weight: 700, 199 | style: "normal", 200 | }, 201 | ], 202 | }, 203 | ); 204 | } catch (err) { 205 | console.log({ err }); 206 | return new Response("Failed to generate the og image", { status: 500 }); 207 | } 208 | } 209 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/test-adapter.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { afterAll, beforeAll, describe } from "vitest"; 2 | import type { BetterAuthOptions } from "@better-auth/core"; 3 | import type { DBAdapter } from "@better-auth/core/db/adapter"; 4 | import { getAuthTables } from "../db"; 5 | import type { createTestSuite } from "./create-test-suite"; 6 | import { TTY_COLORS } from "@better-auth/core/env"; 7 | import { deepmerge } from "./utils"; 8 | 9 | export type Logger = { 10 | info: (...args: any[]) => void; 11 | success: (...args: any[]) => void; 12 | warn: (...args: any[]) => void; 13 | error: (...args: any[]) => void; 14 | debug: (...args: any[]) => void; 15 | }; 16 | 17 | export const testAdapter = async ({ 18 | adapter: getAdapter, 19 | runMigrations, 20 | overrideBetterAuthOptions, 21 | additionalCleanups, 22 | tests, 23 | prefixTests, 24 | onFinish, 25 | customIdGenerator, 26 | }: { 27 | /** 28 | * A function that will return the adapter instance to test with. 29 | * 30 | * @example 31 | * ```ts 32 | * testAdapter({ 33 | * adapter: (options) => drizzleAdapter(drizzle(db), { 34 | * schema: generateSchema(options), 35 | * }), 36 | * }) 37 | */ 38 | adapter: ( 39 | options: BetterAuthOptions, 40 | ) => 41 | | Promise<(options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>> 42 | | ((options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>); 43 | /** 44 | * A function that will run the database migrations. 45 | */ 46 | runMigrations: (betterAuthOptions: BetterAuthOptions) => Promise<void> | void; 47 | /** 48 | * Any potential better-auth options overrides. 49 | */ 50 | overrideBetterAuthOptions?: < 51 | Passed extends BetterAuthOptions, 52 | Returned extends BetterAuthOptions, 53 | >( 54 | betterAuthOptions: Passed, 55 | ) => Returned; 56 | /** 57 | * By default we will cleanup all tables automatically, 58 | * but if you have additional cleanup logic, you can pass it here. 59 | * 60 | * Such as deleting a DB file that could had been created. 61 | */ 62 | additionalCleanups?: () => Promise<void> | void; 63 | /** 64 | * A test suite to run. 65 | */ 66 | tests: ReturnType<ReturnType<typeof createTestSuite>>[]; 67 | /** 68 | * A prefix to add to the test suite name. 69 | */ 70 | prefixTests?: string; 71 | /** 72 | * Upon finish of the tests, this function will be called. 73 | */ 74 | onFinish?: () => Promise<void> | void; 75 | /** 76 | * Custom ID generator function to be used by the helper functions. (such as `insertRandom`) 77 | */ 78 | customIdGenerator?: () => string | Promise<string>; 79 | }) => { 80 | const defaultBAOptions = {} satisfies BetterAuthOptions; 81 | let betterAuthOptions = (() => { 82 | return { 83 | ...defaultBAOptions, 84 | ...(overrideBetterAuthOptions?.(defaultBAOptions) || {}), 85 | } satisfies BetterAuthOptions; 86 | })(); 87 | 88 | let adapter: DBAdapter<BetterAuthOptions> = ( 89 | await getAdapter(betterAuthOptions) 90 | )(betterAuthOptions); 91 | 92 | const adapterName = adapter.options?.adapterConfig.adapterName; 93 | const adapterId = adapter.options?.adapterConfig.adapterId || adapter.id; 94 | const adapterDisplayName = adapterName || adapterId; 95 | 96 | const refreshAdapter = async (betterAuthOptions: BetterAuthOptions) => { 97 | adapter = (await getAdapter(betterAuthOptions))(betterAuthOptions); 98 | }; 99 | 100 | /** 101 | * A helper function to log to the console. 102 | */ 103 | const log: Logger = (() => { 104 | return { 105 | info: (...args: any[]) => 106 | console.log( 107 | `${TTY_COLORS.fg.blue}INFO ${TTY_COLORS.reset} [${adapterDisplayName}]`, 108 | ...args, 109 | ), 110 | success: (...args: any[]) => 111 | console.log( 112 | `${TTY_COLORS.fg.green}SUCCESS${TTY_COLORS.reset} [${adapterDisplayName}]`, 113 | ...args, 114 | ), 115 | warn: (...args: any[]) => 116 | console.log( 117 | `${TTY_COLORS.fg.yellow}WARN ${TTY_COLORS.reset} [${adapterDisplayName}]`, 118 | ...args, 119 | ), 120 | error: (...args: any[]) => 121 | console.log( 122 | `${TTY_COLORS.fg.red}ERROR ${TTY_COLORS.reset} [${adapterDisplayName}]`, 123 | ...args, 124 | ), 125 | debug: (...args: any[]) => 126 | console.log( 127 | `${TTY_COLORS.fg.magenta}DEBUG ${TTY_COLORS.reset} [${adapterDisplayName}]`, 128 | ...args, 129 | ), 130 | }; 131 | })(); 132 | 133 | /** 134 | * Cleanup function to remove all rows from the database. 135 | */ 136 | const cleanup = async () => { 137 | const start = performance.now(); 138 | await refreshAdapter(betterAuthOptions); 139 | const getAllModels = getAuthTables(betterAuthOptions); 140 | 141 | // Clean up all rows from all models 142 | for (const model of Object.keys(getAllModels)) { 143 | try { 144 | await adapter.deleteMany({ model: model, where: [] }); 145 | } catch (error) { 146 | const msg = `Error while cleaning up all rows from ${model}`; 147 | log.error(msg, error); 148 | throw new Error(msg, { 149 | cause: error, 150 | }); 151 | } 152 | } 153 | 154 | // Run additional cleanups 155 | try { 156 | await additionalCleanups?.(); 157 | } catch (error) { 158 | const msg = `Error while running additional cleanups`; 159 | log.error(msg, error); 160 | throw new Error(msg, { 161 | cause: error, 162 | }); 163 | } 164 | await refreshAdapter(betterAuthOptions); 165 | log.success( 166 | `${TTY_COLORS.bright}CLEAN-UP${TTY_COLORS.reset} completed successfully (${(performance.now() - start).toFixed(3)}ms)`, 167 | ); 168 | }; 169 | 170 | /** 171 | * A function that will run the database migrations. 172 | */ 173 | const migrate = async () => { 174 | const start = performance.now(); 175 | 176 | try { 177 | await runMigrations(betterAuthOptions); 178 | } catch (error) { 179 | const msg = `Error while running migrations`; 180 | log.error(msg, error); 181 | throw new Error(msg, { 182 | cause: error, 183 | }); 184 | } 185 | log.success( 186 | `${TTY_COLORS.bright}MIGRATIONS${TTY_COLORS.reset} completed successfully (${(performance.now() - start).toFixed(3)}ms)`, 187 | ); 188 | }; 189 | 190 | return { 191 | execute: () => { 192 | describe(adapterDisplayName, async () => { 193 | beforeAll(async () => { 194 | await migrate(); 195 | }, 20000); 196 | 197 | afterAll(async () => { 198 | await cleanup(); 199 | await onFinish?.(); 200 | }, 20000); 201 | 202 | for (const testSuite of tests) { 203 | await testSuite({ 204 | adapter: async () => { 205 | await refreshAdapter(betterAuthOptions); 206 | return adapter; 207 | }, 208 | adapterDisplayName, 209 | log, 210 | getBetterAuthOptions: () => betterAuthOptions, 211 | modifyBetterAuthOptions: async (options) => { 212 | const newOptions = deepmerge(defaultBAOptions, options); 213 | betterAuthOptions = deepmerge( 214 | newOptions, 215 | overrideBetterAuthOptions?.(newOptions) || {}, 216 | ); 217 | await refreshAdapter(betterAuthOptions); 218 | return betterAuthOptions; 219 | }, 220 | cleanup, 221 | prefixTests, 222 | runMigrations: migrate, 223 | onTestFinish: async () => {}, 224 | customIdGenerator, 225 | }); 226 | } 227 | }); 228 | }, 229 | }; 230 | }; 231 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; 2 | import { setupServer } from "msw/node"; 3 | import { http, HttpResponse } from "msw"; 4 | import { getTestInstance } from "../../test-utils/test-instance"; 5 | import { oAuthProxy } from "."; 6 | import type { GoogleProfile } from "@better-auth/core/social-providers"; 7 | import { DEFAULT_SECRET } from "../../utils/constants"; 8 | import { signJWT } from "../../crypto"; 9 | 10 | let testIdToken: string; 11 | let handlers: ReturnType<typeof http.post>[]; 12 | 13 | const server = setupServer(); 14 | 15 | beforeAll(async () => { 16 | const data: GoogleProfile = { 17 | email: "[email protected]", 18 | email_verified: true, 19 | name: "First Last", 20 | picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", 21 | exp: 1234567890, 22 | sub: "1234567890", 23 | iat: 1234567890, 24 | aud: "test", 25 | azp: "test", 26 | nbf: 1234567890, 27 | iss: "test", 28 | locale: "en", 29 | jti: "test", 30 | given_name: "First", 31 | family_name: "Last", 32 | }; 33 | testIdToken = await signJWT(data, DEFAULT_SECRET); 34 | 35 | handlers = [ 36 | http.post("https://oauth2.googleapis.com/token", () => { 37 | return HttpResponse.json({ 38 | access_token: "test", 39 | refresh_token: "test", 40 | id_token: testIdToken, 41 | }); 42 | }), 43 | ]; 44 | 45 | server.listen({ onUnhandledRequest: "bypass" }); 46 | server.use(...handlers); 47 | }); 48 | 49 | afterEach(() => { 50 | server.resetHandlers(); 51 | server.use(...handlers); 52 | }); 53 | 54 | afterAll(() => server.close()); 55 | 56 | describe("oauth-proxy", async () => { 57 | it("should redirect to proxy url", async () => { 58 | const { client, cookieSetter } = await getTestInstance({ 59 | plugins: [ 60 | oAuthProxy({ 61 | currentURL: "http://preview-localhost:3000", 62 | }), 63 | ], 64 | socialProviders: { 65 | google: { 66 | clientId: "test", 67 | clientSecret: "test", 68 | }, 69 | }, 70 | }); 71 | const headers = new Headers(); 72 | const res = await client.signIn.social( 73 | { 74 | provider: "google", 75 | callbackURL: "/dashboard", 76 | }, 77 | { 78 | throw: true, 79 | }, 80 | ); 81 | const state = new URL(res.url!).searchParams.get("state"); 82 | await client.$fetch(`/callback/google?code=test&state=${state}`, { 83 | headers, 84 | onError(context) { 85 | const location = context.response.headers.get("location") ?? ""; 86 | if (!location) { 87 | throw new Error("Location header not found"); 88 | } 89 | expect(location).toContain( 90 | "http://preview-localhost:3000/api/auth/oauth-proxy-callback?callbackURL=%2Fdashboard", 91 | ); 92 | const cookies = new URL(location).searchParams.get("cookies"); 93 | expect(cookies).toBeTruthy(); 94 | }, 95 | }); 96 | }); 97 | 98 | it("shouldn't redirect to proxy url on same origin", async () => { 99 | const { client, cookieSetter } = await getTestInstance({ 100 | plugins: [oAuthProxy()], 101 | socialProviders: { 102 | google: { 103 | clientId: "test", 104 | clientSecret: "test", 105 | }, 106 | }, 107 | }); 108 | const headers = new Headers(); 109 | const res = await client.signIn.social( 110 | { 111 | provider: "google", 112 | callbackURL: "/dashboard", 113 | }, 114 | { 115 | throw: true, 116 | onSuccess: cookieSetter(headers), 117 | }, 118 | ); 119 | const state = new URL(res.url!).searchParams.get("state"); 120 | await client.$fetch(`/callback/google?code=test&state=${state}`, { 121 | onError(context) { 122 | const location = context.response.headers.get("location"); 123 | if (!location) { 124 | throw new Error("Location header not found"); 125 | } 126 | expect(location).not.toContain("/api/auth/oauth-proxy-callback"); 127 | expect(location).toContain("/dashboard"); 128 | }, 129 | }); 130 | }); 131 | 132 | it("should proxy to the original request url", async () => { 133 | const { client } = await getTestInstance({ 134 | baseURL: "https://myapp.com", 135 | plugins: [ 136 | oAuthProxy({ 137 | productionURL: "https://login.myapp.com", 138 | }), 139 | ], 140 | socialProviders: { 141 | google: { 142 | clientId: "test", 143 | clientSecret: "test", 144 | }, 145 | }, 146 | }); 147 | const res = await client.signIn.social( 148 | { 149 | provider: "google", 150 | callbackURL: "/dashboard", 151 | }, 152 | { 153 | throw: true, 154 | }, 155 | ); 156 | const state = new URL(res.url!).searchParams.get("state"); 157 | await client.$fetch(`/callback/google?code=test&state=${state}`, { 158 | onError(context) { 159 | const location = context.response.headers.get("location"); 160 | if (!location) { 161 | throw new Error("Location header not found"); 162 | } 163 | expect(location).toContain( 164 | "https://myapp.com/api/auth/oauth-proxy-callback?callbackURL=%2Fdashboard", 165 | ); 166 | const cookies = new URL(location).searchParams.get("cookies"); 167 | expect(cookies).toBeTruthy(); 168 | }, 169 | }); 170 | }); 171 | 172 | it("should require state cookie if it's not in proxy url", async () => { 173 | const { client } = await getTestInstance({ 174 | baseURL: "https://myapp.com", 175 | plugins: [ 176 | oAuthProxy({ 177 | productionURL: "https://myapp.com", 178 | }), 179 | ], 180 | socialProviders: { 181 | google: { 182 | clientId: "test", 183 | clientSecret: "test", 184 | }, 185 | }, 186 | }); 187 | const res = await client.signIn.social( 188 | { 189 | provider: "google", 190 | callbackURL: "/dashboard", 191 | }, 192 | { 193 | throw: true, 194 | }, 195 | ); 196 | const state = new URL(res.url!).searchParams.get("state"); 197 | await client.$fetch(`/callback/google?code=test&state=${state}`, { 198 | onError(context) { 199 | const location = context.response.headers.get("location"); 200 | if (!location) { 201 | throw new Error("Location header not found"); 202 | } 203 | expect(location).toContain("state_mismatch"); 204 | }, 205 | }); 206 | }); 207 | 208 | it("shouldn't redirect to proxy url on same origin", async () => { 209 | const { client, cookieSetter } = await getTestInstance({ 210 | baseURL: "https://myapp.com", 211 | plugins: [ 212 | oAuthProxy({ 213 | productionURL: "https://myapp.com", 214 | }), 215 | ], 216 | socialProviders: { 217 | google: { 218 | clientId: "test", 219 | clientSecret: "test", 220 | }, 221 | }, 222 | }); 223 | const headers = new Headers(); 224 | const res = await client.signIn.social( 225 | { 226 | provider: "google", 227 | callbackURL: "/dashboard", 228 | }, 229 | { 230 | throw: true, 231 | onSuccess: cookieSetter(headers), 232 | }, 233 | ); 234 | const state = new URL(res.url!).searchParams.get("state"); 235 | await client.$fetch(`/callback/google?code=test&state=${state}`, { 236 | headers, 237 | onError(context) { 238 | const location = context.response.headers.get("location"); 239 | if (!location) { 240 | throw new Error("Location header not found"); 241 | } 242 | expect(location).not.toContain("/api/auth/oauth-proxy-callback"); 243 | expect(location).toContain("/dashboard"); 244 | }, 245 | }); 246 | }); 247 | }); 248 | ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/tiktok.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { betterFetch } from "@better-fetch/fetch"; 2 | import type { OAuthProvider, ProviderOptions } from "../oauth2"; 3 | import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; 4 | 5 | /** 6 | * [More info](https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info/) 7 | */ 8 | export interface TiktokProfile extends Record<string, any> { 9 | data: { 10 | user: { 11 | /** 12 | * The unique identification of the user in the current application.Open id 13 | * for the client. 14 | * 15 | * To return this field, add `fields=open_id` in the user profile request's query parameter. 16 | */ 17 | open_id: string; 18 | /** 19 | * The unique identification of the user across different apps for the same developer. 20 | * For example, if a partner has X number of clients, 21 | * it will get X number of open_id for the same TikTok user, 22 | * but one persistent union_id for the particular user. 23 | * 24 | * To return this field, add `fields=union_id` in the user profile request's query parameter. 25 | */ 26 | union_id?: string; 27 | /** 28 | * User's profile image. 29 | * 30 | * To return this field, add `fields=avatar_url` in the user profile request's query parameter. 31 | */ 32 | avatar_url?: string; 33 | /** 34 | * User`s profile image in 100x100 size. 35 | * 36 | * To return this field, add `fields=avatar_url_100` in the user profile request's query parameter. 37 | */ 38 | avatar_url_100?: string; 39 | /** 40 | * User's profile image with higher resolution 41 | * 42 | * To return this field, add `fields=avatar_url_100` in the user profile request's query parameter. 43 | */ 44 | avatar_large_url: string; 45 | /** 46 | * User's profile name 47 | * 48 | * To return this field, add `fields=display_name` in the user profile request's query parameter. 49 | */ 50 | display_name: string; 51 | /** 52 | * User's username. 53 | * 54 | * To return this field, add `fields=username` in the user profile request's query parameter. 55 | */ 56 | username: string; 57 | /** @note Email is currently unsupported by TikTok */ 58 | email?: string; 59 | /** 60 | * User's bio description if there is a valid one. 61 | * 62 | * To return this field, add `fields=bio_description` in the user profile request's query parameter. 63 | */ 64 | bio_description?: string; 65 | /** 66 | * The link to user's TikTok profile page. 67 | * 68 | * To return this field, add `fields=profile_deep_link` in the user profile request's query parameter. 69 | */ 70 | profile_deep_link?: string; 71 | /** 72 | * Whether TikTok has provided a verified badge to the account after confirming 73 | * that it belongs to the user it represents. 74 | * 75 | * To return this field, add `fields=is_verified` in the user profile request's query parameter. 76 | */ 77 | is_verified?: boolean; 78 | /** 79 | * User's followers count. 80 | * 81 | * To return this field, add `fields=follower_count` in the user profile request's query parameter. 82 | */ 83 | follower_count?: number; 84 | /** 85 | * The number of accounts that the user is following. 86 | * 87 | * To return this field, add `fields=following_count` in the user profile request's query parameter. 88 | */ 89 | following_count?: number; 90 | /** 91 | * The total number of likes received by the user across all of their videos. 92 | * 93 | * To return this field, add `fields=likes_count` in the user profile request's query parameter. 94 | */ 95 | likes_count?: number; 96 | /** 97 | * The total number of publicly posted videos by the user. 98 | * 99 | * To return this field, add `fields=video_count` in the user profile request's query parameter. 100 | */ 101 | video_count?: number; 102 | }; 103 | }; 104 | error?: { 105 | /** 106 | * The error category in string. 107 | */ 108 | code?: string; 109 | /** 110 | * The error message in string. 111 | */ 112 | message?: string; 113 | /** 114 | * The error message in string. 115 | */ 116 | log_id?: string; 117 | }; 118 | } 119 | 120 | export interface TiktokOptions extends ProviderOptions { 121 | // Client ID is not used in TikTok, we delete it from the options 122 | clientId?: never; 123 | clientSecret: string; 124 | clientKey: string; 125 | } 126 | 127 | export const tiktok = (options: TiktokOptions) => { 128 | return { 129 | id: "tiktok", 130 | name: "TikTok", 131 | createAuthorizationURL({ state, scopes, redirectURI }) { 132 | const _scopes = options.disableDefaultScope ? [] : ["user.info.profile"]; 133 | options.scope && _scopes.push(...options.scope); 134 | scopes && _scopes.push(...scopes); 135 | return new URL( 136 | `https://www.tiktok.com/v2/auth/authorize?scope=${_scopes.join( 137 | ",", 138 | )}&response_type=code&client_key=${options.clientKey}&redirect_uri=${encodeURIComponent( 139 | options.redirectURI || redirectURI, 140 | )}&state=${state}`, 141 | ); 142 | }, 143 | 144 | validateAuthorizationCode: async ({ code, redirectURI }) => { 145 | return validateAuthorizationCode({ 146 | code, 147 | redirectURI: options.redirectURI || redirectURI, 148 | options: { 149 | clientKey: options.clientKey, 150 | clientSecret: options.clientSecret, 151 | }, 152 | tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/", 153 | }); 154 | }, 155 | refreshAccessToken: options.refreshAccessToken 156 | ? options.refreshAccessToken 157 | : async (refreshToken) => { 158 | return refreshAccessToken({ 159 | refreshToken, 160 | options: { 161 | clientSecret: options.clientSecret, 162 | }, 163 | tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/", 164 | authentication: "post", 165 | extraParams: { 166 | client_key: options.clientKey, 167 | }, 168 | }); 169 | }, 170 | async getUserInfo(token) { 171 | if (options.getUserInfo) { 172 | return options.getUserInfo(token); 173 | } 174 | 175 | const fields = [ 176 | "open_id", 177 | "avatar_large_url", 178 | "display_name", 179 | "username", 180 | ]; 181 | const { data: profile, error } = await betterFetch<TiktokProfile>( 182 | `https://open.tiktokapis.com/v2/user/info/?fields=${fields.join(",")}`, 183 | { 184 | headers: { 185 | authorization: `Bearer ${token.accessToken}`, 186 | }, 187 | }, 188 | ); 189 | 190 | if (error) { 191 | return null; 192 | } 193 | 194 | return { 195 | user: { 196 | email: profile.data.user.email || profile.data.user.username, 197 | id: profile.data.user.open_id, 198 | name: profile.data.user.display_name || profile.data.user.username, 199 | image: profile.data.user.avatar_large_url, 200 | /** @note Tiktok does not provide emailVerified or even email*/ 201 | emailVerified: profile.data.user.email ? true : false, 202 | }, 203 | data: profile, 204 | }; 205 | }, 206 | options, 207 | } satisfies OAuthProvider<TiktokProfile, TiktokOptions>; 208 | }; 209 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/oauth2/link-account.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { APIError, createEmailVerificationToken } from "../api"; 2 | import type { Account } from "../types"; 3 | import type { User } from "../types"; 4 | import { logger } from "@better-auth/core/env"; 5 | import { isDevelopment } from "@better-auth/core/env"; 6 | import { setTokenUtil } from "./utils"; 7 | import type { GenericEndpointContext } from "@better-auth/core"; 8 | 9 | export async function handleOAuthUserInfo( 10 | c: GenericEndpointContext, 11 | { 12 | userInfo, 13 | account, 14 | callbackURL, 15 | disableSignUp, 16 | overrideUserInfo, 17 | }: { 18 | userInfo: Omit<User, "createdAt" | "updatedAt">; 19 | account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">; 20 | callbackURL?: string; 21 | disableSignUp?: boolean; 22 | overrideUserInfo?: boolean; 23 | }, 24 | ) { 25 | const dbUser = await c.context.internalAdapter 26 | .findOAuthUser( 27 | userInfo.email.toLowerCase(), 28 | account.accountId, 29 | account.providerId, 30 | ) 31 | .catch((e) => { 32 | logger.error( 33 | "Better auth was unable to query your database.\nError: ", 34 | e, 35 | ); 36 | const errorURL = 37 | c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`; 38 | throw c.redirect(`${errorURL}?error=internal_server_error`); 39 | }); 40 | let user = dbUser?.user; 41 | let isRegister = !user; 42 | 43 | if (dbUser) { 44 | const hasBeenLinked = dbUser.accounts.find( 45 | (a) => 46 | a.providerId === account.providerId && 47 | a.accountId === account.accountId, 48 | ); 49 | if (!hasBeenLinked) { 50 | const trustedProviders = 51 | c.context.options.account?.accountLinking?.trustedProviders; 52 | const isTrustedProvider = trustedProviders?.includes( 53 | account.providerId as "apple", 54 | ); 55 | if ( 56 | (!isTrustedProvider && !userInfo.emailVerified) || 57 | c.context.options.account?.accountLinking?.enabled === false 58 | ) { 59 | if (isDevelopment()) { 60 | logger.warn( 61 | `User already exist but account isn't linked to ${account.providerId}. To read more about how account linking works in Better Auth see https://www.better-auth.com/docs/concepts/users-accounts#account-linking.`, 62 | ); 63 | } 64 | return { 65 | error: "account not linked", 66 | data: null, 67 | }; 68 | } 69 | try { 70 | await c.context.internalAdapter.linkAccount({ 71 | providerId: account.providerId, 72 | accountId: userInfo.id.toString(), 73 | userId: dbUser.user.id, 74 | accessToken: await setTokenUtil(account.accessToken, c.context), 75 | refreshToken: await setTokenUtil(account.refreshToken, c.context), 76 | idToken: account.idToken, 77 | accessTokenExpiresAt: account.accessTokenExpiresAt, 78 | refreshTokenExpiresAt: account.refreshTokenExpiresAt, 79 | scope: account.scope, 80 | }); 81 | } catch (e) { 82 | logger.error("Unable to link account", e); 83 | return { 84 | error: "unable to link account", 85 | data: null, 86 | }; 87 | } 88 | 89 | if ( 90 | userInfo.emailVerified && 91 | !dbUser.user.emailVerified && 92 | userInfo.email.toLowerCase() === dbUser.user.email 93 | ) { 94 | await c.context.internalAdapter.updateUser(dbUser.user.id, { 95 | emailVerified: true, 96 | }); 97 | } 98 | } else { 99 | if (c.context.options.account?.updateAccountOnSignIn !== false) { 100 | const updateData = Object.fromEntries( 101 | Object.entries({ 102 | idToken: account.idToken, 103 | accessToken: await setTokenUtil(account.accessToken, c.context), 104 | refreshToken: await setTokenUtil(account.refreshToken, c.context), 105 | accessTokenExpiresAt: account.accessTokenExpiresAt, 106 | refreshTokenExpiresAt: account.refreshTokenExpiresAt, 107 | scope: account.scope, 108 | }).filter(([_, value]) => value !== undefined), 109 | ); 110 | 111 | if (Object.keys(updateData).length > 0) { 112 | await c.context.internalAdapter.updateAccount( 113 | hasBeenLinked.id, 114 | updateData, 115 | ); 116 | } 117 | } 118 | 119 | if ( 120 | userInfo.emailVerified && 121 | !dbUser.user.emailVerified && 122 | userInfo.email.toLowerCase() === dbUser.user.email 123 | ) { 124 | await c.context.internalAdapter.updateUser(dbUser.user.id, { 125 | emailVerified: true, 126 | }); 127 | } 128 | } 129 | if (overrideUserInfo) { 130 | const { id: _, ...restUserInfo } = userInfo; 131 | // update user info from the provider if overrideUserInfo is true 132 | await c.context.internalAdapter.updateUser(dbUser.user.id, { 133 | ...restUserInfo, 134 | email: userInfo.email.toLowerCase(), 135 | emailVerified: 136 | userInfo.email.toLowerCase() === dbUser.user.email 137 | ? dbUser.user.emailVerified || userInfo.emailVerified 138 | : userInfo.emailVerified, 139 | }); 140 | } 141 | } else { 142 | if (disableSignUp) { 143 | return { 144 | error: "signup disabled", 145 | data: null, 146 | isRegister: false, 147 | }; 148 | } 149 | try { 150 | const { id: _, ...restUserInfo } = userInfo; 151 | user = await c.context.internalAdapter 152 | .createOAuthUser( 153 | { 154 | ...restUserInfo, 155 | email: userInfo.email.toLowerCase(), 156 | }, 157 | { 158 | accessToken: await setTokenUtil(account.accessToken, c.context), 159 | refreshToken: await setTokenUtil(account.refreshToken, c.context), 160 | idToken: account.idToken, 161 | accessTokenExpiresAt: account.accessTokenExpiresAt, 162 | refreshTokenExpiresAt: account.refreshTokenExpiresAt, 163 | scope: account.scope, 164 | providerId: account.providerId, 165 | accountId: userInfo.id.toString(), 166 | }, 167 | ) 168 | .then((res) => res?.user); 169 | if ( 170 | !userInfo.emailVerified && 171 | user && 172 | c.context.options.emailVerification?.sendOnSignUp 173 | ) { 174 | const token = await createEmailVerificationToken( 175 | c.context.secret, 176 | user.email, 177 | undefined, 178 | c.context.options.emailVerification?.expiresIn, 179 | ); 180 | const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`; 181 | await c.context.options.emailVerification?.sendVerificationEmail?.( 182 | { 183 | user, 184 | url, 185 | token, 186 | }, 187 | c.request, 188 | ); 189 | } 190 | } catch (e: any) { 191 | logger.error(e); 192 | if (e instanceof APIError) { 193 | return { 194 | error: e.message, 195 | data: null, 196 | isRegister: false, 197 | }; 198 | } 199 | return { 200 | error: "unable to create user", 201 | data: null, 202 | isRegister: false, 203 | }; 204 | } 205 | } 206 | if (!user) { 207 | return { 208 | error: "unable to create user", 209 | data: null, 210 | isRegister: false, 211 | }; 212 | } 213 | 214 | const session = await c.context.internalAdapter.createSession(user.id); 215 | if (!session) { 216 | return { 217 | error: "unable to create session", 218 | data: null, 219 | isRegister: false, 220 | }; 221 | } 222 | return { 223 | data: { 224 | session, 225 | user, 226 | }, 227 | error: null, 228 | isRegister, 229 | }; 230 | } 231 | ``` -------------------------------------------------------------------------------- /docs/content/docs/authentication/salesforce.mdx: -------------------------------------------------------------------------------- ```markdown 1 | --- 2 | title: Salesforce 3 | description: Salesforce provider setup and usage. 4 | --- 5 | 6 | <Steps> 7 | <Step> 8 | ### Get your Salesforce Credentials 9 | 1. Log into your Salesforce org (Production or Developer Edition) 10 | 2. Navigate to **Setup > App Manager** 11 | 3. Click **New Connected App** 12 | 4. Fill in the basic information: 13 | - Connected App Name: Your app name 14 | - API Name: Auto-generated from app name 15 | - Contact Email: Your email address 16 | 5. Enable OAuth Settings: 17 | - Check **Enable OAuth Settings** 18 | - Set **Callback URL** to your redirect URI (e.g., `http://localhost:3000/api/auth/callback/salesforce` for development) 19 | - Select Required OAuth Scopes: 20 | - Access your basic information (id) 21 | - Access your identity URL service (openid) 22 | - Access your email address (email) 23 | - Perform requests on your behalf at any time (refresh_token, offline_access) 24 | 6. Enable **Require Proof Key for Code Exchange (PKCE)** (required) 25 | 7. Save and note your **Consumer Key** (Client ID) and **Consumer Secret** (Client Secret) 26 | 27 | <Callout type="info"> 28 | - For development, you can use `http://localhost:3000` URLs, but production requires HTTPS 29 | - The callback URL must exactly match what's configured in Better Auth 30 | - PKCE (Proof Key for Code Exchange) is required by Salesforce and is automatically handled by the provider 31 | </Callout> 32 | 33 | <Callout type="warning"> 34 | For sandbox testing, you can create the Connected App in your sandbox org, or use the same Connected App but specify `environment: "sandbox"` in the provider configuration. 35 | </Callout> 36 | 37 | </Step> 38 | 39 | <Step> 40 | ### Configure the provider 41 | To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance. 42 | 43 | ```ts title="auth.ts" 44 | import { betterAuth } from "better-auth" 45 | 46 | export const auth = betterAuth({ 47 | socialProviders: { 48 | salesforce: { // [!code highlight] 49 | clientId: process.env.SALESFORCE_CLIENT_ID as string, // [!code highlight] 50 | clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, // [!code highlight] 51 | environment: "production", // or "sandbox" // [!code highlight] 52 | }, // [!code highlight] 53 | }, 54 | }) 55 | ``` 56 | 57 | #### Configuration Options 58 | 59 | - `clientId`: Your Connected App's Consumer Key 60 | - `clientSecret`: Your Connected App's Consumer Secret 61 | - `environment`: `"production"` (default) or `"sandbox"` 62 | - `loginUrl`: Custom My Domain URL (without `https://`) - overrides environment setting 63 | - `redirectURI`: Override the auto-generated redirect URI if needed 64 | 65 | #### Advanced Configuration 66 | 67 | ```ts title="auth.ts" 68 | export const auth = betterAuth({ 69 | socialProviders: { 70 | salesforce: { 71 | clientId: process.env.SALESFORCE_CLIENT_ID as string, 72 | clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, 73 | environment: "sandbox", // [!code highlight] 74 | loginUrl: "mycompany.my.salesforce.com", // Custom My Domain // [!code highlight] 75 | redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // Override if needed // [!code highlight] 76 | }, 77 | }, 78 | }) 79 | ``` 80 | 81 | <Callout type="info"> 82 | - Use `environment: "sandbox"` for testing with Salesforce sandbox orgs 83 | - The `loginUrl` option is useful for organizations with My Domain enabled 84 | - The `redirectURI` option helps resolve redirect URI mismatch errors 85 | </Callout> 86 | </Step> 87 | 88 | <Step> 89 | ### Environment Variables 90 | Add the following environment variables to your `.env.local` file: 91 | 92 | ```bash title=".env.local" 93 | SALESFORCE_CLIENT_ID=your_consumer_key_here 94 | SALESFORCE_CLIENT_SECRET=your_consumer_secret_here 95 | BETTER_AUTH_URL=http://localhost:3000 # Important for redirect URI generation 96 | ``` 97 | 98 | For production: 99 | ```bash title=".env" 100 | SALESFORCE_CLIENT_ID=your_consumer_key_here 101 | SALESFORCE_CLIENT_SECRET=your_consumer_secret_here 102 | BETTER_AUTH_URL=https://yourdomain.com 103 | ``` 104 | </Step> 105 | 106 | <Step> 107 | ### Sign In with Salesforce 108 | To sign in with Salesforce, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties: 109 | - `provider`: The provider to use. It should be set to `salesforce`. 110 | 111 | ```ts title="auth-client.ts" 112 | import { createAuthClient } from "better-auth/client" 113 | const authClient = createAuthClient() 114 | 115 | const signIn = async () => { 116 | const data = await authClient.signIn.social({ 117 | provider: "salesforce" 118 | }) 119 | } 120 | ``` 121 | </Step> 122 | <Step> 123 | ### Troubleshooting 124 | 125 | #### Redirect URI Mismatch Error 126 | If you encounter a `redirect_uri_mismatch` error: 127 | 128 | 1. **Check Callback URL**: Ensure the Callback URL in your Salesforce Connected App exactly matches your Better Auth callback URL 129 | 2. **Protocol**: Make sure you're using the same protocol (`http://` vs `https://`) 130 | 3. **Port**: Verify the port number matches (e.g., `:3000`) 131 | 4. **Override if needed**: Use the `redirectURI` option to explicitly set the redirect URI 132 | 133 | ```ts 134 | salesforce: { 135 | clientId: process.env.SALESFORCE_CLIENT_ID as string, 136 | clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, 137 | redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // [!code highlight] 138 | } 139 | ``` 140 | 141 | #### Environment Issues 142 | - **Production**: Use `environment: "production"` (default) with `login.salesforce.com` 143 | - **Sandbox**: Use `environment: "sandbox"` with `test.salesforce.com` 144 | - **My Domain**: Use `loginUrl: "yourcompany.my.salesforce.com"` for custom domains 145 | 146 | #### PKCE Requirements 147 | Salesforce requires PKCE (Proof Key for Code Exchange) which is automatically handled by this provider. Make sure PKCE is enabled in your Connected App settings. 148 | 149 | <Callout type="info"> 150 | The default scopes requested are `openid`, `email`, and `profile`. The provider will automatically include the `id` scope for accessing basic user information. 151 | </Callout> 152 | </Step> 153 | </Steps> 154 | ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/test/node-sqlite-dialect.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, it, expect, beforeAll, afterAll } from "vitest"; 2 | import { Kysely, sql } from "kysely"; 3 | import { NodeSqliteDialect } from "../node-sqlite-dialect"; 4 | import { kyselyAdapter } from "../kysely-adapter"; 5 | import { runAdapterTest } from "../../test"; 6 | import { getMigrations } from "../../../db/get-migration"; 7 | import type { BetterAuthOptions } from "@better-auth/core"; 8 | import merge from "deepmerge"; 9 | import type { DatabaseSync } from "node:sqlite"; 10 | const nodeVersion = process.version; 11 | const nodeSqliteSupported = +nodeVersion.split(".")[0]!.slice(1) >= 22; 12 | 13 | describe.runIf(nodeSqliteSupported)("node-sqlite-dialect", async () => { 14 | let db: DatabaseSync; 15 | let kysely: Kysely<any>; 16 | 17 | beforeAll(async () => { 18 | if (!nodeSqliteSupported) { 19 | return; 20 | } 21 | const { DatabaseSync } = await import("node:sqlite"); 22 | 23 | db = new DatabaseSync(":memory:"); 24 | 25 | kysely = new Kysely({ 26 | dialect: new NodeSqliteDialect({ 27 | database: db, 28 | }), 29 | }); 30 | }); 31 | 32 | afterAll(async () => { 33 | if (!nodeSqliteSupported) { 34 | return; 35 | } 36 | await kysely.destroy(); 37 | }); 38 | 39 | describe("basic operations", () => { 40 | it("should create tables", async () => { 41 | await kysely.schema 42 | .createTable("test_table") 43 | .addColumn("id", "integer", (col) => col.primaryKey()) 44 | .addColumn("name", "text", (col) => col.notNull()) 45 | .addColumn("created_at", "timestamp", (col) => 46 | col.defaultTo(sql`CURRENT_TIMESTAMP`), 47 | ) 48 | .execute(); 49 | 50 | const tables = await kysely.introspection.getTables(); 51 | const testTable = tables.find((t) => t.name === "test_table"); 52 | expect(testTable).toBeDefined(); 53 | expect(testTable?.columns).toHaveLength(3); 54 | }); 55 | 56 | it("should insert and select data", async () => { 57 | await kysely 58 | .insertInto("test_table") 59 | .values({ id: 1, name: "Test User" }) 60 | .execute(); 61 | 62 | const result = await kysely 63 | .selectFrom("test_table") 64 | .selectAll() 65 | .execute(); 66 | 67 | expect(result).toHaveLength(1); 68 | expect(result[0]).toMatchObject({ 69 | id: 1, 70 | name: "Test User", 71 | }); 72 | }); 73 | 74 | it("should update data", async () => { 75 | await kysely 76 | .updateTable("test_table") 77 | .set({ name: "Updated User" }) 78 | .where("id", "=", 1) 79 | .execute(); 80 | 81 | const result = await kysely 82 | .selectFrom("test_table") 83 | .where("id", "=", 1) 84 | .selectAll() 85 | .executeTakeFirst(); 86 | 87 | expect(result?.name).toBe("Updated User"); 88 | }); 89 | 90 | it("should delete data", async () => { 91 | await kysely.deleteFrom("test_table").where("id", "=", 1).execute(); 92 | 93 | const result = await kysely 94 | .selectFrom("test_table") 95 | .selectAll() 96 | .execute(); 97 | 98 | expect(result).toHaveLength(0); 99 | }); 100 | 101 | it("should handle transactions", async () => { 102 | await kysely.transaction().execute(async (trx) => { 103 | await trx 104 | .insertInto("test_table") 105 | .values({ id: 2, name: "Transaction Test" }) 106 | .execute(); 107 | 108 | const result = await trx 109 | .selectFrom("test_table") 110 | .where("id", "=", 2) 111 | .selectAll() 112 | .executeTakeFirst(); 113 | 114 | expect(result?.name).toBe("Transaction Test"); 115 | }); 116 | 117 | // Verify the transaction was committed 118 | const result = await kysely 119 | .selectFrom("test_table") 120 | .where("id", "=", 2) 121 | .selectAll() 122 | .executeTakeFirst(); 123 | 124 | expect(result?.name).toBe("Transaction Test"); 125 | }); 126 | 127 | it("should rollback transactions on error", async () => { 128 | try { 129 | await kysely.transaction().execute(async (trx) => { 130 | await trx 131 | .insertInto("test_table") 132 | .values({ id: 3, name: "Rollback Test" }) 133 | .execute(); 134 | 135 | // Force an error 136 | throw new Error("Test error"); 137 | }); 138 | } catch (error) { 139 | // Expected error 140 | } 141 | 142 | // Verify the transaction was rolled back 143 | const result = await kysely 144 | .selectFrom("test_table") 145 | .where("id", "=", 3) 146 | .selectAll() 147 | .executeTakeFirst(); 148 | 149 | expect(result).toBeUndefined(); 150 | }); 151 | }); 152 | 153 | describe("introspection", () => { 154 | beforeAll(async () => { 155 | // Create a table with various column types 156 | await kysely.schema 157 | .createTable("introspection_test") 158 | .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) 159 | .addColumn("name", "text", (col) => col.notNull()) 160 | .addColumn("email", "text", (col) => col.unique()) 161 | .addColumn("age", "integer") 162 | .addColumn("is_active", "boolean", (col) => col.defaultTo(true)) 163 | .execute(); 164 | }); 165 | 166 | it("should get table metadata", async () => { 167 | const tables = await kysely.introspection.getTables(); 168 | const table = tables.find((t) => t.name === "introspection_test"); 169 | 170 | expect(table).toBeDefined(); 171 | expect(table?.columns).toHaveLength(5); 172 | 173 | const idColumn = table?.columns.find((c) => c.name === "id"); 174 | expect(idColumn?.isAutoIncrementing).toBe(true); 175 | expect(idColumn?.isNullable).toBe(true); // SQLite primary keys can be NULL until a value is inserted 176 | 177 | const nameColumn = table?.columns.find((c) => c.name === "name"); 178 | expect(nameColumn?.isNullable).toBe(false); 179 | 180 | const ageColumn = table?.columns.find((c) => c.name === "age"); 181 | expect(ageColumn?.isNullable).toBe(true); 182 | 183 | const isActiveColumn = table?.columns.find((c) => c.name === "is_active"); 184 | expect(isActiveColumn?.hasDefaultValue).toBe(true); 185 | }); 186 | }); 187 | 188 | describe("better-auth adapter integration", async () => { 189 | if (!nodeSqliteSupported) { 190 | return; 191 | } 192 | const { DatabaseSync } = await import("node:sqlite"); 193 | const db = new DatabaseSync(":memory:"); 194 | const betterAuthKysely = new Kysely({ 195 | dialect: new NodeSqliteDialect({ 196 | database: db, 197 | }), 198 | }); 199 | 200 | const opts: BetterAuthOptions = { 201 | database: { 202 | db: betterAuthKysely, 203 | type: "sqlite", 204 | }, 205 | user: { 206 | fields: { 207 | email: "email_address", 208 | }, 209 | additionalFields: { 210 | test: { 211 | type: "string", 212 | defaultValue: "test", 213 | }, 214 | }, 215 | }, 216 | session: { 217 | modelName: "sessions", 218 | }, 219 | }; 220 | 221 | beforeAll(async () => { 222 | const { runMigrations } = await getMigrations(opts); 223 | await runMigrations(); 224 | }); 225 | 226 | afterAll(async () => { 227 | await betterAuthKysely.destroy(); 228 | }); 229 | 230 | const adapter = kyselyAdapter(betterAuthKysely, { 231 | type: "sqlite", 232 | debugLogs: { 233 | isRunningAdapterTests: true, 234 | }, 235 | }); 236 | 237 | runAdapterTest({ 238 | getAdapter: async (customOptions = {}) => { 239 | return adapter(merge(customOptions, opts)); 240 | }, 241 | testPrefix: "node-sqlite", 242 | }); 243 | }); 244 | }); 245 | ```