greentic_oauth_core/
oidc.rs

1//! OpenID Connect relying party utilities.
2//!
3//! ```no_run
4//! use greentic_oauth_core::oidc::{OidcClient, PkceState};
5//! use url::Url;
6//!
7//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
8//! let issuer = Url::parse("https://accounts.example.com")?;
9//! let redirect = Url::parse("https://app.example.com/oauth/callback")?;
10//! let client_id = "oauth-demo-client";
11//!
12//! let mut rp = OidcClient::discover(&issuer).await?;
13//! rp.set_client_credentials(client_id, None)?;
14//!
15//! let (authorize_url, pkce) = rp.auth_url(&redirect, &["openid", "email"])?;
16//! println!("Redirect the browser to {}", authorize_url);
17//!
18//! // ... later, exchange the returned code + PKCE verifier ...
19//! # let code = "dummy-code";
20//! let token_set = rp.exchange_code(code, &pkce, &redirect).await?;
21//! if let Some(id_token) = token_set.id_token.as_deref() {
22//!     let claims = rp.validate_id_token(id_token, pkce.nonce())?;
23//!     println!("Authenticated subject {}", claims.subject);
24//! }
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! Revocation endpoints are optional; relative values resolve against the issuer and HTTP is
30//! permitted only for localhost during development and testing.
31
32use std::sync::Arc;
33
34use anyhow::{Error as AnyhowError, anyhow};
35use openidconnect::reqwest::async_http_client;
36use openidconnect::{
37    AccessToken, AdditionalProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken,
38    IssuerUrl, LogoutProviderMetadata, Nonce, OAuth2TokenResponse, PkceCodeChallenge,
39    PkceCodeVerifier, ProviderMetadata, RedirectUrl, RefreshToken, RevocationUrl, Scope,
40    core::{
41        CoreAuthDisplay, CoreAuthenticationFlow, CoreClaimName, CoreClaimType, CoreClient,
42        CoreClientAuthMethod, CoreGrantType, CoreIdToken, CoreIdTokenClaims, CoreJsonWebKey,
43        CoreJsonWebKeyType, CoreJsonWebKeyUse, CoreJweContentEncryptionAlgorithm,
44        CoreJweKeyManagementAlgorithm, CoreJwsSigningAlgorithm, CoreResponseMode, CoreResponseType,
45        CoreRevocableToken, CoreSubjectIdentifierType, CoreTokenResponse,
46    },
47};
48use serde::{Deserialize, Serialize};
49use thiserror::Error;
50use time::OffsetDateTime;
51use tracing::instrument;
52use url::{Host, Url};
53
54use crate::{pkce::PkcePair, types::TokenSet};
55
56#[derive(Clone, Debug, Default, Deserialize, Serialize)]
57struct GreenticAdditionalMetadata {
58    #[serde(default)]
59    revocation_endpoint: Option<String>,
60}
61
62impl AdditionalProviderMetadata for GreenticAdditionalMetadata {}
63
64type GreenticProviderMetadata = ProviderMetadata<
65    LogoutProviderMetadata<GreenticAdditionalMetadata>,
66    CoreAuthDisplay,
67    CoreClientAuthMethod,
68    CoreClaimName,
69    CoreClaimType,
70    CoreGrantType,
71    CoreJweContentEncryptionAlgorithm,
72    CoreJweKeyManagementAlgorithm,
73    CoreJwsSigningAlgorithm,
74    CoreJsonWebKeyType,
75    CoreJsonWebKeyUse,
76    CoreJsonWebKey,
77    CoreResponseMode,
78    CoreResponseType,
79    CoreSubjectIdentifierType,
80>;
81
82fn resolve_endpoint(issuer: &Url, candidate: &str) -> Result<Url, AnyhowError> {
83    let trimmed = candidate.trim();
84    if trimmed.is_empty() {
85        return Err(anyhow!("endpoint value empty"));
86    }
87    if let Ok(abs) = Url::parse(trimmed) {
88        return Ok(abs);
89    }
90    issuer
91        .join(trimmed)
92        .map_err(|err| anyhow!("failed to resolve `{trimmed}` against issuer `{issuer}`: {err}"))
93}
94
95fn validate_secure_or_localhost(url: &Url) -> Result<(), AnyhowError> {
96    match url.scheme() {
97        "https" => Ok(()),
98        "http" => {
99            if url.host().map(is_loopback_host).unwrap_or(false) {
100                Ok(())
101            } else {
102                Err(anyhow!("insecure non-localhost URL"))
103            }
104        }
105        other => Err(anyhow!("unsupported scheme `{other}`")),
106    }
107}
108
109fn is_loopback_host(host: Host<&str>) -> bool {
110    match host {
111        Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"),
112        Host::Ipv4(addr) => addr.is_loopback(),
113        Host::Ipv6(addr) => addr.is_loopback(),
114    }
115}
116
117/// Errors returned by [`OidcClient`].
118#[derive(Debug, Error)]
119pub enum OidcError {
120    /// Generic HTTP failure.
121    #[error("http error: {0}")]
122    Http(#[from] reqwest::Error),
123    /// The OAuth client configuration was invalid.
124    #[error("client configuration error: {0}")]
125    Configuration(#[from] openidconnect::ConfigurationError),
126    /// No client credentials configured.
127    #[error("client credentials have not been configured")]
128    MissingClientCredentials,
129    /// ID token validation failed.
130    #[error("id token validation failed: {0}")]
131    IdToken(#[from] openidconnect::ClaimsVerificationError),
132    /// The provider does not advertise end-session support.
133    #[error("provider does not expose an end session endpoint")]
134    EndSessionNotSupported,
135    /// Generic error.
136    #[error("{0}")]
137    Other(String),
138}
139
140/// Claims extracted from a validated ID token.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
142pub struct IdClaims {
143    pub issuer: Url,
144    pub subject: String,
145    pub audience: Vec<String>,
146    pub expires_at: Option<OffsetDateTime>,
147    pub issued_at: Option<OffsetDateTime>,
148    pub email: Option<String>,
149    pub name: Option<String>,
150    pub preferred_username: Option<String>,
151    pub nonce: Option<String>,
152    pub gender: Option<String>,
153}
154
155/// Persisted PKCE state returned by [`OidcClient::auth_url`].
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PkceState {
158    verifier: String,
159    csrf: String,
160    nonce: String,
161}
162
163impl PkceState {
164    /// Returns the PKCE verifier associated with the authorization request.
165    pub fn verifier_secret(&self) -> &str {
166        &self.verifier
167    }
168
169    /// Returns the CSRF token that was bundled into the authorization URL.
170    pub fn csrf_token(&self) -> &str {
171        &self.csrf
172    }
173
174    /// Returns the nonce that should be echoed in the ID token.
175    pub fn nonce(&self) -> &str {
176        &self.nonce
177    }
178
179    fn pkce_verifier(&self) -> PkceCodeVerifier {
180        PkceCodeVerifier::new(self.verifier.clone())
181    }
182}
183
184/// Thin wrapper around OpenID Connect provider discovery and RP interactions.
185#[derive(Clone)]
186pub struct OidcClient {
187    metadata: Arc<GreenticProviderMetadata>,
188    client_id: Option<ClientId>,
189    client_secret: Option<ClientSecret>,
190}
191
192impl OidcClient {
193    /// Discovers the provider configuration and JSON Web Key Set for `issuer`.
194    #[instrument(skip_all, fields(issuer = %issuer))]
195    pub async fn discover(issuer: &Url) -> Result<Self, OidcError> {
196        let issuer_url = IssuerUrl::from_url(issuer.clone());
197
198        let metadata: GreenticProviderMetadata =
199            GreenticProviderMetadata::discover_async(issuer_url.clone(), async_http_client)
200                .await
201                .map_err(|err| OidcError::Other(err.to_string()))?;
202        Ok(Self {
203            metadata: Arc::new(metadata),
204            client_id: None,
205            client_secret: None,
206        })
207    }
208
209    /// Configures the OAuth client credentials.
210    pub fn set_client_credentials(
211        &mut self,
212        client_id: impl Into<String>,
213        client_secret: Option<String>,
214    ) -> Result<(), OidcError> {
215        self.client_id = Some(ClientId::new(client_id.into()));
216        self.client_secret = client_secret.map(ClientSecret::new);
217        Ok(())
218    }
219
220    /// Returns the authorization URL and PKCE material for the authorization code flow.
221    #[instrument(skip(self, scopes))]
222    pub fn auth_url(&self, redirect: &Url, scopes: &[&str]) -> Result<(Url, PkceState), OidcError> {
223        let client = self
224            .core_client()?
225            .set_redirect_uri(RedirectUrl::from_url(redirect.clone()));
226
227        let pkce = PkcePair::generate();
228        let pkce_verifier = PkceCodeVerifier::new(pkce.verifier.clone());
229        let pkce_challenge = PkceCodeChallenge::from_code_verifier_sha256(&pkce_verifier);
230
231        let (auth_url, csrf, nonce) = client
232            .authorize_url(
233                CoreAuthenticationFlow::AuthorizationCode,
234                CsrfToken::new_random,
235                Nonce::new_random,
236            )
237            .set_pkce_challenge(pkce_challenge)
238            .add_scope(Scope::new("openid".into()))
239            .add_scopes(
240                scopes
241                    .iter()
242                    .filter(|scope| !scope.is_empty())
243                    .map(|scope| Scope::new(scope.to_string())),
244            )
245            .url();
246
247        Ok((
248            auth_url,
249            PkceState {
250                verifier: pkce.verifier,
251                csrf: csrf.secret().to_string(),
252                nonce: nonce.secret().to_string(),
253            },
254        ))
255    }
256
257    /// Exchanges the authorization code for tokens.
258    #[instrument(skip(self, pkce))]
259    pub async fn exchange_code(
260        &self,
261        code: &str,
262        pkce: &PkceState,
263        redirect: &Url,
264    ) -> Result<TokenSet, OidcError> {
265        let client = self
266            .core_client()?
267            .set_redirect_uri(RedirectUrl::from_url(redirect.clone()));
268
269        let response = client
270            .exchange_code(AuthorizationCode::new(code.to_string()))
271            .set_pkce_verifier(pkce.pkce_verifier())
272            .request_async(async_http_client)
273            .await
274            .map_err(|err| OidcError::Other(err.to_string()))?;
275
276        Ok(token_set_from_response(&response))
277    }
278
279    /// Validates an ID token using the downloaded JWKS.
280    pub fn validate_id_token(
281        &self,
282        id_token: &str,
283        expected_nonce: &str,
284    ) -> Result<IdClaims, OidcError> {
285        let client = self.core_client()?;
286        let verifier = client.id_token_verifier();
287        let nonce = Nonce::new(expected_nonce.to_owned());
288        let token: CoreIdToken = id_token
289            .parse()
290            .map_err(|err| OidcError::Other(format!("invalid id token: {err}")))?;
291
292        let claims = token.claims(&verifier, &nonce)?;
293        Ok(IdClaims::from_claims(claims))
294    }
295
296    /// Refreshes an access token using the refresh token.
297    pub async fn refresh(&self, refresh_token: &str) -> Result<TokenSet, OidcError> {
298        let client = self.core_client()?;
299        let response = client
300            .exchange_refresh_token(&RefreshToken::new(refresh_token.to_string()))
301            .request_async(async_http_client)
302            .await
303            .map_err(|err| OidcError::Other(err.to_string()))?;
304
305        Ok(token_set_from_response(&response))
306    }
307
308    /// Revokes a refresh or access token.
309    pub async fn revoke(
310        &self,
311        token: &str,
312        token_type_hint: Option<&str>,
313    ) -> Result<(), OidcError> {
314        let client = self.core_client()?;
315        let hint_lower = token_type_hint.map(|hint| hint.to_ascii_lowercase());
316        let revocable = match hint_lower.as_deref() {
317            Some("refresh_token") => CoreRevocableToken::from(RefreshToken::new(token.to_string())),
318            _ => CoreRevocableToken::from(AccessToken::new(token.to_string())),
319        };
320        let mut request = match client.revoke_token(revocable) {
321            Ok(builder) => builder,
322            Err(err) => {
323                tracing::info!(
324                    target: "oauth.oidc",
325                    error = %err,
326                    "revocation endpoint unavailable; skipping revoke"
327                );
328                return Ok(());
329            }
330        };
331        if let Some(hint) = token_type_hint
332            && !matches!(
333                hint_lower.as_deref(),
334                Some("refresh_token" | "access_token")
335            )
336        {
337            request = request.add_extra_param("token_type_hint", hint.to_string());
338        }
339        request
340            .request_async(async_http_client)
341            .await
342            .map_err(|err| OidcError::Other(err.to_string()))?;
343        Ok(())
344    }
345
346    /// Constructs the provider's end-session URL.
347    pub fn end_session_url(
348        &self,
349        id_token_hint: &str,
350        post_logout_redirect_uri: &url::Url,
351    ) -> Result<url::Url, OidcError> {
352        let end_session = self
353            .metadata
354            .additional_metadata()
355            .end_session_endpoint
356            .as_ref()
357            .ok_or(OidcError::EndSessionNotSupported)?
358            .url()
359            .clone();
360        let mut url = end_session;
361        url.query_pairs_mut()
362            .append_pair("id_token_hint", id_token_hint)
363            .append_pair(
364                "post_logout_redirect_uri",
365                post_logout_redirect_uri.as_str(),
366            );
367        Ok(url)
368    }
369
370    fn core_client(&self) -> Result<CoreClient, OidcError> {
371        let client_id = self
372            .client_id
373            .clone()
374            .ok_or(OidcError::MissingClientCredentials)?;
375        let issuer = self.metadata.issuer().url().clone();
376        let revocation_url_opt = self
377            .metadata
378            .additional_metadata()
379            .additional_metadata
380            .revocation_endpoint
381            .as_deref()
382            .and_then(|raw| {
383                match resolve_endpoint(&issuer, raw).and_then(|resolved| {
384                    validate_secure_or_localhost(&resolved)?;
385                    Ok(resolved)
386                }) {
387                    Ok(url) => Some(RevocationUrl::from_url(url)),
388                    Err(err) => {
389                        tracing::warn!(
390                            target: "oauth.oidc",
391                            raw,
392                            error = %err,
393                            "skipping revocation endpoint"
394                        );
395                        None
396                    }
397                }
398            });
399
400        let mut client = CoreClient::from_provider_metadata(
401            (*self.metadata).clone(),
402            client_id,
403            self.client_secret.clone(),
404        );
405        if let Some(revocation_url) = revocation_url_opt {
406            client = client.set_revocation_uri(revocation_url);
407        }
408        Ok(client)
409    }
410
411    #[cfg(test)]
412    fn test_new(
413        mut metadata: GreenticProviderMetadata,
414        jwks: openidconnect::core::CoreJsonWebKeySet,
415    ) -> Self {
416        metadata = metadata.set_jwks(jwks);
417        Self {
418            metadata: Arc::new(metadata),
419            client_id: None,
420            client_secret: None,
421        }
422    }
423}
424
425fn token_set_from_response(response: &CoreTokenResponse) -> TokenSet {
426    let access_token = response.access_token().secret().to_owned();
427    let expires_in = response.expires_in().map(|d| d.as_secs());
428    let refresh_token = response
429        .refresh_token()
430        .map(|token| token.secret().to_owned());
431    let scopes = response
432        .scopes()
433        .map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
434        .unwrap_or_default();
435    let id_token = response.extra_fields().id_token().map(|id| id.to_string());
436
437    TokenSet {
438        access_token,
439        expires_in,
440        refresh_token,
441        token_type: Some(response.token_type().as_ref().to_string()),
442        scopes,
443        id_token,
444    }
445}
446
447impl IdClaims {
448    fn from_claims(claims: &CoreIdTokenClaims) -> Self {
449        let expires_at = OffsetDateTime::from_unix_timestamp(claims.expiration().timestamp()).ok();
450        let issued_at = OffsetDateTime::from_unix_timestamp(claims.issue_time().timestamp()).ok();
451
452        IdClaims {
453            issuer: claims.issuer().url().clone(),
454            subject: claims.subject().as_str().to_string(),
455            audience: claims
456                .audiences()
457                .iter()
458                .map(|aud| aud.as_str().to_string())
459                .collect(),
460            expires_at,
461            issued_at,
462            email: claims.email().map(|email| email.as_str().to_string()),
463            name: claims
464                .name()
465                .and_then(|claim| claim.iter().next())
466                .map(|(_, value)| value.to_string()),
467            preferred_username: claims
468                .preferred_username()
469                .map(|username| username.as_str().to_string()),
470            nonce: claims.nonce().map(|n| n.secret().to_string()),
471            gender: claims.gender().map(|g| g.to_string()),
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use openidconnect::JsonWebKeyId;
480    use openidconnect::core::{CoreJsonWebKey, CoreJsonWebKeySet};
481    use serde_json::json;
482    use url::Url;
483    use wiremock::{
484        Mock, MockServer, ResponseTemplate,
485        matchers::{body_string_contains, method, path},
486    };
487
488    #[tokio::test]
489    async fn discover_fetches_metadata_and_jwks() {
490        let Ok(server) = tokio::spawn(async { MockServer::start().await }).await else {
491            eprintln!("skipping discovery test: mock server unavailable");
492            return;
493        };
494        let issuer = server.uri();
495        let issuer_root = issuer.trim_end_matches('/');
496        let issuer_with_trailing = format!("{issuer_root}/");
497
498        let discovery_body = json!({
499            "issuer": issuer_with_trailing,
500            "authorization_endpoint": format!("{}/oauth2/auth", issuer_root),
501            "token_endpoint": format!("{}/oauth2/token", issuer_root),
502            "jwks_uri": format!("{}/oauth2/jwks", issuer_root),
503            "response_types_supported": ["code"],
504            "subject_types_supported": ["public"],
505            "id_token_signing_alg_values_supported": ["RS256"]
506        });
507
508        Mock::given(method("GET"))
509            .and(path("/.well-known/openid-configuration"))
510            .respond_with(ResponseTemplate::new(200).set_body_json(discovery_body))
511            .mount(&server)
512            .await;
513
514        Mock::given(method("GET"))
515            .and(path("/oauth2/jwks"))
516            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
517                "keys": sample_jwks().keys()
518            })))
519            .mount(&server)
520            .await;
521
522        let issuer = Url::parse(&issuer_with_trailing).expect("issuer url");
523        let client = OidcClient::discover(&issuer).await.expect("discover");
524
525        assert_eq!(client.metadata.issuer().as_str(), issuer.as_str());
526        assert!(!client.metadata.jwks().keys().is_empty());
527    }
528
529    #[tokio::test]
530    async fn exchange_and_refresh_tokens() {
531        let Ok(server) = tokio::spawn(async { MockServer::start().await }).await else {
532            eprintln!("skipping exchange test: mock server unavailable");
533            return;
534        };
535        let issuer_base = server.uri();
536        let metadata = provider_metadata(&issuer_base, Some("revoke"));
537        let jwks = sample_jwks();
538
539        let mut client = OidcClient::test_new(metadata, jwks);
540        client
541            .set_client_credentials("client", Some("secret".to_string()))
542            .expect("credentials");
543
544        let token_response = json!({
545            "access_token": "access-123",
546            "token_type": "Bearer",
547            "expires_in": 3600,
548            "refresh_token": "refresh-456",
549            "scope": "openid profile"
550        });
551
552        Mock::given(method("POST"))
553            .and(path("/oauth2/token"))
554            .and(body_string_contains("grant_type=authorization_code"))
555            .respond_with(ResponseTemplate::new(200).set_body_json(token_response.clone()))
556            .mount(&server)
557            .await;
558
559        Mock::given(method("POST"))
560            .and(path("/oauth2/token"))
561            .and(body_string_contains("grant_type=refresh_token"))
562            .respond_with(ResponseTemplate::new(200).set_body_json(token_response.clone()))
563            .mount(&server)
564            .await;
565
566        Mock::given(method("POST"))
567            .and(path("/revoke"))
568            .respond_with(ResponseTemplate::new(200))
569            .mount(&server)
570            .await;
571
572        let redirect = Url::parse("https://app.example.com/callback").unwrap();
573        let (_, pkce) = client.auth_url(&redirect, &["openid"]).expect("auth url");
574
575        let tokens = client
576            .exchange_code("code123", &pkce, &redirect)
577            .await
578            .expect("token exchange");
579
580        assert_eq!(tokens.access_token, "access-123");
581        assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-456"));
582        assert!(tokens.scopes.contains(&"openid".to_string()));
583
584        let refreshed = client.refresh("refresh-456").await.expect("refresh");
585        assert_eq!(refreshed.access_token, "access-123");
586
587        client
588            .revoke("refresh-456", Some("refresh_token"))
589            .await
590            .expect("revoke");
591    }
592
593    fn provider_metadata(base: &str, revocation: Option<&str>) -> GreenticProviderMetadata {
594        let trimmed = base.trim_end_matches('/');
595        let issuer = format!("{trimmed}/");
596        let auth = format!("{trimmed}/oauth2/auth");
597        let token = format!("{trimmed}/oauth2/token");
598        let jwks = format!("{trimmed}/oauth2/jwks");
599        let end_session = format!("{trimmed}/logout");
600
601        serde_json::from_value(json!({
602            "issuer": issuer,
603            "authorization_endpoint": auth,
604            "token_endpoint": token,
605            "jwks_uri": jwks,
606            "response_types_supported": ["code"],
607            "subject_types_supported": ["public"],
608            "id_token_signing_alg_values_supported": ["RS256"],
609            "scopes_supported": ["openid", "email", "profile"],
610            "token_endpoint_auth_methods_supported": ["client_secret_basic"],
611            "revocation_endpoint": revocation.map(|value| value.to_string()),
612            "end_session_endpoint": end_session
613        }))
614        .expect("metadata")
615    }
616
617    fn sample_jwks() -> CoreJsonWebKeySet {
618        CoreJsonWebKeySet::new(vec![CoreJsonWebKey::new_rsa(
619            vec![0x01],
620            vec![0x01],
621            Some(JsonWebKeyId::new("kid".into())),
622        )])
623    }
624
625    #[test]
626    fn pkce_state_contains_secrets() {
627        let metadata = provider_metadata(
628            "https://example.com",
629            Some("https://example.com/oauth/revoke"),
630        );
631        let jwks = sample_jwks();
632        let client = OidcClient::test_new(metadata, jwks);
633        let mut client = client;
634        client
635            .set_client_credentials("client", None)
636            .expect("credentials");
637
638        let redirect = url::Url::parse("https://example.com/callback").unwrap();
639        let (url, pkce) = client.auth_url(&redirect, &["email"]).unwrap();
640
641        assert!(url.as_str().contains("code_challenge="));
642        assert!(!pkce.verifier_secret().is_empty());
643        assert!(!pkce.csrf_token().is_empty());
644        assert!(!pkce.nonce().is_empty());
645    }
646
647    #[test]
648    fn relative_revocation_is_resolved() {
649        let issuer = Url::parse("http://127.0.0.1:4444/").expect("issuer url");
650        let resolved = resolve_endpoint(&issuer, "revocation").expect("resolved url");
651        assert_eq!(resolved.as_str(), "http://127.0.0.1:4444/revocation");
652        validate_secure_or_localhost(&resolved).expect("localhost http allowed");
653    }
654
655    #[test]
656    fn https_revocation_is_accepted() {
657        let issuer = Url::parse("https://auth.example.com/").expect("issuer url");
658        let resolved =
659            resolve_endpoint(&issuer, "https://auth.example.com/oauth/revoke").expect("resolved");
660        assert_eq!(resolved.as_str(), "https://auth.example.com/oauth/revoke");
661        validate_secure_or_localhost(&resolved).expect("https allowed");
662    }
663
664    #[test]
665    fn http_localhost_is_allowed() {
666        let issuer = Url::parse("http://localhost:8080/").expect("issuer url");
667        let resolved = resolve_endpoint(&issuer, "http://localhost:8080/revoke").expect("resolved");
668        validate_secure_or_localhost(&resolved).expect("localhost http allowed");
669    }
670
671    #[test]
672    fn http_non_localhost_is_rejected_and_skipped() {
673        let issuer = Url::parse("https://idp.example.com/").expect("issuer url");
674        let resolved =
675            resolve_endpoint(&issuer, "http://auth.example.com/revoke").expect("resolved");
676        assert!(
677            validate_secure_or_localhost(&resolved).is_err(),
678            "non-localhost http should be rejected"
679        );
680    }
681
682    #[tokio::test]
683    async fn invalid_revocation_is_skipped() {
684        let issuer = Url::parse("https://auth.example.com/").expect("issuer url");
685        assert!(
686            resolve_endpoint(&issuer, "").is_err(),
687            "empty endpoint should be rejected"
688        );
689        let metadata = provider_metadata("https://auth.example.com", Some(""));
690        let jwks = sample_jwks();
691        let mut client = OidcClient::test_new(metadata, jwks);
692        client
693            .set_client_credentials("client", Some("secret".into()))
694            .expect("credentials");
695        client
696            .revoke("refresh-token", Some("refresh_token"))
697            .await
698            .expect("invalid endpoint should be skipped without error");
699    }
700
701    #[tokio::test]
702    async fn revoke_path_does_not_panic_when_missing() {
703        let metadata = provider_metadata("https://auth.example.com", None);
704        let jwks = sample_jwks();
705        let mut client = OidcClient::test_new(metadata, jwks);
706        client
707            .set_client_credentials("client", Some("secret".into()))
708            .expect("credentials");
709        client
710            .revoke("refresh-token", Some("refresh_token"))
711            .await
712            .expect("revoke without endpoint should succeed");
713    }
714}