diff --git a/Cargo.lock b/Cargo.lock index 948b200c..24187a03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2850,7 +2850,7 @@ dependencies = [ "prost-types 0.14.4", "prost-wkt-types", "quote", - "reqwest 0.13.5", + "reqwest", "serde", "serde_json", "sha2 0.10.9", @@ -2915,11 +2915,12 @@ dependencies = [ "imageproc", "maxminddb", "mimalloc", + "oauth2-reqwest", "openidconnect", "password-auth", "prost 0.14.4", "rand 0.8.8", - "reqwest 0.13.5", + "reqwest", "rust-embed", "rust-i18n", "sea-orm", @@ -6030,7 +6031,6 @@ dependencies = [ "getrandom 0.2.17", "http", "rand 0.8.8", - "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -6039,6 +6039,16 @@ dependencies = [ "url", ] +[[package]] +name = "oauth2-reqwest" +version = "0.1.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" +dependencies = [ + "oauth2", + "reqwest", +] + [[package]] name = "objc2" version = "0.6.4" @@ -7692,38 +7702,6 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-http 0.6.11", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "reqwest" version = "0.13.5" @@ -9579,7 +9557,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.5", + "reqwest", "serde", "serde_json", "serde_repr", diff --git a/easytier-web/Cargo.toml b/easytier-web/Cargo.toml index 93be0a3f..14231dc3 100644 --- a/easytier-web/Cargo.toml +++ b/easytier-web/Cargo.toml @@ -74,7 +74,10 @@ uuid = { workspace = true, features = [ ] } chrono = { workspace = true, features = ["serde"] } -openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps", "reqwest"] } +# Keep the bundled reqwest 0.12 client disabled; use the official 0.13 adapter. +openidconnect = { version = "4.0", default-features = false, features = ["accept-rfc3339-timestamps"] } +# Pin the adapter until its API stabilizes. +oauth2-reqwest = "=0.1.0-alpha.3" # Reuse EasyTier's ring provider. reqwest 0.13's rustls feature adds aws-lc, # making rustls backend selection ambiguous when core/web are built together. # Initialize the process provider before constructing a reqwest client. diff --git a/easytier-web/src/restful/oidc.rs b/easytier-web/src/restful/oidc.rs index 3229c42a..b111c715 100644 --- a/easytier-web/src/restful/oidc.rs +++ b/easytier-web/src/restful/oidc.rs @@ -1,4 +1,4 @@ -use openidconnect::reqwest; +use oauth2_reqwest::ReqwestClient; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -180,7 +180,7 @@ pub struct OidcConfig { pub scopes: Vec, pub pkce_enabled: bool, pub frontend_base_url: Option, - pub http_client: Option, + pub http_client: Option, cached_client: Option>, } @@ -224,10 +224,14 @@ impl OidcConfig { if oidc_username_claim.trim().is_empty() { return Err(anyhow::anyhow!("--oidc-username-claim cannot be empty")); } - let http_client = reqwest::ClientBuilder::new() - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) - .build()?; + // OIDC configuration can also be created outside the web entry point. + easytier::utils::init_crypto_provider(); + let http_client = ReqwestClient::from( + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build()?, + ); let issuer_url = oidc_issuer_url.ok_or_else(|| { anyhow::anyhow!("--oidc-issuer-url is required when using OIDC authentication") @@ -697,6 +701,121 @@ mod route { mod tests { use super::*; + #[tokio::test] + async fn reqwest_adapter_supports_discovery_and_token_exchange() { + use axum::{ + Form, Json, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Redirect}, + routing::post, + }; + use openidconnect::{AuthorizationCode, OAuth2TokenResponse, RequestTokenError}; + use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tokio_util::task::AbortOnDropHandle; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let issuer = format!("http://{}", listener.local_addr().unwrap()); + let metadata = json!({ + "issuer": issuer, + "authorization_endpoint": format!("{issuer}/authorize"), + "token_endpoint": format!("{issuer}/token"), + "jwks_uri": format!("{issuer}/jwks"), + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + }); + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let hits = redirect_hits.clone(); + let app = Router::new() + .route( + "/.well-known/openid-configuration", + get(move || async move { Json(metadata) }), + ) + .route("/jwks", get(|| async { Json(json!({ "keys": [] })) })) + .route( + "/token", + post( + |headers: HeaderMap, Form(form): Form>| async move { + assert_eq!( + headers["authorization"], + "Basic dGVzdC1jbGllbnQ6dGVzdC1zZWNyZXQ=" + ); + assert_eq!(form["grant_type"], "authorization_code"); + assert_eq!(form["redirect_uri"], "http://localhost/callback"); + match form["code"].as_str() { + "valid" => Json(json!({ + "access_token": "test-access-token", + "token_type": "Bearer", + "expires_in": 3600, + })) + .into_response(), + "redirect" => Redirect::temporary("/unexpected").into_response(), + _ => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "invalid_grant" })), + ) + .into_response(), + } + }, + ), + ) + .route( + "/unexpected", + post(move || async move { + hits.fetch_add(1, Ordering::SeqCst); + Json(json!({ "access_token": "unexpected", "token_type": "Bearer" })) + }), + ); + let _server = AbortOnDropHandle::new(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + + let config = OidcConfig::from_params(OidcOptions { + oidc_issuer_url: Some(issuer), + oidc_client_id: Some("test-client".to_owned()), + oidc_client_secret: Some("test-secret".to_owned()), + oidc_username_claim: "preferred_username".to_owned(), + oidc_scopes: vec!["openid".to_owned()], + oidc_redirect_url: Some("http://localhost/callback".to_owned()), + oidc_disable_pkce: false, + oidc_frontend_base_url: None, + }) + .await + .unwrap(); + let client = config.client().unwrap(); + let http_client = config.http_client.as_ref().unwrap(); + let token = client + .exchange_code(AuthorizationCode::new("valid".to_owned())) + .unwrap() + .request_async(http_client) + .await + .unwrap(); + assert_eq!(token.access_token().secret(), "test-access-token"); + assert_eq!(token.expires_in(), Some(Duration::from_secs(3600))); + + let error = client + .exchange_code(AuthorizationCode::new("invalid".to_owned())) + .unwrap() + .request_async(http_client) + .await + .unwrap_err(); + assert!( + matches!(error, RequestTokenError::ServerResponse(ref response) + if response.error() == &CoreErrorResponseType::InvalidGrant) + ); + + assert!( + client + .exchange_code(AuthorizationCode::new("redirect".to_owned())) + .unwrap() + .request_async(http_client) + .await + .is_err() + ); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + } + #[test] fn test_dot_path_to_json_pointer() { use serde_json::json;