1use std::collections::HashMap;
75use std::future::Future;
76use std::pin::Pin;
77use std::sync::Arc;
78use std::task::{Context, Poll};
79use std::time::{Duration, Instant};
80
81use base64::engine::general_purpose::STANDARD as BASE64;
82use base64::Engine as _;
83use http_body_util::{BodyExt as _, Full, Limited};
84use hyper::{Method, Request, Uri};
85use hyper_util::client::legacy::connect::{Connected, Connection as HyperConnection};
86use hyper_util::client::legacy::Client;
87use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
88use rustls::pki_types::ServerName;
89use sha2::{Digest, Sha256};
90use tower_service::Service;
91
92use crate::config::OidcProvider;
93
94const OIDC_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
98
99const OIDC_BODY_CAP: usize = 1024 * 1024;
103
104const DISCOVERY_FRESH_SECS: u64 = 3600;
110
111#[derive(Debug)]
116pub enum OidcError {
117 InvalidEndpoint(String),
119 EndpointStatus(u16),
121 MalformedResponse(String),
124 Fetch(String),
126 TlsConfig(String),
128 Inactive,
132}
133
134impl std::fmt::Display for OidcError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 OidcError::InvalidEndpoint(m) => {
138 write!(f, "oidc endpoint is invalid: {m}")
139 }
140 OidcError::EndpointStatus(s) => {
141 write!(f, "oidc endpoint returned status {s}")
142 }
143 OidcError::MalformedResponse(m) => {
144 write!(f, "oidc response is malformed: {m}")
145 }
146 OidcError::Fetch(m) => write!(f, "oidc fetch failed: {m}"),
147 OidcError::TlsConfig(m) => write!(f, "oidc tls config error: {m}"),
148 OidcError::Inactive => write!(f, "oidc introspection returned active: false"),
149 }
150 }
151}
152
153impl std::error::Error for OidcError {}
154
155enum MaybeTls {
162 Plain(hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>),
163 Tls(
164 Box<
165 tokio_rustls::client::TlsStream<
166 hyper_util::rt::tokio::WithHyperIo<tokio::net::TcpStream>,
167 >,
168 >,
169 ),
170}
171
172impl tokio::io::AsyncRead for MaybeTls {
173 fn poll_read(
174 mut self: Pin<&mut Self>,
175 cx: &mut Context<'_>,
176 buf: &mut tokio::io::ReadBuf<'_>,
177 ) -> Poll<std::io::Result<()>> {
178 match &mut *self {
179 MaybeTls::Plain(s) => Pin::new(s).poll_read(cx, buf),
180 MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_read(cx, buf),
181 }
182 }
183}
184
185impl tokio::io::AsyncWrite for MaybeTls {
186 fn poll_write(
187 mut self: Pin<&mut Self>,
188 cx: &mut Context<'_>,
189 buf: &[u8],
190 ) -> Poll<std::io::Result<usize>> {
191 match &mut *self {
192 MaybeTls::Plain(s) => Pin::new(s).poll_write(cx, buf),
193 MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_write(cx, buf),
194 }
195 }
196
197 fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
198 match &mut *self {
199 MaybeTls::Plain(s) => Pin::new(s).poll_flush(cx),
200 MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_flush(cx),
201 }
202 }
203
204 fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
205 match &mut *self {
206 MaybeTls::Plain(s) => Pin::new(s).poll_shutdown(cx),
207 MaybeTls::Tls(s) => Pin::new(s.as_mut()).poll_shutdown(cx),
208 }
209 }
210}
211
212impl HyperConnection for MaybeTls {
213 fn connected(&self) -> Connected {
214 Connected::new()
215 }
216}
217
218#[derive(Clone)]
225struct OidcConnector {
226 http: hyper_util::client::legacy::connect::HttpConnector,
227 tls: tokio_rustls::TlsConnector,
228}
229
230impl OidcConnector {
231 fn new(trusted_ca: Option<&str>) -> Result<Self, OidcError> {
236 let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
237 http.enforce_http(false); let roots = match trusted_ca {
239 Some(path) => crate::security::tls::root_store_from_pem_file(path).map_err(|e| {
240 OidcError::TlsConfig(format!("trusted_ca_file '{path}' could not be loaded: {e}"))
241 })?,
242 None => crate::security::tls::webpki_root_store(),
243 };
244 let cfg = crate::security::tls::https_h1_client_config(roots);
245 Ok(OidcConnector {
246 http,
247 tls: tokio_rustls::TlsConnector::from(Arc::new(cfg)),
248 })
249 }
250}
251
252impl Service<Uri> for OidcConnector {
253 type Response = TokioIo<MaybeTls>;
254 type Error = std::io::Error;
255 type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
256
257 fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
258 self.http.poll_ready(cx).map_err(std::io::Error::other)
259 }
260
261 fn call(&mut self, uri: Uri) -> Self::Future {
262 let mut http = self.http.clone();
263 let tls = self.tls.clone();
264 Box::pin(async move {
265 let host = uri
266 .host()
267 .ok_or_else(|| std::io::Error::other("oidc url has no host"))?
268 .to_string();
269 let stream = tokio::time::timeout(OIDC_CONNECT_TIMEOUT, http.call(uri.clone()))
270 .await
271 .map_err(|_| {
272 std::io::Error::new(std::io::ErrorKind::TimedOut, "oidc connect timed out")
273 })?
274 .map_err(std::io::Error::other)?
275 .into_inner();
276 match uri.scheme_str() {
277 Some("https") => {
278 let name = ServerName::try_from(host.clone()).map_err(|e| {
279 std::io::Error::other(format!("oidc host is not a valid tls name: {e}"))
280 })?;
281 let tls_stream = tokio::time::timeout(
282 OIDC_CONNECT_TIMEOUT,
283 tls.connect(name, hyper_util::rt::tokio::WithHyperIo::new(stream)),
284 )
285 .await
286 .map_err(|_| {
287 std::io::Error::new(
288 std::io::ErrorKind::TimedOut,
289 "oidc tls handshake timed out",
290 )
291 })??;
292 Ok(TokioIo::new(MaybeTls::Tls(Box::new(tls_stream))))
293 }
294 _ => Ok(TokioIo::new(MaybeTls::Plain(
295 hyper_util::rt::tokio::WithHyperIo::new(stream),
296 ))),
297 }
298 })
299 }
300}
301
302#[derive(Debug, Clone)]
306pub struct DiscoveryDocument {
307 pub issuer: String,
309 pub jwks_uri: Option<String>,
312 pub introspection_endpoint: Option<String>,
314 pub revocation_endpoint: Option<String>,
316 pub authorization_endpoint: Option<String>,
318 pub token_endpoint: Option<String>,
320}
321
322impl DiscoveryDocument {
323 fn parse(body: &[u8]) -> Result<Self, OidcError> {
327 let json: serde_json::Value = serde_json::from_slice(body)
328 .map_err(|e| OidcError::MalformedResponse(format!("discovery is not JSON: {e}")))?;
329 let get = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
330 let issuer = get("issuer").ok_or_else(|| {
331 OidcError::MalformedResponse("discovery document has no string 'issuer'".into())
332 })?;
333 Ok(DiscoveryDocument {
334 issuer,
335 jwks_uri: get("jwks_uri"),
336 introspection_endpoint: get("introspection_endpoint"),
337 revocation_endpoint: get("revocation_endpoint"),
338 authorization_endpoint: get("authorization_endpoint"),
339 token_endpoint: get("token_endpoint"),
340 })
341 }
342}
343
344#[derive(Debug, Clone)]
348pub struct IntrospectionResponse {
349 pub active: bool,
351 pub sub: Option<String>,
353 pub username: Option<String>,
356 pub scope: Option<String>,
358 pub iss: Option<String>,
361 pub client_id: Option<String>,
363 pub exp: Option<i64>,
365 pub raw: serde_json::Value,
367}
368
369impl IntrospectionResponse {
370 fn parse(body: &[u8]) -> Result<Self, OidcError> {
377 let raw: serde_json::Value = serde_json::from_slice(body)
378 .map_err(|e| OidcError::MalformedResponse(format!("introspection is not JSON: {e}")))?;
379 let active = raw.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
380 let get = |k: &str| raw.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
381 Ok(IntrospectionResponse {
382 active,
383 sub: get("sub"),
384 username: get("username").or_else(|| get("preferred_username")),
385 scope: get("scope"),
386 iss: get("iss"),
387 client_id: get("client_id"),
388 exp: raw.get("exp").and_then(|v| v.as_i64()),
389 raw,
390 })
391 }
392}
393
394#[derive(Clone)]
397struct CachedIntrospection {
398 result: IntrospectionResponse,
399 expires_at: Instant,
400}
401
402impl CachedIntrospection {
403 fn is_fresh(&self) -> bool {
404 Instant::now() < self.expires_at
405 }
406}
407
408pub struct OidcIntrospectionCache {
415 inner: std::sync::Mutex<HashMap<String, CachedIntrospection>>,
416}
417
418impl OidcIntrospectionCache {
419 pub fn new() -> Self {
421 OidcIntrospectionCache {
422 inner: std::sync::Mutex::new(HashMap::new()),
423 }
424 }
425
426 fn get(&self, provider: &str, token: &str) -> Option<IntrospectionResponse> {
430 let key = cache_key(provider, token);
431 let inner = self.inner.lock().expect("oidc cache poisoned");
432 let entry = inner.get(&key)?;
433 if entry.is_fresh() {
434 Some(entry.result.clone())
435 } else {
436 None
437 }
438 }
439
440 fn put(&self, provider: &str, token: &str, result: IntrospectionResponse, ttl: Duration) {
443 if !result.active {
444 return;
445 }
446 let key = cache_key(provider, token);
447 let entry = CachedIntrospection {
448 result,
449 expires_at: Instant::now() + ttl,
450 };
451 self.inner
452 .lock()
453 .expect("oidc cache poisoned")
454 .insert(key, entry);
455 }
456
457 pub fn invalidate(&self, provider: &str, token: &str) {
460 let key = cache_key(provider, token);
461 self.inner.lock().expect("oidc cache poisoned").remove(&key);
462 }
463}
464
465impl Default for OidcIntrospectionCache {
466 fn default() -> Self {
467 Self::new()
468 }
469}
470
471impl std::fmt::Debug for OidcIntrospectionCache {
472 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
473 f.debug_struct("OidcIntrospectionCache")
474 .field("entries", &self.inner.lock().map(|m| m.len()).unwrap_or(0))
475 .finish_non_exhaustive()
476 }
477}
478
479fn cache_key(provider: &str, token: &str) -> String {
484 let mut hasher = Sha256::new();
485 hasher.update(token.as_bytes());
486 let digest = hasher.finalize();
487 format!("{}:{}", provider, hex_encode(&digest))
488}
489
490fn hex_encode(bytes: &[u8]) -> String {
494 const HEX: &[u8; 16] = b"0123456789abcdef";
495 let mut out = String::with_capacity(bytes.len() * 2);
496 for b in bytes {
497 out.push(HEX[(b >> 4) as usize] as char);
498 out.push(HEX[(b & 0x0f) as usize] as char);
499 }
500 out
501}
502
503pub struct OidcClient {
508 cfg: OidcProvider,
509 http: Client<OidcConnector, Full<bytes::Bytes>>,
510 discovery: std::sync::Mutex<Option<(Arc<DiscoveryDocument>, Instant)>>,
513 discovery_lock: tokio::sync::Mutex<()>,
516}
517
518impl OidcClient {
519 pub fn build(cfg: OidcProvider) -> Result<Arc<Self>, OidcError> {
523 let connector = OidcConnector::new(cfg.trusted_ca_file.as_deref())?;
524 let mut builder = Client::builder(TokioExecutor::new());
525 builder.pool_timer(TokioTimer::new());
526 Ok(Arc::new(OidcClient {
527 cfg,
528 http: builder.build(connector),
529 discovery: std::sync::Mutex::new(None),
530 discovery_lock: tokio::sync::Mutex::new(()),
531 }))
532 }
533
534 pub fn config(&self) -> &OidcProvider {
536 &self.cfg
537 }
538
539 pub fn name(&self) -> &str {
541 &self.cfg.name
542 }
543
544 async fn fetch_discovery(&self) -> Result<Arc<DiscoveryDocument>, OidcError> {
548 let url = format!(
549 "{}/.well-known/openid-configuration",
550 self.cfg.issuer.trim_end_matches('/')
551 );
552 let uri: Uri = url.parse().map_err(|e| {
553 OidcError::InvalidEndpoint(format!("discovery url '{url}' is invalid: {e}"))
554 })?;
555 let req = Request::builder()
556 .method(Method::GET)
557 .uri(uri)
558 .body(Full::new(bytes::Bytes::new()))
559 .map_err(|e| OidcError::Fetch(format!("discovery request build failed: {e}")))?;
560 let resp = self
561 .http
562 .request(req)
563 .await
564 .map_err(|e| OidcError::Fetch(format!("discovery request failed: {e:#}")))?;
565 if !resp.status().is_success() {
566 return Err(OidcError::EndpointStatus(resp.status().as_u16()));
567 }
568 let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
569 .collect()
570 .await
571 .map_err(|e| OidcError::Fetch(format!("discovery body read failed: {e}")))?
572 .to_bytes();
573 let doc = DiscoveryDocument::parse(&body)?;
574 if doc.issuer != self.cfg.issuer {
578 return Err(OidcError::MalformedResponse(format!(
579 "discovery issuer '{}' does not match configured issuer '{}'",
580 doc.issuer, self.cfg.issuer
581 )));
582 }
583 let doc = Arc::new(doc);
584 *self
585 .discovery
586 .lock()
587 .expect("oidc discovery cache poisoned") = Some((Arc::clone(&doc), Instant::now()));
588 Ok(doc)
589 }
590
591 async fn discovery(&self) -> Result<Arc<DiscoveryDocument>, OidcError> {
595 {
597 let guard = self
598 .discovery
599 .lock()
600 .expect("oidc discovery cache poisoned");
601 if let Some((doc, fetched_at)) = guard.as_ref() {
602 if fetched_at.elapsed().as_secs() < DISCOVERY_FRESH_SECS {
603 return Ok(Arc::clone(doc));
604 }
605 }
606 }
607 let _guard = self.discovery_lock.lock().await;
611 {
612 let guard = self
613 .discovery
614 .lock()
615 .expect("oidc discovery cache poisoned");
616 if let Some((doc, fetched_at)) = guard.as_ref() {
617 if fetched_at.elapsed().as_secs() < DISCOVERY_FRESH_SECS {
618 return Ok(Arc::clone(doc));
619 }
620 }
621 }
622 self.fetch_discovery().await
623 }
624
625 async fn introspection_endpoint(&self) -> Result<String, OidcError> {
628 if let Some(ep) = &self.cfg.introspection_endpoint {
629 return Ok(ep.clone());
630 }
631 let doc = self.discovery().await?;
632 doc.introspection_endpoint.clone().ok_or_else(|| {
633 OidcError::InvalidEndpoint(
634 "provider advertises no introspection endpoint and no override is configured"
635 .into(),
636 )
637 })
638 }
639
640 async fn revocation_endpoint(&self) -> Result<String, OidcError> {
643 if let Some(ep) = &self.cfg.revocation_endpoint {
644 return Ok(ep.clone());
645 }
646 let doc = self.discovery().await?;
647 doc.revocation_endpoint.clone().ok_or_else(|| {
648 OidcError::InvalidEndpoint(
649 "provider advertises no revocation endpoint and no override is configured".into(),
650 )
651 })
652 }
653
654 async fn token_endpoint(&self) -> Result<String, OidcError> {
657 let doc = self.discovery().await?;
658 doc.token_endpoint.clone().ok_or_else(|| {
659 OidcError::InvalidEndpoint("provider advertises no token endpoint".into())
660 })
661 }
662
663 pub async fn authorization_endpoint(&self) -> Result<String, OidcError> {
666 let doc = self.discovery().await?;
667 doc.authorization_endpoint.clone().ok_or_else(|| {
668 OidcError::InvalidEndpoint("provider advertises no authorization endpoint".into())
669 })
670 }
671
672 fn secret(&self) -> Result<String, OidcError> {
675 crate::config::credentials::resolve_configured_secret(&self.cfg.client_secret)
676 .map_err(|e| OidcError::Fetch(format!("client_secret unresolvable: {e}")))
677 }
678
679 fn basic_auth(&self) -> Result<String, OidcError> {
681 let secret = self.secret()?;
682 Ok(format!(
683 "Basic {}",
684 BASE64.encode(format!("{}:{}", self.cfg.client_id, secret))
685 ))
686 }
687
688 pub async fn introspect(
694 &self,
695 token: &str,
696 cache: &OidcIntrospectionCache,
697 ) -> Result<IntrospectionResponse, OidcError> {
698 if let Some(cached) = cache.get(&self.cfg.name, token) {
700 return Ok(cached);
701 }
702 let endpoint = self.introspection_endpoint().await?;
703 let basic = self.basic_auth()?;
704 let body = format!("token={}", &urlencode(token));
705 let req = Request::builder()
706 .method(Method::POST)
707 .uri(endpoint.parse::<Uri>().map_err(|e| {
708 OidcError::InvalidEndpoint(format!("introspection endpoint is invalid: {e}"))
709 })?)
710 .header(hyper::header::AUTHORIZATION, basic)
711 .header(
712 hyper::header::CONTENT_TYPE,
713 "application/x-www-form-urlencoded",
714 )
715 .body(Full::new(bytes::Bytes::from(body)))
716 .map_err(|e| OidcError::Fetch(format!("introspection request build failed: {e}")))?;
717 let resp = self
718 .http
719 .request(req)
720 .await
721 .map_err(|e| OidcError::Fetch(format!("introspection request failed: {e:#}")))?;
722 if !resp.status().is_success() {
723 return Err(OidcError::EndpointStatus(resp.status().as_u16()));
724 }
725 let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
726 .collect()
727 .await
728 .map_err(|e| OidcError::Fetch(format!("introspection body read failed: {e}")))?
729 .to_bytes();
730 let result = IntrospectionResponse::parse(&body)?;
731 if !result.active {
732 return Err(OidcError::Inactive);
733 }
734 if let Some(iss) = &result.iss {
739 if iss != &self.cfg.issuer {
740 return Err(OidcError::MalformedResponse(format!(
741 "introspection issuer '{iss}' does not match configured issuer '{}'",
742 self.cfg.issuer
743 )));
744 }
745 }
746 cache.put(
748 &self.cfg.name,
749 token,
750 result.clone(),
751 Duration::from_secs(self.cfg.introspection_cache_ttl_s),
752 );
753 Ok(result)
754 }
755
756 pub async fn revoke(
764 &self,
765 token: &str,
766 cache: &OidcIntrospectionCache,
767 ) -> Result<(), OidcError> {
768 let endpoint = self.revocation_endpoint().await?;
769 let basic = self.basic_auth()?;
770 let body = format!("token={}", urlencode(token));
771 let req = Request::builder()
772 .method(Method::POST)
773 .uri(endpoint.parse::<Uri>().map_err(|e| {
774 OidcError::InvalidEndpoint(format!("revocation endpoint is invalid: {e}"))
775 })?)
776 .header(hyper::header::AUTHORIZATION, basic)
777 .header(
778 hyper::header::CONTENT_TYPE,
779 "application/x-www-form-urlencoded",
780 )
781 .body(Full::new(bytes::Bytes::from(body)))
782 .map_err(|e| OidcError::Fetch(format!("revocation request build failed: {e}")))?;
783 let resp = self
784 .http
785 .request(req)
786 .await
787 .map_err(|e| OidcError::Fetch(format!("revocation request failed: {e:#}")))?;
788 let status = resp.status();
789 let _ = Limited::new(resp.into_body(), OIDC_BODY_CAP)
791 .collect()
792 .await;
793 if !status.is_success() {
794 return Err(OidcError::EndpointStatus(status.as_u16()));
795 }
796 cache.invalidate(&self.cfg.name, token);
797 Ok(())
798 }
799
800 pub async fn exchange_token(
807 &self,
808 subject_token: &str,
809 audience: &str,
810 ) -> Result<String, OidcError> {
811 let endpoint = self.token_endpoint().await?;
812 let basic = self.basic_auth()?;
813 let mut body = format!(
819 "grant_type=urn:ietf:params:oauth:grant-type:token-exchange\
820 &subject_token={}&subject_token_type=urn:ietf:params:oauth:token-type:access_token\
821 &audience={}",
822 urlencode(subject_token),
823 urlencode(audience)
824 );
825 if !self.cfg.scopes.is_empty() {
826 body.push_str("&scope=");
827 body.push_str(&urlencode(&self.cfg.scopes.join(" ")));
828 }
829 let req = Request::builder()
830 .method(Method::POST)
831 .uri(endpoint.parse::<Uri>().map_err(|e| {
832 OidcError::InvalidEndpoint(format!("token endpoint is invalid: {e}"))
833 })?)
834 .header(hyper::header::AUTHORIZATION, basic)
835 .header(
836 hyper::header::CONTENT_TYPE,
837 "application/x-www-form-urlencoded",
838 )
839 .body(Full::new(bytes::Bytes::from(body)))
840 .map_err(|e| OidcError::Fetch(format!("token exchange request build failed: {e}")))?;
841 let resp = self
842 .http
843 .request(req)
844 .await
845 .map_err(|e| OidcError::Fetch(format!("token exchange request failed: {e:#}")))?;
846 if !resp.status().is_success() {
847 return Err(OidcError::EndpointStatus(resp.status().as_u16()));
848 }
849 let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
850 .collect()
851 .await
852 .map_err(|e| OidcError::Fetch(format!("token exchange body read failed: {e}")))?
853 .to_bytes();
854 let json: serde_json::Value = serde_json::from_slice(&body).map_err(|e| {
855 OidcError::MalformedResponse(format!("token exchange is not JSON: {e}"))
856 })?;
857 let token = json
858 .get("access_token")
859 .and_then(|v| v.as_str())
860 .ok_or_else(|| {
861 OidcError::MalformedResponse("token exchange response missing access_token".into())
862 })?
863 .to_string();
864 if token.is_empty() {
865 return Err(OidcError::MalformedResponse(
866 "token exchange access_token is empty".into(),
867 ));
868 }
869 Ok(token)
870 }
871
872 pub async fn exchange_code(
879 &self,
880 code: &str,
881 redirect_uri: &str,
882 code_verifier: &str,
883 ) -> Result<TokenSet, OidcError> {
884 let endpoint = self.token_endpoint().await?;
885 let basic = self.basic_auth()?;
886 let body = format!(
887 "grant_type=authorization_code\
888 &code={}&redirect_uri={}&code_verifier={}",
889 urlencode(code),
890 urlencode(redirect_uri),
891 urlencode(code_verifier)
892 );
893 let req = Request::builder()
894 .method(Method::POST)
895 .uri(endpoint.parse::<Uri>().map_err(|e| {
896 OidcError::InvalidEndpoint(format!("token endpoint is invalid: {e}"))
897 })?)
898 .header(hyper::header::AUTHORIZATION, basic)
899 .header(
900 hyper::header::CONTENT_TYPE,
901 "application/x-www-form-urlencoded",
902 )
903 .body(Full::new(bytes::Bytes::from(body)))
904 .map_err(|e| {
905 OidcError::Fetch(format!("auth-code exchange request build failed: {e}"))
906 })?;
907 let resp =
908 self.http.request(req).await.map_err(|e| {
909 OidcError::Fetch(format!("auth-code exchange request failed: {e:#}"))
910 })?;
911 if !resp.status().is_success() {
912 return Err(OidcError::EndpointStatus(resp.status().as_u16()));
913 }
914 let body = Limited::new(resp.into_body(), OIDC_BODY_CAP)
915 .collect()
916 .await
917 .map_err(|e| OidcError::Fetch(format!("auth-code exchange body read failed: {e}")))?
918 .to_bytes();
919 TokenSet::parse(&body)
920 }
921
922 pub async fn authorization_url(
930 &self,
931 redirect_uri: &str,
932 state: &str,
933 code_challenge: &str,
934 ) -> Result<String, OidcError> {
935 let endpoint = self.authorization_endpoint().await?;
936 let mut params = vec![
937 ("response_type", "code"),
938 ("client_id", self.cfg.client_id.as_str()),
939 ("redirect_uri", redirect_uri),
940 ("state", state),
941 ("code_challenge", code_challenge),
942 ("code_challenge_method", "S256"),
943 ];
944 let scope = self.cfg.scopes.join(" ");
945 if !scope.is_empty() {
946 params.push(("scope", scope.as_str()));
947 }
948 let query = params
949 .iter()
950 .map(|(k, v)| format!("{}={}", urlencode(k), urlencode(v)))
951 .collect::<Vec<_>>()
952 .join("&");
953 Ok(format!("{endpoint}?{query}"))
954 }
955}
956
957impl std::fmt::Debug for OidcClient {
958 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959 f.debug_struct("OidcClient")
960 .field("name", &self.cfg.name)
961 .field("issuer", &self.cfg.issuer)
962 .field("client_id", &self.cfg.client_id)
963 .field("client_secret", &"[redacted]")
964 .field("scopes", &self.cfg.scopes)
965 .field(
966 "introspection_cache_ttl_s",
967 &self.cfg.introspection_cache_ttl_s,
968 )
969 .field("fail_open", &self.cfg.fail_open)
970 .finish_non_exhaustive()
971 }
972}
973
974#[derive(Debug, Clone)]
978pub struct TokenSet {
979 pub access_token: String,
981 pub token_type: Option<String>,
983 pub refresh_token: Option<String>,
985 pub id_token: Option<String>,
988 pub expires_in: Option<u64>,
990 pub scope: Option<String>,
992}
993
994impl TokenSet {
995 fn parse(body: &[u8]) -> Result<Self, OidcError> {
997 let json: serde_json::Value = serde_json::from_slice(body).map_err(|e| {
998 OidcError::MalformedResponse(format!("token response is not JSON: {e}"))
999 })?;
1000 let get = |k: &str| json.get(k).and_then(|v| v.as_str()).map(|s| s.to_string());
1001 let access_token = get("access_token").ok_or_else(|| {
1002 OidcError::MalformedResponse("token response missing access_token".into())
1003 })?;
1004 if access_token.is_empty() {
1005 return Err(OidcError::MalformedResponse("access_token is empty".into()));
1006 }
1007 Ok(TokenSet {
1008 access_token,
1009 token_type: get("token_type"),
1010 refresh_token: get("refresh_token"),
1011 id_token: get("id_token"),
1012 expires_in: json.get("expires_in").and_then(|v| v.as_u64()),
1013 scope: get("scope"),
1014 })
1015 }
1016}
1017
1018fn urlencode(input: &str) -> String {
1024 const HEX: &[u8; 16] = b"0123456789ABCDEF";
1025 let mut out = String::with_capacity(input.len());
1026 for &b in input.as_bytes() {
1027 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
1029 out.push(b as char);
1030 } else {
1031 out.push('%');
1032 out.push(HEX[(b >> 4) as usize] as char);
1033 out.push(HEX[(b & 0x0f) as usize] as char);
1034 }
1035 }
1036 out
1037}
1038
1039pub fn pkce_code_verifier(seed: &[u8; 32]) -> String {
1047 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(seed)
1051}
1052
1053pub fn pkce_code_challenge(verifier: &str) -> String {
1056 let mut hasher = Sha256::new();
1057 hasher.update(verifier.as_bytes());
1058 let digest = hasher.finalize();
1059 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
1060}