diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b64f3dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: Cargo Build & Test + +on: + push: + pull_request: + schedule: + - cron: '0 0 1,7,14,21 * *' + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: axum-oidc + 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 + - run: cargo test --verbose --release + + test_basic_example: + name: axum-oidc - basic + 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 + working-directory: ./examples/basic diff --git a/.gitignore b/.gitignore index 96ef6c0..e08f5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/target +target Cargo.lock +.env diff --git a/Cargo.toml b/Cargo.toml index 7c16d26..e92d92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,26 +1,32 @@ [package] name = "axum-oidc" -description = "A OpenID Connect Client Libary for axum" -version = "0.1.0" +description = "A wrapper for the openidconnect crate for axum" +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.50" -axum-core = "0.3" -axum = { version = "0.6", default-features = false, features = [ "query" ] } -tower-service = "0.3.2" -tower-layer = "0.3.2" -tower-sessions = { version = "0.4", default-features = false, features = [ "axum-core" ] } -http = "0.2" -async-trait = "0.1" -openidconnect = "3.4" +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.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 1c11fcc..d7d6049 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 Conenct Issuer and provides Extractors. +It authenticates the user with the OpenID Connect Issuer and provides Extractors. # Usage The `OidcAuthLayer` must be loaded on any handler that might use the extractors. @@ -13,55 +13,17 @@ 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. -```rust -#[tokio::main] -async fn main() { +# Examples +Take a look at the `examples` folder for examples. - let session_store = MemoryStore::default(); - let session_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|_: BoxError| async { - StatusCode::BAD_REQUEST - })) - .layer(SessionManagerLayer::new(session_store).with_same_site(SameSite::Lax)); - - 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_static("https://example.com"), - "".to_string(), - "".to_string(), - "".to_owned(), - vec![], - ).await.unwrap(), - ); - - let app = Router::new() - .route("/", get(|| async { "Hello, authenticated World!" })) - .layer(oidc_login_service) - .layer(oidc_auth_service) - .layer(session_service); - - axum::Server::bind(&"[::]:8080".parse().unwrap()) - .serve(app.into_make_service()) - .await - .unwrap(); -} -``` - -# Example Projects -Here is a place for projects that are using this library. -- [zettoIT ARS - AudienceResponseSystem](https://git2.zettoit.eu/zettoit/ars) (by me) +# 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. @@ -69,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 new file mode 100644 index 0000000..833473d --- /dev/null +++ b/examples/basic/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition = "2024" +name = "basic" +version = "0.1.0" + +[dependencies] +axum = { version = "0.8", features = ["macros"] } +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" diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..30cdf2f --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..45d99c3 --- /dev/null +++ b/examples/basic/src/main.rs @@ -0,0 +1,111 @@ +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 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) + .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/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 c91ea66..071e911 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,10 +10,19 @@ 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, @@ -26,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), @@ -42,7 +62,7 @@ pub enum MiddlewareError { RequestToken( #[from] openidconnect::RequestTokenError< - openidconnect::reqwest::Error, + openidconnect::HttpClientError, StandardErrorResponse, >, ), @@ -58,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)] @@ -65,36 +127,58 @@ 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>), + 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 { fn into_response(self) -> axum_core::response::Response { - (StatusCode::UNAUTHORIZED, "unauthorized").into_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() + } + } } } 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 5e33b2a..dbb6482 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,16 +1,20 @@ -use crate::{error::ExtractorError, AdditionalClaims}; -use async_trait::async_trait; -use axum_core::extract::FromRequestParts; -use http::request::Parts; -use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; +use std::{borrow::Cow, convert::Infallible, 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}; /// 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)] +/// This Extractor will only return the Claims when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. +#[derive(Clone, Debug)] pub struct OidcClaims(pub IdTokenClaims); -#[async_trait] impl FromRequestParts for OidcClaims where S: Send + Sync, @@ -27,13 +31,41 @@ 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; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef> for OidcClaims +where + AC: AdditionalClaims, +{ + fn as_ref(&self) -> &IdTokenClaims { + &self.0 + } +} + /// 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, @@ -48,3 +80,187 @@ where .ok_or(ExtractorError::Unauthorized) } } + +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; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +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 + } +} 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 609060f..bcf783f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,31 +1,40 @@ +#![deny(unsafe_code)] +#![deny(clippy::unwrap_used)] +#![deny(warnings)] #![doc = include_str!("../README.md")] -use crate::error::Error; use http::Uri; use openidconnect::{ core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, - CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, - CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreRevocableToken, - CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenType, + CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClientAuthMethod, + CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, + CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, + CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, + CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - reqwest::async_http_client, - ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, IssuerUrl, Nonce, - PkceCodeVerifier, StandardErrorResponse, StandardTokenResponse, + AccessToken, Audience, AuthenticationContextClass, ClientId, CsrfToken, EmptyExtraTokenFields, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, + RefreshToken, Scope, StandardErrorResponse, StandardTokenResponse, }; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +pub mod builder; pub mod error; mod extractor; +mod handler; mod middleware; -mod util; -pub use extractor::{OidcAccessToken, OidcClaims}; +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"; -pub trait AdditionalClaims: openidconnect::AdditionalClaims + Clone + Sync + Send {} +pub trait AdditionalClaims: + openidconnect::AdditionalClaims + Clone + Sync + Send + Serialize + DeserializeOwned +{ +} type OidcTokenResponse = StandardTokenResponse< IdTokenFields< @@ -34,7 +43,6 @@ type OidcTokenResponse = StandardTokenResponse< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, >, CoreTokenType, >; @@ -44,25 +52,49 @@ 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< + AdditionalProviderMetadata, + CoreAuthDisplay, + CoreClientAuthMethod, + CoreClaimName, + CoreClaimType, + CoreGrantType, + CoreJweContentEncryptionAlgorithm, + CoreJweKeyManagementAlgorithm, + CoreJsonWebKey, + CoreResponseMode, + CoreResponseType, + CoreSubjectIdentifierType, >; pub type BoxError = Box; @@ -70,33 +102,13 @@ pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { - scopes: Vec, + scopes: Vec, + client_id: ClientId, client: Client, - 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(|x| ClientSecret::new(x)), - ); - Ok(Self { - scopes, - client, - application_base_url, - }) - } + http_client: reqwest::Client, + end_session_endpoint: Option, + auth_context_class: Option, + untrusted_audiences: Vec, } /// an empty struct to be used as the default type for the additional claims generic @@ -105,21 +117,33 @@ 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)] -struct OidcSession { +#[serde(bound = "AC: Serialize + DeserializeOwned")] +struct OidcSession { nonce: Nonce, csrf_token: CsrfToken, pkce_verifier: PkceCodeVerifier, - id_token: Option, - access_token: Option, + authenticated: Option>, + refresh_token: Option, + redirect_url: Box, } + +#[derive(Serialize, Deserialize, Debug)] +#[serde(bound = "AC: Serialize + DeserializeOwned")] +struct AuthenticatedSession { + id_token: IdToken, + access_token: AccessToken, +} + +/// 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 70c9054..b4b68b5 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,34 +1,35 @@ use std::{ marker::PhantomData, - str::FromStr, task::{Context, Poll}, }; use axum::{ - extract::Query, + extract::OriginalUri, response::{IntoResponse, Redirect}, }; -use axum_core::{extract::FromRequestParts, response::Response}; +use axum_core::response::Response; use futures_util::future::BoxFuture; -use http::{Request, Uri}; +use http::{request::Parts, Request}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ - core::CoreAuthenticationFlow, reqwest::async_http_client, AccessTokenHash, AuthorizationCode, - CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, - TokenResponse, + core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, + AccessToken, AccessTokenHash, CsrfToken, IdTokenClaims, IdTokenVerifier, Nonce, + NonceVerifier as _, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + RequestTokenError::ServerResponse, + Scope, TokenResponse, UserInfoClaims, }; use crate::{ - error::{Error, MiddlewareError}, - extractor::{OidcAccessToken, OidcClaims}, - util::strip_oidc_from_path, - AdditionalClaims, BoxError, IdToken, OidcClient, OidcQuery, OidcSession, SESSION_KEY, + error::MiddlewareError, + extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}, + AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, + OidcSession, SESSION_KEY, }; -/// Layer for the [OidcLoginMiddleware]. +/// Layer for the [`OidcLoginMiddleware`]. #[derive(Clone, Default)] pub struct OidcLoginLayer where @@ -60,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 @@ -93,116 +94,79 @@ where let mut inner = std::mem::replace(&mut self.inner, inner); if request.extensions().get::().is_some() { + // the OidcAuthMiddleware had a valid id token Box::pin(async move { let response: Response = inner .call(request) .await .map_err(|e| MiddlewareError::NextMiddleware(e.into()))?; - return Ok(response); + Ok(response) }) } 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).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) { - 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()); - - session.insert(SESSION_KEY, login_session).unwrap(); - - 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 { - 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, - ); + redirect_url.path().to_string() + }; + // generate a login url and redirect the user to it - for scope in oidcclient.scopes.iter() { - auth = auth.add_scope(Scope::new(scope.to_string())); - } + 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, + ); - auth.set_pkce_challenge(pkce_challenge).url() - }; + for scope in oidcclient.scopes.iter() { + auth = auth.add_scope(Scope::new(scope.to_string())); + } - let oidc_session = OidcSession { - nonce, - csrf_token, - pkce_verifier, - id_token: None, - access_token: None, - }; + if let Some(acr) = oidcclient.auth_context_class { + auth = auth.add_auth_context_value(acr); + } - session.insert(SESSION_KEY, oidc_session).unwrap(); + auth.set_pkce_challenge(pkce_challenge).url() + }; - Ok(Redirect::temporary(auth_url.as_str()).into_response()) - } + 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?; + + Ok(Redirect::to(auth_url.as_str()).into_response()) }) } } } -/// Layer for the [OidcAuthMiddleware]. +/// Layer for the [`OidcAuthMiddleware`]. #[derive(Clone)] pub struct OidcAuthLayer where @@ -215,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) } } @@ -285,38 +235,77 @@ 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(); let session = parts .extensions .get::() - .ok_or(MiddlewareError::SessionNotFound)?; - let login_session: Option = - session.get(SESSION_KEY).map_err(MiddlewareError::from)?; + .ok_or(MiddlewareError::SessionNotFound)? + .clone(); + 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)?; + 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, + ) + .ok() + .cloned() + .map(|claims| (session, claims)) + }); - oidcclient.client = oidcclient - .client - .set_redirect_uri(RedirectUrl::new(handler_uri.to_string())?); + 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(), + 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); - if let Some(OidcSession { - nonce, - csrf_token: _, - pkce_verifier: _, - id_token: Some(id_token), - access_token, - }) = &login_session - { - let id_token = IdToken::::from_str(&id_token).unwrap(); - if let Ok(claims) = id_token.claims(&oidcclient.client.id_token_verifier(), nonce) { - parts.extensions.insert(OidcClaims(claims.clone())); - parts + if let Some(refresh_token) = refresh_token { + login_session.refresh_token = Some(refresh_token); + } + }; + + // save refreshed session or delete it when the token couldn't be refreshed + let session = parts .extensions - .insert(OidcAccessToken(access_token.clone().unwrap_or_default())); + .get::() + .ok_or(MiddlewareError::SessionNotFound)?; + + session.insert(SESSION_KEY, login_session).await?; } } @@ -328,7 +317,148 @@ where .await .map_err(|e| MiddlewareError::NextMiddleware(e.into()))? .into_response(); - return Ok(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()), + } +} diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index e601438..0000000 --- a/src/util.rs +++ /dev/null @@ -1,32 +0,0 @@ -use http::{uri::PathAndQuery, Uri}; - -use crate::error::MiddlewareError; - -/// 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") - }) - .map(|x| x.to_string()) - .reduce(|acc, x| acc + "&" + &x) - }) - .unwrap_or_default(); - - PathAndQuery::from_maybe_shared(format!("{}?{}", path_and_query.path(), query)) - }) - .transpose()?; - - Ok(Uri::from_parts(base_url)?) -}