-
Notifications
You must be signed in to change notification settings - Fork 2
ADR 010 Authentication Architecture
Accepted
Cornerstone needs an authentication system that supports two flows:
- 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.
- 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.
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).
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.
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."
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
fetchand 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.
Chosen: A two-layer Fastify hook system:
-
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. -
Authorization decorator (
requireRole('admin')): A route-levelpreHandlerthat 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
onRequesthook: Earlier in the lifecycle thanpreHandler. Would work butpreHandlerruns 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.
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:
-
AuthProvidercallsGET /api/auth/meon mount - While loading, show a spinner/skeleton
- If
setupRequired, render the setup page - If
useris null and notsetupRequired, render the login page - If
useris 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.
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_atfor 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.
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.
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_atat 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.
-
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-clientlibrary handles provider-specific quirks via the OIDC discovery protocol. Switching providers only requires changing environment variables.
- 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
argon2package requires a C compiler duringnpm install. This is already handled by the Docker builder stage (apk add build-base python3) and is consistent with thebetter-sqlite3precedent. -
OIDC testing: Testing OIDC flows requires either a real OIDC provider or mocking the
openid-clientlibrary. This adds complexity to integration tests.