Files
2026-05-27 23:29:53 +01:00

4.5 KiB

create-provider

Purpose

Factory for the auth.provider half of The split. Returns an AuthProviderAdapter (per @suaveplan/api-auth-provider) that mounts the canonical PROVIDER_ROUTE_SURFACE (9 routes) under routePrefix (default /auth). Each handler internally rewrites the incoming Request onto the Better-Auth-native sub-path via ROUTE_TRANSLATION and delegates to auth.handler(...). This indirection means downstream consumers of the provider see the canonical foundation surface — never Better-Auth's idioms — while Better-Auth still owns the precise wire response (cookies, 302 redirects, error envelopes).

Features

  • id: "better-auth"; pathPrefix validated as AuthPathPrefix.
  • All 9 canonical route names registered:
    • POST /register → POST /sign-up/email
    • POST /login → POST /sign-in/email
    • POST /logout → POST /sign-out
    • POST /refresh → GET /get-session
    • GET /oauth/:provider/start → POST /oauth2/sign-in/:provider
    • GET /oauth/:provider/callback → GET /oauth2/callback/:provider
    • GET /verify-email/:token → GET /verify-email
    • POST /forgot-password → POST /forget-password
    • POST /reset-password → POST /reset-password
  • routePrefix collision-checked against @suaveplan/api-host/reserved-prefix — refused with AUTH_BA.ROUTE_PREFIX_COLLISION on collision.
  • Better-Auth instance materialised during lifecycle.install so DB unreachable errors fail before traffic.
  • OAuth flow conclusions emit oauth_flows_total{provider,outcome}.
  • Per-options WeakMap so the same Better-Auth instance is shared with createBetterAuthSession(opts) when wired with the same options object.

Usage: Basic

import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";

const provider = createBetterAuthProvider({
    database: pool,
    baseUrl: "https://api.example.com",
    secret: process.env.BETTER_AUTH_SECRET!,
});

// Mount via the host's plugin lifecycle:
host.registerAuthProvider(provider);

Usage: Advanced

import { createBetterAuthProvider } from "@suaveplan/api-auth-better-auth";
import { createBetterAuthSession } from "@suaveplan/api-auth-better-auth";

// Operators MUST share the SAME options bag between the two factories to
// share the Better-Auth instance (and thus the DB pool):
const opts = {
    database: pool,
    baseUrl: "https://api.example.com",
    secret: process.env.BETTER_AUTH_SECRET!,
    routePrefix: "/api/auth",
    oauth: {
        providers: {
            github: {
                providerId: "github",
                clientId: process.env.GITHUB_CLIENT_ID!,
                clientSecret: process.env.GITHUB_CLIENT_SECRET!,
                scopes: ["read:user", "user:email"],
                authorizationUrl: "https://github.com/login/oauth/authorize",
                tokenUrl: "https://github.com/login/oauth/access_token",
                userInfoUrl: "https://api.github.com/user",
            },
        },
    },
};

host.registerAuth({
    resolver: createBetterAuthSession(opts),
    csrf: true,
});
host.registerAuthProvider(createBetterAuthProvider(opts));

API Reference

Export Shape Purpose
createBetterAuthProvider(opts) (BetterAuthOptions) => AuthProviderAdapter Build the provider
ROUTE_TRANSLATION Record<ProviderRouteName, NativeRoute> Re-exported for callers wanting to introspect the mapping
buildNativeUrl(args) function Re-exported for advanced wrappers

Implementation Notes

The provider's routes(router, ctx) mounts every canonical entry from PROVIDER_ROUTE_SURFACE with auth: null because the routes are mounted INSIDE the auth provider's own prefix and the routes themselves are the authentication flow — they MUST be reachable unauthenticated. The handler's inner rewrite reads (ctx as { params }) because the abstract RequestContext doesn't declare params, but the request pipeline driver (@suaveplan/api-host/request-pipeline-driver.ts) attaches them at runtime. The cast is documented inline. The OAuth outcome heuristic maps status codes to labels — 2xx/3xx → success, 403 → provider_rejected, anything else → state_mismatch. The semantic split between "state_mismatch" and "provider_rejected" is intentional even though a single 400 covers both wire-wise: the conformance suite distinguishes the two via the underlying mock IdP's primeError API. The per-options WeakMap is keyed by object identity, not deep equality — sharing requires literal same-object reuse.