From 5ea43c1d617d9bb7ef29f5416050b756b8d026f4 Mon Sep 17 00:00:00 2001 From: Matthew Martin Date: Sun, 15 Sep 2024 10:27:27 -0500 Subject: [PATCH 1/2] Add ProviderMetadata discover_with_options to relax validation --- src/discovery/mod.rs | 65 ++++++++++++++++++++++++++++++++++++++++++-- src/lib.rs | 1 + 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index 183e4a7..b44a52a 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -278,6 +278,23 @@ where issuer_url: &IssuerUrl, http_client: &C, ) -> Result::Error>> + where + C: SyncHttpClient, + { + Self::discover_with_options( + issuer_url, + http_client, + ProviderMetadataDiscoveryOptions::default(), + ) + } + + /// Fetches the OpenID Connect Discovery document and associated JSON Web Key Set from the + /// OpenID Connect Provider. + pub fn discover_with_options( + issuer_url: &IssuerUrl, + http_client: &C, + options: ProviderMetadataDiscoveryOptions, + ) -> Result::Error>> where C: SyncHttpClient, { @@ -293,7 +310,7 @@ where ) .map_err(DiscoveryError::Request) .and_then(|http_response| { - Self::discovery_response(issuer_url, &discovery_url, http_response) + Self::discovery_response(issuer_url, &discovery_url, http_response, options) }) .and_then(|provider_metadata| { JsonWebKeySet::fetch(provider_metadata.jwks_uri(), http_client).map(|jwks| Self { @@ -309,6 +326,24 @@ where issuer_url: IssuerUrl, http_client: &'c C, ) -> impl Future>::Error>>> + 'c + where + Self: 'c, + C: AsyncHttpClient<'c>, + { + Self::discover_async_with_options( + issuer_url, + http_client, + ProviderMetadataDiscoveryOptions::default(), + ) + } + + /// Asynchronously fetches the OpenID Connect Discovery document and associated JSON Web Key Set + /// from the OpenID Connect Provider. + pub fn discover_async_with_options<'c, C>( + issuer_url: IssuerUrl, + http_client: &'c C, + options: ProviderMetadataDiscoveryOptions, + ) -> impl Future>::Error>>> + 'c where Self: 'c, C: AsyncHttpClient<'c>, @@ -327,7 +362,7 @@ where .await .map_err(DiscoveryError::Request) .and_then(|http_response| { - Self::discovery_response(&issuer_url, &discovery_url, http_response) + Self::discovery_response(&issuer_url, &discovery_url, http_response, options) })?; JsonWebKeySet::fetch_async(provider_metadata.jwks_uri(), http_client) @@ -351,6 +386,7 @@ where issuer_url: &IssuerUrl, discovery_url: &url::Url, discovery_response: HttpResponse, + options: ProviderMetadataDiscoveryOptions, ) -> Result> where RE: std::error::Error + 'static, @@ -380,7 +416,7 @@ where ) .map_err(DiscoveryError::Parse)?; - if provider_metadata.issuer() != issuer_url { + if options.validate_issuer_url && provider_metadata.issuer() != issuer_url { Err(DiscoveryError::Validation(format!( "unexpected issuer URI `{}` (expected `{}`)", provider_metadata.issuer().as_str(), @@ -401,6 +437,29 @@ where } } +/// Options for [`ProviderMetadata::discover_with_options`] for non-conforming implementations. +#[derive(Clone, Debug)] +pub struct ProviderMetadataDiscoveryOptions { + validate_issuer_url: bool, +} + +impl ProviderMetadataDiscoveryOptions { + /// If the issuer in the discovered provider metadata should be checked against the + /// `issuer_url` used to fetch the provider metadata. + pub fn validate_issuer_url(mut self, value: bool) -> Self { + self.validate_issuer_url = value; + self + } +} + +impl Default for ProviderMetadataDiscoveryOptions { + fn default() -> Self { + Self { + validate_issuer_url: true, + } + } +} + /// Error retrieving provider metadata. #[derive(Debug, Error)] #[non_exhaustive] diff --git a/src/lib.rs b/src/lib.rs index dbb235b..b45443d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -702,6 +702,7 @@ pub use crate::claims::{ pub use crate::client::Client; pub use crate::discovery::{ AdditionalProviderMetadata, DiscoveryError, EmptyAdditionalProviderMetadata, ProviderMetadata, + ProviderMetadataDiscoveryOptions, }; pub use crate::id_token::IdTokenFields; pub use crate::id_token::{IdToken, IdTokenClaims}; From 0e005069201273c507713d0e557c1fbc8d00825d Mon Sep 17 00:00:00 2001 From: Matthew Martin Date: Sun, 15 Sep 2024 10:30:57 -0500 Subject: [PATCH 2/2] Add example for Microsoft Entra --- Cargo.toml | 4 + examples/entra.rs | 185 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 190 insertions(+) create mode 100644 examples/entra.rs diff --git a/Cargo.toml b/Cargo.toml index 2cb0a61..6e73923 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,10 @@ rustls-tls = ["oauth2/rustls-tls"] timing-resistant-secret-traits = ["oauth2/timing-resistant-secret-traits"] ureq = ["oauth2/ureq"] +[[example]] +name = "entra" +required-features = ["reqwest-blocking"] + [[example]] name = "gitlab" required-features = ["reqwest-blocking"] diff --git a/examples/entra.rs b/examples/entra.rs new file mode 100644 index 0000000..0bceecb --- /dev/null +++ b/examples/entra.rs @@ -0,0 +1,185 @@ +//! +//! This example showcases the process of integrating with the +//! [Microsoft Entra OpenID +//! Connect](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) +//! provider. +//! +//! Before running it, you'll need to generate your own Entra OAuth2 credentials. +//! +//! In order to run the example call: +//! +//! ```sh +//! ENTRA_CLIENT_ID=xxx ENTRA_CLIENT_SECRET=yyy cargo run --example entra --features reqwest-blocking +//! ``` +//! +//! ...and follow the instructions. +//! + +use openidconnect::core::{ + CoreClient, CoreIdTokenClaims, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, +}; +use openidconnect::reqwest; +use openidconnect::{ + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, + OAuth2TokenResponse, ProviderMetadataDiscoveryOptions, RedirectUrl, Scope, +}; +use url::Url; + +use std::env; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::process::exit; + +fn handle_error(fail: &T, msg: &'static str) { + let mut err_msg = format!("ERROR: {}", msg); + let mut cur_fail: Option<&dyn std::error::Error> = Some(fail); + while let Some(cause) = cur_fail { + err_msg += &format!("\n caused by: {}", cause); + cur_fail = cause.source(); + } + println!("{}", err_msg); + exit(1); +} + +fn main() { + env_logger::init(); + + let entra_client_id = ClientId::new( + env::var("ENTRA_CLIENT_ID").expect("Missing the ENTRA_CLIENT_ID environment variable."), + ); + let entra_client_secret = ClientSecret::new( + env::var("ENTRA_CLIENT_SECRET") + .expect("Missing the ENTRA_CLIENT_SECRET environment variable."), + ); + let issuer_url = IssuerUrl::new("https://login.microsoftonline.com/common/v2.0".to_string()) + .unwrap_or_else(|err| { + handle_error(&err, "Invalid issuer URL"); + unreachable!(); + }); + + let http_client = reqwest::blocking::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_else(|err| { + handle_error(&err, "Failed to build HTTP client"); + unreachable!(); + }); + + // Fetch Entra's OpenID Connect discovery document. + let discovery_options = ProviderMetadataDiscoveryOptions::default().validate_issuer_url(false); + let provider_metadata = + CoreProviderMetadata::discover_with_options(&issuer_url, &http_client, discovery_options) + .unwrap_or_else(|err| { + handle_error(&err, "Failed to discover OpenID Provider"); + unreachable!(); + }); + + // Set up the config for the Entra OAuth2 process. + let client = CoreClient::from_provider_metadata( + provider_metadata, + entra_client_id, + Some(entra_client_secret), + ) + // This example will be running its own server at localhost:8080. + // See below for the server implementation. + .set_redirect_uri( + RedirectUrl::new("http://localhost:8080".to_string()).unwrap_or_else(|err| { + handle_error(&err, "Invalid redirect URL"); + unreachable!(); + }), + ); + + // Generate the authorization URL to which we'll redirect the user. + let (authorize_url, csrf_state, nonce) = client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + // This example is requesting access to the user's email and profile. + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + println!("Open this URL in your browser:\n{}\n", authorize_url); + + let (code, state) = { + // A very naive implementation of the redirect server. + let listener = TcpListener::bind("127.0.0.1:8080").unwrap(); + + // Accept one connection + let (mut stream, _) = listener.accept().unwrap(); + + let mut reader = BufReader::new(&stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + + let redirect_url = request_line.split_whitespace().nth(1).unwrap(); + let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); + + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .unwrap(); + + let state = url + .query_pairs() + .find(|(key, _)| key == "state") + .map(|(_, state)| CsrfToken::new(state.into_owned())) + .unwrap(); + + let message = "Go back to your terminal :)"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream.write_all(response.as_bytes()).unwrap(); + + (code, state) + }; + + println!("Entra returned the following code:\n{}\n", code.secret()); + println!( + "Entra returned the following state:\n{} (expected `{}`)\n", + state.secret(), + csrf_state.secret() + ); + + // Exchange the code with a token. + let token_response = client + .exchange_code(code) + .unwrap_or_else(|err| { + handle_error(&err, "No user info endpoint"); + unreachable!(); + }) + .request(&http_client) + .unwrap_or_else(|err| { + handle_error(&err, "Failed to contact token endpoint"); + unreachable!(); + }); + + println!( + "Entra returned access token:\n{}\n", + token_response.access_token().secret() + ); + println!("Entra returned scopes: {:?}", token_response.scopes()); + + // The issuer in Entra issued tokens contain the specific tenantid. Since the provider metadata + // uses {tenantid} as a placeholder, issuer match needs to be disabled. + let id_token_verifier: CoreIdTokenVerifier = + client.id_token_verifier().require_issuer_match(false); + let id_token_claims: &CoreIdTokenClaims = token_response + .extra_fields() + .id_token() + .expect("Server did not return an ID token") + .claims(&id_token_verifier, &nonce) + .unwrap_or_else(|err| { + handle_error(&err, "Failed to verify ID token"); + unreachable!(); + }); + println!("Entra returned ID token: {:?}", id_token_claims); +} diff --git a/src/lib.rs b/src/lib.rs index b45443d..aa99b73 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -121,6 +121,7 @@ //! //! ## Examples //! +//! * [Entra](https://github.com/ramosbugs/openidconnect-rs/tree/main/examples/entra.rs) //! * [Google](https://github.com/ramosbugs/openidconnect-rs/tree/main/examples/google.rs) //! //! ## Getting started: Authorization Code Grant w/ PKCE