From 84bd595ea1f7bad3cbd8b57ae03c3544c7ea2ed0 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 10 Mar 2024 20:22:56 +0100 Subject: [PATCH 01/37] repair example/basic tokio features --- examples/basic/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index d57537a..bb54e8d 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1.36.0", features = ["net", "macros"] } +tokio = { version = "1.36.0", features = ["net", "macros", "rt-multi-thread"] } axum = "0.7.4" axum-oidc = { path = "./../.." } tower = "0.4.13" From a522b7936d2e11337fed3113ee35df88bd2b3524 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Tue, 12 Mar 2024 16:18:18 +0100 Subject: [PATCH 02/37] replace outdated test credentials with placeholder --- examples/basic/src/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index e8cd78a..4da1acf 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -31,10 +31,10 @@ async fn main() { })) .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()), + Uri::from_static("https://app.example.com"), + "https://auth.example.com/auth/realms/example".to_string(), + "my-client".to_string(), + Some("123456".to_owned()), vec![], ) .await From 1844b880c165b474f5f33d5b385fc4bf2f8db688 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Mon, 25 Mar 2024 17:20:44 +0100 Subject: [PATCH 03/37] Added first implementation of RP Initiated Logout Created a new extractor for RP-Initiated-Logout and modified example to use it. --- .gitignore | 1 + Cargo.toml | 1 + examples/basic/Cargo.toml | 2 + examples/basic/src/main.rs | 20 +++++++-- src/error.rs | 13 +++++- src/extractor.rs | 87 ++++++++++++++++++++++++++++++++++++-- src/lib.rs | 52 +++++++++++++++++++---- src/middleware.rs | 12 +++++- 8 files changed, 171 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index a9d37c5..e08f5fc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target Cargo.lock +.env diff --git a/Cargo.toml b/Cargo.toml index 0e19f01..fbcbb81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,4 @@ openidconnect = "3.5" serde = "1.0" futures-util = "0.3" reqwest = { version = "0.11", default-features = false } +urlencoding = "2.1.3" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index bb54e8d..fcd75e1 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -11,3 +11,5 @@ axum = "0.7.4" axum-oidc = { path = "./../.." } tower = "0.4.13" tower-sessions = "0.11.0" + +dotenvy = "0.15.7" diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 4da1acf..1ece2be 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -3,6 +3,7 @@ use axum::{ }; use axum_oidc::{ error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, + OidcRpInitiatedLogout, }; use tokio::net::TcpListener; use tower::ServiceBuilder; @@ -13,6 +14,12 @@ use tower_sessions::{ #[tokio::main] async fn main() { + dotenvy::dotenv().ok(); + let app_url = std::env::var("APP_URL").expect("APP_URL env variable"); + let issuer = std::env::var("ISSUER").expect("ISSUER env variable"); + let client_id = std::env::var("CLIENT_ID").expect("CLIENT_ID env variable"); + let client_secret = std::env::var("CLIENT_SECRET").ok(); + let session_store = MemoryStore::default(); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) @@ -31,10 +38,10 @@ async fn main() { })) .layer( OidcAuthLayer::::discover_client( - Uri::from_static("https://app.example.com"), - "https://auth.example.com/auth/realms/example".to_string(), - "my-client".to_string(), - Some("123456".to_owned()), + Uri::from_maybe_shared(app_url).expect("valid APP_URL"), + issuer, + client_id, + client_secret, vec![], ) .await @@ -43,6 +50,7 @@ async fn main() { let app = Router::new() .route("/foo", get(authenticated)) + .route("/logout", get(logout)) .layer(oidc_login_service) .route("/bar", get(maybe_authenticated)) .layer(oidc_auth_service) @@ -70,3 +78,7 @@ async fn maybe_authenticated( "Hello anon!".to_string() } } + +async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { + logout.with_post_logout_redirect(Uri::from_static("https://google.de")) +} diff --git a/src/error.rs b/src/error.rs index c91ea66..6d8997e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,9 @@ use thiserror::Error; pub enum ExtractorError { #[error("unauthorized")] Unauthorized, + + #[error("rp initiated logout information not found")] + RpInitiatedLogoutInformationNotFound, } #[derive(Debug, Error)] @@ -65,6 +68,9 @@ 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>), @@ -77,7 +83,12 @@ pub enum Error { 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::RpInitiatedLogoutInformationNotFound => { + (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() + } + } } } diff --git a/src/extractor.rs b/src/extractor.rs index 5c18d78..2161ee3 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,9 +1,13 @@ -use std::ops::Deref; +use std::{borrow::Cow, ops::Deref}; use crate::{error::ExtractorError, AdditionalClaims}; use async_trait::async_trait; -use axum_core::extract::FromRequestParts; -use http::request::Parts; +use axum::response::Redirect; +use axum_core::{ + extract::FromRequestParts, + response::{IntoResponse, Response}, +}; +use http::{request::Parts, uri::PathAndQuery, Uri}; use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; /// Extractor for the OpenID Connect Claims. @@ -81,3 +85,80 @@ impl AsRef for OidcAccessToken { self.0.as_str() } } + +#[derive(Clone)] +pub struct OidcRpInitiatedLogout { + pub(crate) end_session_endpoint: Uri, + pub(crate) id_token_hint: String, + pub(crate) client_id: String, + pub(crate) post_logout_redirect_uri: Option, + pub(crate) state: Option, +} + +impl OidcRpInitiatedLogout { + pub fn with_post_logout_redirect(mut self, uri: Uri) -> Self { + self.post_logout_redirect_uri = Some(uri); + self + } + pub fn with_state(mut self, state: String) -> Self { + self.state = Some(state); + self + } + pub fn uri(self) -> Uri { + let mut parts = self.end_session_endpoint.into_parts(); + + let query = { + let mut query = 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.as_str()))) + .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.unwrap()); + + Uri::from_parts(parts).unwrap() + } +} +#[async_trait] +impl FromRequestParts for OidcRpInitiatedLogout +where + S: Send + Sync, +{ + type Rejection = ExtractorError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(ExtractorError::Unauthorized) + } +} + +#[async_trait] +impl IntoResponse for OidcRpInitiatedLogout { + fn into_response(self) -> Response { + Redirect::temporary(&self.uri().to_string()).into_response() + } +} diff --git a/src/lib.rs b/src/lib.rs index 8458314..29fa9a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,10 +6,12 @@ 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, CoreJsonWebKeyType, + CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, + CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreResponseMode, CoreResponseType, + CoreRevocableToken, CoreRevocationErrorResponse, CoreSubjectIdentifierType, + CoreTokenIntrospectionResponse, CoreTokenType, }, reqwest::async_http_client, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, IssuerUrl, Nonce, @@ -21,7 +23,7 @@ pub mod error; mod extractor; mod middleware; -pub use extractor::{OidcAccessToken, OidcClaims}; +pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; const SESSION_KEY: &str = "axum-oidc"; @@ -66,14 +68,34 @@ type Client = openidconnect::Client< CoreRevocationErrorResponse, >; +type ProviderMetadata = openidconnect::ProviderMetadata< + AdditionalProviderMetadata, + CoreAuthDisplay, + CoreClientAuthMethod, + CoreClaimName, + CoreClaimType, + CoreGrantType, + CoreJweContentEncryptionAlgorithm, + CoreJweKeyManagementAlgorithm, + CoreJwsSigningAlgorithm, + CoreJsonWebKeyType, + CoreJsonWebKeyUse, + CoreJsonWebKey, + CoreResponseMode, + CoreResponseType, + CoreSubjectIdentifierType, +>; + pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { scopes: Vec, + client_id: String, client: Client, application_base_url: Uri, + end_session_endpoint: Option, } impl OidcClient { @@ -85,17 +107,25 @@ impl OidcClient { scopes: Vec, ) -> Result { let provider_metadata = - CoreProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client) - .await?; + ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; + let end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone() + .map(Uri::from_maybe_shared) + .transpose() + .map_err(Error::InvalidEndSessionEndpoint)?; let client = Client::from_provider_metadata( provider_metadata, - ClientId::new(client_id), + ClientId::new(client_id.clone()), client_secret.map(ClientSecret::new), ); Ok(Self { scopes, client, + client_id, application_base_url, + end_session_endpoint, }) } } @@ -138,3 +168,9 @@ impl OidcSession { .map(|x| RefreshToken::new(x.to_string())) } } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct AdditionalProviderMetadata { + end_session_endpoint: Option, +} +impl openidconnect::AdditionalProviderMetadata for AdditionalProviderMetadata {} diff --git a/src/middleware.rs b/src/middleware.rs index 4be9446..280aae0 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -25,7 +25,7 @@ use openidconnect::{ use crate::{ error::{Error, MiddlewareError}, - extractor::{OidcAccessToken, OidcClaims}, + extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, AdditionalClaims, BoxError, OidcClient, OidcQuery, OidcSession, SESSION_KEY, }; @@ -334,6 +334,16 @@ where parts.extensions.insert(OidcAccessToken( login_session.access_token.clone().unwrap_or_default(), )); + if let Some(end_session_endpoint) = oidcclient.end_session_endpoint.clone() + { + parts.extensions.insert(OidcRpInitiatedLogout { + end_session_endpoint, + id_token_hint: login_session.id_token.clone().unwrap(), + client_id: oidcclient.client_id.clone(), + post_logout_redirect_uri: None, + state: None, + }); + } } // 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. From 6528a6f247658b4b76d2046893bc50b11055c083 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Tue, 26 Mar 2024 21:06:50 +0100 Subject: [PATCH 04/37] Cleanup of RP-Initiated Logout Added comments Removed unwraps Reworked Session container and middlewares --- README.md | 2 + examples/basic/src/main.rs | 12 +- src/extractor.rs | 35 +++--- src/lib.rs | 48 ++++---- src/middleware.rs | 237 +++++++++++++++++++------------------ 5 files changed, 175 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 11a3fe7..9dd061a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ 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 diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 1ece2be..da5165f 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,5 +1,9 @@ use axum::{ - error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, + error_handling::HandleErrorLayer, + http::Uri, + response::{IntoResponse, Redirect}, + routing::get, + Router, }; use axum_oidc::{ error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, @@ -80,5 +84,9 @@ async fn maybe_authenticated( } async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://google.de")) + let logout_uri = logout + .with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) + .uri() + .unwrap(); + Redirect::temporary(&logout_uri.to_string()) } diff --git a/src/extractor.rs b/src/extractor.rs index 2161ee3..bf477c8 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -2,11 +2,7 @@ use std::{borrow::Cow, ops::Deref}; use crate::{error::ExtractorError, AdditionalClaims}; use async_trait::async_trait; -use axum::response::Redirect; -use axum_core::{ - extract::FromRequestParts, - response::{IntoResponse, Response}, -}; +use axum_core::extract::FromRequestParts; use http::{request::Parts, uri::PathAndQuery, Uri}; use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; @@ -82,10 +78,13 @@ impl Deref for OidcAccessToken { impl AsRef for OidcAccessToken { fn as_ref(&self) -> &str { - self.0.as_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, @@ -96,19 +95,23 @@ pub struct OidcRpInitiatedLogout { } 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 } - pub fn uri(self) -> Uri { - let mut parts = self.end_session_endpoint.into_parts(); + /// get the uri that the client needs to access for logout + pub fn uri(&self) -> Result { + let mut parts = self.end_session_endpoint.clone().into_parts(); let query = { - let mut query = Vec::with_capacity(4); + 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))); @@ -124,7 +127,7 @@ impl OidcRpInitiatedLogout { query .into_iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v.as_str()))) + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v))) .collect::>() .join("&") }; @@ -135,11 +138,12 @@ impl OidcRpInitiatedLogout { } None => PathAndQuery::from_maybe_shared(format!("?{}", query)), }; - parts.path_and_query = Some(path_and_query.unwrap()); + parts.path_and_query = Some(path_and_query?); - Uri::from_parts(parts).unwrap() + Ok(Uri::from_parts(parts)?) } } + #[async_trait] impl FromRequestParts for OidcRpInitiatedLogout where @@ -155,10 +159,3 @@ where .ok_or(ExtractorError::Unauthorized) } } - -#[async_trait] -impl IntoResponse for OidcRpInitiatedLogout { - fn into_response(self) -> Response { - Redirect::temporary(&self.uri().to_string()).into_response() - } -} diff --git a/src/lib.rs b/src/lib.rs index 29fa9a4..9eb2551 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,8 @@ +#![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::{ @@ -9,15 +10,15 @@ use openidconnect::{ CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, - CoreJwsSigningAlgorithm, CoreProviderMetadata, CoreResponseMode, CoreResponseType, - CoreRevocableToken, CoreRevocationErrorResponse, CoreSubjectIdentifierType, - CoreTokenIntrospectionResponse, CoreTokenType, + CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken, + CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, + CoreTokenType, }, reqwest::async_http_client, - ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, IssuerUrl, Nonce, - PkceCodeVerifier, RefreshToken, StandardErrorResponse, StandardTokenResponse, + AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, + IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, StandardErrorResponse, StandardTokenResponse, }; -use serde::{Deserialize, Serialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; pub mod error; mod extractor; @@ -28,7 +29,10 @@ pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLogi 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< @@ -147,28 +151,24 @@ struct OidcQuery { /// 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, - refresh_token: Option, + authenticated: Option>, } -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())) - } +#[derive(Serialize, Deserialize, Debug)] +#[serde(bound = "AC: Serialize + DeserializeOwned")] +struct AuthenticatedSession { + id_token: IdToken, + access_token: AccessToken, + refresh_token: Option, } +/// additional metadata that is discovered on client creation via the +/// `.well-knwon/openid-configuration` endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] struct AdditionalProviderMetadata { end_session_endpoint: Option, diff --git a/src/middleware.rs b/src/middleware.rs index 280aae0..b823e66 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -9,16 +9,16 @@ use axum::{ }; use axum_core::{extract::FromRequestParts, response::Response}; use futures_util::future::BoxFuture; -use http::{uri::PathAndQuery, Request, Uri}; +use http::{request::Parts, uri::PathAndQuery, Request, Uri}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreErrorResponseType}, + core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim}, reqwest::async_http_client, - AccessTokenHash, AuthorizationCode, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, - PkceCodeVerifier, RedirectUrl, + AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, IdTokenClaims, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; @@ -26,7 +26,8 @@ use openidconnect::{ use crate::{ error::{Error, MiddlewareError}, extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, - AdditionalClaims, BoxError, OidcClient, OidcQuery, OidcSession, SESSION_KEY, + AdditionalClaims, AuthenticatedSession, BoxError, IdToken, OidcClient, OidcQuery, OidcSession, + SESSION_KEY, }; /// Layer for the [OidcLoginMiddleware]. @@ -121,7 +122,7 @@ where .extensions .get::() .ok_or(MiddlewareError::SessionNotFound)?; - let login_session: Option = session + let login_session: Option> = session .get(SESSION_KEY) .await .map_err(MiddlewareError::from)?; @@ -158,26 +159,15 @@ where 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); - } - } + validate_access_token_hash(id_token, token_response.access_token(), claims)?; - 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()); + login_session.authenticated = Some(AuthenticatedSession { + id_token: id_token.clone(), + access_token: token_response.access_token().clone(), + refresh_token: token_response.refresh_token().cloned(), + }); - session.insert(SESSION_KEY, login_session).await.unwrap(); + session.insert(SESSION_KEY, login_session).await?; Ok(Redirect::temporary(&handler_uri.to_string()).into_response()) } else { @@ -198,16 +188,14 @@ where auth.set_pkce_challenge(pkce_challenge).url() }; - let oidc_session = OidcSession { + let oidc_session = OidcSession:: { nonce, csrf_token, pkce_verifier, - id_token: None, - access_token: None, - refresh_token: None, + authenticated: None, }; - session.insert(SESSION_KEY, oidc_session).await.unwrap(); + session.insert(SESSION_KEY, oidc_session).await?; Ok(Redirect::temporary(auth_url.as_str()).into_response()) } @@ -307,7 +295,7 @@ where .extensions .get::() .ok_or(MiddlewareError::SessionNotFound)?; - let mut login_session: Option = session + let mut login_session: Option> = session .get(SESSION_KEY) .await .map_err(MiddlewareError::from)?; @@ -320,98 +308,37 @@ where .set_redirect_uri(RedirectUrl::new(handler_uri.to_string())?); if let Some(login_session) = &mut login_session { - let id_token_claims = login_session.id_token::().and_then(|id_token| { - id_token + let id_token_claims = login_session.authenticated.as_ref().and_then(|session| { + session + .id_token .claims(&oidcclient.client.id_token_verifier(), &login_session.nonce) .ok() .cloned() + .map(|claims| (session, claims)) }); - match (id_token_claims, login_session.refresh_token()) { + if let Some((session, claims)) = id_token_claims { // stored id token is valid and can be used - (Some(claims), _) => { - parts.extensions.insert(OidcClaims(claims)); - parts.extensions.insert(OidcAccessToken( - login_session.access_token.clone().unwrap_or_default(), - )); - if let Some(end_session_endpoint) = oidcclient.end_session_endpoint.clone() - { - parts.extensions.insert(OidcRpInitiatedLogout { - end_session_endpoint, - id_token_hint: login_session.id_token.clone().unwrap(), - client_id: oidcclient.client_id.clone(), - post_logout_redirect_uri: None, - state: None, - }); - } - } - // 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); + insert_extensions(&mut parts, claims.clone(), &oidcclient, session); + } else if let Some(refresh_token) = login_session + .authenticated + .as_ref() + .and_then(|x| x.refresh_token.as_ref()) + { + if let Some((claims, authenticated_session)) = + try_refresh_token(&oidcclient, refresh_token, &login_session.nonce).await? + { + insert_extensions(&mut parts, claims, &oidcclient, &authenticated_session); + login_session.authenticated = Some(authenticated_session); + }; - 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, - )?; - - // 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) => {} + session.insert(SESSION_KEY, login_session).await?; } } @@ -458,3 +385,85 @@ pub fn strip_oidc_from_path(base_url: Uri, uri: &Uri) -> Result( + parts: &mut Parts, + claims: IdTokenClaims, + client: &OidcClient, + authenticated_session: &AuthenticatedSession, +) { + parts.extensions.insert(OidcClaims(claims)); + parts.extensions.insert(OidcAccessToken( + authenticated_session.access_token.secret().to_string(), + )); + if let Some(end_session_endpoint) = &client.end_session_endpoint { + parts.extensions.insert(OidcRpInitiatedLogout { + end_session_endpoint: end_session_endpoint.clone(), + id_token_hint: authenticated_session.id_token.to_string(), + client_id: client.client_id.clone(), + post_logout_redirect_uri: None, + state: None, + }); + } +} + +/// 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, + 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()?)?; + if actual_access_token_hash == *expected_access_token_hash { + Ok(()) + } else { + Err(MiddlewareError::AccessTokenHashInvalid) + } + } else { + Ok(()) + } +} + +async fn try_refresh_token( + client: &OidcClient, + refresh_token: &RefreshToken, + nonce: &Nonce, +) -> Result, AuthenticatedSession)>, 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(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(&client.client.id_token_verifier(), nonce)?; + + validate_access_token_hash(id_token, token_response.access_token(), claims)?; + + let authenticated_session = AuthenticatedSession { + id_token: id_token.clone(), + access_token: token_response.access_token().clone(), + refresh_token: token_response.refresh_token().cloned(), + }; + + Ok(Some((claims.clone(), authenticated_session))) + } + 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()), + } +} From a7b76ace76d13e52d39c190dcedf18e1f586d4d7 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Thu, 18 Apr 2024 16:13:57 +0200 Subject: [PATCH 05/37] Add version structure to README.md Added the new branch and tag strategy in the README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9dd061a..9df8a65 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,11 @@ Your OIDC-Client must be allowed to redirect to **every** subpath of your applic # 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. +Additonal 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. From ac3e0caa0b72b5a424abbc985e0634553cc0df47 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sat, 20 Apr 2024 20:35:04 +0200 Subject: [PATCH 06/37] implement fix for #10 fixed #10 by implementing a flag in the response extensions that instructs the middleware to clear the session. The flag is automatically set when using the `OidcRpInitiatedLogout` as a responder. improved documentation modified example to reflect api changes --- examples/basic/src/main.rs | 12 ++------- src/error.rs | 6 +++++ src/extractor.rs | 29 +++++++++++++++----- src/lib.rs | 8 +++++- src/middleware.rs | 55 ++++++++++++++++++++++++++------------ 5 files changed, 76 insertions(+), 34 deletions(-) diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index da5165f..6659890 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,9 +1,5 @@ use axum::{ - error_handling::HandleErrorLayer, - http::Uri, - response::{IntoResponse, Redirect}, - routing::get, - Router, + error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, }; use axum_oidc::{ error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, @@ -84,9 +80,5 @@ async fn maybe_authenticated( } async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - let logout_uri = logout - .with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) - .uri() - .unwrap(); - Redirect::temporary(&logout_uri.to_string()) + logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) } diff --git a/src/error.rs b/src/error.rs index 6d8997e..c580d16 100644 --- a/src/error.rs +++ b/src/error.rs @@ -13,6 +13,9 @@ pub enum ExtractorError { #[error("rp initiated logout information not found")] RpInitiatedLogoutInformationNotFound, + + #[error("could not build rp initiated logout uri")] + FailedToCreateRpInitiatedLogoutUri, } #[derive(Debug, Error)] @@ -88,6 +91,9 @@ impl IntoResponse for ExtractorError { Self::RpInitiatedLogoutInformationNotFound => { (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() } + Self::FailedToCreateRpInitiatedLogoutUri => { + (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() + } } } } diff --git a/src/extractor.rs b/src/extractor.rs index bf477c8..233f20d 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,14 +1,15 @@ use std::{borrow::Cow, ops::Deref}; -use crate::{error::ExtractorError, AdditionalClaims}; +use crate::{error::ExtractorError, AdditionalClaims, ClearSessionFlag}; use async_trait::async_trait; -use axum_core::extract::FromRequestParts; +use axum::response::Redirect; +use axum_core::{extract::FromRequestParts, response::IntoResponse}; use http::{request::Parts, uri::PathAndQuery, Uri}; 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. +/// 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); @@ -48,7 +49,7 @@ 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); @@ -84,7 +85,7 @@ impl AsRef for OidcAccessToken { /// 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. +/// 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, @@ -106,7 +107,9 @@ impl OidcRpInitiatedLogout { self.state = Some(state); self } - /// get the uri that the client needs to access for logout + /// 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(); @@ -159,3 +162,17 @@ where .ok_or(ExtractorError::Unauthorized) } } + +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() + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 9eb2551..5b861a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -103,6 +103,8 @@ pub struct OidcClient { } impl OidcClient { + /// create a new [`OidcClient`] by fetching the required information from the + /// `/.well-known/openid-configuration` endpoint of the issuer. pub async fn discover_new( application_base_url: Uri, issuer: String, @@ -157,6 +159,7 @@ struct OidcSession { csrf_token: CsrfToken, pkce_verifier: PkceCodeVerifier, authenticated: Option>, + refresh_token: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -164,7 +167,6 @@ struct OidcSession { struct AuthenticatedSession { id_token: IdToken, access_token: AccessToken, - refresh_token: Option, } /// additional metadata that is discovered on client creation via the @@ -174,3 +176,7 @@ 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 b823e66..97bbc09 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -26,11 +26,11 @@ use openidconnect::{ use crate::{ error::{Error, MiddlewareError}, extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, - AdditionalClaims, AuthenticatedSession, BoxError, IdToken, OidcClient, OidcQuery, OidcSession, - SESSION_KEY, + AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, + OidcQuery, OidcSession, SESSION_KEY, }; -/// Layer for the [OidcLoginMiddleware]. +/// Layer for the [`OidcLoginMiddleware`]. #[derive(Clone, Default)] pub struct OidcLoginLayer where @@ -62,7 +62,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 @@ -164,8 +164,11 @@ where login_session.authenticated = Some(AuthenticatedSession { id_token: id_token.clone(), access_token: token_response.access_token().clone(), - refresh_token: token_response.refresh_token().cloned(), }); + let refresh_token = token_response.refresh_token().cloned(); + if let Some(refresh_token) = refresh_token { + login_session.refresh_token = Some(refresh_token); + } session.insert(SESSION_KEY, login_session).await?; @@ -193,6 +196,7 @@ where csrf_token, pkce_verifier, authenticated: None, + refresh_token: None, }; session.insert(SESSION_KEY, oidc_session).await?; @@ -204,7 +208,7 @@ where } } -/// Layer for the [OidcAuthMiddleware]. +/// Layer for the [`OidcAuthMiddleware`]. #[derive(Clone)] pub struct OidcAuthLayer where @@ -294,7 +298,8 @@ where let session = parts .extensions .get::() - .ok_or(MiddlewareError::SessionNotFound)?; + .ok_or(MiddlewareError::SessionNotFound)? + .clone(); let mut login_session: Option> = session .get(SESSION_KEY) .await @@ -320,16 +325,16 @@ where if let Some((session, claims)) = id_token_claims { // stored id token is valid and can be used insert_extensions(&mut parts, claims.clone(), &oidcclient, session); - } else if let Some(refresh_token) = login_session - .authenticated - .as_ref() - .and_then(|x| x.refresh_token.as_ref()) - { - if let Some((claims, authenticated_session)) = + } else if let Some(refresh_token) = login_session.refresh_token.as_ref() { + if let Some((claims, authenticated_session, refresh_token)) = try_refresh_token(&oidcclient, refresh_token, &login_session.nonce).await? { insert_extensions(&mut parts, claims, &oidcclient, &authenticated_session); login_session.authenticated = Some(authenticated_session); + + 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 @@ -350,6 +355,13 @@ 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; + session.insert(SESSION_KEY, login_session).await?; + } + Ok(response) }) } @@ -433,8 +445,14 @@ async fn try_refresh_token( client: &OidcClient, refresh_token: &RefreshToken, nonce: &Nonce, -) -> Result, AuthenticatedSession)>, MiddlewareError> -{ +) -> Result< + Option<( + IdTokenClaims, + AuthenticatedSession, + Option, + )>, + MiddlewareError, +> { let mut refresh_request = client.client.exchange_refresh_token(refresh_token); for scope in client.scopes.iter() { @@ -454,10 +472,13 @@ async fn try_refresh_token( let authenticated_session = AuthenticatedSession { id_token: id_token.clone(), access_token: token_response.access_token().clone(), - refresh_token: token_response.refresh_token().cloned(), }; - Ok(Some((claims.clone(), authenticated_session))) + Ok(Some(( + claims.clone(), + authenticated_session, + token_response.refresh_token().cloned(), + ))) } Err(ServerResponse(e)) if *e.error() == CoreErrorResponseType::InvalidGrant => { // Refresh failed, refresh_token most likely expired or From 443389f3c41badb019086681ea7d415db595aa36 Mon Sep 17 00:00:00 2001 From: Johann Vieselthaler Date: Sun, 28 Apr 2024 09:55:59 +0200 Subject: [PATCH 07/37] feat: compatibility to kanidm kanidm does'nt send session_state --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 5b861a5..0cdd757 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,7 +148,7 @@ struct OidcQuery { code: String, state: String, #[allow(dead_code)] - session_state: String, + session_state: Option, } /// oidc session From 43406661f623d3361005ceb274a6703befbadc68 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 28 Apr 2024 10:52:41 +0200 Subject: [PATCH 08/37] v0.4.0 & dependency update --- Cargo.toml | 6 +++--- examples/basic/Cargo.toml | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fbcbb81..75c352d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "0.3.0" +version = "0.4.0" edition = "2021" authors = [ "Paul Z " ] readme = "README.md" @@ -17,11 +17,11 @@ 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.11", default-features = false, features = [ "axum-core" ] } +tower-sessions = { version = "0.12", 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.11", default-features = false } -urlencoding = "2.1.3" +urlencoding = "2.1" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index fcd75e1..b943d97 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -6,10 +6,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1.36.0", features = ["net", "macros", "rt-multi-thread"] } -axum = "0.7.4" +tokio = { version = "1.37", features = ["net", "macros", "rt-multi-thread"] } +axum = "0.7" axum-oidc = { path = "./../.." } -tower = "0.4.13" -tower-sessions = "0.11.0" +tower = "0.4" +tower-sessions = "0.12" -dotenvy = "0.15.7" +dotenvy = "0.15" From c9f63180b3aa49a0be336c012bfa14477f4fb6aa Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 17 May 2024 16:09:25 +0200 Subject: [PATCH 09/37] feat: add additional constructors for `OidcClient` Adds `discover_new_with_client` and `from_provider_metadata` functions to `OidcClient` to allow for custom client configurations as requested in #12. --- src/lib.rs | 92 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0cdd757..ae32f57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,9 +14,9 @@ use openidconnect::{ CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - reqwest::async_http_client, - AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, IdTokenFields, - IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, StandardErrorResponse, StandardTokenResponse, + AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, HttpRequest, + HttpResponse, IdTokenFields, IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, + StandardErrorResponse, StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -72,7 +72,7 @@ type Client = openidconnect::Client< CoreRevocationErrorResponse, >; -type ProviderMetadata = openidconnect::ProviderMetadata< +pub type ProviderMetadata = openidconnect::ProviderMetadata< AdditionalProviderMetadata, CoreAuthDisplay, CoreClientAuthMethod, @@ -103,17 +103,14 @@ pub struct OidcClient { } impl OidcClient { - /// create a new [`OidcClient`] by fetching the required information from the - /// `/.well-known/openid-configuration` endpoint of the issuer. - pub async fn discover_new( + /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. + pub fn from_provider_metadata( + provider_metadata: ProviderMetadata, application_base_url: Uri, - issuer: String, client_id: String, client_secret: Option, scopes: Vec, ) -> Result { - let provider_metadata = - ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; let end_session_endpoint = provider_metadata .additional_metadata() .end_session_endpoint @@ -134,6 +131,79 @@ impl OidcClient { end_session_endpoint, }) } + + /// create a new [`OidcClient`] by fetching the required information from the + /// `/.well-known/openid-configuration` endpoint of the issuer. + pub async fn discover_new( + application_base_url: Uri, + issuer: String, + client_id: String, + client_secret: Option, + scopes: Vec, + ) -> Result { + let client = reqwest::Client::default(); + Self::discover_new_with_client( + application_base_url, + issuer, + client_id, + client_secret, + scopes, + &client, + ) + .await + } + + /// create a new [`OidcClient`] by fetching the required information from the + /// `/.well-known/openid-configuration` endpoint of the issuer using the provided + /// `reqwest::Client`. + pub async fn discover_new_with_client( + application_base_url: Uri, + issuer: String, + client_id: String, + client_secret: Option, + scopes: Vec, + client: &reqwest::Client, + ) -> Result { + // modified version of `openidconnect::reqwest::async_client::async_http_client`. + let async_http_client = |request: HttpRequest| async move { + let mut request_builder = client + .request(request.method, request.url.as_str()) + .body(request.body); + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + let request = request_builder + .build() + .map_err(openidconnect::reqwest::Error::Reqwest)?; + + let response = client + .execute(request) + .await + .map_err(openidconnect::reqwest::Error::Reqwest)?; + + let status_code = response.status(); + let headers = response.headers().to_owned(); + let chunks = response + .bytes() + .await + .map_err(openidconnect::reqwest::Error::Reqwest)?; + Ok(HttpResponse { + status_code, + headers, + body: chunks.to_vec(), + }) + }; + + let provider_metadata = + ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; + Self::from_provider_metadata( + provider_metadata, + application_base_url, + client_id, + client_secret, + scopes, + ) + } } /// an empty struct to be used as the default type for the additional claims generic @@ -172,7 +242,7 @@ struct AuthenticatedSession { /// additional metadata that is discovered on client creation via the /// `.well-knwon/openid-configuration` endpoint. #[derive(Debug, Clone, Serialize, Deserialize)] -struct AdditionalProviderMetadata { +pub struct AdditionalProviderMetadata { end_session_endpoint: Option, } impl openidconnect::AdditionalProviderMetadata for AdditionalProviderMetadata {} From e62aba722c8eaf81e6961cc178d26e8f3aefddd3 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 21 Apr 2024 01:21:36 +0200 Subject: [PATCH 10/37] feat: automated integration test for example/basic Adds automated CI integration tests to the basic example. The integration tests launch and configure a keycloak server, launch the example and test its functionality with a headless browser. --- .github/workflows/ci.yml | 11 +- examples/basic/Cargo.toml | 8 ++ examples/basic/README.md | 22 ++++ examples/basic/src/lib.rs | 82 +++++++++++++ examples/basic/src/main.rs | 78 +----------- examples/basic/tests/integration.rs | 101 ++++++++++++++++ examples/basic/tests/keycloak.rs | 180 ++++++++++++++++++++++++++++ 7 files changed, 402 insertions(+), 80 deletions(-) create mode 100644 examples/basic/README.md create mode 100644 examples/basic/src/lib.rs create mode 100644 examples/basic/tests/integration.rs create mode 100644 examples/basic/tests/keycloak.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a1c7a1..ee27b5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,14 +21,17 @@ jobs: steps: - uses: actions/checkout@v3 - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo build --verbose - - run: cargo test --verbose + - run: cargo build --verbose --release + - run: cargo test --verbose --release - build_examples: + build_and_test_examples: name: axum-oidc - examples runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - run: sudo apt install chromium-browser -y - run: rustup update stable && rustup default stable - - run: cargo build --verbose + - run: cargo build --verbose --release + working-directory: ./examples/basic + - run: cargo test --verbose --release working-directory: ./examples/basic diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index b943d97..00449f8 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -13,3 +13,11 @@ tower = "0.4" tower-sessions = "0.12" dotenvy = "0.15" + +[dev-dependencies] +testcontainers = "0.15.0" +tokio = { version = "1.37.0", features = ["rt-multi-thread"] } +reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } +env_logger = "0.11.3" +log = "0.4.21" +headless_chrome = "1.0.9" diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..4011a45 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,22 @@ +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. + +You can take a look at the `tests/`-folder to see how the automated keycloak deployment for the integration tests work. + +## Setup Environment +Create a `.env`-file that contains the following keys: +``` +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/lib.rs b/examples/basic/src/lib.rs new file mode 100644 index 0000000..df77766 --- /dev/null +++ b/examples/basic/src/lib.rs @@ -0,0 +1,82 @@ +use axum::{ + error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, +}; +use axum_oidc::{ + error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, + OidcRpInitiatedLogout, +}; +use tokio::net::TcpListener; +use tower::ServiceBuilder; +use tower_sessions::{ + cookie::{time::Duration, SameSite}, + Expiry, MemoryStore, SessionManagerLayer, +}; + +pub async fn run( + app_url: String, + issuer: String, + client_id: String, + client_secret: Option, +) { + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); + + let oidc_login_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + e.into_response() + })) + .layer(OidcLoginLayer::::new()); + + let oidc_auth_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + e.into_response() + })) + .layer( + OidcAuthLayer::::discover_client( + Uri::from_maybe_shared(app_url).expect("valid APP_URL"), + issuer, + client_id, + client_secret, + vec![], + ) + .await + .unwrap(), + ); + + let app = Router::new() + .route("/foo", get(authenticated)) + .route("/logout", get(logout)) + .layer(oidc_login_service) + .route("/bar", get(maybe_authenticated)) + .layer(oidc_auth_service) + .layer(session_layer); + + let listener = TcpListener::bind("[::]:8080").await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +async fn authenticated(claims: OidcClaims) -> impl IntoResponse { + format!("Hello {}", claims.subject().as_str()) +} + +async fn maybe_authenticated( + claims: Option>, +) -> impl IntoResponse { + if let Some(claims) = claims { + format!( + "Hello {}! You are already logged in from another Handler.", + claims.subject().as_str() + ) + } else { + "Hello anon!".to_string() + } +} + +async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { + logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) +} diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 6659890..6252c87 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,17 +1,4 @@ -use axum::{ - error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, -}; -use axum_oidc::{ - error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, - OidcRpInitiatedLogout, -}; -use tokio::net::TcpListener; -use tower::ServiceBuilder; -use tower_sessions::{ - cookie::{time::Duration, SameSite}, - Expiry, MemoryStore, SessionManagerLayer, -}; - +use basic::run; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); @@ -19,66 +6,5 @@ async fn main() { 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 { - e.into_response() - })) - .layer(OidcLoginLayer::::new()); - - let oidc_auth_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - e.into_response() - })) - .layer( - OidcAuthLayer::::discover_client( - Uri::from_maybe_shared(app_url).expect("valid APP_URL"), - issuer, - client_id, - client_secret, - vec![], - ) - .await - .unwrap(), - ); - - let app = Router::new() - .route("/foo", get(authenticated)) - .route("/logout", get(logout)) - .layer(oidc_login_service) - .route("/bar", get(maybe_authenticated)) - .layer(oidc_auth_service) - .layer(session_layer); - - let listener = TcpListener::bind("[::]:8080").await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); -} - -async fn authenticated(claims: OidcClaims) -> impl IntoResponse { - format!("Hello {}", claims.subject().as_str()) -} - -async fn maybe_authenticated( - claims: Option>, -) -> impl IntoResponse { - if let Some(claims) = claims { - format!( - "Hello {}! You are already logged in from another Handler.", - claims.subject().as_str() - ) - } else { - "Hello anon!".to_string() - } -} - -async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) + run(app_url, issuer, client_id, client_secret).await } diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs new file mode 100644 index 0000000..e4a014c --- /dev/null +++ b/examples/basic/tests/integration.rs @@ -0,0 +1,101 @@ +mod keycloak; + +use headless_chrome::Browser; +use log::info; +use testcontainers::*; + +use crate::keycloak::{Client, Keycloak, Realm, User}; + +#[tokio::test(flavor = "multi_thread")] +async fn first() { + env_logger::init(); + + let docker = clients::Cli::default(); + + let alice = User { + username: "alice".to_string(), + email: "alice@example.com".to_string(), + firstname: "alice".to_string(), + lastname: "doe".to_string(), + password: "alice".to_string(), + }; + + let basic_client = Client { + client_id: "axum-oidc-example-basic".to_string(), + client_secret: Some("123456".to_string()), + }; + + let keycloak = Keycloak::start( + vec![Realm { + name: "test".to_string(), + users: vec![alice.clone()], + clients: vec![basic_client.clone()], + }], + &docker, + ) + .await; + + info!("starting basic example app"); + + let app_url = "http://127.0.0.1:8080/"; + let app_handle = tokio::spawn(basic::run( + app_url.to_string(), + format!("{}/realms/test", keycloak.url()), + basic_client.client_id.to_string(), + basic_client.client_secret.clone(), + )); + + info!("starting browser"); + + let browser = Browser::default().unwrap(); + let tab = browser.new_tab().unwrap(); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert_eq!(body, "Hello anon!"); + + tab.navigate_to(&format!("{}foo", app_url)).unwrap(); + let username = tab.wait_for_xpath(r#"//*[@id="username"]"#).unwrap(); + username.type_into(&alice.username).unwrap(); + let password = tab.wait_for_xpath(r#"//*[@id="password"]"#).unwrap(); + password.type_into(&alice.password).unwrap(); + let submit = tab.wait_for_xpath(r#"//*[@id="kc-login"]"#).unwrap(); + submit.click().unwrap(); + + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert!(body.starts_with("Hello ") && body.contains('-')); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert!(body.contains("! You are already logged in from another Handler.")); + + tab.navigate_to(&format!("{}logout", app_url)).unwrap(); + tab.wait_until_navigated().unwrap(); + + tab.navigate_to(&format!("{}bar", app_url)).unwrap(); + let body = tab + .wait_for_xpath(r#"/html/body/pre"#) + .unwrap() + .get_inner_text() + .unwrap(); + assert_eq!(body, "Hello anon!"); + + tab.navigate_to(&format!("{}foo", app_url)).unwrap(); + tab.wait_until_navigated().unwrap(); + tab.find_element_by_xpath(r#"//*[@id="username"]"#).unwrap(); + + tab.close(true).unwrap(); + app_handle.abort(); +} diff --git a/examples/basic/tests/keycloak.rs b/examples/basic/tests/keycloak.rs new file mode 100644 index 0000000..961d544 --- /dev/null +++ b/examples/basic/tests/keycloak.rs @@ -0,0 +1,180 @@ +use log::info; +use std::time::Duration; +use testcontainers::*; + +use testcontainers::core::ExecCommand; +use testcontainers::{core::WaitFor, Container, Image, RunnableImage}; + +struct KeycloakImage; + +impl Image for KeycloakImage { + type Args = Vec; + + fn name(&self) -> String { + "quay.io/keycloak/keycloak".to_string() + } + + fn tag(&self) -> String { + "latest".to_string() + } + + fn ready_conditions(&self) -> Vec { + vec![] + } +} + +pub struct Keycloak<'a> { + container: Container<'a, KeycloakImage>, + realms: Vec, + url: String, +} + +#[derive(Clone)] +pub struct Realm { + pub name: String, + pub clients: Vec, + pub users: Vec, +} + +#[derive(Clone)] +pub struct Client { + pub client_id: String, + pub client_secret: Option, +} + +#[derive(Clone)] +pub struct User { + pub username: String, + pub email: String, + pub firstname: String, + pub lastname: String, + pub password: String, +} + +impl<'a> Keycloak<'a> { + pub async fn start(realms: Vec, docker: &'a clients::Cli) -> Keycloak<'a> { + info!("starting keycloak"); + + let keycloak_image = RunnableImage::from((KeycloakImage, vec!["start-dev".to_string()])) + .with_env_var(("KEYCLOAK_ADMIN", "admin")) + .with_env_var(("KEYCLOAK_ADMIN_PASSWORD", "admin")); + let container = docker.run(keycloak_image); + + let keycloak = Self { + url: format!("http://127.0.0.1:{}", container.get_host_port_ipv4(8080),), + container, + realms, + }; + + let issuer = format!( + "http://127.0.0.1:{}/realms/{}", + keycloak.container.get_host_port_ipv4(8080), + "test" + ); + + while reqwest::get(&issuer).await.is_err() { + tokio::time::sleep(Duration::from_secs(1)).await; + } + + keycloak.execute("/opt/keycloak/bin/kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin".to_string()).await; + + for realm in keycloak.realms.iter() { + keycloak.create_realm(&realm.name).await; + for client in realm.clients.iter() { + keycloak + .create_client( + &client.client_id, + client.client_secret.as_deref(), + &realm.name, + ) + .await; + } + for user in realm.users.iter() { + keycloak + .create_user( + &user.username, + &user.email, + &user.firstname, + &user.lastname, + &user.password, + &realm.name, + ) + .await; + } + } + + keycloak + } + + pub fn url(&self) -> &str { + &self.url + } + + async fn create_realm(&self, name: &str) { + self.execute(format!( + "/opt/keycloak/bin/kcadm.sh create realms -s realm={} -s enabled=true", + name + )) + .await; + } + + async fn create_client(&self, client_id: &str, client_secret: Option<&str>, realm: &str) { + if let Some(client_secret) = client_secret { + self.execute(format!( + r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF + {{ + "clientId": "{}", + "secret": "{}", + "redirectUris": ["*"] + }} + EOF + "#, + realm, client_id, client_secret + )) + .await; + } else { + self.execute(format!( + r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF + {{ + "clientId": "{}", + "redirectUris": ["*"] + }} + EOF + "#, + realm, client_id + )) + .await; + } + } + + async fn create_user( + &self, + username: &str, + email: &str, + firstname: &str, + lastname: &str, + password: &str, + realm: &str, + ) { + let id = self.execute( + format!( + "/opt/keycloak/bin/kcadm.sh create users -r {} -s username={} -s enabled=true -s emailVerified=true -s email={} -s firstName={} -s lastName={}", + realm, username, email, firstname, lastname + ), + ) + .await; + self.execute(format!( + "/opt/keycloak/bin/kcadm.sh set-password -r {} --username {} --new-password {}", + realm, username, password + )) + .await; + id + } + + async fn execute(&self, cmd: String) { + self.container.exec(ExecCommand { + cmd, + ready_conditions: vec![], + }); + } +} From bda8797960e7ab6d43bc5c800e6e5621b754281f Mon Sep 17 00:00:00 2001 From: Armin Date: Fri, 30 Aug 2024 09:45:59 +0200 Subject: [PATCH 11/37] Fix two typos in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9df8a65..ca5dee8 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. @@ -22,7 +22,7 @@ Take a look at the `examples` folder for examples. # Older Versions All versions on [crates.io](https://crates.io) are available as git tags. -Additonal 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. +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 From 202b61fa8375dc2c8ab10d244fc36b220434e890 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 30 Aug 2024 10:33:07 +0200 Subject: [PATCH 12/37] fix: correct error handling in rp initiated logout Previously the extractor would return `ExtractorError::Unauthorized` when the issuer does not provide a end_session_endpoint. Now it will return a `ExtractorError::RpInitiatedLogoutNotSupported`. --- src/error.rs | 7 ++++--- src/extractor.rs | 9 ++++++--- src/middleware.rs | 9 +++++---- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/error.rs b/src/error.rs index c580d16..454dddc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -11,11 +11,12 @@ pub enum ExtractorError { #[error("unauthorized")] Unauthorized, - #[error("rp initiated logout information not found")] - RpInitiatedLogoutInformationNotFound, + #[error("rp initiated logout not supported by issuer")] + RpInitiatedLogoutNotSupported, #[error("could not build rp initiated logout uri")] FailedToCreateRpInitiatedLogoutUri, + } #[derive(Debug, Error)] @@ -88,7 +89,7 @@ impl IntoResponse for ExtractorError { fn into_response(self) -> axum_core::response::Response { match self { Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized").into_response(), - Self::RpInitiatedLogoutInformationNotFound => { + Self::RpInitiatedLogoutNotSupported => { (StatusCode::INTERNAL_SERVER_ERROR, "intenal server error").into_response() } Self::FailedToCreateRpInitiatedLogoutUri => { diff --git a/src/extractor.rs b/src/extractor.rs index 233f20d..9cd41ed 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -155,11 +155,14 @@ where type Rejection = ExtractorError; async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { - parts + match parts .extensions - .get::() + .get::>() .cloned() - .ok_or(ExtractorError::Unauthorized) + .ok_or(ExtractorError::Unauthorized)?{ + Some(this) => Ok(this), + None => Err(ExtractorError::RpInitiatedLogoutNotSupported), + } } } diff --git a/src/middleware.rs b/src/middleware.rs index 97bbc09..8f0432a 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -409,15 +409,16 @@ fn insert_extensions( parts.extensions.insert(OidcAccessToken( authenticated_session.access_token.secret().to_string(), )); - if let Some(end_session_endpoint) = &client.end_session_endpoint { - parts.extensions.insert(OidcRpInitiatedLogout { + 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(), 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 From 9dd85a770357eb2718b4117836c3b23482766c2d Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 6 Sep 2024 20:53:12 +0200 Subject: [PATCH 13/37] fix: use custom `reqwest::Client` in middleware previously the middlewares would use the default `reqwest::Client` even if the `OidcClient` is created with a custom client. Now the middleware uses the `reqwest::Client` used during creation of `OidcClient`. --- src/lib.rs | 36 +++++++++++++++++++++++- src/middleware.rs | 71 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ae32f57..a6825b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -98,6 +98,7 @@ pub struct OidcClient { scopes: Vec, client_id: String, client: Client, + http_client: reqwest::Client, application_base_url: Uri, end_session_endpoint: Option, } @@ -129,6 +130,37 @@ impl OidcClient { client_id, application_base_url, end_session_endpoint, + http_client: reqwest::Client::default(), + }) + } + /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. + pub fn from_provider_metadata_and_client( + provider_metadata: ProviderMetadata, + application_base_url: Uri, + client_id: String, + client_secret: Option, + scopes: Vec, + http_client: reqwest::Client, + ) -> Result { + let end_session_endpoint = provider_metadata + .additional_metadata() + .end_session_endpoint + .clone() + .map(Uri::from_maybe_shared) + .transpose() + .map_err(Error::InvalidEndSessionEndpoint)?; + let client = Client::from_provider_metadata( + provider_metadata, + ClientId::new(client_id.clone()), + client_secret.map(ClientSecret::new), + ); + Ok(Self { + scopes, + client, + client_id, + application_base_url, + end_session_endpoint, + http_client, }) } @@ -162,6 +194,7 @@ impl OidcClient { client_id: String, client_secret: Option, scopes: Vec, + //TODO remove borrow with next breaking version client: &reqwest::Client, ) -> Result { // modified version of `openidconnect::reqwest::async_client::async_http_client`. @@ -196,12 +229,13 @@ impl OidcClient { let provider_metadata = ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; - Self::from_provider_metadata( + Self::from_provider_metadata_and_client( provider_metadata, application_base_url, client_id, client_secret, scopes, + client.clone(), ) } } diff --git a/src/middleware.rs b/src/middleware.rs index 8f0432a..3ae8437 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,5 +1,6 @@ use std::{ marker::PhantomData, + pin::Pin, task::{Context, Poll}, }; @@ -8,7 +9,7 @@ use axum::{ response::{IntoResponse, Redirect}, }; use axum_core::{extract::FromRequestParts, response::Response}; -use futures_util::future::BoxFuture; +use futures_util::{future::BoxFuture, Future}; use http::{request::Parts, uri::PathAndQuery, Request, Uri}; use tower_layer::Layer; use tower_service::Service; @@ -16,9 +17,9 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim}, - reqwest::async_http_client, - AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, IdTokenClaims, Nonce, - OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, + AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, HttpRequest, HttpResponse, + IdTokenClaims, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, + RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; @@ -149,7 +150,7 @@ where .set_pkce_verifier(PkceCodeVerifier::new( login_session.pkce_verifier.secret().to_string(), )) - .request_async(async_http_client) + .request_async(async_http_client(&oidcclient.http_client)) .await?; // Extract the ID token claims after verifying its authenticity and nonce. @@ -409,16 +410,17 @@ fn insert_extensions( 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 { + 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(), client_id: client.client_id.clone(), post_logout_redirect_uri: None, state: None, - } - ); - parts.extensions.insert(rp_initiated_logout); + }); + parts.extensions.insert(rp_initiated_logout); } /// Verify the access token hash to ensure that the access token hasn't been substituted for @@ -460,7 +462,10 @@ async fn try_refresh_token( refresh_request = refresh_request.add_scope(Scope::new(scope.to_string())); } - match refresh_request.request_async(async_http_client).await { + match refresh_request + .request_async(async_http_client(&client.http_client)) + .await + { Ok(token_response) => { // Extract the ID token claims after verifying its authenticity and nonce. let id_token = token_response @@ -489,3 +494,47 @@ async fn try_refresh_token( Err(err) => Err(err.into()), } } + +/// `openidconnect::reqwest::async_http_client` that uses a custom `reqwest::client` +fn async_http_client<'a>( + client: &'a reqwest::Client, +) -> impl FnOnce( + HttpRequest, +) -> Pin< + Box< + dyn Future>> + + Send + + 'a, + >, +> { + move |request: HttpRequest| { + Box::pin(async move { + let mut request_builder = client + .request(request.method, request.url.as_str()) + .body(request.body); + for (name, value) in &request.headers { + request_builder = request_builder.header(name.as_str(), value.as_bytes()); + } + let request = request_builder + .build() + .map_err(openidconnect::reqwest::Error::Reqwest)?; + + let response = client + .execute(request) + .await + .map_err(openidconnect::reqwest::Error::Reqwest)?; + + let status_code = response.status(); + let headers = response.headers().to_owned(); + let chunks = response + .bytes() + .await + .map_err(openidconnect::reqwest::Error::Reqwest)?; + Ok(HttpResponse { + status_code, + headers, + body: chunks.to_vec(), + }) + }) + } +} From 89c3a9ccb48f75f9e78f750d8aceafbe8219104b Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Mon, 23 Sep 2024 21:23:45 +0200 Subject: [PATCH 14/37] Dependency Update Updated dependencies: - tower-sessions to 0.13 --- Cargo.toml | 4 ++-- examples/basic/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 75c352d..d60c527 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ keywords = [ "axum", "oidc", "openidconnect", "authentication" ] thiserror = "1.0" axum-core = "0.4" axum = { version = "0.7", default-features = false, features = [ "query" ] } -tower-service = "0.3.2" +tower-service = "0.3" tower-layer = "0.3" -tower-sessions = { version = "0.12", default-features = false, features = [ "axum-core" ] } +tower-sessions = { version = "0.13", default-features = false, features = [ "axum-core" ] } http = "1.1" async-trait = "0.1" openidconnect = "3.5" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 00449f8..a1b712e 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -10,7 +10,7 @@ tokio = { version = "1.37", features = ["net", "macros", "rt-multi-thread"] } axum = "0.7" axum-oidc = { path = "./../.." } tower = "0.4" -tower-sessions = "0.12" +tower-sessions = "0.13" dotenvy = "0.15" From 57e571bd93e8e0e29a07587f97f719cf96e87736 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Mon, 23 Sep 2024 21:24:56 +0200 Subject: [PATCH 15/37] Version 0.5.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index d60c527..f6c53bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = [ "Paul Z " ] readme = "README.md" From 3b4bbb6978defcf2a6ec7ca51e677ca5137eec33 Mon Sep 17 00:00:00 2001 From: MATILLAT Quentin Date: Sat, 11 Jan 2025 17:38:34 +0100 Subject: [PATCH 16/37] chore: Update axum to 0.8 Signed-off-by: MATILLAT Quentin --- Cargo.toml | 5 ++-- examples/basic/Cargo.toml | 2 +- src/extractor.rs | 50 ++++++++++++++++++++++++++++++++------- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f6c53bd..482eeec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,13 +13,12 @@ keywords = [ "axum", "oidc", "openidconnect", "authentication" ] [dependencies] thiserror = "1.0" -axum-core = "0.4" -axum = { version = "0.7", default-features = false, features = [ "query" ] } +axum-core = "0.5" +axum = { version = "0.8", default-features = false, features = [ "query" ] } tower-service = "0.3" tower-layer = "0.3" tower-sessions = { version = "0.13", default-features = false, features = [ "axum-core" ] } http = "1.1" -async-trait = "0.1" openidconnect = "3.5" serde = "1.0" futures-util = "0.3" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index a1b712e..6d862c7 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] tokio = { version = "1.37", features = ["net", "macros", "rt-multi-thread"] } -axum = "0.7" +axum = { version = "0.8", features = ["macros"] } axum-oidc = { path = "./../.." } tower = "0.4" tower-sessions = "0.13" diff --git a/src/extractor.rs b/src/extractor.rs index 9cd41ed..01635a8 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -1,9 +1,11 @@ -use std::{borrow::Cow, ops::Deref}; +use std::{borrow::Cow, convert::Infallible, ops::Deref}; use crate::{error::ExtractorError, AdditionalClaims, ClearSessionFlag}; -use async_trait::async_trait; use axum::response::Redirect; -use axum_core::{extract::FromRequestParts, response::IntoResponse}; +use axum_core::{ + extract::{FromRequestParts, OptionalFromRequestParts}, + response::IntoResponse, +}; use http::{request::Parts, uri::PathAndQuery, Uri}; use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; @@ -13,7 +15,6 @@ use openidconnect::{core::CoreGenderClaim, IdTokenClaims}; #[derive(Clone)] pub struct OidcClaims(pub IdTokenClaims); -#[async_trait] impl FromRequestParts for OidcClaims where S: Send + Sync, @@ -30,6 +31,18 @@ where } } +impl OptionalFromRequestParts for OidcClaims +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + impl Deref for OidcClaims { type Target = IdTokenClaims; @@ -53,7 +66,6 @@ where #[derive(Clone)] pub struct OidcAccessToken(pub String); -#[async_trait] impl FromRequestParts for OidcAccessToken where S: Send + Sync, @@ -69,6 +81,17 @@ where } } +impl OptionalFromRequestParts for OidcAccessToken +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + impl Deref for OidcAccessToken { type Target = str; @@ -147,7 +170,6 @@ impl OidcRpInitiatedLogout { } } -#[async_trait] impl FromRequestParts for OidcRpInitiatedLogout where S: Send + Sync, @@ -159,10 +181,22 @@ where .extensions .get::>() .cloned() - .ok_or(ExtractorError::Unauthorized)?{ + .ok_or(ExtractorError::Unauthorized)? + { Some(this) => Ok(this), None => Err(ExtractorError::RpInitiatedLogoutNotSupported), - } + } + } +} + +impl OptionalFromRequestParts for OidcRpInitiatedLogout +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::>().cloned().flatten()) } } From 74551fb4797f4708295725912fb6fe100484c6b0 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 12 Jan 2025 22:37:30 +0100 Subject: [PATCH 17/37] update dependencies --- Cargo.toml | 6 ++-- examples/basic/Cargo.toml | 20 +++++------ examples/basic/src/lib.rs | 5 +-- examples/basic/tests/integration.rs | 16 +++------ examples/basic/tests/keycloak.rs | 52 +++++++++++++++++------------ 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 482eeec..6686a40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,13 @@ 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" +thiserror = "2.0" axum-core = "0.5" axum = { version = "0.8", default-features = false, features = [ "query" ] } tower-service = "0.3" tower-layer = "0.3" -tower-sessions = { version = "0.13", default-features = false, features = [ "axum-core" ] } -http = "1.1" +tower-sessions = { version = "0.14", default-features = false, features = [ "axum-core" ] } +http = "1.2" openidconnect = "3.5" serde = "1.0" futures-util = "0.3" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 6d862c7..fe9028c 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -6,18 +6,18 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -tokio = { version = "1.37", features = ["net", "macros", "rt-multi-thread"] } -axum = { version = "0.8", features = ["macros"] } +tokio = { version = "1.43", features = ["net", "macros", "rt-multi-thread"] } +axum = { version = "0.8", features = [ "macros" ]} axum-oidc = { path = "./../.." } -tower = "0.4" -tower-sessions = "0.13" +tower = "0.5" +tower-sessions = "0.14" dotenvy = "0.15" [dev-dependencies] -testcontainers = "0.15.0" -tokio = { version = "1.37.0", features = ["rt-multi-thread"] } -reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } -env_logger = "0.11.3" -log = "0.4.21" -headless_chrome = "1.0.9" +testcontainers = "0.23" +tokio = { version = "1.43", features = ["rt-multi-thread"] } +reqwest = { version = "0.11", features = ["rustls-tls"], default-features = false } +env_logger = "0.11" +log = "0.4" +headless_chrome = "1.0" diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index df77766..389d742 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -64,10 +64,11 @@ async fn authenticated(claims: OidcClaims) -> impl IntoRe format!("Hello {}", claims.subject().as_str()) } +#[axum::debug_handler] async fn maybe_authenticated( - claims: Option>, + claims: Result, axum_oidc::error::ExtractorError>, ) -> impl IntoResponse { - if let Some(claims) = claims { + if let Ok(claims) = claims { format!( "Hello {}! You are already logged in from another Handler.", claims.subject().as_str() diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs index e4a014c..2d8b89b 100644 --- a/examples/basic/tests/integration.rs +++ b/examples/basic/tests/integration.rs @@ -2,7 +2,6 @@ mod keycloak; use headless_chrome::Browser; use log::info; -use testcontainers::*; use crate::keycloak::{Client, Keycloak, Realm, User}; @@ -10,8 +9,6 @@ use crate::keycloak::{Client, Keycloak, Realm, User}; async fn first() { env_logger::init(); - let docker = clients::Cli::default(); - let alice = User { username: "alice".to_string(), email: "alice@example.com".to_string(), @@ -25,14 +22,11 @@ async fn first() { client_secret: Some("123456".to_string()), }; - let keycloak = Keycloak::start( - vec![Realm { - name: "test".to_string(), - users: vec![alice.clone()], - clients: vec![basic_client.clone()], - }], - &docker, - ) + let keycloak = Keycloak::start(vec![Realm { + name: "test".to_string(), + users: vec![alice.clone()], + clients: vec![basic_client.clone()], + }]) .await; info!("starting basic example app"); diff --git a/examples/basic/tests/keycloak.rs b/examples/basic/tests/keycloak.rs index 961d544..d06a5d6 100644 --- a/examples/basic/tests/keycloak.rs +++ b/examples/basic/tests/keycloak.rs @@ -1,21 +1,20 @@ use log::info; use std::time::Duration; -use testcontainers::*; +use testcontainers::runners::AsyncRunner; +use testcontainers::ContainerAsync; use testcontainers::core::ExecCommand; -use testcontainers::{core::WaitFor, Container, Image, RunnableImage}; +use testcontainers::{core::WaitFor, Image, ImageExt}; struct KeycloakImage; impl Image for KeycloakImage { - type Args = Vec; - - fn name(&self) -> String { - "quay.io/keycloak/keycloak".to_string() + fn name(&self) -> &str { + "quay.io/keycloak/keycloak" } - fn tag(&self) -> String { - "latest".to_string() + fn tag(&self) -> &str { + "latest" } fn ready_conditions(&self) -> Vec { @@ -23,8 +22,8 @@ impl Image for KeycloakImage { } } -pub struct Keycloak<'a> { - container: Container<'a, KeycloakImage>, +pub struct Keycloak { + container: ContainerAsync, realms: Vec, url: String, } @@ -51,24 +50,28 @@ pub struct User { pub password: String, } -impl<'a> Keycloak<'a> { - pub async fn start(realms: Vec, docker: &'a clients::Cli) -> Keycloak<'a> { +impl Keycloak { + pub async fn start(realms: Vec) -> Keycloak { info!("starting keycloak"); - let keycloak_image = RunnableImage::from((KeycloakImage, vec!["start-dev".to_string()])) - .with_env_var(("KEYCLOAK_ADMIN", "admin")) - .with_env_var(("KEYCLOAK_ADMIN_PASSWORD", "admin")); - let container = docker.run(keycloak_image); + let keycloak_image = KeycloakImage + .with_cmd(["start-dev".to_string()]) + .with_env_var("KEYCLOAK_ADMIN", "admin") + .with_env_var("KEYCLOAK_ADMIN_PASSWORD", "admin"); + let container = keycloak_image.start().await.unwrap(); let keycloak = Self { - url: format!("http://127.0.0.1:{}", container.get_host_port_ipv4(8080),), + url: format!( + "http://127.0.0.1:{}", + container.get_host_port_ipv4(8080).await.unwrap() + ), container, realms, }; let issuer = format!( "http://127.0.0.1:{}/realms/{}", - keycloak.container.get_host_port_ipv4(8080), + keycloak.container.get_host_port_ipv4(8080).await.unwrap(), "test" ); @@ -172,9 +175,14 @@ impl<'a> Keycloak<'a> { } async fn execute(&self, cmd: String) { - self.container.exec(ExecCommand { - cmd, - ready_conditions: vec![], - }); + let mut result = self + .container + .exec(ExecCommand::new( + ["/bin/sh", "-c", cmd.as_str()].iter().copied(), + )) + .await + .unwrap(); + // collect stdout to wait until command completion + let _output = String::from_utf8(result.stdout_to_vec().await.unwrap()); } } From f0d9126652c9f1bf7c328297a26fe6ab6a3979f9 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Sun, 12 Jan 2025 22:46:19 +0100 Subject: [PATCH 18/37] Version 0.6.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6686a40..5cd18f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "0.5.0" +version = "0.6.0" edition = "2021" authors = [ "Paul Z " ] readme = "README.md" From 2800b88b82b51a3d42df6b5abf1c3ca3d827e694 Mon Sep 17 00:00:00 2001 From: MATILLAT Quentin Date: Sat, 25 Jan 2025 21:30:16 +0100 Subject: [PATCH 19/37] chore(deps): Update to openidconnect 0.4 Signed-off-by: MATILLAT Quentin --- Cargo.toml | 4 +- examples/basic/Cargo.toml | 2 +- src/error.rs | 16 +++++-- src/lib.rs | 70 +++++++++-------------------- src/middleware.rs | 94 +++++++++++++-------------------------- 5 files changed, 68 insertions(+), 118 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5cd18f0..52e1353 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,8 @@ tower-service = "0.3" tower-layer = "0.3" tower-sessions = { version = "0.14", default-features = false, features = [ "axum-core" ] } http = "1.2" -openidconnect = "3.5" +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" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index fe9028c..c5dcf7f 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -17,7 +17,7 @@ dotenvy = "0.15" [dev-dependencies] testcontainers = "0.23" tokio = { version = "1.43", features = ["rt-multi-thread"] } -reqwest = { version = "0.11", features = ["rustls-tls"], default-features = false } +reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } env_logger = "0.11" log = "0.4" headless_chrome = "1.0" diff --git a/src/error.rs b/src/error.rs index 454dddc..48f0903 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,11 +16,13 @@ pub enum ExtractorError { #[error("could not build rp initiated logout uri")] FailedToCreateRpInitiatedLogoutUri, - } #[derive(Debug, Error)] pub enum MiddlewareError { + #[error("configuration: {0:?}")] + Configuration(#[from] openidconnect::ConfigurationError), + #[error("access token hash invalid")] AccessTokenHashInvalid, @@ -33,6 +35,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), @@ -49,7 +54,7 @@ pub enum MiddlewareError { RequestToken( #[from] openidconnect::RequestTokenError< - openidconnect::reqwest::Error, + openidconnect::HttpClientError, StandardErrorResponse, >, ), @@ -76,7 +81,12 @@ pub enum Error { InvalidEndSessionEndpoint(http::uri::InvalidUri), #[error("discovery: {0:?}")] - Discovery(#[from] openidconnect::DiscoveryError>), + Discovery( + #[from] + openidconnect::DiscoveryError< + openidconnect::HttpClientError, + >, + ), #[error("extractor: {0:?}")] Extractor(#[from] ExtractorError), diff --git a/src/lib.rs b/src/lib.rs index a6825b3..94ed56a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,14 +8,13 @@ use http::Uri; use openidconnect::{ core::{ CoreAuthDisplay, CoreAuthPrompt, CoreClaimName, CoreClaimType, CoreClientAuthMethod, - CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, - CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType, CoreRevocableToken, - CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, - CoreTokenType, + CoreErrorResponseType, CoreGenderClaim, CoreGrantType, CoreJsonWebKey, + CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, + CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, + CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, HttpRequest, - HttpResponse, IdTokenFields, IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, + AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, + EndpointNotSet, EndpointSet, IdTokenFields, IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, StandardErrorResponse, StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -41,7 +40,6 @@ type OidcTokenResponse = StandardTokenResponse< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, >, CoreTokenType, >; @@ -51,25 +49,34 @@ pub type IdToken = openidconnect::IdToken< CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, >; -type Client = openidconnect::Client< +type Client< + AC, + HasAuthUrl = EndpointSet, + HasDeviceAuthUrl = EndpointNotSet, + HasIntrospectionUrl = EndpointNotSet, + HasRevocationUrl = EndpointNotSet, + HasTokenUrl = EndpointMaybeSet, + HasUserInfoUrl = EndpointMaybeSet, +> = openidconnect::Client< AC, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, - CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, OidcTokenResponse, - CoreTokenType, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, + HasAuthUrl, + HasDeviceAuthUrl, + HasIntrospectionUrl, + HasRevocationUrl, + HasTokenUrl, + HasUserInfoUrl, >; pub type ProviderMetadata = openidconnect::ProviderMetadata< @@ -81,9 +88,6 @@ pub type ProviderMetadata = openidconnect::ProviderMetadata< CoreGrantType, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm, - CoreJwsSigningAlgorithm, - CoreJsonWebKeyType, - CoreJsonWebKeyUse, CoreJsonWebKey, CoreResponseMode, CoreResponseType, @@ -197,38 +201,8 @@ impl OidcClient { //TODO remove borrow with next breaking version client: &reqwest::Client, ) -> Result { - // modified version of `openidconnect::reqwest::async_client::async_http_client`. - let async_http_client = |request: HttpRequest| async move { - let mut request_builder = client - .request(request.method, request.url.as_str()) - .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let request = request_builder - .build() - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let response = client - .execute(request) - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response - .bytes() - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) - }; - let provider_metadata = - ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?; + ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, client).await?; Self::from_provider_metadata_and_client( provider_metadata, application_base_url, diff --git a/src/middleware.rs b/src/middleware.rs index 3ae8437..5538108 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -1,6 +1,5 @@ use std::{ marker::PhantomData, - pin::Pin, task::{Context, Poll}, }; @@ -9,17 +8,16 @@ use axum::{ response::{IntoResponse, Redirect}, }; use axum_core::{extract::FromRequestParts, response::Response}; -use futures_util::{future::BoxFuture, Future}; +use futures_util::future::BoxFuture; use http::{request::Parts, uri::PathAndQuery, Request, Uri}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ - core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim}, - AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, HttpRequest, HttpResponse, - IdTokenClaims, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, - RefreshToken, + core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, + AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, IdTokenClaims, IdTokenVerifier, + Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; @@ -145,22 +143,27 @@ where let token_response = oidcclient .client - .exchange_code(AuthorizationCode::new(query.code.to_string())) + .exchange_code(AuthorizationCode::new(query.code.to_string()))? // Set the PKCE code verifier. .set_pkce_verifier(PkceCodeVerifier::new( login_session.pkce_verifier.secret().to_string(), )) - .request_async(async_http_client(&oidcclient.http_client)) + .request_async(&oidcclient.http_client) .await?; // Extract the ID token claims after verifying its authenticity and nonce. let id_token = token_response .id_token() .ok_or(MiddlewareError::IdTokenMissing)?; - let claims = id_token - .claims(&oidcclient.client.id_token_verifier(), &login_session.nonce)?; + let id_token_verifier = oidcclient.client.id_token_verifier(); + let claims = id_token.claims(&id_token_verifier, &login_session.nonce)?; - validate_access_token_hash(id_token, token_response.access_token(), claims)?; + validate_access_token_hash( + id_token, + id_token_verifier, + token_response.access_token(), + claims, + )?; login_session.authenticated = Some(AuthenticatedSession { id_token: id_token.clone(), @@ -428,12 +431,16 @@ fn insert_extensions( /// Returns `Ok` when access token is valid fn validate_access_token_hash( id_token: &IdToken, + id_token_verifier: IdTokenVerifier, access_token: &AccessToken, claims: &IdTokenClaims, ) -> Result<(), MiddlewareError> { if let Some(expected_access_token_hash) = claims.access_token_hash() { - let actual_access_token_hash = - AccessTokenHash::from_token(access_token, &id_token.signing_alg()?)?; + let actual_access_token_hash = AccessTokenHash::from_token( + access_token, + id_token.signing_alg()?, + id_token.signing_key(&id_token_verifier)?, + )?; if actual_access_token_hash == *expected_access_token_hash { Ok(()) } else { @@ -456,24 +463,27 @@ async fn try_refresh_token( )>, MiddlewareError, > { - let mut refresh_request = client.client.exchange_refresh_token(refresh_token); + let mut refresh_request = client.client.exchange_refresh_token(refresh_token)?; for scope in client.scopes.iter() { refresh_request = refresh_request.add_scope(Scope::new(scope.to_string())); } - match refresh_request - .request_async(async_http_client(&client.http_client)) - .await - { + match refresh_request.request_async(&client.http_client).await { Ok(token_response) => { // Extract the ID token claims after verifying its authenticity and nonce. let id_token = token_response .id_token() .ok_or(MiddlewareError::IdTokenMissing)?; - let claims = id_token.claims(&client.client.id_token_verifier(), nonce)?; + let id_token_verifier = client.client.id_token_verifier(); + let claims = id_token.claims(&id_token_verifier, nonce)?; - validate_access_token_hash(id_token, token_response.access_token(), claims)?; + validate_access_token_hash( + id_token, + id_token_verifier, + token_response.access_token(), + claims, + )?; let authenticated_session = AuthenticatedSession { id_token: id_token.clone(), @@ -494,47 +504,3 @@ async fn try_refresh_token( Err(err) => Err(err.into()), } } - -/// `openidconnect::reqwest::async_http_client` that uses a custom `reqwest::client` -fn async_http_client<'a>( - client: &'a reqwest::Client, -) -> impl FnOnce( - HttpRequest, -) -> Pin< - Box< - dyn Future>> - + Send - + 'a, - >, -> { - move |request: HttpRequest| { - Box::pin(async move { - let mut request_builder = client - .request(request.method, request.url.as_str()) - .body(request.body); - for (name, value) in &request.headers { - request_builder = request_builder.header(name.as_str(), value.as_bytes()); - } - let request = request_builder - .build() - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let response = client - .execute(request) - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - - let status_code = response.status(); - let headers = response.headers().to_owned(); - let chunks = response - .bytes() - .await - .map_err(openidconnect::reqwest::Error::Reqwest)?; - Ok(HttpResponse { - status_code, - headers, - body: chunks.to_vec(), - }) - }) - } -} From 10349c61b51cce0bad43f044299c63061614c5c8 Mon Sep 17 00:00:00 2001 From: MATILLAT Quentin Date: Wed, 29 Jan 2025 15:03:53 +0100 Subject: [PATCH 20/37] chore!: Remove ref from http_client in constructors Signed-off-by: MATILLAT Quentin --- src/lib.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 94ed56a..bf3c15e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,7 +184,7 @@ impl OidcClient { client_id, client_secret, scopes, - &client, + client, ) .await } @@ -198,18 +198,17 @@ impl OidcClient { client_id: String, client_secret: Option, scopes: Vec, - //TODO remove borrow with next breaking version - client: &reqwest::Client, + client: reqwest::Client, ) -> Result { let provider_metadata = - ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, client).await?; + ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, &client).await?; Self::from_provider_metadata_and_client( provider_metadata, application_base_url, client_id, client_secret, scopes, - client.clone(), + client, ) } } From 58369449cf95d206bc26f0bfa2f6eaa49169a10e Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Tue, 18 Feb 2025 21:26:56 +0100 Subject: [PATCH 21/37] feat: add typestate OidcClient builder The previous generator functions for `OidcClient` have been replaced by a Builder. With this change the suggested changes by #14 and #21 have been implemented. --- examples/basic/src/lib.rs | 33 ++--- src/builder.rs | 255 ++++++++++++++++++++++++++++++++++++++ src/error.rs | 2 - src/extractor.rs | 4 +- src/lib.rs | 120 ++---------------- src/middleware.rs | 77 ++++++------ 6 files changed, 324 insertions(+), 167 deletions(-) create mode 100644 src/builder.rs diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index 389d742..be7daca 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -2,8 +2,8 @@ use axum::{ error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, }; use axum_oidc::{ - error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcLoginLayer, - OidcRpInitiatedLogout, + error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, + OidcLoginLayer, OidcRpInitiatedLogout, }; use tokio::net::TcpListener; use tower::ServiceBuilder; @@ -26,25 +26,30 @@ pub async fn run( 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_application_base_url(Uri::from_maybe_shared(app_url).expect("valid APP_URL")) + .with_client_id(client_id); + if let Some(client_secret) = client_secret { + oidc_client = oidc_client.with_client_secret(client_secret); + } + let oidc_client = oidc_client + .discover(Uri::from_maybe_shared(issuer).expect("valid issuer URI")) + .await + .unwrap() + .build(); + let oidc_auth_service = ServiceBuilder::new() .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + dbg!(&e); e.into_response() })) - .layer( - OidcAuthLayer::::discover_client( - Uri::from_maybe_shared(app_url).expect("valid APP_URL"), - issuer, - client_id, - client_secret, - vec![], - ) - .await - .unwrap(), - ); + .layer(OidcAuthLayer::new(oidc_client)); let app = Router::new() .route("/foo", get(authenticated)) @@ -79,5 +84,5 @@ async fn maybe_authenticated( } async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://pfzetto.de")) + 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..ba3b351 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,255 @@ +use std::marker::PhantomData; + +use http::Uri; +use openidconnect::{ClientId, ClientSecret, IssuerUrl}; + +use crate::{error::Error, AdditionalClaims, Client, OidcClient, ProviderMetadata}; + +pub struct Unconfigured; +pub struct ApplicationBaseUrl(Uri); +pub struct OpenidconnectClient(crate::Client); +pub struct HttpClient(reqwest::Client); + +pub struct ClientCredentials { + id: Box, + secret: Option>, +} + +pub struct Builder { + application_base_url: ApplicationBaseUrl, + credentials: Credentials, + client: Client, + http_client: HttpClient, + end_session_endpoint: Option, + scopes: Vec>, + oidc_request_parameters: Vec>, + auth_context_class: Option>, + _ac: PhantomData, +} + +impl Default for Builder { + fn default() -> Self { + Self::new() + } +} +impl Builder { + /// create a new builder with default values + pub fn new() -> Self { + let oidc_request_parameters = ["code", "state", "session_state", "iss"] + .into_iter() + .map(Box::::from) + .collect(); + + Self { + application_base_url: (), + credentials: (), + client: (), + http_client: (), + end_session_endpoint: None, + scopes: vec![Box::from("openid")], + oidc_request_parameters, + auth_context_class: None, + _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: impl Into>) -> Self { + self.scopes.push(scope.into()); + self + } + /// replace scopes (including default) + pub fn with_scopes(mut self, scopes: impl Iterator>>) -> Self { + self.scopes = scopes.map(|x| x.into()).collect::>(); + self + } + + /// add a query parameter that will be filtered from requests to existing (default) filtered + /// query parameters + pub fn add_oidc_request_parameter( + mut self, + oidc_request_parameter: impl Into>, + ) -> Self { + self.oidc_request_parameters + .push(oidc_request_parameter.into()); + self + } + + /// replace query parameters that will be filtered from requests (including default) + pub fn with_oidc_request_parameters( + mut self, + oidc_request_parameters: impl Iterator>>, + ) -> Self { + self.oidc_request_parameters = oidc_request_parameters + .map(|x| x.into()) + .collect::>(); + self + } + + /// authenticate with Authentication Context Class Reference + pub fn with_auth_context_class(mut self, acr: impl Into>) -> Self { + self.auth_context_class = Some(acr.into()); + self + } +} + +impl Builder { + /// set application base url (e.g. https://example.com) + pub fn with_application_base_url( + self, + url: impl Into, + ) -> Builder { + Builder { + application_base_url: ApplicationBaseUrl(url.into()), + credentials: self.credentials, + client: self.client, + http_client: self.http_client, + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + auth_context_class: self.auth_context_class, + _ac: PhantomData, + } + } +} + +impl Builder { + /// set client id for authentication with issuer + pub fn with_client_id( + self, + id: impl Into>, + ) -> Builder { + Builder::<_, _, _, _, _> { + application_base_url: self.application_base_url, + credentials: ClientCredentials { + id: id.into(), + secret: None, + }, + client: self.client, + http_client: self.http_client, + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + auth_context_class: self.auth_context_class, + _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 { + application_base_url: self.application_base_url, + credentials: self.credentials, + client: self.client, + http_client: HttpClient(client), + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + auth_context_class: self.auth_context_class, + _ac: self._ac, + } + } + /// use default reqwest http client + pub fn with_default_http_client(self) -> Builder { + Builder { + application_base_url: self.application_base_url, + credentials: self.credentials, + client: self.client, + http_client: HttpClient(reqwest::Client::default()), + end_session_endpoint: self.end_session_endpoint, + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + auth_context_class: self.auth_context_class, + _ac: self._ac, + } + } +} + +impl Builder { + /// provide issuer details manually + pub fn manual( + self, + provider_metadata: ProviderMetadata, + ) -> Result, HttpClient>, 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 + .as_ref() + .map(|x| ClientSecret::new(x.to_string())), + ); + + Ok(Builder { + application_base_url: self.application_base_url, + credentials: self.credentials, + client: OpenidconnectClient(client), + http_client: self.http_client, + end_session_endpoint, + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + auth_context_class: self.auth_context_class, + _ac: self._ac, + }) + } + /// discover issuer details + pub async fn discover( + self, + issuer: impl Into, + ) -> Result, HttpClient>, Error> + { + let issuer_url = IssuerUrl::new(issuer.into().to_string())?; + let http_client = self.http_client.0.clone(); + let provider_metadata = ProviderMetadata::discover_async(issuer_url, &http_client); + + Self::manual(self, provider_metadata.await?) + } +} + +impl + Builder, HttpClient> +{ + /// create oidc client + pub fn build(self) -> OidcClient { + OidcClient { + scopes: self.scopes, + oidc_request_parameters: self.oidc_request_parameters, + client_id: self.credentials.id, + client: self.client.0, + http_client: self.http_client.0, + application_base_url: self.application_base_url.0, + end_session_endpoint: self.end_session_endpoint, + auth_context_class: self.auth_context_class, + } + } +} diff --git a/src/error.rs b/src/error.rs index 48f0903..0bd1cdc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -111,7 +111,6 @@ impl IntoResponse for ExtractorError { impl IntoResponse for Error { fn into_response(self) -> axum_core::response::Response { - dbg!(&self); match self { _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), } @@ -120,7 +119,6 @@ impl IntoResponse for Error { 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(), } diff --git a/src/extractor.rs b/src/extractor.rs index 01635a8..8735ee7 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -112,8 +112,8 @@ impl AsRef for OidcAccessToken { #[derive(Clone)] pub struct OidcRpInitiatedLogout { pub(crate) end_session_endpoint: Uri, - pub(crate) id_token_hint: String, - pub(crate) client_id: String, + pub(crate) id_token_hint: Box, + pub(crate) client_id: Box, pub(crate) post_logout_redirect_uri: Option, pub(crate) state: Option, } diff --git a/src/lib.rs b/src/lib.rs index bf3c15e..3319875 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,6 @@ #![deny(warnings)] #![doc = include_str!("../README.md")] -use crate::error::Error; use http::Uri; use openidconnect::{ core::{ @@ -13,12 +12,13 @@ use openidconnect::{ CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, - EndpointNotSet, EndpointSet, IdTokenFields, IssuerUrl, Nonce, PkceCodeVerifier, RefreshToken, - StandardErrorResponse, StandardTokenResponse, + AccessToken, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet, EndpointSet, + IdTokenFields, Nonce, PkceCodeVerifier, RefreshToken, StandardErrorResponse, + StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +pub mod builder; pub mod error; mod extractor; mod middleware; @@ -99,118 +99,14 @@ pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { - scopes: Vec, - client_id: String, + scopes: Vec>, + oidc_request_parameters: Vec>, + client_id: Box, client: Client, http_client: reqwest::Client, application_base_url: Uri, end_session_endpoint: Option, -} - -impl OidcClient { - /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. - pub fn from_provider_metadata( - provider_metadata: ProviderMetadata, - application_base_url: Uri, - client_id: String, - client_secret: Option, - scopes: Vec, - ) -> Result { - let end_session_endpoint = provider_metadata - .additional_metadata() - .end_session_endpoint - .clone() - .map(Uri::from_maybe_shared) - .transpose() - .map_err(Error::InvalidEndSessionEndpoint)?; - let client = Client::from_provider_metadata( - provider_metadata, - ClientId::new(client_id.clone()), - client_secret.map(ClientSecret::new), - ); - Ok(Self { - scopes, - client, - client_id, - application_base_url, - end_session_endpoint, - http_client: reqwest::Client::default(), - }) - } - /// create a new [`OidcClient`] from an existing [`ProviderMetadata`]. - pub fn from_provider_metadata_and_client( - provider_metadata: ProviderMetadata, - application_base_url: Uri, - client_id: String, - client_secret: Option, - scopes: Vec, - http_client: reqwest::Client, - ) -> Result { - let end_session_endpoint = provider_metadata - .additional_metadata() - .end_session_endpoint - .clone() - .map(Uri::from_maybe_shared) - .transpose() - .map_err(Error::InvalidEndSessionEndpoint)?; - let client = Client::from_provider_metadata( - provider_metadata, - ClientId::new(client_id.clone()), - client_secret.map(ClientSecret::new), - ); - Ok(Self { - scopes, - client, - client_id, - application_base_url, - end_session_endpoint, - http_client, - }) - } - - /// create a new [`OidcClient`] by fetching the required information from the - /// `/.well-known/openid-configuration` endpoint of the issuer. - pub async fn discover_new( - application_base_url: Uri, - issuer: String, - client_id: String, - client_secret: Option, - scopes: Vec, - ) -> Result { - let client = reqwest::Client::default(); - Self::discover_new_with_client( - application_base_url, - issuer, - client_id, - client_secret, - scopes, - client, - ) - .await - } - - /// create a new [`OidcClient`] by fetching the required information from the - /// `/.well-known/openid-configuration` endpoint of the issuer using the provided - /// `reqwest::Client`. - pub async fn discover_new_with_client( - application_base_url: Uri, - issuer: String, - client_id: String, - client_secret: Option, - scopes: Vec, - client: reqwest::Client, - ) -> Result { - let provider_metadata = - ProviderMetadata::discover_async(IssuerUrl::new(issuer)?, &client).await?; - Self::from_provider_metadata_and_client( - provider_metadata, - application_base_url, - client_id, - client_secret, - scopes, - client, - ) - } + auth_context_class: Option>, } /// an empty struct to be used as the default type for the additional claims generic diff --git a/src/middleware.rs b/src/middleware.rs index 5538108..f68e923 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -16,14 +16,15 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, - AccessToken, AccessTokenHash, AuthorizationCode, CsrfToken, IdTokenClaims, IdTokenVerifier, - Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, + AccessToken, AccessTokenHash, AuthenticationContextClass, AuthorizationCode, CsrfToken, + IdTokenClaims, IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, + PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; use crate::{ - error::{Error, MiddlewareError}, + error::MiddlewareError, extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, OidcQuery, OidcSession, SESSION_KEY, @@ -126,8 +127,11 @@ where .await .map_err(MiddlewareError::from)?; - let handler_uri = - strip_oidc_from_path(oidcclient.application_base_url.clone(), &parts.uri)?; + let handler_uri = strip_oidc_from_path( + oidcclient.application_base_url.clone(), + &parts.uri, + &oidcclient.oidc_request_parameters, + )?; oidcclient.client = oidcclient .client @@ -192,6 +196,12 @@ where auth = auth.add_scope(Scope::new(scope.to_string())); } + if let Some(acr) = oidcclient.auth_context_class { + auth = auth.add_auth_context_value(AuthenticationContextClass::new( + acr.into(), + )); + } + auth.set_pkce_challenge(pkce_challenge).url() }; @@ -225,24 +235,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) } } @@ -309,8 +305,11 @@ where .await .map_err(MiddlewareError::from)?; - let handler_uri = - strip_oidc_from_path(oidcclient.application_base_url.clone(), &parts.uri)?; + let handler_uri = strip_oidc_from_path( + oidcclient.application_base_url.clone(), + &parts.uri, + &oidcclient.oidc_request_parameters, + )?; oidcclient.client = oidcclient .client @@ -373,7 +372,11 @@ where /// Helper function to remove the OpenID Connect authentication response query attributes from a /// [`Uri`]. -pub fn strip_oidc_from_path(base_url: Uri, uri: &Uri) -> Result { +pub fn strip_oidc_from_path( + base_url: Uri, + uri: &Uri, + filter: &[Box], +) -> Result { let mut base_url = base_url.into_parts(); base_url.path_and_query = uri @@ -381,20 +384,20 @@ pub fn strip_oidc_from_path(base_url: Uri, uri: &Uri) -> Result( .as_ref() .map(|end_session_endpoint| OidcRpInitiatedLogout { end_session_endpoint: end_session_endpoint.clone(), - id_token_hint: authenticated_session.id_token.to_string(), + id_token_hint: authenticated_session.id_token.to_string().into(), client_id: client.client_id.clone(), post_logout_redirect_uri: None, state: None, From 19adcbabd2ed86f299b1cf6825744d93e68481cc Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 18 Apr 2025 12:30:29 +0200 Subject: [PATCH 22/37] fix: fixed redirect_uri with handler_uri in session Previously the redirect_uri was the uri of the handler that needed authentication. Now one fixed redirect_uri for the entire application is used that will redirect the user to the correct handler after successful authentication. This commit should fix: #28, #27, #26, #21 --- examples/basic/src/lib.rs | 13 ++- src/builder.rs | 124 ++++++++++---------------- src/error.rs | 50 +++++++++++ src/handler.rs | 102 +++++++++++++++++++++ src/lib.rs | 14 +-- src/middleware.rs | 182 ++++++++------------------------------ 6 files changed, 246 insertions(+), 239 deletions(-) create mode 100644 src/handler.rs diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index be7daca..4c3a7e6 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -1,9 +1,13 @@ use axum::{ - error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::get, Router, + error_handling::HandleErrorLayer, + http::Uri, + response::IntoResponse, + routing::{any, get}, + Router, }; use axum_oidc::{ - error::MiddlewareError, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, - OidcLoginLayer, OidcRpInitiatedLogout, + error::MiddlewareError, handle_oidc_redirect, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, + OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, }; use tokio::net::TcpListener; use tower::ServiceBuilder; @@ -33,7 +37,7 @@ pub async fn run( let mut oidc_client = OidcClient::::builder() .with_default_http_client() - .with_application_base_url(Uri::from_maybe_shared(app_url).expect("valid APP_URL")) + .with_redirect_url(Uri::from_static("http://localhost:8080/oidc")) .with_client_id(client_id); if let Some(client_secret) = client_secret { oidc_client = oidc_client.with_client_secret(client_secret); @@ -56,6 +60,7 @@ pub async fn run( .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); diff --git a/src/builder.rs b/src/builder.rs index ba3b351..4e75080 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -6,23 +6,22 @@ use openidconnect::{ClientId, ClientSecret, IssuerUrl}; use crate::{error::Error, AdditionalClaims, Client, OidcClient, ProviderMetadata}; pub struct Unconfigured; -pub struct ApplicationBaseUrl(Uri); pub struct OpenidconnectClient(crate::Client); pub struct HttpClient(reqwest::Client); +pub struct RedirectUrl(Uri); pub struct ClientCredentials { id: Box, secret: Option>, } -pub struct Builder { - application_base_url: ApplicationBaseUrl, +pub struct Builder { credentials: Credentials, client: Client, http_client: HttpClient, + redirect_url: RedirectUrl, end_session_endpoint: Option, scopes: Vec>, - oidc_request_parameters: Vec>, auth_context_class: Option>, _ac: PhantomData, } @@ -35,19 +34,13 @@ impl Default for Builder { impl Builder { /// create a new builder with default values pub fn new() -> Self { - let oidc_request_parameters = ["code", "state", "session_state", "iss"] - .into_iter() - .map(Box::::from) - .collect(); - Self { - application_base_url: (), credentials: (), client: (), http_client: (), + redirect_url: (), end_session_endpoint: None, scopes: vec![Box::from("openid")], - oidc_request_parameters, auth_context_class: None, _ac: PhantomData, } @@ -61,7 +54,7 @@ impl OidcClient { } } -impl Builder { +impl Builder { /// add a scope to existing (default) scopes pub fn add_scope(mut self, scope: impl Into>) -> Self { self.scopes.push(scope.into()); @@ -73,28 +66,6 @@ impl Builder>, - ) -> Self { - self.oidc_request_parameters - .push(oidc_request_parameter.into()); - self - } - - /// replace query parameters that will be filtered from requests (including default) - pub fn with_oidc_request_parameters( - mut self, - oidc_request_parameters: impl Iterator>>, - ) -> Self { - self.oidc_request_parameters = oidc_request_parameters - .map(|x| x.into()) - .collect::>(); - self - } - /// authenticate with Authentication Context Class Reference pub fn with_auth_context_class(mut self, acr: impl Into>) -> Self { self.auth_context_class = Some(acr.into()); @@ -102,50 +73,29 @@ impl Builder Builder { - /// set application base url (e.g. https://example.com) - pub fn with_application_base_url( - self, - url: impl Into, - ) -> Builder { - Builder { - application_base_url: ApplicationBaseUrl(url.into()), - credentials: self.credentials, - client: self.client, - http_client: self.http_client, - end_session_endpoint: self.end_session_endpoint, - scopes: self.scopes, - oidc_request_parameters: self.oidc_request_parameters, - auth_context_class: self.auth_context_class, - _ac: PhantomData, - } - } -} - -impl Builder { +impl Builder { /// set client id for authentication with issuer pub fn with_client_id( self, id: impl Into>, - ) -> Builder { + ) -> Builder { Builder::<_, _, _, _, _> { - application_base_url: self.application_base_url, 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, - oidc_request_parameters: self.oidc_request_parameters, auth_context_class: self.auth_context_class, _ac: PhantomData, } } } -impl Builder { +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()); @@ -153,47 +103,65 @@ impl Builder Builder { +impl Builder { /// use custom http client pub fn with_http_client( self, client: reqwest::Client, - ) -> Builder { + ) -> Builder { Builder { - application_base_url: self.application_base_url, 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, - oidc_request_parameters: self.oidc_request_parameters, auth_context_class: self.auth_context_class, _ac: self._ac, } } /// use default reqwest http client - pub fn with_default_http_client(self) -> Builder { + pub fn with_default_http_client(self) -> Builder { Builder { - application_base_url: self.application_base_url, 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, - oidc_request_parameters: self.oidc_request_parameters, auth_context_class: self.auth_context_class, _ac: self._ac, } } } -impl Builder { +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, + _ac: self._ac, + } + } +} + +impl Builder { /// provide issuer details manually pub fn manual( self, provider_metadata: ProviderMetadata, - ) -> Result, HttpClient>, Error> - { + ) -> Result< + Builder, HttpClient, RedirectUrl>, + Error, + > { let end_session_endpoint = provider_metadata .additional_metadata() .end_session_endpoint @@ -208,16 +176,18 @@ impl Builder Builder, - ) -> Result, HttpClient>, Error> - { + ) -> Result< + Builder, HttpClient, RedirectUrl>, + Error, + > { let issuer_url = IssuerUrl::new(issuer.into().to_string())?; let http_client = self.http_client.0.clone(); let provider_metadata = ProviderMetadata::discover_async(issuer_url, &http_client); @@ -237,17 +209,15 @@ impl Builder - Builder, HttpClient> + Builder, HttpClient, RedirectUrl> { /// create oidc client pub fn build(self) -> OidcClient { OidcClient { scopes: self.scopes, - oidc_request_parameters: self.oidc_request_parameters, client_id: self.credentials.id, client: self.client.0, http_client: self.http_client.0, - application_base_url: self.application_base_url.0, end_session_endpoint: self.end_session_endpoint, auth_context_class: self.auth_context_class, } diff --git a/src/error.rs b/src/error.rs index 0bd1cdc..1bd47d5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -72,6 +72,45 @@ pub enum MiddlewareError { AuthMiddlewareNotFound, } +#[derive(Debug, Error)] +pub enum HandlerError { + #[error("the redirect handler got accessed without a valid session")] + 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)] pub enum Error { #[error("url parsing: {0:?}")] @@ -93,6 +132,9 @@ pub enum Error { #[error("extractor: {0:?}")] Middleware(#[from] MiddlewareError), + + #[error("handler: {0:?}")] + Handler(#[from] HandlerError), } impl IntoResponse for ExtractorError { @@ -124,3 +166,11 @@ impl IntoResponse for MiddlewareError { } } } + +impl IntoResponse for HandlerError { + fn into_response(self) -> axum_core::response::Response { + match self { + _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response(), + } + } +} diff --git a/src/handler.rs b/src/handler.rs new file mode 100644 index 0000000..c3bbd95 --- /dev/null +++ b/src/handler.rs @@ -0,0 +1,102 @@ +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, +} + +pub async fn handle_oidc_redirect( + session: Session, + Extension(oidcclient): Extension>, + Query(query): Query, +) -> Result { + 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 + + if login_session.csrf_token.secret() != &query.state { + return Err(HandlerError::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(&oidcclient.http_client) + .await?; + + // 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(); + let claims = id_token.claims(&id_token_verifier, &login_session.nonce)?; + + validate_access_token_hash( + id_token, + id_token_verifier, + token_response.access_token(), + claims, + )?; + + login_session.authenticated = Some(AuthenticatedSession { + id_token: id_token.clone(), + access_token: token_response.access_token().clone(), + }); + let refresh_token = token_response.refresh_token().cloned(); + if let Some(refresh_token) = refresh_token { + login_session.refresh_token = Some(refresh_token); + } + + 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 +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 3319875..dc22366 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,9 +21,11 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; pub mod builder; pub mod error; mod extractor; +mod handler; mod middleware; pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}; +pub use handler::handle_oidc_redirect; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; const SESSION_KEY: &str = "axum-oidc"; @@ -100,11 +102,9 @@ pub type BoxError = Box; #[derive(Clone)] pub struct OidcClient { scopes: Vec>, - oidc_request_parameters: Vec>, client_id: Box, client: Client, http_client: reqwest::Client, - application_base_url: Uri, end_session_endpoint: Option, auth_context_class: Option>, } @@ -115,15 +115,6 @@ pub struct EmptyAdditionalClaims {} impl AdditionalClaims for EmptyAdditionalClaims {} impl openidconnect::AdditionalClaims for EmptyAdditionalClaims {} -/// response data of the openid issuer after login -#[derive(Debug, Deserialize)] -struct OidcQuery { - code: String, - state: String, - #[allow(dead_code)] - session_state: Option, -} - /// oidc session #[derive(Serialize, Deserialize, Debug)] #[serde(bound = "AC: Serialize + DeserializeOwned")] @@ -133,6 +124,7 @@ struct OidcSession { pkce_verifier: PkceCodeVerifier, authenticated: Option>, refresh_token: Option, + redirect_url: Box, } #[derive(Serialize, Deserialize, Debug)] diff --git a/src/middleware.rs b/src/middleware.rs index f68e923..5eb14e6 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -3,22 +3,18 @@ use std::{ task::{Context, Poll}, }; -use axum::{ - extract::Query, - response::{IntoResponse, Redirect}, -}; -use axum_core::{extract::FromRequestParts, response::Response}; +use axum::response::{IntoResponse, Redirect}; +use axum_core::response::Response; use futures_util::future::BoxFuture; -use http::{request::Parts, uri::PathAndQuery, Request, Uri}; +use http::{request::Parts, Request}; use tower_layer::Layer; use tower_service::Service; use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, - AccessToken, AccessTokenHash, AuthenticationContextClass, AuthorizationCode, CsrfToken, - IdTokenClaims, IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, - PkceCodeVerifier, RedirectUrl, RefreshToken, + AccessToken, AccessTokenHash, AuthenticationContextClass, CsrfToken, IdTokenClaims, + IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; @@ -27,7 +23,7 @@ use crate::{ error::MiddlewareError, extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, - OidcQuery, OidcSession, SESSION_KEY, + OidcSession, SESSION_KEY, }; /// Layer for the [`OidcLoginMiddleware`]. @@ -106,117 +102,53 @@ where } else { // no valid id token or refresh token was found and the user has to login Box::pin(async move { - let (mut parts, _) = request.into_parts(); + let (parts, _) = request.into_parts(); - let mut oidcclient: OidcClient = parts + let oidcclient: OidcClient = parts .extensions .get() .cloned() .ok_or(MiddlewareError::AuthMiddlewareNotFound)?; - let query = Query::::from_request_parts(&mut parts, &()) - .await - .ok(); - let session = parts .extensions .get::() .ok_or(MiddlewareError::SessionNotFound)?; - let login_session: Option> = session - .get(SESSION_KEY) - .await - .map_err(MiddlewareError::from)?; - let handler_uri = strip_oidc_from_path( - oidcclient.application_base_url.clone(), - &parts.uri, - &oidcclient.oidc_request_parameters, - )?; + // generate a login url and redirect the user to it - oidcclient.client = oidcclient - .client - .set_redirect_uri(RedirectUrl::new(handler_uri.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, + ); - 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); + for scope in oidcclient.scopes.iter() { + auth = auth.add_scope(Scope::new(scope.to_string())); } - 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?; - - // 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 = oidcclient.client.id_token_verifier(); - let claims = id_token.claims(&id_token_verifier, &login_session.nonce)?; - - validate_access_token_hash( - id_token, - id_token_verifier, - token_response.access_token(), - claims, - )?; - - login_session.authenticated = Some(AuthenticatedSession { - id_token: id_token.clone(), - access_token: token_response.access_token().clone(), - }); - let refresh_token = token_response.refresh_token().cloned(); - if let Some(refresh_token) = refresh_token { - login_session.refresh_token = Some(refresh_token); + if let Some(acr) = oidcclient.auth_context_class { + auth = auth + .add_auth_context_value(AuthenticationContextClass::new(acr.into())); } - session.insert(SESSION_KEY, login_session).await?; + auth.set_pkce_challenge(pkce_challenge).url() + }; - Ok(Redirect::temporary(&handler_uri.to_string()).into_response()) - } else { - // generate a login url and redirect the user to it + let oidc_session = OidcSession:: { + nonce, + csrf_token, + pkce_verifier, + authenticated: None, + refresh_token: None, + redirect_url: parts.uri.to_string().into(), + }; - 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, - ); + session.insert(SESSION_KEY, oidc_session).await?; - 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(AuthenticationContextClass::new( - acr.into(), - )); - } - - auth.set_pkce_challenge(pkce_challenge).url() - }; - - let oidc_session = OidcSession:: { - nonce, - csrf_token, - pkce_verifier, - authenticated: None, - refresh_token: None, - }; - - session.insert(SESSION_KEY, oidc_session).await?; - - Ok(Redirect::temporary(auth_url.as_str()).into_response()) - } + Ok(Redirect::to(auth_url.as_str()).into_response()) }) } } @@ -291,7 +223,7 @@ 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(); @@ -305,16 +237,6 @@ where .await .map_err(MiddlewareError::from)?; - let handler_uri = strip_oidc_from_path( - oidcclient.application_base_url.clone(), - &parts.uri, - &oidcclient.oidc_request_parameters, - )?; - - 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 @@ -329,6 +251,7 @@ where // stored id token is valid and can be used insert_extensions(&mut parts, claims.clone(), &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, authenticated_session, refresh_token)) = try_refresh_token(&oidcclient, refresh_token, &login_session.nonce).await? { @@ -370,41 +293,6 @@ where } } -/// Helper function to remove the OpenID Connect authentication response query attributes from a -/// [`Uri`]. -pub fn strip_oidc_from_path( - base_url: Uri, - uri: &Uri, - filter: &[Box], -) -> 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() - .map(|uri| { - uri.split('&') - .filter(|x| filter.iter().all(|y| !x.starts_with(y.as_ref()))) - .fold(String::default(), |mut acc, x| { - if !acc.is_empty() { - acc += "&"; - } else { - acc += "?"; - } - acc += x; - acc - }) - }) - .unwrap_or_default(); - PathAndQuery::from_maybe_shared(format!("{}{}", path_and_query.path(), query)) - }) - .transpose()?; - - Ok(Uri::from_parts(base_url)?) -} - /// insert all extensions that are used by the extractors fn insert_extensions( parts: &mut Parts, From fa5faed004458d65f9468d559f09d07238fafba5 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 18 Apr 2025 15:42:16 +0200 Subject: [PATCH 23/37] fix: issuer URL at / path Previously the issuer url was received as an http::Uri. When it was converted into a String, a `/` was appended which caused the IssuerURL validation check in openidconnect to fail. Now the IssuerURL is received as a String. --- examples/basic/src/lib.rs | 6 +----- src/builder.rs | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index 4c3a7e6..38a8457 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -42,11 +42,7 @@ pub async fn run( if let Some(client_secret) = client_secret { oidc_client = oidc_client.with_client_secret(client_secret); } - let oidc_client = oidc_client - .discover(Uri::from_maybe_shared(issuer).expect("valid issuer URI")) - .await - .unwrap() - .build(); + let oidc_client = oidc_client.discover(issuer).await.unwrap().build(); let oidc_auth_service = ServiceBuilder::new() .layer(HandleErrorLayer::new(|e: MiddlewareError| async { diff --git a/src/builder.rs b/src/builder.rs index 4e75080..c04c58f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -195,12 +195,12 @@ impl Builder, + issuer: String, ) -> Result< Builder, HttpClient, RedirectUrl>, Error, > { - let issuer_url = IssuerUrl::new(issuer.into().to_string())?; + let issuer_url = IssuerUrl::new(issuer)?; let http_client = self.http_client.0.clone(); let provider_metadata = ProviderMetadata::discover_async(issuer_url, &http_client); From 65cb17560304ab31d342ebdb37db6cf4ed3aae99 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Fri, 18 Apr 2025 16:10:16 +0200 Subject: [PATCH 24/37] test: fix chromium-headless and issuer_url fix for #25 --- examples/basic/Cargo.toml | 2 ++ examples/basic/src/lib.rs | 7 +------ examples/basic/src/main.rs | 3 +-- examples/basic/tests/integration.rs | 3 +-- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index c5dcf7f..bf2562f 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -21,3 +21,5 @@ reqwest = { version = "0.12", features = ["rustls-tls"], default-features = fals env_logger = "0.11" log = "0.4" headless_chrome = "1.0" +#see https://github.com/rust-headless-chrome/rust-headless-chrome/issues/535 +auto_generate_cdp = "=0.4.4" diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index 38a8457..96593ee 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -16,12 +16,7 @@ use tower_sessions::{ Expiry, MemoryStore, SessionManagerLayer, }; -pub async fn run( - app_url: String, - issuer: String, - client_id: String, - client_secret: Option, -) { +pub async fn run(issuer: String, client_id: String, client_secret: Option) { let session_store = MemoryStore::default(); let session_layer = SessionManagerLayer::new(session_store) .with_secure(false) diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 6252c87..0456d55 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -2,9 +2,8 @@ use basic::run; #[tokio::main] async fn main() { dotenvy::dotenv().ok(); - let app_url = std::env::var("APP_URL").expect("APP_URL env variable"); let issuer = std::env::var("ISSUER").expect("ISSUER env variable"); let client_id = std::env::var("CLIENT_ID").expect("CLIENT_ID env variable"); let client_secret = std::env::var("CLIENT_SECRET").ok(); - run(app_url, issuer, client_id, client_secret).await + run(issuer, client_id, client_secret).await } diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs index 2d8b89b..8676797 100644 --- a/examples/basic/tests/integration.rs +++ b/examples/basic/tests/integration.rs @@ -31,9 +31,8 @@ async fn first() { info!("starting basic example app"); - let app_url = "http://127.0.0.1:8080/"; + let app_url = "http://localhost:8080/"; let app_handle = tokio::spawn(basic::run( - app_url.to_string(), format!("{}/realms/test", keycloak.url()), basic_client.client_id.to_string(), basic_client.client_secret.clone(), From bacab1c93924c53b81e5a9e253aba13edc7e8781 Mon Sep 17 00:00:00 2001 From: pfzetto Date: Thu, 6 Nov 2025 18:44:10 +0100 Subject: [PATCH 25/37] fix: #34 optional nonce in ID token refresh Only verify nonce in token request response if one was given. --- src/middleware.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/middleware.rs b/src/middleware.rs index 5eb14e6..0eddfa4 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -14,7 +14,7 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, AccessToken, AccessTokenHash, AuthenticationContextClass, CsrfToken, IdTokenClaims, - IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + IdTokenVerifier, Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, }; @@ -367,7 +367,12 @@ async fn try_refresh_token( .id_token() .ok_or(MiddlewareError::IdTokenMissing)?; let id_token_verifier = client.client.id_token_verifier(); - let claims = id_token.claims(&id_token_verifier, nonce)?; + 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, From 861cb70cee01699d3094e0169e325cdd9f20ff97 Mon Sep 17 00:00:00 2001 From: pfzetto Date: Thu, 6 Nov 2025 18:56:33 +0100 Subject: [PATCH 26/37] fix: #32 use OriginalUri for redirect_url --- Cargo.toml | 2 +- src/error.rs | 3 +++ src/middleware.rs | 17 +++++++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 52e1353..29a0ba6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ keywords = [ "axum", "oidc", "openidconnect", "authentication" ] [dependencies] thiserror = "2.0" axum-core = "0.5" -axum = { version = "0.8", default-features = false, features = [ "query" ] } +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" ] } diff --git a/src/error.rs b/src/error.rs index 1bd47d5..537d41a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -70,6 +70,9 @@ pub enum MiddlewareError { #[error("auth middleware not found")] AuthMiddlewareNotFound, + + #[error("original url not found")] + OriginalUrlNotFound, } #[derive(Debug, Error)] diff --git a/src/middleware.rs b/src/middleware.rs index 0eddfa4..5d0deab 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -3,7 +3,10 @@ use std::{ task::{Context, Poll}, }; -use axum::response::{IntoResponse, Redirect}; +use axum::{ + extract::OriginalUri, + response::{IntoResponse, Redirect}, +}; use axum_core::response::Response; use futures_util::future::BoxFuture; use http::{request::Parts, Request}; @@ -115,6 +118,16 @@ where .get::() .ok_or(MiddlewareError::SessionNotFound)?; + let redirect_url = parts + .extensions + .get::() + .ok_or(MiddlewareError::OriginalUrlNotFound)?; + + let redirect_url = if let Some(query) = redirect_url.query() { + redirect_url.path().to_string() + "?" + query + } else { + redirect_url.path().to_string() + }; // generate a login url and redirect the user to it let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -143,7 +156,7 @@ where pkce_verifier, authenticated: None, refresh_token: None, - redirect_url: parts.uri.to_string().into(), + redirect_url: redirect_url.into(), }; session.insert(SESSION_KEY, oidc_session).await?; From af03d32d1cb8d9c60245c96703e476dc200c1215 Mon Sep 17 00:00:00 2001 From: Paul Zinselmeyer Date: Thu, 20 Nov 2025 14:55:53 +0100 Subject: [PATCH 27/37] License change from LGPLv3 to MPLv2 (#30) LGPLv3 is the wrong choice for rust programs due to rusts transitive recompilation requirements. MPLv2 captures the same 'spirit' but allow transitive recompilation --- Cargo.toml | 2 +- LICENSE.txt | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 LICENSE.txt diff --git a/Cargo.toml b/Cargo.toml index 29a0ba6..84d25d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" authors = [ "Paul Z " ] readme = "README.md" repository = "https://github.com/pfz4/axum-oidc" -license = "LGPL-3.0-or-later" +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 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d0a1fa1 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index ca5dee8..d7d6049 100644 --- a/README.md +++ b/README.md @@ -31,5 +31,5 @@ Feel free to submit feature requests and bug reports using a GitHub Issue. PR's are also appreciated. # License -This Library is licensed under [LGPLv3](https://www.gnu.org/licenses/lgpl-3.0.en.html). +This Library is licensed under [MPLv2](https://www.mozilla.org/en-US/MPL/2.0/). From c5e83655bca9e9f5f2dfccc84ba097bb809c3929 Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:56:06 +0100 Subject: [PATCH 28/37] set refresh_token to None when ClearSessionFlag is set --- src/middleware.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/middleware.rs b/src/middleware.rs index 5d0deab..4f66352 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -298,6 +298,7 @@ where let has_logout_ext = response.extensions().get::().is_some(); if let (true, Some(mut login_session)) = (has_logout_ext, login_session) { login_session.authenticated = None; + login_session.refresh_token = None; session.insert(SESSION_KEY, login_session).await?; } From 992cdb8ef93b4a161692f1da09a7e3121303d896 Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Fri, 21 Nov 2025 11:49:29 +0100 Subject: [PATCH 29/37] use openidconnect ClientId and ClientSecret directly instead of Box --- examples/basic/src/lib.rs | 8 ++++---- src/builder.rs | 13 +++++-------- src/extractor.rs | 4 ++-- src/lib.rs | 3 ++- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs index 96593ee..8f7bbc2 100644 --- a/examples/basic/src/lib.rs +++ b/examples/basic/src/lib.rs @@ -6,8 +6,8 @@ use axum::{ Router, }; use axum_oidc::{ - error::MiddlewareError, handle_oidc_redirect, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, - OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, + error::MiddlewareError, handle_oidc_redirect, ClientId, ClientSecret, EmptyAdditionalClaims, + OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, }; use tokio::net::TcpListener; use tower::ServiceBuilder; @@ -33,9 +33,9 @@ pub async fn run(issuer: String, client_id: String, client_secret: Option::builder() .with_default_http_client() .with_redirect_url(Uri::from_static("http://localhost:8080/oidc")) - .with_client_id(client_id); + .with_client_id(ClientId::new(client_id)); if let Some(client_secret) = client_secret { - oidc_client = oidc_client.with_client_secret(client_secret); + oidc_client = oidc_client.with_client_secret(ClientSecret::new(client_secret)); } let oidc_client = oidc_client.discover(issuer).await.unwrap().build(); diff --git a/src/builder.rs b/src/builder.rs index c04c58f..6df0d77 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -11,8 +11,8 @@ pub struct HttpClient(reqwest::Client); pub struct RedirectUrl(Uri); pub struct ClientCredentials { - id: Box, - secret: Option>, + id: ClientId, + secret: Option, } pub struct Builder { @@ -77,7 +77,7 @@ impl Builder>, + id: impl Into, ) -> Builder { Builder::<_, _, _, _, _> { credentials: ClientCredentials { @@ -97,7 +97,7 @@ impl Builder Builder { /// set client secret for authentication with issuer - pub fn with_client_secret(mut self, secret: impl Into>) -> Self { + pub fn with_client_secret(mut self, secret: impl Into) -> Self { self.credentials.secret = Some(secret.into()); self } @@ -172,10 +172,7 @@ impl Builder for OidcAccessToken { pub struct OidcRpInitiatedLogout { pub(crate) end_session_endpoint: Uri, pub(crate) id_token_hint: Box, - pub(crate) client_id: Box, + pub(crate) client_id: ClientId, pub(crate) post_logout_redirect_uri: Option, pub(crate) state: Option, } diff --git a/src/lib.rs b/src/lib.rs index dc22366..5251088 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ mod middleware; pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}; pub use handler::handle_oidc_redirect; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; +pub use openidconnect::{Audience, ClientId, ClientSecret}; const SESSION_KEY: &str = "axum-oidc"; @@ -102,7 +103,7 @@ pub type BoxError = Box; #[derive(Clone)] pub struct OidcClient { scopes: Vec>, - client_id: Box, + client_id: ClientId, client: Client, http_client: reqwest::Client, end_session_endpoint: Option, From 00136320a92bc5f1bd767ddc92d0efff6f28aae1 Mon Sep 17 00:00:00 2001 From: pfzetto Date: Fri, 21 Nov 2025 13:47:37 +0100 Subject: [PATCH 30/37] removed integration test from `examples/basic` Integrations tests will be re-implemented on the main-crate. See #20 and #35. --- .github/workflows/ci.yml | 16 ++- examples/basic/Cargo.toml | 13 -- examples/basic/README.md | 2 - examples/basic/src/lib.rs | 84 ------------- examples/basic/src/main.rs | 87 ++++++++++++- examples/basic/tests/integration.rs | 94 -------------- examples/basic/tests/keycloak.rs | 188 ---------------------------- 7 files changed, 94 insertions(+), 390 deletions(-) delete mode 100644 examples/basic/src/lib.rs delete mode 100644 examples/basic/tests/integration.rs delete mode 100644 examples/basic/tests/keycloak.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee27b5e..b64f3dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,8 @@ env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: axum-oidc - latest + test: + name: axum-oidc runs-on: ubuntu-latest strategy: matrix: @@ -24,13 +24,17 @@ jobs: - run: cargo build --verbose --release - run: cargo test --verbose --release - build_and_test_examples: - name: axum-oidc - examples + test_basic_example: + name: axum-oidc - basic runs-on: ubuntu-latest + strategy: + matrix: + toolchain: + - stable + - nightly steps: - uses: actions/checkout@v3 - - run: sudo apt install chromium-browser -y - - run: rustup update stable && rustup default stable + - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - run: cargo build --verbose --release working-directory: ./examples/basic - run: cargo test --verbose --release diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index bf2562f..88426a4 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -3,23 +3,10 @@ name = "basic" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] tokio = { version = "1.43", features = ["net", "macros", "rt-multi-thread"] } axum = { version = "0.8", features = [ "macros" ]} axum-oidc = { path = "./../.." } tower = "0.5" tower-sessions = "0.14" - dotenvy = "0.15" - -[dev-dependencies] -testcontainers = "0.23" -tokio = { version = "1.43", features = ["rt-multi-thread"] } -reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } -env_logger = "0.11" -log = "0.4" -headless_chrome = "1.0" -#see https://github.com/rust-headless-chrome/rust-headless-chrome/issues/535 -auto_generate_cdp = "=0.4.4" diff --git a/examples/basic/README.md b/examples/basic/README.md index 4011a45..30cdf2f 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -8,8 +8,6 @@ It has three endpoints: ## Dependencies You will need a running OpenID Connect capable issuer like [Keycloak](https://www.keycloak.org/getting-started/getting-started-docker) and a valid client for the issuer. -You can take a look at the `tests/`-folder to see how the automated keycloak deployment for the integration tests work. - ## Setup Environment Create a `.env`-file that contains the following keys: ``` diff --git a/examples/basic/src/lib.rs b/examples/basic/src/lib.rs deleted file mode 100644 index 8f7bbc2..0000000 --- a/examples/basic/src/lib.rs +++ /dev/null @@ -1,84 +0,0 @@ -use axum::{ - error_handling::HandleErrorLayer, - http::Uri, - response::IntoResponse, - routing::{any, get}, - Router, -}; -use axum_oidc::{ - error::MiddlewareError, handle_oidc_redirect, ClientId, ClientSecret, EmptyAdditionalClaims, - OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, -}; -use tokio::net::TcpListener; -use tower::ServiceBuilder; -use tower_sessions::{ - cookie::{time::Duration, SameSite}, - Expiry, MemoryStore, SessionManagerLayer, -}; - -pub async fn run(issuer: String, client_id: String, client_secret: Option) { - let session_store = MemoryStore::default(); - let session_layer = SessionManagerLayer::new(session_store) - .with_secure(false) - .with_same_site(SameSite::Lax) - .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); - - let oidc_login_service = ServiceBuilder::new() - .layer(HandleErrorLayer::new(|e: MiddlewareError| async { - 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)); - if let Some(client_secret) = client_secret { - oidc_client = oidc_client.with_client_secret(ClientSecret::new(client_secret)); - } - let oidc_client = oidc_client.discover(issuer).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); - - let listener = TcpListener::bind("[::]:8080").await.unwrap(); - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); -} - -async fn authenticated(claims: OidcClaims) -> impl IntoResponse { - format!("Hello {}", claims.subject().as_str()) -} - -#[axum::debug_handler] -async fn maybe_authenticated( - claims: Result, axum_oidc::error::ExtractorError>, -) -> impl IntoResponse { - if let Ok(claims) = claims { - format!( - "Hello {}! You are already logged in from another Handler.", - claims.subject().as_str() - ) - } else { - "Hello anon!".to_string() - } -} - -async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { - logout.with_post_logout_redirect(Uri::from_static("https://example.com")) -} diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 0456d55..c22816d 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,9 +1,90 @@ -use basic::run; +use axum::{ + error_handling::HandleErrorLayer, + http::Uri, + response::IntoResponse, + routing::{any, get}, + Router, +}; +use axum_oidc::{ + error::MiddlewareError, handle_oidc_redirect, ClientId, ClientSecret, EmptyAdditionalClaims, + OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, +}; +use tokio::net::TcpListener; +use tower::ServiceBuilder; +use tower_sessions::{ + cookie::{time::Duration, SameSite}, + Expiry, MemoryStore, SessionManagerLayer, +}; + #[tokio::main] -async fn main() { +pub async fn run() { 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(); - run(issuer, client_id, client_secret).await + + let session_store = MemoryStore::default(); + let session_layer = SessionManagerLayer::new(session_store) + .with_secure(false) + .with_same_site(SameSite::Lax) + .with_expiry(Expiry::OnInactivity(Duration::seconds(120))); + + let oidc_login_service = ServiceBuilder::new() + .layer(HandleErrorLayer::new(|e: MiddlewareError| async { + dbg!(&e); + e.into_response() + })) + .layer(OidcLoginLayer::::new()); + + let mut oidc_client = OidcClient::::builder() + .with_default_http_client() + .with_redirect_url(Uri::from_static("http://localhost:8080/oidc")) + .with_client_id(ClientId::new(client_id)); + if let Some(client_secret) = client_secret { + oidc_client = oidc_client.with_client_secret(ClientSecret::new(client_secret)); + } + let oidc_client = oidc_client.discover(issuer).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); + + let listener = TcpListener::bind("[::]:8080").await.unwrap(); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +async fn authenticated(claims: OidcClaims) -> impl IntoResponse { + format!("Hello {}", claims.subject().as_str()) +} + +#[axum::debug_handler] +async fn maybe_authenticated( + claims: Result, axum_oidc::error::ExtractorError>, +) -> impl IntoResponse { + if let Ok(claims) = claims { + format!( + "Hello {}! You are already logged in from another Handler.", + claims.subject().as_str() + ) + } else { + "Hello anon!".to_string() + } +} + +async fn logout(logout: OidcRpInitiatedLogout) -> impl IntoResponse { + logout.with_post_logout_redirect(Uri::from_static("https://example.com")) } diff --git a/examples/basic/tests/integration.rs b/examples/basic/tests/integration.rs deleted file mode 100644 index 8676797..0000000 --- a/examples/basic/tests/integration.rs +++ /dev/null @@ -1,94 +0,0 @@ -mod keycloak; - -use headless_chrome::Browser; -use log::info; - -use crate::keycloak::{Client, Keycloak, Realm, User}; - -#[tokio::test(flavor = "multi_thread")] -async fn first() { - env_logger::init(); - - let alice = User { - username: "alice".to_string(), - email: "alice@example.com".to_string(), - firstname: "alice".to_string(), - lastname: "doe".to_string(), - password: "alice".to_string(), - }; - - let basic_client = Client { - client_id: "axum-oidc-example-basic".to_string(), - client_secret: Some("123456".to_string()), - }; - - let keycloak = Keycloak::start(vec![Realm { - name: "test".to_string(), - users: vec![alice.clone()], - clients: vec![basic_client.clone()], - }]) - .await; - - info!("starting basic example app"); - - let app_url = "http://localhost:8080/"; - let app_handle = tokio::spawn(basic::run( - format!("{}/realms/test", keycloak.url()), - basic_client.client_id.to_string(), - basic_client.client_secret.clone(), - )); - - info!("starting browser"); - - let browser = Browser::default().unwrap(); - let tab = browser.new_tab().unwrap(); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert_eq!(body, "Hello anon!"); - - tab.navigate_to(&format!("{}foo", app_url)).unwrap(); - let username = tab.wait_for_xpath(r#"//*[@id="username"]"#).unwrap(); - username.type_into(&alice.username).unwrap(); - let password = tab.wait_for_xpath(r#"//*[@id="password"]"#).unwrap(); - password.type_into(&alice.password).unwrap(); - let submit = tab.wait_for_xpath(r#"//*[@id="kc-login"]"#).unwrap(); - submit.click().unwrap(); - - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert!(body.starts_with("Hello ") && body.contains('-')); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert!(body.contains("! You are already logged in from another Handler.")); - - tab.navigate_to(&format!("{}logout", app_url)).unwrap(); - tab.wait_until_navigated().unwrap(); - - tab.navigate_to(&format!("{}bar", app_url)).unwrap(); - let body = tab - .wait_for_xpath(r#"/html/body/pre"#) - .unwrap() - .get_inner_text() - .unwrap(); - assert_eq!(body, "Hello anon!"); - - tab.navigate_to(&format!("{}foo", app_url)).unwrap(); - tab.wait_until_navigated().unwrap(); - tab.find_element_by_xpath(r#"//*[@id="username"]"#).unwrap(); - - tab.close(true).unwrap(); - app_handle.abort(); -} diff --git a/examples/basic/tests/keycloak.rs b/examples/basic/tests/keycloak.rs deleted file mode 100644 index d06a5d6..0000000 --- a/examples/basic/tests/keycloak.rs +++ /dev/null @@ -1,188 +0,0 @@ -use log::info; -use std::time::Duration; -use testcontainers::runners::AsyncRunner; -use testcontainers::ContainerAsync; - -use testcontainers::core::ExecCommand; -use testcontainers::{core::WaitFor, Image, ImageExt}; - -struct KeycloakImage; - -impl Image for KeycloakImage { - fn name(&self) -> &str { - "quay.io/keycloak/keycloak" - } - - fn tag(&self) -> &str { - "latest" - } - - fn ready_conditions(&self) -> Vec { - vec![] - } -} - -pub struct Keycloak { - container: ContainerAsync, - realms: Vec, - url: String, -} - -#[derive(Clone)] -pub struct Realm { - pub name: String, - pub clients: Vec, - pub users: Vec, -} - -#[derive(Clone)] -pub struct Client { - pub client_id: String, - pub client_secret: Option, -} - -#[derive(Clone)] -pub struct User { - pub username: String, - pub email: String, - pub firstname: String, - pub lastname: String, - pub password: String, -} - -impl Keycloak { - pub async fn start(realms: Vec) -> Keycloak { - info!("starting keycloak"); - - let keycloak_image = KeycloakImage - .with_cmd(["start-dev".to_string()]) - .with_env_var("KEYCLOAK_ADMIN", "admin") - .with_env_var("KEYCLOAK_ADMIN_PASSWORD", "admin"); - let container = keycloak_image.start().await.unwrap(); - - let keycloak = Self { - url: format!( - "http://127.0.0.1:{}", - container.get_host_port_ipv4(8080).await.unwrap() - ), - container, - realms, - }; - - let issuer = format!( - "http://127.0.0.1:{}/realms/{}", - keycloak.container.get_host_port_ipv4(8080).await.unwrap(), - "test" - ); - - while reqwest::get(&issuer).await.is_err() { - tokio::time::sleep(Duration::from_secs(1)).await; - } - - keycloak.execute("/opt/keycloak/bin/kcadm.sh config credentials --server http://127.0.0.1:8080 --realm master --user admin --password admin".to_string()).await; - - for realm in keycloak.realms.iter() { - keycloak.create_realm(&realm.name).await; - for client in realm.clients.iter() { - keycloak - .create_client( - &client.client_id, - client.client_secret.as_deref(), - &realm.name, - ) - .await; - } - for user in realm.users.iter() { - keycloak - .create_user( - &user.username, - &user.email, - &user.firstname, - &user.lastname, - &user.password, - &realm.name, - ) - .await; - } - } - - keycloak - } - - pub fn url(&self) -> &str { - &self.url - } - - async fn create_realm(&self, name: &str) { - self.execute(format!( - "/opt/keycloak/bin/kcadm.sh create realms -s realm={} -s enabled=true", - name - )) - .await; - } - - async fn create_client(&self, client_id: &str, client_secret: Option<&str>, realm: &str) { - if let Some(client_secret) = client_secret { - self.execute(format!( - r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF - {{ - "clientId": "{}", - "secret": "{}", - "redirectUris": ["*"] - }} - EOF - "#, - realm, client_id, client_secret - )) - .await; - } else { - self.execute(format!( - r#"/opt/keycloak/bin/kcadm.sh create clients -r {} -f - << EOF - {{ - "clientId": "{}", - "redirectUris": ["*"] - }} - EOF - "#, - realm, client_id - )) - .await; - } - } - - async fn create_user( - &self, - username: &str, - email: &str, - firstname: &str, - lastname: &str, - password: &str, - realm: &str, - ) { - let id = self.execute( - format!( - "/opt/keycloak/bin/kcadm.sh create users -r {} -s username={} -s enabled=true -s emailVerified=true -s email={} -s firstName={} -s lastName={}", - realm, username, email, firstname, lastname - ), - ) - .await; - self.execute(format!( - "/opt/keycloak/bin/kcadm.sh set-password -r {} --username {} --new-password {}", - realm, username, password - )) - .await; - id - } - - async fn execute(&self, cmd: String) { - let mut result = self - .container - .exec(ExecCommand::new( - ["/bin/sh", "-c", cmd.as_str()].iter().copied(), - )) - .await - .unwrap(); - // collect stdout to wait until command completion - let _output = String::from_utf8(result.stdout_to_vec().await.unwrap()); - } -} From 6280ad62cc2305316ad32acc0860019b8af626a2 Mon Sep 17 00:00:00 2001 From: pfzetto Date: Fri, 21 Nov 2025 13:51:37 +0100 Subject: [PATCH 31/37] fix: repair `examples/basic` --- examples/basic/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index c22816d..c7f6831 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -17,7 +17,7 @@ use tower_sessions::{ }; #[tokio::main] -pub async fn run() { +pub async fn main() { 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"); From 094e9e5ff6e81c21d20d944ce1339a8cffc14ff0 Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Mon, 24 Nov 2025 11:18:54 +0100 Subject: [PATCH 32/37] add UserInfoClaims, add untrusted_audiences, add tracing --- Cargo.toml | 16 ++++++--- examples/basic/Cargo.toml | 12 ++++--- examples/basic/src/main.rs | 23 +++++++++--- src/builder.rs | 23 +++++++++++- src/error.rs | 25 +++++++------ src/extractor.rs | 55 +++++++++++++++++++++++++++-- src/handler.rs | 24 +++++++++++-- src/lib.rs | 3 +- src/middleware.rs | 72 ++++++++++++++++++++++++++++++-------- 9 files changed, 210 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 84d25d3..ba70915 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,24 +3,30 @@ name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" version = "0.6.0" edition = "2021" -authors = [ "Paul Z " ] +authors = ["Paul Z "] readme = "README.md" repository = "https://github.com/pfz4/axum-oidc" license = "MPL-2.0" -keywords = [ "axum", "oidc", "openidconnect", "authentication" ] +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" ] } +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.2" +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.12", default-features = false } urlencoding = "2.1" +tracing = "0.1.41" diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 88426a4..86467c3 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -1,12 +1,16 @@ [package] +edition = "2024" name = "basic" version = "0.1.0" -edition = "2021" [dependencies] -tokio = { version = "1.43", features = ["net", "macros", "rt-multi-thread"] } -axum = { version = "0.8", features = [ "macros" ]} +axum = { version = "0.8", features = ["macros"] } axum-oidc = { path = "./../.." } +dotenvy = "0.15" +openidconnect = "4.0.1" +tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] } tower = "0.5" tower-sessions = "0.14" -dotenvy = "0.15" +tracing-subscriber = "0.3.20" +tracing = "0.1.41" +serde = "1.0.228" diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index c7f6831..8c76841 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -6,8 +6,9 @@ use axum::{ Router, }; use axum_oidc::{ - error::MiddlewareError, handle_oidc_redirect, ClientId, ClientSecret, EmptyAdditionalClaims, - OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, + error::MiddlewareError, handle_oidc_redirect, Audience, ClientId, ClientSecret, + EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, + OidcRpInitiatedLogout, }; use tokio::net::TcpListener; use tower::ServiceBuilder; @@ -15,9 +16,15 @@ use tower_sessions::{ cookie::{time::Duration, SameSite}, Expiry, MemoryStore, SessionManagerLayer, }; +use tracing::Level; #[tokio::main] -pub async fn 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"); @@ -39,7 +46,12 @@ pub async fn main() { 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)); + .with_client_id(ClientId::new(client_id)) + .add_scope("profile") + .add_scope("email") + // 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)); } @@ -61,6 +73,9 @@ pub async fn main() { .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 diff --git a/src/builder.rs b/src/builder.rs index 6df0d77..be6c733 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,7 +1,7 @@ use std::marker::PhantomData; use http::Uri; -use openidconnect::{ClientId, ClientSecret, IssuerUrl}; +use openidconnect::{Audience, ClientId, ClientSecret, IssuerUrl}; use crate::{error::Error, AdditionalClaims, Client, OidcClient, ProviderMetadata}; @@ -23,6 +23,7 @@ pub struct Builder, scopes: Vec>, auth_context_class: Option>, + untrusted_audiences: Vec, _ac: PhantomData, } @@ -42,6 +43,7 @@ impl Builder { end_session_endpoint: None, scopes: vec![Box::from("openid")], auth_context_class: None, + untrusted_audiences: Vec::new(), _ac: PhantomData, } } @@ -60,6 +62,7 @@ impl Builder>>) -> Self { self.scopes = scopes.map(|x| x.into()).collect::>(); @@ -71,6 +74,18 @@ impl Builder 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 { @@ -90,6 +105,7 @@ impl Builder Builder Builder Builder Builder 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 537d41a..071e911 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,6 +41,14 @@ pub enum MiddlewareError { #[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), @@ -77,7 +85,7 @@ pub enum MiddlewareError { #[derive(Debug, Error)] pub enum HandlerError { - #[error("the redirect handler got accessed without a valid session")] + #[error("redirect handler accessed without valid session, session cookie missing?")] RedirectedWithoutSession, #[error("csrf token invalid")] @@ -156,24 +164,21 @@ impl IntoResponse for ExtractorError { impl IntoResponse for Error { fn into_response(self) -> axum_core::response::Response { - 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 { - 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 { - 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() } } diff --git a/src/extractor.rs b/src/extractor.rs index aeb2ef2..dbb6482 100644 --- a/src/extractor.rs +++ b/src/extractor.rs @@ -7,12 +7,12 @@ use axum_core::{ response::IntoResponse, }; use http::{request::Parts, uri::PathAndQuery, Uri}; -use openidconnect::{core::CoreGenderClaim, ClientId, IdTokenClaims}; +use openidconnect::{core::CoreGenderClaim, ClientId, IdTokenClaims, UserInfoClaims}; /// Extractor for the OpenID Connect Claims. /// /// This Extractor will only return the Claims when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct OidcClaims(pub IdTokenClaims); impl FromRequestParts for OidcClaims @@ -213,3 +213,54 @@ impl IntoResponse for OidcRpInitiatedLogout { } } } + +/// Extractor for the OpenID Connect User Info Claims. +/// +/// This Extractor will only return the User Info Claims when the cached session is valid and [`crate::middleware::OidcAuthMiddleware`] is loaded. +#[derive(Clone, Debug)] +pub struct OidcUserInfo(pub UserInfoClaims); + +impl FromRequestParts for OidcUserInfo +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = ExtractorError; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .ok_or(ExtractorError::Unauthorized) + } +} + +impl OptionalFromRequestParts for OidcUserInfo +where + S: Send + Sync, + AC: AdditionalClaims, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} + +impl Deref for OidcUserInfo { + type Target = UserInfoClaims; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef> for OidcUserInfo +where + AC: AdditionalClaims, +{ + fn as_ref(&self) -> &UserInfoClaims { + &self.0 + } +} diff --git a/src/handler.rs b/src/handler.rs index c3bbd95..68f4793 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -21,11 +21,14 @@ pub struct OidcQuery { 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? @@ -33,10 +36,12 @@ pub async fn handle_oidc_redirect( // 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()))? @@ -47,19 +52,29 @@ pub async fn handle_oidc_redirect( .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(); + 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(), @@ -70,6 +85,10 @@ pub async fn handle_oidc_redirect( 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?; @@ -79,6 +98,7 @@ pub async fn handle_oidc_redirect( /// 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, diff --git a/src/lib.rs b/src/lib.rs index 5251088..fe6aac0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ mod extractor; mod handler; mod middleware; -pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}; +pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}; pub use handler::handle_oidc_redirect; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; pub use openidconnect::{Audience, ClientId, ClientSecret}; @@ -108,6 +108,7 @@ pub struct OidcClient { 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 diff --git a/src/middleware.rs b/src/middleware.rs index 4f66352..c7e0e7f 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -17,14 +17,14 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, AccessToken, AccessTokenHash, AuthenticationContextClass, CsrfToken, IdTokenClaims, - IdTokenVerifier, Nonce, NonceVerifier, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, - Scope, TokenResponse, + Scope, TokenResponse, UserInfoClaims, }; use crate::{ error::MiddlewareError, - extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout}, + extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}, AdditionalClaims, AuthenticatedSession, BoxError, ClearSessionFlag, IdToken, OidcClient, OidcSession, SESSION_KEY, }; @@ -237,6 +237,7 @@ where let inner = self.inner.clone(); let mut inner = std::mem::replace(&mut self.inner, inner); let oidcclient = self.client.clone(); + Box::pin(async move { let (mut parts, body) = request.into_parts(); @@ -254,21 +255,44 @@ where let id_token_claims = login_session.authenticated.as_ref().and_then(|session| { session .id_token - .claims(&oidcclient.client.id_token_verifier(), &login_session.nonce) + .claims( + &oidcclient + .client + .id_token_verifier() + .set_other_audience_verifier_fn(|audience| { + // Return false (reject) if audience is in list of untrusted audiences + !oidcclient.untrusted_audiences.contains(audience) + }), + &login_session.nonce, + ) .ok() .cloned() .map(|claims| (session, claims)) }); if let Some((session, claims)) = id_token_claims { + let user_claims = + get_user_claims(&oidcclient, session.access_token.clone()).await?; // stored id token is valid and can be used - insert_extensions(&mut parts, claims.clone(), &oidcclient, session); + insert_extensions( + &mut parts, + claims.clone(), + user_claims, + &oidcclient, + session, + ); } else if let Some(refresh_token) = login_session.refresh_token.as_ref() { // session is expired but can be refreshed using the refresh_token - if let Some((claims, authenticated_session, refresh_token)) = + if let Some((claims, user_claims, authenticated_session, refresh_token)) = try_refresh_token(&oidcclient, refresh_token, &login_session.nonce).await? { - insert_extensions(&mut parts, claims, &oidcclient, &authenticated_session); + insert_extensions( + &mut parts, + claims, + user_claims.clone(), + &oidcclient, + &authenticated_session, + ); login_session.authenticated = Some(authenticated_session); if let Some(refresh_token) = refresh_token { @@ -311,10 +335,12 @@ where 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(), )); @@ -356,6 +382,19 @@ fn validate_access_token_hash( } } +async fn get_user_claims( + client: &OidcClient, + access_token: AccessToken, +) -> Result, MiddlewareError> { + client + .client + .user_info(access_token, None) + .map_err(MiddlewareError::Configuration)? + .request_async(&client.http_client) + .await + .map_err(|e| e.into()) +} + async fn try_refresh_token( client: &OidcClient, refresh_token: &RefreshToken, @@ -363,6 +402,7 @@ async fn try_refresh_token( ) -> Result< Option<( IdTokenClaims, + UserInfoClaims, AuthenticatedSession, Option, )>, @@ -380,13 +420,13 @@ async fn try_refresh_token( let id_token = token_response .id_token() .ok_or(MiddlewareError::IdTokenMissing)?; - let id_token_verifier = client.client.id_token_verifier(); - let claims = id_token.claims(&id_token_verifier, |claims_nonce: Option<&Nonce>| { - match claims_nonce { - Some(_) => nonce.verify(claims_nonce), - None => Ok(()), - } - })?; + 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, nonce)?; validate_access_token_hash( id_token, @@ -400,8 +440,12 @@ async fn try_refresh_token( 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(), ))) From 542fe66313d4ce9bbb284d9341478755253400ae Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:28:08 +0100 Subject: [PATCH 33/37] re-export openidconnect --- examples/basic/Cargo.toml | 1 - examples/basic/src/main.rs | 2 +- src/lib.rs | 8 ++++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/examples/basic/Cargo.toml b/examples/basic/Cargo.toml index 86467c3..833473d 100644 --- a/examples/basic/Cargo.toml +++ b/examples/basic/Cargo.toml @@ -7,7 +7,6 @@ version = "0.1.0" axum = { version = "0.8", features = ["macros"] } axum-oidc = { path = "./../.." } dotenvy = "0.15" -openidconnect = "4.0.1" tokio = { version = "1.48.0", features = ["macros", "net", "rt-multi-thread"] } tower = "0.5" tower-sessions = "0.14" diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 8c76841..31d8eb0 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -6,7 +6,7 @@ use axum::{ Router, }; use axum_oidc::{ - error::MiddlewareError, handle_oidc_redirect, Audience, ClientId, ClientSecret, + error::MiddlewareError, handle_oidc_redirect, openidconnect::{Audience, ClientId, ClientSecret}, EmptyAdditionalClaims, OidcAuthLayer, OidcClaims, OidcClient, OidcLoginLayer, OidcRpInitiatedLogout, }; diff --git a/src/lib.rs b/src/lib.rs index fe6aac0..4fc8881 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,9 +12,9 @@ use openidconnect::{ CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, EndpointNotSet, EndpointSet, - IdTokenFields, Nonce, PkceCodeVerifier, RefreshToken, StandardErrorResponse, - StandardTokenResponse, + AccessToken, Audience, ClientId, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, + EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, RefreshToken, + StandardErrorResponse, StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -27,7 +27,7 @@ mod middleware; pub use extractor::{OidcAccessToken, OidcClaims, OidcRpInitiatedLogout, OidcUserInfo}; pub use handler::handle_oidc_redirect; pub use middleware::{OidcAuthLayer, OidcAuthMiddleware, OidcLoginLayer, OidcLoginMiddleware}; -pub use openidconnect::{Audience, ClientId, ClientSecret}; +pub use openidconnect; const SESSION_KEY: &str = "axum-oidc"; From 3acdd41a9ab4918d848908515aaaabfe5ac59070 Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:44:39 +0100 Subject: [PATCH 34/37] Use AuthenticationContextClass, IssuerUrl and Scope instead of strings --- src/builder.rs | 27 ++++++++++++++------------- src/lib.rs | 10 +++++----- src/middleware.rs | 7 +++---- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index be6c733..ef6c54f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1,7 +1,9 @@ use std::marker::PhantomData; use http::Uri; -use openidconnect::{Audience, ClientId, ClientSecret, IssuerUrl}; +use openidconnect::{ + Audience, AuthenticationContextClass, ClientId, ClientSecret, IssuerUrl, Scope, +}; use crate::{error::Error, AdditionalClaims, Client, OidcClient, ProviderMetadata}; @@ -21,8 +23,8 @@ pub struct Builder, - scopes: Vec>, - auth_context_class: Option>, + scopes: Vec, + auth_context_class: Option, untrusted_audiences: Vec, _ac: PhantomData, } @@ -41,7 +43,7 @@ impl Builder { http_client: (), redirect_url: (), end_session_endpoint: None, - scopes: vec![Box::from("openid")], + scopes: vec![Scope::new("openid".to_string())], auth_context_class: None, untrusted_audiences: Vec::new(), _ac: PhantomData, @@ -58,20 +60,20 @@ impl OidcClient { impl Builder { /// add a scope to existing (default) scopes - pub fn add_scope(mut self, scope: impl Into>) -> Self { - self.scopes.push(scope.into()); + 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: impl Iterator>>) -> Self { - self.scopes = scopes.map(|x| x.into()).collect::>(); + 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: impl Into>) -> Self { - self.auth_context_class = Some(acr.into()); + pub fn with_auth_context_class(mut self, acr: AuthenticationContextClass) -> Self { + self.auth_context_class = Some(acr); self } @@ -212,14 +214,13 @@ impl Builder Result< Builder, HttpClient, RedirectUrl>, Error, > { - let issuer_url = IssuerUrl::new(issuer)?; let http_client = self.http_client.0.clone(); - let provider_metadata = ProviderMetadata::discover_async(issuer_url, &http_client); + let provider_metadata = ProviderMetadata::discover_async(issuer, &http_client); Self::manual(self, provider_metadata.await?) } diff --git a/src/lib.rs b/src/lib.rs index 4fc8881..bcf783f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,9 +12,9 @@ use openidconnect::{ CoreResponseMode, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreSubjectIdentifierType, CoreTokenIntrospectionResponse, CoreTokenType, }, - AccessToken, Audience, ClientId, CsrfToken, EmptyExtraTokenFields, EndpointMaybeSet, - EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, RefreshToken, - StandardErrorResponse, StandardTokenResponse, + AccessToken, Audience, AuthenticationContextClass, ClientId, CsrfToken, EmptyExtraTokenFields, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IdTokenFields, Nonce, PkceCodeVerifier, + RefreshToken, Scope, StandardErrorResponse, StandardTokenResponse, }; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -102,12 +102,12 @@ pub type BoxError = Box; /// OpenID Connect Client #[derive(Clone)] pub struct OidcClient { - scopes: Vec>, + scopes: Vec, client_id: ClientId, client: Client, http_client: reqwest::Client, end_session_endpoint: Option, - auth_context_class: Option>, + auth_context_class: Option, untrusted_audiences: Vec, } diff --git a/src/middleware.rs b/src/middleware.rs index c7e0e7f..4b881b1 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -16,8 +16,8 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, - AccessToken, AccessTokenHash, AuthenticationContextClass, CsrfToken, IdTokenClaims, - IdTokenVerifier, Nonce, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + AccessToken, AccessTokenHash, CsrfToken, IdTokenClaims, IdTokenVerifier, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, UserInfoClaims, }; @@ -143,8 +143,7 @@ where } if let Some(acr) = oidcclient.auth_context_class { - auth = auth - .add_auth_context_value(AuthenticationContextClass::new(acr.into())); + auth = auth.add_auth_context_value(acr); } auth.set_pkce_challenge(pkce_challenge).url() From a5e0bc705ef5280bb9e7daf3fe57af08a26e4e6a Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:55:27 +0100 Subject: [PATCH 35/37] update example to use exported types for Scope and IssuerUrl --- examples/basic/src/main.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/basic/src/main.rs b/examples/basic/src/main.rs index 31d8eb0..45d99c3 100644 --- a/examples/basic/src/main.rs +++ b/examples/basic/src/main.rs @@ -1,20 +1,22 @@ use axum::{ + Router, error_handling::HandleErrorLayer, http::Uri, response::IntoResponse, routing::{any, get}, - Router, }; use axum_oidc::{ - error::MiddlewareError, handle_oidc_redirect, openidconnect::{Audience, ClientId, ClientSecret}, 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::{ - cookie::{time::Duration, SameSite}, Expiry, MemoryStore, SessionManagerLayer, + cookie::{SameSite, time::Duration}, }; use tracing::Level; @@ -47,15 +49,19 @@ async fn main() { .with_default_http_client() .with_redirect_url(Uri::from_static("http://localhost:8080/oidc")) .with_client_id(ClientId::new(client_id)) - .add_scope("profile") - .add_scope("email") + .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(issuer).await.unwrap().build(); + 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 { From 4c508a22e6364f999e03a396b845d295efbc8e67 Mon Sep 17 00:00:00 2001 From: JuliDi <20155974+JuliDi@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:51:30 +0100 Subject: [PATCH 36/37] fix regression from #39 that broke #34 again --- src/middleware.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/middleware.rs b/src/middleware.rs index 4b881b1..b4b68b5 100644 --- a/src/middleware.rs +++ b/src/middleware.rs @@ -17,7 +17,7 @@ use tower_sessions::Session; use openidconnect::{ core::{CoreAuthenticationFlow, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey}, AccessToken, AccessTokenHash, CsrfToken, IdTokenClaims, IdTokenVerifier, Nonce, - OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, + NonceVerifier as _, OAuth2TokenResponse, PkceCodeChallenge, RefreshToken, RequestTokenError::ServerResponse, Scope, TokenResponse, UserInfoClaims, }; @@ -425,7 +425,12 @@ async fn try_refresh_token( .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, nonce)?; + 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, From bd71f2efe5f725c2fce52d7027b49035300d0c1b Mon Sep 17 00:00:00 2001 From: pfzetto Date: Sun, 7 Dec 2025 21:43:57 +0100 Subject: [PATCH 37/37] version '1.0.0-dev-0' --- Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ba70915..e92d92a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "axum-oidc" description = "A wrapper for the openidconnect crate for axum" -version = "0.6.0" +version = "1.0.0-dev-0" edition = "2021" -authors = ["Paul Z "] +authors = ["Paul Z "] readme = "README.md" -repository = "https://github.com/pfz4/axum-oidc" +repository = "https://codeberg.org/pfzetto/axum-oidc" license = "MPL-2.0" keywords = ["axum", "oidc", "openidconnect", "authentication"]