Skip to content

Auth flow

cms-api runs two independent auth models, one per audience:

  • Admin / editorial (/admin/v1): JWT (HS256) + Redis-backed sessions.
  • Worker (/public/v2): a single shared API key presented as a bearer token.

The public v1 block (/public/v1) is open and read-only; it has no auth dependency.

Admin auth: JWT + Redis sessions

The admin model is mirrored from skynet-app-api. It lives in cms_api.authentication.utils and is wired by cms_api.authentication.routes.

Tokens wrap a session token

A successful login mints a random session token (secrets.token_hex(16)) for each of an access and a refresh session, stores them in Redis against the resolved User, and returns JWTs that merely wrap those session tokens:

  • generate_jwt_token(data) signs an HS256 JWT carrying data (e.g. {"token": <session token>}) plus an exp claim. The exp is enforced by jwt.decode, so the token is rejected once it expires, independently of the Redis session TTL.
  • decode_jwt_session_token(token) decodes a JWT and returns the inner session token, raising the 401 invalid_token_exception when the JWT is invalid or carries no session claim.

ACCESS_TOKEN_EXPIRE is 24 hours; the Redis session TTL (TOKEN_TTL) is 3 hours, matching skynet-app-api.

Validating a request

Two FastAPI dependencies gate authenticated routes:

  • validate_access_token -- decodes the JWT, looks the wrapped session token up in Redis via RedisConnector.get_user_info, and returns the live User. A missing, malformed, expired, or unknown-session token raises invalid_token_exception (401). When a request is present it stamps request.state.user_id.
  • validate_admin -- calls validate_access_token, then additionally requires a platform-admin role (is_platform_admin(user.role)), raising user_is_not_admin (403) otherwise. This is the block-level dependency on admin_app, so it runs for every admin route except login.

Login / refresh / logout

The auth routes are mounted under /admin/v1 but outside the admin gate:

Endpoint Behavior
POST /admin/v1/auth/login OAuth2 password grant. Verifies credentials with verify_pbkdf2_hash, checks the user is active, mints an access/refresh JWT pair, and stores the session in Redis. Returns a Token. 401 on unknown user, bad password, or inactive user.
POST /admin/v1/auth/refresh Exchanges a refresh JWT for a new access JWT via RedisConnector.refresh_session. 401 when the refresh token is invalid/expired.
POST /admin/v1/auth/logout Revokes the bearer access session and its paired refresh session. 401 when the JWT is invalid.

Passwords are hashed with salted PBKDF2/SHA-1 (hash_pbkdf2 / verify_pbkdf2_hash); the stored hash is prefixed with p and a 32-byte salt.

sequenceDiagram
    participant C as Admin client
    participant API as cms-api (/admin/v1)
    participant R as Redis

    C->>API: POST /auth/login (username, password)
    API->>API: verify_pbkdf2_hash + active check
    API->>R: create_user_session(access, refresh, user, TTL=3h)
    API-->>C: Token { access_token (JWT, exp=24h), refresh_token }

    C->>API: GET /admin/v1/me  (Authorization: Bearer <access JWT>)
    API->>API: validate_admin -> decode JWT, read "token" claim
    API->>R: get_user_info(session token)
    R-->>API: User
    API->>API: is_platform_admin(user.role)?  (else 403)
    API-->>C: 200 User

The UserRepository seam

The login route depends on a UserRepository abstraction (cms_api.authentication.repository) so the user lookup can be swapped:

  • MockUserRepository -- an in-memory mapping used by tests; never touches a DB.
  • CmsUserRepository -- the default; queries the CMS DB (get_user_by_email) through a process-wide SqlAdminStore and maps the row to the auth User.

Tests override the dependency with app.dependency_overrides[get_user_repository].

Worker auth: API key on /public/v2

The v2 block is gated by validate_worker_api_key (cms_api.authentication.worker), attached as a block-level dependency in main.py. It requires Authorization: Bearer <worker-key>:

Condition Result
WORKER_API_KEY not configured 503 -- the service refuses open v2 reads.
Missing header or not Bearer ... 401 -- missing/invalid Authorization.
Token mismatch 401 -- compared with secrets.compare_digest (constant time).
Valid token request proceeds.

The worker key is resolved from fs_config (/<env>/cms-api/worker-key) at config load; in prd/stg a missing key is fatal, while test/local use an os.environ override (CMS_WORKER_API_KEY) or a deterministic dev key.

Preview secret (versioned v2 reads)

The v2 pages endpoint supports ?version=N reads from content_history, gated by a separate preview secret (cfg.PREVIEW_SECRET, resolved from /common/cms-api/preview-secret):

  • The feature is boot-safe: when no preview secret is provisioned (PREVIEW_SECRET is None) a ?version request is rejected with 503 rather than bricking the endpoint.
  • When the secret is set, the X-CMS-Preview request header is compared with hmac.compare_digest; a wrong or missing header is rejected with 403 before any history lookup.

A plain (non-versioned) GET on the pages endpoint never touches this gate.

See the autodoc in API reference / Authentication for the full signatures and docstrings.