18. Refresh token flow
Date: 2026-03-17
Status
Accepted
Context
After Fase 4, the OIDC PKCE browser login produces a session cookie containing access_token and id_token (the id_token was later removed — see D1). However:
- The
refresh_tokenreturned by Cognito during token exchange is discarded — not stored in the cookie - The
ProxyHandleronly extracts credentials from HTTP headers (Authorization,X-API-Key) — it does not read session cookies - When the access token expires (typically 1 hour for Cognito), the user must re-authenticate via the full OIDC login flow
This ADR addresses the refresh token lifecycle: storage, extraction, and automatic renewal.
Decision
D1: Store refresh token in the session cookie — id_token excluded
The refresh token is added to the HMAC-signed session cookie alongside access_token only. The id_token is intentionally not stored. This extends ADR-0015.
Why stateless: The proxy is designed for single-instance deployment (no shared session store). The cookie is HttpOnly, Secure, SameSite=lax — the refresh token cannot be accessed by JavaScript and is protected against CSRF.
Cookie payload: { access_token, refresh_token, _ts } — two fields only.
Cookie size: access_token (~1-2KB) + refresh_token (~1.5KB) + JSON/base64 overhead ≈ 3-4KB, within the 4096-byte browser cookie limit.
Why not id_token: The id_token is never read back from the cookie — extract_credential() uses only access_token, and the refresh path uses only refresh_token. Including the id_token (~1KB) alongside both other Cognito tokens causes the total payload to exceed the 4096-byte browser limit, causing browsers to silently discard the cookie (no error, no warning — the session simply never starts).
Alternative rejected: Server-side refresh token store (Redis/database) — adds external dependency and operational complexity for a single-instance proxy.
D2: Proactive refresh in proxy handler + explicit /auth/refresh endpoint
Two refresh mechanisms:
| Mechanism | Trigger | Use case |
|---|---|---|
| Proactive (in ProxyHandler) | Access token expired when proxying a request | Transparent to the client — request is retried with new token |
Explicit (POST /auth/refresh) |
Client calls the endpoint | SPAs that want to refresh tokens programmatically |
Why proactive: Avoids returning 401 to the browser for an expired token when a valid refresh token is available. The user experience is seamless — the proxy handles renewal transparently and injects an updated Set-Cookie in the response.
Alternative rejected: Reactive refresh (forward expired token to upstream, catch 401, then refresh and retry) — adds a wasted round-trip to the upstream on every expired request.
D3: Cookie-based credential extraction
The extract_credential() function gains a third source: the session cookie. Priority order:
Authorization: Bearer <token>header (highest priority)X-API-Key: <key>header- Session cookie
access_token(lowest priority)
Why lowest priority: Header-based credentials are explicit and should always take precedence. The cookie is a fallback for browser-based sessions where no Authorization header is sent.
D4: TokenRefreshResult domain type
A dedicated result type for refresh operations, separate from TokenExchangeResult:
@dataclass(frozen=True, slots=True)
class TokenRefreshResult:
success: bool
access_token: str | None = None
id_token: str | None = None
error: str | None = None
Why separate from TokenExchangeResult: Cognito refresh responses return access_token and id_token but NOT a new refresh_token — the original refresh token remains valid. A dedicated type makes this explicit and prevents confusion.
Route summary
| Route | Method | Purpose |
|---|---|---|
/auth/login |
GET | Redirect to OIDC provider with PKCE S256 (existing) |
/auth/callback |
GET | Exchange code, set cookie with access + refresh token — id_token excluded (modified) |
/auth/refresh |
POST | New — explicit token refresh from cookie |
/auth/logout |
POST | Clear session cookie (existing) |
Proactive refresh flow in ProxyHandler
Browser → ProxyHandler.handle(request)
├── Read session cookie → verify HMAC → extract session_data
├── extract_credential(authorization, api_key, session_data)
├── authenticator.authenticate(credential)
│ └── Failed ("Token has expired") AND session_data has refresh_token?
│ ├── YES → oidc_service.refresh(refresh_token)
│ │ ├── Success → re-authenticate with new token → forward upstream
│ │ │ + inject Set-Cookie with updated tokens
│ │ └── Failure → 401
│ └── NO → 401
├── authorizer.authorize(...)
└── upstream_client.forward(...)
Wiring changes
wire_oidc_flow() returns an OIDCWiringResult dataclass exposing auth_handler, session_cookie, oidc_service, and cookie_name. These are threaded to ProxyHandler via create_app().
Consequences
- Refresh tokens are stored in the HMAC-signed cookie — no server-side state needed
- Browser sessions survive access token expiry transparently (proactive refresh)
- SPAs can explicitly refresh via
POST /auth/refresh ProxyHandlergains optional OIDC dependencies (session_cookie,oidc_service) —Nonewhen OIDC is not configuredextract_credential()backward compatible — newsession_dataparameter defaults toNone- Cookie size increases by ~200B (refresh token) — still under 4KB limit
- Concurrent requests with expired tokens all succeed — Cognito does not rotate refresh tokens
References
- ADR-0015 PKCE S256 and Session Cookie — Session cookie design
- ADR-0014 Async Ports for OIDC — Async port pattern
- ADR-0017 Cognito Adapter Specialization — Cognito token handling
- AWS Cognito Token Endpoint —
grant_type=refresh_token