diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee27b5e..b64f3dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: axum-oidc - latest + test: + name: axum-oidc runs-on: ubuntu-latest strategy: matrix: @@ -24,13 +24,17 @@ jobs: - run: cargo build --verbose --release - run: cargo test --verbose --release - build_and_test_examples: - name: axum-oidc - examples + test_basic_example: + name: axum-oidc - basic runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + - nightly steps: - uses: actions/checkout@v3 - - run: sudo apt install chromium-browser -y - - run: rustup update stable && rustup default stable + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - run: cargo build --verbose --release working-directory: ./examples/basic - run: cargo test --verbose --release diff --git a/Cargo.toml b/Cargo.toml index f6c53bd..e92d92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,32 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "0.5.0" +version = "1.0.0-dev-0" edition = "2021" -authors = [ "Paul Z " ] +authors = ["Paul Z "] readme = "README.md" -repository = "https://github.com/pfz4/axum-oidc" -license = "LGPL-3.0-or-later" -keywords = [ "axum", "oidc", "openidconnect", "authentication" ] +repository = "https://codeberg.org/pfzetto/axum-oidc" +license = "MPL-2.0" +keywords = ["axum", "oidc", "openidconnect", "authentication"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -thiserror = "1.0" -axum-core = "0.4" -axum = { version = "0.7", default-features = false, features = [ "query" ] } +thiserror = "2.0" +axum-core = "0.5" +axum = { version = "0.8", default-features = false, features = [ + "query", + "original-uri", +] } tower-service = "0.3" tower-layer = "0.3" -tower-sessions = { version = "0.13", default-features = false, features = [ "axum-core" ] } -http = "1.1" -async-trait = "0.1" -openidconnect = "3.5" +tower-sessions = { version = "0.14", default-features = false, features = [ + "axum-core", +] } +http = "1.3.1" +openidconnect = "4.0" serde = "1.0" futures-util = "0.3" -reqwest = { version = "0.11", default-features = false } +reqwest = { version = "0.12", default-features = false } urlencoding = "2.1" +tracing = "0.1.41" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index ca5dee8..d7d6049 100644 --- a/README.md +++ b/README.md @@ -31,5 +31,5 @@ Feel free to submit feature requests and bug reports using a GitHub Issue. PR's are also appreciated. # License -This Library is licensed under [LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html). +This Library is licensed under [MPLv2](https://www.mozilla.org/en-US/MPL/2.0/). diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index a1b712e..833473d 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -1,23 +1,15 @@ [package] +edition = "2024" name = "basic" version = "0.1.0" -edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1.37", features = ["net", "macros", "rt-multi-thread"] } -axum = "0.7" +axum = { version = "0.8", features = ["macros"] } axum-oidc = { path = "./../.." } -tower = "0.4" -tower-sessions = "0.13" - dotenvy = "0.15" - -[dev-dependencies] -testcontainers = "0.15.0" -tokio = { version = "1.37.0", features = ["rt-multi-thread"] } -reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } -env_logger = "0.11.3" -log = "0.4.21" -headless_chrome = "1.0.9" +tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] } +tower = "0.5" +tower-sessions = "0.14" +tracing-subscriber = "0.3.20" +tracing = "0.1.41" +serde = "1.0.228" diff --git a/examples/basic/README.md b/examples/basic/README.md index 4011a45..30cdf2f 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -8,8 +8,6 @@ It has three endpoints: ## Dependencies You will need a running OpenID Connect capable issuer like [Keycloak](https://www.keycloak.org/getting-started/getting-started-docker) and a valid client for the issuer. -You can take a look at the `tests/`-folder to see how the automated keycloak deployment for the integration tests work. - ## Setup Environment Create a `.env`-file that contains the following keys: ``` diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs deleted file mode 100644 index df77766..0000000 --- a/examples/basic/src/lib.rs +++ /dev/null @@ -1,82 +0,0 @@ -use axum::{ - error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, -}; -use axum_oidc::{ - error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, - OidcRpInitiatedLogout, -}; -use tokio::net::TcpListener; -use tower::ServiceBuilder; -use tower_sessions::{ - cookie::{time::Duration, SameSite}, - Expiry, MemoryStore, SessionManagerLayer, -}; - -pub async fn run( - app_url: String, - issuer: String, - client_id: String, - client_secret: Option, -) { - let session_store = MemoryStore::default(); - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) - .with_same_site(SameSite::Lax) - .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); - - let oidc_login_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - e.into_response() - })) - .layer(OidcLoginLayer::::new()); - - let oidc_auth_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - e.into_response() - })) - .layer( - OidcAuthLayer::::discover_client( - Uri::from_maybe_shared(app_url).expect("valid APP_URL"), - issuer, - client_id, - client_secret, - vec![], - ) - .await - .unwrap(), - ); - - let app = Router::new() - .route("/foo", get(authenticated)) - .route("/logout", get(logout)) - .layer(oidc_login_service) - .route("/bar", get(maybe_authenticated)) - .layer(oidc_auth_service) - .layer(session_layer); - - let listener = TcpListener::bind("[::]:8080").await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); -} - -async fn authenticated(claims: OidcClaims) -> impl IntoResponse { - format!("Hello {}", claims.subject().as_str()) -} - -async fn maybe_authenticated( - claims: Option>, -) -> impl IntoResponse { - if let Some(claims) = claims { - format!( - "Hello {}! You are already logged in from another Handler.", - claims.subject().as_str() - ) - } else { - "Hello anon!".to_string() - } -} - -async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) -} diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 6252c87..45d99c3 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,10 +1,111 @@ -use basic::run; +use axum::{ + Router, + error_handling::HandleErrorLayer, + http::Uri, + response::IntoResponse, + routing::{any, get}, +}; +use axum_oidc::{ + EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, + OidcRpInitiatedLogout, + error::MiddlewareError, + handle_oidc_redirect, + openidconnect::{Audience, ClientId, ClientSecret, IssuerUrl, Scope}, +}; +use tokio::net::TcpListener; +use tower::ServiceBuilder; +use tower_sessions::{ + Expiry, MemoryStore, SessionManagerLayer, + cookie::{SameSite, time::Duration}, +}; +use tracing::Level; + #[tokio::main] async fn main() { + tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_max_level(Level::INFO) + .init(); dotenvy::dotenv().ok(); - let app_url = std::env::var("APP_URL").expect("APP_URL env variable"); let issuer = std::env::var("ISSUER").expect("ISSUER env variable"); let client_id = std::env::var("CLIENT_ID").expect("CLIENT_ID env variable"); let client_secret = std::env::var("CLIENT_SECRET").ok(); - run(app_url, issuer, client_id, client_secret).await + + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); + + let oidc_login_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + dbg!(&e); + e.into_response() + })) + .layer(OidcLoginLayer::::new()); + + let mut oidc_client = OidcClient::::builder() + .with_default_http_client() + .with_redirect_url(Uri::from_static("http://localhost:8080/oidc")) + .with_client_id(ClientId::new(client_id)) + .add_scope(Scope::new("profile".into())) + .add_scope(Scope::new("email".into())) + // Optional: add untrusted audiences. If the `aud` claim contains any of these audiences, the token is rejected. + .add_untrusted_audience(Audience::new("123456789".to_string())); + + if let Some(client_secret) = client_secret { + oidc_client = oidc_client.with_client_secret(ClientSecret::new(client_secret)); + } + let oidc_client = oidc_client + .discover(IssuerUrl::new(issuer.into()).expect("Invalid IssuerUrl")) + .await + .unwrap() + .build(); + + let oidc_auth_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + dbg!(&e); + e.into_response() + })) + .layer(OidcAuthLayer::new(oidc_client)); + + let app = Router::new() + .route("/foo", get(authenticated)) + .route("/logout", get(logout)) + .layer(oidc_login_service) + .route("/bar", get(maybe_authenticated)) + .route("/oidc", any(handle_oidc_redirect::)) + .layer(oidc_auth_service) + .layer(session_layer); + + tracing::info!("Running on http://localhost:8080"); + tracing::info!("Visit http://localhost:8080/bar or http://localhost:8080/foo"); + + let listener = TcpListener::bind("[::]:8080").await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +async fn authenticated(claims: OidcClaims) -> impl IntoResponse { + format!("Hello {}", claims.subject().as_str()) +} + +#[axum::debug_handler] +async fn maybe_authenticated( + claims: Result, axum_oidc::error::ExtractorError>, +) -> impl IntoResponse { + if let Ok(claims) = claims { + format!( + "Hello {}! You are already logged in from another Handler.", + claims.subject().as_str() + ) + } else { + "Hello anon!".to_string() + } +} + +async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { + logout.with_post_logout_redirect(Uri::from_static("https://example.com")) } diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs deleted file mode 100644 index e4a014c..0000000 --- a/examples/basic/tests/integration.rs +++ /dev/null @@ -1,101 +0,0 @@ -mod keycloak; - -use headless_chrome::Browser; -use log::info; -use testcontainers::*; - -use crate::keycloak::{Client, Keycloak, Realm, User}; - -#[tokio::test(flavor = "multi_thread")] -async fn first() { - env_logger::init(); - - let docker = clients::Cli::default(); - - let alice = User { - username: "alice".to_string(), - email: "alice@example.com".to_string(), - firstname: "alice".to_string(), - lastname: "doe".to_string(), - password: "alice".to_string(), - }; - - let basic_client = Client { - client_id: "axum-oidc-example-basic".to_string(), - client_secret: Some("123456".to_string()), - }; - - let keycloak = Keycloak::start( - vec![Realm { - name: "test".to_string(), - users: vec![alice.clone()], - clients: vec![basic_client.clone()], - }], - &docker, - ) - .await; - - info!("starting basic example app"); - - let app_url = "http://127.0.0.1:8080/"; - let app_handle = tokio::spawn(basic::run( - app_url.to_string(), - format!("{}/realms/test", keycloak.url()), - basic_client.client_id.to_string(), - basic_client.client_secret.clone(), - )); - - info!("starting browser"); - - let browser = Browser::default().unwrap(); - let tab = browser.new_tab().unwrap(); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert_eq!(body, "Hello anon!"); - - tab.navigate_to(&format!("{}foo", app_url)).unwrap(); - let username = tab.wait_for_xpath(r#"//*[@id="username"]"#).unwrap(); - username.type_into(&alice.username).unwrap(); - let password = tab.wait_for_xpath(r#"//*[@id="password"]"#).unwrap(); - password.type_into(&alice.password).unwrap(); - let submit = tab.wait_for_xpath(r#"//*[@id="kc-login"]"#).unwrap(); - submit.click().unwrap(); - - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert!(body.starts_with("Hello ") && body.contains('-')); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert!(body.contains("! You are already logged in from another Handler.")); - - tab.navigate_to(&format!("{}logout", app_url)).unwrap(); - tab.wait_until_navigated().unwrap(); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert_eq!(body, "Hello anon!"); - - tab.navigate_to(&format!("{}foo", app_url)).unwrap(); - tab.wait_until_navigated().unwrap(); - tab.find_element_by_xpath(r#"//*[@id="username"]"#).unwrap(); - - tab.close(true).unwrap(); - app_handle.abort(); -} diff --git a/examples/basic/tests/keycloak.rs b/examples/basic/tests/keycloak.rs deleted file mode 100644 index 961d544..0000000 --- a/examples/basic/tests/keycloak.rs +++ /dev/null @@ -1,180 +0,0 @@ -use log::info; -use std::time::Duration; -use testcontainers::*; - -use testcontainers::core::ExecCommand; -use testcontainers::{core::WaitFor, Container, Image, RunnableImage}; - -struct KeycloakImage; - -impl Image for KeycloakImage { - type Args = Vec; - - fn name(&self) -> String { - "quay.io/keycloak/keycloak".to_string() - } - - fn tag(&self) -> String { - "latest".to_string() - } - - fn ready_conditions(&self) -> Vec { - vec![] - } -} - -pub struct Keycloak<'a> { - container: Container<'a, KeycloakImage>, - realms: Vec, - url: String, -} - -#[derive(Clone)] -pub struct Realm { - pub name: String, - pub clients: Vec, - pub users: Vec, -} - -#[derive(Clone)] -pub struct Client { - pub client_id: String, - pub client_secret: Option, -} - -#[derive(Clone)] -pub struct User { - pub username: String, - pub email: String, - pub firstname: String, - pub lastname: String, - pub password: String, -} - -impl<'a> Keycloak<'a> { - pub async fn start(realms: Vec, docker: &'a clients::Cli) -> Keycloak<'a> { - info!("starting keycloak"); - - let keycloak_image = RunnableImage::from((KeycloakImage, vec!["start-dev".to_string()])) - .with_env_var(("KEYCLOAK_ADMIN", "admin")) - .with_env_var(("KEYCLOAK_ADMIN_PASSWORD", "admin")); - let container = docker.run(keycloak_image); - - let keycloak = Self { - url: format!("http://127.0.0.1:{}", container.get_host_port_ipv4(8080),), - container, - realms, - }; - - let issuer = format!( - "http://127.0.0.1:{}/realms/{}", - keycloak.container.get_host_port_ipv4(8080), - "test" - ); - - while reqwest::get(&issuer).await.is_err() { - tokio::time::sleep(Duration::from_secs(1)).await; - } - - keycloak.execute("/opt/keycloak/bin/kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin".to_string()).await; - - for realm in keycloak.realms.iter() { - keycloak.create_realm(&realm.name).await; - for client in realm.clients.iter() { - keycloak - .create_client( - &client.client_id, - client.client_secret.as_deref(), - &realm.name, - ) - .await; - } - for user in realm.users.iter() { - keycloak - .create_user( - &user.username, - &user.email, - &user.firstname, - &user.lastname, - &user.password, - &realm.name, - ) - .await; - } - } - - keycloak - } - - pub fn url(&self) -> &str { - &self.url - } - - async fn create_realm(&self, name: &str) { - self.execute(format!( - "/opt/keycloak/bin/kcadm.sh create realms -s realm={} -s enabled=true", - name - )) - .await; - } - - async fn create_client(&self, client_id: &str, client_secret: Option<&str>, realm: &str) { - if let Some(client_secret) = client_secret { - self.execute(format!( - r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF - {{ - "clientId": "{}", - "secret": "{}", - "redirectUris": ["*"] - }} - EOF - "#, - realm, client_id, client_secret - )) - .await; - } else { - self.execute(format!( - r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF - {{ - "clientId": "{}", - "redirectUris": ["*"] - }} - EOF - "#, - realm, client_id - )) - .await; - } - } - - async fn create_user( - &self, - username: &str, - email: &str, - firstname: &str, - lastname: &str, - password: &str, - realm: &str, - ) { - let id = self.execute( - format!( - "/opt/keycloak/bin/kcadm.sh create users -r {} -s username={} -s enabled=true -s emailVerified=true -s email={} -s firstName={} -s lastName={}", - realm, username, email, firstname, lastname - ), - ) - .await; - self.execute(format!( - "/opt/keycloak/bin/kcadm.sh set-password -r {} --username {} --new-password {}", - realm, username, password - )) - .await; - id - } - - async fn execute(&self, cmd: String) { - self.container.exec(ExecCommand { - cmd, - ready_conditions: vec![], - }); - } -} diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 0000000..ef6c54f --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,244 @@ +use std::marker::PhantomData; + +use http::Uri; +use openidconnect::{ + Audience, AuthenticationContextClass, ClientId, ClientSecret, IssuerUrl, Scope, +}; + +use crate::{error::Error, AdditionalClaims, Client, OidcClient, ProviderMetadata}; + +pub struct Unconfigured; +pub struct OpenidconnectClient(crate::Client); +pub struct HttpClient(reqwest::Client); +pub struct RedirectUrl(Uri); + +pub struct ClientCredentials { + id: ClientId, + secret: Option, +} + +pub struct Builder { + credentials: Credentials, + client: Client, + http_client: HttpClient, + redirect_url: RedirectUrl, + end_session_endpoint: Option, + scopes: Vec, + auth_context_class: Option, + untrusted_audiences: Vec, + _ac: PhantomData, +} + +impl Default for Builder { + fn default() -> Self { + Self::new() + } +} +impl Builder { + /// create a new builder with default values + pub fn new() -> Self { + Self { + credentials: (), + client: (), + http_client: (), + redirect_url: (), + end_session_endpoint: None, + scopes: vec![Scope::new("openid".to_string())], + auth_context_class: None, + untrusted_audiences: Vec::new(), + _ac: PhantomData, + } + } +} + +impl OidcClient { + /// create a new builder with default values + pub fn builder() -> Builder { + Builder::::new() + } +} + +impl Builder { + /// add a scope to existing (default) scopes + pub fn add_scope(mut self, scope: Scope) -> Self { + self.scopes.push(scope); + self + } + + /// replace scopes (including default) + pub fn with_scopes(mut self, scopes: Vec) -> Self { + self.scopes = scopes; + self + } + + /// authenticate with Authentication Context Class Reference + pub fn with_auth_context_class(mut self, acr: AuthenticationContextClass) -> Self { + self.auth_context_class = Some(acr); + self + } + + /// add a an untrusted audience to existing untrusted audiences + pub fn add_untrusted_audience(mut self, audience: Audience) -> Self { + self.untrusted_audiences.push(audience); + self + } + + /// replace untrusted audiences + pub fn with_untrusted_audiences(mut self, untrusted_audiences: Vec) -> Self { + self.untrusted_audiences = untrusted_audiences; + self + } +} + +impl Builder { + /// set client id for authentication with issuer + pub fn with_client_id( + self, + id: impl Into, + ) -> Builder { + Builder::<_, _, _, _, _> { + credentials: ClientCredentials { + id: id.into(), + secret: None, + }, + client: self.client, + http_client: self.http_client, + redirect_url: self.redirect_url, + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + _ac: PhantomData, + } + } +} + +impl Builder { + /// set client secret for authentication with issuer + pub fn with_client_secret(mut self, secret: impl Into) -> Self { + self.credentials.secret = Some(secret.into()); + self + } +} + +impl Builder { + /// use custom http client + pub fn with_http_client( + self, + client: reqwest::Client, + ) -> Builder { + Builder { + credentials: self.credentials, + client: self.client, + http_client: HttpClient(client), + redirect_url: self.redirect_url, + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + _ac: self._ac, + } + } + /// use default reqwest http client + pub fn with_default_http_client(self) -> Builder { + Builder { + credentials: self.credentials, + client: self.client, + http_client: HttpClient(reqwest::Client::default()), + redirect_url: self.redirect_url, + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + _ac: self._ac, + } + } +} + +impl Builder { + pub fn with_redirect_url( + self, + redirect_url: Uri, + ) -> Builder { + Builder { + credentials: self.credentials, + client: self.client, + http_client: self.http_client, + redirect_url: RedirectUrl(redirect_url), + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + _ac: self._ac, + } + } +} + +impl Builder { + /// provide issuer details manually + pub fn manual( + self, + provider_metadata: ProviderMetadata, + ) -> Result< + Builder, HttpClient, RedirectUrl>, + Error, + > { + let end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone() + .map(Uri::from_maybe_shared) + .transpose() + .map_err(Error::InvalidEndSessionEndpoint)?; + let client = Client::from_provider_metadata( + provider_metadata, + ClientId::new(self.credentials.id.to_string()), + self.credentials.secret.clone(), + ) + .set_redirect_uri(openidconnect::RedirectUrl::new( + self.redirect_url.0.to_string(), + )?); + + Ok(Builder { + credentials: self.credentials, + client: OpenidconnectClient(client), + http_client: self.http_client, + redirect_url: self.redirect_url, + end_session_endpoint, + scopes: self.scopes, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + _ac: self._ac, + }) + } + /// discover issuer details + pub async fn discover( + self, + issuer: IssuerUrl, + ) -> Result< + Builder, HttpClient, RedirectUrl>, + Error, + > { + let http_client = self.http_client.0.clone(); + let provider_metadata = ProviderMetadata::discover_async(issuer, &http_client); + + Self::manual(self, provider_metadata.await?) + } +} + +impl + Builder, HttpClient, RedirectUrl> +{ + /// create oidc client + pub fn build(self) -> OidcClient { + OidcClient { + scopes: self.scopes, + client_id: self.credentials.id, + client: self.client.0, + http_client: self.http_client.0, + end_session_endpoint: self.end_session_endpoint, + auth_context_class: self.auth_context_class, + untrusted_audiences: self.untrusted_audiences, + } + } +} diff --git a/src/error.rs b/src/error.rs index 454dddc..071e911 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,11 +16,13 @@ pub enum ExtractorError { #[error("could not build rp initiated logout uri")] FailedToCreateRpInitiatedLogoutUri, - } #[derive(Debug, Error)] pub enum MiddlewareError { + #[error("configuration: {0:?}")] + Configuration(#[from] openidconnect::ConfigurationError), + #[error("access token hash invalid")] AccessTokenHashInvalid, @@ -33,9 +35,20 @@ pub enum MiddlewareError { #[error("signing: {0:?}")] Signing(#[from] openidconnect::SigningError), + #[error("signature verification: {0:?}")] + Signature(#[from] openidconnect::SignatureVerificationError), + #[error("claims verification: {0:?}")] ClaimsVerification(#[from] openidconnect::ClaimsVerificationError), + #[error("user info retrieval: {0:?}")] + UserInfoRetrieval( + #[from] + openidconnect::UserInfoError< + openidconnect::HttpClientError, + >, + ), + #[error("url parsing: {0:?}")] UrlParsing(#[from] openidconnect::url::ParseError), @@ -49,7 +62,7 @@ pub enum MiddlewareError { RequestToken( #[from] openidconnect::RequestTokenError< - openidconnect::reqwest::Error, + openidconnect::HttpClientError, StandardErrorResponse, >, ), @@ -65,6 +78,48 @@ pub enum MiddlewareError { #[error("auth middleware not found")] AuthMiddlewareNotFound, + + #[error("original url not found")] + OriginalUrlNotFound, +} + +#[derive(Debug, Error)] +pub enum HandlerError { + #[error("redirect handler accessed without valid session, session cookie missing?")] + RedirectedWithoutSession, + + #[error("csrf token invalid")] + CsrfTokenInvalid, + + #[error("id token missing")] + IdTokenMissing, + + #[error("access token hash invalid")] + AccessTokenHashInvalid, + + #[error("signing: {0:?}")] + Signing(#[from] openidconnect::SigningError), + + #[error("signature verification: {0:?}")] + Signature(#[from] openidconnect::SignatureVerificationError), + + #[error("session error: {0:?}")] + Session(#[from] tower_sessions::session::Error), + + #[error("configuration: {0:?}")] + Configuration(#[from] openidconnect::ConfigurationError), + + #[error("request token: {0:?}")] + RequestToken( + #[from] + openidconnect::RequestTokenError< + openidconnect::HttpClientError, + StandardErrorResponse, + >, + ), + + #[error("claims verification: {0:?}")] + ClaimsVerification(#[from] openidconnect::ClaimsVerificationError), } #[derive(Debug, Error)] @@ -76,13 +131,21 @@ pub enum Error { InvalidEndSessionEndpoint(http::uri::InvalidUri), #[error("discovery: {0:?}")] - Discovery(#[from] openidconnect::DiscoveryError>), + Discovery( + #[from] + openidconnect::DiscoveryError< + openidconnect::HttpClientError, + >, + ), #[error("extractor: {0:?}")] Extractor(#[from] ExtractorError), #[error("extractor: {0:?}")] Middleware(#[from] MiddlewareError), + + #[error("handler: {0:?}")] + Handler(#[from] HandlerError), } impl IntoResponse for ExtractorError { @@ -101,18 +164,21 @@ impl IntoResponse for ExtractorError { impl IntoResponse for Error { fn into_response(self) -> axum_core::response::Response { - dbg!(&self); - match self { - _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), - } + tracing::error!(error = self.to_string()); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() } } impl IntoResponse for MiddlewareError { fn into_response(self) -> axum_core::response::Response { - dbg!(&self); - match self { - _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), - } + tracing::error!(error = self.to_string()); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() + } +} + +impl IntoResponse for HandlerError { + fn into_response(self) -> axum_core::response::Response { + tracing::error!(error = self.to_string()); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() } } diff --git a/src/extractor.rs b/src/extractor.rs index 9cd41ed..dbb6482 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,19 +1,20 @@ -use std::{borrow::Cow, ops::Deref}; +use std::{borrow::Cow, convert::Infallible, ops::Deref}; use crate::{error::ExtractorError, AdditionalClaims, ClearSessionFlag}; -use async_trait::async_trait; use axum::response::Redirect; -use axum_core::{extract::FromRequestParts, response::IntoResponse}; +use axum_core::{ + extract::{FromRequestParts, OptionalFromRequestParts}, + response::IntoResponse, +}; use http::{request::Parts, uri::PathAndQuery, Uri}; -use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; +use openidconnect::{core::CoreGenderClaim, ClientId, IdTokenClaims, UserInfoClaims}; /// Extractor for the OpenID Connect Claims. /// /// This Extractor will only return the Claims when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct OidcClaims(pub IdTokenClaims); -#[async_trait] impl FromRequestParts for OidcClaims where S: Send + Sync, @@ -30,6 +31,18 @@ where } } +impl OptionalFromRequestParts for OidcClaims +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + impl Deref for OidcClaims { type Target = IdTokenClaims; @@ -53,7 +66,6 @@ where #[derive(Clone)] pub struct OidcAccessToken(pub String); -#[async_trait] impl FromRequestParts for OidcAccessToken where S: Send + Sync, @@ -69,6 +81,17 @@ where } } +impl OptionalFromRequestParts for OidcAccessToken +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + impl Deref for OidcAccessToken { type Target = str; @@ -89,8 +112,8 @@ impl AsRef for OidcAccessToken { #[derive(Clone)] pub struct OidcRpInitiatedLogout { pub(crate) end_session_endpoint: Uri, - pub(crate) id_token_hint: String, - pub(crate) client_id: String, + pub(crate) id_token_hint: Box, + pub(crate) client_id: ClientId, pub(crate) post_logout_redirect_uri: Option, pub(crate) state: Option, } @@ -147,7 +170,6 @@ impl OidcRpInitiatedLogout { } } -#[async_trait] impl FromRequestParts for OidcRpInitiatedLogout where S: Send + Sync, @@ -159,10 +181,22 @@ where .extensions .get::>() .cloned() - .ok_or(ExtractorError::Unauthorized)?{ + .ok_or(ExtractorError::Unauthorized)? + { Some(this) => Ok(this), None => Err(ExtractorError::RpInitiatedLogoutNotSupported), - } + } + } +} + +impl OptionalFromRequestParts for OidcRpInitiatedLogout +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::>().cloned().flatten()) } } @@ -179,3 +213,54 @@ impl IntoResponse for OidcRpInitiatedLogout { } } } + +/// Extractor for the OpenID Connect User Info Claims. +/// +/// This Extractor will only return the User Info Claims when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. +#[derive(Clone, Debug)] +pub struct OidcUserInfo(pub UserInfoClaims); + +impl FromRequestParts for OidcUserInfo +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = ExtractorError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(ExtractorError::Unauthorized) + } +} + +impl OptionalFromRequestParts for OidcUserInfo +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + +impl Deref for OidcUserInfo { + type Target = UserInfoClaims; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef> for OidcUserInfo +where + AC: AdditionalClaims, +{ + fn as_ref(&self) -> &UserInfoClaims { + &self.0 + } +} diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..68f4793 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,122 @@ +use axum::{extract::Query, response::Redirect, Extension}; +use openidconnect::{ + core::{CoreGenderClaim, CoreJsonWebKey}, + AccessToken, AccessTokenHash, AuthorizationCode, IdTokenClaims, IdTokenVerifier, + OAuth2TokenResponse, PkceCodeVerifier, TokenResponse, +}; +use serde::Deserialize; +use tower_sessions::Session; + +use crate::{ + error::HandlerError, AdditionalClaims, AuthenticatedSession, IdToken, OidcClient, OidcSession, + SESSION_KEY, +}; + +/// response data of the openid issuer after login +#[derive(Debug, Deserialize)] +pub struct OidcQuery { + code: String, + state: String, + #[allow(dead_code)] + session_state: Option, +} + +#[tracing::instrument(skip(oidcclient), err)] +pub async fn handle_oidc_redirect( + session: Session, + Extension(oidcclient): Extension>, + Query(query): Query, +) -> Result { + tracing::debug!("start handling oidc redirect"); + + let mut login_session: OidcSession = session + .get(SESSION_KEY) + .await? + .ok_or(HandlerError::RedirectedWithoutSession)?; + // the request has the request headers of the oidc redirect + // parse the headers and exchange the code for a valid token + + tracing::debug!("validating scrf token"); + if login_session.csrf_token.secret() != &query.state { + return Err(HandlerError::CsrfTokenInvalid); + } + + tracing::debug!("obtain token response"); + let token_response = oidcclient + .client + .exchange_code(AuthorizationCode::new(query.code.to_string()))? + // Set the PKCE code verifier. + .set_pkce_verifier(PkceCodeVerifier::new( + login_session.pkce_verifier.secret().to_string(), + )) + .request_async(&oidcclient.http_client) + .await?; + + tracing::debug!("extract claims and verify it"); + // Extract the ID token claims after verifying its authenticity and nonce. + let id_token = token_response + .id_token() + .ok_or(HandlerError::IdTokenMissing)?; + let id_token_verifier = oidcclient + .client + .id_token_verifier() + .set_other_audience_verifier_fn(|audience| + // Return false (reject) if audience is in list of untrusted audiences + !oidcclient.untrusted_audiences.contains(audience)); + let claims = id_token.claims(&id_token_verifier, &login_session.nonce)?; + + tracing::debug!("validate access token hash"); + validate_access_token_hash( + id_token, + id_token_verifier, + token_response.access_token(), + claims, + ) + .inspect_err(|e| tracing::error!(?e, "Access token hash invalid"))?; + + tracing::debug!("Access token hash validated"); + + login_session.authenticated = Some(AuthenticatedSession { + id_token: id_token.clone(), + access_token: token_response.access_token().clone(), + }); + let refresh_token = token_response.refresh_token().cloned(); + if let Some(refresh_token) = refresh_token { + login_session.refresh_token = Some(refresh_token); + } + + tracing::debug!( + "Inserting session and redirecting to {}", + &login_session.redirect_url + ); + let redirect_url = login_session.redirect_url.clone(); + session.insert(SESSION_KEY, login_session).await?; + + Ok(Redirect::to(&redirect_url)) +} + +/// Verify the access token hash to ensure that the access token hasn't been substituted for +/// another user's. +/// Returns `Ok` when access token is valid +#[tracing::instrument(skip_all, err)] +fn validate_access_token_hash( + id_token: &IdToken, + id_token_verifier: IdTokenVerifier, + access_token: &AccessToken, + claims: &IdTokenClaims, +) -> Result<(), HandlerError> { + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = AccessTokenHash::from_token( + access_token, + id_token.signing_alg()?, + id_token.signing_key(&id_token_verifier)?, + )?; + if actual_access_token_hash == *expected_access_token_hash { + Ok(()) + } else { + Err(HandlerError::AccessTokenHashInvalid) + } + } else { + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index a6825b3..bcf783f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,29 +3,31 @@ #![deny(warnings)] #![doc = include_str!("../README.md")] -use crate::error::Error; use http::Uri; use openidconnect::{ core::{ CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClientAuthMethod, - CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, - CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken, - CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, - CoreTokenType, + CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, + CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, + CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, + CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, HttpRequest, - HttpResponse, IdTokenFields, IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, - StandardErrorResponse, StandardTokenResponse, + AccessToken, Audience, AuthenticationContextClass, ClientId, CsrfToken, EmptyExtraTokenFields, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, + RefreshToken, Scope, StandardErrorResponse, StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +pub mod builder; pub mod error; mod extractor; +mod handler; mod middleware; -pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}; +pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}; +pub use handler::handle_oidc_redirect; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; +pub use openidconnect; const SESSION_KEY: &str = "axum-oidc"; @@ -41,7 +43,6 @@ type OidcTokenResponse = StandardTokenResponse< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, >, CoreTokenType, >; @@ -51,25 +52,34 @@ pub type IdToken = openidconnect::IdToken< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, >; -type Client = openidconnect::Client< +type Client< + AC, + HasAuthUrl = EndpointSet, + HasDeviceAuthUrl = EndpointNotSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointMaybeSet, + HasUserInfoUrl = EndpointMaybeSet, +> = openidconnect::Client< AC, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, - CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, OidcTokenResponse, - CoreTokenType, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, + HasUserInfoUrl, >; pub type ProviderMetadata = openidconnect::ProviderMetadata< @@ -81,9 +91,6 @@ pub type ProviderMetadata = openidconnect::ProviderMetadata< CoreGrantType, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, - CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJsonWebKey, CoreResponseMode, CoreResponseType, @@ -95,149 +102,13 @@ pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { - scopes: Vec, - client_id: String, + scopes: Vec, + client_id: ClientId, client: Client, http_client: reqwest::Client, - application_base_url: Uri, end_session_endpoint: Option, -} - -impl OidcClient { - /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. - pub fn from_provider_metadata( - provider_metadata: ProviderMetadata, - application_base_url: Uri, - client_id: String, - client_secret: Option, - scopes: Vec, - ) -> Result { - let end_session_endpoint = provider_metadata - .additional_metadata() - .end_session_endpoint - .clone() - .map(Uri::from_maybe_shared) - .transpose() - .map_err(Error::InvalidEndSessionEndpoint)?; - let client = Client::from_provider_metadata( - provider_metadata, - ClientId::new(client_id.clone()), - client_secret.map(ClientSecret::new), - ); - Ok(Self { - scopes, - client, - client_id, - application_base_url, - end_session_endpoint, - http_client: reqwest::Client::default(), - }) - } - /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. - pub fn from_provider_metadata_and_client( - provider_metadata: ProviderMetadata, - application_base_url: Uri, - client_id: String, - client_secret: Option, - scopes: Vec, - http_client: reqwest::Client, - ) -> Result { - let end_session_endpoint = provider_metadata - .additional_metadata() - .end_session_endpoint - .clone() - .map(Uri::from_maybe_shared) - .transpose() - .map_err(Error::InvalidEndSessionEndpoint)?; - let client = Client::from_provider_metadata( - provider_metadata, - ClientId::new(client_id.clone()), - client_secret.map(ClientSecret::new), - ); - Ok(Self { - scopes, - client, - client_id, - application_base_url, - end_session_endpoint, - http_client, - }) - } - - /// create a new [`OidcClient`] by fetching the required information from the - /// `/.well-known/openid-configuration` endpoint of the issuer. - pub async fn discover_new( - application_base_url: Uri, - issuer: String, - client_id: String, - client_secret: Option, - scopes: Vec, - ) -> Result { - let client = reqwest::Client::default(); - Self::discover_new_with_client( - application_base_url, - issuer, - client_id, - client_secret, - scopes, - &client, - ) - .await - } - - /// create a new [`OidcClient`] by fetching the required information from the - /// `/.well-known/openid-configuration` endpoint of the issuer using the provided - /// `reqwest::Client`. - pub async fn discover_new_with_client( - application_base_url: Uri, - issuer: String, - client_id: String, - client_secret: Option, - scopes: Vec, - //TODO remove borrow with next breaking version - client: &reqwest::Client, - ) -> Result { - // modified version of `openidconnect::reqwest::async_client::async_http_client`. - let async_http_client = |request: HttpRequest| async move { - let mut request_builder = client - .request(request.method, request.url.as_str()) - .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let request = request_builder - .build() - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let response = client - .execute(request) - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response - .bytes() - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) - }; - - let provider_metadata = - ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; - Self::from_provider_metadata_and_client( - provider_metadata, - application_base_url, - client_id, - client_secret, - scopes, - client.clone(), - ) - } + auth_context_class: Option, + untrusted_audiences: Vec, } /// an empty struct to be used as the default type for the additional claims generic @@ -246,15 +117,6 @@ pub struct EmptyAdditionalClaims {} impl AdditionalClaims for EmptyAdditionalClaims {} impl openidconnect::AdditionalClaims for EmptyAdditionalClaims {} -/// response data of the openid issuer after login -#[derive(Debug, Deserialize)] -struct OidcQuery { - code: String, - state: String, - #[allow(dead_code)] - session_state: Option, -} - /// oidc session #[derive(Serialize, Deserialize, Debug)] #[serde(bound = "AC: Serialize + DeserializeOwned")] @@ -264,6 +126,7 @@ struct OidcSession { pkce_verifier: PkceCodeVerifier, authenticated: Option>, refresh_token: Option, + redirect_url: Box, } #[derive(Serialize, Deserialize, Debug)] diff --git a/src/middleware.rs b/src/middleware.rs index 3ae8437..b4b68b5 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,34 +1,32 @@ use std::{ marker::PhantomData, - pin::Pin, task::{Context, Poll}, }; use axum::{ - extract::Query, + extract::OriginalUri, response::{IntoResponse, Redirect}, }; -use axum_core::{extract::FromRequestParts, response::Response}; -use futures_util::{future::BoxFuture, Future}; -use http::{request::Parts, uri::PathAndQuery, Request, Uri}; +use axum_core::response::Response; +use futures_util::future::BoxFuture; +use http::{request::Parts, Request}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim}, - AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, HttpRequest, HttpResponse, - IdTokenClaims, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, - RefreshToken, + core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, + AccessToken, AccessTokenHash, CsrfToken, IdTokenClaims, IdTokenVerifier, Nonce, + NonceVerifier as _, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, - Scope, TokenResponse, + Scope, TokenResponse, UserInfoClaims, }; use crate::{ - error::{Error, MiddlewareError}, - extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, + error::MiddlewareError, + extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}, AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, - OidcQuery, OidcSession, SESSION_KEY, + OidcSession, SESSION_KEY, }; /// Layer for the [`OidcLoginMiddleware`]. @@ -107,103 +105,62 @@ where } else { // no valid id token or refresh token was found and the user has to login Box::pin(async move { - let (mut parts, _) = request.into_parts(); + let (parts, _) = request.into_parts(); - let mut oidcclient: OidcClient = parts + let oidcclient: OidcClient = parts .extensions .get() .cloned() .ok_or(MiddlewareError::AuthMiddlewareNotFound)?; - let query = Query::::from_request_parts(&mut parts, &()) - .await - .ok(); - let session = parts .extensions .get::() .ok_or(MiddlewareError::SessionNotFound)?; - let login_session: Option> = session - .get(SESSION_KEY) - .await - .map_err(MiddlewareError::from)?; - let handler_uri = - strip_oidc_from_path(oidcclient.application_base_url.clone(), &parts.uri)?; + let redirect_url = parts + .extensions + .get::() + .ok_or(MiddlewareError::OriginalUrlNotFound)?; - oidcclient.client = oidcclient - .client - .set_redirect_uri(RedirectUrl::new(handler_uri.to_string())?); - - if let (Some(mut login_session), Some(query)) = (login_session, query) { - // the request has the request headers of the oidc redirect - // parse the headers and exchange the code for a valid token - - if login_session.csrf_token.secret() != &query.state { - return Err(MiddlewareError::CsrfTokenInvalid); - } - - let token_response = oidcclient - .client - .exchange_code(AuthorizationCode::new(query.code.to_string())) - // Set the PKCE code verifier. - .set_pkce_verifier(PkceCodeVerifier::new( - login_session.pkce_verifier.secret().to_string(), - )) - .request_async(async_http_client(&oidcclient.http_client)) - .await?; - - // Extract the ID token claims after verifying its authenticity and nonce. - let id_token = token_response - .id_token() - .ok_or(MiddlewareError::IdTokenMissing)?; - let claims = id_token - .claims(&oidcclient.client.id_token_verifier(), &login_session.nonce)?; - - validate_access_token_hash(id_token, token_response.access_token(), claims)?; - - login_session.authenticated = Some(AuthenticatedSession { - id_token: id_token.clone(), - access_token: token_response.access_token().clone(), - }); - let refresh_token = token_response.refresh_token().cloned(); - if let Some(refresh_token) = refresh_token { - login_session.refresh_token = Some(refresh_token); - } - - session.insert(SESSION_KEY, login_session).await?; - - Ok(Redirect::temporary(&handler_uri.to_string()).into_response()) + let redirect_url = if let Some(query) = redirect_url.query() { + redirect_url.path().to_string() + "?" + query } else { - // generate a login url and redirect the user to it + redirect_url.path().to_string() + }; + // generate a login url and redirect the user to it - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - let (auth_url, csrf_token, nonce) = { - let mut auth = oidcclient.client.authorize_url( - CoreAuthenticationFlow::AuthorizationCode, - CsrfToken::new_random, - Nonce::new_random, - ); + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let (auth_url, csrf_token, nonce) = { + let mut auth = oidcclient.client.authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ); - for scope in oidcclient.scopes.iter() { - auth = auth.add_scope(Scope::new(scope.to_string())); - } + for scope in oidcclient.scopes.iter() { + auth = auth.add_scope(Scope::new(scope.to_string())); + } - auth.set_pkce_challenge(pkce_challenge).url() - }; + if let Some(acr) = oidcclient.auth_context_class { + auth = auth.add_auth_context_value(acr); + } - let oidc_session = OidcSession:: { - nonce, - csrf_token, - pkce_verifier, - authenticated: None, - refresh_token: None, - }; + auth.set_pkce_challenge(pkce_challenge).url() + }; - session.insert(SESSION_KEY, oidc_session).await?; + let oidc_session = OidcSession:: { + nonce, + csrf_token, + pkce_verifier, + authenticated: None, + refresh_token: None, + redirect_url: redirect_url.into(), + }; - Ok(Redirect::temporary(auth_url.as_str()).into_response()) - } + session.insert(SESSION_KEY, oidc_session).await?; + + Ok(Redirect::to(auth_url.as_str()).into_response()) }) } } @@ -222,24 +179,10 @@ impl OidcAuthLayer { pub fn new(client: OidcClient) -> Self { Self { client } } - - pub async fn discover_client( - application_base_url: Uri, - issuer: String, - client_id: String, - client_secret: Option, - scopes: Vec, - ) -> Result { - Ok(Self { - client: OidcClient::::discover_new( - application_base_url, - issuer, - client_id, - client_secret, - scopes, - ) - .await?, - }) +} +impl From> for OidcAuthLayer { + fn from(value: OidcClient) -> Self { + Self::new(value) } } @@ -292,7 +235,8 @@ where fn call(&mut self, request: Request) -> Self::Future { let inner = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, inner); - let mut oidcclient = self.client.clone(); + let oidcclient = self.client.clone(); + Box::pin(async move { let (mut parts, body) = request.into_parts(); @@ -306,31 +250,48 @@ where .await .map_err(MiddlewareError::from)?; - let handler_uri = - strip_oidc_from_path(oidcclient.application_base_url.clone(), &parts.uri)?; - - oidcclient.client = oidcclient - .client - .set_redirect_uri(RedirectUrl::new(handler_uri.to_string())?); - if let Some(login_session) = &mut login_session { let id_token_claims = login_session.authenticated.as_ref().and_then(|session| { session .id_token - .claims(&oidcclient.client.id_token_verifier(), &login_session.nonce) + .claims( + &oidcclient + .client + .id_token_verifier() + .set_other_audience_verifier_fn(|audience| { + // Return false (reject) if audience is in list of untrusted audiences + !oidcclient.untrusted_audiences.contains(audience) + }), + &login_session.nonce, + ) .ok() .cloned() .map(|claims| (session, claims)) }); if let Some((session, claims)) = id_token_claims { + let user_claims = + get_user_claims(&oidcclient, session.access_token.clone()).await?; // stored id token is valid and can be used - insert_extensions(&mut parts, claims.clone(), &oidcclient, session); + insert_extensions( + &mut parts, + claims.clone(), + user_claims, + &oidcclient, + session, + ); } else if let Some(refresh_token) = login_session.refresh_token.as_ref() { - if let Some((claims, authenticated_session, refresh_token)) = + // session is expired but can be refreshed using the refresh_token + if let Some((claims, user_claims, authenticated_session, refresh_token)) = try_refresh_token(&oidcclient, refresh_token, &login_session.nonce).await? { - insert_extensions(&mut parts, claims, &oidcclient, &authenticated_session); + insert_extensions( + &mut parts, + claims, + user_claims.clone(), + &oidcclient, + &authenticated_session, + ); login_session.authenticated = Some(authenticated_session); if let Some(refresh_token) = refresh_token { @@ -360,6 +321,7 @@ where let has_logout_ext = response.extensions().get::().is_some(); if let (true, Some(mut login_session)) = (has_logout_ext, login_session) { login_session.authenticated = None; + login_session.refresh_token = None; session.insert(SESSION_KEY, login_session).await?; } @@ -368,45 +330,16 @@ where } } -/// Helper function to remove the OpenID Connect authentication response query attributes from a -/// [`Uri`]. -pub fn strip_oidc_from_path(base_url: Uri, uri: &Uri) -> Result { - let mut base_url = base_url.into_parts(); - - base_url.path_and_query = uri - .path_and_query() - .map(|path_and_query| { - let query = path_and_query - .query() - .and_then(|uri| { - uri.split('&') - .filter(|x| { - !x.starts_with("code") - && !x.starts_with("state") - && !x.starts_with("session_state") - && !x.starts_with("iss") - }) - .map(|x| x.to_string()) - .reduce(|acc, x| acc + "&" + &x) - }) - .map(|x| format!("?{x}")) - .unwrap_or_default(); - - PathAndQuery::from_maybe_shared(format!("{}{}", path_and_query.path(), query)) - }) - .transpose()?; - - Ok(Uri::from_parts(base_url)?) -} - /// insert all extensions that are used by the extractors fn insert_extensions( parts: &mut Parts, claims: IdTokenClaims, + user_claims: UserInfoClaims, client: &OidcClient, authenticated_session: &AuthenticatedSession, ) { parts.extensions.insert(OidcClaims(claims)); + parts.extensions.insert(OidcUserInfo(user_claims)); parts.extensions.insert(OidcAccessToken( authenticated_session.access_token.secret().to_string(), )); @@ -415,7 +348,7 @@ fn insert_extensions( .as_ref() .map(|end_session_endpoint| OidcRpInitiatedLogout { end_session_endpoint: end_session_endpoint.clone(), - id_token_hint: authenticated_session.id_token.to_string(), + id_token_hint: authenticated_session.id_token.to_string().into(), client_id: client.client_id.clone(), post_logout_redirect_uri: None, state: None, @@ -428,12 +361,16 @@ fn insert_extensions( /// Returns `Ok` when access token is valid fn validate_access_token_hash( id_token: &IdToken, + id_token_verifier: IdTokenVerifier, access_token: &AccessToken, claims: &IdTokenClaims, ) -> Result<(), MiddlewareError> { if let Some(expected_access_token_hash) = claims.access_token_hash() { - let actual_access_token_hash = - AccessTokenHash::from_token(access_token, &id_token.signing_alg()?)?; + let actual_access_token_hash = AccessTokenHash::from_token( + access_token, + id_token.signing_alg()?, + id_token.signing_key(&id_token_verifier)?, + )?; if actual_access_token_hash == *expected_access_token_hash { Ok(()) } else { @@ -444,6 +381,19 @@ fn validate_access_token_hash( } } +async fn get_user_claims( + client: &OidcClient, + access_token: AccessToken, +) -> Result, MiddlewareError> { + client + .client + .user_info(access_token, None) + .map_err(MiddlewareError::Configuration)? + .request_async(&client.http_client) + .await + .map_err(|e| e.into()) +} + async fn try_refresh_token( client: &OidcClient, refresh_token: &RefreshToken, @@ -451,37 +401,55 @@ async fn try_refresh_token( ) -> Result< Option<( IdTokenClaims, + UserInfoClaims, AuthenticatedSession, Option, )>, MiddlewareError, > { - let mut refresh_request = client.client.exchange_refresh_token(refresh_token); + let mut refresh_request = client.client.exchange_refresh_token(refresh_token)?; for scope in client.scopes.iter() { refresh_request = refresh_request.add_scope(Scope::new(scope.to_string())); } - match refresh_request - .request_async(async_http_client(&client.http_client)) - .await - { + match refresh_request.request_async(&client.http_client).await { Ok(token_response) => { // Extract the ID token claims after verifying its authenticity and nonce. let id_token = token_response .id_token() .ok_or(MiddlewareError::IdTokenMissing)?; - let claims = id_token.claims(&client.client.id_token_verifier(), nonce)?; + let id_token_verifier = client + .client + .id_token_verifier() + .set_other_audience_verifier_fn(|audience| + // Return false (reject) if audience is in list of untrusted audiences + !client.untrusted_audiences.contains(audience)); + let claims = id_token.claims(&id_token_verifier, |claims_nonce: Option<&Nonce>| { + match claims_nonce { + Some(_) => nonce.verify(claims_nonce), + None => Ok(()), + } + })?; - validate_access_token_hash(id_token, token_response.access_token(), claims)?; + validate_access_token_hash( + id_token, + id_token_verifier, + token_response.access_token(), + claims, + )?; let authenticated_session = AuthenticatedSession { id_token: id_token.clone(), access_token: token_response.access_token().clone(), }; + let user_claims = + get_user_claims(client, authenticated_session.access_token.clone()).await?; + Ok(Some(( claims.clone(), + user_claims, authenticated_session, token_response.refresh_token().cloned(), ))) @@ -494,47 +462,3 @@ async fn try_refresh_token( Err(err) => Err(err.into()), } } - -/// `openidconnect::reqwest::async_http_client` that uses a custom `reqwest::client` -fn async_http_client<'a>( - client: &'a reqwest::Client, -) -> impl FnOnce( - HttpRequest, -) -> Pin< - Box< - dyn Future>> - + Send - + 'a, - >, -> { - move |request: HttpRequest| { - Box::pin(async move { - let mut request_builder = client - .request(request.method, request.url.as_str()) - .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let request = request_builder - .build() - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let response = client - .execute(request) - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response - .bytes() - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) - }) - } -}