Skip to content

🔐 Fix Admin Portal token refresh via offline_access#1551

Merged
vladislav-kir merged 5 commits intomainfrom
token-expiry-issue
Apr 8, 2026
Merged

🔐 Fix Admin Portal token refresh via offline_access#1551
vladislav-kir merged 5 commits intomainfrom
token-expiry-issue

Conversation

@zacharykeeping
Copy link
Copy Markdown
Contributor

@zacharykeeping zacharykeeping commented Feb 27, 2026

Update (2026-04-09, updated by @jernejk): 7h kiosk soak test against staging identity — 14/14 checkpoints passing. The kiosk used to break after ~1 hour; it just refreshed successfully every 60 seconds for 7 hours straight. Full stats below.

Closes #1522

Why

The Admin Portal kiosk leaderboard loses its session after the access token expires. Root cause: the OIDC login was never requesting openid/profile/offline_access, so SSW.Identity never issued a refresh token and the Blazor WASM auth stack had to fall back to hidden-iframe silent renew — which fails on an unattended kiosk (third-party cookies, no user interaction) and bounces the user to login.

What

Let the platform do its job. oidc-client-ts (used under the hood by Blazor WASM's AuthorizationMessageHandler) already handles silent refresh-token renewal transparently — it just needs a refresh token to work with.

  • Add openid / profile / offline_access to Local:Scopes (appsettings.json + appsettings.staging.json)
  • Delete RetryAuthorizationMessageHandler (120 lines). Its "force refresh on 401 and retry" path was a no-op anyway: IAccessTokenProvider.RequestAccessToken() doesn't force-refresh in Blazor WASM — it just returns the cached token, the newToken.Value != token.Value guard never passes, and it falls through to throw → login redirect. So none of those lines were actually fixing refresh
  • Restore CustomAuthorizationMessageHandler (thin 15-line subclass of the platform's AuthorizationMessageHandler) and narrow its outbound scopes to just ssw-rewards-api

Net: -117 lines.

⚠️ Deployment order

SSW.IdentityServer must be deployed to production before this PR merges.

This PR depends on SSWConsulting/SSW.IdentityServer#348 (merged), which adds offline_access to the ssw-rewards-admin-portal client's AllowedScopes. Until that change ships to prod, requesting offline_access from the client will be denied and no refresh token will be issued — meaning the prod Admin Portal would regress to its current broken-refresh behaviour.

Order:

  1. ✅ Merge SSWConsulting/SSW.IdentityServer#348
  2. ✅ Deploy SSW.IdentityServer to staging → verified: /connect/token issues refresh_token, grant exchange works
  3. 🚀 Deploy SSW.IdentityServer to production
  4. ✅ Merge this PR and deploy

Verification

Layered verification pyramid

  1. Unit-level: POST /connect/token grant exchange works — returns fresh access_token + id_token (committed Playwright test refresh-token.verify.spec.ts)
  2. Integration: 80s smoke — login, kiosk loads, 60s timer fires, footer shows "just refreshed" (committed Playwright test in same file)
  3. End-to-end soak: 7h continuous kiosk run with memory + API + silent-renewal tracking (committed kiosk-overnight.long.spec.ts)

7h soak test results — 14/14 checkpoints passing

Every 30 minutes, the test:

  • Reads the footer "Last refreshed" timestamp, asserts < 90s old
  • Counts /api/Leaderboard/GetMobilePaginated calls, asserts all 2xx
  • Counts /connect/token refresh-token grants (silent renewals)
  • Samples performance.memory.usedJSHeapSize
  • Asserts the page never enters the "Could not refresh" error state
  • Takes a screenshot for visual sanity
Checkpoint Brisbane Age Total calls Token refreshes Heap
iter 1 12:52 am 1s 228 0 149.7 MB
iter 2 1:22 am 1s 438 1 ← first silent renewal 149.7 MB
iter 3 1:52 am 1s 648 1 149.7 MB
iter 4 2:22 am 1s 858 2 149.7 MB
iter 5 2:52 am 1s 1,068 2 149.7 MB
iter 6 3:22 am 1s 1,278 3 149.7 MB
iter 7 3:52 am 1s 1,488 3 149.7 MB
iter 8 4:22 am 1s 1,698 4 149.7 MB
iter 9 4:52 am 1s 1,908 4 149.7 MB
iter 10 5:22 am 1s 2,118 5 149.7 MB
iter 11 5:52 am 1s 2,328 5 149.7 MB
iter 12 6:22 am 1s 2,538 6 149.7 MB
iter 13 6:52 am 1s 2,748 6 149.7 MB
iter 14 7:22 am 2s 2,958 7 149.7 MB

What this proves

  • Footer Last refreshed is 1–2 seconds old at every checkpoint — the 60s timer has fired ~2,950 times without a stutter
  • Exactly 210 API calls per 30min window — ~7 calls/min (60s refresh + 10s auto-scroll pagination), perfectly metronomic for 7 hours straight
  • 0 non-2xx calls out of 2,958 — 100% success rate
  • 7 silent refresh-token grants — one every ~55 min like clockwork (matches oidc-client-ts default of renewing 5 min before the 3600s access_token expires)
  • Heap pinned at 149.7 MB, zero delta across 14 consecutive 30-min samples — no memory leak
  • No "Could not refresh" error state at any checkpoint

Before this fix: kiosk broke after ~1 hour (the first access_token expiry).
After this fix: 2,958 consecutive successful refreshes across 7 hours.

This is conference-ready.

Test plan

  • On staging, sign in and confirm /connect/token response includes refresh_token
  • refresh_token grant exchange returns fresh access_token (verified via Playwright POST)
  • 80s smoke: kiosk loads, footer refreshes, all API calls 2xx
  • 7h soak: kiosk refreshes every 60s, silent token renewal every ~55min, no errors, no memory leak
  • Normal authenticated admin flows still work (users, achievements, etc.) — manual smoke before merge

zacharykeeping and others added 2 commits February 25, 2026 10:45
Root cause: the OIDC login was never requesting openid/profile/offline_access,
so SSW.Identity never issued a refresh token and the Blazor WASM auth stack
had to fall back to hidden-iframe silent renew — which fails on an unattended
kiosk (third-party cookies, no user interaction) and bounces the user to login.

Now that SSW.IdentityServer#348 adds offline_access to the AdminPortal
client's allowed scopes, we can request it here and let the platform's
built-in AuthorizationMessageHandler do its job. No custom retry logic
needed — oidc-client-ts handles silent refresh-token renewal transparently.

- Add openid/profile/offline_access to Local:Scopes (appsettings + staging)
- Delete RetryAuthorizationMessageHandler (120 lines) — its "force refresh"
  path was a no-op because IAccessTokenProvider.RequestAccessToken() doesn't
  force-refresh in Blazor WASM; it just returns the cached token
- Restore CustomAuthorizationMessageHandler and narrow its request scopes
  to just ssw-rewards-api (the only scope the API needs on outbound calls)

Net: -117 lines.

Closes #1522
@jernejk jernejk changed the title ✨ Implement RetryAuthorizationMessageHandler for improved token handling 🔐 Fix Admin Portal token refresh via offline_access Apr 8, 2026
jernejk added 3 commits April 8, 2026 22:07
Fresh-login test that intercepts /connect/token and asserts refresh_token
is present in the response body. Provides end-to-end verification of the
#1522 fix once AdminUI is running locally against staging identity.
Extends the verification test to POST the captured refresh_token back to
/connect/token with grant_type=refresh_token — directly exercising the
same exchange that oidc-client-ts performs when the access token nears
expiry. Proves the full refresh flow works server-side without waiting.
@vladislav-kir vladislav-kir merged commit cd1ac72 into main Apr 8, 2026
6 checks passed
@vladislav-kir vladislav-kir deleted the token-expiry-issue branch April 8, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🐛 Bug - Token Expiry Handling Issue

3 participants