Skip to content

ADR 010 Authentication Architecture

Claude product-architect (Opus 4.6) edited this page Feb 22, 2026 · 2 revisions

ADR-010: Authentication Architecture

Status

Accepted

Context

Cornerstone needs an authentication system that supports two flows:

  1. Local admin authentication -- An initial setup flow where the first user creates a local admin account with email/password. This bootstraps the application and serves as a fallback when OIDC is not configured.
  2. OIDC authentication -- OpenID Connect as the primary authentication mechanism, with automatic user provisioning on first login.

Key requirements from the project requirements (Section 2.5, 3.3, 5):

  • OIDC as primary authentication (supporting Keycloak, Auth0, Okta, Google, Azure AD, Authentik)
  • Local admin authentication for initial setup and fallback
  • Automatic user provisioning on first OIDC login
  • Session management across page reloads
  • Two roles: Admin (full access) and Member (create/edit)
  • Self-hosted application behind a reverse proxy (HTTPS handled upstream)
  • Fewer than 5 users per instance

This ADR documents the key architectural decisions for the auth system.

Decisions

1. Session Storage: Server-Side SQLite + HttpOnly Cookie

Chosen: Server-side sessions stored in the sessions SQLite table, with a session token delivered via an HttpOnly cookie.

Alternatives considered:

  • JWT (JSON Web Tokens): Stateless tokens containing user claims, signed with a server secret. JWTs avoid database lookups on every request but cannot be revoked without a blocklist (which reintroduces state). For an app with <5 users, the "scalability" benefit of JWTs is irrelevant, and the inability to instantly invalidate sessions on user deactivation is a security concern.
  • Fastify session plugin (@fastify/session): Provides session management out of the box with various store backends. Adds a dependency and abstraction layer that may not align with our specific needs (SQLite storage, custom cookie configuration). The implementation is straightforward enough to build directly.

Rationale: Server-side sessions in SQLite are the simplest approach for our scale. They allow instant session invalidation (critical for user deactivation), require no additional infrastructure, and the session table is co-located with the rest of the data. The cookie-based delivery avoids the security pitfalls of localStorage-based token storage (XSS vulnerability).

2. Session Token: 256-bit crypto.randomBytes Hex

Chosen: crypto.randomBytes(32).toString('hex') producing a 64-character hex string.

Alternatives considered:

  • UUID v4 (crypto.randomUUID()): 122 bits of randomness. Sufficient for most purposes but session tokens benefit from maximum entropy since they are the sole authentication credential.
  • Base64 encoding: More compact (44 chars vs 64 chars for 32 bytes), but hex is simpler to handle (no URL-encoding needed, no + or / characters).

Rationale: 256 bits of cryptographic randomness provides a security margin far beyond what is needed. Hex encoding is universally safe in all contexts (URLs, cookies, headers, logs) without escaping.

3. Password Hashing: argon2 (argon2id variant)

Chosen: The argon2 npm package using the argon2id variant with OWASP-recommended parameters.

Alternatives considered:

  • bcrypt: Well-established and widely used. However, bcrypt has a 72-byte input limit (silently truncating longer passwords) and is not recommended by OWASP for new applications.
  • scrypt: Available in Node.js core (crypto.scrypt). Good security properties but less standardized for password hashing and requires manual parameter tuning.

Rationale: Argon2id is the current OWASP recommendation for password hashing. It is resistant to both GPU attacks (memory-hard) and side-channel attacks (data-independent memory access). The argon2 npm package is a well-maintained C binding that compiles natively (acceptable for server-side use per our dependency policy). Default parameters (memory cost 65536 KiB, time cost 3, parallelism 4) provide strong security for our use case.

Note: argon2 is a native C addon. This is acceptable per the project's dependency policy: "Native addons for the server (e.g., better-sqlite3) are acceptable since the Docker builder can install build tools."

4. OIDC Library: openid-client v6

Chosen: The openid-client npm package (v6.x), a certified OpenID Connect Relying Party implementation.

Alternatives considered:

  • Manual OIDC implementation: Building the Authorization Code flow from scratch using fetch and JWT verification. This would avoid a dependency but is error-prone: OIDC has many subtle security requirements (nonce validation, token signature verification, discovery endpoint parsing).
  • passport + passport-openidconnect: The Passport ecosystem provides OIDC support but is designed for Express, adds significant abstraction, and the OIDC strategy has limited maintenance.

Rationale: openid-client is the de facto standard for OIDC in Node.js. It is OpenID Certified, handles discovery, token exchange, and ID token validation correctly, and has no native dependencies (pure JavaScript). Version 6 uses modern ESM and the Fetch API internally, aligning with our stack.

5. Route Protection: Fastify preHandler Hooks (Layered)

Chosen: A two-layer Fastify hook system:

  1. Authentication hook (authenticate): Registered globally on all /api/* routes. Reads the session cookie, looks up the session in SQLite, loads the user, and attaches the user to the request. Skips specific public routes (setup, login, health, OIDC endpoints). Returns 401 for invalid/expired sessions.
  2. Authorization decorator (requireRole('admin')): A route-level preHandler that checks the authenticated user's role. Returns 403 for insufficient permissions.

Alternatives considered:

  • Route-level authentication: Each route handler checks authentication individually. Simple but error-prone (easy to forget on new routes, leading to security gaps).
  • Fastify onRequest hook: Earlier in the lifecycle than preHandler. Would work but preHandler runs after parsing and validation, which means the request body is available if needed for logging.

Rationale: Global authentication with explicit exemptions follows the "secure by default" principle. New routes are automatically protected. The layered approach (authentication + authorization) keeps concerns separate and composable.

6. Frontend Auth: React Context + useAuth() Hook

Chosen: An AuthContext React context with a useAuth() hook that provides the current user, loading state, and auth actions (login, logout, setup).

Design:

interface AuthContextValue {
  user: UserResponse | null;
  isLoading: boolean;
  setupRequired: boolean;
  oidcEnabled: boolean;
  login: (email: string, password: string) => Promise<void>;
  logout: () => Promise<void>;
  setup: (email: string, displayName: string, password: string) => Promise<void>;
  refreshAuth: () => Promise<void>;
}

App initialization flow:

  1. AuthProvider calls GET /api/auth/me on mount
  2. While loading, show a spinner/skeleton
  3. If setupRequired, render the setup page
  4. If user is null and not setupRequired, render the login page
  5. If user is present, render the main app

Alternatives considered:

  • Redux/Zustand global store: Overkill for auth state in an app with <5 users. React Context is sufficient.
  • Route-based protection only: Check auth in route loaders. This works but duplicates the auth check across routes; a context provider centralizes it.

Rationale: React Context is the idiomatic React approach for app-wide state that does not change frequently (user session). The useAuth() hook provides a clean API for components to access auth state and actions.

7. User IDs: crypto.randomUUID()

Chosen: UUID v4 strings generated by Node.js crypto.randomUUID().

Alternatives considered:

  • Auto-incrementing integers: Simple and compact, but leak information about user count and creation order. Also problematic if the app ever needs to merge databases.
  • CUID/ULID: Time-sortable identifiers. Unnecessary for users (we have created_at for sorting) and add a dependency.

Rationale: UUIDs are universally understood, do not leak information, and are generated by the Node.js standard library with no additional dependencies. The TEXT storage cost in SQLite is negligible for <5 users.

8. Cookie Flags: HttpOnly + SameSite=Strict + Secure

Chosen: HttpOnly=true, SameSite=Strict, Secure=true (configurable for dev).

Flag Value Purpose
HttpOnly true Prevents JavaScript access to the cookie (XSS mitigation)
SameSite Strict Prevents the cookie from being sent on cross-site requests (CSRF mitigation)
Secure Configurable (SECURE_COOKIES env var, default true) Cookie only sent over HTTPS in production
Path / Cookie available for all routes (needed for OIDC callback)

Rationale: This is the most restrictive cookie configuration possible. SameSite=Strict is preferred over Lax because Cornerstone does not need cross-site cookie delivery (it is a standalone app, not embedded in another site). The Secure flag is configurable because local development typically runs without TLS.

9. Session Lifetime and Cleanup

Chosen: 7-day session lifetime (configurable via SESSION_DURATION env var), with lazy cleanup of expired sessions.

Cleanup strategy: Expired sessions are deleted during periodic cleanup triggered by a Fastify interval (e.g., every hour) rather than on every request. This avoids adding latency to every request while still preventing unbounded session table growth.

Alternatives considered:

  • Per-request cleanup: Delete expired sessions on every authentication check. Simple but adds a write operation to every request.
  • No cleanup (rely on expiry check): Sessions remain in the table forever, filtered by expires_at at query time. Works but wastes space and slows down queries over time.

Rationale: An hourly cleanup interval is a reasonable balance for <5 users. The session table will never grow large, so even without cleanup the performance impact would be minimal, but periodic cleanup keeps the table tidy.

Consequences

Easier

  • Adding new protected routes: All new /api/* routes are automatically protected. Only public routes need explicit exemption.
  • Instant session revocation: Deactivating a user immediately invalidates all their sessions by deleting rows from the sessions table. No token blocklists needed.
  • Client simplicity: The useAuth() hook provides a single entry point for all auth state and actions. Components do not need to manage cookies or tokens.
  • OIDC provider flexibility: The openid-client library handles provider-specific quirks via the OIDC discovery protocol. Switching providers only requires changing environment variables.

More Difficult

  • Horizontal scaling: Server-side sessions are stored in SQLite, which does not support multi-node deployments. This is acceptable for <5 users on a single container, but would need redesign if the deployment model changed (noted for future reference, not a current concern).
  • Native dependency (argon2): The argon2 package requires a C compiler during npm install. This is already handled by the Docker builder stage (apk add build-base python3) and is consistent with the better-sqlite3 precedent.
  • OIDC testing: Testing OIDC flows requires either a real OIDC provider or mocking the openid-client library. This adds complexity to integration tests.

Clone this wiki locally