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 carryingdata(e.g.{"token": <session token>}) plus anexpclaim. Theexpis enforced byjwt.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 401invalid_token_exceptionwhen 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 viaRedisConnector.get_user_info, and returns the liveUser. A missing, malformed, expired, or unknown-session token raisesinvalid_token_exception(401). When arequestis present it stampsrequest.state.user_id.validate_admin-- callsvalidate_access_token, then additionally requires a platform-admin role (is_platform_admin(user.role)), raisinguser_is_not_admin(403) otherwise. This is the block-level dependency onadmin_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-wideSqlAdminStoreand maps the row to the authUser.
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?versionrequest is rejected with 503 rather than bricking the endpoint. - When the secret is set, the
X-CMS-Previewrequest header is compared withhmac.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.