Replaces URL-embedded JWT tokens with a single-use, 60-second handoff code that the SPA exchanges via server-to-server POST. The URL now contains only `?handoff=<code>` — no tokens are placed in the browser history, proxy access logs, or Referer header. Backend: new SsoHandoff store (DashMap, 60s TTL, atomic DashMap::remove for single-use), POST /api/v1/auth/sso/handoff endpoint, 7 new tests. Frontend: SsoCallbackPage rewritten to use useSearchParams + POST exchange, with history.replaceState to clear the handoff code from the address bar. Switched from window.location.search to useSearchParams() for test compatibility. New Vitest infrastructure (vitest, @testing-library/react, jsdom) and 6 new tests. CI fix in ccba9e3: cargo fmt --all and added searchParams to useEffect dep array to satisfy CI's Rust Format and Frontend Lint checks. Refs: closes #4
14 KiB
SSO Token Handoff — Specification
Issue: #4
Component: crates/pm-web/src/routes/sso.rs, frontend/src/pages/SsoCallbackPage.tsx, frontend/src/store/authStore.ts
Spec version: 0.1.0 (draft)
Status: Awaiting Kelly sign-off
1. Goal
Stop embedding JWT access tokens, refresh tokens, and user objects in the SSO callback redirect URL. Today, after a successful OIDC login, the backend 302-redirects the browser to the SPA with the tokens in the query string:
https://app.example.com/auth/sso/callback
?access_token=<jwt>
&refresh_token=<raw>
&token_type=Bearer
&expires_in=900
&user=<urlencoded-json>
Tokens in URLs are written to browser history, intermediate proxy and
load-balancer access logs, and may leak via the Referer header when
the landing page loads third-party resources. The refresh token is
the most sensitive value (long-lived, rotating) and gets the worst
exposure.
Replace the URL-embedded tokens with a single-use, short-lived handoff code that the SPA exchanges for tokens via a server-to-server POST. The URL then contains only the code, which expires in 60 seconds and is invalidated on first use.
2. Non-Goals
- Changing the OIDC flow itself (Authorization Code + PKCE stays the same).
- Changing the MFA verification path that runs after the OIDC callback.
- Touching the WS ticket pattern (issue #10) — this spec is a new
in-memory store for SSO handoff codes, mirroring but separate from
ws_tickets: Arc<DashMap<String, WsTicket>>. - Adding cookie-based or
form_postdelivery. The handoff code approach was selected over those (Kelly sign-off Q1). - Long-lived SSO sessions. The handoff code is single-use; subsequent SSO logins re-issue a new code.
3. Design Decisions (Kelly sign-off, 2026-06-02)
| # | Question | Resolution |
|---|---|---|
| Q1 | Approach selection | Handoff code (option C in issue #4). Mirrors the existing WS-ticket pattern. URL contains only a single-use, 60s handoff_code. SPA POSTs to /api/v1/auth/sso/handoff and gets tokens in the JSON response. |
| Q2 | Cookie attributes | N/A — handoff code approach uses no cookies. |
| Q3 | Rollout strategy | Hard cutover — remove the old query-string parsing in the same PR. No dual-read window. (Justification: security-critical fix, deploy window is short, no in-flight SSO logins survive a rolling restart because the auth state is in the user's browser, not on the server.) |
| Q4 | Secure cookie flag |
N/A — handoff code approach uses no cookies. Kelly's answer ("unconditionally secure") is noted for future cookie work but does not apply here. |
4. Design
4.1 Backend: SSO callback (crates/pm-web/src/routes/sso.rs)
The sso_callback handler currently constructs a redirect URL with all
token values. Replace this with a handoff code generation step:
- After the access/refresh tokens and
user_jsonare computed (the existing logic throughsso_callbackis unchanged up to the redirect construction), generate a cryptographically randomhandoff_code(32 bytes, base64url-encoded, ~43 chars). - Store the handoff payload in a new in-memory map:
Mirrors the
pub struct SsoHandoff { pub access_token: String, pub raw_refresh: String, pub user_json: Value, pub access_ttl: u64, pub expires_at: Instant, // now + 60s } pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,WsTicketstruct (single-use, in-memory, TTL enforced on read). The map is added toAppStatealongsidews_tickets. - Build the redirect URL with ONLY the handoff code:
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code); Ok(Redirect::to(&redirect_url)) - Log the handoff creation (without the code value itself) for audit:
tracing::info!(user_id = %user.id, auth_provider, "SSO handoff issued");
4.2 Backend: Handoff exchange endpoint
New handler POST /api/v1/auth/sso/handoff:
- Request body:
{ "handoff_code": "<code>" } - Behavior:
- Look up
handoff_codeinsso_handoffs(DashMap read lock). - If not found →
400 invalid_handoff. - If found but
expires_at < Instant::now()→ remove the entry and return400 invalid_handoff(the cleanup-on-expiry also prevents memory bloat from expired-but-unconsumed codes). - Remove the entry atomically (DashMap
removeis atomic) — this is the single-use guarantee. Even if two requests race with the same code, only one wins. - Return the payload as JSON:
{ "access_token": "<jwt>", "refresh_token": "<raw>", "token_type": "Bearer", "expires_in": 900, "user": { "id": "...", "username": "...", ... } }
- Look up
- Log:
- On success:
tracing::info!(user_id = %payload.user.id, "SSO handoff exchanged") - On failure:
tracing::warn!(reason = %reason, "SSO handoff exchange failed") - Never log the handoff code value itself (it's a bearer secret with 60s window).
- On success:
4.3 Backend: Cleanup task
Add a tokio::spawn cleanup task in main.rs (mirroring the existing
WS-ticket cleanup if present, or the SSO-session cleanup that already
runs per the codebase). Every 60 seconds, walk sso_handoffs and
remove entries with expires_at < Instant::now(). Bounded memory
growth even if the SPA never POSTs back.
4.4 Backend: Route registration
In pm-web/src/main.rs, add the new route to the public router
(alongside /api/v1/ws/ticket, which is also public — no JWT
required because the handoff code IS the credential):
.route("/api/v1/auth/sso/handoff", post(sso_handoff_exchange))
4.5 Frontend: SsoCallbackPage.tsx
Replace the URL-param parsing with a POST to the handoff endpoint:
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const errorCode = params.get('error')
if (errorCode) {
// ... existing error handling unchanged ...
return
}
const handoffCode = params.get('handoff')
if (!handoffCode) {
setError('Missing handoff code. Please try logging in again.')
setProcessing(false)
return
}
// Exchange handoff code for tokens
fetch('/api/v1/auth/sso/handoff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handoff_code: handoffCode }),
})
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e)))
.then(data => {
setTokens(data.access_token, data.refresh_token)
setUser(buildUser(data.user))
// Clear the handoff code from the URL to prevent bookmarking/sharing
window.history.replaceState({}, '', '/auth/sso/callback')
navigate('/dashboard', { replace: true })
})
.catch(err => {
setError(err?.error?.message || 'Failed to complete sign-in. Please try again.')
setProcessing(false)
})
}, [setTokens, setUser, navigate])
The buildUser helper mirrors the existing field-mapping logic
(lines 54–67 of the current file).
4.6 Frontend: authStore.ts
No change required. The existing setTokens(access, refresh) and
setUser(user) API is what the new code calls. The partialize
config (line 74) already correctly persists only refreshToken and
user — not accessToken — so the in-memory access token is never
written to localStorage. This is the correct security posture and
should be preserved.
5. Acceptance Criteria
- SSO callback no longer places
access_token,refresh_token,token_type,expires_in, oruserin the redirect URL. The URL contains onlyhandoff=<code>(plus the error params on failure, which are unchanged). - The handoff code is at least 128 bits of entropy (32 bytes, base64url-encoded) and is generated with a CSPRNG.
- The handoff code is single-use: a second exchange attempt with
the same code returns
400 invalid_handoffand does NOT return the tokens again. - The handoff code expires after 60 seconds. An exchange attempt
with an expired code returns
400 invalid_handoffand the entry is removed from the in-memory map. - The SPA successfully completes login: POST to the handoff
endpoint receives the tokens, calls
setTokensandsetUser, and navigates to/dashboard. authStore.tsis unchanged (its existingpartializealready prevents access-token persistence; the handoff code approach doesn't change that contract).cargo checkandcargo clippy --all-targetspass.cargo test -p pm-webpasses with new tests for the handoff endpoint (create, exchange success, exchange duplicate=400, exchange expired=400, exchange unknown=400).frontendbuilds cleanly (npm run buildinfrontend/).- No access or refresh token values appear in any URL or query string in the SSO flow. Manual verification: complete a login and grep the server access log for the callback URL — only the handoff code should be present.
docs/security-review.md§2.5 (Azure SSO) is updated to document the handoff code control.
6. Test Plan
6.1 Backend unit/integration tests (crates/pm-web/src/routes/sso.rs)
Using a small TestApp harness mirroring the WS-ticket test pattern
(no real HTTP listener, no DB beyond the connection that's already
mocked in the existing tests):
handoff_exchange_success— create a handoff, POST to the exchange endpoint, expect 200 with the access/refresh/user fields.handoff_exchange_single_use— exchange once (success), exchange the same code again (expect 400invalid_handoff).handoff_exchange_unknown_code— POST with a code that was never issued (expect 400invalid_handoff).handoff_exchange_expired_code— create a handoff withexpires_at = past, exchange (expect 400invalid_handoffAND the entry is removed from the map).handoff_exchange_race— two concurrent POSTs with the same code (usingtokio::join!); exactly one succeeds, the other gets 400.handoff_exchange_malformed_body— POST with invalid JSON or missinghandoff_codefield (expect 400invalid_handoff).callback_redirect_contains_only_handoff— invokesso_callbackthrough a mock OIDC config and assert the resulting redirect URL contains onlyhandoff=<code>and NOaccess_token/refresh_token/userquery params.
6.2 Backend cleanup test
handoff_cleanup_removes_expired— create 3 handoffs with varyingexpires_at, run one tick of the cleanup task, assert only the non-expired ones remain.
6.3 Frontend tests (frontend/src/pages/SsoCallbackPage.tsx)
Add a Vitest + React Testing Library test suite (the frontend already
uses Vitest — see frontend/package.json and frontend/vite.config.ts):
renders_processing_state_initially— on mount with a handoff code, shows the spinner and "Completing sign-in…".calls_handoff_endpoint_on_mount— mocksfetchand asserts the POST goes to/api/v1/auth/sso/handoffwith{ handoff_code: <code> }.stores_tokens_and_user_on_success— mocks a successful response, assertssetTokensandsetUserare called with the response payload, and the SPA navigates to/dashboard.shows_error_on_handoff_failure— mocks a 400 response, asserts the error message is rendered and the spinner stops.shows_error_when_handoff_code_missing— invokes the effect with no handoff code, asserts the "Missing handoff code" error is shown.clears_handoff_code_from_url_after_success— assertswindow.history.replaceStateis called to remove the?handoff=param from the URL after a successful exchange.
7. Risk Analysis
- Risk: regression in the SSO login flow. Mitigation: the test
plan covers the callback redirect shape, the exchange endpoint
behavior (success, single-use, expiry, race), and the frontend
effect. Manual end-to-end test (completing a real Azure AD login)
is required before merge — the new
scripts/integration-test.shshould be extended or a newscripts/integration-test-sso.shadded to exercise the full flow against a mock OIDC provider. - Risk: in-flight SSO logins during deploy break. Per Kelly
sign-off Q3, we accept hard cutover. The mitigation: the 60s
handoff TTL means any in-flight redirect that arrives after the
server restart has a 60s window to complete. If the new code is
deployed and the old handoffs are lost, the user is sent back to
/auth/sso/callback?handoff=<old-code>which the new code rejects with400 invalid_handoff, and the SPA shows "Please try logging in again." Worst case: a 30-second re-login. Acceptable for a security-critical fix. - Risk: handoff code leaked via browser history or
Referer. The code is single-use and 60s TTL, so the blast radius is small even if logged. The SPA callshistory.replaceStateafter a successful exchange to remove the code from the address bar (and the underlying history entry). The 60s window limits exposure toRefererleakage on subsequent navigations from the callback page. - Risk: memory growth from unconsumed handoffs. Mitigation: the
cleanup task runs every 60s and removes expired entries. Worst
case memory usage is
O(active_logins)— typically single digits. - Risk: race condition in the single-use guarantee. Mitigation:
DashMap::removeis atomic, so only one of two concurrent exchange attempts can succeed. Verified by thehandoff_exchange_racetest.
8. Documentation Updates
docs/security-review.md§2.5 (Azure SSO): add a new row documenting the handoff code control and explicitly state that no tokens appear in any URL.frontend/src/pages/SsoCallbackPage.tsx: update the doc-comment to describe the POST-and-exchange flow instead of the URL-param parse.docs/REST_API.md: document the newPOST /api/v1/auth/sso/handoffendpoint.
9. Out of Scope / Follow-ups
- Cookie-based SSO session (a future enhancement that would let the SPA refresh state without a new OIDC flow on every page load).
form_postresponse mode (a future enhancement if browsers standardize it more widely).- Rate limiting on the handoff endpoint (out of scope here; the
existing governor-based rate limits on
/auth/*may already cover this — verify during implementation). - Moving the in-memory
sso_handoffsto Redis (out of scope; the single-instance design constraint inSPEC.mdis fine for this control).