This is page 14 of 51. Use http://codebase.md/better-auth/better-auth?lines=false&page={x} to view the full context. # Directory Structure ``` ├── .gitattributes ├── .github │ ├── CODEOWNERS │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ ├── bug_report.yml │ │ └── feature_request.yml │ ├── renovate.json5 │ └── workflows │ ├── ci.yml │ ├── e2e.yml │ ├── preview.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── banner-dark.png ├── banner.png ├── biome.json ├── bump.config.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── demo │ ├── expo-example │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── app.config.ts │ │ ├── assets │ │ │ ├── bg-image.jpeg │ │ │ ├── fonts │ │ │ │ └── SpaceMono-Regular.ttf │ │ │ ├── icon.png │ │ │ └── images │ │ │ ├── adaptive-icon.png │ │ │ ├── favicon.png │ │ │ ├── logo.png │ │ │ ├── partial-react-logo.png │ │ │ ├── react-logo.png │ │ │ ├── [email protected] │ │ │ ├── [email protected] │ │ │ └── splash.png │ │ ├── babel.config.js │ │ ├── components.json │ │ ├── expo-env.d.ts │ │ ├── index.ts │ │ ├── metro.config.js │ │ ├── nativewind-env.d.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── app │ │ │ │ ├── _layout.tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...route]+api.ts │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── forget-password.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── sign-up.tsx │ │ │ ├── components │ │ │ │ ├── icons │ │ │ │ │ └── google.tsx │ │ │ │ └── ui │ │ │ │ ├── avatar.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── separator.tsx │ │ │ │ └── text.tsx │ │ │ ├── global.css │ │ │ └── lib │ │ │ ├── auth-client.ts │ │ │ ├── auth.ts │ │ │ ├── icons │ │ │ │ ├── iconWithClassName.ts │ │ │ │ └── X.tsx │ │ │ └── utils.ts │ │ ├── tailwind.config.js │ │ └── tsconfig.json │ └── nextjs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── (auth) │ │ │ ├── forget-password │ │ │ │ └── page.tsx │ │ │ ├── reset-password │ │ │ │ └── page.tsx │ │ │ ├── sign-in │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── two-factor │ │ │ ├── otp │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── accept-invitation │ │ │ └── [id] │ │ │ ├── invitation-error.tsx │ │ │ └── page.tsx │ │ ├── admin │ │ │ └── page.tsx │ │ ├── api │ │ │ └── auth │ │ │ └── [...all] │ │ │ └── route.ts │ │ ├── apps │ │ │ └── register │ │ │ └── page.tsx │ │ ├── client-test │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ ├── change-plan.tsx │ │ │ ├── client.tsx │ │ │ ├── organization-card.tsx │ │ │ ├── page.tsx │ │ │ ├── upgrade-button.tsx │ │ │ └── user-card.tsx │ │ ├── device │ │ │ ├── approve │ │ │ │ └── page.tsx │ │ │ ├── denied │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── success │ │ │ └── page.tsx │ │ ├── favicon.ico │ │ ├── features.tsx │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── oauth │ │ │ └── authorize │ │ │ ├── concet-buttons.tsx │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── pricing │ │ └── page.tsx │ ├── components │ │ ├── account-switch.tsx │ │ ├── blocks │ │ │ └── pricing.tsx │ │ ├── logo.tsx │ │ ├── one-tap.tsx │ │ ├── sign-in-btn.tsx │ │ ├── sign-in.tsx │ │ ├── sign-up.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggle.tsx │ │ ├── tier-labels.tsx │ │ ├── ui │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── breadcrumb.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── chart.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── copy-button.tsx │ │ │ ├── dialog.tsx │ │ │ ├── drawer.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-otp.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── menubar.tsx │ │ │ ├── navigation-menu.tsx │ │ │ ├── pagination.tsx │ │ │ ├── password-input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── tabs2.tsx │ │ │ ├── textarea.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ │ └── wrapper.tsx │ ├── components.json │ ├── hooks │ │ └── use-toast.ts │ ├── lib │ │ ├── auth-client.ts │ │ ├── auth-types.ts │ │ ├── auth.ts │ │ ├── email │ │ │ ├── invitation.tsx │ │ │ ├── resend.ts │ │ │ └── reset-password.tsx │ │ ├── metadata.ts │ │ ├── shared.ts │ │ └── utils.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── proxy.ts │ ├── public │ │ ├── __og.png │ │ ├── _og.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── logo.svg │ │ └── og.png │ ├── README.md │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── turbo.json ├── docker-compose.yml ├── docs │ ├── .env.example │ ├── .gitignore │ ├── app │ │ ├── api │ │ │ ├── ai-chat │ │ │ │ └── route.ts │ │ │ ├── analytics │ │ │ │ ├── conversation │ │ │ │ │ └── route.ts │ │ │ │ ├── event │ │ │ │ │ └── route.ts │ │ │ │ └── feedback │ │ │ │ └── route.ts │ │ │ ├── chat │ │ │ │ └── route.ts │ │ │ ├── og │ │ │ │ └── route.tsx │ │ │ ├── og-release │ │ │ │ └── route.tsx │ │ │ ├── search │ │ │ │ └── route.ts │ │ │ └── support │ │ │ └── route.ts │ │ ├── blog │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── blog-list.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── icons.tsx │ │ │ │ ├── stat-field.tsx │ │ │ │ └── support.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── changelogs │ │ │ ├── _components │ │ │ │ ├── _layout.tsx │ │ │ │ ├── changelog-layout.tsx │ │ │ │ ├── default-changelog.tsx │ │ │ │ ├── fmt-dates.tsx │ │ │ │ ├── grid-pattern.tsx │ │ │ │ ├── icons.tsx │ │ │ │ └── stat-field.tsx │ │ │ ├── [[...slug]] │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── community │ │ │ ├── _components │ │ │ │ ├── header.tsx │ │ │ │ └── stats.tsx │ │ │ └── page.tsx │ │ ├── docs │ │ │ ├── [[...slug]] │ │ │ │ ├── page.client.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── lib │ │ │ └── get-llm-text.ts │ │ ├── global.css │ │ ├── layout.config.tsx │ │ ├── layout.tsx │ │ ├── llms.txt │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── reference │ │ │ └── route.ts │ │ ├── sitemap.xml │ │ ├── static.json │ │ │ └── route.ts │ │ └── v1 │ │ ├── _components │ │ │ └── v1-text.tsx │ │ ├── bg-line.tsx │ │ └── page.tsx │ ├── assets │ │ ├── Geist.ttf │ │ └── GeistMono.ttf │ ├── components │ │ ├── ai-chat-modal.tsx │ │ ├── anchor-scroll-fix.tsx │ │ ├── api-method-tabs.tsx │ │ ├── api-method.tsx │ │ ├── banner.tsx │ │ ├── blocks │ │ │ └── features.tsx │ │ ├── builder │ │ │ ├── beam.tsx │ │ │ ├── code-tabs │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── code-tabs.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── tab-bar.tsx │ │ │ │ └── theme.ts │ │ │ ├── index.tsx │ │ │ ├── sign-in.tsx │ │ │ ├── sign-up.tsx │ │ │ ├── social-provider.tsx │ │ │ ├── store.ts │ │ │ └── tabs.tsx │ │ ├── display-techstack.tsx │ │ ├── divider-text.tsx │ │ ├── docs │ │ │ ├── docs.client.tsx │ │ │ ├── docs.tsx │ │ │ ├── layout │ │ │ │ ├── nav.tsx │ │ │ │ ├── theme-toggle.tsx │ │ │ │ ├── toc-thumb.tsx │ │ │ │ └── toc.tsx │ │ │ ├── page.client.tsx │ │ │ ├── page.tsx │ │ │ ├── shared.tsx │ │ │ └── ui │ │ │ ├── button.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── popover.tsx │ │ │ └── scroll-area.tsx │ │ ├── endpoint.tsx │ │ ├── features.tsx │ │ ├── floating-ai-search.tsx │ │ ├── fork-button.tsx │ │ ├── generate-apple-jwt.tsx │ │ ├── generate-secret.tsx │ │ ├── github-stat.tsx │ │ ├── icons.tsx │ │ ├── landing │ │ │ ├── gradient-bg.tsx │ │ │ ├── grid-pattern.tsx │ │ │ ├── hero.tsx │ │ │ ├── section-svg.tsx │ │ │ ├── section.tsx │ │ │ ├── spotlight.tsx │ │ │ └── testimonials.tsx │ │ ├── logo-context-menu.tsx │ │ ├── logo.tsx │ │ ├── markdown-renderer.tsx │ │ ├── markdown.tsx │ │ ├── mdx │ │ │ ├── add-to-cursor.tsx │ │ │ └── database-tables.tsx │ │ ├── message-feedback.tsx │ │ ├── mobile-search-icon.tsx │ │ ├── nav-bar.tsx │ │ ├── nav-link.tsx │ │ ├── nav-mobile.tsx │ │ ├── promo-card.tsx │ │ ├── resource-card.tsx │ │ ├── resource-grid.tsx │ │ ├── resource-section.tsx │ │ ├── ripple.tsx │ │ ├── search-dialog.tsx │ │ ├── side-bar.tsx │ │ ├── sidebar-content.tsx │ │ ├── techstack-icons.tsx │ │ ├── theme-provider.tsx │ │ ├── theme-toggler.tsx │ │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── aside-link.tsx │ │ ├── aspect-ratio.tsx │ │ ├── avatar.tsx │ │ ├── background-beams.tsx │ │ ├── background-boxes.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── callout.tsx │ │ ├── card.tsx │ │ ├── carousel.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── code-block.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── dynamic-code-block.tsx │ │ ├── fade-in.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── menubar.tsx │ │ ├── navigation-menu.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── resizable.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── sidebar.tsx │ │ ├── skeleton.tsx │ │ ├── slider.tsx │ │ ├── sonner.tsx │ │ ├── sparkles.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip-docs.tsx │ │ ├── tooltip.tsx │ │ └── use-copy-button.tsx │ ├── components.json │ ├── content │ │ ├── blogs │ │ │ ├── 0-supabase-auth-to-planetscale-migration.mdx │ │ │ ├── 1-3.mdx │ │ │ ├── authjs-joins-better-auth.mdx │ │ │ └── seed-round.mdx │ │ ├── changelogs │ │ │ ├── 1-2.mdx │ │ │ └── 1.0.mdx │ │ └── docs │ │ ├── adapters │ │ │ ├── community-adapters.mdx │ │ │ ├── drizzle.mdx │ │ │ ├── mongo.mdx │ │ │ ├── mssql.mdx │ │ │ ├── mysql.mdx │ │ │ ├── other-relational-databases.mdx │ │ │ ├── postgresql.mdx │ │ │ ├── prisma.mdx │ │ │ └── sqlite.mdx │ │ ├── authentication │ │ │ ├── apple.mdx │ │ │ ├── atlassian.mdx │ │ │ ├── cognito.mdx │ │ │ ├── discord.mdx │ │ │ ├── dropbox.mdx │ │ │ ├── email-password.mdx │ │ │ ├── facebook.mdx │ │ │ ├── figma.mdx │ │ │ ├── github.mdx │ │ │ ├── gitlab.mdx │ │ │ ├── google.mdx │ │ │ ├── huggingface.mdx │ │ │ ├── kakao.mdx │ │ │ ├── kick.mdx │ │ │ ├── line.mdx │ │ │ ├── linear.mdx │ │ │ ├── linkedin.mdx │ │ │ ├── microsoft.mdx │ │ │ ├── naver.mdx │ │ │ ├── notion.mdx │ │ │ ├── other-social-providers.mdx │ │ │ ├── paypal.mdx │ │ │ ├── reddit.mdx │ │ │ ├── roblox.mdx │ │ │ ├── salesforce.mdx │ │ │ ├── slack.mdx │ │ │ ├── spotify.mdx │ │ │ ├── tiktok.mdx │ │ │ ├── twitch.mdx │ │ │ ├── twitter.mdx │ │ │ ├── vk.mdx │ │ │ └── zoom.mdx │ │ ├── basic-usage.mdx │ │ ├── comparison.mdx │ │ ├── concepts │ │ │ ├── api.mdx │ │ │ ├── cli.mdx │ │ │ ├── client.mdx │ │ │ ├── cookies.mdx │ │ │ ├── database.mdx │ │ │ ├── email.mdx │ │ │ ├── hooks.mdx │ │ │ ├── oauth.mdx │ │ │ ├── plugins.mdx │ │ │ ├── rate-limit.mdx │ │ │ ├── session-management.mdx │ │ │ ├── typescript.mdx │ │ │ └── users-accounts.mdx │ │ ├── examples │ │ │ ├── astro.mdx │ │ │ ├── next-js.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ └── svelte-kit.mdx │ │ ├── guides │ │ │ ├── auth0-migration-guide.mdx │ │ │ ├── browser-extension-guide.mdx │ │ │ ├── clerk-migration-guide.mdx │ │ │ ├── create-a-db-adapter.mdx │ │ │ ├── next-auth-migration-guide.mdx │ │ │ ├── optimizing-for-performance.mdx │ │ │ ├── saml-sso-with-okta.mdx │ │ │ ├── supabase-migration-guide.mdx │ │ │ └── your-first-plugin.mdx │ │ ├── installation.mdx │ │ ├── integrations │ │ │ ├── astro.mdx │ │ │ ├── convex.mdx │ │ │ ├── elysia.mdx │ │ │ ├── expo.mdx │ │ │ ├── express.mdx │ │ │ ├── fastify.mdx │ │ │ ├── hono.mdx │ │ │ ├── lynx.mdx │ │ │ ├── nestjs.mdx │ │ │ ├── next.mdx │ │ │ ├── nitro.mdx │ │ │ ├── nuxt.mdx │ │ │ ├── remix.mdx │ │ │ ├── solid-start.mdx │ │ │ ├── svelte-kit.mdx │ │ │ ├── tanstack.mdx │ │ │ └── waku.mdx │ │ ├── introduction.mdx │ │ ├── meta.json │ │ ├── plugins │ │ │ ├── 2fa.mdx │ │ │ ├── admin.mdx │ │ │ ├── anonymous.mdx │ │ │ ├── api-key.mdx │ │ │ ├── autumn.mdx │ │ │ ├── bearer.mdx │ │ │ ├── captcha.mdx │ │ │ ├── community-plugins.mdx │ │ │ ├── device-authorization.mdx │ │ │ ├── dodopayments.mdx │ │ │ ├── dub.mdx │ │ │ ├── email-otp.mdx │ │ │ ├── generic-oauth.mdx │ │ │ ├── have-i-been-pwned.mdx │ │ │ ├── jwt.mdx │ │ │ ├── last-login-method.mdx │ │ │ ├── magic-link.mdx │ │ │ ├── mcp.mdx │ │ │ ├── multi-session.mdx │ │ │ ├── oauth-proxy.mdx │ │ │ ├── oidc-provider.mdx │ │ │ ├── one-tap.mdx │ │ │ ├── one-time-token.mdx │ │ │ ├── open-api.mdx │ │ │ ├── organization.mdx │ │ │ ├── passkey.mdx │ │ │ ├── phone-number.mdx │ │ │ ├── polar.mdx │ │ │ ├── siwe.mdx │ │ │ ├── sso.mdx │ │ │ ├── stripe.mdx │ │ │ └── username.mdx │ │ └── reference │ │ ├── contributing.mdx │ │ ├── faq.mdx │ │ ├── options.mdx │ │ ├── resources.mdx │ │ ├── security.mdx │ │ └── telemetry.mdx │ ├── hooks │ │ └── use-mobile.ts │ ├── ignore-build.sh │ ├── lib │ │ ├── blog.ts │ │ ├── chat │ │ │ └── inkeep-qa-schema.ts │ │ ├── constants.ts │ │ ├── export-search-indexes.ts │ │ ├── inkeep-analytics.ts │ │ ├── is-active.ts │ │ ├── metadata.ts │ │ ├── source.ts │ │ └── utils.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── proxy.ts │ ├── public │ │ ├── avatars │ │ │ └── beka.jpg │ │ ├── blogs │ │ │ ├── authjs-joins.png │ │ │ ├── seed-round.png │ │ │ └── supabase-ps.png │ │ ├── branding │ │ │ ├── better-auth-brand-assets.zip │ │ │ ├── better-auth-logo-dark.png │ │ │ ├── better-auth-logo-dark.svg │ │ │ ├── better-auth-logo-light.png │ │ │ ├── better-auth-logo-light.svg │ │ │ ├── better-auth-logo-wordmark-dark.png │ │ │ ├── better-auth-logo-wordmark-dark.svg │ │ │ ├── better-auth-logo-wordmark-light.png │ │ │ └── better-auth-logo-wordmark-light.svg │ │ ├── extension-id.png │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── light │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── favicon.ico │ │ │ │ └── site.webmanifest │ │ │ └── site.webmanifest │ │ ├── images │ │ │ └── blogs │ │ │ └── better auth (1).png │ │ ├── logo.png │ │ ├── logo.svg │ │ ├── LogoDark.webp │ │ ├── LogoLight.webp │ │ ├── og.png │ │ ├── open-api-reference.png │ │ ├── people-say │ │ │ ├── code-with-antonio.jpg │ │ │ ├── dagmawi-babi.png │ │ │ ├── dax.png │ │ │ ├── dev-ed.png │ │ │ ├── egoist.png │ │ │ ├── guillermo-rauch.png │ │ │ ├── jonathan-wilke.png │ │ │ ├── josh-tried-coding.jpg │ │ │ ├── kitze.jpg │ │ │ ├── lazar-nikolov.png │ │ │ ├── nizzy.png │ │ │ ├── omar-mcadam.png │ │ │ ├── ryan-vogel.jpg │ │ │ ├── saltyatom.jpg │ │ │ ├── sebastien-chopin.png │ │ │ ├── shreyas-mididoddi.png │ │ │ ├── tech-nerd.png │ │ │ ├── theo.png │ │ │ ├── vybhav-bhargav.png │ │ │ └── xavier-pladevall.jpg │ │ ├── plus.svg │ │ ├── release-og │ │ │ ├── 1-2.png │ │ │ ├── 1-3.png │ │ │ └── changelog-og.png │ │ └── v1-og.png │ ├── README.md │ ├── scripts │ │ ├── endpoint-to-doc │ │ │ ├── index.ts │ │ │ ├── input.ts │ │ │ ├── output.mdx │ │ │ └── readme.md │ │ └── sync-orama.ts │ ├── source.config.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo.json ├── e2e │ ├── integration │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── solid-vinxi │ │ │ ├── .gitignore │ │ │ ├── app.config.ts │ │ │ ├── e2e │ │ │ │ ├── test.spec.ts │ │ │ │ └── utils.ts │ │ │ ├── package.json │ │ │ ├── public │ │ │ │ └── favicon.ico │ │ │ ├── src │ │ │ │ ├── app.tsx │ │ │ │ ├── entry-client.tsx │ │ │ │ ├── entry-server.tsx │ │ │ │ ├── global.d.ts │ │ │ │ ├── lib │ │ │ │ │ ├── auth-client.ts │ │ │ │ │ └── auth.ts │ │ │ │ └── routes │ │ │ │ ├── [...404].tsx │ │ │ │ ├── api │ │ │ │ │ └── auth │ │ │ │ │ └── [...all].ts │ │ │ │ └── index.tsx │ │ │ └── tsconfig.json │ │ ├── test-utils │ │ │ ├── package.json │ │ │ └── src │ │ │ └── playwright.ts │ │ └── vanilla-node │ │ ├── e2e │ │ │ ├── app.ts │ │ │ ├── domain.spec.ts │ │ │ ├── postgres-js.spec.ts │ │ │ ├── test.spec.ts │ │ │ └── utils.ts │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ │ ├── main.ts │ │ │ └── vite-env.d.ts │ │ ├── tsconfig.json │ │ └── vite.config.ts │ └── smoke │ ├── package.json │ ├── test │ │ ├── bun.spec.ts │ │ ├── cloudflare.spec.ts │ │ ├── deno.spec.ts │ │ ├── fixtures │ │ │ ├── bun-simple.ts │ │ │ ├── cloudflare │ │ │ │ ├── .gitignore │ │ │ │ ├── drizzle │ │ │ │ │ ├── 0000_clean_vector.sql │ │ │ │ │ └── meta │ │ │ │ │ ├── _journal.json │ │ │ │ │ └── 0000_snapshot.json │ │ │ │ ├── drizzle.config.ts │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── auth-schema.ts │ │ │ │ │ ├── db.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── test │ │ │ │ │ ├── apply-migrations.ts │ │ │ │ │ ├── env.d.ts │ │ │ │ │ └── index.test.ts │ │ │ │ ├── tsconfig.json │ │ │ │ ├── vitest.config.ts │ │ │ │ ├── worker-configuration.d.ts │ │ │ │ └── wrangler.json │ │ │ ├── deno-simple.ts │ │ │ ├── tsconfig-decelration │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── demo.ts │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-exact-optional-property-types │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── user-additional-fields.ts │ │ │ │ │ └── username.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-isolated-module-bundler │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ ├── tsconfig-verbatim-module-syntax-node10 │ │ │ │ ├── package.json │ │ │ │ ├── src │ │ │ │ │ └── index.ts │ │ │ │ └── tsconfig.json │ │ │ └── vite │ │ │ ├── package.json │ │ │ ├── src │ │ │ │ ├── client.ts │ │ │ │ └── server.ts │ │ │ ├── tsconfig.json │ │ │ └── vite.config.ts │ │ ├── ssr.ts │ │ ├── typecheck.spec.ts │ │ └── vite.spec.ts │ └── tsconfig.json ├── LICENSE.md ├── package.json ├── packages │ ├── better-auth │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── __snapshots__ │ │ │ │ └── init.test.ts.snap │ │ │ ├── adapters │ │ │ │ ├── adapter-factory │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── __snapshots__ │ │ │ │ │ │ │ └── adapter-factory.test.ts.snap │ │ │ │ │ │ └── adapter-factory.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── create-test-suite.ts │ │ │ │ ├── drizzle-adapter │ │ │ │ │ ├── drizzle-adapter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── adapter.drizzle.mysql.test.ts │ │ │ │ │ ├── adapter.drizzle.pg.test.ts │ │ │ │ │ ├── adapter.drizzle.sqlite.test.ts │ │ │ │ │ └── generate-schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely-adapter │ │ │ │ │ ├── bun-sqlite-dialect.ts │ │ │ │ │ ├── dialect.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── kysely-adapter.ts │ │ │ │ │ ├── node-sqlite-dialect.ts │ │ │ │ │ ├── test │ │ │ │ │ │ ├── adapter.kysely.mssql.test.ts │ │ │ │ │ │ ├── adapter.kysely.mysql.test.ts │ │ │ │ │ │ ├── adapter.kysely.pg.test.ts │ │ │ │ │ │ ├── adapter.kysely.sqlite.test.ts │ │ │ │ │ │ └── node-sqlite-dialect.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── memory-adapter │ │ │ │ │ ├── adapter.memory.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── memory-adapter.ts │ │ │ │ ├── mongodb-adapter │ │ │ │ │ ├── adapter.mongo-db.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mongodb-adapter.ts │ │ │ │ ├── prisma-adapter │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── prisma-adapter.ts │ │ │ │ │ └── test │ │ │ │ │ ├── .gitignore │ │ │ │ │ ├── base.prisma │ │ │ │ │ ├── generate-auth-config.ts │ │ │ │ │ ├── generate-prisma-schema.ts │ │ │ │ │ ├── get-prisma-client.ts │ │ │ │ │ ├── prisma.mysql.test.ts │ │ │ │ │ ├── prisma.pg.test.ts │ │ │ │ │ ├── prisma.sqlite.test.ts │ │ │ │ │ └── push-prisma-schema.ts │ │ │ │ ├── test-adapter.ts │ │ │ │ ├── test.ts │ │ │ │ ├── tests │ │ │ │ │ ├── auth-flow.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normal.ts │ │ │ │ │ ├── number-id.ts │ │ │ │ │ ├── performance.ts │ │ │ │ │ └── transactions.ts │ │ │ │ └── utils.ts │ │ │ ├── api │ │ │ │ ├── check-endpoint-conflicts.test.ts │ │ │ │ ├── index.test.ts │ │ │ │ ├── index.ts │ │ │ │ ├── middlewares │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── origin-check.test.ts │ │ │ │ │ └── origin-check.ts │ │ │ │ ├── rate-limiter │ │ │ │ │ ├── index.ts │ │ │ │ │ └── rate-limiter.test.ts │ │ │ │ ├── routes │ │ │ │ │ ├── account.test.ts │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── callback.ts │ │ │ │ │ ├── email-verification.test.ts │ │ │ │ │ ├── email-verification.ts │ │ │ │ │ ├── error.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── ok.ts │ │ │ │ │ ├── reset-password.test.ts │ │ │ │ │ ├── reset-password.ts │ │ │ │ │ ├── session-api.test.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── sign-in.test.ts │ │ │ │ │ ├── sign-in.ts │ │ │ │ │ ├── sign-out.test.ts │ │ │ │ │ ├── sign-out.ts │ │ │ │ │ ├── sign-up.test.ts │ │ │ │ │ ├── sign-up.ts │ │ │ │ │ ├── update-user.test.ts │ │ │ │ │ └── update-user.ts │ │ │ │ ├── to-auth-endpoints.test.ts │ │ │ │ └── to-auth-endpoints.ts │ │ │ ├── auth.test.ts │ │ │ ├── auth.ts │ │ │ ├── call.test.ts │ │ │ ├── client │ │ │ │ ├── client-ssr.test.ts │ │ │ │ ├── client.test.ts │ │ │ │ ├── config.ts │ │ │ │ ├── fetch-plugins.ts │ │ │ │ ├── index.ts │ │ │ │ ├── lynx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── lynx-store.ts │ │ │ │ ├── parser.ts │ │ │ │ ├── path-to-object.ts │ │ │ │ ├── plugins │ │ │ │ │ ├── index.ts │ │ │ │ │ └── infer-plugin.ts │ │ │ │ ├── proxy.ts │ │ │ │ ├── query.ts │ │ │ │ ├── react │ │ │ │ │ ├── index.ts │ │ │ │ │ └── react-store.ts │ │ │ │ ├── session-atom.ts │ │ │ │ ├── solid │ │ │ │ │ ├── index.ts │ │ │ │ │ └── solid-store.ts │ │ │ │ ├── svelte │ │ │ │ │ └── index.ts │ │ │ │ ├── test-plugin.ts │ │ │ │ ├── types.ts │ │ │ │ ├── url.test.ts │ │ │ │ ├── vanilla.ts │ │ │ │ └── vue │ │ │ │ ├── index.ts │ │ │ │ └── vue-store.ts │ │ │ ├── cookies │ │ │ │ ├── check-cookies.ts │ │ │ │ ├── cookie-utils.ts │ │ │ │ ├── cookies.test.ts │ │ │ │ └── index.ts │ │ │ ├── crypto │ │ │ │ ├── buffer.ts │ │ │ │ ├── hash.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.ts │ │ │ │ ├── password.test.ts │ │ │ │ ├── password.ts │ │ │ │ └── random.ts │ │ │ ├── db │ │ │ │ ├── db.test.ts │ │ │ │ ├── field.ts │ │ │ │ ├── get-migration.ts │ │ │ │ ├── get-schema.ts │ │ │ │ ├── get-tables.test.ts │ │ │ │ ├── get-tables.ts │ │ │ │ ├── index.ts │ │ │ │ ├── internal-adapter.test.ts │ │ │ │ ├── internal-adapter.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── secondary-storage.test.ts │ │ │ │ ├── to-zod.ts │ │ │ │ ├── utils.ts │ │ │ │ └── with-hooks.ts │ │ │ ├── index.ts │ │ │ ├── init.test.ts │ │ │ ├── init.ts │ │ │ ├── integrations │ │ │ │ ├── next-js.ts │ │ │ │ ├── node.ts │ │ │ │ ├── react-start.ts │ │ │ │ ├── solid-start.ts │ │ │ │ └── svelte-kit.ts │ │ │ ├── oauth2 │ │ │ │ ├── index.ts │ │ │ │ ├── link-account.test.ts │ │ │ │ ├── link-account.ts │ │ │ │ ├── state.ts │ │ │ │ └── utils.ts │ │ │ ├── plugins │ │ │ │ ├── access │ │ │ │ │ ├── access.test.ts │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── additional-fields │ │ │ │ │ ├── additional-fields.test.ts │ │ │ │ │ └── client.ts │ │ │ │ ├── admin │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── admin.test.ts │ │ │ │ │ ├── admin.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── anonymous │ │ │ │ │ ├── anon.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── api-key │ │ │ │ │ ├── api-key.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── create-api-key.ts │ │ │ │ │ │ ├── delete-all-expired-api-keys.ts │ │ │ │ │ │ ├── delete-api-key.ts │ │ │ │ │ │ ├── get-api-key.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-api-keys.ts │ │ │ │ │ │ ├── update-api-key.ts │ │ │ │ │ │ └── verify-api-key.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── bearer │ │ │ │ │ ├── bearer.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── captcha │ │ │ │ │ ├── captcha.test.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-handlers │ │ │ │ │ ├── captchafox.ts │ │ │ │ │ ├── cloudflare-turnstile.ts │ │ │ │ │ ├── google-recaptcha.ts │ │ │ │ │ ├── h-captcha.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── custom-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-session.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── device-authorization │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── device-authorization.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── email-otp │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── email-otp.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── generic-oauth │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── generic-oauth.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── haveibeenpwned │ │ │ │ │ ├── haveibeenpwned.test.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jwt │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── jwt.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── sign.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── last-login-method │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── custom-prefix.test.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── last-login-method.test.ts │ │ │ │ ├── magic-link │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── magic-link.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── mcp │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mcp.test.ts │ │ │ │ ├── multi-session │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── multi-session.test.ts │ │ │ │ ├── oauth-proxy │ │ │ │ │ ├── index.ts │ │ │ │ │ └── oauth-proxy.test.ts │ │ │ │ ├── oidc-provider │ │ │ │ │ ├── authorize.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oidc.test.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── ui.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── one-tap │ │ │ │ │ ├── client.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── one-time-token │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-time-token.test.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── open-api │ │ │ │ │ ├── generator.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── logo.ts │ │ │ │ │ └── open-api.test.ts │ │ │ │ ├── organization │ │ │ │ │ ├── access │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── statement.ts │ │ │ │ │ ├── adapter.ts │ │ │ │ │ ├── call.ts │ │ │ │ │ ├── client.test.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── error-codes.ts │ │ │ │ │ ├── has-permission.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-hook.test.ts │ │ │ │ │ ├── organization.test.ts │ │ │ │ │ ├── organization.ts │ │ │ │ │ ├── permission.ts │ │ │ │ │ ├── routes │ │ │ │ │ │ ├── crud-access-control.test.ts │ │ │ │ │ │ ├── crud-access-control.ts │ │ │ │ │ │ ├── crud-invites.ts │ │ │ │ │ │ ├── crud-members.test.ts │ │ │ │ │ │ ├── crud-members.ts │ │ │ │ │ │ ├── crud-org.test.ts │ │ │ │ │ │ ├── crud-org.ts │ │ │ │ │ │ └── crud-team.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── team.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── passkey │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passkey.test.ts │ │ │ │ ├── phone-number │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── phone-number-error.ts │ │ │ │ │ └── phone-number.test.ts │ │ │ │ ├── siwe │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── siwe.test.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── two-factor │ │ │ │ │ ├── backup-codes │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── constant.ts │ │ │ │ │ ├── error-code.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── otp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── totp │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── two-factor.test.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-two-factor.ts │ │ │ │ └── username │ │ │ │ ├── client.ts │ │ │ │ ├── error-codes.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── username.test.ts │ │ │ ├── social-providers │ │ │ │ └── index.ts │ │ │ ├── social.test.ts │ │ │ ├── test-utils │ │ │ │ ├── headers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── state.ts │ │ │ │ └── test-instance.ts │ │ │ ├── types │ │ │ │ ├── adapter.ts │ │ │ │ ├── api.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── models.ts │ │ │ │ ├── plugins.ts │ │ │ │ └── types.test.ts │ │ │ └── utils │ │ │ ├── await-object.ts │ │ │ ├── boolean.ts │ │ │ ├── clone.ts │ │ │ ├── constants.ts │ │ │ ├── date.ts │ │ │ ├── ensure-utc.ts │ │ │ ├── get-request-ip.ts │ │ │ ├── hashing.ts │ │ │ ├── hide-metadata.ts │ │ │ ├── id.ts │ │ │ ├── import-util.ts │ │ │ ├── index.ts │ │ │ ├── is-atom.ts │ │ │ ├── is-promise.ts │ │ │ ├── json.ts │ │ │ ├── merger.ts │ │ │ ├── middleware-response.ts │ │ │ ├── misc.ts │ │ │ ├── password.ts │ │ │ ├── plugin-helper.ts │ │ │ ├── shim.ts │ │ │ ├── time.ts │ │ │ ├── url.ts │ │ │ └── wildcard.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ ├── vitest.config.ts │ │ └── vitest.setup.ts │ ├── cli │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── commands │ │ │ │ ├── generate.ts │ │ │ │ ├── info.ts │ │ │ │ ├── init.ts │ │ │ │ ├── login.ts │ │ │ │ ├── mcp.ts │ │ │ │ ├── migrate.ts │ │ │ │ └── secret.ts │ │ │ ├── generators │ │ │ │ ├── auth-config.ts │ │ │ │ ├── drizzle.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kysely.ts │ │ │ │ ├── prisma.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ └── utils │ │ │ ├── add-svelte-kit-env-modules.ts │ │ │ ├── check-package-managers.ts │ │ │ ├── format-ms.ts │ │ │ ├── get-config.ts │ │ │ ├── get-package-info.ts │ │ │ ├── get-tsconfig-info.ts │ │ │ └── install-dependencies.ts │ │ ├── test │ │ │ ├── __snapshots__ │ │ │ │ ├── auth-schema-mysql-enum.txt │ │ │ │ ├── auth-schema-mysql-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey-number-id.txt │ │ │ │ ├── auth-schema-mysql-passkey.txt │ │ │ │ ├── auth-schema-mysql.txt │ │ │ │ ├── auth-schema-number-id.txt │ │ │ │ ├── auth-schema-pg-enum.txt │ │ │ │ ├── auth-schema-pg-passkey.txt │ │ │ │ ├── auth-schema-sqlite-enum.txt │ │ │ │ ├── auth-schema-sqlite-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey-number-id.txt │ │ │ │ ├── auth-schema-sqlite-passkey.txt │ │ │ │ ├── auth-schema-sqlite.txt │ │ │ │ ├── auth-schema.txt │ │ │ │ ├── migrations.sql │ │ │ │ ├── schema-mongodb.prisma │ │ │ │ ├── schema-mysql-custom.prisma │ │ │ │ ├── schema-mysql.prisma │ │ │ │ ├── schema-numberid.prisma │ │ │ │ └── schema.prisma │ │ │ ├── generate-all-db.test.ts │ │ │ ├── generate.test.ts │ │ │ ├── get-config.test.ts │ │ │ ├── info.test.ts │ │ │ └── migrate.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.test.json │ │ └── tsdown.config.ts │ ├── core │ │ ├── package.json │ │ ├── src │ │ │ ├── api │ │ │ │ └── index.ts │ │ │ ├── async_hooks │ │ │ │ └── index.ts │ │ │ ├── context │ │ │ │ ├── endpoint-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── transaction.ts │ │ │ ├── db │ │ │ │ ├── adapter │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plugin.ts │ │ │ │ ├── schema │ │ │ │ │ ├── account.ts │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── verification.ts │ │ │ │ └── type.ts │ │ │ ├── env │ │ │ │ ├── color-depth.ts │ │ │ │ ├── env-impl.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.test.ts │ │ │ │ └── logger.ts │ │ │ ├── error │ │ │ │ ├── codes.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── oauth2 │ │ │ │ ├── client-credentials-token.ts │ │ │ │ ├── create-authorization-url.ts │ │ │ │ ├── index.ts │ │ │ │ ├── oauth-provider.ts │ │ │ │ ├── refresh-access-token.ts │ │ │ │ ├── utils.ts │ │ │ │ └── validate-authorization-code.ts │ │ │ ├── social-providers │ │ │ │ ├── apple.ts │ │ │ │ ├── atlassian.ts │ │ │ │ ├── cognito.ts │ │ │ │ ├── discord.ts │ │ │ │ ├── dropbox.ts │ │ │ │ ├── facebook.ts │ │ │ │ ├── figma.ts │ │ │ │ ├── github.ts │ │ │ │ ├── gitlab.ts │ │ │ │ ├── google.ts │ │ │ │ ├── huggingface.ts │ │ │ │ ├── index.ts │ │ │ │ ├── kakao.ts │ │ │ │ ├── kick.ts │ │ │ │ ├── line.ts │ │ │ │ ├── linear.ts │ │ │ │ ├── linkedin.ts │ │ │ │ ├── microsoft-entra-id.ts │ │ │ │ ├── naver.ts │ │ │ │ ├── notion.ts │ │ │ │ ├── paypal.ts │ │ │ │ ├── reddit.ts │ │ │ │ ├── roblox.ts │ │ │ │ ├── salesforce.ts │ │ │ │ ├── slack.ts │ │ │ │ ├── spotify.ts │ │ │ │ ├── tiktok.ts │ │ │ │ ├── twitch.ts │ │ │ │ ├── twitter.ts │ │ │ │ ├── vk.ts │ │ │ │ └── zoom.ts │ │ │ ├── types │ │ │ │ ├── context.ts │ │ │ │ ├── cookie.ts │ │ │ │ ├── helper.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init-options.ts │ │ │ │ ├── plugin-client.ts │ │ │ │ └── plugin.ts │ │ │ └── utils │ │ │ ├── error-codes.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── expo │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── expo.test.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── sso │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── index.ts │ │ │ ├── oidc.test.ts │ │ │ └── saml.test.ts │ │ ├── tsconfig.json │ │ └── tsdown.config.ts │ ├── stripe │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── src │ │ │ ├── client.ts │ │ │ ├── hooks.ts │ │ │ ├── index.ts │ │ │ ├── schema.ts │ │ │ ├── stripe.test.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsdown.config.ts │ │ └── vitest.config.ts │ └── telemetry │ ├── package.json │ ├── src │ │ ├── detectors │ │ │ ├── detect-auth-config.ts │ │ │ ├── detect-database.ts │ │ │ ├── detect-framework.ts │ │ │ ├── detect-project-info.ts │ │ │ ├── detect-runtime.ts │ │ │ └── detect-system-info.ts │ │ ├── index.ts │ │ ├── project-id.ts │ │ ├── telemetry.test.ts │ │ ├── types.ts │ │ └── utils │ │ ├── hash.ts │ │ ├── id.ts │ │ ├── import-util.ts │ │ └── package-json.ts │ ├── tsconfig.json │ └── tsdown.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── README.md ├── SECURITY.md ├── tsconfig.json └── turbo.json ``` # Files -------------------------------------------------------------------------------- /demo/nextjs/components/sign-up.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useState, useTransition } from "react"; import { Loader2, X } from "lucide-react"; import { signUp } from "@/lib/auth-client"; import { toast } from "sonner"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { getCallbackURL } from "@/lib/shared"; export function SignUp() { const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirmation, setPasswordConfirmation] = useState(""); const [image, setImage] = useState<File | null>(null); const [imagePreview, setImagePreview] = useState<string | null>(null); const router = useRouter(); const params = useSearchParams(); const [loading, startTransition] = useTransition(); const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) { setImage(file); setImagePreview((preview) => { if (preview) { URL.revokeObjectURL(preview); } return URL.createObjectURL(file); }); } }; return ( <Card className="z-50 rounded-md rounded-t-none max-w-md"> <CardHeader> <CardTitle className="text-lg md:text-xl">Sign Up</CardTitle> <CardDescription className="text-xs md:text-sm"> Enter your information to create an account </CardDescription> </CardHeader> <CardContent> <div className="grid gap-4"> <div className="grid grid-cols-2 gap-4"> <div className="grid gap-2"> <Label htmlFor="first-name">First name</Label> <Input id="first-name" placeholder="Max" required onChange={(e) => { setFirstName(e.target.value); }} value={firstName} /> </div> <div className="grid gap-2"> <Label htmlFor="last-name">Last name</Label> <Input id="last-name" placeholder="Robinson" required onChange={(e) => { setLastName(e.target.value); }} value={lastName} /> </div> </div> <div className="grid gap-2"> <Label htmlFor="email">Email</Label> <Input id="email" type="email" placeholder="[email protected]" required onChange={(e) => { setEmail(e.target.value); }} value={email} /> </div> <div className="grid gap-2"> <Label htmlFor="password">Password</Label> <Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder="Password" /> </div> <div className="grid gap-2"> <Label htmlFor="password">Confirm Password</Label> <Input id="password_confirmation" type="password" value={passwordConfirmation} onChange={(e) => setPasswordConfirmation(e.target.value)} autoComplete="new-password" placeholder="Confirm Password" /> </div> <div className="grid gap-2"> <Label htmlFor="image">Profile Image (optional)</Label> <div className="flex items-end gap-4"> {imagePreview && ( <div className="relative w-16 h-16 rounded-sm overflow-hidden"> <img src={imagePreview} alt="Profile preview" className="object-cover w-full h-full" /> </div> )} <div className="flex items-center gap-2 w-full"> <Input id="image" type="file" accept="image/*" onChange={handleImageChange} className="w-full" /> {imagePreview && ( <X className="cursor-pointer" onClick={() => { setImage(null); setImagePreview(null); }} /> )} </div> </div> </div> <Button type="submit" className="w-full" disabled={loading} onClick={async () => { startTransition(async () => { await signUp.email({ email, password, name: `${firstName} ${lastName}`, image: image ? await convertImageToBase64(image) : "", callbackURL: "/dashboard", fetchOptions: { onError: (ctx) => { toast.error(ctx.error.message); }, onSuccess: async () => { toast.success("Successfully signed up"); router.push(getCallbackURL(params)); }, }, }); }); }} > {loading ? ( <Loader2 size={16} className="animate-spin" /> ) : ( "Create an account" )} </Button> </div> </CardContent> <CardFooter> <div className="flex justify-center w-full border-t pt-4"> <p className="text-center text-xs text-neutral-500"> built with{" "} <Link href="https://better-auth.com" className="underline" target="_blank" > <span className="dark:text-white/70 cursor-pointer"> better-auth. </span> </Link> </p> </div> </CardFooter> </Card> ); } async function convertImageToBase64(file: File): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(file); }); } ``` -------------------------------------------------------------------------------- /demo/nextjs/app/dashboard/change-plan.tsx: -------------------------------------------------------------------------------- ```typescript import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { client } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { ArrowUpFromLine, CreditCard, RefreshCcw } from "lucide-react"; import { useId, useState } from "react"; import { toast } from "sonner"; function Component(props: { currentPlan?: string; isTrial?: boolean }) { const [selectedPlan, setSelectedPlan] = useState("plus"); const id = useId(); return ( <Dialog> <DialogTrigger asChild> <Button variant={!props.currentPlan ? "default" : "outline"} size="sm" className={cn( "gap-2", !props.currentPlan && " bg-linear-to-br from-purple-100 to-stone-300", )} > {props.currentPlan ? ( <RefreshCcw className="opacity-80" size={14} strokeWidth={2} /> ) : ( <ArrowUpFromLine className="opacity-80" size={14} strokeWidth={2} /> )} {props.currentPlan ? "Change Plan" : "Upgrade Plan"} </Button> </DialogTrigger> <DialogContent> <div className="mb-2 flex flex-col gap-2"> <div className="flex size-11 shrink-0 items-center justify-center rounded-full border border-border" aria-hidden="true" > {props.currentPlan ? ( <RefreshCcw className="opacity-80" size={16} strokeWidth={2} /> ) : ( <CreditCard className="opacity-80" size={16} strokeWidth={2} /> )} </div> <DialogHeader> <DialogTitle className="text-left"> {!props.currentPlan ? "Upgrade" : "Change"} your plan </DialogTitle> <DialogDescription className="text-left"> Pick one of the following plans. </DialogDescription> </DialogHeader> </div> <form className="space-y-5"> <RadioGroup className="gap-2" defaultValue="2" value={selectedPlan} onValueChange={(value) => setSelectedPlan(value)} > <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> <RadioGroupItem value="plus" id={`${id}-1`} aria-describedby={`${id}-1-description`} className="order-1 after:absolute after:inset-0" /> <div className="grid grow gap-1"> <Label htmlFor={`${id}-1`}>Plus</Label> <p id={`${id}-1-description`} className="text-xs text-muted-foreground" > $20/month </p> </div> </div> <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> <RadioGroupItem value="pro" id={`${id}-2`} aria-describedby={`${id}-2-description`} className="order-1 after:absolute after:inset-0" /> <div className="grid grow gap-1"> <Label htmlFor={`${id}-2`}>Pro</Label> <p id={`${id}-2-description`} className="text-xs text-muted-foreground" > $200/month </p> </div> </div> <div className="relative flex w-full items-center gap-2 rounded-lg border border-input px-4 py-3 shadow-sm shadow-black/5 has-data-[state=checked]:border-ring has-data-[state=checked]:bg-accent"> <RadioGroupItem value="enterprise" id={`${id}-3`} aria-describedby={`${id}-3-description`} className="order-1 after:absolute after:inset-0" /> <div className="grid grow gap-1"> <Label htmlFor={`${id}-3`}>Enterprise</Label> <p id={`${id}-3-description`} className="text-xs text-muted-foreground" > Contact our sales team </p> </div> </div> </RadioGroup> <div className="space-y-3"> <p className="text-xs text-white/70 text-center"> note: all upgrades takes effect immediately and you'll be charged the new amount on your next billing cycle. </p> </div> <div className="grid gap-2"> <Button type="button" className="w-full" disabled={ selectedPlan === props.currentPlan?.toLowerCase() && !props.isTrial } onClick={async () => { if (selectedPlan === "enterprise") { return; } await client.subscription.upgrade( { plan: selectedPlan, }, { onError: (ctx) => { toast.error(ctx.error.message); }, }, ); }} > {selectedPlan === props.currentPlan?.toLowerCase() ? props.isTrial ? "Upgrade" : "Current Plan" : selectedPlan === "plus" ? !props.currentPlan ? "Upgrade" : "Downgrade" : selectedPlan === "pro" ? "Upgrade" : "Contact us"} </Button> {props.currentPlan && ( <Button type="button" variant="destructive" className="w-full" onClick={async () => { await client.subscription.cancel( { returnUrl: "/dashboard", }, { onError: (ctx) => { toast.error(ctx.error.message); }, }, ); }} > Cancel Plan </Button> )} </div> </form> </DialogContent> </Dialog> ); } export { Component }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/oauth2/link-account.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { getTestInstance } from "../test-utils/test-instance"; import type { GoogleProfile } from "@better-auth/core/social-providers"; import { DEFAULT_SECRET } from "../utils/constants"; import { signJWT } from "../crypto"; import type { User } from "../types"; let mockEmail = ""; let mockEmailVerified = true; const server = setupServer(); beforeAll(() => { server.listen({ onUnhandledRequest: "bypass" }); }); afterEach(() => { server.resetHandlers(); }); afterAll(() => server.close()); describe("oauth2 - email verification on link", async () => { const { auth, client, cookieSetter } = await getTestInstance({ socialProviders: { google: { clientId: "test", clientSecret: "test", enabled: true, }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, }, account: { accountLinking: { enabled: true, trustedProviders: ["google"], }, }, }); const ctx = await auth.$context; async function linkGoogleAccount() { server.use( http.post("https://oauth2.googleapis.com/token", async () => { const profile: GoogleProfile = { email: mockEmail, email_verified: mockEmailVerified, name: "Test User", picture: "https://example.com/photo.jpg", exp: 1234567890, sub: "google_oauth_sub_1234567890", iat: 1234567890, aud: "test", azp: "test", nbf: 1234567890, iss: "test", locale: "en", jti: "test", given_name: "Test", family_name: "User", }; const idToken = await signJWT(profile, DEFAULT_SECRET); return HttpResponse.json({ access_token: "test_access_token", refresh_token: "test_refresh_token", id_token: idToken, }); }), ); const oAuthHeaders = new Headers(); const signInRes = await client.signIn.social({ provider: "google", callbackURL: "/", fetchOptions: { onSuccess: cookieSetter(oAuthHeaders), }, }); const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; await client.$fetch("/callback/google", { query: { state, code: "test_code" }, method: "GET", headers: oAuthHeaders, onError(context) { expect(context.response.status).toBe(302); }, }); } it("should update emailVerified when linking account with verified email", async () => { const testEmail = "[email protected]"; // Create user with unverified email mockEmail = testEmail; mockEmailVerified = false; const signUpRes = await client.signUp.email({ email: testEmail, password: "password123", name: "Test User", }); const userId = signUpRes.data!.user.id; // Verify initial state let user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(false); // Link with Google account that has verified email mockEmailVerified = true; await linkGoogleAccount(); // Verify email is now verified user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(true); }); it("should not update emailVerified when provider reports unverified", async () => { const testEmail = "[email protected]"; // Create user with unverified email mockEmail = testEmail; mockEmailVerified = false; const signUpRes = await client.signUp.email({ email: testEmail, password: "password123", name: "Unverified User", }); const userId = signUpRes.data!.user.id; // Link Google account with unverified email from provider await linkGoogleAccount(); // Verify email remains unverified const user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(false); }); it("should not update emailVerified when email addresses don't match", async () => { const userEmail = "[email protected]"; const googleEmail = "[email protected]"; // Create user with one email mockEmail = userEmail; mockEmailVerified = false; const signUpRes = await client.signUp.email({ email: userEmail, password: "password123", name: "Test User", }); const userId = signUpRes.data!.user.id; // Verify initial state let user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(false); // Try to link with Google using different email (verified) mockEmail = googleEmail; mockEmailVerified = true; await linkGoogleAccount(); // Verify emailVerified remains false (emails don't match) user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(false); }); it("should handle already verified emails gracefully", async () => { const testEmail = "[email protected]"; // Create user with verified email mockEmail = testEmail; mockEmailVerified = true; const signUpRes = await client.signUp.email({ email: testEmail, password: "password123", name: "Verified User", }); const userId = signUpRes.data!.user.id; // Manually set emailVerified to true await ctx.adapter.update({ model: "user", where: [{ field: "id", value: userId }], update: { emailVerified: true }, }); // Link with Google account (also verified) await linkGoogleAccount(); // Verify email remains verified const user = await ctx.adapter.findOne<User>({ model: "user", where: [{ field: "id", value: userId }], }); expect(user?.emailVerified).toBe(true); }); }); ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/api-key/routes/list-api-keys.ts: -------------------------------------------------------------------------------- ```typescript import { sessionMiddleware } from "../../../api"; import { createAuthEndpoint } from "@better-auth/core/api"; import type { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import { API_KEY_TABLE_NAME } from ".."; import type { AuthContext } from "@better-auth/core"; export function listApiKeys({ opts, schema, deleteAllExpiredApiKeys, }: { opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; deleteAllExpiredApiKeys( ctx: AuthContext, byPassLastCheckTime?: boolean, ): void; }) { return createAuthEndpoint( "/api-key/list", { method: "GET", use: [sessionMiddleware], metadata: { openapi: { description: "List all API keys for the authenticated user", responses: { "200": { description: "API keys retrieved successfully", content: { "application/json": { schema: { type: "array", items: { type: "object", properties: { id: { type: "string", description: "ID", }, name: { type: "string", nullable: true, description: "The name of the key", }, start: { type: "string", nullable: true, description: "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.", }, prefix: { type: "string", nullable: true, description: "The API Key prefix. Stored as plain text.", }, userId: { type: "string", description: "The owner of the user id", }, refillInterval: { type: "number", nullable: true, description: "The interval in milliseconds between refills of the `remaining` count. Example: 3600000 // refill every hour (3600000ms = 1h)", }, refillAmount: { type: "number", nullable: true, description: "The amount to refill", }, lastRefillAt: { type: "string", format: "date-time", nullable: true, description: "The last refill date", }, enabled: { type: "boolean", description: "Sets if key is enabled or disabled", default: true, }, rateLimitEnabled: { type: "boolean", description: "Whether the key has rate limiting enabled", }, rateLimitTimeWindow: { type: "number", nullable: true, description: "The duration in milliseconds", }, rateLimitMax: { type: "number", nullable: true, description: "Maximum amount of requests allowed within a window", }, requestCount: { type: "number", description: "The number of requests made within the rate limit time window", }, remaining: { type: "number", nullable: true, description: "Remaining requests (every time api key is used this should updated and should be updated on refill as well)", }, lastRequest: { type: "string", format: "date-time", nullable: true, description: "When last request occurred", }, expiresAt: { type: "string", format: "date-time", nullable: true, description: "Expiry date of a key", }, createdAt: { type: "string", format: "date-time", description: "created at", }, updatedAt: { type: "string", format: "date-time", description: "updated at", }, metadata: { type: "object", nullable: true, additionalProperties: true, description: "Extra metadata about the apiKey", }, permissions: { type: "string", nullable: true, description: "Permissions for the api key (stored as JSON string)", }, }, required: [ "id", "userId", "enabled", "rateLimitEnabled", "requestCount", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, }, async (ctx) => { const session = ctx.context.session; let apiKeys = await ctx.context.adapter.findMany<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "userId", value: session.user.id, }, ], }); deleteAllExpiredApiKeys(ctx.context); apiKeys = apiKeys.map((apiKey) => { return { ...apiKey, metadata: schema.apikey.fields.metadata.transform.output( apiKey.metadata as never as string, ), }; }); let returningApiKey = apiKeys.map((x) => { const { key, ...returningApiKey } = x; return { ...returningApiKey, permissions: returningApiKey.permissions ? safeJSONParse<{ [key: string]: string[]; }>(returningApiKey.permissions) : null, }; }); return ctx.json(returningApiKey); }, ); } ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/lynx.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Lynx Integration description: Integrate Better Auth with Lynx cross-platform framework. --- 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. 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). ## Installation Install Better Auth and the Lynx React dependency: ```package-install better-auth @lynx-js/react ``` ## Create Client Instance Import `createAuthClient` from `better-auth/lynx` to create your client instance: ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/lynx" export const authClient = createAuthClient({ baseURL: "http://localhost:3000" // The base URL of your auth server }) ``` ## Usage The Lynx client provides the same API as other Better Auth clients, with optimized integration for Lynx's reactive system. ### Authentication Methods ```ts import { authClient } from "./lib/auth-client" // Sign in with email and password await authClient.signIn.email({ email: "[email protected]", password: "password1234" }) // Sign up await authClient.signUp.email({ email: "[email protected]", password: "password1234", name: "John Doe" }) // Sign out await authClient.signOut() ``` ### Hooks The Lynx client includes reactive hooks that integrate seamlessly with Lynx's component system: #### useSession ```tsx title="components/user.tsx" import { authClient } from "../lib/auth-client" export function User() { const { data: session, isPending, // loading state error // error object } = authClient.useSession() if (isPending) return <div>Loading...</div> if (error) return <div>Error: {error.message}</div> return ( <div> {session ? ( <div> <p>Welcome, {session.user.name}!</p> <button onClick={() => authClient.signOut()}> Sign Out </button> </div> ) : ( <button onClick={() => authClient.signIn.social({ provider: 'github' })}> Sign In with GitHub </button> )} </div> ) } ``` ### Store Integration The Lynx client uses [nanostores](https://github.com/nanostores/nanostores) for state management and provides a `useStore` hook for accessing reactive state: ```tsx title="components/session-info.tsx" import { useStore } from "better-auth/lynx" import { authClient } from "../lib/auth-client" export function SessionInfo() { // Access the session store directly const session = useStore(authClient.$store.session) return ( <div> {session && ( <pre>{JSON.stringify(session, null, 2)}</pre> )} </div> ) } ``` ### Advanced Store Usage You can use the store with selective key watching for optimized re-renders: ```tsx title="components/optimized-user.tsx" import { useStore } from "better-auth/lynx" import { authClient } from "../lib/auth-client" export function OptimizedUser() { // Only re-render when specific keys change const session = useStore(authClient.$store.session, { keys: ['user.name', 'user.email'] // Only watch these specific keys }) return ( <div> {session?.user && ( <div> <h2>{session.user.name}</h2> <p>{session.user.email}</p> </div> )} </div> ) } ``` ## Plugin Support The Lynx client supports all Better Auth plugins: ```ts title="lib/auth-client.ts" import { createAuthClient } from "better-auth/lynx" import { magicLinkClient } from "better-auth/client/plugins" const authClient = createAuthClient({ plugins: [ magicLinkClient() ] }) // Use plugin methods await authClient.signIn.magicLink({ email: "[email protected]" }) ``` ## Error Handling Error handling works the same as other Better Auth clients: ```tsx title="components/login-form.tsx" import { authClient } from "../lib/auth-client" export function LoginForm() { const signIn = async (email: string, password: string) => { const { data, error } = await authClient.signIn.email({ email, password }) if (error) { console.error('Login failed:', error.message) return } console.log('Login successful:', data) } return ( <form onSubmit={(e) => { e.preventDefault() const formData = new FormData(e.target) signIn(formData.get('email'), formData.get('password')) }}> <input name="email" type="email" placeholder="Email" /> <input name="password" type="password" placeholder="Password" /> <button type="submit">Sign In</button> </form> ) } ``` ## Features The Lynx client provides: - **Cross-Platform Support**: Works across Android, iOS, and Web platforms - **Optimized Performance**: Built specifically for Lynx's reactive system - **Nanostores Integration**: Uses nanostores for efficient state management - **Selective Re-rendering**: Watch specific store keys to minimize unnecessary updates - **Full API Compatibility**: All Better Auth methods and plugins work seamlessly - **TypeScript Support**: Full type safety with TypeScript inference 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 import * as z from "zod"; import { APIError, sessionMiddleware } from "../../../api"; import { createAuthEndpoint } from "@better-auth/core/api"; import { API_KEY_TABLE_NAME, ERROR_CODES } from ".."; import type { apiKeySchema } from "../schema"; import type { ApiKey } from "../types"; import type { PredefinedApiKeyOptions } from "."; import { safeJSONParse } from "../../../utils/json"; import type { AuthContext } from "@better-auth/core"; export function getApiKey({ opts, schema, deleteAllExpiredApiKeys, }: { opts: PredefinedApiKeyOptions; schema: ReturnType<typeof apiKeySchema>; deleteAllExpiredApiKeys( ctx: AuthContext, byPassLastCheckTime?: boolean, ): void; }) { return createAuthEndpoint( "/api-key/get", { method: "GET", query: z.object({ id: z.string().meta({ description: "The id of the Api Key", }), }), use: [sessionMiddleware], metadata: { openapi: { description: "Retrieve an existing API key by ID", responses: { "200": { description: "API key retrieved successfully", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string", description: "ID", }, name: { type: "string", nullable: true, description: "The name of the key", }, start: { type: "string", nullable: true, description: "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.", }, prefix: { type: "string", nullable: true, description: "The API Key prefix. Stored as plain text.", }, userId: { type: "string", description: "The owner of the user id", }, refillInterval: { type: "number", nullable: true, description: "The interval in milliseconds between refills of the `remaining` count. Example: 3600000 // refill every hour (3600000ms = 1h)", }, refillAmount: { type: "number", nullable: true, description: "The amount to refill", }, lastRefillAt: { type: "string", format: "date-time", nullable: true, description: "The last refill date", }, enabled: { type: "boolean", description: "Sets if key is enabled or disabled", default: true, }, rateLimitEnabled: { type: "boolean", description: "Whether the key has rate limiting enabled", }, rateLimitTimeWindow: { type: "number", nullable: true, description: "The duration in milliseconds", }, rateLimitMax: { type: "number", nullable: true, description: "Maximum amount of requests allowed within a window", }, requestCount: { type: "number", description: "The number of requests made within the rate limit time window", }, remaining: { type: "number", nullable: true, description: "Remaining requests (every time api key is used this should updated and should be updated on refill as well)", }, lastRequest: { type: "string", format: "date-time", nullable: true, description: "When last request occurred", }, expiresAt: { type: "string", format: "date-time", nullable: true, description: "Expiry date of a key", }, createdAt: { type: "string", format: "date-time", description: "created at", }, updatedAt: { type: "string", format: "date-time", description: "updated at", }, metadata: { type: "object", nullable: true, additionalProperties: true, description: "Extra metadata about the apiKey", }, permissions: { type: "string", nullable: true, description: "Permissions for the api key (stored as JSON string)", }, }, required: [ "id", "userId", "enabled", "rateLimitEnabled", "requestCount", "createdAt", "updatedAt", ], }, }, }, }, }, }, }, }, async (ctx) => { const { id } = ctx.query; const session = ctx.context.session; let apiKey = await ctx.context.adapter.findOne<ApiKey>({ model: API_KEY_TABLE_NAME, where: [ { field: "id", value: id, }, { field: "userId", value: session.user.id, }, ], }); if (!apiKey) { throw new APIError("NOT_FOUND", { message: ERROR_CODES.KEY_NOT_FOUND, }); } deleteAllExpiredApiKeys(ctx.context); // convert metadata string back to object apiKey.metadata = schema.apikey.fields.metadata.transform.output( apiKey.metadata as never as string, ); const { key, ...returningApiKey } = apiKey; return ctx.json({ ...returningApiKey, permissions: returningApiKey.permissions ? safeJSONParse<{ [key: string]: string[]; }>(returningApiKey.permissions) : null, }); }, ); } ``` -------------------------------------------------------------------------------- /docs/content/docs/integrations/nitro.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Nitro Integration description: Integrate Better Auth with Nitro. --- Better Auth can be integrated with your [Nitro Application](https://nitro.build/) (an open source framework to build web servers). This guide aims to help you integrate Better Auth with your Nitro application in a few simple steps. ## Create a new Nitro Application Start by scaffolding a new Nitro application using the following command: ```bash title="Terminal" npx giget@latest nitro nitro-app --install ``` This will create the `nitro-app` directory and install all the dependencies. You can now open the `nitro-app` directory in your code editor. ### Prisma Adapter Setup <Callout> 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). 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`. </Callout> For this guide, we will be using the Prisma adapter. You can install prisma client by running the following command: ```package-install @prisma/client ``` `prisma` can be installed as a dev dependency using the following command: ```package-install -D prisma ``` Generate a `schema.prisma` file in the `prisma` directory by running the following command: ```bash title="Terminal" npx prisma init ``` You can now replace the contents of the `schema.prisma` file with the following: ```prisma title="prisma/schema.prisma" generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } // Will be deleted. Just need it to generate the prisma client model Test { id Int @id @default(autoincrement()) name String } ``` Ensure that you update the `DATABASE_URL` in your `.env` file to point to the location of your database. ```txt title=".env" DATABASE_URL="file:./dev.db" ``` Run the following command to generate the Prisma client & sync the database: ```bash title="Terminal" npx prisma db push ``` ### Install & Configure Better Auth Follow steps 1 & 2 from the [installation guide](/docs/installation) to install Better Auth in your Nitro application & set up the environment variables. Once that is done, create your Better Auth instance within the `server/utils/auth.ts` file. ```ts title="server/utils/auth.ts" import { betterAuth } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: "sqlite" }), emailAndPassword: { enabled: true }, }); ``` ### Update Prisma Schema Use the Better Auth CLI to update your Prisma schema with the required models by running the following command: ```bash title="Terminal" npx @better-auth/cli generate --config server/utils/auth.ts ``` <Callout> The `--config` flag is used to specify the path to the file where you have created your Better Auth instance. </Callout> Head over to the `prisma/schema.prisma` file & save the file to trigger the format on save. After saving the file, you can run the `npx prisma db push` command to update the database schema. ## Mount The Handler 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: ```ts title="server/routes/api/auth/[...all].ts" export default defineEventHandler((event) => { return auth.handler(toWebRequest(event)); }); ``` <Callout> This is a [catch-all](https://nitro.build/guide/routing#catch-all-route) route that will handle all requests to `/api/auth/*`. </Callout> ### CORS You can configure CORS for your Nitro app by creating a plugin. Start by installing the cors package: ```package-install cors ``` You can now create a new file `server/plugins/cors.ts` and add the following code: ```ts title="server/plugins/cors.ts" import cors from "cors"; export default defineNitroPlugin((plugin) => { plugin.h3App.use( fromNodeMiddleware( cors({ origin: "*", }), ), ); }); ``` <Callout> 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. </Callout> ### Auth Guard/Middleware 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: ```ts title="server/utils/require-auth.ts" import { EventHandler, H3Event } from "h3"; import { fromNodeHeaders } from "better-auth/node"; /** * Middleware used to require authentication for a route. * * Can be extended to check for specific roles or permissions. */ export const requireAuth: EventHandler = async (event: H3Event) => { const headers = event.headers; const session = await auth.api.getSession({ headers: headers, }); if (!session) throw createError({ statusCode: 401, statusMessage: "Unauthorized", }); // You can save the session to the event context for later use event.context.auth = session; }; ``` You can now use this event handler/middleware in your routes to protect them: ```ts title="server/routes/api/secret.get.ts" // Object syntax of the route handler export default defineEventHandler({ // The user has to be logged in to access this route onRequest: [requireAuth], handler: async (event) => { setResponseStatus(event, 201, "Secret data"); return { message: "Secret data" }; }, }); ``` ### Example You can find an example of a Nitro application integrated with Better Auth & Prisma [here](https://github.com/BayBreezy/nitrojs-better-auth-prisma). ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/twitter.ts: -------------------------------------------------------------------------------- ```typescript import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, } from "../oauth2"; export interface TwitterProfile { data: { /** * Unique identifier of this user. This is returned as a string in order to avoid complications with languages and tools * that cannot handle large integers. */ id: string; /** The friendly name of this user, as shown on their profile. */ name: string; /** The email address of this user. */ email?: string; /** The Twitter handle (screen name) of this user. */ username: string; /** * The location specified in the user's profile, if the user provided one. * 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. * * To return this field, add `user.fields=location` in the authorization request's query parameter. */ location?: string; /** * This object and its children fields contain details about text that has a special meaning in the user's description. * *To return this field, add `user.fields=entities` in the authorization request's query parameter. */ entities?: { /** Contains details about the user's profile website. */ url: { /** Contains details about the user's profile website. */ urls: Array<{ /** The start position (zero-based) of the recognized user's profile website. All start indices are inclusive. */ start: number; /** The end position (zero-based) of the recognized user's profile website. This end index is exclusive. */ end: number; /** The URL in the format entered by the user. */ url: string; /** The fully resolved URL. */ expanded_url: string; /** The URL as displayed in the user's profile. */ display_url: string; }>; }; /** Contains details about URLs, Hashtags, Cashtags, or mentions located within a user's description. */ description: { hashtags: Array<{ start: number; end: number; tag: string; }>; }; }; /** * Indicate if this user is a verified Twitter user. * * To return this field, add `user.fields=verified` in the authorization request's query parameter. */ verified?: boolean; /** * The text of this user's profile description (also known as bio), if the user provided one. * * To return this field, add `user.fields=description` in the authorization request's query parameter. */ description?: string; /** * The URL specified in the user's profile, if present. * * To return this field, add `user.fields=url` in the authorization request's query parameter. */ url?: string; /** The URL to the profile image for this user, as shown on the user's profile. */ profile_image_url?: string; protected?: boolean; /** * Unique identifier of this user's pinned Tweet. * * You can obtain the expanded object in `includes.tweets` by adding `expansions=pinned_tweet_id` in the authorization request's query parameter. */ pinned_tweet_id?: string; created_at?: string; }; includes?: { tweets?: Array<{ id: string; text: string; }>; }; [claims: string]: unknown; } export interface TwitterOption extends ProviderOptions<TwitterProfile> { clientId: string; } export const twitter = (options: TwitterOption) => { return { id: "twitter", name: "Twitter", createAuthorizationURL(data) { const _scopes = options.disableDefaultScope ? [] : ["users.read", "tweet.read", "offline.access", "users.email"]; options.scope && _scopes.push(...options.scope); data.scopes && _scopes.push(...data.scopes); return createAuthorizationURL({ id: "twitter", options, authorizationEndpoint: "https://x.com/i/oauth2/authorize", scopes: _scopes, state: data.state, codeVerifier: data.codeVerifier, redirectURI: data.redirectURI, }); }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { return validateAuthorizationCode({ code, codeVerifier, authentication: "basic", redirectURI, options, tokenEndpoint: "https://api.x.com/2/oauth2/token", }); }, refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => { return refreshAccessToken({ refreshToken, options: { clientId: options.clientId, clientKey: options.clientKey, clientSecret: options.clientSecret, }, authentication: "basic", tokenEndpoint: "https://api.x.com/2/oauth2/token", }); }, async getUserInfo(token) { if (options.getUserInfo) { return options.getUserInfo(token); } const { data: profile, error: profileError } = await betterFetch<TwitterProfile>( "https://api.x.com/2/users/me?user.fields=profile_image_url", { method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, }, }, ); if (profileError) { return null; } const { data: emailData, error: emailError } = await betterFetch<{ data: { confirmed_email: string }; }>("https://api.x.com/2/users/me?user.fields=confirmed_email", { method: "GET", headers: { Authorization: `Bearer ${token.accessToken}`, }, }); let emailVerified = false; if (!emailError && emailData?.data?.confirmed_email) { profile.data.email = emailData.data.confirmed_email; emailVerified = true; } const userMap = await options.mapProfileToUser?.(profile); return { user: { id: profile.data.id, name: profile.data.name, email: profile.data.email || profile.data.username || null, image: profile.data.profile_image_url, emailVerified: emailVerified, ...userMap, }, data: profile, }; }, options, } satisfies OAuthProvider<TwitterProfile>; }; ``` -------------------------------------------------------------------------------- /docs/components/nav-mobile.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { ChevronRight, Menu } from "lucide-react"; import Link from "next/link"; import { Fragment, createContext, useContext, useState } from "react"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { contents, examples } from "./sidebar-content"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; interface NavbarMobileContextProps { isOpen: boolean; toggleNavbar: () => void; isDocsOpen: boolean; toggleDocsNavbar: () => void; } const NavbarContext = createContext<NavbarMobileContextProps | undefined>( undefined, ); export const NavbarProvider = ({ children }: { children: React.ReactNode }) => { const [isOpen, setIsOpen] = useState(false); const [isDocsOpen, setIsDocsOpen] = useState(false); const toggleNavbar = () => { setIsOpen((prevIsOpen) => !prevIsOpen); }; const toggleDocsNavbar = () => { setIsDocsOpen((prevIsOpen) => !prevIsOpen); }; return ( <NavbarContext.Provider value={{ isOpen, toggleNavbar, isDocsOpen, toggleDocsNavbar }} > {children} </NavbarContext.Provider> ); }; export const useNavbarMobile = (): NavbarMobileContextProps => { const context = useContext(NavbarContext); if (!context) { throw new Error( "useNavbarMobile must be used within a NavbarMobileProvider", ); } return context; }; export const NavbarMobileBtn: React.FC = () => { const { toggleNavbar } = useNavbarMobile(); return ( <div className="flex items-center"> <button className="overflow-hidden px-2.5 block md:hidden" onClick={() => { toggleNavbar(); }} > <Menu className="size-5" /> </button> </div> ); }; export const NavbarMobile = () => { const { isOpen, toggleNavbar } = useNavbarMobile(); const pathname = usePathname(); const isDocs = pathname.startsWith("/docs"); return ( <div className={cn( "fixed top-[50px] inset-x-0 transform-gpu z-[100] bg-background grid grid-rows-[0fr] duration-300 transition-all md:hidden", isOpen && "shadow-lg border-b border-[rgba(255,255,255,.1)] grid-rows-[1fr]", )} > <div className={cn( "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", isOpen ? "py-5" : "invisible", isDocs && "px-4", )} > {navMenu.map((menu) => ( <Fragment key={menu.name}> {menu.child ? ( <Accordion type="single" collapsible> <AccordionItem value={menu.name}> <AccordionTrigger className={cn( "font-normal text-foreground", !isDocs && "text-2xl", )} > {menu.name} </AccordionTrigger> <AccordionContent className="pl-5 divide-y"> {menu.child.map((child, j) => ( <Link href={child.path} key={child.name} className={cn( "block py-2 border-b first:pt-0 last:pb-0 last:border-0 text-muted-foreground", !isDocs && "text-xl", )} onClick={toggleNavbar} > {child.name} </Link> ))} </AccordionContent> </AccordionItem> </Accordion> ) : ( <Link href={menu.path} className={cn( "group flex items-center gap-2.5 first:pt-0 last:pb-0 text-2xl py-4", isDocs && "text-base py-2", )} onClick={toggleNavbar} > {isDocs && ( <ChevronRight className="ml-0.5 size-4 text-muted-foreground md:hidden" /> )} {menu.name} </Link> )} </Fragment> ))} <DocsNavBarContent /> </div> </div> ); }; function DocsNavBarContent() { const pathname = usePathname(); const { toggleNavbar } = useNavbarMobile(); if (!pathname.startsWith("/docs")) return null; const content = pathname.startsWith("/docs/examples") ? examples : contents; return ( <> {content.map((menu) => ( <Accordion type="single" collapsible key={menu.title}> <AccordionItem value={menu.title}> <AccordionTrigger className="font-normal text-foreground"> <div className="flex items-center gap-2"> {!!menu.Icon && <menu.Icon className="w-5 h-5" />} {menu.title} </div> </AccordionTrigger> <AccordionContent className="pl-5 divide-y"> {menu.list.map((child, index) => child.group ? ( // Group header rendered as div (just a divider) <div key={child.title} className="block py-2 text-sm text-muted-foreground border-none select-none" > <div className="flex flex-row items-center gap-2"> <p className="text-sm text-primary">{child.title}</p> <div className="flex-grow h-px bg-border" /> </div> </div> ) : ( // Regular menu item rendered as Link <Link href={child.href} key={child.title} className={`block py-2 text-sm text-muted-foreground ${ // Add border only when not last item // and next item is not a group header index === menu.list.length - 1 || menu.list[index + 1]?.group ? "border-none" : "border-b" }`} onClick={toggleNavbar} > <div className="flex items-center gap-2"> <child.icon /> {child.title} </div> </Link> ), )} </AccordionContent> </AccordionItem> </Accordion> ))} </> ); } export const navMenu: { name: string; path: string; child?: { name: string; path: string; }[]; }[] = [ { name: "_helo", path: "/", }, { name: "docs", path: "/docs", }, { name: "examples", path: "/docs/examples/next-js", }, { name: "changelogs", path: "/changelogs", }, { name: "blogs", path: "/blog", }, { name: "community", path: "/community", }, ]; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/utils/wildcard.ts: -------------------------------------------------------------------------------- ```typescript //https://github.com/axtgr/wildcard-match /** * Escapes a character if it has a special meaning in regular expressions * and returns the character as is if it doesn't */ function escapeRegExpChar(char: string) { if ( char === "-" || char === "^" || char === "$" || char === "+" || char === "." || char === "(" || char === ")" || char === "|" || char === "[" || char === "]" || char === "{" || char === "}" || char === "*" || char === "?" || char === "\\" ) { return `\\${char}`; } else { return char; } } /** * Escapes all characters in a given string that have a special meaning in regular expressions */ function escapeRegExpString(str: string) { let result = ""; for (let i = 0; i < str.length; i++) { result += escapeRegExpChar(str[i]!); } return result; } /** * Transforms one or more glob patterns into a RegExp pattern */ function transform( pattern: string | string[], separator: string | boolean = true, ): string { if (Array.isArray(pattern)) { let regExpPatterns = pattern.map((p) => `^${transform(p, separator)}$`); return `(?:${regExpPatterns.join("|")})`; } let separatorSplitter = ""; let separatorMatcher = ""; let wildcard = "."; if (separator === true) { // In this case forward slashes in patterns match both forward and backslashes in samples: // // `foo/bar` will match `foo/bar` // will match `foo\bar` // separatorSplitter = "/"; separatorMatcher = "[/\\\\]"; wildcard = "[^/\\\\]"; } else if (separator) { separatorSplitter = separator; separatorMatcher = escapeRegExpString(separatorSplitter); if (separatorMatcher.length > 1) { separatorMatcher = `(?:${separatorMatcher})`; wildcard = `((?!${separatorMatcher}).)`; } else { wildcard = `[^${separatorMatcher}]`; } } // When a separator is explicitly specified in a pattern, // it MUST match ONE OR MORE separators in a sample: // // `foo/bar/` will match `foo//bar///` // won't match `foo/bar` // // When a pattern doesn't have a trailing separator, // a sample can still optionally have them: // // `foo/bar` will match `foo/bar//` // // So we use different quantifiers depending on the index of a segment. let requiredSeparator = separator ? `${separatorMatcher}+?` : ""; let optionalSeparator = separator ? `${separatorMatcher}*?` : ""; let segments = separator ? pattern.split(separatorSplitter) : [pattern]; let result = ""; for (let s = 0; s < segments.length; s++) { let segment = segments[s]!; let nextSegment = segments[s + 1]!; let currentSeparator = ""; if (!segment && s > 0) { continue; } if (separator) { if (s === segments.length - 1) { currentSeparator = optionalSeparator; } else if (nextSegment !== "**") { currentSeparator = requiredSeparator; } else { currentSeparator = ""; } } if (separator && segment === "**") { if (currentSeparator) { result += s === 0 ? "" : currentSeparator; result += `(?:${wildcard}*?${currentSeparator})*?`; } continue; } for (let c = 0; c < segment.length; c++) { let char = segment[c]!; if (char === "\\") { if (c < segment.length - 1) { result += escapeRegExpChar(segment[c + 1]!); c++; } } else if (char === "?") { result += wildcard; } else if (char === "*") { result += `${wildcard}*?`; } else { result += escapeRegExpChar(char); } } result += currentSeparator; } return result; } export default transform; interface WildcardMatchOptions { /** Separator to be used to split patterns and samples into segments */ separator?: string | boolean; /** Flags to pass to the RegExp */ flags?: string; } // This overrides the function's signature because for the end user // the function is always bound to a RegExp interface isMatch { /** * Tests if a sample string matches the pattern(s) * * ```js * isMatch('foo') //=> true * ``` */ (sample: string): boolean; /** Compiled regular expression */ regexp: RegExp; /** Original pattern or array of patterns that was used to compile the RegExp */ pattern: string | string[]; /** Options that were used to compile the RegExp */ options: WildcardMatchOptions; } function isMatch(regexp: RegExp, sample: string) { if (typeof sample !== "string") { throw new TypeError(`Sample must be a string, but ${typeof sample} given`); } return regexp.test(sample); } /** * Compiles one or more glob patterns into a RegExp and returns an isMatch function. * The isMatch function takes a sample string as its only argument and returns `true` * if the string matches the pattern(s). * * ```js * wildcardMatch('src/*.js')('src/index.js') //=> true * ``` * * ```js * const isMatch = wildcardMatch('*.example.com', '.') * isMatch('foo.example.com') //=> true * isMatch('foo.bar.com') //=> false * ``` */ function wildcardMatch( pattern: string | string[], options?: string | boolean | WildcardMatchOptions, ) { if (typeof pattern !== "string" && !Array.isArray(pattern)) { throw new TypeError( `The first argument must be a single pattern string or an array of patterns, but ${typeof pattern} given`, ); } if (typeof options === "string" || typeof options === "boolean") { options = { separator: options }; } if ( arguments.length === 2 && !( typeof options === "undefined" || (typeof options === "object" && options !== null && !Array.isArray(options)) ) ) { throw new TypeError( `The second argument must be an options object or a string/boolean separator, but ${typeof options} given`, ); } options = options || {}; if (options.separator === "\\") { throw new Error( "\\ is not a valid separator because it is used for escaping. Try setting the separator to `true` instead", ); } let regexpPattern = transform(pattern, options.separator); let regexp = new RegExp(`^${regexpPattern}$`, options.flags); let fn = isMatch.bind(null, regexp) as isMatch; fn.options = options; fn.pattern = pattern; fn.regexp = regexp; return fn; } export { wildcardMatch, isMatch }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/anonymous/anon.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, expect, it, vi, beforeAll, afterAll, afterEach, } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { anonymous } from "."; import { getTestInstance } from "../../test-utils/test-instance"; import { createAuthClient } from "../../client"; import { anonymousClient } from "./client"; import type { GoogleProfile } from "@better-auth/core/social-providers"; import { DEFAULT_SECRET } from "../../utils/constants"; import { signJWT } from "../../crypto"; let testIdToken: string; let handlers: ReturnType<typeof http.post>[]; const server = setupServer(); beforeAll(async () => { const data: GoogleProfile = { email: "[email protected]", email_verified: true, name: "First Last", picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", exp: 1234567890, sub: "1234567890", iat: 1234567890, aud: "test", azp: "test", nbf: 1234567890, iss: "test", locale: "en", jti: "test", given_name: "First", family_name: "Last", }; testIdToken = await signJWT(data, DEFAULT_SECRET); handlers = [ http.post("https://oauth2.googleapis.com/token", () => { return HttpResponse.json({ access_token: "test", refresh_token: "test", id_token: testIdToken, }); }), ]; server.listen({ onUnhandledRequest: "bypass" }); server.use(...handlers); }); afterEach(() => { server.resetHandlers(); server.use(...handlers); }); afterAll(() => server.close()); describe("anonymous", async () => { const linkAccountFn = vi.fn(); const { customFetchImpl, auth, sessionSetter, testUser, cookieSetter } = await getTestInstance({ plugins: [ anonymous({ async onLinkAccount(data) { linkAccountFn(data); }, schema: { user: { fields: { isAnonymous: "is_anon", }, }, }, }), ], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const headers = new Headers(); const client = createAuthClient({ plugins: [anonymousClient()], fetchOptions: { customFetchImpl, }, baseURL: "http://localhost:3000", }); it("should sign in anonymously", async () => { await client.signIn.anonymous({ fetchOptions: { onSuccess: sessionSetter(headers), }, }); const session = await client.getSession({ fetchOptions: { headers, }, }); expect(session.data?.session).toBeDefined(); expect(session.data?.user.isAnonymous).toBe(true); }); it("link anonymous user account", async () => { expect(linkAccountFn).toHaveBeenCalledTimes(0); const res = await client.signIn.email(testUser, { headers, }); expect(linkAccountFn).toHaveBeenCalledWith(expect.any(Object)); linkAccountFn.mockClear(); }); it("should link in social sign on", async () => { const headers = new Headers(); await client.signIn.anonymous({ fetchOptions: { onSuccess: sessionSetter(headers), }, }); await client.getSession({ fetchOptions: { headers, }, }); const singInRes = await client.signIn.social({ provider: "google", callbackURL: "/dashboard", fetchOptions: { onSuccess: cookieSetter(headers), }, }); const state = new URL(singInRes.data?.url || "").searchParams.get("state"); await client.$fetch("/callback/google", { query: { state, code: "test", }, headers, }); expect(linkAccountFn).toHaveBeenCalledWith(expect.any(Object)); }); it("should work with generateName", async () => { const { customFetchImpl, sessionSetter } = await getTestInstance({ plugins: [ anonymous({ generateName() { return "i-am-anonymous"; }, }), ], }); const client = createAuthClient({ plugins: [anonymousClient()], fetchOptions: { customFetchImpl, }, baseURL: "http://localhost:3000", }); const res = await client.signIn.anonymous({ fetchOptions: { onSuccess: sessionSetter(headers), }, }); expect(res.data?.user.name).toBe("i-am-anonymous"); }); it("should not reject first-time anonymous sign-in", async () => { const { customFetchImpl, sessionSetter } = await getTestInstance({ plugins: [anonymous()], }); const client = createAuthClient({ plugins: [anonymousClient()], fetchOptions: { customFetchImpl, }, baseURL: "http://localhost:3000", }); const freshHeaders = new Headers(); // First-time anonymous sign-in should succeed without 400 error const res = await client.signIn.anonymous({ fetchOptions: { onSuccess: sessionSetter(freshHeaders), }, }); expect(res.data?.user).toBeDefined(); expect(res.error).toBeNull(); // Verify session is actually created and contains isAnonymous const session = await client.getSession({ fetchOptions: { headers: freshHeaders, }, }); expect(session.data?.session).toBeDefined(); expect(session.data?.user.isAnonymous).toBe(true); }); it("should reject subsequent anonymous sign-in attempts once signed in", async () => { const { customFetchImpl, sessionSetter } = await getTestInstance({ plugins: [anonymous()], }); const client = createAuthClient({ plugins: [anonymousClient()], fetchOptions: { customFetchImpl, }, baseURL: "http://localhost:3000", }); const persistentHeaders = new Headers(); // First sign-in should succeed await client.signIn.anonymous({ fetchOptions: { headers: persistentHeaders, onSuccess: sessionSetter(persistentHeaders), }, }); // Verify session is established before testing rejection const session = await client.getSession({ fetchOptions: { headers: persistentHeaders, }, }); expect(session.data?.session).toBeDefined(); expect(session.data?.user.isAnonymous).toBe(true); // Second attempt should be rejected at the endpoint level const secondAttempt = await client.signIn.anonymous({ fetchOptions: { headers: persistentHeaders, }, }); expect(secondAttempt.data).toBeNull(); expect(secondAttempt.error).toBeDefined(); expect(secondAttempt.error?.message).toBe( "Anonymous users cannot sign in again anonymously", ); }); }); ``` -------------------------------------------------------------------------------- /docs/app/api/og/route.tsx: -------------------------------------------------------------------------------- ```typescript import { ImageResponse } from "@vercel/og"; import { z } from "zod"; export const runtime = "edge"; const ogSchema = z.object({ heading: z.string(), mode: z.string(), type: z.string(), }); export async function GET(req: Request) { try { const geist = await fetch( new URL("../../../assets/Geist.ttf", import.meta.url), ).then((res) => res.arrayBuffer()); const geistMono = await fetch( new URL("../../../assets/GeistMono.ttf", import.meta.url), ).then((res) => res.arrayBuffer()); const url = new URL(req.url); const urlParamsValues = Object.fromEntries(url.searchParams); const validParams = ogSchema.parse(urlParamsValues); const { heading, type } = validParams; const trueHeading = heading.length > 140 ? `${heading.substring(0, 140)}...` : heading; const paint = "#fff"; const fontSize = trueHeading.length > 100 ? "30px" : "60px"; return new ImageResponse( <div tw="flex w-full relative flex-col p-12" style={{ color: paint, backgroundColor: "transparent", border: "1px solid rgba(255, 255, 255, 0.1)", boxShadow: "0 -20px 80px -20px rgba(28, 12, 12, 0.1) inset", background: "#0a0505", }} > <div tw={`relative flex flex-col w-full h-full border-2 border-[${paint}]/20 p-10}`} > <svg style={{ position: "absolute", top: "-9px", right: "-9px", }} width="17" height="17" fill="none" > <path 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" fill="#d0cfd1d3" /> </svg> <svg style={{ position: "absolute", top: "-9px", left: "-9px", }} width="17" height="17" fill="none" > <path 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" fill="#cacaca" /> </svg> <svg style={{ position: "absolute", bottom: "-9px", left: "-9px", }} width="17" height="17" fill="none" > <path 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" fill="#cacaca" /> </svg> <svg style={{ position: "absolute", bottom: "-9px", right: "-9px", }} width="17" height="17" fill="none" > <path 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" fill="#cacaca" /> </svg> <div tw="flex flex-col flex-1 py-10"> <svg width="100" height="95" viewBox="0 0 60 45" fill="none" className="mb-10" xmlns="http://www.w3.org/2000/svg" > <path fillRule="evenodd" stroke={paint} clipRule="evenodd" d="M0 0H15V15H30V30H15V45H0V30V15V0ZM45 30V15H30V0H45H60V15V30V45H45H30V30H45Z" fill="white" /> </svg> <div style={{ fontFamily: "GeistMono", fontWeight: "normal" }} tw="relative flex mt-10 text-xl uppercase font-bold gap-2 items-center" > {type === "documentation" ? ( <svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 24 24" > <path fill="currentColor" fillRule="evenodd" 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" clipRule="evenodd" ></path> </svg> ) : null} {type} </div> <div tw="flex max-w-[70%] mt-5 tracking-tighter leading-[1.1] text-[30px] font-bold" style={{ fontWeight: "bold", marginLeft: "-3px", fontSize, fontFamily: "GeistMono", }} > {trueHeading} </div> </div> <div tw="flex items-center w-full justify-between"> <div tw="flex text-xl" style={{ fontFamily: "GeistSans", fontWeight: "semibold" }} > Better Auth. </div> <div tw="flex gap-2 items-center text-xl"> <svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 24 24" > <path fill="currentColor" 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" ></path> </svg> <span style={{ fontFamily: "GeistSans", }} tw="flex ml-2" > github.com/better-auth/better-auth </span> </div> </div> </div> </div>, { width: 1200, height: 630, fonts: [ { name: "Geist", data: geist, weight: 400, style: "normal", }, { name: "GeistMono", data: geistMono, weight: 700, style: "normal", }, ], }, ); } catch (err) { console.log({ err }); return new Response("Failed to generate the og image", { status: 500 }); } } ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/test-adapter.ts: -------------------------------------------------------------------------------- ```typescript import { afterAll, beforeAll, describe } from "vitest"; import type { BetterAuthOptions } from "@better-auth/core"; import type { DBAdapter } from "@better-auth/core/db/adapter"; import { getAuthTables } from "../db"; import type { createTestSuite } from "./create-test-suite"; import { TTY_COLORS } from "@better-auth/core/env"; import { deepmerge } from "./utils"; export type Logger = { info: (...args: any[]) => void; success: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void; debug: (...args: any[]) => void; }; export const testAdapter = async ({ adapter: getAdapter, runMigrations, overrideBetterAuthOptions, additionalCleanups, tests, prefixTests, onFinish, customIdGenerator, }: { /** * A function that will return the adapter instance to test with. * * @example * ```ts * testAdapter({ * adapter: (options) => drizzleAdapter(drizzle(db), { * schema: generateSchema(options), * }), * }) */ adapter: ( options: BetterAuthOptions, ) => | Promise<(options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>> | ((options: BetterAuthOptions) => DBAdapter<BetterAuthOptions>); /** * A function that will run the database migrations. */ runMigrations: (betterAuthOptions: BetterAuthOptions) => Promise<void> | void; /** * Any potential better-auth options overrides. */ overrideBetterAuthOptions?: < Passed extends BetterAuthOptions, Returned extends BetterAuthOptions, >( betterAuthOptions: Passed, ) => Returned; /** * By default we will cleanup all tables automatically, * but if you have additional cleanup logic, you can pass it here. * * Such as deleting a DB file that could had been created. */ additionalCleanups?: () => Promise<void> | void; /** * A test suite to run. */ tests: ReturnType<ReturnType<typeof createTestSuite>>[]; /** * A prefix to add to the test suite name. */ prefixTests?: string; /** * Upon finish of the tests, this function will be called. */ onFinish?: () => Promise<void> | void; /** * Custom ID generator function to be used by the helper functions. (such as `insertRandom`) */ customIdGenerator?: () => string | Promise<string>; }) => { const defaultBAOptions = {} satisfies BetterAuthOptions; let betterAuthOptions = (() => { return { ...defaultBAOptions, ...(overrideBetterAuthOptions?.(defaultBAOptions) || {}), } satisfies BetterAuthOptions; })(); let adapter: DBAdapter<BetterAuthOptions> = ( await getAdapter(betterAuthOptions) )(betterAuthOptions); const adapterName = adapter.options?.adapterConfig.adapterName; const adapterId = adapter.options?.adapterConfig.adapterId || adapter.id; const adapterDisplayName = adapterName || adapterId; const refreshAdapter = async (betterAuthOptions: BetterAuthOptions) => { adapter = (await getAdapter(betterAuthOptions))(betterAuthOptions); }; /** * A helper function to log to the console. */ const log: Logger = (() => { return { info: (...args: any[]) => console.log( `${TTY_COLORS.fg.blue}INFO ${TTY_COLORS.reset} [${adapterDisplayName}]`, ...args, ), success: (...args: any[]) => console.log( `${TTY_COLORS.fg.green}SUCCESS${TTY_COLORS.reset} [${adapterDisplayName}]`, ...args, ), warn: (...args: any[]) => console.log( `${TTY_COLORS.fg.yellow}WARN ${TTY_COLORS.reset} [${adapterDisplayName}]`, ...args, ), error: (...args: any[]) => console.log( `${TTY_COLORS.fg.red}ERROR ${TTY_COLORS.reset} [${adapterDisplayName}]`, ...args, ), debug: (...args: any[]) => console.log( `${TTY_COLORS.fg.magenta}DEBUG ${TTY_COLORS.reset} [${adapterDisplayName}]`, ...args, ), }; })(); /** * Cleanup function to remove all rows from the database. */ const cleanup = async () => { const start = performance.now(); await refreshAdapter(betterAuthOptions); const getAllModels = getAuthTables(betterAuthOptions); // Clean up all rows from all models for (const model of Object.keys(getAllModels)) { try { await adapter.deleteMany({ model: model, where: [] }); } catch (error) { const msg = `Error while cleaning up all rows from ${model}`; log.error(msg, error); throw new Error(msg, { cause: error, }); } } // Run additional cleanups try { await additionalCleanups?.(); } catch (error) { const msg = `Error while running additional cleanups`; log.error(msg, error); throw new Error(msg, { cause: error, }); } await refreshAdapter(betterAuthOptions); log.success( `${TTY_COLORS.bright}CLEAN-UP${TTY_COLORS.reset} completed successfully (${(performance.now() - start).toFixed(3)}ms)`, ); }; /** * A function that will run the database migrations. */ const migrate = async () => { const start = performance.now(); try { await runMigrations(betterAuthOptions); } catch (error) { const msg = `Error while running migrations`; log.error(msg, error); throw new Error(msg, { cause: error, }); } log.success( `${TTY_COLORS.bright}MIGRATIONS${TTY_COLORS.reset} completed successfully (${(performance.now() - start).toFixed(3)}ms)`, ); }; return { execute: () => { describe(adapterDisplayName, async () => { beforeAll(async () => { await migrate(); }, 20000); afterAll(async () => { await cleanup(); await onFinish?.(); }, 20000); for (const testSuite of tests) { await testSuite({ adapter: async () => { await refreshAdapter(betterAuthOptions); return adapter; }, adapterDisplayName, log, getBetterAuthOptions: () => betterAuthOptions, modifyBetterAuthOptions: async (options) => { const newOptions = deepmerge(defaultBAOptions, options); betterAuthOptions = deepmerge( newOptions, overrideBetterAuthOptions?.(newOptions) || {}, ); await refreshAdapter(betterAuthOptions); return betterAuthOptions; }, cleanup, prefixTests, runMigrations: migrate, onTestFinish: async () => {}, customIdGenerator, }); } }); }, }; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/plugins/oauth-proxy/oauth-proxy.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; import { getTestInstance } from "../../test-utils/test-instance"; import { oAuthProxy } from "."; import type { GoogleProfile } from "@better-auth/core/social-providers"; import { DEFAULT_SECRET } from "../../utils/constants"; import { signJWT } from "../../crypto"; let testIdToken: string; let handlers: ReturnType<typeof http.post>[]; const server = setupServer(); beforeAll(async () => { const data: GoogleProfile = { email: "[email protected]", email_verified: true, name: "First Last", picture: "https://lh3.googleusercontent.com/a-/AOh14GjQ4Z7Vw", exp: 1234567890, sub: "1234567890", iat: 1234567890, aud: "test", azp: "test", nbf: 1234567890, iss: "test", locale: "en", jti: "test", given_name: "First", family_name: "Last", }; testIdToken = await signJWT(data, DEFAULT_SECRET); handlers = [ http.post("https://oauth2.googleapis.com/token", () => { return HttpResponse.json({ access_token: "test", refresh_token: "test", id_token: testIdToken, }); }), ]; server.listen({ onUnhandledRequest: "bypass" }); server.use(...handlers); }); afterEach(() => { server.resetHandlers(); server.use(...handlers); }); afterAll(() => server.close()); describe("oauth-proxy", async () => { it("should redirect to proxy url", async () => { const { client, cookieSetter } = await getTestInstance({ plugins: [ oAuthProxy({ currentURL: "http://preview-localhost:3000", }), ], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const headers = new Headers(); const res = await client.signIn.social( { provider: "google", callbackURL: "/dashboard", }, { throw: true, }, ); const state = new URL(res.url!).searchParams.get("state"); await client.$fetch(`/callback/google?code=test&state=${state}`, { headers, onError(context) { const location = context.response.headers.get("location") ?? ""; if (!location) { throw new Error("Location header not found"); } expect(location).toContain( "http://preview-localhost:3000/api/auth/oauth-proxy-callback?callbackURL=%2Fdashboard", ); const cookies = new URL(location).searchParams.get("cookies"); expect(cookies).toBeTruthy(); }, }); }); it("shouldn't redirect to proxy url on same origin", async () => { const { client, cookieSetter } = await getTestInstance({ plugins: [oAuthProxy()], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const headers = new Headers(); const res = await client.signIn.social( { provider: "google", callbackURL: "/dashboard", }, { throw: true, onSuccess: cookieSetter(headers), }, ); const state = new URL(res.url!).searchParams.get("state"); await client.$fetch(`/callback/google?code=test&state=${state}`, { onError(context) { const location = context.response.headers.get("location"); if (!location) { throw new Error("Location header not found"); } expect(location).not.toContain("/api/auth/oauth-proxy-callback"); expect(location).toContain("/dashboard"); }, }); }); it("should proxy to the original request url", async () => { const { client } = await getTestInstance({ baseURL: "https://myapp.com", plugins: [ oAuthProxy({ productionURL: "https://login.myapp.com", }), ], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const res = await client.signIn.social( { provider: "google", callbackURL: "/dashboard", }, { throw: true, }, ); const state = new URL(res.url!).searchParams.get("state"); await client.$fetch(`/callback/google?code=test&state=${state}`, { onError(context) { const location = context.response.headers.get("location"); if (!location) { throw new Error("Location header not found"); } expect(location).toContain( "https://myapp.com/api/auth/oauth-proxy-callback?callbackURL=%2Fdashboard", ); const cookies = new URL(location).searchParams.get("cookies"); expect(cookies).toBeTruthy(); }, }); }); it("should require state cookie if it's not in proxy url", async () => { const { client } = await getTestInstance({ baseURL: "https://myapp.com", plugins: [ oAuthProxy({ productionURL: "https://myapp.com", }), ], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const res = await client.signIn.social( { provider: "google", callbackURL: "/dashboard", }, { throw: true, }, ); const state = new URL(res.url!).searchParams.get("state"); await client.$fetch(`/callback/google?code=test&state=${state}`, { onError(context) { const location = context.response.headers.get("location"); if (!location) { throw new Error("Location header not found"); } expect(location).toContain("state_mismatch"); }, }); }); it("shouldn't redirect to proxy url on same origin", async () => { const { client, cookieSetter } = await getTestInstance({ baseURL: "https://myapp.com", plugins: [ oAuthProxy({ productionURL: "https://myapp.com", }), ], socialProviders: { google: { clientId: "test", clientSecret: "test", }, }, }); const headers = new Headers(); const res = await client.signIn.social( { provider: "google", callbackURL: "/dashboard", }, { throw: true, onSuccess: cookieSetter(headers), }, ); const state = new URL(res.url!).searchParams.get("state"); await client.$fetch(`/callback/google?code=test&state=${state}`, { headers, onError(context) { const location = context.response.headers.get("location"); if (!location) { throw new Error("Location header not found"); } expect(location).not.toContain("/api/auth/oauth-proxy-callback"); expect(location).toContain("/dashboard"); }, }); }); }); ``` -------------------------------------------------------------------------------- /packages/core/src/social-providers/tiktok.ts: -------------------------------------------------------------------------------- ```typescript import { betterFetch } from "@better-fetch/fetch"; import type { OAuthProvider, ProviderOptions } from "../oauth2"; import { refreshAccessToken, validateAuthorizationCode } from "../oauth2"; /** * [More info](https://developers.tiktok.com/doc/tiktok-api-v2-get-user-info/) */ export interface TiktokProfile extends Record<string, any> { data: { user: { /** * The unique identification of the user in the current application.Open id * for the client. * * To return this field, add `fields=open_id` in the user profile request's query parameter. */ open_id: string; /** * The unique identification of the user across different apps for the same developer. * For example, if a partner has X number of clients, * it will get X number of open_id for the same TikTok user, * but one persistent union_id for the particular user. * * To return this field, add `fields=union_id` in the user profile request's query parameter. */ union_id?: string; /** * User's profile image. * * To return this field, add `fields=avatar_url` in the user profile request's query parameter. */ avatar_url?: string; /** * User`s profile image in 100x100 size. * * To return this field, add `fields=avatar_url_100` in the user profile request's query parameter. */ avatar_url_100?: string; /** * User's profile image with higher resolution * * To return this field, add `fields=avatar_url_100` in the user profile request's query parameter. */ avatar_large_url: string; /** * User's profile name * * To return this field, add `fields=display_name` in the user profile request's query parameter. */ display_name: string; /** * User's username. * * To return this field, add `fields=username` in the user profile request's query parameter. */ username: string; /** @note Email is currently unsupported by TikTok */ email?: string; /** * User's bio description if there is a valid one. * * To return this field, add `fields=bio_description` in the user profile request's query parameter. */ bio_description?: string; /** * The link to user's TikTok profile page. * * To return this field, add `fields=profile_deep_link` in the user profile request's query parameter. */ profile_deep_link?: string; /** * Whether TikTok has provided a verified badge to the account after confirming * that it belongs to the user it represents. * * To return this field, add `fields=is_verified` in the user profile request's query parameter. */ is_verified?: boolean; /** * User's followers count. * * To return this field, add `fields=follower_count` in the user profile request's query parameter. */ follower_count?: number; /** * The number of accounts that the user is following. * * To return this field, add `fields=following_count` in the user profile request's query parameter. */ following_count?: number; /** * The total number of likes received by the user across all of their videos. * * To return this field, add `fields=likes_count` in the user profile request's query parameter. */ likes_count?: number; /** * The total number of publicly posted videos by the user. * * To return this field, add `fields=video_count` in the user profile request's query parameter. */ video_count?: number; }; }; error?: { /** * The error category in string. */ code?: string; /** * The error message in string. */ message?: string; /** * The error message in string. */ log_id?: string; }; } export interface TiktokOptions extends ProviderOptions { // Client ID is not used in TikTok, we delete it from the options clientId?: never; clientSecret: string; clientKey: string; } export const tiktok = (options: TiktokOptions) => { return { id: "tiktok", name: "TikTok", createAuthorizationURL({ state, scopes, redirectURI }) { const _scopes = options.disableDefaultScope ? [] : ["user.info.profile"]; options.scope && _scopes.push(...options.scope); scopes && _scopes.push(...scopes); return new URL( `https://www.tiktok.com/v2/auth/authorize?scope=${_scopes.join( ",", )}&response_type=code&client_key=${options.clientKey}&redirect_uri=${encodeURIComponent( options.redirectURI || redirectURI, )}&state=${state}`, ); }, validateAuthorizationCode: async ({ code, redirectURI }) => { return validateAuthorizationCode({ code, redirectURI: options.redirectURI || redirectURI, options: { clientKey: options.clientKey, clientSecret: options.clientSecret, }, tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/", }); }, refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => { return refreshAccessToken({ refreshToken, options: { clientSecret: options.clientSecret, }, tokenEndpoint: "https://open.tiktokapis.com/v2/oauth/token/", authentication: "post", extraParams: { client_key: options.clientKey, }, }); }, async getUserInfo(token) { if (options.getUserInfo) { return options.getUserInfo(token); } const fields = [ "open_id", "avatar_large_url", "display_name", "username", ]; const { data: profile, error } = await betterFetch<TiktokProfile>( `https://open.tiktokapis.com/v2/user/info/?fields=${fields.join(",")}`, { headers: { authorization: `Bearer ${token.accessToken}`, }, }, ); if (error) { return null; } return { user: { email: profile.data.user.email || profile.data.user.username, id: profile.data.user.open_id, name: profile.data.user.display_name || profile.data.user.username, image: profile.data.user.avatar_large_url, /** @note Tiktok does not provide emailVerified or even email*/ emailVerified: profile.data.user.email ? true : false, }, data: profile, }; }, options, } satisfies OAuthProvider<TiktokProfile, TiktokOptions>; }; ``` -------------------------------------------------------------------------------- /packages/better-auth/src/oauth2/link-account.ts: -------------------------------------------------------------------------------- ```typescript import { APIError, createEmailVerificationToken } from "../api"; import type { Account } from "../types"; import type { User } from "../types"; import { logger } from "@better-auth/core/env"; import { isDevelopment } from "@better-auth/core/env"; import { setTokenUtil } from "./utils"; import type { GenericEndpointContext } from "@better-auth/core"; export async function handleOAuthUserInfo( c: GenericEndpointContext, { userInfo, account, callbackURL, disableSignUp, overrideUserInfo, }: { userInfo: Omit<User, "createdAt" | "updatedAt">; account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">; callbackURL?: string; disableSignUp?: boolean; overrideUserInfo?: boolean; }, ) { const dbUser = await c.context.internalAdapter .findOAuthUser( userInfo.email.toLowerCase(), account.accountId, account.providerId, ) .catch((e) => { logger.error( "Better auth was unable to query your database.\nError: ", e, ); const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`; throw c.redirect(`${errorURL}?error=internal_server_error`); }); let user = dbUser?.user; let isRegister = !user; if (dbUser) { const hasBeenLinked = dbUser.accounts.find( (a) => a.providerId === account.providerId && a.accountId === account.accountId, ); if (!hasBeenLinked) { const trustedProviders = c.context.options.account?.accountLinking?.trustedProviders; const isTrustedProvider = trustedProviders?.includes( account.providerId as "apple", ); if ( (!isTrustedProvider && !userInfo.emailVerified) || c.context.options.account?.accountLinking?.enabled === false ) { if (isDevelopment) { logger.warn( `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.`, ); } return { error: "account not linked", data: null, }; } try { await c.context.internalAdapter.linkAccount({ providerId: account.providerId, accountId: userInfo.id.toString(), userId: dbUser.user.id, accessToken: await setTokenUtil(account.accessToken, c.context), refreshToken: await setTokenUtil(account.refreshToken, c.context), idToken: account.idToken, accessTokenExpiresAt: account.accessTokenExpiresAt, refreshTokenExpiresAt: account.refreshTokenExpiresAt, scope: account.scope, }); } catch (e) { logger.error("Unable to link account", e); return { error: "unable to link account", data: null, }; } if ( userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email ) { await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true, }); } } else { if (c.context.options.account?.updateAccountOnSignIn !== false) { const updateData = Object.fromEntries( Object.entries({ idToken: account.idToken, accessToken: await setTokenUtil(account.accessToken, c.context), refreshToken: await setTokenUtil(account.refreshToken, c.context), accessTokenExpiresAt: account.accessTokenExpiresAt, refreshTokenExpiresAt: account.refreshTokenExpiresAt, scope: account.scope, }).filter(([_, value]) => value !== undefined), ); if (Object.keys(updateData).length > 0) { await c.context.internalAdapter.updateAccount( hasBeenLinked.id, updateData, ); } } if ( userInfo.emailVerified && !dbUser.user.emailVerified && userInfo.email.toLowerCase() === dbUser.user.email ) { await c.context.internalAdapter.updateUser(dbUser.user.id, { emailVerified: true, }); } } if (overrideUserInfo) { const { id: _, ...restUserInfo } = userInfo; // update user info from the provider if overrideUserInfo is true await c.context.internalAdapter.updateUser(dbUser.user.id, { ...restUserInfo, email: userInfo.email.toLowerCase(), emailVerified: userInfo.email.toLowerCase() === dbUser.user.email ? dbUser.user.emailVerified || userInfo.emailVerified : userInfo.emailVerified, }); } } else { if (disableSignUp) { return { error: "signup disabled", data: null, isRegister: false, }; } try { const { id: _, ...restUserInfo } = userInfo; user = await c.context.internalAdapter .createOAuthUser( { ...restUserInfo, email: userInfo.email.toLowerCase(), }, { accessToken: await setTokenUtil(account.accessToken, c.context), refreshToken: await setTokenUtil(account.refreshToken, c.context), idToken: account.idToken, accessTokenExpiresAt: account.accessTokenExpiresAt, refreshTokenExpiresAt: account.refreshTokenExpiresAt, scope: account.scope, providerId: account.providerId, accountId: userInfo.id.toString(), }, ) .then((res) => res?.user); if ( !userInfo.emailVerified && user && c.context.options.emailVerification?.sendOnSignUp ) { const token = await createEmailVerificationToken( c.context.secret, user.email, undefined, c.context.options.emailVerification?.expiresIn, ); const url = `${c.context.baseURL}/verify-email?token=${token}&callbackURL=${callbackURL}`; await c.context.options.emailVerification?.sendVerificationEmail?.( { user, url, token, }, c.request, ); } } catch (e: any) { logger.error(e); if (e instanceof APIError) { return { error: e.message, data: null, isRegister: false, }; } return { error: "unable to create user", data: null, isRegister: false, }; } } if (!user) { return { error: "unable to create user", data: null, isRegister: false, }; } const session = await c.context.internalAdapter.createSession(user.id); if (!session) { return { error: "unable to create session", data: null, isRegister: false, }; } return { data: { session, user, }, error: null, isRegister, }; } ``` -------------------------------------------------------------------------------- /packages/core/src/types/context.ts: -------------------------------------------------------------------------------- ```typescript import type { Account, BetterAuthDBSchema, SecondaryStorage, Session, User, Verification, } from "../db"; import type { OAuthProvider } from "../oauth2"; import { createLogger } from "../env"; import type { DBAdapter, Where } from "../db/adapter"; import type { BetterAuthCookies } from "./cookie"; import type { DBPreservedModels } from "../db"; import type { LiteralUnion } from "./helper"; import type { CookieOptions, EndpointContext } from "better-call"; import type { BetterAuthOptions, BetterAuthRateLimitOptions, } from "./init-options"; export type GenericEndpointContext< Options extends BetterAuthOptions = BetterAuthOptions, > = EndpointContext<string, any> & { context: AuthContext<Options>; }; export interface InternalAdapter< Options extends BetterAuthOptions = BetterAuthOptions, > { createOAuthUser( user: Omit<User, "id" | "createdAt" | "updatedAt">, account: Omit<Account, "userId" | "id" | "createdAt" | "updatedAt"> & Partial<Account>, ): Promise<{ user: User; account: Account }>; createUser<T extends Record<string, any>>( user: Omit<User, "id" | "createdAt" | "updatedAt" | "emailVerified"> & Partial<User> & Record<string, any>, ): Promise<T & User>; createAccount<T extends Record<string, any>>( account: Omit<Account, "id" | "createdAt" | "updatedAt"> & Partial<Account> & T, ): Promise<T & Account>; listSessions(userId: string): Promise<Session[]>; listUsers( limit?: number, offset?: number, sortBy?: { field: string; direction: "asc" | "desc" }, where?: Where[], ): Promise<User[]>; countTotalUsers(where?: Where[]): Promise<number>; deleteUser(userId: string): Promise<void>; createSession( userId: string, dontRememberMe?: boolean, override?: Partial<Session> & Record<string, any>, overrideAll?: boolean, ): Promise<Session>; findSession(token: string): Promise<{ session: Session & Record<string, any>; user: User & Record<string, any>; } | null>; findSessions( sessionTokens: string[], ): Promise<{ session: Session; user: User }[]>; updateSession( sessionToken: string, session: Partial<Session> & Record<string, any>, ): Promise<Session | null>; deleteSession(token: string): Promise<void>; deleteAccounts(userId: string): Promise<void>; deleteAccount(accountId: string): Promise<void>; deleteSessions(userIdOrSessionTokens: string | string[]): Promise<void>; findOAuthUser( email: string, accountId: string, providerId: string, ): Promise<{ user: User; accounts: Account[] } | null>; findUserByEmail( email: string, options?: { includeAccounts: boolean }, ): Promise<{ user: User; accounts: Account[] } | null>; findUserById(userId: string): Promise<User | null>; linkAccount( account: Omit<Account, "id" | "createdAt" | "updatedAt"> & Partial<Account>, ): Promise<Account>; // fixme: any type updateUser( userId: string, data: Partial<User> & Record<string, any>, ): Promise<any>; updateUserByEmail( email: string, data: Partial<User & Record<string, any>>, ): Promise<User>; updatePassword(userId: string, password: string): Promise<void>; findAccounts(userId: string): Promise<Account[]>; findAccount(accountId: string): Promise<Account | null>; findAccountByProviderId( accountId: string, providerId: string, ): Promise<Account | null>; findAccountByUserId(userId: string): Promise<Account[]>; updateAccount(id: string, data: Partial<Account>): Promise<Account>; createVerificationValue( data: Omit<Verification, "createdAt" | "id" | "updatedAt"> & Partial<Verification>, ): Promise<Verification>; findVerificationValue(identifier: string): Promise<Verification | null>; deleteVerificationValue(id: string): Promise<void>; deleteVerificationByIdentifier(identifier: string): Promise<void>; updateVerificationValue( id: string, data: Partial<Verification>, ): Promise<Verification>; } type CreateCookieGetterFn = ( cookieName: string, overrideAttributes?: Partial<CookieOptions>, ) => { name: string; attributes: CookieOptions; }; type CheckPasswordFn<Options extends BetterAuthOptions = BetterAuthOptions> = ( userId: string, ctx: GenericEndpointContext<Options>, ) => Promise<boolean>; export type AuthContext<Options extends BetterAuthOptions = BetterAuthOptions> = { options: Options; appName: string; baseURL: string; trustedOrigins: string[]; oauthConfig?: { /** * This is dangerous and should only be used in dev or staging environments. */ skipStateCookieCheck?: boolean; }; /** * New session that will be set after the request * meaning: there is a `set-cookie` header that will set * the session cookie. This is the fetched session. And it's set * by `setNewSession` method. */ newSession: { session: Session & Record<string, any>; user: User & Record<string, any>; } | null; session: { session: Session & Record<string, any>; user: User & Record<string, any>; } | null; setNewSession: ( session: { session: Session & Record<string, any>; user: User & Record<string, any>; } | null, ) => void; socialProviders: OAuthProvider[]; authCookies: BetterAuthCookies; logger: ReturnType<typeof createLogger>; rateLimit: { enabled: boolean; window: number; max: number; storage: "memory" | "database" | "secondary-storage"; } & BetterAuthRateLimitOptions; adapter: DBAdapter<Options>; internalAdapter: InternalAdapter<Options>; createAuthCookie: CreateCookieGetterFn; secret: string; sessionConfig: { updateAge: number; expiresIn: number; freshAge: number; }; generateId: (options: { model: LiteralUnion<DBPreservedModels, string>; size?: number; }) => string | false; secondaryStorage: SecondaryStorage | undefined; password: { hash: (password: string) => Promise<string>; verify: (data: { password: string; hash: string }) => Promise<boolean>; config: { minPasswordLength: number; maxPasswordLength: number; }; checkPassword: CheckPasswordFn<Options>; }; tables: BetterAuthDBSchema; runMigrations: () => Promise<void>; publishTelemetry: (event: { type: string; anonymousId?: string; payload: Record<string, any>; }) => Promise<void>; }; ``` -------------------------------------------------------------------------------- /docs/content/docs/authentication/salesforce.mdx: -------------------------------------------------------------------------------- ```markdown --- title: Salesforce description: Salesforce provider setup and usage. --- <Steps> <Step> ### Get your Salesforce Credentials 1. Log into your Salesforce org (Production or Developer Edition) 2. Navigate to **Setup > App Manager** 3. Click **New Connected App** 4. Fill in the basic information: - Connected App Name: Your app name - API Name: Auto-generated from app name - Contact Email: Your email address 5. Enable OAuth Settings: - Check **Enable OAuth Settings** - Set **Callback URL** to your redirect URI (e.g., `http://localhost:3000/api/auth/callback/salesforce` for development) - Select Required OAuth Scopes: - Access your basic information (id) - Access your identity URL service (openid) - Access your email address (email) - Perform requests on your behalf at any time (refresh_token, offline_access) 6. Enable **Require Proof Key for Code Exchange (PKCE)** (required) 7. Save and note your **Consumer Key** (Client ID) and **Consumer Secret** (Client Secret) <Callout type="info"> - For development, you can use `http://localhost:3000` URLs, but production requires HTTPS - The callback URL must exactly match what's configured in Better Auth - PKCE (Proof Key for Code Exchange) is required by Salesforce and is automatically handled by the provider </Callout> <Callout type="warning"> 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. </Callout> </Step> <Step> ### Configure the provider To configure the provider, you need to import the provider and pass it to the `socialProviders` option of the auth instance. ```ts title="auth.ts" import { betterAuth } from "better-auth" export const auth = betterAuth({ socialProviders: { salesforce: { // [!code highlight] clientId: process.env.SALESFORCE_CLIENT_ID as string, // [!code highlight] clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, // [!code highlight] environment: "production", // or "sandbox" // [!code highlight] }, // [!code highlight] }, }) ``` #### Configuration Options - `clientId`: Your Connected App's Consumer Key - `clientSecret`: Your Connected App's Consumer Secret - `environment`: `"production"` (default) or `"sandbox"` - `loginUrl`: Custom My Domain URL (without `https://`) - overrides environment setting - `redirectURI`: Override the auto-generated redirect URI if needed #### Advanced Configuration ```ts title="auth.ts" export const auth = betterAuth({ socialProviders: { salesforce: { clientId: process.env.SALESFORCE_CLIENT_ID as string, clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, environment: "sandbox", // [!code highlight] loginUrl: "mycompany.my.salesforce.com", // Custom My Domain // [!code highlight] redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // Override if needed // [!code highlight] }, }, }) ``` <Callout type="info"> - Use `environment: "sandbox"` for testing with Salesforce sandbox orgs - The `loginUrl` option is useful for organizations with My Domain enabled - The `redirectURI` option helps resolve redirect URI mismatch errors </Callout> </Step> <Step> ### Environment Variables Add the following environment variables to your `.env.local` file: ```bash title=".env.local" SALESFORCE_CLIENT_ID=your_consumer_key_here SALESFORCE_CLIENT_SECRET=your_consumer_secret_here BETTER_AUTH_URL=http://localhost:3000 # Important for redirect URI generation ``` For production: ```bash title=".env" SALESFORCE_CLIENT_ID=your_consumer_key_here SALESFORCE_CLIENT_SECRET=your_consumer_secret_here BETTER_AUTH_URL=https://yourdomain.com ``` </Step> <Step> ### Sign In with Salesforce 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: - `provider`: The provider to use. It should be set to `salesforce`. ```ts title="auth-client.ts" import { createAuthClient } from "better-auth/client" const authClient = createAuthClient() const signIn = async () => { const data = await authClient.signIn.social({ provider: "salesforce" }) } ``` </Step> <Step> ### Troubleshooting #### Redirect URI Mismatch Error If you encounter a `redirect_uri_mismatch` error: 1. **Check Callback URL**: Ensure the Callback URL in your Salesforce Connected App exactly matches your Better Auth callback URL 2. **Protocol**: Make sure you're using the same protocol (`http://` vs `https://`) 3. **Port**: Verify the port number matches (e.g., `:3000`) 4. **Override if needed**: Use the `redirectURI` option to explicitly set the redirect URI ```ts salesforce: { clientId: process.env.SALESFORCE_CLIENT_ID as string, clientSecret: process.env.SALESFORCE_CLIENT_SECRET as string, redirectURI: "http://localhost:3000/api/auth/callback/salesforce", // [!code highlight] } ``` #### Environment Issues - **Production**: Use `environment: "production"` (default) with `login.salesforce.com` - **Sandbox**: Use `environment: "sandbox"` with `test.salesforce.com` - **My Domain**: Use `loginUrl: "yourcompany.my.salesforce.com"` for custom domains #### PKCE Requirements 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. <Callout type="info"> The default scopes requested are `openid`, `email`, and `profile`. The provider will automatically include the `id` scope for accessing basic user information. </Callout> </Step> </Steps> ``` -------------------------------------------------------------------------------- /packages/better-auth/src/adapters/kysely-adapter/test/node-sqlite-dialect.test.ts: -------------------------------------------------------------------------------- ```typescript import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { Kysely, sql } from "kysely"; import { NodeSqliteDialect } from "../node-sqlite-dialect"; import { kyselyAdapter } from "../kysely-adapter"; import { runAdapterTest } from "../../test"; import { getMigrations } from "../../../db/get-migration"; import type { BetterAuthOptions } from "@better-auth/core"; import merge from "deepmerge"; import type { DatabaseSync } from "node:sqlite"; const nodeVersion = process.version; const nodeSqliteSupported = +nodeVersion.split(".")[0]!.slice(1) >= 22; describe.runIf(nodeSqliteSupported)("node-sqlite-dialect", async () => { let db: DatabaseSync; let kysely: Kysely<any>; beforeAll(async () => { if (!nodeSqliteSupported) { return; } const { DatabaseSync } = await import("node:sqlite"); db = new DatabaseSync(":memory:"); kysely = new Kysely({ dialect: new NodeSqliteDialect({ database: db, }), }); }); afterAll(async () => { if (!nodeSqliteSupported) { return; } await kysely.destroy(); }); describe("basic operations", () => { it("should create tables", async () => { await kysely.schema .createTable("test_table") .addColumn("id", "integer", (col) => col.primaryKey()) .addColumn("name", "text", (col) => col.notNull()) .addColumn("created_at", "timestamp", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); const tables = await kysely.introspection.getTables(); const testTable = tables.find((t) => t.name === "test_table"); expect(testTable).toBeDefined(); expect(testTable?.columns).toHaveLength(3); }); it("should insert and select data", async () => { await kysely .insertInto("test_table") .values({ id: 1, name: "Test User" }) .execute(); const result = await kysely .selectFrom("test_table") .selectAll() .execute(); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ id: 1, name: "Test User", }); }); it("should update data", async () => { await kysely .updateTable("test_table") .set({ name: "Updated User" }) .where("id", "=", 1) .execute(); const result = await kysely .selectFrom("test_table") .where("id", "=", 1) .selectAll() .executeTakeFirst(); expect(result?.name).toBe("Updated User"); }); it("should delete data", async () => { await kysely.deleteFrom("test_table").where("id", "=", 1).execute(); const result = await kysely .selectFrom("test_table") .selectAll() .execute(); expect(result).toHaveLength(0); }); it("should handle transactions", async () => { await kysely.transaction().execute(async (trx) => { await trx .insertInto("test_table") .values({ id: 2, name: "Transaction Test" }) .execute(); const result = await trx .selectFrom("test_table") .where("id", "=", 2) .selectAll() .executeTakeFirst(); expect(result?.name).toBe("Transaction Test"); }); // Verify the transaction was committed const result = await kysely .selectFrom("test_table") .where("id", "=", 2) .selectAll() .executeTakeFirst(); expect(result?.name).toBe("Transaction Test"); }); it("should rollback transactions on error", async () => { try { await kysely.transaction().execute(async (trx) => { await trx .insertInto("test_table") .values({ id: 3, name: "Rollback Test" }) .execute(); // Force an error throw new Error("Test error"); }); } catch (error) { // Expected error } // Verify the transaction was rolled back const result = await kysely .selectFrom("test_table") .where("id", "=", 3) .selectAll() .executeTakeFirst(); expect(result).toBeUndefined(); }); }); describe("introspection", () => { beforeAll(async () => { // Create a table with various column types await kysely.schema .createTable("introspection_test") .addColumn("id", "integer", (col) => col.primaryKey().autoIncrement()) .addColumn("name", "text", (col) => col.notNull()) .addColumn("email", "text", (col) => col.unique()) .addColumn("age", "integer") .addColumn("is_active", "boolean", (col) => col.defaultTo(true)) .execute(); }); it("should get table metadata", async () => { const tables = await kysely.introspection.getTables(); const table = tables.find((t) => t.name === "introspection_test"); expect(table).toBeDefined(); expect(table?.columns).toHaveLength(5); const idColumn = table?.columns.find((c) => c.name === "id"); expect(idColumn?.isAutoIncrementing).toBe(true); expect(idColumn?.isNullable).toBe(true); // SQLite primary keys can be NULL until a value is inserted const nameColumn = table?.columns.find((c) => c.name === "name"); expect(nameColumn?.isNullable).toBe(false); const ageColumn = table?.columns.find((c) => c.name === "age"); expect(ageColumn?.isNullable).toBe(true); const isActiveColumn = table?.columns.find((c) => c.name === "is_active"); expect(isActiveColumn?.hasDefaultValue).toBe(true); }); }); describe("better-auth adapter integration", async () => { if (!nodeSqliteSupported) { return; } const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(":memory:"); const betterAuthKysely = new Kysely({ dialect: new NodeSqliteDialect({ database: db, }), }); const opts: BetterAuthOptions = { database: { db: betterAuthKysely, type: "sqlite", }, user: { fields: { email: "email_address", }, additionalFields: { test: { type: "string", defaultValue: "test", }, }, }, session: { modelName: "sessions", }, }; beforeAll(async () => { const { runMigrations } = await getMigrations(opts); await runMigrations(); }); afterAll(async () => { await betterAuthKysely.destroy(); }); const adapter = kyselyAdapter(betterAuthKysely, { type: "sqlite", debugLogs: { isRunningAdapterTests: true, }, }); runAdapterTest({ getAdapter: async (customOptions = {}) => { return adapter(merge(customOptions, opts)); }, testPrefix: "node-sqlite", }); }); }); ``` -------------------------------------------------------------------------------- /docs/components/docs/page.client.tsx: -------------------------------------------------------------------------------- ```typescript "use client"; import { Fragment, type HTMLAttributes, useEffect, useMemo, useRef, useState, } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import Link from "next/link"; import { cva } from "class-variance-authority"; import { cn } from "../../lib/utils"; import { useI18n } from "fumadocs-ui/provider"; import { useTreeContext, useTreePath } from "fumadocs-ui/provider"; import { useSidebar } from "fumadocs-ui/provider"; import type { PageTree } from "fumadocs-core/server"; import { usePathname } from "next/navigation"; import { useNav } from "./layout/nav"; import { type BreadcrumbOptions, getBreadcrumbItemsFromPath, } from "fumadocs-core/breadcrumb"; import { usePageStyles } from "fumadocs-ui/provider"; import { isActive } from "../../lib/is-active"; import { TocPopover } from "./layout/toc"; import { useEffectEvent } from "fumadocs-core/utils/use-effect-event"; export function TocPopoverHeader(props: HTMLAttributes<HTMLDivElement>) { const ref = useRef<HTMLElement>(null); const [open, setOpen] = useState(false); const sidebar = useSidebar(); const { tocNav } = usePageStyles(); const { isTransparent } = useNav(); const onClick = useEffectEvent((e: Event) => { if (!open) return; if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false); }); useEffect(() => { window.addEventListener("click", onClick); return () => { window.removeEventListener("click", onClick); }; }, [onClick]); return ( <div className={cn("sticky overflow-visible z-10", tocNav, props.className)} style={{ top: "calc(var(--fd-banner-height) + var(--fd-nav-height))", }} > <TocPopover open={open} onOpenChange={setOpen} asChild> <header ref={ref} id="nd-tocnav" {...props} className={cn( "border-b border-fd-foreground/10 backdrop-blur-md transition-colors", (!isTransparent || open) && "bg-fd-background/80", open && "shadow-lg", sidebar.open && "max-md:hidden", )} > {props.children} </header> </TocPopover> </div> ); } export function PageBody(props: HTMLAttributes<HTMLDivElement>) { const { page } = usePageStyles(); return ( <div id="nd-page" {...props} className={cn("flex w-full min-w-0 flex-col", page, props.className)} > {props.children} </div> ); } export function PageArticle(props: HTMLAttributes<HTMLElement>) { const { article } = usePageStyles(); return ( <article {...props} className={cn( "flex w-full flex-1 flex-col gap-6 px-4 pt-8 md:px-6 md:pt-12 xl:px-12 xl:mx-auto", article, props.className, )} > {props.children} </article> ); } export function LastUpdate(props: { date: Date }) { const { text } = useI18n(); const [date, setDate] = useState(""); useEffect(() => { // to the timezone of client setDate(props.date.toLocaleDateString()); }, [props.date]); return ( <p className="text-sm text-fd-muted-foreground"> {text.lastUpdate} {date} </p> ); } export interface FooterProps { /** * Items including information for the next and previous page */ items?: { previous?: { name: string; url: string }; next?: { name: string; url: string }; }; } const itemVariants = cva( "flex w-full flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground", ); const itemLabel = cva( "inline-flex items-center gap-0.5 text-fd-muted-foreground", ); function scanNavigationList(tree: PageTree.Node[]) { const list: PageTree.Item[] = []; tree.forEach((node) => { if (node.type === "folder") { if (node.index) { list.push(node.index); } list.push(...scanNavigationList(node.children)); return; } if (node.type === "page" && !node.external) { list.push(node); } }); return list; } const listCache = new WeakMap<PageTree.Root, PageTree.Item[]>(); export function Footer({ items }: FooterProps) { const { root } = useTreeContext(); const { text } = useI18n(); const pathname = usePathname(); const { previous, next } = useMemo(() => { if (items) return items; const cached = listCache.get(root); const list = cached ?? scanNavigationList(root.children); listCache.set(root, list); const idx = list.findIndex((item) => isActive(item.url, pathname, false)); if (idx === -1) return {}; return { previous: list[idx - 1], next: list[idx + 1], }; }, [items, pathname, root]); return ( <div className="grid grid-cols-2 gap-4 pb-6"> {previous ? ( <Link href={previous.url} className={cn(itemVariants())}> <div className={cn(itemLabel())}> <ChevronLeft className="-ms-1 size-4 shrink-0 rtl:rotate-180" /> <p>{text.previousPage}</p> </div> <p className="font-medium md:text-[15px]">{previous.name}</p> </Link> ) : null} {next ? ( <Link href={next.url} className={cn(itemVariants({ className: "col-start-2 text-end" }))} > <div className={cn(itemLabel({ className: "flex-row-reverse" }))}> <ChevronRight className="-me-1 size-4 shrink-0 rtl:rotate-180" /> <p>{text.nextPage}</p> </div> <p className="font-medium md:text-[15px]">{next.name}</p> </Link> ) : null} </div> ); } export type BreadcrumbProps = BreadcrumbOptions; export function Breadcrumb(options: BreadcrumbProps) { const path = useTreePath(); const { root } = useTreeContext(); const items = useMemo(() => { return getBreadcrumbItemsFromPath(root, path, { includePage: options.includePage ?? false, ...options, }); }, [options, path, root]); if (items.length === 0) return null; return ( <div className="flex flex-row items-center gap-1.5 text-[15px] text-fd-muted-foreground"> {items.map((item, i) => { const className = cn( "truncate", i === items.length - 1 && "text-fd-primary font-medium", ); return ( <Fragment key={i}> {i !== 0 && <span className="text-fd-foreground/30">/</span>} {item.url ? ( <Link href={item.url} className={cn(className, "transition-opacity hover:opacity-80")} > {item.name} </Link> ) : ( <span className={className}>{item.name}</span> )} </Fragment> ); })} </div> ); } ```