Back to Spree

Admin Auth — Cookie-Backed Refresh Tokens

docs/plans/5.5-admin-auth-cookie-refresh.md

5.5.016.8 KB
Original Source

Admin Auth — Cookie-Backed Refresh Tokens

Status: Shipped in 5.5 Target: Spree 5.5 Depends on: 6.0-admin-spa.md Related: 6.0-platform-auth.md (broader auth direction, lands in 6.0) Author: Damian Legawiec Last updated: 2026-05-20

Summary

The Admin SPA currently stores both the JWT access token and the long-lived refresh token in localStorage, where any JavaScript on the page can read them. This plan moves the refresh token into an HttpOnly signed cookie scoped to /api/v3/admin/auth, keeps the short-lived access token in JS memory only, and introduces a real server-side logout that destroys the Spree::RefreshToken row. Server-to-server integrations using secret API keys are unaffected.

The design follows Saleor's storefront Auth SDK pattern — access token in memory, refresh token in httpOnly cookie — adapted to our existing Spree::RefreshToken rotation infrastructure. CSRF protection is delivered by the SameSite + CORS allowlist combination already in place; no separate CSRF token is issued.

Key Decisions (do not deviate without discussion)

  • Refresh token delivery: Set-Cookie on /auth/login and /auth/refresh. Never returned in the JSON response body.
  • Cookie attributes: HttpOnly; SameSite=Lax; Path=/api/v3/admin/auth in dev, HttpOnly; Secure; SameSite=None; Path=/api/v3/admin/auth in production. Cookie is signed via Rails cookies.signed for tamper-evidence.
  • Cookie name: spree_admin_refresh_token.
  • Access token storage: React state only. No localStorage, no sessionStorage.
  • CSRF protection: Provided by the combination of (a) SameSite=Lax/SameSite=None cookie attributes and (b) Spree::AllowedOrigin enforced via Rack::Cors with credentials: true. No separate CSRF token is issued or verified. Reasoning: a cross-origin attacker cannot pass the CORS preflight (Origin not on the allowlist), and SameSite blocks cross-site form/POST autosubmits independently. A double-submit token would only add value if the AllowedOrigin allowlist were misconfigured (*) or if XSS happened on a different allowlisted origin — both scenarios where deeper problems outweigh the CSRF mitigation, and where an in-memory CSRF token has the same XSS exposure as the access token anyway.
  • Cookie scope: Path=/api/v3/admin/auth so the refresh cookie is only sent to login/refresh/logout, never on /products, /orders, etc.
  • Logout: new POST /api/v3/admin/auth/logout endpoint clears the cookie via Set-Cookie with Max-Age=0 and destroys the Spree::RefreshToken row server-side (matching the hard-delete pattern in RefreshToken#rotate!). Idempotent — succeeds even when no session exists.
  • No special-casing for secret-key clients: secret-key flows never hit /auth/login (they use the key directly). The backend sets cookies unconditionally on login responses; non-browser clients ignore Set-Cookie.
  • Local development: uses Vite proxy (/api/*:3000) so the SPA is same-origin with the API. Cookie attributes are environment-conditional: SameSite=Lax without Secure in dev, SameSite=None; Secure in production.
  • Bootstrap: SPA calls /auth/refresh on cold load before rendering authenticated routes. Success → in-memory access token, render. Failure → render /login.
  • No backwards compatibility window: @spree/admin-sdk is unreleased (Developer Preview, next dist-tag) so we don't need a transition period. The 5.5 release ships the cookie-only contract directly: refresh token only via cookie, no refresh_token field in response bodies, no params[:refresh_token] body fallback.
  • Admin JWT TTL is 5 minutes (separate from storefront). A new Spree::Api::Config[:admin_jwt_expiration] (default 300s) is consumed only by Spree::Api::V3::Admin::AuthController#jwt_expiration. The general Spree::Api::Config[:jwt_expiration] (default 3600s) continues to govern Store API tokens — customer JWTs have lower blast radius and don't justify the refresh chatter. SPA's background refresh schedule is 4m30s (30s before expiry); 401-driven refresh handles the rest.
  • CORS: no changes. Spree::AllowedOrigin + credentials: true on /api/v3/admin/* already supports browser cookies.

Design Details

CookieAttributes (production)Attributes (development)Set onCleared on
spree_admin_refresh_tokenHttpOnly; Secure; SameSite=None; Path=/api/v3/admin/auth (signed)HttpOnly; SameSite=Lax; Path=/api/v3/admin/auth (signed)/auth/login, /auth/refresh (rotation)/auth/logout, refresh failure

Cookie expiry: Max-Age matches the refresh token's natural lifetime (Spree::RefreshToken config, default 30 days). On refresh, the cookie is re-issued with the new value and a fresh Max-Age (sliding window).

Why no CSRF token?

Classic CSRF assumes the attacker can trigger an authenticated request from a different origin and only needs the cookie to be auto-attached. Three independent defenses already block this for our setup:

  1. SameSite=Lax (dev) blocks cross-site POSTs entirely. The cookie won't be attached.
  2. SameSite=None; Secure (prod) is the only mode that allows cross-site cookie attachment, and it is constrained by:
  3. CORS preflight + Spree::AllowedOrigin — for any cross-origin authenticated POST, the browser sends an OPTIONS preflight. Rack::Cors echoes Access-Control-Allow-Origin only for origins on Spree::AllowedOrigin. An attacker's origin (evil.com) is not on the allowlist, the preflight fails, and the browser blocks the actual POST. The cookie is never sent.

A double-submit CSRF token would only add value in two narrow scenarios:

  • A merchant misconfigures AllowedOrigin (e.g. adds *). The CSRF token would still hold, but this is "defense against the merchant breaking their own allowlist."
  • XSS on a different allowlisted origin (e.g. their storefront). An in-memory CSRF token would technically not be readable from the storefront's JS, but at that point the attacker controls a trusted page and can do significantly more damage than CSRF would unlock.

Storing the CSRF token in localStorage or JS memory gives it the same XSS exposure profile as the access token — when XSS lands on the admin SPA itself, both leak together. So a CSRF token wouldn't add real defense over what we already have, while adding real complexity (cookie/header plumbing, edge cases around rotation, document.cookie reads). Skipped deliberately.

Backend — Spree::Api::V3::Admin::AuthController

POST /api/v3/admin/auth/login

Request body unchanged: { email, password, provider? }.

Response body:

json
{
  "token": "eyJ...",
  "user": { ... }
}

Set-Cookie header:

  • spree_admin_refresh_token=<signed_value>; HttpOnly; Secure; SameSite=None; Path=/api/v3/admin/auth; Max-Age=2592000 (production)
  • spree_admin_refresh_token=<signed_value>; HttpOnly; SameSite=Lax; Path=/api/v3/admin/auth; Max-Age=2592000 (development)

POST /api/v3/admin/auth/refresh

Request: empty body. Reads refresh token from cookies.signed[:spree_admin_refresh_token].

If cookie missing → 401 invalid_refresh_token. If cookie present but Spree::RefreshToken.active.find_by(token: ...) returns nil → clear the cookie and return 401 invalid_refresh_token (the cookie pointed to a revoked or expired row). On success → rotate the row (RefreshToken#rotate!), set the new cookie, return { token, user }.

POST /api/v3/admin/auth/logout

Request: empty body. No CSRF check; the only credential is the cookie. (CSRF defense applies via SameSite + CORS as described above.)

Behavior:

  • Look up Spree::RefreshToken.active.find_by(token: cookie_value).
  • If found, destroy! it.
  • Set-Cookie with Max-Age=0 to clear the cookie.
  • Return 204 No Content whether or not the row was found (idempotent).

sdk-corecredentials seam

RequestConfig gains an optional field:

ts
export interface RequestConfig {
  baseUrl: string
  fetchFn: typeof fetch
  retryConfig: Required<RetryConfig> | false
  credentials?: RequestCredentials  // 'omit' | 'same-origin' | 'include'
}

Threaded into the fetch() call:

ts
const response = await config.fetchFn(url.toString(), {
  method,
  headers: requestHeaders,
  body: body ? JSON.stringify(body) : undefined,
  credentials: config.credentials,
})

No behavior change for the Store SDK (omits the field). When baseUrl is empty (browser → Vite proxy), the SDK resolves the relative path against window.location.origin so new URL doesn't throw.

AdminClientConfig gains an optional field:

ts
/** Send cookies on cross-origin requests. Defaults to 'include' for cookie-based auth. */
credentials?: RequestCredentials

Default is 'include' — admin clients are always browser-driven or proxied; secret-key flows ignore cookies harmlessly.

AdminAuthResource:

ts
auth = {
  login(credentials): Promise<AuthTokens>           // POST /auth/login
  refresh(): Promise<AuthTokens>                    // POST /auth/refresh, no body
  logout(): Promise<void>                           // POST /auth/logout
}

AuthTokens is { token, user } — no refresh_token.

The constructor's secretKey || jwtToken guard is relaxed so a cookie-auth SPA can boot empty and bootstrap via auth.refresh().

Admin SPA — auth-provider.tsx

Storage:

  • Removed: TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY localStorage entries.
  • Removed: localStorage.getItem(TOKEN_KEY) from packages/dashboard/src/client.ts (initial token now empty).
  • Kept: useState for token, user, isLoading.
  • New: isInitializing state — true until cold-load /auth/refresh settles.

Bootstrap flow on mount:

ts
useEffect(() => {
  refreshAccessToken()
    .then((success) => { if (success) scheduleRefresh() })
    .finally(() => setIsInitializing(false))
}, [])

Route guards (_authenticated.tsx, login.tsx, _authenticated/index.tsx) wait for isInitializing === false before deciding to redirect. main.tsx calls router.invalidate() when auth state changes so the guards re-run with fresh context.

Login flow: unchanged shape, no localStorage writes.

Logout flow: calls adminClient.auth.logout(), then clears React state + refresh timer. Network failure on logout still clears local state (server-side row will expire naturally).

401 handler: unchanged — calls refresh, retries. The handler doesn't need to know that refresh now reads from cookies; that's encapsulated in the SDK.

Local dev — Vite proxy

packages/dashboard/vite.config.ts proxies /api/* to http://localhost:3000 so the SPA is same-origin with the Rails API. Same-origin keeps SameSite=Lax cookies working without HTTPS. Production builds (with VITE_SPREE_API_URL set to the absolute API origin) hit cross-origin and use SameSite=None; Secure.

packages/dashboard/src/client.ts:

ts
const baseUrl = import.meta.env.VITE_SPREE_API_URL || ''
export const adminClient = createAdminClient({
  baseUrl,
  // No initial token — auth-provider bootstraps via /auth/refresh on mount.
})

Migration Path

Shipping as a single coordinated PR for Spree 5.5. Backend, SDK and SPA all change at once because @spree/admin-sdk is unreleased (Developer Preview / next dist-tag) and the SPA is its only consumer — there are no external clients to coordinate with.

The work landed in two commits on the PR branch:

  • New concern Spree::Api::V3::Admin::AuthCookies (cookie helper, env-conditional attributes).
  • AuthController#create sets cookie; remove refresh_token from response body.
  • AuthController#refresh reads refresh token from cookie only.
  • AuthController#logout new action — destroys row + clears cookie.
  • Routes: add POST /api/v3/admin/auth/logout.
  • sdk-core: add credentials to RequestConfig, thread to fetch. Resolve relative URLs against window.location.origin when baseUrl is empty.
  • admin-sdk: default credentials: 'include', drop refresh_token from AuthTokens, auth.refresh() no body, auth.logout() new action, relax constructor guard.
  • Vite proxy on packages/dashboard/vite.config.ts so the SPA is same-origin with the API in dev.
  • auth-provider.tsx rewrite — in-memory token + bootstrap via /auth/refresh, no localStorage.
  • isInitializing plumbed through router context; guards in _authenticated.tsx, _authenticated/index.tsx, login.tsx.
  • main.tsx: router.invalidate() on auth state change.
  • RSpec: cookie set/clear, refresh rotation, logout destroys row, missing-cookie 401, missing-row 401 + cookie-clear.
  • Vitest: credentials passthrough, refresh sends no body, logout reaches the endpoint.
  • Changeset for @spree/admin-sdk.

No model or migration changes — reuses existing RefreshToken infrastructure.

Commit 2 — Reduce Admin JWT TTL to 5 minutes

  • New Spree::Api::Config[:admin_jwt_expiration] preference (300s default).
  • Admin::AuthController overrides jwt_expiration to read the admin-specific config.
  • SPA refresh interval shortened to 4m30s to match.
  • Storefront JWT TTL untouched (still 3600s) — customer tokens have lower blast radius.

Follow-up — OpenAPI + docs

  1. Regenerate admin OpenAPI spec.
  2. Update packages/dashboard/README.md auth section.
  3. Update developer docs in docs/developer/ if they describe the old shape.

Constraints on Current Work

Until this lands:

  • Don't add features that depend on the refresh token being readable in JS. No "show me my session expiry" UI built off the localStorage value, no analytics on token rotation timing from the client. Server-driven only.
  • Don't write new admin code that reads localStorage.getItem('spree_admin_token') outside the auth provider. The token belongs in React state and the SDK, not scattered.
  • New admin endpoints that mutate state should not reuse the Path=/api/v3/admin/auth scope — that path is reserved for auth flows specifically so the refresh cookie's blast radius stays minimal.
  • Don't broaden Spree::AllowedOrigin to *. The CORS allowlist is now load-bearing for CSRF defense, not just access control.

Open Questions

  • Multi-tab coordination: if a user logs out in tab A, tab B still has a valid in-memory access token until its next refresh attempt (which will 401, trigger a refresh that fails because the row was destroyed, and bounce to login). Acceptable, or should we add a BroadcastChannel to coordinate? Recommend: defer, ship without.
  • HMR remounts log the user out in dev: AuthProvider remount → in-memory token resets → bootstrap fires /auth/refresh which should re-hydrate. If the bootstrap is slow or the user is on a route that fires data fetches before bootstrap settles, they may see brief 401s before re-hydration completes. Production never HMRs, so this is dev-only friction.
  • Mobile app / non-browser admin clients: if anyone embeds the admin in a webview without cookie support, the cookie path breaks. Currently no known case. Document the SPA's browser-only assumption.
  • Refresh token rotation telemetry: today we log refresh rotations to Spree::RefreshToken rows. Cookie-based flow keeps this. Worth surfacing as an admin "active sessions" UI in a follow-up?
  • Instant access-token revocation: stateless JWTs are valid until their exp claim regardless of server-side state, so logout doesn't immediately kill an in-flight access token (worst case ~5 min until natural expiry). Three follow-up options exist (5-min TTL — already done; per-user tokens_invalid_before stamp; full JTI denylist with cache-backed lookup). Do nothing further unless an active-sessions UI or compliance requirement demands it.
  • Active sessions UI: Spree::RefreshToken already records ip_address + user_agent per rotation. A "see and revoke active sessions" admin screen would unlock JTI revocation as a natural follow-up. Defer.

References

  • docs/plans/6.0-platform-auth.md — broader auth direction
  • docs/plans/6.0-admin-spa.md — admin SPA architecture
  • spree/api/app/controllers/spree/api/v3/admin/auth_controller.rb — current implementation
  • spree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb — JWT layer
  • packages/dashboard/src/providers/auth-provider.tsx — SPA auth provider
  • packages/sdk-core/src/request.ts — shared HTTP layer
  • Saleor Auth SDK — reference design (refresh in cookie, access in memory)
  • OWASP — JWT for Java Cheat Sheet — storage guidance
  • MDN — SameSite cookies — cross-site behavior
  • Rack::Corscredentials: true semantics