1use crate::error::{AuthError, CallbackError, RefreshError};
2use crate::jwks::{JwksValidator, JwksValidatorStorage, RemoteJwksValidator};
3use crate::oidc::OpenIdConfiguration;
4use crate::token_response::{TokenParser, default_token_parser};
5
6#[non_exhaustive]
10pub enum OidcJwksConfig {
11 Enabled(JwksValidatorStorage),
13 Disabled,
19}
20use crate::pages::{
21 ErrorPageRenderer, ErrorRendererStorage, SuccessPageRenderer, SuccessRendererStorage,
22};
23use crate::scope::{OAuth2Scope, RequestScope};
24use crate::server::{
25 CallbackResult, HttpTransport, PortConfig, RenderedHtml, ServerState, Transport, bind_listener,
26};
27use std::sync::Arc;
28use tokio::sync::{Mutex, mpsc, oneshot};
29
30struct AuthUrlParams<'a> {
37 client_id: &'a str,
38 redirect_uri: &'a url::Url,
39 state_token: &'a str,
40 pkce: &'a crate::pkce::PkceChallenge,
41 nonce: Option<&'a str>,
42 scopes: &'a [OAuth2Scope],
43}
44
45impl AuthUrlParams<'_> {
46 const KEYS: &'static [&'static str] = &[
51 "response_type",
52 "client_id",
53 "redirect_uri",
54 "state",
55 "code_challenge",
56 "code_challenge_method",
57 "nonce",
58 "scope",
59 ];
60
61 fn append_to(&self, url: &mut url::Url) {
62 url.query_pairs_mut()
63 .append_pair("response_type", "code")
64 .append_pair("client_id", self.client_id)
65 .append_pair("redirect_uri", self.redirect_uri.as_str())
66 .append_pair("state", self.state_token)
67 .append_pair("code_challenge", &self.pkce.code_challenge)
68 .append_pair("code_challenge_method", self.pkce.code_challenge_method);
69
70 if let Some(nonce) = self.nonce {
71 url.query_pairs_mut().append_pair("nonce", nonce);
72 }
73
74 if !self.scopes.is_empty() {
75 let scope_str = self
76 .scopes
77 .iter()
78 .map(ToString::to_string)
79 .collect::<Vec<_>>()
80 .join(" ");
81 url.query_pairs_mut().append_pair("scope", &scope_str);
82 }
83 }
84}
85
86pub struct ExtraAuthParams {
101 pairs: Vec<(String, String)>,
102}
103
104impl ExtraAuthParams {
105 const fn new() -> Self {
106 Self { pairs: Vec::new() }
107 }
108
109 pub fn append(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
115 self.pairs.push((key.into(), value.into()));
116 self
117 }
118
119 fn apply_to(self, url: &mut url::Url) {
120 for (key, value) in self.pairs {
121 if AuthUrlParams::KEYS.contains(&key.as_str()) {
122 tracing::warn!(
123 key = key.as_str(),
124 "on_auth_url hook attempted to set a reserved parameter; ignoring"
125 );
126 } else {
127 url.query_pairs_mut().append_pair(&key, &value);
128 }
129 }
130 }
131}
132
133type OnAuthUrlCallback = Box<dyn Fn(&mut ExtraAuthParams) + Send + Sync + 'static>;
134type OnUrlCallback = Box<dyn Fn(&url::Url) + Send + Sync + 'static>;
135type OnServerReadyCallback = Box<dyn Fn(u16) + Send + Sync + 'static>;
136
137#[derive(Debug, Clone)]
139pub struct ClientId(String);
140
141impl ClientId {
142 pub(crate) fn as_str(&self) -> &str {
143 &self.0
144 }
145}
146
147const TIMEOUT_DURATION_IN_SECONDS: u64 = 300;
148const HTTP_CONNECT_TIMEOUT_SECONDS: u64 = 10;
149const HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 30;
150
151pub struct CliTokenClient {
163 client_id: ClientId,
164 client_secret: Option<String>,
165 auth_url: url::Url,
166 token_url: url::Url,
167 issuer: Option<url::Url>,
168 scopes: Vec<OAuth2Scope>,
169 port_config: PortConfig,
170 success_html: Option<String>,
171 error_html: Option<String>,
172 success_renderer: Option<SuccessRendererStorage>,
173 error_renderer: Option<ErrorRendererStorage>,
174 open_browser: bool,
175 timeout: std::time::Duration,
176 on_auth_url: Option<OnAuthUrlCallback>,
177 on_url: Option<OnUrlCallback>,
178 on_server_ready: Option<OnServerReadyCallback>,
179 oidc_jwks: Option<OidcJwksConfig>,
180 http_client: reqwest::Client,
181 transport: Arc<dyn Transport>,
182 token_parser: TokenParser,
183}
184
185impl CliTokenClient {
186 #[must_use]
188 pub fn builder() -> CliTokenClientBuilder {
189 CliTokenClientBuilder::default()
190 }
191
192 pub async fn run_authorization_flow(&self) -> Result<crate::token::TokenSet, AuthError> {
239 let listener = bind_listener(self.port_config)
241 .await
242 .map_err(AuthError::ServerBind)?;
243
244 let redirect_uri_url = self
246 .transport
247 .redirect_uri(&listener)
248 .map_err(AuthError::ServerBind)
249 .and_then(|redirect_uri| {
250 url::Url::parse(&redirect_uri).map_err(AuthError::InvalidUrl)
251 })?;
252
253 let pkce = crate::pkce::PkceChallenge::generate();
255
256 let state_token = uuid::Uuid::new_v4().to_string();
258
259 let nonce = self
261 .oidc_jwks
262 .is_some()
263 .then(|| uuid::Uuid::new_v4().to_string());
264
265 let mut auth_url = self.auth_url.clone();
267 AuthUrlParams {
268 client_id: self.client_id.as_str(),
269 redirect_uri: &redirect_uri_url,
270 state_token: &state_token,
271 pkce: &pkce,
272 nonce: nonce.as_deref(),
273 scopes: &self.scopes,
274 }
275 .append_to(&mut auth_url);
276
277 if let Some(ref hook) = self.on_auth_url {
279 let mut extras = ExtraAuthParams::new();
280 hook(&mut extras);
281 extras.apply_to(&mut auth_url);
282 }
283
284 let (outer_tx, outer_rx) = mpsc::channel::<CallbackResult>(1);
286 let (inner_tx, inner_rx) = mpsc::channel::<RenderedHtml>(1);
287 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
288
289 let server_state = ServerState {
291 outer_tx,
292 inner_rx: Arc::new(Mutex::new(Some(inner_rx))),
293 shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
294 };
295
296 let port = listener.local_addr().map_err(AuthError::ServerBind)?.port();
298 let shutdown_arc = Arc::clone(&server_state.shutdown_tx);
299 let transport = Arc::clone(&self.transport);
300 tokio::spawn(async move {
301 transport
302 .run_server(listener, server_state, shutdown_rx)
303 .await
304 });
305
306 if let Some(ref hook) = self.on_server_ready {
308 hook(port);
309 }
310
311 if let Some(ref hook) = self.on_url {
313 hook(&auth_url);
314 }
315
316 if self.open_browser {
318 webbrowser::open(auth_url.as_str()).map_err(|e| AuthError::Browser(e.to_string()))?;
319 } else {
320 tracing::info!(url = auth_url.as_str(), "authorization URL");
321 }
322
323 handle_callback(
325 self,
326 &redirect_uri_url,
327 &state_token,
328 &pkce.code_verifier,
329 nonce.as_deref(),
330 inner_tx,
331 outer_rx,
332 shutdown_arc,
333 )
334 .await
335 }
336
337 pub async fn refresh(
365 &self,
366 refresh_token: &str,
367 ) -> Result<crate::token::TokenSet, RefreshError> {
368 if refresh_token.is_empty() {
369 return Err(RefreshError::NoRefreshToken);
370 }
371 let unvalidated = exchange_refresh_token(
372 &self.http_client,
373 &self.token_url,
374 self.client_id.as_str(),
375 self.client_secret.as_deref(),
376 &self.token_parser,
377 refresh_token,
378 &self.scopes,
379 )
380 .await?;
381 if let Some(oidc_jwks) = &self.oidc_jwks {
382 validate_id_token_if_present(
383 oidc_jwks,
384 unvalidated,
385 self.client_id.as_str(),
386 self.issuer.as_ref().map_or(
387 crate::oidc::IssuerValidation::Skip,
388 crate::oidc::IssuerValidation::MustMatch,
389 ),
390 )
391 .await
392 .map_err(RefreshError::IdToken)
393 } else {
394 Ok(unvalidated.into_validated())
395 }
396 }
397
398 pub async fn refresh_if_expiring(
434 &self,
435 tokens: &crate::token::TokenSet,
436 threshold: std::time::Duration,
437 ) -> Result<crate::token::RefreshOutcome, RefreshError> {
438 if !tokens.expires_within(threshold) {
439 return Ok(crate::token::RefreshOutcome::NotNeeded);
440 }
441 let refresh_token = tokens.refresh_token().ok_or(RefreshError::NoRefreshToken)?;
442 let new_tokens = self.refresh(refresh_token.as_str()).await?;
443 Ok(crate::token::RefreshOutcome::Refreshed(Box::new(
444 new_tokens,
445 )))
446 }
447}
448
449fn parse_oidc_if_requested(
454 id_token: Option<&str>,
455 scopes: &[crate::scope::OAuth2Scope],
456) -> Result<Option<crate::oidc::Token>, crate::error::IdTokenError> {
457 if !scopes.contains(&crate::scope::OAuth2Scope::OpenId) {
458 return Ok(None);
459 }
460 id_token.map(crate::oidc::Token::from_raw_jwt).transpose()
461}
462
463fn parse_scopes(scope_str: &str) -> Vec<OAuth2Scope> {
465 scope_str
466 .split_whitespace()
467 .map(OAuth2Scope::from)
468 .collect()
469}
470
471async fn trigger_shutdown(shutdown_arc: &Arc<Mutex<Option<oneshot::Sender<()>>>>) {
472 let mut guard = shutdown_arc.lock().await;
473 if let Some(tx) = guard.take() {
474 let _ = tx.send(());
475 }
476}
477
478async fn resolve_callback_code(
479 callback_result: CallbackResult,
480 state_token: &str,
481 auth: &CliTokenClient,
482 redirect_uri_url: &url::Url,
483 inner_tx: &mpsc::Sender<RenderedHtml>,
484) -> Result<String, CallbackError> {
485 match validate_callback_code(callback_result, state_token) {
486 Err(err) => {
487 let html = render_error_html(&err.clone().into(), auth, redirect_uri_url).await;
488 let _ = inner_tx.send(RenderedHtml(html)).await;
489 Err(err)
490 }
491 v => v,
492 }
493}
494
495fn validate_callback_code(
496 callback_result: CallbackResult,
497 state_token: &str,
498) -> Result<String, CallbackError> {
499 use subtle::ConstantTimeEq as _;
500
501 match callback_result {
502 CallbackResult::Success { code, state }
503 if state.as_bytes().ct_eq(state_token.as_bytes()).into() =>
504 {
505 Ok(code)
506 }
507 CallbackResult::Success { .. } => Err(CallbackError::StateMismatch),
508 CallbackResult::ProviderError { error, description } => Err(CallbackError::ProviderError {
509 error,
510 description: description.unwrap_or_default(),
511 }),
512 }
513}
514
515async fn validate_id_token_required(
526 oidc_jwks: &OidcJwksConfig,
527 token_set: crate::token::TokenSet<crate::token::Unvalidated>,
528 client_id: &str,
529 issuer: crate::oidc::IssuerValidation<'_>,
530 expected_nonce: Option<&str>,
531) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
532 use crate::error::IdTokenError;
533
534 let oidc = token_set.oidc_token().ok_or(IdTokenError::NoIdToken)?;
535
536 if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
537 validator
538 .validate(oidc.raw())
539 .await
540 .map_err(IdTokenError::JwksValidationFailed)?;
541 }
542
543 oidc.validate_standard_claims(client_id, issuer, expected_nonce)?;
545
546 Ok(token_set.into_validated())
547}
548
549async fn validate_id_token_if_present(
556 oidc_jwks: &OidcJwksConfig,
557 token_set: crate::token::TokenSet<crate::token::Unvalidated>,
558 client_id: &str,
559 issuer: crate::oidc::IssuerValidation<'_>,
560) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
561 use crate::error::IdTokenError;
562
563 let Some(oidc) = token_set.oidc_token() else {
564 return Ok(token_set.into_validated());
565 };
566
567 if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
568 validator
569 .validate(oidc.raw())
570 .await
571 .map_err(IdTokenError::JwksValidationFailed)?;
572 }
573
574 oidc.validate_standard_claims(client_id, issuer, None)?;
576
577 Ok(token_set.into_validated())
578}
579
580#[expect(
581 clippy::too_many_arguments,
582 reason = "private orchestrator function; all args are distinct concerns that cannot be bundled without noise"
583)]
584async fn handle_callback(
585 auth: &CliTokenClient,
586 redirect_uri_url: &url::Url,
587 state_token: &str,
588 code_verifier: &str,
589 nonce: Option<&str>,
590 inner_tx: mpsc::Sender<RenderedHtml>,
591 mut outer_rx: mpsc::Receiver<CallbackResult>,
592 shutdown_arc: Arc<Mutex<Option<oneshot::Sender<()>>>>,
593) -> Result<crate::token::TokenSet<crate::token::Validated>, AuthError> {
594 let callback_result = tokio::select! {
596 result = tokio::time::timeout(auth.timeout, outer_rx.recv()) => {
597 match result {
598 Err(_) => {
599 trigger_shutdown(&shutdown_arc).await;
600 return Err(AuthError::Timeout);
601 }
602 Ok(None) => return Err(AuthError::Server("channel closed".to_string())),
603 Ok(Some(r)) => r,
604 }
605 }
606 _ = tokio::signal::ctrl_c() => {
607 trigger_shutdown(&shutdown_arc).await;
608 return Err(AuthError::Cancelled);
609 }
610 };
611
612 let code = resolve_callback_code(
614 callback_result,
615 state_token,
616 auth,
617 redirect_uri_url,
618 &inner_tx,
619 )
620 .await?;
621
622 let token_set = match exchange_code(
624 &auth.http_client,
625 &auth.token_url,
626 auth.client_id.as_str(),
627 auth.client_secret.as_deref(),
628 &auth.token_parser,
629 &code,
630 redirect_uri_url.as_str(),
631 code_verifier,
632 &auth.scopes,
633 )
634 .await
635 {
636 Ok(ts) => ts,
637 Err(e) => {
638 let html = render_error_html(&e, auth, redirect_uri_url).await;
639 let _ = inner_tx.send(RenderedHtml(html)).await;
640 return Err(e);
641 }
642 };
643
644 let token_set = if let Some(oidc_jwks) = &auth.oidc_jwks {
646 match validate_id_token_required(
647 oidc_jwks,
648 token_set,
649 auth.client_id.as_str(),
650 auth.issuer.as_ref().map_or(
651 crate::oidc::IssuerValidation::Skip,
652 crate::oidc::IssuerValidation::MustMatch,
653 ),
654 nonce,
655 )
656 .await
657 .map_err(AuthError::IdToken)
658 {
659 Ok(ts) => ts,
660 Err(e) => {
661 let html = render_error_html(&e, auth, redirect_uri_url).await;
662 let _ = inner_tx.send(RenderedHtml(html)).await;
663 return Err(e);
664 }
665 }
666 } else {
667 token_set.into_validated()
668 };
669
670 let html = render_success_html(
672 &token_set,
673 token_set.scopes(),
674 redirect_uri_url,
675 auth.client_id.as_str(),
676 auth.success_renderer.as_deref(),
677 auth.success_html.as_deref(),
678 )
679 .await;
680 let _ = inner_tx.send(RenderedHtml(html)).await;
681
682 Ok(token_set)
683}
684
685async fn render_error_html(
686 err: &AuthError,
687 auth: &CliTokenClient,
688 redirect_uri_url: &url::Url,
689) -> String {
690 let ctx = crate::pages::ErrorPageContext::new(
691 err,
692 &auth.scopes,
693 redirect_uri_url,
694 auth.client_id.as_str(),
695 );
696 if let Some(renderer) = auth.error_renderer.as_deref() {
697 renderer.render_error(&ctx).await
698 } else if let Some(html) = auth.error_html.as_deref() {
699 html.to_string()
700 } else {
701 crate::pages::DefaultErrorPageRenderer
702 .render_error(&ctx)
703 .await
704 }
705}
706
707async fn render_success_html(
708 token_set: &crate::token::TokenSet,
709 scopes: &[OAuth2Scope],
710 redirect_uri_url: &url::Url,
711 client_id: &str,
712 success_renderer: Option<&(dyn crate::pages::SuccessPageRenderer + Send + Sync)>,
713 success_html: Option<&str>,
714) -> String {
715 let ctx = crate::pages::PageContext::new(
716 token_set.oidc().map(crate::oidc::Token::claims),
717 scopes,
718 redirect_uri_url,
719 client_id,
720 token_set.expires_at(),
721 token_set.refresh_token().is_some(),
722 );
723 if let Some(renderer) = success_renderer {
724 renderer.render_success(&ctx).await
725 } else if let Some(html) = success_html {
726 html.to_string()
727 } else {
728 crate::pages::DefaultSuccessPageRenderer
729 .render_success(&ctx)
730 .await
731 }
732}
733
734#[expect(
735 clippy::too_many_arguments,
736 reason = "all arguments are distinct OAuth2 code exchange parameters; grouping them would obscure their individual meanings"
737)]
738async fn exchange_code(
739 http_client: &reqwest::Client,
740 token_url: &url::Url,
741 client_id: &str,
742 client_secret: Option<&str>,
743 token_parser: &TokenParser,
744 code: &str,
745 redirect_uri: &str,
746 code_verifier: &str,
747 scopes: &[crate::scope::OAuth2Scope],
748) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, AuthError> {
749 let mut params = vec![
750 ("grant_type", "authorization_code"),
751 ("code", code),
752 ("redirect_uri", redirect_uri),
753 ("client_id", client_id),
754 ("code_verifier", code_verifier),
755 ];
756 if let Some(secret) = client_secret {
757 params.push(("client_secret", secret));
758 }
759
760 let t0 = std::time::SystemTime::now();
761 let response = http_client
762 .post(token_url.as_str())
763 .header(reqwest::header::ACCEPT, "application/json")
764 .form(¶ms)
765 .send()
766 .await?;
767
768 if !response.status().is_success() {
769 let status = response.status().as_u16();
770 let body_bytes = response.bytes().await.unwrap_or_default();
771 let body = String::from_utf8_lossy(&body_bytes).into_owned();
772 return Err(AuthError::TokenExchange { status, body });
773 }
774
775 let body = response.text().await?;
776 let fields = token_parser(&body).map_err(|e| AuthError::TokenParse(format!("{e}: {body}")))?;
777
778 let expires_at = fields
779 .expires_in
780 .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs)));
781
782 let oidc =
783 parse_oidc_if_requested(fields.id_token.as_deref(), scopes).map_err(AuthError::IdToken)?;
784
785 let resolved_scopes = fields
787 .scope
788 .as_deref()
789 .map_or_else(|| scopes.to_vec(), parse_scopes);
790
791 Ok(crate::token::TokenSet::new(
792 fields.access_token,
793 fields.refresh_token,
794 expires_at,
795 fields.token_type.unwrap_or_else(|| "Bearer".to_string()),
796 oidc,
797 resolved_scopes,
798 ))
799}
800
801async fn exchange_refresh_token(
802 http_client: &reqwest::Client,
803 token_url: &url::Url,
804 client_id: &str,
805 client_secret: Option<&str>,
806 token_parser: &TokenParser,
807 refresh_token: &str,
808 scopes: &[crate::scope::OAuth2Scope],
809) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, RefreshError> {
810 let scope_str = (!scopes.is_empty()).then(|| {
812 scopes
813 .iter()
814 .map(ToString::to_string)
815 .collect::<Vec<_>>()
816 .join(" ")
817 });
818
819 let mut params = vec![
820 ("grant_type", "refresh_token"),
821 ("refresh_token", refresh_token),
822 ("client_id", client_id),
823 ];
824 if let Some(secret) = client_secret {
825 params.push(("client_secret", secret));
826 }
827 if let Some(ref s) = scope_str {
828 params.push(("scope", s.as_str()));
829 }
830
831 let t0 = std::time::SystemTime::now();
832 let response = http_client
833 .post(token_url.as_str())
834 .header(reqwest::header::ACCEPT, "application/json")
835 .form(¶ms)
836 .send()
837 .await?; if !response.status().is_success() {
840 let status = response.status().as_u16();
841 let body_bytes = response.bytes().await.unwrap_or_default();
842 let body = String::from_utf8_lossy(&body_bytes).into_owned();
843 return Err(RefreshError::TokenExchange { status, body });
844 }
845
846 let body = response.text().await?;
847 let fields =
848 token_parser(&body).map_err(|e| RefreshError::TokenParse(format!("{e}: {body}")))?;
849
850 let expires_at = fields
851 .expires_in
852 .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs)));
853
854 let oidc = parse_oidc_if_requested(fields.id_token.as_deref(), scopes)
855 .map_err(RefreshError::IdToken)?;
856
857 let resolved_scopes = fields
859 .scope
860 .as_deref()
861 .map_or_else(|| scopes.to_vec(), parse_scopes);
862
863 let resolved_refresh_token = fields
868 .refresh_token
869 .or_else(|| Some(refresh_token.to_string()));
870
871 Ok(crate::token::TokenSet::new(
872 fields.access_token,
873 resolved_refresh_token,
874 expires_at,
875 fields.token_type.unwrap_or_else(|| "Bearer".to_string()),
876 oidc,
877 resolved_scopes,
878 ))
879}
880
881#[non_exhaustive]
905pub struct Http;
906
907pub struct Https(Option<crate::tls::TlsCertificate>);
912
913pub trait IntoTransport: sealed::Sealed {
918 fn into_transport(self) -> Arc<dyn Transport>;
920}
921
922impl sealed::Sealed for Http {}
923impl IntoTransport for Http {
924 fn into_transport(self) -> Arc<dyn Transport> {
925 Arc::new(HttpTransport)
926 }
927}
928
929impl sealed::Sealed for Https {}
930impl IntoTransport for Https {
931 fn into_transport(self) -> Arc<dyn Transport> {
932 match self.0 {
933 Some(cert) => Arc::new(crate::server::HttpsCustomTransport {
934 acceptor: cert.acceptor,
935 }),
936 None => Arc::new(crate::server::HttpsSelfSignedTransport),
937 }
938 }
939}
940
941mod sealed {
942 pub trait Sealed {}
943}
944
945#[non_exhaustive]
947pub struct NoClientId;
948#[non_exhaustive]
950pub struct HasClientId(ClientId);
951#[non_exhaustive]
953pub struct NoAuthUrl;
954#[non_exhaustive]
956pub struct HasAuthUrl(url::Url);
957#[non_exhaustive]
959pub struct NoTokenUrl;
960#[non_exhaustive]
962pub struct HasTokenUrl(url::Url);
963#[non_exhaustive]
965pub struct NoOidc;
966#[non_exhaustive]
972pub struct OidcPending;
973pub struct JwksEnabled(JwksValidatorStorage);
977#[non_exhaustive]
981pub struct JwksDisabled;
982
983struct BuilderConfig {
989 client_secret: Option<String>,
990 issuer: Option<url::Url>,
991 scopes: std::collections::BTreeSet<OAuth2Scope>,
992 port_config: PortConfig,
993 success_html: Option<String>,
994 error_html: Option<String>,
995 success_renderer: Option<SuccessRendererStorage>,
996 error_renderer: Option<ErrorRendererStorage>,
997 open_browser: bool,
998 timeout: std::time::Duration,
999 on_auth_url: Option<OnAuthUrlCallback>,
1000 on_url: Option<OnUrlCallback>,
1001 on_server_ready: Option<OnServerReadyCallback>,
1002 token_parser: Option<TokenParser>,
1003}
1004
1005impl Default for BuilderConfig {
1006 fn default() -> Self {
1007 Self {
1008 client_secret: None,
1009 scopes: std::collections::BTreeSet::new(),
1010 port_config: PortConfig::Random,
1011 success_html: None,
1012 error_html: None,
1013 success_renderer: None,
1014 error_renderer: None,
1015 open_browser: true,
1016 timeout: std::time::Duration::from_secs(TIMEOUT_DURATION_IN_SECONDS),
1017 on_auth_url: None,
1018 on_url: None,
1019 on_server_ready: None,
1020 issuer: None,
1021 token_parser: None,
1022 }
1023 }
1024}
1025
1026pub struct CliTokenClientBuilder<
1084 C = NoClientId,
1085 A = NoAuthUrl,
1086 T = NoTokenUrl,
1087 O = NoOidc,
1088 S = Http,
1089> {
1090 client_id: C,
1091 auth_url: A,
1092 token_url: T,
1093 oidc: O,
1094 scheme: S,
1095 config: BuilderConfig,
1096}
1097
1098impl Default for CliTokenClientBuilder {
1099 fn default() -> Self {
1100 Self {
1101 client_id: NoClientId,
1102 auth_url: NoAuthUrl,
1103 token_url: NoTokenUrl,
1104 oidc: NoOidc,
1105 scheme: Http,
1106 config: BuilderConfig::default(),
1107 }
1108 }
1109}
1110
1111impl CliTokenClientBuilder {
1116 #[must_use]
1126 pub fn from_open_id_configuration(
1127 open_id_configuration: &OpenIdConfiguration,
1128 ) -> CliTokenClientBuilder<NoClientId, HasAuthUrl, HasTokenUrl, OidcPending, Http> {
1129 CliTokenClientBuilder {
1130 client_id: NoClientId,
1131 auth_url: HasAuthUrl(open_id_configuration.authorization_endpoint().clone()),
1132 token_url: HasTokenUrl(open_id_configuration.token_endpoint().clone()),
1133 oidc: OidcPending,
1134 scheme: Http,
1135 config: BuilderConfig {
1136 issuer: Some(open_id_configuration.issuer().clone()),
1137 scopes: std::collections::BTreeSet::from([OAuth2Scope::OpenId]),
1138 ..BuilderConfig::default()
1139 },
1140 }
1141 }
1142}
1143
1144impl<C, A, T, O, S> CliTokenClientBuilder<C, A, T, O, S> {
1147 #[must_use]
1149 pub fn client_id(self, v: impl Into<String>) -> CliTokenClientBuilder<HasClientId, A, T, O, S> {
1150 CliTokenClientBuilder {
1151 client_id: HasClientId(ClientId(v.into())),
1152 auth_url: self.auth_url,
1153 token_url: self.token_url,
1154 oidc: self.oidc,
1155 scheme: self.scheme,
1156 config: self.config,
1157 }
1158 }
1159
1160 #[must_use]
1162 pub fn auth_url(self, v: url::Url) -> CliTokenClientBuilder<C, HasAuthUrl, T, O, S> {
1163 CliTokenClientBuilder {
1164 client_id: self.client_id,
1165 auth_url: HasAuthUrl(v),
1166 token_url: self.token_url,
1167 oidc: self.oidc,
1168 scheme: self.scheme,
1169 config: self.config,
1170 }
1171 }
1172
1173 #[must_use]
1175 pub fn token_url(self, v: url::Url) -> CliTokenClientBuilder<C, A, HasTokenUrl, O, S> {
1176 CliTokenClientBuilder {
1177 client_id: self.client_id,
1178 auth_url: self.auth_url,
1179 token_url: HasTokenUrl(v),
1180 oidc: self.oidc,
1181 scheme: self.scheme,
1182 config: self.config,
1183 }
1184 }
1185
1186 #[must_use]
1188 pub fn client_secret(mut self, v: impl Into<String>) -> Self {
1189 self.config.client_secret = Some(v.into());
1190 self
1191 }
1192
1193 #[must_use]
1204 pub fn add_scopes(mut self, v: impl IntoIterator<Item = RequestScope>) -> Self {
1205 self.config
1206 .scopes
1207 .extend(v.into_iter().map(OAuth2Scope::from));
1208 self
1209 }
1210
1211 #[must_use]
1216 pub const fn port_hint(mut self, v: u16) -> Self {
1217 self.config.port_config = PortConfig::Hint(v);
1218 self
1219 }
1220
1221 #[must_use]
1241 pub const fn require_port(mut self, v: u16) -> Self {
1242 self.config.port_config = PortConfig::Required(v);
1243 self
1244 }
1245
1246 #[must_use]
1248 pub fn success_html(mut self, v: impl Into<String>) -> Self {
1249 self.config.success_html = Some(v.into());
1250 self
1251 }
1252
1253 #[must_use]
1255 pub fn error_html(mut self, v: impl Into<String>) -> Self {
1256 self.config.error_html = Some(v.into());
1257 self
1258 }
1259
1260 #[must_use]
1264 pub fn success_renderer(mut self, r: impl SuccessPageRenderer + 'static) -> Self {
1265 self.config.success_renderer = Some(Box::new(r));
1266 self
1267 }
1268
1269 #[must_use]
1273 pub fn error_renderer(mut self, r: impl ErrorPageRenderer + 'static) -> Self {
1274 self.config.error_renderer = Some(Box::new(r));
1275 self
1276 }
1277
1278 #[must_use]
1283 pub const fn open_browser(mut self, v: bool) -> Self {
1284 self.config.open_browser = v;
1285 self
1286 }
1287
1288 #[must_use]
1292 pub const fn timeout(mut self, v: std::time::Duration) -> Self {
1293 self.config.timeout = v;
1294 self
1295 }
1296
1297 #[must_use]
1309 pub fn token_response_type<R>(mut self) -> Self
1310 where
1311 R: serde::de::DeserializeOwned
1312 + Into<crate::token_response::TokenResponseFields>
1313 + Send
1314 + 'static,
1315 {
1316 self.config.token_parser = Some(crate::token_response::custom_token_parser::<R>());
1317 self
1318 }
1319
1320 #[must_use]
1348 pub fn on_auth_url(mut self, f: impl Fn(&mut ExtraAuthParams) + Send + Sync + 'static) -> Self {
1349 self.config.on_auth_url = Some(Box::new(f));
1350 self
1351 }
1352
1353 #[must_use]
1357 pub fn on_url(mut self, f: impl Fn(&url::Url) + Send + Sync + 'static) -> Self {
1358 self.config.on_url = Some(Box::new(f));
1359 self
1360 }
1361
1362 #[must_use]
1366 pub fn on_server_ready(mut self, f: impl Fn(u16) + Send + Sync + 'static) -> Self {
1367 self.config.on_server_ready = Some(Box::new(f));
1368 self
1369 }
1370
1371 #[must_use]
1383 pub fn use_https(self) -> CliTokenClientBuilder<C, A, T, O, Https> {
1384 CliTokenClientBuilder {
1385 client_id: self.client_id,
1386 auth_url: self.auth_url,
1387 token_url: self.token_url,
1388 oidc: self.oidc,
1389 scheme: Https(None),
1390 config: self.config,
1391 }
1392 }
1393
1394 #[must_use]
1424 pub fn use_https_with(
1425 self,
1426 certificate: crate::tls::TlsCertificate,
1427 ) -> CliTokenClientBuilder<C, A, T, O, Https> {
1428 CliTokenClientBuilder {
1429 client_id: self.client_id,
1430 auth_url: self.auth_url,
1431 token_url: self.token_url,
1432 oidc: self.oidc,
1433 scheme: Https(Some(certificate)),
1434 config: self.config,
1435 }
1436 }
1437}
1438
1439impl<C, A, T, S> CliTokenClientBuilder<C, A, T, NoOidc, S> {
1442 #[must_use]
1457 pub fn with_openid_scope(mut self) -> CliTokenClientBuilder<C, A, T, OidcPending, S> {
1458 self.config.scopes.insert(OAuth2Scope::OpenId);
1459 CliTokenClientBuilder {
1460 client_id: self.client_id,
1461 auth_url: self.auth_url,
1462 token_url: self.token_url,
1463 oidc: OidcPending,
1464 scheme: self.scheme,
1465 config: self.config,
1466 }
1467 }
1468}
1469
1470impl<C, A, T, S> CliTokenClientBuilder<C, A, T, OidcPending, S> {
1473 #[must_use]
1481 pub fn issuer(mut self, v: url::Url) -> Self {
1482 self.config.issuer = Some(v);
1483 self
1484 }
1485
1486 #[must_use]
1493 pub fn jwks_validator(
1494 self,
1495 v: Box<dyn JwksValidator>,
1496 ) -> CliTokenClientBuilder<C, A, T, JwksEnabled, S> {
1497 CliTokenClientBuilder {
1498 client_id: self.client_id,
1499 auth_url: self.auth_url,
1500 token_url: self.token_url,
1501 oidc: JwksEnabled(v),
1502 scheme: self.scheme,
1503 config: self.config,
1504 }
1505 }
1506
1507 #[must_use]
1522 pub fn without_jwks_validation(self) -> CliTokenClientBuilder<C, A, T, JwksDisabled, S> {
1523 CliTokenClientBuilder {
1524 client_id: self.client_id,
1525 auth_url: self.auth_url,
1526 token_url: self.token_url,
1527 oidc: JwksDisabled,
1528 scheme: self.scheme,
1529 config: self.config,
1530 }
1531 }
1532}
1533
1534impl<A, T, S> CliTokenClientBuilder<HasClientId, A, T, OidcPending, S> {
1535 #[must_use]
1542 pub fn with_open_id_configuration_jwks_validator(
1543 self,
1544 open_id_configuration: &OpenIdConfiguration,
1545 ) -> CliTokenClientBuilder<HasClientId, A, T, JwksEnabled, S> {
1546 let client_id = self.client_id.0.as_str().to_owned();
1547 let validator = Box::new(RemoteJwksValidator::from_open_id_configuration(
1548 open_id_configuration,
1549 client_id,
1550 ));
1551 CliTokenClientBuilder {
1552 client_id: self.client_id,
1553 auth_url: self.auth_url,
1554 token_url: self.token_url,
1555 oidc: JwksEnabled(validator),
1556 scheme: self.scheme,
1557 config: self.config,
1558 }
1559 }
1560}
1561
1562impl<C, A, T, S> CliTokenClientBuilder<C, A, T, JwksEnabled, S> {
1563 #[must_use]
1569 pub fn issuer(mut self, v: url::Url) -> Self {
1570 self.config.issuer = Some(v);
1571 self
1572 }
1573}
1574
1575impl<C, A, T, S> CliTokenClientBuilder<C, A, T, JwksDisabled, S> {
1576 #[must_use]
1582 pub fn issuer(mut self, v: url::Url) -> Self {
1583 self.config.issuer = Some(v);
1584 self
1585 }
1586}
1587
1588impl<S: IntoTransport> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksEnabled, S> {
1589 #[must_use]
1595 pub fn build(mut self) -> CliTokenClient {
1596 self.config.scopes.insert(OAuth2Scope::OpenId);
1597 build_client(
1598 self.client_id.0,
1599 self.auth_url.0,
1600 self.token_url.0,
1601 self.config,
1602 Some(OidcJwksConfig::Enabled(self.oidc.0)),
1603 self.scheme.into_transport(),
1604 )
1605 }
1606}
1607
1608impl<S: IntoTransport>
1609 CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksDisabled, S>
1610{
1611 #[must_use]
1617 pub fn build(mut self) -> CliTokenClient {
1618 self.config.scopes.insert(OAuth2Scope::OpenId);
1619 build_client(
1620 self.client_id.0,
1621 self.auth_url.0,
1622 self.token_url.0,
1623 self.config,
1624 Some(OidcJwksConfig::Disabled),
1625 self.scheme.into_transport(),
1626 )
1627 }
1628}
1629
1630impl<S: IntoTransport> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc, S> {
1631 #[must_use]
1642 pub fn build(self) -> CliTokenClient {
1643 build_client(
1644 self.client_id.0,
1645 self.auth_url.0,
1646 self.token_url.0,
1647 self.config,
1648 None,
1649 self.scheme.into_transport(),
1650 )
1651 }
1652}
1653
1654fn build_client(
1655 client_id: ClientId,
1656 auth_url: url::Url,
1657 token_url: url::Url,
1658 config: BuilderConfig,
1659 oidc_jwks: Option<OidcJwksConfig>,
1660 transport: Arc<dyn Transport>,
1661) -> CliTokenClient {
1662 CliTokenClient {
1663 client_id,
1664 client_secret: config.client_secret,
1665 auth_url,
1666 token_url,
1667 issuer: config.issuer,
1668 scopes: config.scopes.into_iter().collect(),
1669 port_config: config.port_config,
1670 success_html: config.success_html,
1671 error_html: config.error_html,
1672 success_renderer: config.success_renderer,
1673 error_renderer: config.error_renderer,
1674 open_browser: config.open_browser,
1675 timeout: config.timeout,
1676 on_auth_url: config.on_auth_url,
1677 on_url: config.on_url,
1678 on_server_ready: config.on_server_ready,
1679 oidc_jwks,
1680 http_client: reqwest::Client::builder()
1681 .connect_timeout(std::time::Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECONDS))
1682 .timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECONDS))
1683 .build()
1684 .unwrap_or_default(),
1685 transport,
1686 token_parser: config.token_parser.unwrap_or_else(default_token_parser),
1687 }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692 #![expect(
1693 clippy::indexing_slicing,
1694 clippy::expect_used,
1695 clippy::unwrap_used,
1696 reason = "tests do not need to meet production lint standards"
1697 )]
1698
1699 use super::{
1700 AuthUrlParams, CliTokenClient, CliTokenClientBuilder, ExtraAuthParams, HasAuthUrl,
1701 HasClientId, HasTokenUrl, NoOidc, parse_scopes,
1702 };
1703 use crate::jwks::{JwksValidationError, JwksValidator};
1704 use crate::oidc::Token;
1705 use crate::scope::OAuth2Scope;
1706 use async_trait::async_trait;
1707
1708 fn fake_jwt(sub: &str, email: &str) -> String {
1709 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1710 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1711 let claims = URL_SAFE_NO_PAD.encode(format!(
1712 r#"{{"sub":"{sub}","email":"{email}","iss":"https://accounts.example.com","iat":1000000000,"exp":9999999999}}"#
1713 ));
1714 format!("{header}.{claims}.fakesig")
1715 }
1716
1717 fn fake_jwt_google_style(
1718 sub: &str,
1719 email: &str,
1720 name: &str,
1721 picture: &str,
1722 aud: &str,
1723 ) -> String {
1724 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1725 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1726 let claims = URL_SAFE_NO_PAD.encode(format!(
1727 r#"{{"iss":"https://accounts.google.com","aud":"{aud}","sub":"{sub}","email":"{email}","email_verified":true,"name":"{name}","picture":"{picture}","iat":1000000000,"exp":9999999999}}"#
1728 ));
1729 format!("{header}.{claims}.fakesig")
1730 }
1731
1732 #[test]
1733 fn oidc_token_from_raw_jwt_returns_ok_for_valid_fake_jwt() {
1734 let jwt = fake_jwt("user_42", "user@example.com");
1735 let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for valid fake JWT");
1736 assert_eq!(oidc.claims().sub().as_str(), "user_42");
1737 assert_eq!(
1738 oidc.claims().email().map(crate::oidc::Email::as_str),
1739 Some("user@example.com")
1740 );
1741 }
1742
1743 #[test]
1744 fn oidc_token_from_raw_jwt_returns_err_for_invalid_input() {
1745 let result = Token::from_raw_jwt("not.a.jwt");
1746 assert!(result.is_err(), "expected Err for invalid JWT");
1747 }
1748
1749 #[test]
1750 fn oidc_token_from_raw_jwt_with_aud_claim_returns_ok() {
1751 let jwt = fake_jwt_google_style(
1754 "1234567890",
1755 "user@gmail.com",
1756 "Test User",
1757 "https://example.com/photo.jpg",
1758 "my-client-id.apps.googleusercontent.com",
1759 );
1760 let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for JWT with aud claim");
1761 assert_eq!(oidc.claims().sub().as_str(), "1234567890");
1762 assert_eq!(
1763 oidc.claims().email().map(crate::oidc::Email::as_str),
1764 Some("user@gmail.com")
1765 );
1766 assert_eq!(oidc.claims().name(), Some("Test User"));
1767 assert_eq!(
1768 oidc.claims().picture().map(|p| p.as_url().as_str()),
1769 Some("https://example.com/photo.jpg")
1770 );
1771 assert!(oidc.claims().email().unwrap().is_verified());
1772 }
1773
1774 fn valid_builder() -> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc> {
1775 CliTokenClient::builder()
1776 .client_id("test-client")
1777 .auth_url(url::Url::parse("https://example.com/auth").unwrap())
1778 .token_url(url::Url::parse("https://example.com/token").unwrap())
1779 }
1780
1781 #[test]
1782 fn builder_returns_cli_token_client_builder() {
1783 let _builder: CliTokenClientBuilder = CliTokenClient::builder();
1785 }
1786
1787 #[test]
1793 fn build_with_valid_inputs_returns_client() {
1794 let _client = valid_builder().build();
1795 }
1796
1797 #[test]
1800 fn rfc_6749_s5_1_scope_fallback_uses_requested_scopes_when_response_omits_scope() {
1801 let requested = vec![OAuth2Scope::OpenId, OAuth2Scope::Email];
1805 let resolved: Vec<OAuth2Scope> = None::<String>
1807 .as_deref()
1808 .map_or_else(|| requested.clone(), parse_scopes);
1809 assert_eq!(resolved, requested);
1810
1811 let resolved_from_response: Vec<OAuth2Scope> = Some("openid profile".to_string())
1813 .as_deref()
1814 .map_or_else(|| requested.clone(), parse_scopes);
1815 assert_eq!(
1816 resolved_from_response,
1817 vec![OAuth2Scope::OpenId, OAuth2Scope::Profile]
1818 );
1819 }
1820
1821 #[test]
1822 fn oidc_token_from_raw_jwt_populates_iss_aud_iat_exp() {
1823 let jwt = fake_jwt_google_style(
1824 "sub-iss-test",
1825 "user@example.com",
1826 "Test User",
1827 "https://example.com/photo.jpg",
1828 "my-client-id",
1829 );
1830 let oidc = Token::from_raw_jwt(&jwt).expect("should decode");
1831 let claims = oidc.claims();
1832 assert_eq!(
1833 claims.iss().as_url(),
1834 &url::Url::parse("https://accounts.google.com").unwrap()
1835 );
1836 assert_eq!(claims.aud().len(), 1);
1837 assert_eq!(claims.aud()[0].as_str(), "my-client-id");
1838 assert!(
1840 claims.iat() > std::time::UNIX_EPOCH,
1841 "iat should be after epoch"
1842 );
1843 assert!(
1844 claims.exp() > std::time::UNIX_EPOCH,
1845 "exp should be after epoch"
1846 );
1847 }
1848
1849 struct AcceptAll;
1850
1851 #[async_trait]
1852 impl JwksValidator for AcceptAll {
1853 async fn validate(&self, _raw_token: &str) -> Result<(), JwksValidationError> {
1854 Ok(())
1855 }
1856 }
1857
1858 #[test]
1859 fn build_with_jwks_validator_and_openid_scope_succeeds() {
1860 let _client = valid_builder()
1861 .with_openid_scope()
1862 .jwks_validator(Box::new(AcceptAll))
1863 .build();
1864 }
1865
1866 fn make_open_id_configuration() -> crate::oidc::OpenIdConfiguration {
1870 use url::Url;
1871 crate::oidc::OpenIdConfiguration::new_for_test(
1872 Url::parse("https://accounts.example.com").unwrap(),
1873 Url::parse("https://accounts.example.com/authorize").unwrap(),
1874 Url::parse("https://accounts.example.com/token").unwrap(),
1875 Url::parse("https://accounts.example.com/.well-known/jwks.json").unwrap(),
1876 )
1877 }
1878
1879 #[test]
1884 fn from_open_id_configuration_always_includes_openid_scope() {
1885 let config = make_open_id_configuration();
1886 let _client = CliTokenClientBuilder::from_open_id_configuration(&config)
1889 .client_id("test-client")
1890 .without_jwks_validation()
1891 .build();
1892 }
1893
1894 #[test]
1897 fn extra_auth_params_append_accumulates_pairs() {
1898 let mut params = ExtraAuthParams::new();
1899 params.append("access_type", "offline");
1900 params.append("prompt", "consent");
1901 assert_eq!(params.pairs.len(), 2);
1902 assert_eq!(
1903 params.pairs[0],
1904 ("access_type".to_string(), "offline".to_string())
1905 );
1906 assert_eq!(
1907 params.pairs[1],
1908 ("prompt".to_string(), "consent".to_string())
1909 );
1910 }
1911
1912 #[test]
1913 fn extra_auth_params_apply_to_adds_non_reserved_keys() {
1914 let mut params = ExtraAuthParams::new();
1915 params.append("access_type", "offline");
1916 let mut url = url::Url::parse("https://example.com/auth").unwrap();
1917 params.apply_to(&mut url);
1918 let pairs: Vec<(_, _)> = url.query_pairs().collect();
1919 assert_eq!(pairs.len(), 1);
1920 assert_eq!(pairs[0].0, "access_type");
1921 assert_eq!(pairs[0].1, "offline");
1922 }
1923
1924 #[test]
1925 fn extra_auth_params_apply_to_drops_reserved_keys() {
1926 for reserved in AuthUrlParams::KEYS {
1928 let mut params = ExtraAuthParams::new();
1929 params.append(*reserved, "injected");
1930 let mut url = url::Url::parse("https://example.com/auth").unwrap();
1931 params.apply_to(&mut url);
1932 assert!(
1933 url.query_pairs().next().is_none(),
1934 "reserved key '{reserved}' should have been dropped"
1935 );
1936 }
1937 }
1938
1939 #[test]
1940 fn extra_auth_params_apply_to_passes_non_reserved_and_drops_reserved() {
1941 let mut params = ExtraAuthParams::new();
1942 params.append("state", "injected"); params.append("access_type", "offline"); let mut url = url::Url::parse("https://example.com/auth").unwrap();
1945 params.apply_to(&mut url);
1946 let pairs: Vec<(_, _)> = url.query_pairs().collect();
1947 assert_eq!(pairs.len(), 1);
1948 assert_eq!(pairs[0].0, "access_type");
1949 }
1950}