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

View File

@ -12,7 +12,7 @@ use pm_auth::{
use pm_core::{
config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware,
};
use routes::sso::{OidcCache, SsoSession};
use routes::sso::{OidcCache, SsoHandoff, SsoSession};
use routes::ws::WsTicket;
use serde_json::{json, Value};
use std::{net::SocketAddr, sync::Arc, time::Duration};
@ -36,6 +36,9 @@ pub struct AppState {
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
@ -104,6 +107,7 @@ async fn main() -> anyhow::Result<()> {
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
let sso_handoffs: Arc<DashMap<String, SsoHandoff>> = Arc::new(DashMap::new());
let oidc_cache: Arc<Mutex<OidcCache>> = Arc::new(Mutex::new(OidcCache::default()));
let approved_enrollments: Arc<DashMap<String, ApprovedEntry>> = Arc::new(DashMap::new());
@ -163,6 +167,27 @@ async fn main() -> anyhow::Result<()> {
});
}
// Background task: purge expired SSO handoff codes every 60 seconds.
// See `tasks/sso-token-handoff-spec.md` §4.3. Handoffs are also
// atomically removed on exchange (single-use), so this task only
// cleans up codes that the SPA never POSTed back for.
{
let handoffs = sso_handoffs.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60));
loop {
interval.tick().await;
let now = std::time::Instant::now();
let before = handoffs.len();
handoffs.retain(|_, v| v.expires_at > now);
let removed = before.saturating_sub(handoffs.len());
if removed > 0 {
tracing::debug!(removed, "Purged expired SSO handoff codes");
}
}
});
}
let state = AppState {
db: pool,
config: Arc::new(config.clone()),
@ -170,6 +195,7 @@ async fn main() -> anyhow::Result<()> {
auth_config,
ws_tickets,
sso_sessions,
sso_handoffs,
ca: Arc::new(ca),
approved_enrollments,
oidc_cache,