use anyhow::anyhow; use domain::{ AuthorizationCode, AuthorizationUrlData, ClientId, ClientSecret, CsrfToken, IssuerUrl, OidcNonce, PkceVerifier, RedirectUrl, ResourceId, }; use openidconnect::{ AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse, UserInfoClaims, core::{ CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, CoreTokenResponse, }, reqwest, }; pub type OidcClient = Client< EmptyAdditionalClaims, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, CoreTokenResponse, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, EndpointSet, // HasAuthUrl (Required and guaranteed by discovery) EndpointNotSet, // HasDeviceAuthUrl EndpointNotSet, // HasIntrospectionUrl EndpointNotSet, // HasRevocationUrl EndpointMaybeSet, // HasTokenUrl (Discovered, might be missing) EndpointMaybeSet, // HasUserInfoUrl (Discovered, might be missing) >; #[derive(Clone)] pub struct OidcService { client: OidcClient, resource_id: Option, } #[derive(Debug)] pub struct OidcUser { pub subject: String, pub email: String, } impl OidcService { /// Create a new OIDC service with validated configuration newtypes pub async fn new( issuer: IssuerUrl, client_id: ClientId, client_secret: Option, redirect_url: RedirectUrl, resource_id: Option, ) -> anyhow::Result { tracing::debug!("🔵 OIDC Setup: Client ID = '{}'", client_id); tracing::debug!("🔵 OIDC Setup: Redirect = '{}'", redirect_url); tracing::debug!( "🔵 OIDC Setup: Secret = {:?}", if client_secret.is_some() { "SET" } else { "NONE" } ); let http_client = reqwest::ClientBuilder::new() .redirect(reqwest::redirect::Policy::none()) .build()?; let provider_metadata = CoreProviderMetadata::discover_async( openidconnect::IssuerUrl::new(issuer.as_ref().to_string())?, &http_client, ) .await?; // Convert to openidconnect types let oidc_client_id = openidconnect::ClientId::new(client_id.as_ref().to_string()); let oidc_client_secret = client_secret .as_ref() .filter(|s| !s.is_empty()) .map(|s| openidconnect::ClientSecret::new(s.as_ref().to_string())); let oidc_redirect_url = openidconnect::RedirectUrl::new(redirect_url.as_ref().to_string())?; let client = CoreClient::from_provider_metadata( provider_metadata, oidc_client_id, oidc_client_secret, ) .set_redirect_uri(oidc_redirect_url); Ok(Self { client, resource_id, }) } /// Get the authorization URL and associated state for OIDC login /// /// Returns structured data instead of a raw tuple for better type safety pub fn get_authorization_url(&self) -> AuthorizationUrlData { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); let (auth_url, csrf_token, nonce) = self .client .authorize_url( CoreAuthenticationFlow::AuthorizationCode, openidconnect::CsrfToken::new_random, openidconnect::Nonce::new_random, ) .add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("email".to_string())) .set_pkce_challenge(pkce_challenge) .url(); AuthorizationUrlData { url: auth_url.into(), csrf_token: CsrfToken::new(csrf_token.secret().to_string()), nonce: OidcNonce::new(nonce.secret().to_string()), pkce_verifier: PkceVerifier::new(pkce_verifier.secret().to_string()), } } /// Resolve the OIDC callback with type-safe parameters pub async fn resolve_callback( &self, code: AuthorizationCode, nonce: OidcNonce, pkce_verifier: PkceVerifier, ) -> anyhow::Result { let http_client = reqwest::ClientBuilder::new() .redirect(reqwest::redirect::Policy::none()) .build()?; let oidc_pkce_verifier = openidconnect::PkceCodeVerifier::new(pkce_verifier.as_ref().to_string()); let oidc_nonce = openidconnect::Nonce::new(nonce.as_ref().to_string()); let token_response = self .client .exchange_code(openidconnect::AuthorizationCode::new( code.as_ref().to_string(), ))? .set_pkce_verifier(oidc_pkce_verifier) .request_async(&http_client) .await?; let id_token = token_response .id_token() .ok_or_else(|| anyhow!("Server did not return an ID token"))?; let mut id_token_verifier = self.client.id_token_verifier().clone(); if let Some(resource_id) = &self.resource_id { let trusted_resource_id = resource_id.as_ref().to_string(); id_token_verifier = id_token_verifier .set_other_audience_verifier_fn(move |aud| aud.as_str() == trusted_resource_id); } let claims = id_token.claims(&id_token_verifier, &oidc_nonce)?; 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()?, id_token.signing_key(&id_token_verifier)?, )?; if actual_access_token_hash != *expected_access_token_hash { return Err(anyhow!("Invalid access token")); } } let email = if let Some(email) = claims.email() { Some(email.as_str().to_string()) } else { // Fallback: Call UserInfo Endpoint using the Access Token tracing::debug!("🔵 Email missing in ID Token, fetching UserInfo..."); let user_info: UserInfoClaims = self .client .user_info(token_response.access_token().clone(), None)? .request_async(&http_client) .await?; user_info.email().map(|e| e.as_str().to_string()) }; // If email is still missing, we must error out because your app requires valid emails let email = email.ok_or_else(|| anyhow!("User has no verified email address in ZITADEL"))?; Ok(OidcUser { subject: claims.subject().to_string(), email, }) } }