ADR 010 Authentication Architecture - steilerDev/cornerstone GitHub Wiki
Accepted. The OIDC account model (automatic provisioning on first login) is superseded by
ADR-035: OIDC never self-registers and links existing accounts by
verified email instead. Password hashing (§3) is amended twice: the implementation uses Node's
built-in crypto.scrypt, not argon2, and since #2122 at N = 2^17 (see Amendments). All other decisions on this page stand.
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.
Amended (2026-02-16, PR #72; recorded 2026-10-02). The implementation replaced
argon2with Node's built-incrypto.scrypt. See Amendments. The text below is the original decision.
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. No longer applies:argon2was removed (see Amendments). -
OIDC testing: Testing OIDC flows requires either a real OIDC provider or mocking the
openid-clientlibrary. This adds complexity to integration tests.
Change. §3 chose the argon2 package (argon2id). PR #72 replaced it with Node's built-in
crypto.scrypt (hashPassword/verifyPassword in server/src/services/userService.ts):
N = 16384, r = 8, p = 1, a 16-byte random salt and a 64-byte key, stored as a PHC-style
$scrypt$n=…,r=…,p=…$<salt>$<hash> string and compared with timingSafeEqual. (These were the
parameters until #2122; the next amendment raises N. Hashes made with them are now the "legacy" set.)
Why. The prebuilt argon2 musl binary crashed (SIGSEGV, exit 139) inside the production image
during E2E container tests. scrypt is in Node core, so there is no native addon to build, ship or keep
ABI-compatible. It was the alternative this ADR already listed, and it is memory-hard. The cost is the
one the ADR named: parameters are hand-tuned rather than library defaults.
Consequences. The argon2 native-dependency bullet under Consequences no longer applies.
GET /api/health/ready performs a hash round-trip, so a broken hashing setup fails readiness (since
#2122 only until the first success per process; see the next amendment). The
change was made without updating this page; the Schema page carried "Argon2id" until 2026-10-02 (see
its Deviation Log).
Change. New hashes use N = 131072 (2^17), r = 8, p = 1, the OWASP minimum for scrypt. One
hash needs about 128 MiB (128 · N · r bytes). maxmem is set to that requirement plus 128 MiB of
headroom, about 256 MiB. Salt (16 bytes), key length (64 bytes), the PHC-style string format and the
timingSafeEqual comparison are unchanged.
-
Accepted parameter sets.
verifyPasswordreads N, r and p from the stored string but accepts only the current set (131072, 8, 1) and the legacy set (16384, 8, 1). Any other set, or a malformed string, verifies asfalsewithout running scrypt, so a tampered row cannot make the server spend arbitrary memory or CPU. -
Rehash on login. After a successful local login (and after the failed-attempt counter is reset),
a hash that is not on the current set is rehashed with the current parameters and stored. This is
best-effort: a failure is logged at
warnand the login still succeeds. The write is a compare-and-swap on the old hash (WHERE id = ? AND password_hash = <old>), so a password changed concurrently is never overwritten. The rehash does not changeupdated_at. Legacy hashes therefore disappear as users sign in; there is no bulk migration, and an account that never signs in keeps its legacy hash. - Dummy hash. Logins for an unknown email, or for an account without a password, verify against a dummy hash so they take as long as a real check. The dummy is generated once per process with the current parameters (lazily, on first use), so it cannot drift from them. A failed login against a legacy-parameter hash also runs one dummy verification after the cheap legacy check. A wrong password for a legacy account therefore costs at least as much as one for an unknown email, and the response time does not reveal which accounts still hold a legacy hash.
-
Concurrency cap. Every scrypt derivation (hash, verify, dummy, readiness) goes through one
in-process FIFO semaphore that allows
MAX_CONCURRENT_SCRYPT = 2derivations at a time. Peak scrypt memory is therefore about 2 × 128 MiB, however many logins arrive at once. Extra requests queue instead of exhausting memory or the libuv threadpool. A release hands its slot directly to the next waiter, and a failed derivation still releases its slot. The wait queue is bounded atMAX_SCRYPT_QUEUE_LENGTH = 50. A caller arriving at a full queue is rejected at once withPasswordHashingBusyError(429 RATE_LIMIT_EXCEEDED, the same body for every account). An unbounded backlog would make every queued login wait behind a flood, so real users would time out anyway. Login records a wrong password before the legacy dummy verification, so a full queue cannot skip the lockout counter. -
Readiness.
GET /api/health/readyruns its hash round-trip once and caches only a success. A failure clears the cache, so the next probe retries. TheSELECT 1database check still runs on every call. Without the cache the 30-second DockerHEALTHCHECKwould allocate about 128 MiB twice per probe. -
Tests.
setPasswordHashParamsForTesting(throws unlessNODE_ENV=test) lets the suites hash at N = 16384 for speed. The override set is added to the accepted sets while it is active.
Why. N = 16384 is an eighth of the OWASP minimum. The deployment (fewer than 5 users, one container) can afford about 128 MiB per login, and the login rate limit bounds how many run.
Consequences.
- Each local login, password change, setup and admin-created local account costs about 128 MiB of transient memory and noticeably more CPU than before. Concurrent derivations are capped at two, so peak scrypt memory stays near 256 MiB. Beyond that, extra logins wait in the queue (added latency) instead of adding memory. Past 50 waiters, requests fail fast with 429 (API Contract, Password-Hashing Queue).
- The first login for an unknown email in a process pays for generating the dummy hash as well as verifying it. After that, the timing is level.
- Raising the parameters again means: add the new set as current, keep the previous set accepted as legacy, and let rehash-on-login migrate rows. Dropping a set from the accepted list locks out every account still on it.
| Date | Section | Deviation | Resolution |
|---|---|---|---|
| 2026-10-02 | Amendment 2026-10-02 (#2122) | PR #2170 review. The amendment said concurrent logins add up and the container needs that headroom. The code caps scrypt derivations at two with a FIFO semaphore. The amendment also did not mention the extra dummy verification on a failed legacy-hash login, or the compare-and-swap on rehash. | Added the "Concurrency cap" bullet, the legacy-failure dummy verification, and the compare-and-swap. The memory consequence is corrected to the capped peak. |
| 2026-10-02 | Amendment 2026-10-02 (#2122) | PR #2170 round 2 added a bound of 50 on the scrypt wait queue, with fail-fast PasswordHashingBusyError (429 RATE_LIMIT_EXCEEDED), and moved recordFailedLogin ahead of the legacy dummy verification. The amendment described the queue as unbounded. |
Added the queue bound, the 429, the reasoning and the lockout ordering to the Concurrency cap bullet and to Consequences. |