# ADR 0009 — Authentication and Session

**Status:** Accepted

## Context

[Security and Trust](../architecture/security-and-trust.md) specifies authorization in depth — `Principal = Human | Agent | System`, capability ceilings, `authorityContext` narrowing — but *authentication* is unspecified. [ADR 0001](0001-control-plane-and-three-plane-topology.md) assigns the gateway "auth/session ingress" and [Transport and Contracts > Authentication Boundary](../architecture/transport-and-contracts.md) says the gateway "may establish a session", with nothing behind the words: no identity provider, no login flow, no session mechanism, no token format, no session table in the Drizzle schema.

The dogfood phase has exactly one human operator. Introducing an external IdP, self-registration, org/tenant modelling or a token service now would add operational surface that I0/I1 cannot justify, while leaving the seam undefined would block the gateway and every mutation endpoint that must produce a `MutationContext.authority`.

## Decision

Single-operator authentication: one operator credential exchanged at the gateway for a server-side session; session state in PostgreSQL; the control plane re-resolves the `Principal` from that session on every request.

```text
operator
  -> POST /auth/login  { password }            gateway: rate limit, forward only
  -> control plane POST /internal/auth/sessions
       argon2id verify against AWP_OPERATOR_PASSWORD_HASH (env, SecretStore-backed)
       mint 256-bit random opaque session token
       INSERT sessions row  (SHA-256 hash of token, principal_id, expiry)
       return token to gateway
  -> gateway: Set-Cookie: __Host-awp_session=<token>; HttpOnly; Secure; SameSite=Lax; Path=/

subsequent request
  -> gateway reads cookie, forwards authenticated internal call carrying the
     session reference (never a Principal or capability set)
  -> control plane: hash -> sessions lookup -> not expired/revoked
       -> load Principal (id, kind "human", capability ceiling)
       -> authorityContext(principal, requested, projectId?) -> MutationContext
  -> unresolvable session => 401, fail closed
```

Exact semantics:

```text
cookie name      __Host-awp_session   (implies Secure, Path=/, no Domain)
cookie value     base64url of 32 random bytes; never stored in plaintext
attributes       HttpOnly; Secure; SameSite=Lax; Path=/
storage          sessions(id, token_hash unique, principal_id, created_at,
                          last_seen_at, expires_at, revoked_at, user_agent_digest)
absolute expiry  30 days from creation, never extended
idle expiry      7 days of inactivity; last_seen_at refreshed at most once/5 min
logout           POST /auth/logout -> gateway forwards; control plane sets
                 revoked_at; gateway clears the cookie
rotation         token rotated on login; password change revokes all sessions
db access        only the control plane reads/writes sessions; the gateway
                 holds no PostgreSQL connection and stores no session state
```

Origin and CSRF: `apps/web` and the gateway API are served from **one public origin** — the gateway is the sole public ingress under ADR 0001 — so `__Host-` + `SameSite=Lax` is the primary CSRF control. In addition: no state-changing operation is reachable by `GET`, and the gateway rejects mutations whose `Origin`/`Sec-Fetch-Site` indicates a cross-site request. No double-submit token in dogfood.

Explicitly **not** decided here: self-registration, password reset, multi-user/org/tenant identity, MFA, agent/system Principal authentication (agents and CI receive scoped short-lived credentials per [Security and Trust](../architecture/security-and-trust.md), not sessions), CLI/MCP token format, and cross-origin web hosting.

Upgrade path: an `IdentityProvider` port with an OIDC adapter (authorization code + PKCE) replaces the credential check at **I9**; the session cookie, `sessions` table and control-plane Principal resolution survive unchanged because they are already IdP-independent. Serving `apps/web` from a separate origin is a superseding Decision that must re-open the CSRF stance.

## Consequences

Positive: revocation is immediate (server-side session), no signing-key rotation or JWT expiry-vs-revocation gap, no browser-accessible token, and the control plane — not the gateway — remains the authority that resolves `Principal` and capabilities, preserving the ADR 0001 trust boundary and the "forwarded arbitrary headers are insufficient" invariant. Authorization semantics never change when the IdP arrives.

Cost: one PostgreSQL read on every authenticated request (mitigable later by a short-TTL cache with revocation checks, not by trusting the gateway) and an operator password hash managed as an environment/SecretStore value on the control plane. The `sessions` table and migrations are now implemented, including token-hash-only persistence and operator-credential-version binding. The gateway stays credential-free, database-free and edge-portable: it forwards login/logout and sets or clears a cookie.

## Rejected Alternatives

- external OIDC/IdP now — operational and configuration cost with one user, and no authorization semantics change deferred by it;
- stateless signed JWT as the session — revocation and rotation complexity for no benefit when both parties share one PostgreSQL;
- bearer token in `localStorage` — XSS-readable, and loses `HttpOnly`/`__Host-` protection;
- gateway asserting a `Principal`/capability header the control plane trusts — violates the ADR 0001 trust boundary;
- gateway holding a PostgreSQL connection to verify the credential and store sessions itself — gives the edge plane direct database authority and breaks the "public ingress and realtime fan-out only", edge/serverless-portable gateway of [Deployment Topology](../architecture/deployment-topology.md);
- self-registration or user CRUD before multi-user is a product requirement;
- HTTP Basic per request — no revocation, no logout, credential replayed on every call.

## Increment Realization

I0 login/logout/session table/control-plane Principal resolution; I1 dogfood use unchanged; I9 OIDC adapter, multi-user, org/tenant isolation and enterprise audit per the [realization matrix](../../plans/AWP-INCREMENT-REALIZATION-MATRIX.md).

## Supersession Rule

Any move of Principal/capability resolution out of the control plane, any browser-readable session credential, or any cross-origin web hosting requires a superseding Decision and a threat-model update.
