Signed-off-by: Alan Brault <alan.brault@visus.io>
6.1 KiB
Authentication & Security
This document covers how the API handles authentication, token lifecycle, and access control.
Overview
The API uses JWE (JSON Web Encryption) tokens — AES-256-GCM encrypted — rather than plain signed JWTs. Clients cannot inspect token claims. The flow is stateless: no server-side sessions. All state is carried in tokens or stored in MongoDB.
Two token types are in play:
| Token | Lifetime | Purpose |
|---|---|---|
| Access token | 1 hour | Authenticate API requests via Authorization: Bearer |
| Refresh token | 30 days | Exchange for a new access token without re-logging in |
Token Structure
Both token types share a base structure but carry a type claim ("access" or "refresh") that prevents cross-use — the API rejects a refresh token presented as a Bearer token and vice versa.
Access token claims:
| Claim | Value |
|---|---|
sub |
User email |
type |
"access" |
authorities |
Array of roles, e.g. ["ROLE_USER"] |
jti |
UUIDv7 — unique token ID used for revocation lookup |
iat / exp / iss |
Standard JWT timestamps and issuer |
Refresh token claims: sub, type, jti, iat, exp, iss (no authorities).
Encryption: JWE with dir key management and A256GCM content encryption, keyed from JWT_ENCRYPTION_SECRET (Base64-encoded 32-byte AES key).
Authentication Flow
Login
POST /v1/auth/login
Content-Type: application/json
{ "email": "user@example.com", "password": "..." }
- User is looked up by email in MongoDB.
- Password is verified via Argon2 (memory-hard, GPU-resistant).
- A new access token and a refresh token family are created.
- Both tokens are returned.
{
"accessToken": "<jwe>",
"refreshToken": "<jwe>",
"tokenType": "Bearer",
"expiresIn": 3600
}
Authentication errors always return 401 regardless of whether the email exists, to prevent account enumeration.
Using the Access Token
Pass the access token in every request to a protected endpoint:
Authorization: Bearer <accessToken>
The JwtAuthenticationFilter runs on every request:
- Extracts and decrypts the Bearer token.
- Checks the token's
jtiagainst the MongoDB revocation blocklist. - Loads the user and sets the security context.
- Unauthenticated requests pass through; endpoint authorization rules handle the rejection.
Token Refresh
POST /v1/auth/refresh
Content-Type: application/json
{ "refreshToken": "<jwe>" }
Returns a new access token and a new refresh token. The old refresh token is immediately invalidated. See Refresh Token Rotation & Theft Detection below.
Logout
POST /v1/auth/logout
Authorization: Bearer <accessToken>
- The current access token's
jtiis added to the revocation blocklist. - All refresh tokens for the user are revoked.
The blocklist entry lives until the access token's own exp, then MongoDB TTL removes it automatically — the blocklist never grows unboundedly.
Refresh Token Rotation & Theft Detection
Every login starts a token family (tracked by familyId). Each refresh produces a new token in the same family; the previous token is marked revoked.
Theft detection: If a refresh token is presented and it's already marked revoked, but its family still has an active sibling, the system infers a stolen token was reused:
- The entire token family is revoked (all active sessions from that login).
- A security event is logged with the attacker's IP.
- Both the legitimate owner and the attacker are forced to re-authenticate.
If a revoked token's family has no active siblings, it's treated as a simple re-consumption (returns 401 without the family wipe).
Session limits: Each user may have at most 5 active refresh token families (concurrent sessions). When the limit is reached, the oldest session is automatically revoked.
Roles & Access Control
Two roles are defined:
| Role | Description |
|---|---|
ROLE_USER |
Default for all registered accounts |
ROLE_ADMIN |
Required for user management operations |
Authorization is enforced at two levels:
- Route level —
SecurityConfigpermits public routes and rejects unauthenticated requests to protected ones. - Method level —
@PreAuthorize("hasRole('ROLE_ADMIN')")on admin controllers.
Endpoint authorization summary:
| Endpoint | Auth | Role |
|---|---|---|
POST /v1/auth/login |
— | — |
POST /v1/auth/refresh |
— | — |
POST /v1/auth/logout |
Bearer | Any |
GET /v1/users/me |
Bearer | Any |
GET /v1/users |
Bearer | ROLE_ADMIN |
POST /v1/admin/users |
Bearer | ROLE_ADMIN |
GET /v1/health |
— | — |
Password Storage
Passwords are hashed with Argon2 via the password4j library. Only the hash is stored; plaintext is never persisted. Comparison uses constant-time evaluation.
MongoDB Collections
| Collection | Purpose | TTL |
|---|---|---|
users |
User accounts (email unique-indexed) | — |
refresh_tokens |
Active and revoked refresh tokens (hash-stored, not plaintext) | expiresAt |
revoked_access_tokens |
Access token blocklist by jti |
expiresAt |
TTL indexes on expiresAt in both token collections ensure automatic cleanup with no manual housekeeping.
Error Responses
All errors return a consistent structure:
{
"status": 401,
"message": "Invalid credentials",
"path": "/v1/auth/login"
}
| Scenario | Status |
|---|---|
| Bad credentials / unknown email | 401 |
| Invalid or expired token | 401 |
| Token reuse / security breach | 401 |
| Authenticated but insufficient role | 403 |
| Validation error | 400 |
Configuration
| Variable | Description |
|---|---|
JWT_ENCRYPTION_SECRET |
Base64-encoded 32-byte AES key (required) |
jwt.access-token-expiration |
Access token TTL in ms (default: 3600000 — 1 hour) |
jwt.refresh-token-expiration |
Refresh token TTL in ms (default: 2592000000 — 30 days) |
jwt.issuer |
Issuer claim value (default: kotlin-api) |
Generate a secret:
openssl rand -base64 32 | tr -d '\n'