diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b64f3dc..5a1c7a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ env: CARGO_TERM_COLOR: always jobs: - test: - name: axum-oidc + build_and_test: + name: axum-oidc - latest runs-on: ubuntu-latest strategy: matrix: @@ -21,21 +21,14 @@ jobs: steps: - uses: actions/checkout@v3 - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose --release - - run: cargo test --verbose --release + - run: cargo build --verbose + - run: cargo test --verbose - test_basic_example: - name: axum-oidc - basic + build_examples: + name: axum-oidc - examples runs-on: ubuntu-latest - strategy: - matrix: - toolchain: - - stable - - nightly steps: - uses: actions/checkout@v3 - - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose --release - working-directory: ./examples/basic - - run: cargo test --verbose --release + - run: rustup update stable && rustup default stable + - run: cargo build --verbose working-directory: ./examples/basic diff --git a/.gitignore b/.gitignore index e08f5fc..a9d37c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ target Cargo.lock -.env diff --git a/Cargo.toml b/Cargo.toml index e92d92a..0e19f01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,26 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "1.0.0-dev-0" +version = "0.3.0" edition = "2021" -authors = ["Paul Z "] +authors = [ "Paul Z " ] readme = "README.md" -repository = "https://codeberg.org/pfzetto/axum-oidc" -license = "MPL-2.0" -keywords = ["axum", "oidc", "openidconnect", "authentication"] +repository = "https://github.com/pfz4/axum-oidc" +license = "LGPL-3.0-or-later" +keywords = [ "axum", "oidc", "openidconnect", "authentication" ] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -thiserror = "2.0" -axum-core = "0.5" -axum = { version = "0.8", default-features = false, features = [ - "query", - "original-uri", -] } -tower-service = "0.3" +thiserror = "1.0" +axum-core = "0.4" +axum = { version = "0.7", default-features = false, features = [ "query" ] } +tower-service = "0.3.2" tower-layer = "0.3" -tower-sessions = { version = "0.14", default-features = false, features = [ - "axum-core", -] } -http = "1.3.1" -openidconnect = "4.0" +tower-sessions = { version = "0.11", default-features = false, features = [ "axum-core" ] } +http = "1.1" +async-trait = "0.1" +openidconnect = "3.5" serde = "1.0" futures-util = "0.3" -reqwest = { version = "0.12", default-features = false } -urlencoding = "2.1" -tracing = "0.1.41" +reqwest = { version = "0.11", default-features = false } diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index d0a1fa1..0000000 --- a/LICENSE.txt +++ /dev/null @@ -1,373 +0,0 @@ -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 d7d6049..11a3fe7 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ This Library allows using [OpenID Connect](https://openid.net/developers/how-connect-works/) with [axum](https://github.com/tokio-rs/axum). -It authenticates the user with the OpenID Connect Issuer and provides Extractors. +It authenticates the user with the OpenID Conenct Issuer and provides Extractors. # Usage The `OidcAuthLayer` must be loaded on any handler that might use the extractors. @@ -13,23 +13,16 @@ The extractors will always return a value. The `OidcClaims`-extractor can be used to get the OpenId Conenct Claims. The `OidcAccessToken`-extractor can be used to get the OpenId Connect Access Token. -The `OidcRpInitializedLogout`-extractor can be used to get the rp initialized logout uri. - Your OIDC-Client must be allowed to redirect to **every** subpath of your application base url. # Examples Take a look at the `examples` folder for examples. -# Older Versions -All versions on [crates.io](https://crates.io) are available as git tags. -Additional all minor versions have their own branch (format `vX.Y` where `X` is the major and `Y` is the minor version) where bug fixes are implemented. -Examples for each version can be found there in the previously mentioned `examples` folder. - # Contributing I'm happy about any contribution in any form. Feel free to submit feature requests and bug reports using a GitHub Issue. PR's are also appreciated. # License -This Library is licensed under [MPLv2](https://www.mozilla.org/en-US/MPL/2.0/). +This Library is licensed under [LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html). diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 833473d..d57537a 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -1,15 +1,13 @@ [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] -axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1.36.0", features = ["net", "macros"] } +axum = "0.7.4" axum-oidc = { path = "./../.." } -dotenvy = "0.15" -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" +tower = "0.4.13" +tower-sessions = "0.11.0" diff --git a/examples/basic/README.md b/examples/basic/README.md deleted file mode 100644 index 30cdf2f..0000000 --- a/examples/basic/README.md +++ /dev/null @@ -1,20 +0,0 @@ -This example is a basic web application to demonstrate the features of the `axum-oidc`-crate. -It has three endpoints: -- `/logout` - Logout of the current session using `OIDC RP-Initiated Logout`. -- `/foo` - A handler that only can be accessed when logged in. -- `/bar` - A handler that can be accessed logged out and logged in. It will greet the user with their name if they are logged in. - -# Running the Example -## 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. - -## Setup Environment -Create a `.env`-file that contains the following keys: -``` -APP_URL=http://127.0.0.1:8080 -ISSUER= -CLIENT_ID= -CLIENT_SECRET= -``` -## Run the application -`RUST_LOG=debug cargo run` diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 45d99c3..e8cd78a 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,37 +1,18 @@ use axum::{ - Router, - error_handling::HandleErrorLayer, - http::Uri, - response::IntoResponse, - routing::{any, get}, + error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, }; use axum_oidc::{ - EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, - OidcRpInitiatedLogout, - error::MiddlewareError, - handle_oidc_redirect, - openidconnect::{Audience, ClientId, ClientSecret, IssuerUrl, Scope}, + error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, }; use tokio::net::TcpListener; use tower::ServiceBuilder; use tower_sessions::{ + cookie::{time::Duration, SameSite}, 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 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(); - let session_store = MemoryStore::default(); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) @@ -40,48 +21,33 @@ async fn main() { 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)); + .layer( + OidcAuthLayer::::discover_client( + Uri::from_static("http://localhost:8080"), + "https://auth.zettoit.eu/realms/zettoit".to_string(), + "oxicloud".to_string(), + Some("IvBcDOfp9WBfGNmwIbiv67bxCwuQUGbl".to_owned()), + vec![], + ) + .await + .unwrap(), + ); 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 @@ -92,11 +58,10 @@ async fn authenticated(claims: OidcClaims) -> impl IntoRe format!("Hello {}", claims.subject().as_str()) } -#[axum::debug_handler] async fn maybe_authenticated( - claims: Result, axum_oidc::error::ExtractorError>, + claims: Option>, ) -> impl IntoResponse { - if let Ok(claims) = claims { + if let Some(claims) = claims { format!( "Hello {}! You are already logged in from another Handler.", claims.subject().as_str() @@ -105,7 +70,3 @@ async fn maybe_authenticated( "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/src/builder.rs b/src/builder.rs deleted file mode 100644 index ef6c54f..0000000 --- a/src/builder.rs +++ /dev/null @@ -1,244 +0,0 @@ -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 071e911..c91ea66 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,19 +10,10 @@ use thiserror::Error; pub enum ExtractorError { #[error("unauthorized")] Unauthorized, - - #[error("rp initiated logout not supported by issuer")] - RpInitiatedLogoutNotSupported, - - #[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, @@ -35,20 +26,9 @@ 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), @@ -62,7 +42,7 @@ pub enum MiddlewareError { RequestToken( #[from] openidconnect::RequestTokenError< - openidconnect::HttpClientError, + openidconnect::reqwest::Error, StandardErrorResponse, >, ), @@ -78,48 +58,6 @@ 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)] @@ -127,58 +65,36 @@ pub enum Error { #[error("url parsing: {0:?}")] UrlParsing(#[from] openidconnect::url::ParseError), - #[error("invalid end_session_endpoint uri: {0:?}")] - InvalidEndSessionEndpoint(http::uri::InvalidUri), - #[error("discovery: {0:?}")] - Discovery( - #[from] - openidconnect::DiscoveryError< - openidconnect::HttpClientError, - >, - ), + Discovery(#[from] openidconnect::DiscoveryError>), #[error("extractor: {0:?}")] Extractor(#[from] ExtractorError), #[error("extractor: {0:?}")] Middleware(#[from] MiddlewareError), - - #[error("handler: {0:?}")] - Handler(#[from] HandlerError), } impl IntoResponse for ExtractorError { fn into_response(self) -> axum_core::response::Response { - match self { - Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(), - Self::RpInitiatedLogoutNotSupported => { - (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() - } - Self::FailedToCreateRpInitiatedLogoutUri => { - (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() - } - } + (StatusCode::UNAUTHORIZED, "unauthorized").into_response() } } impl IntoResponse for Error { fn into_response(self) -> axum_core::response::Response { - tracing::error!(error = self.to_string()); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response() + dbg!(&self); + match self { + _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), + } } } impl IntoResponse for MiddlewareError { fn into_response(self) -> axum_core::response::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() + dbg!(&self); + match self { + _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), + } } } diff --git a/src/extractor.rs b/src/extractor.rs index dbb6482..5c18d78 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,20 +1,18 @@ -use std::{borrow::Cow, convert::Infallible, ops::Deref}; +use std::ops::Deref; -use crate::{error::ExtractorError, AdditionalClaims, ClearSessionFlag}; -use axum::response::Redirect; -use axum_core::{ - extract::{FromRequestParts, OptionalFromRequestParts}, - response::IntoResponse, -}; -use http::{request::Parts, uri::PathAndQuery, Uri}; -use openidconnect::{core::CoreGenderClaim, ClientId, IdTokenClaims, UserInfoClaims}; +use crate::{error::ExtractorError, AdditionalClaims}; +use async_trait::async_trait; +use axum_core::extract::FromRequestParts; +use http::request::Parts; +use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; /// 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, Debug)] +/// This Extractor will only return the Claims when the cached session is valid and [crate::middleware::OidcAuthMiddleware] is loaded. +#[derive(Clone)] pub struct OidcClaims(pub IdTokenClaims); +#[async_trait] impl FromRequestParts for OidcClaims where S: Send + Sync, @@ -31,18 +29,6 @@ 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; @@ -62,10 +48,11 @@ where /// Extractor for the OpenID Connect Access Token. /// -/// This Extractor will only return the Access Token when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. +/// This Extractor will only return the Access Token when the cached session is valid and [crate::middleware::OidcAuthMiddleware] is loaded. #[derive(Clone)] pub struct OidcAccessToken(pub String); +#[async_trait] impl FromRequestParts for OidcAccessToken where S: Send + Sync, @@ -81,17 +68,6 @@ 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; @@ -102,165 +78,6 @@ impl Deref for OidcAccessToken { impl AsRef for OidcAccessToken { fn as_ref(&self) -> &str { - &self.0 - } -} - -/// Extractor for the [OpenID Connect RP-Initialized Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) URL -/// -/// This Extractor will only succed when the cached session is valid, [`crate::middleware::OidcAuthMiddleware`] is loaded and the issuer supports RP-Initialized Logout. -#[derive(Clone)] -pub struct OidcRpInitiatedLogout { - pub(crate) end_session_endpoint: Uri, - pub(crate) id_token_hint: Box, - pub(crate) client_id: ClientId, - pub(crate) post_logout_redirect_uri: Option, - pub(crate) state: Option, -} - -impl OidcRpInitiatedLogout { - /// set uri that the user is redirected to after logout. - /// This uri must be in the allowed by issuer. - pub fn with_post_logout_redirect(mut self, uri: Uri) -> Self { - self.post_logout_redirect_uri = Some(uri); - self - } - /// set the state parameter that is appended as a query to the post logout redirect uri. - pub fn with_state(mut self, state: String) -> Self { - self.state = Some(state); - self - } - /// get the uri that the client needs to access for logout. This does **NOT** delete the - /// session in axum-oidc. You should use the [`ClearSessionFlag`] responder or include - /// [`OidcRpInitiatedLogout`] in the response extensions - pub fn uri(&self) -> Result { - let mut parts = self.end_session_endpoint.clone().into_parts(); - - let query = { - let mut query: Vec<(&str, Cow<'_, str>)> = Vec::with_capacity(4); - query.push(("id_token_hint", Cow::Borrowed(&self.id_token_hint))); - query.push(("client_id", Cow::Borrowed(&self.client_id))); - - if let Some(post_logout_redirect_uri) = &self.post_logout_redirect_uri { - query.push(( - "post_logout_redirect_uri", - Cow::Owned(post_logout_redirect_uri.to_string()), - )); - } - if let Some(state) = &self.state { - query.push(("state", Cow::Borrowed(state))); - } - - query - .into_iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) - .collect::>() - .join("&") - }; - - let path_and_query = match parts.path_and_query { - Some(path_and_query) => { - PathAndQuery::from_maybe_shared(format!("{}?{}", path_and_query.path(), query)) - } - None => PathAndQuery::from_maybe_shared(format!("?{}", query)), - }; - parts.path_and_query = Some(path_and_query?); - - Ok(Uri::from_parts(parts)?) - } -} - -impl FromRequestParts for OidcRpInitiatedLogout -where - S: Send + Sync, -{ - type Rejection = ExtractorError; - - async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - match parts - .extensions - .get::>() - .cloned() - .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()) - } -} - -impl IntoResponse for OidcRpInitiatedLogout { - /// redirect to the logout uri and signal the [`crate::middleware::OidcAuthMiddleware`] that - /// the session should be cleared - fn into_response(self) -> axum_core::response::Response { - if let Ok(uri) = self.uri() { - let mut response = Redirect::temporary(&uri.to_string()).into_response(); - response.extensions_mut().insert(ClearSessionFlag); - response - } else { - ExtractorError::FailedToCreateRpInitiatedLogoutUri.into_response() - } - } -} - -/// 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 + self.0.as_str() } } diff --git a/src/handler.rs b/src/handler.rs deleted file mode 100644 index 68f4793..0000000 --- a/src/handler.rs +++ /dev/null @@ -1,122 +0,0 @@ -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 bcf783f..8458314 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,40 +1,32 @@ -#![deny(unsafe_code)] -#![deny(clippy::unwrap_used)] -#![deny(warnings)] #![doc = include_str!("../README.md")] +use std::str::FromStr; + +use crate::error::Error; use http::Uri; use openidconnect::{ core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClientAuthMethod, - CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, - CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, - CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, - CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, + CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, + CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreRevocableToken, + CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, Audience, AuthenticationContextClass, ClientId, CsrfToken, EmptyExtraTokenFields, - EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, - RefreshToken, Scope, StandardErrorResponse, StandardTokenResponse, + reqwest::async_http_client, + ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, IssuerUrl, Nonce, + PkceCodeVerifier, RefreshToken, StandardErrorResponse, StandardTokenResponse, }; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; -pub mod builder; pub mod error; mod extractor; -mod handler; mod middleware; -pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}; -pub use handler::handle_oidc_redirect; +pub use extractor::{OidcAccessToken, OidcClaims}; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; -pub use openidconnect; const SESSION_KEY: &str = "axum-oidc"; -pub trait AdditionalClaims: - openidconnect::AdditionalClaims + Clone + Sync + Send + Serialize + DeserializeOwned -{ -} +pub trait AdditionalClaims: openidconnect::AdditionalClaims + Clone + Sync + Send {} type OidcTokenResponse = StandardTokenResponse< IdTokenFields< @@ -43,6 +35,7 @@ type OidcTokenResponse = StandardTokenResponse< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, + CoreJsonWebKeyType, >, CoreTokenType, >; @@ -52,49 +45,25 @@ pub type IdToken = openidconnect::IdToken< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, + CoreJsonWebKeyType, >; -type Client< - AC, - HasAuthUrl = EndpointSet, - HasDeviceAuthUrl = EndpointNotSet, - HasIntrospectionUrl = EndpointNotSet, - HasRevocationUrl = EndpointNotSet, - HasTokenUrl = EndpointMaybeSet, - HasUserInfoUrl = EndpointMaybeSet, -> = openidconnect::Client< +type Client = 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< - AdditionalProviderMetadata, - CoreAuthDisplay, - CoreClientAuthMethod, - CoreClaimName, - CoreClaimType, - CoreGrantType, - CoreJweContentEncryptionAlgorithm, - CoreJweKeyManagementAlgorithm, - CoreJsonWebKey, - CoreResponseMode, - CoreResponseType, - CoreSubjectIdentifierType, >; pub type BoxError = Box; @@ -102,13 +71,33 @@ pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { - scopes: Vec, - client_id: ClientId, + scopes: Vec, client: Client, - http_client: reqwest::Client, - end_session_endpoint: Option, - auth_context_class: Option, - untrusted_audiences: Vec, + application_base_url: Uri, +} + +impl OidcClient { + pub async fn discover_new( + application_base_url: Uri, + issuer: String, + client_id: String, + client_secret: Option, + scopes: Vec, + ) -> Result { + let provider_metadata = + CoreProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client) + .await?; + let client = Client::from_provider_metadata( + provider_metadata, + ClientId::new(client_id), + client_secret.map(ClientSecret::new), + ); + Ok(Self { + scopes, + client, + application_base_url, + }) + } } /// an empty struct to be used as the default type for the additional claims generic @@ -117,33 +106,35 @@ 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: String, +} + /// oidc session #[derive(Serialize, Deserialize, Debug)] -#[serde(bound = "AC: Serialize + DeserializeOwned")] -struct OidcSession { +struct OidcSession { nonce: Nonce, csrf_token: CsrfToken, pkce_verifier: PkceCodeVerifier, - authenticated: Option>, - refresh_token: Option, - redirect_url: Box, + id_token: Option, + access_token: Option, + refresh_token: Option, } -#[derive(Serialize, Deserialize, Debug)] -#[serde(bound = "AC: Serialize + DeserializeOwned")] -struct AuthenticatedSession { - id_token: IdToken, - access_token: AccessToken, +impl OidcSession { + pub(crate) fn id_token(&self) -> Option> { + self.id_token + .as_ref() + .map(|x| IdToken::::from_str(x).unwrap()) + } + pub(crate) fn refresh_token(&self) -> Option { + self.refresh_token + .as_ref() + .map(|x| RefreshToken::new(x.to_string())) + } } - -/// additional metadata that is discovered on client creation via the -/// `.well-knwon/openid-configuration` endpoint. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AdditionalProviderMetadata { - end_session_endpoint: Option, -} -impl openidconnect::AdditionalProviderMetadata for AdditionalProviderMetadata {} - -/// response extension flag to signal the [`OidcAuthLayer`] that the session should be cleared. -#[derive(Clone, Copy)] -pub struct ClearSessionFlag; diff --git a/src/middleware.rs b/src/middleware.rs index b4b68b5..4be9446 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -4,32 +4,32 @@ use std::{ }; use axum::{ - extract::OriginalUri, + extract::Query, response::{IntoResponse, Redirect}, }; -use axum_core::response::Response; +use axum_core::{extract::FromRequestParts, response::Response}; use futures_util::future::BoxFuture; -use http::{request::Parts, Request}; +use http::{uri::PathAndQuery, Request, Uri}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, - AccessToken, AccessTokenHash, CsrfToken, IdTokenClaims, IdTokenVerifier, Nonce, - NonceVerifier as _, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + core::{CoreAuthenticationFlow, CoreErrorResponseType}, + reqwest::async_http_client, + AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, RequestTokenError::ServerResponse, - Scope, TokenResponse, UserInfoClaims, + Scope, TokenResponse, }; use crate::{ - error::MiddlewareError, - extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}, - AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, - OidcSession, SESSION_KEY, + error::{Error, MiddlewareError}, + extractor::{OidcAccessToken, OidcClaims}, + AdditionalClaims, BoxError, OidcClient, OidcQuery, OidcSession, SESSION_KEY, }; -/// Layer for the [`OidcLoginMiddleware`]. +/// Layer for the [OidcLoginMiddleware]. #[derive(Clone, Default)] pub struct OidcLoginLayer where @@ -61,7 +61,7 @@ where } /// This middleware forces the user to be authenticated and redirects the user to the OpenID Connect -/// Issuer to authenticate. This Middleware needs to be loaded afer [`OidcAuthMiddleware`]. +/// Issuer to authenticate. This Middleware needs to be loaded afer [OidcAuthMiddleware]. #[derive(Clone)] pub struct OidcLoginMiddleware where @@ -105,68 +105,118 @@ where } else { // no valid id token or refresh token was found and the user has to login Box::pin(async move { - let (parts, _) = request.into_parts(); + let (mut parts, _) = request.into_parts(); - let oidcclient: OidcClient = parts + let mut 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 redirect_url = parts - .extensions - .get::() - .ok_or(MiddlewareError::OriginalUrlNotFound)?; + let handler_uri = + strip_oidc_from_path(oidcclient.application_base_url.clone(), &parts.uri)?; - let redirect_url = if let Some(query) = redirect_url.query() { - redirect_url.path().to_string() + "?" + query + 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) + .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)?; + + // Verify the access token hash to ensure that the access token hasn't been substituted for + // another user's. + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = AccessTokenHash::from_token( + token_response.access_token(), + &id_token.signing_alg()?, + )?; + if actual_access_token_hash != *expected_access_token_hash { + return Err(MiddlewareError::AccessTokenHashInvalid); + } + } + + login_session.id_token = Some(id_token.to_string()); + login_session.access_token = + Some(token_response.access_token().secret().to_string()); + login_session.refresh_token = token_response + .refresh_token() + .map(|x| x.secret().to_string()); + + session.insert(SESSION_KEY, login_session).await.unwrap(); + + Ok(Redirect::temporary(&handler_uri.to_string()).into_response()) } else { - redirect_url.path().to_string() - }; - // generate a login url and redirect the user to it + // 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())); + } - if let Some(acr) = oidcclient.auth_context_class { - auth = auth.add_auth_context_value(acr); - } + auth.set_pkce_challenge(pkce_challenge).url() + }; - auth.set_pkce_challenge(pkce_challenge).url() - }; + let oidc_session = OidcSession { + nonce, + csrf_token, + pkce_verifier, + id_token: None, + access_token: None, + refresh_token: None, + }; - let oidc_session = OidcSession:: { - nonce, - csrf_token, - pkce_verifier, - authenticated: None, - refresh_token: None, - redirect_url: redirect_url.into(), - }; + session.insert(SESSION_KEY, oidc_session).await.unwrap(); - session.insert(SESSION_KEY, oidc_session).await?; - - Ok(Redirect::to(auth_url.as_str()).into_response()) + Ok(Redirect::temporary(auth_url.as_str()).into_response()) + } }) } } } -/// Layer for the [`OidcAuthMiddleware`]. +/// Layer for the [OidcAuthMiddleware]. #[derive(Clone)] pub struct OidcAuthLayer where @@ -179,10 +229,24 @@ impl OidcAuthLayer { pub fn new(client: OidcClient) -> Self { Self { client } } -} -impl From> for OidcAuthLayer { - fn from(value: OidcClient) -> Self { - Self::new(value) + + 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?, + }) } } @@ -235,77 +299,109 @@ 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 oidcclient = self.client.clone(); - + let mut oidcclient = self.client.clone(); Box::pin(async move { let (mut parts, body) = request.into_parts(); let session = parts .extensions .get::() - .ok_or(MiddlewareError::SessionNotFound)? - .clone(); - let mut login_session: Option> = session + .ok_or(MiddlewareError::SessionNotFound)?; + let mut 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)?; + + 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() - .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, - ) + let id_token_claims = login_session.id_token::().and_then(|id_token| { + id_token + .claims(&oidcclient.client.id_token_verifier(), &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?; + match (id_token_claims, login_session.refresh_token()) { // stored id token is valid and can be used - insert_extensions( - &mut parts, - claims.clone(), - user_claims, - &oidcclient, - session, - ); - } else if let Some(refresh_token) = login_session.refresh_token.as_ref() { - // 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, - user_claims.clone(), - &oidcclient, - &authenticated_session, - ); - login_session.authenticated = Some(authenticated_session); + (Some(claims), _) => { + parts.extensions.insert(OidcClaims(claims)); + parts.extensions.insert(OidcAccessToken( + login_session.access_token.clone().unwrap_or_default(), + )); + } + // stored id token is invalid and can't be uses, but we have a refresh token + // and can use it and try to get another id token. + (_, Some(refresh_token)) => { + let mut refresh_request = + oidcclient.client.exchange_refresh_token(&refresh_token); - if let Some(refresh_token) = refresh_token { - login_session.refresh_token = Some(refresh_token); + for scope in oidcclient.scopes.iter() { + refresh_request = + refresh_request.add_scope(Scope::new(scope.to_string())); } - }; - // save refreshed session or delete it when the token couldn't be refreshed - let session = parts - .extensions - .get::() - .ok_or(MiddlewareError::SessionNotFound)?; + match refresh_request.request_async(async_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( + &oidcclient.client.id_token_verifier(), + &login_session.nonce, + )?; - session.insert(SESSION_KEY, login_session).await?; + // Verify the access token hash to ensure that the access token hasn't been substituted for + // another user's. + if let Some(expected_access_token_hash) = claims.access_token_hash() + { + let actual_access_token_hash = AccessTokenHash::from_token( + token_response.access_token(), + &id_token.signing_alg()?, + )?; + if actual_access_token_hash != *expected_access_token_hash { + return Err(MiddlewareError::AccessTokenHashInvalid); + } + } + + login_session.id_token = Some(id_token.to_string()); + login_session.access_token = + Some(token_response.access_token().secret().to_string()); + login_session.refresh_token = token_response + .refresh_token() + .map(|x| x.secret().to_string()); + + parts.extensions.insert(OidcClaims(claims.clone())); + parts.extensions.insert(OidcAccessToken( + login_session.access_token.clone().unwrap_or_default(), + )); + } + Err(ServerResponse(e)) + if *e.error() == CoreErrorResponseType::InvalidGrant => + { + // Refresh failed, refresh_token most likely expired or + // invalid, the session can be considered lost + login_session.refresh_token = None; + } + Err(err) => { + return Err(err.into()); + } + }; + + let session = parts + .extensions + .get::() + .ok_or(MiddlewareError::SessionNotFound)?; + + session.insert(SESSION_KEY, login_session).await.unwrap(); + } + (None, None) => {} } } @@ -317,148 +413,38 @@ where .await .map_err(|e| MiddlewareError::NextMiddleware(e.into()))? .into_response(); - - 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?; - } - Ok(response) }) } } -/// 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(), - )); - let rp_initiated_logout = client - .end_session_endpoint - .as_ref() - .map(|end_session_endpoint| OidcRpInitiatedLogout { - end_session_endpoint: end_session_endpoint.clone(), - id_token_hint: authenticated_session.id_token.to_string().into(), - client_id: client.client_id.clone(), - post_logout_redirect_uri: None, - state: None, - }); - parts.extensions.insert(rp_initiated_logout); -} - -/// 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 -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()?, - id_token.signing_key(&id_token_verifier)?, - )?; - if actual_access_token_hash == *expected_access_token_hash { - Ok(()) - } else { - Err(MiddlewareError::AccessTokenHashInvalid) - } - } else { - Ok(()) - } -} - -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, - nonce: &Nonce, -) -> Result< - Option<( - IdTokenClaims, - UserInfoClaims, - AuthenticatedSession, - Option, - )>, - MiddlewareError, -> { - 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(&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 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, - 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(), - ))) - } - Err(ServerResponse(e)) if *e.error() == CoreErrorResponseType::InvalidGrant => { - // Refresh failed, refresh_token most likely expired or - // invalid, the session can be considered lost - Ok(None) - } - Err(err) => Err(err.into()), - } +/// 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)?) }