# Authentication

Better Auth runs as a Keycloak OIDC client. Sessions are stored in Postgres. The Keycloak access token is read on every authenticated request and forwarded to the Go backend, which validates it via JWKS (see [backend-dev AUTHENTICATION](../../backend-dev/references/AUTHENTICATION.md)).

## Flow

```
Browser
  → GET /login (or auto-redirect from middleware)
  → trigger signIn("keycloak")
  → Better Auth generates state + PKCE
  → 302 to Keycloak authorize endpoint
  → user authenticates at Keycloak
  → Keycloak redirects to /api/auth/callback/keycloak with code
  → Better Auth exchanges code → access + refresh tokens
  → Better Auth stores session in Postgres (cookie = session id)
  → redirect to original destination
  → client mounts → axios interceptor reads token via auth server helper
  → Authorization: Bearer <keycloak_access_token> sent to Go backend
```

## Better Auth Server Instance

```ts
// src/lib/auth/server.ts:1
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { Pool } from "pg";
import { serverEnv } from "@/lib/env";

export const auth = betterAuth({
  secret: serverEnv.BETTER_AUTH_SECRET,
  baseURL: serverEnv.BETTER_AUTH_URL,

  database: new Pool({
    connectionString: serverEnv.DATABASE_URL,
  }),

  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "keycloak",
          discoveryUrl: `${serverEnv.KEYCLOAK_ISSUER}/.well-known/openid-configuration`,
          clientId: serverEnv.KEYCLOAK_CLIENT_ID,
          clientSecret: serverEnv.KEYCLOAK_CLIENT_SECRET,
          scopes: ["openid", "email", "profile"],
          pkce: true,
        },
      ],
    }),
  ],

  user: {
    additionalFields: {
      nip: { type: "string", required: false },
      roles: { type: "string[]", required: false, defaultValue: [] },
    },
  },

  session: {
    expiresIn: 60 * 60 * 8,           // 8h
    updateAge: 60 * 60,                // refresh sliding window every hour
    cookieCache: { enabled: true, maxAge: 5 * 60 },
  },

  advanced: {
    cookiePrefix: "dashboard",
  },
});

export type Session = Awaited<ReturnType<typeof auth.api.getSession>>;

export async function getSession(request?: Request) {
  return auth.api.getSession({
    headers: request ?? (await import("next/headers")).headers(),
  });
}

export async function getAccessToken(request?: Request) {
  const session = await getSession(request);
  if (!session) return null;

  const tokens = await auth.api.getAccessToken({
    body: { providerId: "keycloak" },
    headers: request ?? (await import("next/headers")).headers(),
  });

  return tokens?.accessToken ?? null;
}
```

## Better Auth Catch-All Route

```ts
// src/app/api/auth/[...all]/route.ts:1
import { auth } from "@/lib/auth/server";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);
```

## Better Auth Client

```ts
// src/lib/auth/client.ts:1
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "",
  plugins: [genericOAuthClient()],
});

export const { signIn, signOut, useSession } = authClient;
```

## Login Page

```tsx
// src/app/(public)/login/page.tsx:1
"use client";

import { Button, Center, Stack, Title } from "@mantine/core";
import { IconShieldLock } from "@tabler/icons-react";
import { useRouter, useSearchParams } from "next/navigation";
import { signIn } from "@/lib/auth/client";

export default function LoginPage() {
  const router = useRouter();
  const search = useSearchParams();
  const redirectTo = search.get("redirectTo") ?? "/dashboard";

  async function handleLogin() {
    await signIn.oauth2({
      providerId: "keycloak",
      callbackURL: redirectTo,
    });
  }

  return (
    <Center mih="100vh">
      <Stack align="center" gap="lg">
        <IconShieldLock size={48} />
        <Title order={2}>Sign in to Dashboard</Title>
        <Button onClick={handleLogin} size="md" leftSection={<IconShieldLock />}>
          Continue with Keycloak
        </Button>
      </Stack>
    </Center>
  );
}
```

## Logout

```tsx
// src/app/(public)/logout/page.tsx:1
"use client";

import { useEffect } from "react";
import { Center, Loader } from "@mantine/core";
import { signOut } from "@/lib/auth/client";

export default function LogoutPage() {
  useEffect(() => {
    signOut().then(() => {
      window.location.href = "/login";
    });
  }, []);

  return (
    <Center mih="100vh">
      <Loader />
    </Center>
  );
}
```

## Route Protection

Two layers:

1. **Middleware** (`src/middleware.ts`) — fast cookie check, redirects unauthenticated requests to `/login`. See [ARCHITECTURE.md](ARCHITECTURE.md#middleware).
2. **Layout guard** — `(authed)/layout.tsx` calls `getSession()` server-side. Defense in depth: if middleware is bypassed (edge runtime quirks), the layout still redirects.

## Axios Auth Interceptor

Reads the Keycloak access token via Better Auth's server helper. The interceptor runs only on the client (axios is only used client-side in this template); for RSC fetches, call `getAccessToken()` directly.

```ts
// src/lib/axios.ts:1
import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios";
import { publicEnv } from "@/lib/env";

export const api = axios.create({
  baseURL: publicEnv.NEXT_PUBLIC_API_BASE_URL,
  timeout: 30_000,
});

export class ApiError extends Error {
  constructor(
    message: string,
    public readonly status: number,
    public readonly code?: string,
    public readonly details?: unknown,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

let memToken: { value: string | null; expiresAt: number } | null = null;

async function fetchAccessToken(): Promise<string | null> {
  if (memToken && memToken.expiresAt > Date.now()) {
    return memToken.value;
  }

  const res = await fetch("/api/auth/keycloak/access-token", {
    credentials: "include",
  });

  if (!res.ok) return null;

  const data = (await res.json()) as { accessToken: string; expiresAt: number };
  memToken = { value: data.accessToken, expiresAt: data.expiresAt - 60_000 };
  return memToken.value;
}

api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
  const token = await fetchAccessToken();
  if (token) {
    config.headers.set("Authorization", `Bearer ${token}`);
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  (error: AxiosError<{ code?: string; message?: string; details?: unknown }>) => {
    if (error.response?.status === 401) {
      memToken = null;
      const loginUrl = new URL("/login", window.location.origin);
      loginUrl.searchParams.set("redirectTo", window.location.pathname);
      window.location.href = loginUrl.toString();
      return Promise.reject(new ApiError("Unauthenticated", 401));
    }

    const data = error.response?.data;
    throw new ApiError(
      data?.message ?? error.message,
      error.response?.status ?? 0,
      data?.code,
      data?.details,
    );
  },
);
```

### Token Endpoint

Better Auth does not expose a Keycloak access-token endpoint out of the box; add a thin route handler that uses `auth.api.getAccessToken`:

```ts
// src/app/api/auth/keycloak/access-token/route.ts:1
import { NextResponse, type NextRequest } from "next/server";
import { auth } from "@/lib/auth/server";

export async function GET(request: NextRequest) {
  const tokens = await auth.api.getAccessToken({
    body: { providerId: "keycloak" },
    headers: request.headers,
  });

  if (!tokens?.accessToken) {
    return NextResponse.json({ error: "no_token" }, { status: 401 });
  }

  return NextResponse.json({
    accessToken: tokens.accessToken,
    expiresAt: tokens.accessTokenExpiresAt?.getTime() ?? Date.now() + 60_000,
  });
}
```

## Role-Based UI Gating

The Go backend reads roles from `resource_access.{client_id}.roles` (per backend-dev). Mirror them on the session so UI can gate buttons:

```ts
// src/server/permissions.ts:1
import { getSession } from "@/lib/auth/server";

export async function requireRole(role: string) {
  const session = await getSession();
  if (!session) throw new Error("unauthenticated");

  const roles = (session.user as { roles?: string[] }).roles ?? [];
  if (!roles.includes(role)) throw new Error("forbidden");
}

export function hasRole(session: Session, role: string): boolean {
  const roles = (session.user as { roles?: string[] }).roles ?? [];
  return roles.includes(role);
}
```

Use in server components:

```tsx
// src/app/(authed)/dashboard/users/page.tsx:1
import { hasRole } from "@/server/permissions";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth/server";

export default async function UsersPage() {
  const session = await getSession();
  if (!session || !hasRole(session, "dashboard.admin")) {
    redirect("/dashboard");
  }
  return <UsersList />;
}
```

## Database Schema for Better Auth

Better Auth manages its own tables. Generate them once:

```bash
bun x @better-auth/cli@latest generate --output ./db/migrations
bun x @better-auth/cli@latest migrate
```

The CLI produces a SQL migration you apply to the `DATABASE_URL` database. Do not manually edit these tables.

## Testing Auth

Use MSW to mock `/api/auth/*` and `/api/auth/keycloak/access-token` in unit tests. Playwright E2E tests run against a real Keycloak realm — use a dedicated test realm, never production. See [references/TESTING.md](TESTING.md).