CI / Build and push docker image (push) Successful in 4m20s
Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
//! 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<Mutex<String>>,
|
|
}
|
|
|
|
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<IdpState>,
|
|
Query(params): Query<std::collections::HashMap<String, String>>| 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<IdpState>| 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::<Value>(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<String, String> = 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}"
|
|
);
|
|
}
|