//! End-to-end OIDC login flow against a mock provider. use std::net::SocketAddr; use std::sync::Arc; use axum::extract::{Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::Utc; use serde_json::{json, Value}; use tokio::sync::Mutex; use tower::ServiceExt; use tavolo_app::app; use tavolo_app::config::Settings; use tavolo_platform::oidc::OidcProviderConfig; const RSA_PEM: &str = r#"-----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCMKcE4AgsStq5u l3Rtg9p2XNN5l5N5z1orqDJmRHAJvSNqhr2BHxu5Q1m3o8prvE+lHRqe1lgJXKi5 8aITv6hDKkS9gmp+tvVQ/VJnnKSbgaSJSMEf5P5GoLR43SrsTEtbxlVO03kwCkiH EoOihK0h4GgyIE/3UWkgsJ8eJKi+czBzBffX8+qfR29vJGEk1mhpHIBnHI+19pLV q0GvMcqKA4hnTZwB43/8EI6lZb59r1ZJLvz87AKd/VnXW4zki16PHntkrrRfRl+U Q5k+ErSYp0SXtyh9mONzxGDQ+ra7gIx8itTnSAbYTwi846UzSTltCCYqTwkvi4nm aCmKn3mdAgMBAAECggEARUGKegC0dPo0JWwLuONKSmriiXWmhq04qlKxGIwfagh9 W/EY6Ri0a63GQnGZnvNMzVKcqThTBsk0i7BD/Asa+pkDnzo2IceIXozVL2IcicQc x0LePkK5i69zjTcHMvwdzkqpM0pV2rFyFd/FkEOLgq4t/fquWI9u6RqsfMnzYSqx XxS4JsC6dEgNK69lH2OTWvmpgpujrwWAiJVNlKY0Ehh0R8Wruy7iPjo6NLWH8sQq XlCxIoeWCcPgCbtQRmLgNJPdFB8zSTYGFqGJ5lfYq/7qkcQsAEwIUJBF+EMU+yK5 y8jxiGF+8wDg4bYovNxs0uxU/HltHPGDfe1Yn2OyNwKBgQDA7GNsj4hxDME0rXnI 8SMnSjOVQRsmNTa6Je3XKXuTxzqXwYBndILmTYOmejYnQJ7oToAn1Jiyk9yo+Mzd 2sxKU9iHMVABuLiFmbUm269WB2Zh7fQ7ErhIVGTNsPsHv2iuSuOktGQw6Cd+9pBH 0H8HCf9dWOzQ01lqdKw60UfH2wKBgQC5/VnnCtQ4n+aAzSYLYgZf97XTTKN2+gGq z2caPga/3yA+1dPx18JjVqlEuAxazXQPxgAL2ZnGHQfLR+M0lInHhljrALKHTzMl MsmLTq+J8DChlGIPJIKc0Uk4vLSJLMkaHBS9gM9z49zdGx2ltI4gGUJIKk4z5gT0 iqRFXM1Z5wKBgAuOna4lMxFCqFhVhBQrjeWOpmRskAnL6klgeE0JaSPDLZlluCF6 c1UEk7xnWdMkJeDYwwifzb306aUdsngf4VkaiMAgjiBsRnGKcbWwN2K58sIvFRBs 4KlElP1VetRN/SFjzxtQyye64JLUhnQm41Uynh+jchxYbOTZtG+Zlek9AoGADPsP sT5iPWl7jvXCd/zpcP065Xpt1jnwr9k12vEFS5LpDzmhRznFyWUc/zzRL+0y8n7h NfKD9v3Ds35a7yqGyzQnNI+h5iuy6B+5TU26lqf63OwcQ+5KZ2N79sfE2eYUSww2 rls1H2OB4LSpxwzo9niM1biOKLUfF2otuOGXO3UCgYBIDAvpabTpw6z4IBQGwHKP WOTFrtI/y0Zqf3iNusqb8RdCIokDHvT+fKdvI7VXzaASWt2GajMHArWB6uu/1Xeh nF9SaK1F+mKyGv9a5m5We9VzRNXmaUaQBRLK3XjBr1Co4/UduYRDdqZo/E/5g8gr BMZbIcEMr49N2ns4yHzJ3w== -----END PRIVATE KEY----- "#; const JWK: &str = r#"{"kty":"RSA","use":"sig","alg":"RS256","kid":"test-key","n":"jCnBOAILEraubpd0bYPadlzTeZeTec9aK6gyZkRwCb0jaoa9gR8buUNZt6PKa7xPpR0antZYCVyoufGiE7-oQypEvYJqfrb1UP1SZ5ykm4GkiUjBH-T-RqC0eN0q7ExLW8ZVTtN5MApIhxKDooStIeBoMiBP91FpILCfHiSovnMwcwX31_Pqn0dvbyRhJNZoaRyAZxyPtfaS1atBrzHKigOIZ02cAeN__BCOpWW-fa9WSS78_OwCnf1Z11uM5Itejx57ZK60X0ZflEOZPhK0mKdEl7cofZjjc8Rg0Pq2u4CMfIrU50gG2E8IvOOlM0k5bQgmKk8JL4uJ5mgpip95nQ","e":"AQAB"}"#; #[derive(Clone, Default)] struct IdpState { issuer: String, nonce: Arc>, } struct Idp { state: IdpState, addr: SocketAddr, } async fn start_idp() -> Idp { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind idp"); let addr = listener.local_addr().expect("idp addr"); let base = format!("http://{addr}"); let state = IdpState { issuer: base.clone(), ..Default::default() }; let router = Router::new() .route( "/.well-known/openid-configuration", get({ let base = base.clone(); move || async move { Json(json!({ "issuer": base, "authorization_endpoint": format!("{base}/authorize"), "token_endpoint": format!("{base}/token"), "userinfo_endpoint": format!("{base}/userinfo"), "jwks_uri": format!("{base}/jwks"), })) } }), ) .route( "/authorize", get( |State(state): State, Query(params): Query>| async move { if let Some(nonce) = params.get("nonce") { *state.nonce.lock().await = nonce.clone(); } let state_param = params.get("state").cloned().unwrap_or_default(); let redirect_uri = params .get("redirect_uri") .cloned() .unwrap_or_else(|| "/".into()); let location = format!("{redirect_uri}?code=test-code&state={state_param}"); ( axum::http::StatusCode::FOUND, [(axum::http::header::LOCATION, location)], ) }, ), ) .route( "/token", post(|State(state): State| async move { let nonce = state.nonce.lock().await.clone(); let now = Utc::now().timestamp(); let claims = json!({ "iss": state.issuer, "aud": "tavolo", "sub": "alice", "exp": now + 600, "iat": now, "nonce": nonce, }); let token = jsonwebtoken::encode( &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256), &claims, &jsonwebtoken::EncodingKey::from_rsa_pem(RSA_PEM.as_bytes()).unwrap(), ) .unwrap(); Json(json!({ "access_token": "access-token", "token_type": "Bearer", "id_token": token, })) }), ) .route( "/userinfo", get(|| async { Json(json!({"sub": "alice", "preferred_username": "Alice"})) }), ) .route( "/jwks", get(|| async { Json(json!({"keys": [serde_json::from_str::(JWK).unwrap()]})) }), ) .with_state(state.clone()); tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); Idp { state, addr } } fn test_settings(issuer: &str, redirect_uri: &str) -> Settings { Settings { database_url: "postgres://unused".into(), oidc_providers: vec![OidcProviderConfig { provider: "default".into(), issuer: issuer.into(), client_id: "tavolo".into(), client_secret: Some("dev-secret".into()), redirect_uri: redirect_uri.into(), post_login_redirect: "/".into(), post_logout_redirect: "/".into(), }], app_host: "127.0.0.1".into(), app_port: 0, redis_url: None, game_ttl_seconds: 86400, static_dir: "/nonexistent-web-dist".into(), hand_ack_timeout_seconds: 30, turn_timeout_seconds: 30, deadline_heartbeat_ms: 50, logging_config: None, cors_allow_origins: None, cors_allow_origin_regex: None, cors_allow_methods: None, cors_allow_headers: None, cors_allow_credentials: false, cors_expose_headers: None, cors_max_age: 600, otel_enabled: false, otel_service_name: "tavolo".into(), otel_exporter_endpoint: None, otel_exporter_headers: None, otel_excluded_paths: vec!["/api/health".into()], otel_metric_export_interval_millis: 60000, otel_metrics_poll_interval_seconds: 15, } } fn cookie_header(response: &axum::response::Response) -> String { let value = response .headers() .get(axum::http::header::SET_COOKIE) .expect("set-cookie") .to_str() .expect("cookie header"); value.split(';').next().expect("cookie pair").to_string() } #[tokio::test] async fn oidc_login_callback_and_logout_flow() { // The mock IdP must know its own issuer before we can start it, so bind // first and rebuild the settings from the recorded address. let idp = start_idp().await; let issuer = format!("http://{}", idp.addr); let callback = "http://localhost:8080/auth/default/callback".to_string(); let settings = test_settings(&issuer, &callback); let (platform, session_store) = app::in_memory_platform(&settings).expect("platform"); let router = app::build_router(platform, session_store.clone(), &settings); // 1. Start the login: a 302 to the provider with state/nonce/PKCE and a // fresh session cookie. let response = router .clone() .oneshot( axum::http::Request::builder() .uri("/auth/default/login") .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), 302); let location = response.headers()["location"].to_str().unwrap().to_string(); let login_cookie = cookie_header(&response); let url = url::Url::parse(&location).expect("authorization url"); let params: std::collections::HashMap = url .query_pairs() .map(|(key, value)| (key.into_owned(), value.into_owned())) .collect(); assert_eq!(params["client_id"], "tavolo"); assert_eq!(params["code_challenge_method"], "S256"); assert_eq!(params["scope"], "openid email profile"); assert_eq!( params["redirect_uri"], "http://localhost:8080/auth/default/callback" ); let state = params["state"].clone(); // Simulate the browser visiting the provider: the mock signs the nonce // the client generated. *idp.state.nonce.lock().await = params["nonce"].clone(); // 2. The provider redirects back with the code and state. let response = router .clone() .oneshot( axum::http::Request::builder() .uri(format!( "/auth/default/callback?code=test-code&state={state}" )) .header("cookie", &login_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), 302); assert_eq!(response.headers()["location"], "/"); let session_cookie = cookie_header(&response); assert_ne!( session_cookie, login_cookie, "session id must rotate on login" ); // A wrong state is rejected without touching the provider. let response = router .clone() .oneshot( axum::http::Request::builder() .uri("/auth/default/callback?code=test-code&state=wrong") .header("cookie", &login_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), 400); // 3. The session is authenticated and /api/me reflects the userinfo. let response = router .clone() .oneshot( axum::http::Request::builder() .uri("/api/me") .header("cookie", &session_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), 200); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); let me: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(me, json!({"sub": "alice", "name": "Alice"})); // 4. Logout clears the session and sends an expired cookie. let response = router .clone() .oneshot( axum::http::Request::builder() .uri("/auth/default/logout") .header("cookie", &session_cookie) .body(axum::body::Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), 302); let clear_cookie = response .headers() .get(axum::http::header::SET_COOKIE) .unwrap() .to_str() .unwrap(); assert!( clear_cookie.contains("Max-Age=0"), "cookie not cleared: {clear_cookie}" ); }