Private
Public Access
1
0

fix(security): stop embedding JWT tokens in SSO callback redirect URL (#4) (#14)

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
This commit is contained in:
Draco-Lunaris-Echo
2026-06-03 06:28:08 -05:00
committed by GitHub
parent 3bdae4bcc5
commit f58d7a6f17
11 changed files with 3158 additions and 77 deletions

341
crates/pm-web/src/routes/sso.rs Executable file → Normal file
View File

@ -12,11 +12,12 @@ use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Redirect},
routing::get,
routing::{get, post},
Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use dashmap::DashMap;
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use pm_auth::{jwt::issue_access_token, refresh};
use pm_core::audit::{log_event, AuditAction};
@ -40,6 +41,140 @@ pub struct SsoSession {
pub created_at: chrono::DateTime<Utc>,
}
/// Single-use, short-lived payload that the SSO callback hands to the SPA
/// via a `?handoff=<code>` query param. The SPA exchanges it via
/// `POST /api/v1/auth/sso/handoff` for the actual JWT access/refresh
/// tokens. Mirrors the WS-ticket pattern (issue #10): in-memory, atomic
/// single-use consume, TTL enforced on read.
///
/// See `tasks/sso-token-handoff-spec.md` §4.1 for the full design.
#[derive(Clone)]
pub struct SsoHandoff {
/// JWT access token (short-lived, 15 min TTL).
pub access_token: String,
/// Opaque refresh token (long-lived, rotating).
pub raw_refresh: String,
/// JSON-serialized user object (id, username, display_name, role, etc.).
pub user_json: Value,
/// Access token TTL in seconds (for the `expires_in` field in the response).
pub access_ttl: u64,
/// Expiry instant; the exchange endpoint rejects codes past this time.
pub expires_at: std::time::Instant,
}
/// TTL for SSO handoff codes. Short by design: the SPA should POST to
/// `/api/v1/auth/sso/handoff` within seconds of the redirect landing.
///
/// `dead_code` is allowed here because Phase 1 introduces the store
/// ahead of its consumer; the SSO callback rewrite in Phase 2 of
/// `tasks/sso-token-handoff-spec.md` inserts handoffs with this TTL and
/// the exchange handler reads it back to validate freshness.
#[allow(dead_code)]
pub const HANDOFF_TTL_SECS: u64 = 60;
/// Generate a cryptographically random handoff code (32 bytes,
/// base64url-encoded, ~43 chars). Uses the same `rand` crate family as
/// the WS-ticket path.
///
/// `dead_code` is allowed here for the same reason as `HANDOFF_TTL_SECS`
/// — Phase 2 wires it into the SSO callback redirect construction.
#[allow(dead_code)]
pub fn generate_handoff_code() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
/// Request body for `POST /api/v1/auth/sso/handoff`.
///
/// The SPA sends the handoff code it received in the SSO callback
/// redirect's `?handoff=...` query param, and the backend exchanges it
/// for the actual access/refresh tokens. The code is single-use and
/// 60-second TTL.
#[derive(Debug, Deserialize)]
pub struct HandoffRequest {
pub handoff_code: String,
}
// ============================================================
// Handoff exchange handler
// ============================================================
/// `POST /api/v1/auth/sso/handoff` — exchange a single-use handoff code
/// for the JWT access/refresh tokens + user object. Public route (no
/// JWT required) — the handoff code IS the credential.
///
/// See `tasks/sso-token-handoff-spec.md` §4.2 for the full design.
async fn sso_handoff_exchange(
State(state): State<AppState>,
Json(req): Json<HandoffRequest>,
) -> (StatusCode, Json<Value>) {
sso_handoff_exchange_inner(&state.sso_handoffs, &req.handoff_code).await
}
/// Core exchange logic, separated from the HTTP handler so tests can
/// drive it with a bare `DashMap` (no need to construct a full
/// `AppState` with a real `sqlx::PgPool` and `Arc<AppConfig>`).
///
/// Marked `async` so the race test can use `tokio::join!` to drive
/// two concurrent exchanges against the same code; the function body
/// has no `.await` points (it only does a DashMap read and a return),
/// so this is a zero-cost abstraction.
async fn sso_handoff_exchange_inner(
handoffs: &DashMap<String, SsoHandoff>,
code: &str,
) -> (StatusCode, Json<Value>) {
// Atomically remove the entry (single-use guarantee). If two
// requests race with the same code, DashMap::remove is atomic so
// only one wins.
let removed = handoffs.remove(code);
let Some((_code, handoff)) = removed else {
tracing::warn!(
reason = "unknown_or_already_consumed",
"SSO handoff exchange failed"
);
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
})),
);
};
// Check expiry (the cleanup task also removes expired entries, but
// there's a race between expiry and the next cleanup tick — check
// here too so we never return a token for an expired handoff).
if handoff.expires_at <= std::time::Instant::now() {
tracing::warn!(reason = "expired", "SSO handoff exchange failed");
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": { "code": "invalid_handoff", "message": "Handoff code is invalid or has expired" }
})),
);
}
// Log success without leaking the handoff code or the tokens
let user_id = handoff
.user_json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
tracing::info!(user_id = %user_id, "SSO handoff exchanged");
(
StatusCode::OK,
Json(json!({
"access_token": handoff.access_token,
"refresh_token": handoff.raw_refresh,
"token_type": "Bearer",
"expires_in": handoff.access_ttl,
"user": handoff.user_json,
})),
)
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[allow(dead_code)]
@ -116,6 +251,12 @@ pub fn public_router() -> Router<AppState> {
.route("/login", get(sso_login))
.route("/callback", get(sso_callback))
.route("/config", get(sso_config))
// Issue #4: single-use handoff exchange. The SPA POSTs the
// `?handoff=<code>` it received from the SSO callback redirect
// and gets the JWT access/refresh tokens in the JSON response.
// Public route (no JWT) — the handoff code IS the credential.
// See `tasks/sso-token-handoff-spec.md` §4.2.
.route("/handoff", post(sso_handoff_exchange))
}
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
@ -604,13 +745,32 @@ async fn sso_callback(
"mfa_enabled": user.mfa_enabled,
});
let redirect_url = format!(
"{}?access_token={}&refresh_token={}&token_type=Bearer&expires_in={}&user={}",
callback_url,
urlencoding::encode(&access_token),
urlencoding::encode(&raw_refresh.0),
access_ttl,
urlencoding::encode(&user_json.to_string()),
// Issue #4 fix: instead of embedding access/refresh tokens in the
// redirect URL (which leaks through browser history, proxy access
// logs, and the Referer header), generate a single-use, 60s handoff
// code, store the payload in `sso_handoffs`, and put ONLY the code
// in the redirect. The SPA POSTs to `/api/v1/auth/sso/handoff` to
// exchange the code for tokens. See `tasks/sso-token-handoff-spec.md`
// §4.1.
let handoff_code = generate_handoff_code();
state.sso_handoffs.insert(
handoff_code.clone(),
SsoHandoff {
access_token: access_token.clone(),
raw_refresh: raw_refresh.0.clone(),
user_json: user_json.clone(),
access_ttl: access_ttl as u64,
expires_at: std::time::Instant::now()
+ std::time::Duration::from_secs(HANDOFF_TTL_SECS),
},
);
let redirect_url = format!("{}?handoff={}", callback_url, handoff_code);
tracing::info!(
user_id = %user.id,
auth_provider = %auth_provider,
"SSO handoff issued"
);
Ok(Redirect::to(&redirect_url))
@ -836,3 +996,168 @@ async fn fetch_jwks(jwks_uri: &str) -> Result<serde_json::Value, String> {
.await
.map_err(|e| format!("Failed to parse JWKS response: {}", e))
}
#[cfg(test)]
mod tests {
//! Unit tests for the SSO handoff exchange endpoint and cleanup task.
//!
//! Per `tasks/sso-token-handoff-spec.md` §6.16.2.
//!
//! The tests call `sso_handoff_exchange_inner` directly with a bare
//! `DashMap<String, SsoHandoff>`. This avoids the need to construct
//! a full `AppState` (which has `sqlx::PgPool` and `Arc<AppConfig>`
//! fields that can't be cheaply mocked) and keeps the tests focused
//! on the exchange logic. The HTTP handler is a thin wrapper that
//! extracts the code from the request body and delegates.
use super::*;
use dashmap::DashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
fn fresh_handoffs() -> Arc<DashMap<String, SsoHandoff>> {
Arc::new(DashMap::new())
}
fn make_handoff(access: &str, refresh: &str, user_id: &str) -> SsoHandoff {
SsoHandoff {
access_token: access.to_string(),
raw_refresh: refresh.to_string(),
user_json: json!({ "id": user_id, "username": "testuser" }),
access_ttl: 900,
expires_at: Instant::now() + Duration::from_secs(HANDOFF_TTL_SECS),
}
}
/// 1. handoff_exchange_success — create a handoff, exchange it,
/// expect 200 with the access/refresh/user fields.
#[tokio::test]
async fn handoff_exchange_success() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(
code.clone(),
make_handoff("jwt-access", "refresh-raw", "user-123"),
);
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["access_token"], "jwt-access");
assert_eq!(body["refresh_token"], "refresh-raw");
assert_eq!(body["token_type"], "Bearer");
assert_eq!(body["expires_in"], 900);
assert_eq!(body["user"]["id"], "user-123");
}
/// 2. handoff_exchange_single_use — exchange once (success),
/// exchange the same code again (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_single_use() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
// First exchange succeeds
let (status1, _) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status1, StatusCode::OK);
// Second exchange with the same code fails (entry was removed)
let (status2, body2) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status2, StatusCode::BAD_REQUEST);
assert_eq!(body2["error"]["code"], "invalid_handoff");
}
/// 3. handoff_exchange_unknown_code — exchange a code that was
/// never issued (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_unknown_code() {
let handoffs = fresh_handoffs();
let (status, body) = sso_handoff_exchange_inner(&handoffs, "never-issued-code").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 4. handoff_exchange_expired_code — create a handoff with
/// expires_at in the past, exchange (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_expired_code() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
let mut h = make_handoff("a", "r", "u");
h.expires_at = Instant::now() - Duration::from_secs(1); // already expired
handoffs.insert(code.clone(), h);
let (status, body) = sso_handoff_exchange_inner(&handoffs, &code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 5. handoff_exchange_race — two concurrent exchanges with the
/// same code; exactly one succeeds, the other gets 400.
#[tokio::test]
async fn handoff_exchange_race() {
let handoffs = fresh_handoffs();
let code = generate_handoff_code();
handoffs.insert(code.clone(), make_handoff("a", "r", "u"));
// DashMap::remove is atomic, so only one of two concurrent
// calls can win. The other gets None and returns 400.
let h1 = handoffs.clone();
let h2 = handoffs.clone();
let c1 = code.clone();
let c2 = code.clone();
let (r1, r2) = tokio::join!(
sso_handoff_exchange_inner(&h1, &c1),
sso_handoff_exchange_inner(&h2, &c2),
);
let status1 = r1.0;
let status2 = r2.0;
let successes = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::OK)
.count();
let failures = [status1, status2]
.iter()
.filter(|s| **s == StatusCode::BAD_REQUEST)
.count();
assert_eq!(successes, 1, "exactly one exchange should succeed");
assert_eq!(failures, 1, "exactly one exchange should fail");
}
/// 6. handoff_exchange_malformed_body — exchange with an empty
/// code (expect 400 invalid_handoff).
#[tokio::test]
async fn handoff_exchange_malformed_body() {
let handoffs = fresh_handoffs();
let (status, body) = sso_handoff_exchange_inner(&handoffs, "").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_handoff");
}
/// 7. handoff_cleanup_removes_expired — create 3 handoffs with
/// varying `expires_at`, run one tick of the cleanup task,
/// assert only the non-expired ones remain.
#[tokio::test]
async fn handoff_cleanup_removes_expired() {
let handoffs = fresh_handoffs();
// 2 expired, 1 fresh
for (i, expired) in [true, false, true].iter().enumerate() {
let mut h = make_handoff(&format!("a{}", i), "r", "u");
if *expired {
h.expires_at = Instant::now() - Duration::from_secs(1);
}
handoffs.insert(format!("code-{}", i), h);
}
assert_eq!(handoffs.len(), 3);
// Simulate one tick of the cleanup task (mirrors the logic
// in main.rs lines 174-188)
let now = Instant::now();
handoffs.retain(|_, v| v.expires_at > now);
assert_eq!(handoffs.len(), 1);
assert!(handoffs.contains_key("code-1"));
}
}