1use crate::error::{AuthError, CallbackError, RefreshError};
2use crate::jwks::{JwksValidator, JwksValidatorStorage, RemoteJwksValidator};
3use crate::oidc::OpenIdConfiguration;
4
5#[non_exhaustive]
9pub enum OidcJwksConfig {
10 Enabled(JwksValidatorStorage),
12 Disabled,
18}
19use crate::pages::{
20 ErrorPageRenderer, ErrorRendererStorage, SuccessPageRenderer, SuccessRendererStorage,
21};
22use crate::scope::{OAuth2Scope, RequestScope};
23use crate::server::{
24 CallbackResult, PortConfig, RenderedHtml, ServerState, bind_listener,
25 redirect_uri_from_listener, run_callback_server,
26};
27use std::sync::Arc;
28use tokio::sync::{Mutex, mpsc, oneshot};
29
30type OnAuthUrlCallback = Box<dyn Fn(&mut url::Url) + Send + Sync + 'static>;
31type OnUrlCallback = Box<dyn Fn(&url::Url) + Send + Sync + 'static>;
32type OnServerReadyCallback = Box<dyn Fn(u16) + Send + Sync + 'static>;
33
34#[derive(Debug, Clone)]
36pub struct ClientId(String);
37
38impl ClientId {
39 pub(crate) fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44const TIMEOUT_DURATION_IN_SECONDS: u64 = 300;
45const HTTP_CONNECT_TIMEOUT_SECONDS: u64 = 10;
46const HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 30;
47
48pub struct CliTokenClient {
56 client_id: ClientId,
57 client_secret: Option<String>,
58 auth_url: url::Url,
59 token_url: url::Url,
60 issuer: Option<url::Url>,
61 scopes: Vec<OAuth2Scope>,
62 port_config: PortConfig,
63 success_html: Option<String>,
64 error_html: Option<String>,
65 success_renderer: Option<SuccessRendererStorage>,
66 error_renderer: Option<ErrorRendererStorage>,
67 open_browser: bool,
68 timeout: std::time::Duration,
69 on_auth_url: Option<OnAuthUrlCallback>,
70 on_url: Option<OnUrlCallback>,
71 on_server_ready: Option<OnServerReadyCallback>,
72 oidc_jwks: Option<OidcJwksConfig>,
73 http_client: reqwest::Client,
74}
75
76impl CliTokenClient {
77 #[must_use]
79 pub fn builder() -> CliTokenClientBuilder {
80 CliTokenClientBuilder::default()
81 }
82
83 pub async fn run_authorization_flow(&self) -> Result<crate::token::TokenSet, AuthError> {
130 let listener = bind_listener(self.port_config)
132 .await
133 .map_err(AuthError::ServerBind)?;
134
135 let redirect_uri_url = redirect_uri_from_listener(&listener)
137 .map_err(AuthError::ServerBind)
138 .and_then(|redirect_uri| {
139 url::Url::parse(&redirect_uri).map_err(AuthError::InvalidUrl)
140 })?;
141
142 let pkce = crate::pkce::PkceChallenge::generate();
144
145 let state_token = uuid::Uuid::new_v4().to_string();
147
148 let nonce = self
150 .oidc_jwks
151 .is_some()
152 .then(|| uuid::Uuid::new_v4().to_string());
153
154 let mut auth_url = self.auth_url.clone();
156 auth_url
157 .query_pairs_mut()
158 .append_pair("response_type", "code")
159 .append_pair("client_id", self.client_id.as_str())
160 .append_pair("redirect_uri", redirect_uri_url.as_str())
161 .append_pair("state", &state_token)
162 .append_pair("code_challenge", &pkce.code_challenge)
163 .append_pair("code_challenge_method", pkce.code_challenge_method);
164
165 if let Some(ref n) = nonce {
166 auth_url.query_pairs_mut().append_pair("nonce", n);
167 }
168
169 if !self.scopes.is_empty() {
170 let scope_str = self
171 .scopes
172 .iter()
173 .map(ToString::to_string)
174 .collect::<Vec<_>>()
175 .join(" ");
176 auth_url.query_pairs_mut().append_pair("scope", &scope_str);
177 }
178
179 if let Some(ref hook) = self.on_auth_url {
181 hook(&mut auth_url);
182 }
183
184 let (outer_tx, outer_rx) = mpsc::channel::<CallbackResult>(1);
186 let (inner_tx, inner_rx) = mpsc::channel::<RenderedHtml>(1);
187 let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
188
189 let server_state = ServerState {
191 outer_tx,
192 inner_rx: Arc::new(Mutex::new(Some(inner_rx))),
193 shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
194 };
195
196 let port = listener.local_addr().map_err(AuthError::ServerBind)?.port();
198 let shutdown_arc = Arc::clone(&server_state.shutdown_tx);
199 tokio::spawn(run_callback_server(listener, server_state, shutdown_rx));
200
201 if let Some(ref hook) = self.on_server_ready {
203 hook(port);
204 }
205
206 if let Some(ref hook) = self.on_url {
208 hook(&auth_url);
209 }
210
211 if self.open_browser {
213 webbrowser::open(auth_url.as_str()).map_err(|e| AuthError::Browser(e.to_string()))?;
214 } else {
215 tracing::info!(url = auth_url.as_str(), "authorization URL");
216 }
217
218 handle_callback(
220 self,
221 &redirect_uri_url,
222 &state_token,
223 &pkce.code_verifier,
224 nonce.as_deref(),
225 inner_tx,
226 outer_rx,
227 shutdown_arc,
228 )
229 .await
230 }
231
232 pub async fn refresh(
260 &self,
261 refresh_token: &str,
262 ) -> Result<crate::token::TokenSet, RefreshError> {
263 if refresh_token.is_empty() {
264 return Err(RefreshError::NoRefreshToken);
265 }
266 let unvalidated = exchange_refresh_token(
267 &self.http_client,
268 &self.token_url,
269 self.client_id.as_str(),
270 self.client_secret.as_deref(),
271 refresh_token,
272 &self.scopes,
273 )
274 .await?;
275 if let Some(oidc_jwks) = &self.oidc_jwks {
276 validate_id_token_if_present(
277 oidc_jwks,
278 unvalidated,
279 self.client_id.as_str(),
280 self.issuer.as_ref().map_or(
281 crate::oidc::IssuerValidation::Skip,
282 crate::oidc::IssuerValidation::MustMatch,
283 ),
284 )
285 .await
286 .map_err(RefreshError::IdToken)
287 } else {
288 Ok(unvalidated.into_validated())
289 }
290 }
291
292 pub async fn refresh_if_expiring(
328 &self,
329 tokens: &crate::token::TokenSet,
330 threshold: std::time::Duration,
331 ) -> Result<crate::token::RefreshOutcome, RefreshError> {
332 if !tokens.expires_within(threshold) {
333 return Ok(crate::token::RefreshOutcome::NotNeeded);
334 }
335 let refresh_token = tokens.refresh_token().ok_or(RefreshError::NoRefreshToken)?;
336 let new_tokens = self.refresh(refresh_token.as_str()).await?;
337 Ok(crate::token::RefreshOutcome::Refreshed(Box::new(
338 new_tokens,
339 )))
340 }
341}
342
343#[derive(serde::Deserialize)]
344struct TokenResponse {
345 access_token: String,
346 refresh_token: Option<String>,
347 expires_in: Option<u64>,
348 token_type: Option<String>,
349 id_token: Option<String>,
350 scope: Option<String>,
351}
352
353fn parse_oidc_if_requested(
358 id_token: Option<&str>,
359 scopes: &[crate::scope::OAuth2Scope],
360) -> Result<Option<crate::oidc::Token>, crate::error::IdTokenError> {
361 if !scopes.contains(&crate::scope::OAuth2Scope::OpenId) {
362 return Ok(None);
363 }
364 id_token.map(crate::oidc::Token::from_raw_jwt).transpose()
365}
366
367fn parse_scopes(scope_str: &str) -> Vec<OAuth2Scope> {
369 scope_str
370 .split_whitespace()
371 .map(OAuth2Scope::from)
372 .collect()
373}
374
375async fn trigger_shutdown(shutdown_arc: &Arc<Mutex<Option<oneshot::Sender<()>>>>) {
376 let mut guard = shutdown_arc.lock().await;
377 if let Some(tx) = guard.take() {
378 let _ = tx.send(());
379 }
380}
381
382async fn resolve_callback_code(
383 callback_result: CallbackResult,
384 state_token: &str,
385 auth: &CliTokenClient,
386 redirect_uri_url: &url::Url,
387 inner_tx: &mpsc::Sender<RenderedHtml>,
388) -> Result<String, CallbackError> {
389 match validate_callback_code(callback_result, state_token) {
390 Err(err) => {
391 let html = render_error_html(&err.clone().into(), auth, redirect_uri_url).await;
392 let _ = inner_tx.send(RenderedHtml(html)).await;
393 Err(err)
394 }
395 v => v,
396 }
397}
398
399fn validate_callback_code(
400 callback_result: CallbackResult,
401 state_token: &str,
402) -> Result<String, CallbackError> {
403 use subtle::ConstantTimeEq as _;
404
405 match callback_result {
406 CallbackResult::Success { code, state }
407 if state.as_bytes().ct_eq(state_token.as_bytes()).into() =>
408 {
409 Ok(code)
410 }
411 CallbackResult::Success { .. } => Err(CallbackError::StateMismatch),
412 CallbackResult::ProviderError { error, description } => Err(CallbackError::ProviderError {
413 error,
414 description: description.unwrap_or_default(),
415 }),
416 }
417}
418
419async fn validate_id_token_required(
430 oidc_jwks: &OidcJwksConfig,
431 token_set: crate::token::TokenSet<crate::token::Unvalidated>,
432 client_id: &str,
433 issuer: crate::oidc::IssuerValidation<'_>,
434 expected_nonce: Option<&str>,
435) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
436 use crate::error::IdTokenError;
437
438 let oidc = token_set.oidc_token().ok_or(IdTokenError::NoIdToken)?;
439
440 if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
441 validator
442 .validate(oidc.raw())
443 .await
444 .map_err(IdTokenError::JwksValidationFailed)?;
445 }
446
447 oidc.validate_standard_claims(client_id, issuer, expected_nonce)?;
449
450 Ok(token_set.into_validated())
451}
452
453async fn validate_id_token_if_present(
460 oidc_jwks: &OidcJwksConfig,
461 token_set: crate::token::TokenSet<crate::token::Unvalidated>,
462 client_id: &str,
463 issuer: crate::oidc::IssuerValidation<'_>,
464) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
465 use crate::error::IdTokenError;
466
467 let Some(oidc) = token_set.oidc_token() else {
468 return Ok(token_set.into_validated());
469 };
470
471 if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
472 validator
473 .validate(oidc.raw())
474 .await
475 .map_err(IdTokenError::JwksValidationFailed)?;
476 }
477
478 oidc.validate_standard_claims(client_id, issuer, None)?;
480
481 Ok(token_set.into_validated())
482}
483
484#[expect(
485 clippy::too_many_arguments,
486 reason = "private orchestrator function; all args are distinct concerns that cannot be bundled without noise"
487)]
488async fn handle_callback(
489 auth: &CliTokenClient,
490 redirect_uri_url: &url::Url,
491 state_token: &str,
492 code_verifier: &str,
493 nonce: Option<&str>,
494 inner_tx: mpsc::Sender<RenderedHtml>,
495 mut outer_rx: mpsc::Receiver<CallbackResult>,
496 shutdown_arc: Arc<Mutex<Option<oneshot::Sender<()>>>>,
497) -> Result<crate::token::TokenSet<crate::token::Validated>, AuthError> {
498 let callback_result = tokio::select! {
500 result = tokio::time::timeout(auth.timeout, outer_rx.recv()) => {
501 match result {
502 Err(_) => {
503 trigger_shutdown(&shutdown_arc).await;
504 return Err(AuthError::Timeout);
505 }
506 Ok(None) => return Err(AuthError::Server("channel closed".to_string())),
507 Ok(Some(r)) => r,
508 }
509 }
510 _ = tokio::signal::ctrl_c() => {
511 trigger_shutdown(&shutdown_arc).await;
512 return Err(AuthError::Cancelled);
513 }
514 };
515
516 let code = resolve_callback_code(
518 callback_result,
519 state_token,
520 auth,
521 redirect_uri_url,
522 &inner_tx,
523 )
524 .await?;
525
526 let token_set = match exchange_code(
528 &auth.http_client,
529 &auth.token_url,
530 auth.client_id.as_str(),
531 auth.client_secret.as_deref(),
532 &code,
533 redirect_uri_url.as_str(),
534 code_verifier,
535 &auth.scopes,
536 )
537 .await
538 {
539 Ok(ts) => ts,
540 Err(e) => {
541 let html = render_error_html(&e, auth, redirect_uri_url).await;
542 let _ = inner_tx.send(RenderedHtml(html)).await;
543 return Err(e);
544 }
545 };
546
547 let token_set = if let Some(oidc_jwks) = &auth.oidc_jwks {
549 match validate_id_token_required(
550 oidc_jwks,
551 token_set,
552 auth.client_id.as_str(),
553 auth.issuer.as_ref().map_or(
554 crate::oidc::IssuerValidation::Skip,
555 crate::oidc::IssuerValidation::MustMatch,
556 ),
557 nonce,
558 )
559 .await
560 .map_err(AuthError::IdToken)
561 {
562 Ok(ts) => ts,
563 Err(e) => {
564 let html = render_error_html(&e, auth, redirect_uri_url).await;
565 let _ = inner_tx.send(RenderedHtml(html)).await;
566 return Err(e);
567 }
568 }
569 } else {
570 token_set.into_validated()
571 };
572
573 let html = render_success_html(
575 &token_set,
576 &auth.scopes,
577 redirect_uri_url,
578 auth.client_id.as_str(),
579 auth.success_renderer.as_deref(),
580 auth.success_html.as_deref(),
581 )
582 .await;
583 let _ = inner_tx.send(RenderedHtml(html)).await;
584
585 Ok(token_set)
586}
587
588async fn render_error_html(
589 err: &AuthError,
590 auth: &CliTokenClient,
591 redirect_uri_url: &url::Url,
592) -> String {
593 let ctx = crate::pages::ErrorPageContext::new(
594 err,
595 &auth.scopes,
596 redirect_uri_url,
597 auth.client_id.as_str(),
598 );
599 if let Some(renderer) = auth.error_renderer.as_deref() {
600 renderer.render_error(&ctx).await
601 } else if let Some(html) = auth.error_html.as_deref() {
602 html.to_string()
603 } else {
604 crate::pages::DefaultErrorPageRenderer
605 .render_error(&ctx)
606 .await
607 }
608}
609
610async fn render_success_html(
611 token_set: &crate::token::TokenSet,
612 scopes: &[OAuth2Scope],
613 redirect_uri_url: &url::Url,
614 client_id: &str,
615 success_renderer: Option<&(dyn crate::pages::SuccessPageRenderer + Send + Sync)>,
616 success_html: Option<&str>,
617) -> String {
618 let ctx = crate::pages::PageContext::new(
619 token_set.oidc().map(crate::oidc::Token::claims),
620 scopes,
621 redirect_uri_url,
622 client_id,
623 token_set.expires_at(),
624 token_set.refresh_token().is_some(),
625 );
626 if let Some(renderer) = success_renderer {
627 renderer.render_success(&ctx).await
628 } else if let Some(html) = success_html {
629 html.to_string()
630 } else {
631 crate::pages::DefaultSuccessPageRenderer
632 .render_success(&ctx)
633 .await
634 }
635}
636
637#[expect(
638 clippy::too_many_arguments,
639 reason = "all arguments are distinct OAuth2 code exchange parameters; grouping them would obscure their individual meanings"
640)]
641async fn exchange_code(
642 http_client: &reqwest::Client,
643 token_url: &url::Url,
644 client_id: &str,
645 client_secret: Option<&str>,
646 code: &str,
647 redirect_uri: &str,
648 code_verifier: &str,
649 scopes: &[crate::scope::OAuth2Scope],
650) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, AuthError> {
651 let mut params = vec![
652 ("grant_type", "authorization_code"),
653 ("code", code),
654 ("redirect_uri", redirect_uri),
655 ("client_id", client_id),
656 ("code_verifier", code_verifier),
657 ];
658 if let Some(secret) = client_secret {
659 params.push(("client_secret", secret));
660 }
661
662 let t0 = std::time::SystemTime::now();
663 let response = http_client
664 .post(token_url.as_str())
665 .header(reqwest::header::ACCEPT, "application/json")
666 .form(¶ms)
667 .send()
668 .await?;
669
670 if !response.status().is_success() {
671 let status = response.status().as_u16();
672 let body_bytes = response.bytes().await.unwrap_or_default();
673 let body = String::from_utf8_lossy(&body_bytes).into_owned();
674 return Err(AuthError::TokenExchange { status, body });
675 }
676
677 let body = response.text().await?;
678 let token_response: TokenResponse =
679 serde_json::from_str(&body).map_err(|e| AuthError::Server(format!("{e}: {body}")))?;
680
681 let expires_at = token_response
682 .expires_in
683 .map(|secs| t0 + std::time::Duration::from_secs(secs));
684
685 let oidc = parse_oidc_if_requested(token_response.id_token.as_deref(), scopes)
686 .map_err(AuthError::IdToken)?;
687
688 let resolved_scopes = token_response
690 .scope
691 .as_deref()
692 .map_or_else(|| scopes.to_vec(), parse_scopes);
693
694 Ok(crate::token::TokenSet::new(
695 token_response.access_token,
696 token_response.refresh_token,
697 expires_at,
698 token_response
699 .token_type
700 .unwrap_or_else(|| "Bearer".to_string()),
701 oidc,
702 resolved_scopes,
703 ))
704}
705
706async fn exchange_refresh_token(
707 http_client: &reqwest::Client,
708 token_url: &url::Url,
709 client_id: &str,
710 client_secret: Option<&str>,
711 refresh_token: &str,
712 scopes: &[crate::scope::OAuth2Scope],
713) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, RefreshError> {
714 let scope_str = (!scopes.is_empty()).then(|| {
716 scopes
717 .iter()
718 .map(ToString::to_string)
719 .collect::<Vec<_>>()
720 .join(" ")
721 });
722
723 let mut params = vec![
724 ("grant_type", "refresh_token"),
725 ("refresh_token", refresh_token),
726 ("client_id", client_id),
727 ];
728 if let Some(secret) = client_secret {
729 params.push(("client_secret", secret));
730 }
731 if let Some(ref s) = scope_str {
732 params.push(("scope", s.as_str()));
733 }
734
735 let t0 = std::time::SystemTime::now();
736 let response = http_client
737 .post(token_url.as_str())
738 .header(reqwest::header::ACCEPT, "application/json")
739 .form(¶ms)
740 .send()
741 .await?; if !response.status().is_success() {
744 let status = response.status().as_u16();
745 let body_bytes = response.bytes().await.unwrap_or_default();
746 let body = String::from_utf8_lossy(&body_bytes).into_owned();
747 return Err(RefreshError::TokenExchange { status, body });
748 }
749
750 let token_response: TokenResponse = response.json().await?; let expires_at = token_response
753 .expires_in
754 .map(|secs| t0 + std::time::Duration::from_secs(secs));
755
756 let oidc = parse_oidc_if_requested(token_response.id_token.as_deref(), scopes)
757 .map_err(RefreshError::IdToken)?;
758
759 let resolved_scopes = token_response
761 .scope
762 .as_deref()
763 .map_or_else(|| scopes.to_vec(), parse_scopes);
764
765 Ok(crate::token::TokenSet::new(
766 token_response.access_token,
767 token_response.refresh_token,
768 expires_at,
769 token_response
770 .token_type
771 .unwrap_or_else(|| "Bearer".to_string()),
772 oidc,
773 resolved_scopes,
774 ))
775}
776
777#[non_exhaustive]
801pub struct NoClientId;
802#[non_exhaustive]
804pub struct HasClientId(ClientId);
805#[non_exhaustive]
807pub struct NoAuthUrl;
808#[non_exhaustive]
810pub struct HasAuthUrl(url::Url);
811#[non_exhaustive]
813pub struct NoTokenUrl;
814#[non_exhaustive]
816pub struct HasTokenUrl(url::Url);
817#[non_exhaustive]
819pub struct NoOidc;
820#[non_exhaustive]
826pub struct OidcPending;
827pub struct JwksEnabled(JwksValidatorStorage);
831#[non_exhaustive]
835pub struct JwksDisabled;
836
837struct BuilderConfig {
843 client_secret: Option<String>,
844 issuer: Option<url::Url>,
845 scopes: std::collections::BTreeSet<OAuth2Scope>,
846 port_config: PortConfig,
847 success_html: Option<String>,
848 error_html: Option<String>,
849 success_renderer: Option<SuccessRendererStorage>,
850 error_renderer: Option<ErrorRendererStorage>,
851 open_browser: bool,
852 timeout: std::time::Duration,
853 on_auth_url: Option<OnAuthUrlCallback>,
854 on_url: Option<OnUrlCallback>,
855 on_server_ready: Option<OnServerReadyCallback>,
856}
857
858impl Default for BuilderConfig {
859 fn default() -> Self {
860 Self {
861 client_secret: None,
862 scopes: std::collections::BTreeSet::new(),
863 port_config: PortConfig::Random,
864 success_html: None,
865 error_html: None,
866 success_renderer: None,
867 error_renderer: None,
868 open_browser: true,
869 timeout: std::time::Duration::from_secs(TIMEOUT_DURATION_IN_SECONDS),
870 on_auth_url: None,
871 on_url: None,
872 on_server_ready: None,
873 issuer: None,
874 }
875 }
876}
877
878pub struct CliTokenClientBuilder<C = NoClientId, A = NoAuthUrl, T = NoTokenUrl, O = NoOidc> {
935 client_id: C,
936 auth_url: A,
937 token_url: T,
938 oidc: O,
939 config: BuilderConfig,
940}
941
942impl Default for CliTokenClientBuilder {
943 fn default() -> Self {
944 Self {
945 client_id: NoClientId,
946 auth_url: NoAuthUrl,
947 token_url: NoTokenUrl,
948 oidc: NoOidc,
949 config: BuilderConfig::default(),
950 }
951 }
952}
953
954impl CliTokenClientBuilder {
959 #[must_use]
969 pub fn from_open_id_configuration(
970 open_id_configuration: &OpenIdConfiguration,
971 ) -> CliTokenClientBuilder<NoClientId, HasAuthUrl, HasTokenUrl, OidcPending> {
972 CliTokenClientBuilder {
973 client_id: NoClientId,
974 auth_url: HasAuthUrl(open_id_configuration.authorization_endpoint().clone()),
975 token_url: HasTokenUrl(open_id_configuration.token_endpoint().clone()),
976 oidc: OidcPending,
977 config: BuilderConfig {
978 issuer: Some(open_id_configuration.issuer().clone()),
979 scopes: std::collections::BTreeSet::from([OAuth2Scope::OpenId]),
980 ..BuilderConfig::default()
981 },
982 }
983 }
984}
985
986impl<C, A, T, O> CliTokenClientBuilder<C, A, T, O> {
989 #[must_use]
991 pub fn client_id(self, v: impl Into<String>) -> CliTokenClientBuilder<HasClientId, A, T, O> {
992 CliTokenClientBuilder {
993 client_id: HasClientId(ClientId(v.into())),
994 auth_url: self.auth_url,
995 token_url: self.token_url,
996 oidc: self.oidc,
997 config: self.config,
998 }
999 }
1000
1001 #[must_use]
1003 pub fn auth_url(self, v: url::Url) -> CliTokenClientBuilder<C, HasAuthUrl, T, O> {
1004 CliTokenClientBuilder {
1005 client_id: self.client_id,
1006 auth_url: HasAuthUrl(v),
1007 token_url: self.token_url,
1008 oidc: self.oidc,
1009 config: self.config,
1010 }
1011 }
1012
1013 #[must_use]
1015 pub fn token_url(self, v: url::Url) -> CliTokenClientBuilder<C, A, HasTokenUrl, O> {
1016 CliTokenClientBuilder {
1017 client_id: self.client_id,
1018 auth_url: self.auth_url,
1019 token_url: HasTokenUrl(v),
1020 oidc: self.oidc,
1021 config: self.config,
1022 }
1023 }
1024
1025 #[must_use]
1027 pub fn client_secret(mut self, v: impl Into<String>) -> Self {
1028 self.config.client_secret = Some(v.into());
1029 self
1030 }
1031
1032 #[must_use]
1043 pub fn add_scopes(mut self, v: impl IntoIterator<Item = RequestScope>) -> Self {
1044 self.config
1045 .scopes
1046 .extend(v.into_iter().map(OAuth2Scope::from));
1047 self
1048 }
1049
1050 #[must_use]
1055 pub const fn port_hint(mut self, v: u16) -> Self {
1056 self.config.port_config = PortConfig::Hint(v);
1057 self
1058 }
1059
1060 #[must_use]
1080 pub const fn require_port(mut self, v: u16) -> Self {
1081 self.config.port_config = PortConfig::Required(v);
1082 self
1083 }
1084
1085 #[must_use]
1087 pub fn success_html(mut self, v: impl Into<String>) -> Self {
1088 self.config.success_html = Some(v.into());
1089 self
1090 }
1091
1092 #[must_use]
1094 pub fn error_html(mut self, v: impl Into<String>) -> Self {
1095 self.config.error_html = Some(v.into());
1096 self
1097 }
1098
1099 #[must_use]
1103 pub fn success_renderer(mut self, r: impl SuccessPageRenderer + 'static) -> Self {
1104 self.config.success_renderer = Some(Box::new(r));
1105 self
1106 }
1107
1108 #[must_use]
1112 pub fn error_renderer(mut self, r: impl ErrorPageRenderer + 'static) -> Self {
1113 self.config.error_renderer = Some(Box::new(r));
1114 self
1115 }
1116
1117 #[must_use]
1122 pub const fn open_browser(mut self, v: bool) -> Self {
1123 self.config.open_browser = v;
1124 self
1125 }
1126
1127 #[must_use]
1131 pub const fn timeout(mut self, v: std::time::Duration) -> Self {
1132 self.config.timeout = v;
1133 self
1134 }
1135
1136 #[must_use]
1140 pub fn on_auth_url(mut self, f: impl Fn(&mut url::Url) + Send + Sync + 'static) -> Self {
1141 self.config.on_auth_url = Some(Box::new(f));
1142 self
1143 }
1144
1145 #[must_use]
1149 pub fn on_url(mut self, f: impl Fn(&url::Url) + Send + Sync + 'static) -> Self {
1150 self.config.on_url = Some(Box::new(f));
1151 self
1152 }
1153
1154 #[must_use]
1158 pub fn on_server_ready(mut self, f: impl Fn(u16) + Send + Sync + 'static) -> Self {
1159 self.config.on_server_ready = Some(Box::new(f));
1160 self
1161 }
1162}
1163
1164impl<C, A, T> CliTokenClientBuilder<C, A, T, NoOidc> {
1167 #[must_use]
1182 pub fn with_openid_scope(mut self) -> CliTokenClientBuilder<C, A, T, OidcPending> {
1183 self.config.scopes.insert(OAuth2Scope::OpenId);
1184 CliTokenClientBuilder {
1185 client_id: self.client_id,
1186 auth_url: self.auth_url,
1187 token_url: self.token_url,
1188 oidc: OidcPending,
1189 config: self.config,
1190 }
1191 }
1192}
1193
1194impl<C, A, T> CliTokenClientBuilder<C, A, T, OidcPending> {
1197 #[must_use]
1205 pub fn issuer(mut self, v: url::Url) -> Self {
1206 self.config.issuer = Some(v);
1207 self
1208 }
1209
1210 #[must_use]
1217 pub fn jwks_validator(
1218 self,
1219 v: Box<dyn JwksValidator>,
1220 ) -> CliTokenClientBuilder<C, A, T, JwksEnabled> {
1221 CliTokenClientBuilder {
1222 client_id: self.client_id,
1223 auth_url: self.auth_url,
1224 token_url: self.token_url,
1225 oidc: JwksEnabled(v),
1226 config: self.config,
1227 }
1228 }
1229
1230 #[must_use]
1245 pub fn without_jwks_validation(self) -> CliTokenClientBuilder<C, A, T, JwksDisabled> {
1246 CliTokenClientBuilder {
1247 client_id: self.client_id,
1248 auth_url: self.auth_url,
1249 token_url: self.token_url,
1250 oidc: JwksDisabled,
1251 config: self.config,
1252 }
1253 }
1254}
1255
1256impl<A, T> CliTokenClientBuilder<HasClientId, A, T, OidcPending> {
1257 #[must_use]
1264 pub fn with_open_id_configuration_jwks_validator(
1265 self,
1266 open_id_configuration: &OpenIdConfiguration,
1267 ) -> CliTokenClientBuilder<HasClientId, A, T, JwksEnabled> {
1268 let client_id = self.client_id.0.as_str().to_owned();
1269 let validator = Box::new(RemoteJwksValidator::from_open_id_configuration(
1270 open_id_configuration,
1271 client_id,
1272 ));
1273 CliTokenClientBuilder {
1274 client_id: self.client_id,
1275 auth_url: self.auth_url,
1276 token_url: self.token_url,
1277 oidc: JwksEnabled(validator),
1278 config: self.config,
1279 }
1280 }
1281}
1282
1283impl<C, A, T> CliTokenClientBuilder<C, A, T, JwksEnabled> {
1284 #[must_use]
1290 pub fn issuer(mut self, v: url::Url) -> Self {
1291 self.config.issuer = Some(v);
1292 self
1293 }
1294}
1295
1296impl<C, A, T> CliTokenClientBuilder<C, A, T, JwksDisabled> {
1297 #[must_use]
1303 pub fn issuer(mut self, v: url::Url) -> Self {
1304 self.config.issuer = Some(v);
1305 self
1306 }
1307}
1308
1309impl CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksEnabled> {
1310 #[must_use]
1316 pub fn build(mut self) -> CliTokenClient {
1317 self.config.scopes.insert(OAuth2Scope::OpenId);
1318 build_client(
1319 self.client_id.0,
1320 self.auth_url.0,
1321 self.token_url.0,
1322 self.config,
1323 Some(OidcJwksConfig::Enabled(self.oidc.0)),
1324 )
1325 }
1326}
1327
1328impl CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksDisabled> {
1329 #[must_use]
1335 pub fn build(mut self) -> CliTokenClient {
1336 self.config.scopes.insert(OAuth2Scope::OpenId);
1337 build_client(
1338 self.client_id.0,
1339 self.auth_url.0,
1340 self.token_url.0,
1341 self.config,
1342 Some(OidcJwksConfig::Disabled),
1343 )
1344 }
1345}
1346
1347impl CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc> {
1348 #[must_use]
1353 pub fn build(self) -> CliTokenClient {
1354 build_client(
1355 self.client_id.0,
1356 self.auth_url.0,
1357 self.token_url.0,
1358 self.config,
1359 None,
1360 )
1361 }
1362}
1363
1364fn build_client(
1365 client_id: ClientId,
1366 auth_url: url::Url,
1367 token_url: url::Url,
1368 config: BuilderConfig,
1369 oidc_jwks: Option<OidcJwksConfig>,
1370) -> CliTokenClient {
1371 CliTokenClient {
1372 client_id,
1373 client_secret: config.client_secret,
1374 auth_url,
1375 token_url,
1376 issuer: config.issuer,
1377 scopes: config.scopes.into_iter().collect(),
1378 port_config: config.port_config,
1379 success_html: config.success_html,
1380 error_html: config.error_html,
1381 success_renderer: config.success_renderer,
1382 error_renderer: config.error_renderer,
1383 open_browser: config.open_browser,
1384 timeout: config.timeout,
1385 on_auth_url: config.on_auth_url,
1386 on_url: config.on_url,
1387 on_server_ready: config.on_server_ready,
1388 oidc_jwks,
1389 http_client: reqwest::Client::builder()
1390 .connect_timeout(std::time::Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECONDS))
1391 .timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECONDS))
1392 .build()
1393 .unwrap_or_default(),
1394 }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399 #![expect(
1400 clippy::indexing_slicing,
1401 clippy::expect_used,
1402 reason = "tests do not need to meet production lint standards"
1403 )]
1404
1405 use super::{
1406 CliTokenClient, CliTokenClientBuilder, HasAuthUrl, HasClientId, HasTokenUrl, NoOidc,
1407 parse_scopes,
1408 };
1409 use crate::jwks::{JwksValidationError, JwksValidator};
1410 use crate::oidc::Token;
1411 use crate::scope::OAuth2Scope;
1412 use async_trait::async_trait;
1413
1414 fn fake_jwt(sub: &str, email: &str) -> String {
1415 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1416 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1417 let claims = URL_SAFE_NO_PAD.encode(format!(
1418 r#"{{"sub":"{sub}","email":"{email}","iss":"https://accounts.example.com","iat":1000000000,"exp":9999999999}}"#
1419 ));
1420 format!("{header}.{claims}.fakesig")
1421 }
1422
1423 fn fake_jwt_google_style(
1424 sub: &str,
1425 email: &str,
1426 name: &str,
1427 picture: &str,
1428 aud: &str,
1429 ) -> String {
1430 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1431 let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1432 let claims = URL_SAFE_NO_PAD.encode(format!(
1433 r#"{{"iss":"https://accounts.google.com","aud":"{aud}","sub":"{sub}","email":"{email}","email_verified":true,"name":"{name}","picture":"{picture}","iat":1000000000,"exp":9999999999}}"#
1434 ));
1435 format!("{header}.{claims}.fakesig")
1436 }
1437
1438 #[test]
1439 fn oidc_token_from_raw_jwt_returns_ok_for_valid_fake_jwt() {
1440 let jwt = fake_jwt("user_42", "user@example.com");
1441 let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for valid fake JWT");
1442 assert_eq!(oidc.claims().sub().as_str(), "user_42");
1443 assert_eq!(
1444 oidc.claims().email().map(crate::oidc::Email::as_str),
1445 Some("user@example.com")
1446 );
1447 }
1448
1449 #[test]
1450 fn oidc_token_from_raw_jwt_returns_err_for_invalid_input() {
1451 let result = Token::from_raw_jwt("not.a.jwt");
1452 assert!(result.is_err(), "expected Err for invalid JWT");
1453 }
1454
1455 #[test]
1456 fn oidc_token_from_raw_jwt_with_aud_claim_returns_ok() {
1457 let jwt = fake_jwt_google_style(
1460 "1234567890",
1461 "user@gmail.com",
1462 "Test User",
1463 "https://example.com/photo.jpg",
1464 "my-client-id.apps.googleusercontent.com",
1465 );
1466 let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for JWT with aud claim");
1467 assert_eq!(oidc.claims().sub().as_str(), "1234567890");
1468 assert_eq!(
1469 oidc.claims().email().map(crate::oidc::Email::as_str),
1470 Some("user@gmail.com")
1471 );
1472 assert_eq!(oidc.claims().name(), Some("Test User"));
1473 assert_eq!(
1474 oidc.claims().picture().map(|p| p.as_url().as_str()),
1475 Some("https://example.com/photo.jpg")
1476 );
1477 assert!(oidc.claims().email().unwrap().is_verified());
1478 }
1479
1480 fn valid_builder() -> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc> {
1481 CliTokenClient::builder()
1482 .client_id("test-client")
1483 .auth_url(url::Url::parse("https://example.com/auth").unwrap())
1484 .token_url(url::Url::parse("https://example.com/token").unwrap())
1485 }
1486
1487 #[test]
1488 fn builder_returns_cli_token_client_builder() {
1489 let _builder: CliTokenClientBuilder = CliTokenClient::builder();
1491 }
1492
1493 #[test]
1499 fn build_with_valid_inputs_returns_client() {
1500 let _client = valid_builder().build();
1501 }
1502
1503 #[test]
1506 fn rfc_6749_s5_1_scope_fallback_uses_requested_scopes_when_response_omits_scope() {
1507 let requested = vec![OAuth2Scope::OpenId, OAuth2Scope::Email];
1511 let resolved: Vec<OAuth2Scope> = None::<String>
1513 .as_deref()
1514 .map_or_else(|| requested.clone(), parse_scopes);
1515 assert_eq!(resolved, requested);
1516
1517 let resolved_from_response: Vec<OAuth2Scope> = Some("openid profile".to_string())
1519 .as_deref()
1520 .map_or_else(|| requested.clone(), parse_scopes);
1521 assert_eq!(
1522 resolved_from_response,
1523 vec![OAuth2Scope::OpenId, OAuth2Scope::Profile]
1524 );
1525 }
1526
1527 #[test]
1528 fn oidc_token_from_raw_jwt_populates_iss_aud_iat_exp() {
1529 let jwt = fake_jwt_google_style(
1530 "sub-iss-test",
1531 "user@example.com",
1532 "Test User",
1533 "https://example.com/photo.jpg",
1534 "my-client-id",
1535 );
1536 let oidc = Token::from_raw_jwt(&jwt).expect("should decode");
1537 let claims = oidc.claims();
1538 assert_eq!(
1539 claims.iss().as_url(),
1540 &url::Url::parse("https://accounts.google.com").unwrap()
1541 );
1542 assert_eq!(claims.aud().len(), 1);
1543 assert_eq!(claims.aud()[0].as_str(), "my-client-id");
1544 assert!(
1546 claims.iat() > std::time::UNIX_EPOCH,
1547 "iat should be after epoch"
1548 );
1549 assert!(
1550 claims.exp() > std::time::UNIX_EPOCH,
1551 "exp should be after epoch"
1552 );
1553 }
1554
1555 struct AcceptAll;
1556
1557 #[async_trait]
1558 impl JwksValidator for AcceptAll {
1559 async fn validate(&self, _raw_token: &str) -> Result<(), JwksValidationError> {
1560 Ok(())
1561 }
1562 }
1563
1564 #[test]
1565 fn build_with_jwks_validator_and_openid_scope_succeeds() {
1566 let _client = valid_builder()
1567 .with_openid_scope()
1568 .jwks_validator(Box::new(AcceptAll))
1569 .build();
1570 }
1571
1572 fn make_open_id_configuration() -> crate::oidc::OpenIdConfiguration {
1576 use url::Url;
1577 crate::oidc::OpenIdConfiguration::new_for_test(
1578 Url::parse("https://accounts.example.com").unwrap(),
1579 Url::parse("https://accounts.example.com/authorize").unwrap(),
1580 Url::parse("https://accounts.example.com/token").unwrap(),
1581 Url::parse("https://accounts.example.com/.well-known/jwks.json").unwrap(),
1582 )
1583 }
1584
1585 #[test]
1590 fn from_open_id_configuration_always_includes_openid_scope() {
1591 let config = make_open_id_configuration();
1592 let _client = CliTokenClientBuilder::from_open_id_configuration(&config)
1595 .client_id("test-client")
1596 .without_jwks_validation()
1597 .build();
1598 }
1599}