docs/plans/5.5-admin-auth-cookie-refresh.md
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
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.
Set-Cookie on /auth/login and /auth/refresh. Never returned in the JSON response body.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.spree_admin_refresh_token.localStorage, no sessionStorage.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.Path=/api/v3/admin/auth so the refresh cookie is only sent to login/refresh/logout, never on /products, /orders, etc.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./auth/login (they use the key directly). The backend sets cookies unconditionally on login responses; non-browser clients ignore Set-Cookie./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./auth/refresh on cold load before rendering authenticated routes. Success → in-memory access token, render. Failure → render /login.@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.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.Spree::AllowedOrigin + credentials: true on /api/v3/admin/* already supports browser cookies.| Cookie | Attributes (production) | Attributes (development) | Set on | Cleared on |
|---|---|---|---|---|
spree_admin_refresh_token | HttpOnly; 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).
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:
SameSite=Lax (dev) blocks cross-site POSTs entirely. The cookie won't be attached.SameSite=None; Secure (prod) is the only mode that allows cross-site cookie attachment, and it is constrained by: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:
AllowedOrigin (e.g. adds *). The CSRF token would still hold, but this is "defense against the merchant breaking their own allowlist."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.
Spree::Api::V3::Admin::AuthControllerPOST /api/v3/admin/auth/loginRequest body unchanged: { email, password, provider? }.
Response body:
{
"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/refreshRequest: 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/logoutRequest: empty body. No CSRF check; the only credential is the cookie. (CSRF defense applies via SameSite + CORS as described above.)
Behavior:
Spree::RefreshToken.active.find_by(token: cookie_value).destroy! it.Set-Cookie with Max-Age=0 to clear the cookie.204 No Content whether or not the row was found (idempotent).sdk-core — credentials seamRequestConfig gains an optional field:
export interface RequestConfig {
baseUrl: string
fetchFn: typeof fetch
retryConfig: Required<RetryConfig> | false
credentials?: RequestCredentials // 'omit' | 'same-origin' | 'include'
}
Threaded into the fetch() call:
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.
@spree/admin-sdk — cookie auth + logoutAdminClientConfig gains an optional field:
/** 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:
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().
auth-provider.tsxStorage:
TOKEN_KEY, REFRESH_TOKEN_KEY, USER_KEY localStorage entries.localStorage.getItem(TOKEN_KEY) from packages/dashboard/src/client.ts (initial token now empty).useState for token, user, isLoading.isInitializing state — true until cold-load /auth/refresh settles.Bootstrap flow on mount:
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.
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:
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.
})
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:
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.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.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.@spree/admin-sdk.No model or migration changes — reuses existing RefreshToken infrastructure.
Spree::Api::Config[:admin_jwt_expiration] preference (300s default).Admin::AuthController overrides jwt_expiration to read the admin-specific config.4m30s to match.packages/dashboard/README.md auth section.docs/developer/ if they describe the old shape.Until this lands:
localStorage.getItem('spree_admin_token') outside the auth provider. The token belongs in React state and the SDK, not scattered.Path=/api/v3/admin/auth scope — that path is reserved for auth flows specifically so the refresh cookie's blast radius stays minimal.Spree::AllowedOrigin to *. The CORS allowlist is now load-bearing for CSRF defense, not just access control.BroadcastChannel to coordinate? Recommend: defer, ship without./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.Spree::RefreshToken rows. Cookie-based flow keeps this. Worth surfacing as an admin "active sessions" UI in a follow-up?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.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.docs/plans/6.0-platform-auth.md — broader auth directiondocs/plans/6.0-admin-spa.md — admin SPA architecturespree/api/app/controllers/spree/api/v3/admin/auth_controller.rb — current implementationspree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb — JWT layerpackages/dashboard/src/providers/auth-provider.tsx — SPA auth providerpackages/sdk-core/src/request.ts — shared HTTP layerSameSite cookies — cross-site behaviorcredentials: true semantics