# ADR 0003 — JWT access tokens, opaque rotating refresh tokens, session security

- Status: Accepted
- Date: 2026-08-19

## Context

Phase 5 adds authentication: login with lockout, token issuance/validation,
refresh with reuse detection, logout, and `/users/me`, plus the stateless
security filter chain.

## Decisions

### 1. Spring Security OAuth2 Resource Server + Nimbus, HS256 symmetric

Access tokens are **HS256 JWTs** signed with a symmetric secret (`JWT_SECRET`).
We use `spring-boot-starter-oauth2-resource-server` so the security filter chain
validates the signature, expiry, issuer, and audience for free, and populates a
`JwtAuthenticationToken`. HS256 (over RSA/EC) fits a single-service deployment
and matches the `JWT_SECRET` the spec already provisions in CI; asymmetric keys
would add key-distribution complexity with no benefit here. The signing key is
validated at startup to be ≥32 bytes (fail fast). Roles travel in a `roles`
claim mapped to authorities with no extra prefix (they are already `ROLE_*`).

### 2. Refresh tokens are opaque, hashed, and rotated with reuse detection

Refresh tokens are 32 random bytes (never JWTs), stored only as a SHA-256 hash
in `refresh_tokens`. Each refresh **rotates**: the presented token is revoked,
its successor recorded via `replaced_by_id`, and a new pair returned. Presenting
an already-**rotated** token (revoked with a successor) is treated as theft and
**revokes the entire chain** for that user (`TOKEN_REUSE_DETECTED`). A token
revoked for another reason — logout, or an earlier chain revocation — is simply
`INVALID_REFRESH_TOKEN`, with no chain action. The state changes on the failure
paths (lockout counter increment, chain revocation) use `noRollbackFor` so they
survive the thrown 401, and the bulk revoke uses `clearAutomatically`/
`flushAutomatically` so the persistence context cannot serve a stale token.

### 3. Login is manual, not DaoAuthenticationProvider

Credentials are verified directly in `AuthService.login` rather than through
`DaoAuthenticationProvider`/`UserDetailsService`. This gives precise control over
the required behaviours that the standard provider fights: lockout after N failed
attempts, distinct `ACCOUNT_NOT_ACTIVATED` (403) vs `ACCOUNT_LOCKED` (423)
outcomes, and **no enumeration** — unknown email and wrong password both return
`401 INVALID_CREDENTIALS`, and an unknown email still runs a dummy Argon2 verify
to equalize timing. Request-time authentication comes from the self-contained
JWT, so no `UserDetailsService` is needed; `SecurityUser` was therefore not
introduced (deviating from the spec's suggested class list, per §1).

### 4. Stateless chain, RFC 7807 auth errors, prod-only HSTS

Sessions are stateless, CSRF disabled (no cookies), CORS env-configurable
(`app.cors.allowed-origins`). A custom entry point and access-denied handler emit
RFC 7807 problems with stable codes (`UNAUTHENTICATED` 401, `ACCESS_DENIED` 403),
consistent with the rest of the API. Security headers (frame-deny, referrer
policy) are always on; **HSTS is enabled only under the `prod` profile** since it
is meaningful only over TLS. Actuator exposes only `/health` and `/info`
publicly; items require `ROLE_USER`; everything else requires authentication.

## Consequences

- `auth.service` is under the coverage gate: 100% line / 92% branch.
- Verified by an authorization-matrix IT (anonymous, valid, expired, tampered,
  wrong-issuer, missing-role) and a full-journey IT (login → me → items →
  refresh → reuse-detected → chain revoked; and logout).
- Deferred (Phase 6): forgot/reset password, activation resend, revoke-all on
  reset, and rate limiting on `/auth/**`.
