1use 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#[derive(Debug, Error)]
119pub enum OidcError {
120 #[error("http error: {0}")]
122 Http(#[from] reqwest::Error),
123 #[error("client configuration error: {0}")]
125 Configuration(#[from] openidconnect::ConfigurationError),
126 #[error("client credentials have not been configured")]
128 MissingClientCredentials,
129 #[error("id token validation failed: {0}")]
131 IdToken(#[from] openidconnect::ClaimsVerificationError),
132 #[error("provider does not expose an end session endpoint")]
134 EndSessionNotSupported,
135 #[error("{0}")]
137 Other(String),
138}
139
140#[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#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct PkceState {
158 verifier: String,
159 csrf: String,
160 nonce: String,
161}
162
163impl PkceState {
164 pub fn verifier_secret(&self) -> &str {
166 &self.verifier
167 }
168
169 pub fn csrf_token(&self) -> &str {
171 &self.csrf
172 }
173
174 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#[derive(Clone)]
186pub struct OidcClient {
187 metadata: Arc<GreenticProviderMetadata>,
188 client_id: Option<ClientId>,
189 client_secret: Option<ClientSecret>,
190}
191
192impl OidcClient {
193 #[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 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 #[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 #[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 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 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 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 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}