let d = import "defaults.ncl" in d.make_adr { id = "adr-005", title = "Unified Key-to-Session Auth Model Across CLI, UI, and MCP", status = 'Accepted, date = "2026-03-13", context = "ontoref-daemon exposes project knowledge and mutations over HTTP, a browser UI, and an MCP server. Projects can define argon2id-hashed keys in project.ncl (with role admin|viewer and an audit label). Prior to this ADR, the UI login flow set a session cookie but the REST API accepted raw passwords as Bearer tokens on every request — each call paying ~100ms for argon2 verification. The CLI had no Bearer support at all; project-add and project-remove called the daemon without credentials. The daemon manage page had no admin identity concept. There was no way to enumerate or revoke active sessions.", decision = "All surfaces exchange a raw key once via POST /sessions for a UUID v4 bearer token (30-day lifetime, in-memory SessionStore). The session token is used for all subsequent calls — O(1) DashMap lookup. Sessions carry a stable public id (distinct from the bearer token) for safe list/revoke operations without leaking credentials. Project keys have a label field for audit trail. Daemon-level admin uses a separate argon2id hash (ONTOREF_ADMIN_TOKEN_FILE preferred over ONTOREF_ADMIN_TOKEN) and creates sessions under virtual slug '_daemon'. The CLI injects ONTOREF_TOKEN as Authorization: Bearer automatically via bearer-args in store.nu. Key rotation (PUT /projects/{slug}/keys) revokes all active sessions for the rotated project. GET /sessions and DELETE /sessions/{id} implement two-tier visibility: project admin sees own project sessions; daemon admin sees all.", rationale = [ { claim = "Token exchange eliminates per-request argon2 cost", detail = "argon2id verification takes ~100ms by design (brute-force resistance). Verifying on every CLI invocation, MCP tool call, or UI AJAX request is prohibitive. A UUID v4 session token turns auth into a DashMap::get — O(1), sub-microsecond. The argon2 cost is paid exactly once per login.", }, { claim = "session.id != bearer token prevents session enumeration attacks", detail = "The list endpoints (GET /sessions) return SessionView with a stable public id. If the bearer token were exposed in list responses, a project admin could steal another admin's token and impersonate them. The id field is a second UUID v4 — safe to expose, useless as a credential.", }, { claim = "is_uuid_v4 fast-path preserves backward compatibility", detail = "check_primary_auth tries the session token path first (UUID v4 format check is a length + byte comparison, ~10ns). If the bearer is not UUID-shaped, it falls through to argon2 password verification. Existing integrations using raw passwords continue to work without change.", }, { claim = "Virtual '_daemon' slug isolates admin sessions from project sessions", detail = "Daemon admin sessions are not scoped to any real project. The '_daemon' slug cannot collide with real project slugs (kebab-case, no leading underscore). AdminGuard checks for Role::Admin across any registered project or '_daemon', giving the manage page a clean auth boundary without coupling it to any specific project.", }, { claim = "ONTOREF_ADMIN_TOKEN_FILE preferred over inline env var", detail = "An inline hash in ONTOREF_ADMIN_TOKEN appears in `ps aux` output and shell history. Reading from a file (ONTOREF_ADMIN_TOKEN_FILE) avoids both surfaces. The file contains only the argon2id hash string; the actual password never touches the daemon process env.", }, { claim = "Key rotation invalidates sessions atomically", detail = "When keys are rotated, all in-flight sessions for that project authenticated against the old key set are immediately invalid. revoke_all_for_slug scans the DashMap and removes matching entries including the id_index. Actor sessions (ontoref-daemon actors registry) are also invalidated. The UI will receive 401 on next request and redirect to login.", }, ], consequences = { positive = [ "REST API, MCP, and UI all use the same session token — single auth model, no surface-specific logic", "CLI project-add, project-remove, and notify-daemon-* calls carry credentials automatically when ONTOREF_TOKEN is set", "Session list gives admins visibility into who is connected to their project", "Revocation is O(1) via id_index secondary index; bulk revocation on key rotation is O(active sessions for slug)", "Daemon admin and project admin are cleanly separated — '_daemon' sessions cannot access project-scoped endpoints and vice versa", ], negative = [ "Sessions are in-memory only — daemon restart requires all clients to re-authenticate", "30-day token lifetime means a leaked token is valid for up to 30 days unless explicitly revoked", "ONTOREF_TOKEN in shell env is visible to child processes — use a secrets manager or short-lived token refresh for automated pipelines", ], }, alternatives_considered = [ { option = "Verify argon2 on every Bearer request (no session concept)", why_rejected = "~100ms per request is unacceptable for CLI invocations (each command is a new HTTP call) and MCP tool sequences (multiple calls per agent turn). Session token lookup is sub-microsecond.", }, { option = "Axum middleware for auth instead of per-handler check_primary_auth", why_rejected = "Middleware applies uniformly to all routes. ontoref-daemon coexists auth-enabled and open projects on the same router — some endpoints are always public (health, POST /sessions itself), some require project-scoped auth, some require daemon admin. Per-handler checks encode these rules explicitly; middleware would require complex route exemption logic.", }, { option = "Expose bearer token in session list responses", why_rejected = "GET /sessions is accessible to project admins. Exposing the bearer would allow any project admin to impersonate any other session holder. The public session.id is a safe substitute for revocation targeting.", }, { option = "Separate token store per project", why_rejected = "A single DashMap keyed by token with a slug field in SessionEntry is sufficient. A secondary id_index DashMap gives O(1) revoke-by-id. Per-project sharding would add complexity without benefit given the expected session count (tens, not millions).", }, { option = "JWT instead of opaque UUID v4 tokens", why_rejected = "JWTs are self-contained and cannot be revoked without a denylist. Opaque tokens enable instant revocation (key rotation, logout, admin force-revoke) with O(1) lookup. The daemon is local-only — there is no distributed verification scenario that would justify JWT complexity.", }, ], constraints = [ { id = "session-token-never-in-list-response", claim = "GET /sessions responses must never include the bearer token, only the public session id", scope = "crates/ontoref-daemon/src/session.rs, crates/ontoref-daemon/src/api.rs", severity = 'Hard, check_hint = "rg 'SessionView' crates/ontoref-daemon/src/session.rs — verify no 'token' field exists in SessionView", rationale = "Exposing bearer tokens in list responses would allow admins to impersonate other sessions. The session.id field is a second UUID v4, safe to expose.", }, { id = "post-sessions-no-auth-required", claim = "POST /sessions must not require authentication — it is the credential exchange endpoint", scope = "crates/ontoref-daemon/src/api.rs", severity = 'Hard, check_hint = "rg -A5 'route.*sessions.*post' crates/ontoref-daemon/src/api.rs — must not call require_session or check_primary_auth", rationale = "Requiring auth to obtain auth is a bootstrap deadlock. Rate-limiting on failure is the correct mitigation, not pre-authentication.", }, { id = "key-rotation-must-invalidate-sessions", claim = "PUT /projects/{slug}/keys must call revoke_all_for_slug before persisting new keys", scope = "crates/ontoref-daemon/src/api.rs", severity = 'Hard, check_hint = "rg 'revoke_all_for_slug' crates/ontoref-daemon/src/api.rs — must appear in project_update_keys handler", rationale = "Sessions authenticated against the old key set become invalid after rotation. Failing to revoke them would leave stale sessions with elevated access.", }, { id = "bearer-args-injected-by-cli", claim = "All CLI HTTP calls to the daemon must use bearer-args from store.nu — no hardcoded curl without auth args", scope = "reflection/modules/store.nu, reflection/bin/ontoref.nu", severity = 'Soft, check_hint = "rg 'curl -sf' reflection/bin/ontoref.nu — every occurrence should use ...(bearer-args) or http-get/http-post-json/http-delete helpers", rationale = "ONTOREF_TOKEN is the single credential source for CLI. Direct curl without bearer-args bypasses the auth model silently.", }, ], related_adrs = ["adr-002-daemon-for-caching-and-notification-barrier"], ontology_check = { decision_string = "unified key-to-session token exchange; opaque UUID v4 bearer; session.id != token; revocation on key rotation", invariants_at_risk = ["no-enforcement"], verdict = 'RequiresJustification, }, invariant_justification = { invariant = "no-enforcement", claim = "Auth is opt-in per project. A project with no keys configured runs in open mode — all endpoints accessible without credentials. The protocol itself never mandates auth.", mitigation = "The no-enforcement axiom applies at the protocol level. Auth is a project-level operational decision, not a protocol constraint. Open deployments (no keys) pass through all auth checks unconditionally.", }, }