Skip to main content

Authentication

How the Chompardo App authenticates users via the Identity service using OIDC, and how sessions are managed in the BFF.

Overview

The Identity service (main-identity.chompardo.dev) is the platform's OIDC provider, powered by Better Auth with its OAuth Provider plugin. It supports multiple authentication methods (social providers like Google, email/password, and more in future) and issues tokens to relying-party applications.

The App frontend (main.chompardo.dev) acts as a Backend for Frontend (BFF) — it never exposes tokens to the browser. Instead, its server side handles the full OAuth 2.1 Authorization Code flow with PKCE against Identity, stores tokens server-side, and gives the browser only an HttpOnly session cookie.

This approach follows the architecture established in ADR-003: BFF Architecture.

Two Login Paths

There are two ways a user can end up authenticated at the App:

1. OIDC Flow (from the App)

The user visits main.chompardo.dev and is not authenticated. The App's middleware starts the OIDC flow, which redirects to Identity. After the user signs in at Identity, the OIDC flow completes and the user is redirected back to the App with tokens.

2. Direct Login (at Identity)

The user visits main-identity.chompardo.dev directly and signs in. After sign-in, Identity redirects to a configurable default URL (DEFAULT_REDIRECT_URL, defaults to https://main.chompardo.dev). The App's middleware detects no session and starts the OIDC flow — since Identity already has an active session, it auto-completes the authorize request without showing the login page again.

OIDC Login Flow

If the user already has an active session at Identity (e.g., from a direct login or previous OIDC flow), the authorize endpoint skips the login UI and immediately redirects back with the authorization code.

Direct Login Flow

Logout Flow

Logout clears the App's server-side session and ends the Identity session:

The browser is redirected to Identity's end-session endpoint with the id_token_hint, which ends the Identity session. If no id token or end-session endpoint is available, the browser is redirected to / (home) as a fallback.

Key Components

Middleware (middleware/auth.server.ts)

The root middleware runs on every request and is responsible for:

  1. Skipping auth routes/auth/login, /auth/callback, and /auth/logout bypass authentication
  2. Session validation — Reads the __sid cookie, looks up the session in the server-side store
  3. Proactive token refresh — If the access token expires within 60 seconds, the middleware refreshes it using the refresh token before the request proceeds
  4. Context propagation — Sets the authenticated user, session ID, and access token on the React Router context so loaders and actions can access them

If no valid session exists, the middleware redirects to /auth/login with a return_to parameter preserving the original URL.

Session Store (lib/session.server.ts)

Sessions are stored server-side in Valkey (Redis-compatible) — the browser only holds a signed session ID in an HttpOnly cookie. The implementation uses React Router's createSessionStorage with a Valkey-backed adapter (iovalkey).

Session data includes:

FieldDescription
userIdThe user's sub claim from Identity
accessTokenOAuth access token (for Go API calls)
refreshTokenOAuth refresh token
idTokenOIDC ID token (used for logout)
accessTokenExpiresAtTimestamp for proactive refresh
userUserInfo object (email, name, picture)

Flash data (auto-deleted after a single read, used during the OIDC login flow):

FieldDescription
pkceVerifierPKCE code verifier for the current flow
pkceStateOAuth state parameter
returnToURL to redirect to after login

Session data is stored in Valkey under {env}:session:{id} keys (prefixed with ENV_NAME) with a configurable TTL (SESSION_TTL_SECONDS, default 30 days).

The only cookie is __sid — an HttpOnly, Secure, SameSite=Lax session ID cookie signed with SESSION_SECRET. PKCE data (verifier, state, returnTo) is stored as flash data in the same session, automatically deleted after a single read.

CookiePurposeLifetimeSigned
__sidSession IDSESSION_TTL_SECONDS (default 30 days)HMAC-SHA256

OIDC Client (lib/oidc.server.ts)

A thin wrapper around the openid-client library that caches the OIDC discovery configuration. The library handles:

  • OIDC Discovery — Fetches and caches the Identity service's .well-known/openid-configuration
  • PKCE generationSHA-256 code challenge with base64url encoding
  • Token exchange — Authorization code → tokens via the /token endpoint with client_secret_basic
  • Token refresh — Refresh token → new tokens (same endpoint, different grant type)
  • UserInfo — Fetches user profile from the /userinfo endpoint

Config (lib/config.server.ts)

Environment variables are validated at startup using Zod. The server crashes immediately if any required variable is missing or invalid — fail fast, fail loud.

VariableRequiredDefaultDescription
OAUTH_CLIENT_IDYesOAuth client ID from Identity service
OAUTH_CLIENT_SECRETYesOAuth client secret
OAUTH_ISSUERYesIdentity service base URL
APP_BASE_URLYesApp's own base URL
SESSION_SECRETYesHMAC signing key (min 32 chars)
SESSION_TTL_SECONDSNo2592000Session lifetime in seconds (default 30 days)
VALKEY_URLYesValkey/Redis connection URL

Identity Configuration

The Identity service's login page uses a server-side DEFAULT_REDIRECT_URL (env var, defaults to https://main.chompardo.dev) to determine where users go after a direct login. This is passed to Better Auth's callbackURL for social sign-in and used for client-side redirect after email sign-in.

During OIDC flows, Better Auth's OAuth Provider plugin automatically resumes the authorize flow after login, overriding the default redirect.

API Token Validation

The Go API validates JWT access tokens independently — it never calls back to the Identity service or the BFF to check a token. This is possible because tokens are signed with asymmetric keys (EdDSA/Ed25519) and the API caches the public keys from the Identity service's JWKS endpoint.

The API validates:

  • Signature — using the public key matching the token's kid from the cached JWKS
  • Algorithm — only EdDSA is accepted (Better Auth's default)
  • Issuer — must match AUTH_ISSUER (the Identity service URL)
  • Audience — must match AUTH_AUDIENCE (the API's own URL, configured via OAUTH_VALID_AUDIENCES in Identity)
  • Expiration — expired tokens are rejected with 401
  • Subject — the sub claim must be present

If validation fails, the API returns 401 with a WWW-Authenticate: Bearer realm="api" header. The BFF's middleware handles token refresh proactively (see below), so under normal operation the API rarely sees expired tokens.

Token Refresh

Access tokens are short-lived. Rather than waiting for a 401 from the Go API, the middleware proactively refreshes tokens that will expire within 60 seconds:

  1. Middleware checks accessTokenExpiresAt - 60_000 < Date.now()
  2. If expiring soon, calls the Identity /token endpoint with grant_type=refresh_token
  3. Updates the session in Valkey with new tokens and commits the updated cookie

If refresh fails (e.g., refresh token revoked), the session is destroyed and the user sees a "Session expired" error with a link to sign in again.

TLS in Local Development

Both the App and Identity make server-to-server HTTPS calls through Traefik (e.g., token exchange, JWKS fetch). Traefik uses mkcert-generated certificates, so Node.js needs to trust the mkcert root CA:

NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem

This is set in each app's .env.development and evaluated by dotenvx before the process starts. Run pnpm generate-certs from the repo root to generate the certs and trust the CA.

Without this, server-to-server calls fail with unable to get local issuer certificate.

File Map

FileResponsibility
app/lib/config.server.tsZod-validated environment config
app/lib/session.server.tsSession store + HMAC cookie helpers
app/lib/oidc.server.tsPKCE, token exchange, refresh, userinfo
app/lib/context.tsTyped React Router context definition
app/middleware/auth.server.tsRoot middleware (session check, refresh, redirect)
app/routes/auth.login.tsInitiates OIDC flow (PKCE + redirect)
app/routes/auth.callback.tsHandles OIDC callback (code exchange, session creation)
app/routes/auth.logout.tsDestroys session, ends identity session, redirects to Identity