Skip to content

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:

  1. The refresh_token returned by Cognito during token exchange is discarded — not stored in the cookie
  2. The ProxyHandler only extracts credentials from HTTP headers (Authorization, X-API-Key) — it does not read session cookies
  3. 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

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.

The extract_credential() function gains a third source: the session cookie. Priority order:

  1. Authorization: Bearer <token> header (highest priority)
  2. X-API-Key: <key> header
  3. 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
  • ProxyHandler gains optional OIDC dependencies (session_cookie, oidc_service) — None when OIDC is not configured
  • extract_credential() backward compatible — new session_data parameter defaults to None
  • 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