1
0
Files
KotlinApiDemo/docs/authentication-and-security.md
2026-06-05 07:59:29 -04:00

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": "..." }
  1. User is looked up by email in MongoDB.
  2. Password is verified via Argon2 (memory-hard, GPU-resistant).
  3. A new access token and a refresh token family are created.
  4. 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:

  1. Extracts and decrypts the Bearer token.
  2. Checks the token's jti against the MongoDB revocation blocklist.
  3. Loads the user and sets the security context.
  4. 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>
  1. The current access token's jti is added to the revocation blocklist.
  2. 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:

  1. The entire token family is revoked (all active sessions from that login).
  2. A security event is logged with the attacker's IP.
  3. 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 levelSecurityConfig permits 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'