AuthAuthentication Developer Guide

Authentication Developer Guide

Technical reference for developers working on or extending the bloqr-backend authentication system.

Technical reference for developers working on or extending the bloqr-backend authentication system.

See also

Better Auth Developer Guide — in-depth plugin architecture, adapter swapping, and custom IAuthProvider creation.

Architecture

Authentication Flow

The unified auth middleware runs on every protected request. Token type is determined by the blq_ prefix (or legacy abc_ prefix) — API keys and Better Auth session tokens are mutually exclusive.

Key Files

FilePurpose
worker/types.tsType definitions: IAuthProvider, IAuthContext, UserTier, AuthScope, Env
worker/lib/auth.tsBetter Auth factory — createAuth(env, baseURL)
worker/lib/prisma.tsPrismaClient factory — creates per-request client via Hyperdrive
worker/middleware/auth.tsUnified auth middleware, authenticateRequestUnified(), guards
worker/middleware/better-auth-provider.tsBetterAuthProvider — IAuthProvider implementation
worker/middleware/cf-access.tsCloudflare Access JWT verification (defense-in-depth)
worker/handlers/api-keys.tsAPI key CRUD endpoints
worker/handlers/auth-providers.tsGET /api/auth/providers — active provider metadata

The IAuthProvider Interface

The authentication system is built on an extensible provider interface defined in worker/types.ts. You can swap or add identity providers without changing middleware or route handlers.

Interface Definition

// worker/types.ts

export interface IAuthProvider {
    /** Human-readable provider name, e.g. 'better-auth', 'auth0' */
    readonly name: string;

    /** Auth method identifier stamped onto IAuthContext for every verified request */
    readonly authMethod: IAuthContext['authMethod'];

    /** Verify credentials from the incoming request and return user identity */
    verifyToken(request: Request): Promise<IAuthProviderResult>;
}

export interface IAuthProviderResult {
    readonly valid: boolean;
    readonly providerUserId?: string;
    readonly tier?: UserTier;
    readonly role?: string;
    readonly sessionId?: string | null;
    readonly error?: string;
    readonly email?: string | null;
    readonly displayName?: string | null;
}

export interface IAuthContext {
    readonly userId: string | null;
    readonly tier: UserTier;
    readonly role: string;
    readonly apiKeyId: string | null;
    readonly sessionId: string | null;
    readonly scopes: readonly string[];
    readonly authMethod: 'api-key' | 'anonymous' | 'better-auth';
    readonly email?: string | null;
    readonly displayName?: string | null;
    readonly apiKeyRateLimit?: number | null;
}

Default Implementation: BetterAuthProvider

BetterAuthProvider delegates all credential verification to Better Auth's auth.api.getSession(), which handles both cookie-based (browser) and Bearer token (API) sessions transparently.

// worker/middleware/better-auth-provider.ts (simplified)
export class BetterAuthProvider implements IAuthProvider {
    readonly name = 'better-auth';
    readonly authMethod = 'better-auth' as const;

    constructor(private readonly env: Env) {}

    async verifyToken(request: Request): Promise<IAuthProviderResult> {
        const url = new URL(request.url);
        const auth = createAuth(this.env, url.origin);

        // auth.api.getSession() reads cookies OR the Authorization header.
        // The bearer() plugin enables the Authorization: Bearer <token> path.
        const session = await auth.api.getSession({ headers: request.headers });

        if (!session) {
            return { valid: false }; // No session — fall through to anonymous
        }

        return {
            valid: true,
            providerUserId: session.user.id,
            tier: resolveTier(session.user.tier),   // from additionalFields
            role: resolveRole(session.user.role),   // from additionalFields
            sessionId: session.session.id,
            email: session.user.email ?? null,
            displayName: session.user.name ?? null,
        };
    }
}

Key design points:

  • Stateless per request — a fresh Better Auth instance is created for every call; Hyperdrive ensures this is cheap (local proxy socket, not a remote TCP handshake).
  • ZTA-safe tier resolution — tier and role come from the database on every request, not from JWT claims. Privilege changes take effect immediately.
  • Graceful fallback — a missing session returns { valid: false } (not an error), allowing the middleware to fall through to anonymous.

Creating a Custom Auth Provider

To integrate a third-party identity provider (Auth0, Okta, Supabase Auth, Firebase Auth):

// worker/middleware/my-auth-provider.ts
import type { Env, IAuthProvider, IAuthProviderResult } from '../types.ts';
import { UserTier } from '../types.ts';

export class MyAuthProvider implements IAuthProvider {
    readonly name = 'my-provider';
    readonly authMethod = 'better-auth' as const; // reuse, or extend the union type

    constructor(private readonly env: Env) {}

    async verifyToken(request: Request): Promise<IAuthProviderResult> {
        const authHeader = request.headers.get('Authorization');
        if (!authHeader?.startsWith('Bearer ')) {
            return { valid: false };
        }

        const token = authHeader.slice(7);
        try {
            const payload = await verifyMyJwt(token, this.env.MY_JWKS_URL);
            return {
                valid: true,
                providerUserId: payload.sub,
                tier: mapTier(payload.plan) as UserTier,
                role: payload.role ?? 'user',
                email: payload.email ?? null,
                displayName: payload.name ?? null,
            };
        } catch {
            return { valid: false, error: 'Token verification failed' };
        }
    }
}

Register it in worker/hono-app.ts by replacing the BetterAuthProvider instantiation:

// worker/hono-app.ts
import { MyAuthProvider } from './middleware/my-auth-provider.ts';

app.use('*', async (c, next) => {
    const authProvider = new MyAuthProvider(c.env); // ← swap here
    // ... rest of middleware setup
});

Adding Better Auth Plugins

Better Auth plugins are added to the plugins array inside createAuth() in worker/lib/auth.ts. Each plugin registers routes under /api/auth/* and may add database tables.

Current Plugins

// worker/lib/auth.ts
plugins: [
    bearer(),       // Authorization: Bearer <session-token> path for API clients
    twoFactor({ issuer: 'bloqr-backend' }), // TOTP 2FA
    multiSession(), // Multiple concurrent sessions
    admin(),        // Admin user management endpoints
]

Adding apiKey()

Built-in API key management (replaces the custom api_keys table if desired):

import { apiKey } from 'better-auth/plugins';

plugins: [
    bearer(),
    twoFactor({ issuer: 'bloqr-backend' }),
    multiSession(),
    admin(),
    apiKey(), // adds /api/auth/api-key/create, /list, /revoke
]

After adding, run deno task db:generate to update the Prisma schema, then deno task db:migrate.

Adding organization()

Multi-tenancy support:

import { organization } from 'better-auth/plugins';

plugins: [
    // ... existing plugins
    organization(), // adds /api/auth/organization/* and member/* routes
]

This adds organization and member tables — always run deno task db:generate && deno task db:migrate after adding any plugin.

Adding passkey()

WebAuthn / FIDO2 passkey support:

import { passkey } from 'better-auth/plugins';

plugins: [
    // ... existing plugins
    passkey(), // adds /api/auth/passkey/register, /authenticate
]

Swapping the Database Adapter

The database adapter is the single line database: prismaAdapter(prisma, { provider: 'postgresql' }). Changing it requires no other code changes in the auth system.

Current: Neon PostgreSQL via Hyperdrive

// worker/lib/auth.ts — current configuration
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { createPrismaClient } from './prisma.ts';

const prisma = createPrismaClient(env.HYPERDRIVE.connectionString);

betterAuth({
    database: prismaAdapter(prisma, { provider: 'postgresql' }),
    // ...
});

Cloudflare D1 (edge SQLite)

import { d1Adapter } from 'better-auth/adapters/d1';

betterAuth({
    database: d1Adapter(env.DB),
    // ...
});

Add the D1 binding to wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "adblock-auth"
database_id = "<your-d1-id>"

MySQL / PlanetScale

import { prismaAdapter } from 'better-auth/adapters/prisma';
// Use a MySQL-compatible PrismaClient

betterAuth({
    database: prismaAdapter(prisma, { provider: 'mysql' }),
    // ...
});

Self-Hosted PostgreSQL (without Hyperdrive)

import { prismaAdapter } from 'better-auth/adapters/prisma';
// PrismaClient created with direct DATABASE_URL (not Hyperdrive)

betterAuth({
    database: prismaAdapter(prisma, { provider: 'postgresql' }),
    // ...
});

Custom Adapter

Implement Better Auth's Adapter interface directly for any data source:

import type { Adapter } from 'better-auth';

const myAdapter: Adapter = {
    async create(data) { /* ... */ },
    async findOne(query) { /* ... */ },
    async findMany(query) { /* ... */ },
    async update(query, data) { /* ... */ },
    async delete(query) { /* ... */ },
};

betterAuth({
    database: myAdapter,
    // ...
});

Extending the User Model

Better Auth's user.additionalFields lets you add typed fields to every user record without touching the core session/account tables.

Current Additional Fields

// worker/lib/auth.ts
user: {
    additionalFields: {
        tier: {
            type: 'string',
            required: false,
            defaultValue: 'free',
            input: false, // ← prevents users from self-assigning tier
        },
        role: {
            type: 'string',
            required: false,
            defaultValue: 'user',
            input: false, // ← prevents users from self-assigning role
        },
    },
},

Adding a New Field

Step 1 — Add to additionalFields in worker/lib/auth.ts:

user: {
    additionalFields: {
        tier: { /* ... existing ... */ },
        role: { /* ... existing ... */ },
        displayName: {         // ← new field
            type: 'string',
            required: false,
            defaultValue: null,
            input: true,       // users may set their own display name
        },
    },
},

Step 2 — Add the column to prisma/schema.prisma:

model User {
  id            String    @id @default(cuid())
  name          String
  email         String    @unique
  emailVerified Boolean   @default(false)
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
  tier          String    @default("free")
  role          String    @default("user")
  displayName   String?   // ← new column
  // ...
}

Step 3 — Generate the migration:

deno task db:generate
deno task db:migrate

Step 4 — Access the field from IAuthContext:

The field is automatically included in the session response. BetterAuthProvider passes it through session.user.displayName if you add it to the return value:

return {
    valid: true,
    providerUserId: session.user.id,
    displayName: (session.user as { displayName?: string }).displayName ?? session.user.name,
    // ...
};

wrangler.toml Binding Requirements

BindingTypeRequiredDescription
HYPERDRIVEHyperdriveYesNeon PostgreSQL connection proxy
BETTER_AUTH_SECRETSecretYesSession signing secret (32+ chars)
BETTER_AUTH_URLVarNoOverride base URL for auth endpoints
GITHUB_CLIENT_IDSecretNoEnables GitHub OAuth
GITHUB_CLIENT_SECRETSecretNoEnables GitHub OAuth
GOOGLE_CLIENT_IDSecretNoReserved — Google OAuth (not yet active)
GOOGLE_CLIENT_SECRETSecretNoReserved — Google OAuth (not yet active)
TURNSTILE_SITE_KEYVarNoCloudflare Turnstile public key
TURNSTILE_SECRET_KEYSecretNoCloudflare Turnstile verification key
CF_ACCESS_TEAM_DOMAINSecretNoCF Zero Trust team domain
CF_ACCESS_AUDSecretNoCF Access audience tag
CORS_ALLOWED_ORIGINSSecretNoComma-separated list of allowed origins
ANALYTICS_ENGINEAnalytics EngineNoWorkers Analytics Engine dataset
SENTRY_DSNSecretNoSentry error reporting DSN

Minimal wrangler.toml for Better Auth:

[[hyperdrive]]
binding = "HYPERDRIVE"
id = "<your-hyperdrive-id>"

Local Dev Setup

Copy and fill in .dev.vars (never committed to git):

# .dev.vars — Worker secrets loaded by wrangler dev

# ── Better Auth (required) ──────────────────────────────────────────────────
# Generate: openssl rand -base64 32
BETTER_AUTH_SECRET=your-secret-at-least-32-characters-long

# ── Hyperdrive (required) ───────────────────────────────────────────────────
# Point wrangler dev at your personal Neon development branch.
# Create a branch at https://console.neon.tech → your project → Branches → New Branch.
# Use the "Direct connection" string (not pooled).
CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=postgresql://<user>:<password>@<branch-host>.neon.tech/<dbname>?sslmode=require

# ── GitHub OAuth (optional) ─────────────────────────────────────────────────
GITHUB_CLIENT_ID=your-github-oauth-app-client-id
GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret

# ── Cloudflare Turnstile (optional — use test keys locally) ─────────────────
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA

# ── CORS ────────────────────────────────────────────────────────────────────
CORS_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:8787

Start the dev environment:

deno task db:migrate    # Apply pending Prisma migrations to your Neon branch
wrangler dev            # Start the Worker

See Local Development Setup for full Neon branching instructions.


Testing

Auth Test Files

FileWhat It Covers
worker/lib/auth.test.tscreateAuth() factory, config validation, plugin wiring
worker/middleware/auth.test.tsauthenticateRequestUnified() — all three auth paths
worker/middleware/better-auth-provider.test.tsBetterAuthProvider.verifyToken(), tier/role resolution
worker/middleware/auth-extensibility.test.tsIAuthProvider contract, tier/scope registries, guards
worker/middleware/zta-auth-gates.test.tsZTA gate enforcement, telemetry on auth failure
worker/handlers/auth-config.test.tsGET /api/auth/providers response shape
worker/handlers/auth-admin.test.tsAdmin auth operations

Running Auth Tests

# All worker tests (includes auth)
deno task test:worker

# Auth middleware only
deno test worker/middleware/auth.test.ts worker/middleware/better-auth-provider.test.ts --allow-env --allow-net

# Auth extensibility tests
deno test worker/middleware/auth-extensibility.test.ts --allow-env

# With coverage
deno task test:coverage

Mocking Better Auth in Unit Tests

// Create a mock that returns a preset auth context
import { ANONYMOUS_AUTH_CONTEXT } from '../types.ts';
import type { IAuthContext, IAuthProvider, IAuthProviderResult } from '../types.ts';

export class MockAuthProvider implements IAuthProvider {
    readonly name = 'mock';
    readonly authMethod = 'better-auth' as const;

    constructor(private readonly result: IAuthProviderResult) {}

    async verifyToken(_req: Request): Promise<IAuthProviderResult> {
        return this.result;
    }
}

// In your test:
const provider = new MockAuthProvider({ valid: true, providerUserId: 'u_123', tier: UserTier.Pro });
const result = await authenticateRequestUnified(mockRequest, mockEnv, undefined, provider);
assertEquals(result.context.tier, UserTier.Pro);

Tier System

Registry Architecture

// worker/types.ts
export enum UserTier {
    Anonymous = 'anonymous',
    Free      = 'free',
    Pro       = 'pro',
    Admin     = 'admin',
}

export const TIER_REGISTRY: Readonly<Record<UserTier, ITierConfig>> = {
    [UserTier.Anonymous]: { order: 0, rateLimit: 10,       displayName: 'Anonymous', description: 'Unauthenticated' },
    [UserTier.Free]:      { order: 1, rateLimit: 60,       displayName: 'Free',      description: 'Registered user' },
    [UserTier.Pro]:       { order: 2, rateLimit: 300,      displayName: 'Pro',       description: 'Paid subscriber' },
    [UserTier.Admin]:     { order: 3, rateLimit: Infinity, displayName: 'Admin',     description: 'Administrator' },
};

Tier comparison uses numeric order:

isTierSufficient(UserTier.Pro, UserTier.Free);   // true — Pro (2) ≥ Free (1)
isTierSufficient(UserTier.Free, UserTier.Admin); // false — Free (1) < Admin (3)

Guard Functions

// Require any authenticated user
const authGuard = requireAuth(authContext);
if (authGuard) return authGuard; // 401 Unauthorized

// Require minimum tier
const tierGuard = requireTier(authContext, UserTier.Pro);
if (tierGuard) return tierGuard; // 403 Forbidden

// Require specific scopes (API key auth only)
const scopeGuard = requireScope(authContext, AuthScope.Compile);
if (scopeGuard) return scopeGuard; // 403 Forbidden

Scope System

API key scopes provide fine-grained access control. Session-authenticated users (Better Auth) bypass scope checks — they inherit full access based on their tier.

export enum AuthScope {
    Compile = 'compile',
    Rules   = 'rules',
    Admin   = 'admin',
}
Auth MethodScope Enforcement
Better Auth sessionBypassed — tier determines access
API key (blq_)Enforced — key must include required scopes
Anonymous401 returned before scope check