feat(auth): auto-retry OIDC callback storage errors + 30s /auth/me heartbeat #413

Merged
YousifShkara merged 1 commit from feat/MAPPS-355-auth-callback-retry-plus-heartbeat into main 2026-07-03 13:29:03 +02:00
Owner

Two closely-coupled SPA auth-UX fixes.

  1. /auth/callback no longer shows the red Sign-in failed / storage: no pending OIDC flow / Try again wall for the common transient cases. That error surfaces from storage::take_pending() when sessionStorage is empty - a user opening /auth/callback in a new tab, refreshing after MAPPS-336 stripped the query string, or letting a pending flow expire (>1 week per MAPPS-338). Nothing an operator needs to look at; the user just needs a fresh flow. The callback now detects any storage: prefixed FlowError, silently window.location.replace("/login") (which kicks start_login), and renders the same Signing you in... copy the healthy exchange path shows so there is no visible red flash. CSRF (state mismatch), replay (nonce mismatch), token-endpoint, config, and network errors keep the manual screen because a silent retry could loop on them or mask a real problem.

replace (not set_href) so the browser's back button never bounces the user back to the errored callback URL after the fresh flow finishes.

  1. New use_auth_heartbeat mounted at the App root alongside use_token_refresh. Every 30 seconds it fires an authed GET /api/v1/auth/me. On a 410 response the shared fetch layer's handle_response (added by MAPPS-348) calls note_account_deleted, which flips the ACCOUNT_DELETED GlobalSignal and pops the terminal AccountDeletedOverlay sitting at AppLayout root. So the hook itself discards the response - it only needs to fire the request.

Without this loop the SPA only discovers a soft-delete when the user happens to touch a page that fetches. A user idle on the dashboard after their Bunyip account was deleted could stare at stale UI for the full at+jwt TTL (15 min) before any request fired. 30s cadence puts the "you were signed out" overlay in front of them fast without meaningful cost (~120 requests per hour per active tab, well under the token-refresh loop's own cadence). Heartbeat skips itself when:

  • no access token in the holder (unauthenticated / booting), OR
  • the tab is hidden (document.visibilityState == "hidden"), OR
  • ACCOUNT_DELETED is already set (overlay is up).

Cargo.toml: add VisibilityState to the web-sys feature list so tab_is_hidden() compiles.

Dependencies:

  • The 410 code path in hooks::fetch already exists (MAPPS-348 SPA merged as 77aff3f). Until mokosh-server MAPPS-348 (PR #421) lands and returns 410 for tombstoned users, the heartbeat is quiet (200 on healthy sessions, no-op on 401); it becomes user-visible once the server side lands.
  • use_token_refresh already runs its own 30s loop next door; the auth-heartbeat piggybacks on the same tick shape (gloo_timers::future::TimeoutFuture) so the two loops interleave cleanly without a shared scheduler.

#MAPPS-355

Two closely-coupled SPA auth-UX fixes. 1) `/auth/callback` no longer shows the red `Sign-in failed / storage: no pending OIDC flow / Try again` wall for the common transient cases. That error surfaces from `storage::take_pending()` when sessionStorage is empty - a user opening `/auth/callback` in a new tab, refreshing after MAPPS-336 stripped the query string, or letting a pending flow expire (>1 week per MAPPS-338). Nothing an operator needs to look at; the user just needs a fresh flow. The callback now detects any `storage:` prefixed FlowError, silently `window.location.replace("/login")` (which kicks `start_login`), and renders the same `Signing you in...` copy the healthy exchange path shows so there is no visible red flash. CSRF (`state mismatch`), replay (`nonce mismatch`), token-endpoint, config, and network errors keep the manual screen because a silent retry could loop on them or mask a real problem. `replace` (not `set_href`) so the browser's back button never bounces the user back to the errored callback URL after the fresh flow finishes. 2) New `use_auth_heartbeat` mounted at the App root alongside `use_token_refresh`. Every 30 seconds it fires an authed `GET /api/v1/auth/me`. On a 410 response the shared fetch layer's `handle_response` (added by MAPPS-348) calls `note_account_deleted`, which flips the `ACCOUNT_DELETED` GlobalSignal and pops the terminal `AccountDeletedOverlay` sitting at `AppLayout` root. So the hook itself discards the response - it only needs to fire the request. Without this loop the SPA only discovers a soft-delete when the user happens to touch a page that fetches. A user idle on the dashboard after their Bunyip account was deleted could stare at stale UI for the full at+jwt TTL (15 min) before any request fired. 30s cadence puts the "you were signed out" overlay in front of them fast without meaningful cost (~120 requests per hour per active tab, well under the token-refresh loop's own cadence). Heartbeat skips itself when: - no access token in the holder (unauthenticated / booting), OR - the tab is hidden (`document.visibilityState == "hidden"`), OR - `ACCOUNT_DELETED` is already set (overlay is up). `Cargo.toml`: add `VisibilityState` to the `web-sys` feature list so `tab_is_hidden()` compiles. Dependencies: - The 410 code path in `hooks::fetch` already exists (MAPPS-348 SPA merged as 77aff3f). Until mokosh-server MAPPS-348 (PR #421) lands and returns 410 for tombstoned users, the heartbeat is quiet (200 on healthy sessions, no-op on 401); it becomes user-visible once the server side lands. - `use_token_refresh` already runs its own 30s loop next door; the auth-heartbeat piggybacks on the same tick shape (`gloo_timers::future::TimeoutFuture`) so the two loops interleave cleanly without a shared scheduler. #MAPPS-355
feat(auth): auto-retry OIDC callback storage errors + 30s /auth/me heartbeat
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 1m30s
Create release / Create release from merged PR (pull_request) Has been skipped
cdd6574695
Two closely-coupled SPA auth-UX fixes.

1) `/auth/callback` no longer shows the red `Sign-in failed / storage: no pending OIDC flow / Try again` wall for the common transient cases. That error surfaces from `storage::take_pending()` when sessionStorage is empty - a user opening `/auth/callback` in a new tab, refreshing after MAPPS-336 stripped the query string, or letting a pending flow expire (>1 week per MAPPS-338). Nothing an operator needs to look at; the user just needs a fresh flow. The callback now detects any `storage:` prefixed FlowError, silently `window.location.replace("/login")` (which kicks `start_login`), and renders the same `Signing you in...` copy the healthy exchange path shows so there is no visible red flash. CSRF (`state mismatch`), replay (`nonce mismatch`), token-endpoint, config, and network errors keep the manual screen because a silent retry could loop on them or mask a real problem.

`replace` (not `set_href`) so the browser's back button never bounces the user back to the errored callback URL after the fresh flow finishes.

2) New `use_auth_heartbeat` mounted at the App root alongside `use_token_refresh`. Every 30 seconds it fires an authed `GET /api/v1/auth/me`. On a 410 response the shared fetch layer's `handle_response` (added by MAPPS-348) calls `note_account_deleted`, which flips the `ACCOUNT_DELETED` GlobalSignal and pops the terminal `AccountDeletedOverlay` sitting at `AppLayout` root. So the hook itself discards the response - it only needs to fire the request.

Without this loop the SPA only discovers a soft-delete when the user happens to touch a page that fetches. A user idle on the dashboard after their Bunyip account was deleted could stare at stale UI for the full at+jwt TTL (15 min) before any request fired. 30s cadence puts the "you were signed out" overlay in front of them fast without meaningful cost (~120 requests per hour per active tab, well under the token-refresh loop's own cadence). Heartbeat skips itself when:
- no access token in the holder (unauthenticated / booting), OR
- the tab is hidden (`document.visibilityState == "hidden"`), OR
- `ACCOUNT_DELETED` is already set (overlay is up).

`Cargo.toml`: add `VisibilityState` to the `web-sys` feature list so `tab_is_hidden()` compiles.

Dependencies:
- The 410 code path in `hooks::fetch` already exists (MAPPS-348 SPA merged as 77aff3f). Until mokosh-server MAPPS-348 (PR #421) lands and returns 410 for tombstoned users, the heartbeat is quiet (200 on healthy sessions, no-op on 401); it becomes user-visible once the server side lands.
- `use_token_refresh` already runs its own 30s loop next door; the auth-heartbeat piggybacks on the same tick shape (`gloo_timers::future::TimeoutFuture`) so the two loops interleave cleanly without a shared scheduler.

#MAPPS-355
YousifShkara deleted branch feat/MAPPS-355-auth-callback-retry-plus-heartbeat 2026-07-03 13:29:03 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-apps!413
No description provided.