Skip to main content

loopauth/
builder.rs

1use crate::error::{AuthError, CallbackError, RefreshError};
2use crate::jwks::{JwksValidator, JwksValidatorStorage, RemoteJwksValidator};
3use crate::oidc::OpenIdConfiguration;
4
5/// Whether JWKS signature verification is performed on received ID tokens.
6///
7/// Constructed by the builder type-state; never constructed directly.
8#[non_exhaustive]
9pub enum OidcJwksConfig {
10    /// Verify the ID token signature against the provider's JWKS endpoint.
11    Enabled(JwksValidatorStorage),
12    /// Skip JWKS signature verification.
13    ///
14    /// Claims (`exp`, `nbf`, `aud`, `iss`) are still validated. Use only when
15    /// you have an out-of-band trust anchor (e.g., a mTLS-secured private network
16    /// or a test environment where real JWKS validation is not possible).
17    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/// An OAuth 2.0 client identifier.
35#[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
48/// Acquires OAuth 2.0 provider tokens for CLI applications via the Authorization
49/// Code + PKCE flow.
50///
51/// Construct with [`CliTokenClient::builder`] and call
52/// [`CliTokenClient::run_authorization_flow`] to run the full flow. Use
53/// [`CliTokenClient::refresh`] or [`CliTokenClient::refresh_if_expiring`] to
54/// renew tokens without re-running the authorization flow.
55pub 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    /// Create a new [`CliTokenClientBuilder`].
78    #[must_use]
79    pub fn builder() -> CliTokenClientBuilder {
80        CliTokenClientBuilder::default()
81    }
82
83    /// Run the full OAuth 2.0 Authorization Code + PKCE flow.
84    ///
85    /// # Errors
86    ///
87    /// Returns `AuthError::ServerBind` if the loopback server cannot bind.
88    /// Returns `AuthError::Browser` if `open_browser` is true and the browser fails to open.
89    /// Returns `AuthError::Timeout` if the callback is not received within the configured timeout.
90    /// Returns `AuthError::Callback(CallbackError::StateMismatch)` if the callback state parameter does not match.
91    /// Returns `AuthError::Callback(CallbackError::ProviderError)` if the callback contains an `error` parameter.
92    /// Returns `AuthError::TokenExchange` if the token endpoint returns non-2xx.
93    ///
94    /// # Example
95    ///
96    /// ```
97    /// # #[tokio::main]
98    /// # async fn main() {
99    /// use loopauth::{CliTokenClient, OAuth2Scope};
100    /// use loopauth::test_support::FakeOAuthServer;
101    /// use std::sync::{Arc, Mutex};
102    ///
103    /// let server = FakeOAuthServer::start("my_token").await;
104    /// let (tx, rx) = tokio::sync::oneshot::channel::<url::Url>();
105    /// let tx = Arc::new(Mutex::new(Some(tx)));
106    /// let client = CliTokenClient::builder()
107    ///     .client_id("test-client")
108    ///     .auth_url(server.auth_url())
109    ///     .token_url(server.token_url())
110    ///     .open_browser(false)
111    ///     .on_url(move |url| {
112    ///         if let Some(tx) = tx.lock().unwrap().take() {
113    ///             let _ = tx.send(url.clone());
114    ///         }
115    ///     })
116    ///     .build();
117    ///
118    /// // Spawn a task to fire the redirect (simulates the browser callback)
119    /// tokio::spawn(async move {
120    ///     if let Ok(url) = rx.await {
121    ///         let _ = reqwest::get(url).await;
122    ///     }
123    /// });
124    ///
125    /// let tokens = client.run_authorization_flow().await.unwrap();
126    /// assert_eq!(tokens.access_token().as_str(), "my_token");
127    /// # }
128    /// ```
129    pub async fn run_authorization_flow(&self) -> Result<crate::token::TokenSet, AuthError> {
130        // 1. Bind listener
131        let listener = bind_listener(self.port_config)
132            .await
133            .map_err(AuthError::ServerBind)?;
134
135        // 2. Build redirect URI from listener
136        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        // 3. Generate PKCE challenge
143        let pkce = crate::pkce::PkceChallenge::generate();
144
145        // 4. Generate state token
146        let state_token = uuid::Uuid::new_v4().to_string();
147
148        // 5. Generate nonce when OIDC is active (OIDC Core §3.1.2.1)
149        let nonce = self
150            .oidc_jwks
151            .is_some()
152            .then(|| uuid::Uuid::new_v4().to_string());
153
154        // 6. Build auth URL with query params
155        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        // 7. Call on_auth_url hook
180        if let Some(ref hook) = self.on_auth_url {
181            hook(&mut auth_url);
182        }
183
184        // 8. Create channels
185        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        // 9. Build ServerState
190        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        // 10. Spawn callback server
197        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        // 11. Call on_server_ready hook
202        if let Some(ref hook) = self.on_server_ready {
203            hook(port);
204        }
205
206        // 12. Call on_url hook AFTER server is spawned
207        if let Some(ref hook) = self.on_url {
208            hook(&auth_url);
209        }
210
211        // 13. Open browser or log URL
212        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        // 14-18. Wait for callback, exchange code, send HTML response
219        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    /// Exchange a refresh token for a new [`crate::TokenSet`].
233    ///
234    /// # Errors
235    ///
236    /// Returns [`RefreshError::NoRefreshToken`] when `refresh_token` is empty.
237    /// Returns [`RefreshError::TokenExchange`] when the token endpoint returns non-2xx.
238    /// Returns [`RefreshError::Request`] on network failure.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// # #[tokio::main]
244    /// # async fn main() {
245    /// use loopauth::CliTokenClient;
246    /// use loopauth::test_support::FakeOAuthServer;
247    ///
248    /// let server = FakeOAuthServer::start_with_refresh("new_token", "rt_value").await;
249    /// let client = CliTokenClient::builder()
250    ///     .client_id("test-client")
251    ///     .auth_url(server.auth_url())
252    ///     .token_url(server.token_url())
253    ///     .build();
254    ///
255    /// let tokens = client.refresh("rt_value").await.unwrap();
256    /// assert_eq!(tokens.access_token().as_str(), "new_token");
257    /// # }
258    /// ```
259    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    /// Refresh `tokens` if they expire within `threshold`; otherwise return [`crate::RefreshOutcome::NotNeeded`].
293    ///
294    /// # Errors
295    ///
296    /// Propagates any error from [`Self::refresh`].
297    ///
298    /// # Example
299    ///
300    /// ```
301    /// # #[tokio::main]
302    /// # async fn main() {
303    /// use loopauth::{CliTokenClient, RefreshOutcome};
304    /// use loopauth::test_support::FakeOAuthServer;
305    /// use std::time::Duration;
306    ///
307    /// let server = FakeOAuthServer::start_with_refresh("new_token", "rt_value").await;
308    /// let client = CliTokenClient::builder()
309    ///     .client_id("test-client")
310    ///     .auth_url(server.auth_url())
311    ///     .token_url(server.token_url())
312    ///     .build();
313    ///
314    /// // Build an already-expired TokenSet so the refresh branch is taken
315    /// let tokens: loopauth::TokenSet<loopauth::Unvalidated> = serde_json::from_value(serde_json::json!({
316    ///     "access_token": "old_token",
317    ///     "token_type": "Bearer",
318    ///     "refresh_token": "rt_value",
319    ///     "expires_at": 0
320    /// })).unwrap();
321    /// let tokens = tokens.into_validated();
322    ///
323    /// let outcome = client.refresh_if_expiring(&tokens, Duration::from_secs(300)).await.unwrap();
324    /// assert!(matches!(outcome, RefreshOutcome::Refreshed(_)));
325    /// # }
326    /// ```
327    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
353/// Parse an `id_token` JWT from a token response, if `openid` was in the requested scopes.
354///
355/// Returns `Ok(None)` when `openid` was not requested or when the provider omitted `id_token`.
356/// Returns `Err(IdTokenError)` when parsing the JWT fails.
357fn 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
367/// Parse a space-separated scope string into a `Vec<OAuth2Scope>`.
368fn 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
419/// Validate an ID token that MUST be present; used in the initial authorization flow.
420///
421/// Two-phase validation per RFC 7519 §7.2:
422/// 1. Cryptographic signature check via JWKS (when [`OidcJwksConfig::Enabled`]).
423/// 2. Standard claims: `exp`, `nbf`, `aud`, optionally `iss`, and optionally `nonce`.
424///
425/// Claims are only checked after the signature is verified to prevent accepting
426/// claims from a tampered or unsigned token.
427///
428/// Returns [`crate::error::IdTokenError::NoIdToken`] when the token set carries no `id_token`.
429async 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    // RFC 7519 §7.2: validate standard claims after signature check
448    oidc.validate_standard_claims(client_id, issuer, expected_nonce)?;
449
450    Ok(token_set.into_validated())
451}
452
453/// Validate an ID token if present; used in the refresh flow.
454///
455/// Most OIDC providers do not return an `id_token` on refresh. When absent the
456/// token set is promoted directly without validation. When present, full two-phase
457/// validation is performed (signature + standard claims). Nonce is never checked
458/// on refresh (OIDC Core §3.1.3.7).
459async 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    // RFC 7519 §7.2: validate standard claims after signature check; nonce skipped on refresh
479    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    // Wait for callback, racing against timeout and Ctrl+C
499    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    // Match callback result - send error HTML before returning Err
517    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    // Exchange code for token - send error HTML on failure
527    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    // Run JWKS validation when OIDC is configured; otherwise promote directly
548    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    // Send success HTML to callback handler (renderer > html string > default)
574    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(&params)
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    // RFC 6749 §5.1: if scope omitted, use requested scopes
689    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    // RFC 6749 §6: scope is optional on refresh but required by some providers
715    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(&params)
740        .send()
741        .await?; // RefreshError::Request via #[from] reqwest::Error
742
743    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?; // RefreshError::Request via #[from] reqwest::Error
751
752    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    // RFC 6749 §5.1: if scope omitted, use requested scopes
760    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// ── Type-state markers ────────────────────────────────────────────────────────
778//
779// `CliTokenClientBuilder` carries four type parameters that track required
780// configuration at compile time. `build()` is only reachable once all required
781// fields are in their `Has*` state, turning omitted-field bugs into compile
782// errors rather than runtime panics.
783//
784// Three parameters track the individually-required fields:
785//   C — client_id   (NoClientId | HasClientId)
786//   A — auth_url    (NoAuthUrl  | HasAuthUrl)
787//   T — token_url   (NoTokenUrl | HasTokenUrl)
788//
789// One parameter tracks OIDC + JWKS state:
790//   O — oidc        (NoOidc | OidcPending | JwksEnabled | JwksDisabled)
791//
792// OIDC mode is entered via `with_openid_scope()` or `from_open_id_configuration()`,
793// which transitions to `OidcPending`. From `OidcPending`, callers must resolve JWKS
794// by calling either `jwks_validator()` (→ `JwksEnabled`) or `without_jwks_validation()`
795// (→ `JwksDisabled`) before `build()` becomes available. This ensures that opting out
796// of signature verification is always an explicit, visible choice rather than a silent
797// default.
798
799/// Type-state: `client_id` not yet provided.
800#[non_exhaustive]
801pub struct NoClientId;
802/// Type-state: `client_id` has been provided.
803#[non_exhaustive]
804pub struct HasClientId(ClientId);
805/// Type-state: `auth_url` not yet provided.
806#[non_exhaustive]
807pub struct NoAuthUrl;
808/// Type-state: `auth_url` has been provided.
809#[non_exhaustive]
810pub struct HasAuthUrl(url::Url);
811/// Type-state: `token_url` not yet provided.
812#[non_exhaustive]
813pub struct NoTokenUrl;
814/// Type-state: `token_url` has been provided.
815#[non_exhaustive]
816pub struct HasTokenUrl(url::Url);
817/// Type-state: OIDC mode not yet engaged; `openid` scope is not included.
818#[non_exhaustive]
819pub struct NoOidc;
820/// Type-state: OIDC mode engaged but JWKS decision not yet made.
821///
822/// Call [`CliTokenClientBuilder::jwks_validator`] to enable signature verification
823/// or [`CliTokenClientBuilder::without_jwks_validation`] to explicitly opt out.
824/// `build()` is not available in this state.
825#[non_exhaustive]
826pub struct OidcPending;
827/// Type-state: OIDC mode engaged with JWKS signature verification enabled.
828///
829/// `build()` is available.
830pub struct JwksEnabled(JwksValidatorStorage);
831/// Type-state: OIDC mode engaged with JWKS signature verification explicitly disabled.
832///
833/// Claims (`exp`, `nbf`, `aud`, `iss`) are still validated. `build()` is available.
834#[non_exhaustive]
835pub struct JwksDisabled;
836
837// All optional builder fields live in a private inner struct. This means the
838// state-transition methods (`client_id`, `auth_url`, `token_url`,
839// `with_openid_scope`) only need to forward one `config` field when
840// reconstructing the builder with a new type, rather than copying every
841// individual optional field.
842struct 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
878/// Builder for [`CliTokenClient`].
879///
880/// Obtain via [`CliTokenClient::builder`]. The three required fields `client_id`,
881/// `auth_url`, and `token_url` are tracked at the type level — [`build`] is only
882/// callable once all three have been set, so omitting any of them is a **compile
883/// error**. OIDC mode is tracked separately: JWKS validator methods are only
884/// available after calling [`with_openid_scope`] or using
885/// [`from_open_id_configuration`].
886///
887/// [`build`]: CliTokenClientBuilder::build
888/// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
889/// [`from_open_id_configuration`]: CliTokenClientBuilder::from_open_id_configuration
890///
891/// # Defaults
892///
893/// | Field | Default |
894/// |-------|---------|
895/// | `client_secret` | `None` (public client - PKCE only) |
896/// | `scopes` | empty (plus `openid` when OIDC mode is engaged) |
897/// | `port` | OS assigns port (use `port_hint` for soft preference, `require_port` for hard requirement) |
898/// | `open_browser` | `true` |
899/// | `timeout` | 5 minutes |
900///
901/// # Page rendering priority
902///
903/// Both the success and error pages follow the same three-tier priority:
904///
905/// 1. **Custom renderer** - [`CliTokenClientBuilder::success_renderer`] /
906///    [`CliTokenClientBuilder::error_renderer`] (called dynamically with full context).
907/// 2. **Custom HTML string** - [`CliTokenClientBuilder::success_html`] /
908///    [`CliTokenClientBuilder::error_html`] (returned verbatim, no templating).
909/// 3. **Default embedded page** - used when neither of the above is set.
910///
911/// # Example
912///
913/// ```no_run
914/// use loopauth::{CliTokenClient, RequestScope};
915///
916/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
917/// let client = CliTokenClient::builder()
918///     .client_id("my-client-id")
919///     .auth_url(url::Url::parse("https://provider.example.com/authorize")?)
920///     .token_url(url::Url::parse("https://provider.example.com/token")?)
921///     .with_openid_scope()
922///     .without_jwks_validation() // or .jwks_validator(Box::new(my_validator))
923///     .add_scopes([RequestScope::Email, RequestScope::OfflineAccess])
924///     .on_auth_url(|url| {
925///         url.query_pairs_mut().append_pair("access_type", "offline");
926///     })
927///     .build();
928///
929/// let tokens = client.run_authorization_flow().await?;
930/// println!("access token: {}", tokens.access_token());
931/// # Ok(())
932/// # }
933/// ```
934pub 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
954// Named constructor — pre-fills both URLs from an OIDC discovery document and
955// enters OidcPending mode (adding `openid` to scopes). Placed on the default
956// (all-unset) state so `CliTokenClientBuilder::from_open_id_configuration`
957// remains the natural call site.
958impl CliTokenClientBuilder {
959    /// Create a builder pre-filled from an [`OpenIdConfiguration`].
960    ///
961    /// Sets `auth_url` and `token_url` from the discovery document and
962    /// automatically enters OIDC mode (equivalent to calling
963    /// [`with_openid_scope`]). The issuer URL from the discovery document is
964    /// stored automatically, enabling `iss` claim validation on every received
965    /// ID token. Callers must still call `.client_id()` before `.build()`.
966    ///
967    /// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
968    #[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
986// ── Setters available in any state ───────────────────────────────────────────
987
988impl<C, A, T, O> CliTokenClientBuilder<C, A, T, O> {
989    /// Set the OAuth 2.0 client ID. Required.
990    #[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    /// Set the authorization endpoint URL. Required.
1002    #[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    /// Set the token endpoint URL. Required.
1014    #[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    /// Set the client secret. Optional - omit for public clients using PKCE only.
1026    #[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    /// Add OAuth 2.0 scopes to the request.
1033    ///
1034    /// Scopes accumulate across multiple calls and are deduplicated. Call order
1035    /// does not affect the final scope set.
1036    ///
1037    /// [`RequestScope`] intentionally excludes `openid` — use
1038    /// [`with_openid_scope`] to enable OIDC mode and unlock JWKS validator methods.
1039    ///
1040    /// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
1041    /// [`RequestScope`]: crate::RequestScope
1042    #[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    /// Suggest a preferred local port for the loopback callback server.
1051    ///
1052    /// Falls back to an OS-assigned port if the hint is unavailable.
1053    /// Use [`CliTokenClientBuilder::require_port`] for hard-failure semantics.
1054    #[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    /// Require a specific local port for the loopback callback server.
1061    ///
1062    /// When set, [`CliTokenClient::run_authorization_flow`] returns
1063    /// [`AuthError::ServerBind`] if the port cannot be bound, rather than
1064    /// falling back to an OS-assigned port.
1065    ///
1066    /// # Example
1067    ///
1068    /// ```
1069    /// use loopauth::CliTokenClient;
1070    ///
1071    /// let builder = CliTokenClient::builder()
1072    ///     .client_id("my-client")
1073    ///     .auth_url(url::Url::parse("https://provider.example.com/authorize").unwrap())
1074    ///     .token_url(url::Url::parse("https://provider.example.com/token").unwrap())
1075    ///     .require_port(8080);
1076    /// // If port 8080 is unavailable when run_authorization_flow() is called,
1077    /// // it returns Err(AuthError::ServerBind(...)) immediately.
1078    /// ```
1079    #[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    /// Override the default success page with custom HTML, shown after a successful callback.
1086    #[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    /// Override the default error page with custom HTML, shown when the callback contains an error.
1093    #[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    /// Provide a custom [`SuccessPageRenderer`] for dynamic success page rendering.
1100    ///
1101    /// Takes precedence over [`CliTokenClientBuilder::success_html`].
1102    #[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    /// Provide a custom [`ErrorPageRenderer`] for dynamic error page rendering.
1109    ///
1110    /// Takes precedence over [`CliTokenClientBuilder::error_html`].
1111    #[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    /// Whether to open the authorization URL in the user's browser (default: `true`).
1118    ///
1119    /// When `false`, the URL is emitted via `tracing::info!` instead - useful for
1120    /// testing or headless environments.
1121    #[must_use]
1122    pub const fn open_browser(mut self, v: bool) -> Self {
1123        self.config.open_browser = v;
1124        self
1125    }
1126
1127    /// Set the maximum time to wait for the authorization callback (default: 5 minutes).
1128    ///
1129    /// Returns [`AuthError::Timeout`] if the deadline is exceeded.
1130    #[must_use]
1131    pub const fn timeout(mut self, v: std::time::Duration) -> Self {
1132        self.config.timeout = v;
1133        self
1134    }
1135
1136    /// Lets callers mutate the authorization URL before it is opened or logged. The closure
1137    /// receives a mutable `&mut url::Url` and may append custom query parameters (e.g.,
1138    /// `access_type=offline` for Google). Called after PKCE and state parameters are set.
1139    #[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    /// Fires with the authorization URL string after the loopback server is ready to accept
1146    /// connections. Called regardless of the `open_browser` setting, before the browser opens or
1147    /// the URL is logged. Primary mechanism for headless/CI environments and test harnesses.
1148    #[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    /// Fires with the bound port number once the loopback callback server is ready to accept
1155    /// connections. Useful for test coordination (wait for port before triggering a browser
1156    /// flow) and custom tooling that needs to know the redirect URI port in advance.
1157    #[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
1164// ── OIDC mode transition ──────────────────────────────────────────────────────
1165
1166impl<C, A, T> CliTokenClientBuilder<C, A, T, NoOidc> {
1167    /// Add `openid` to the requested scopes and enter OIDC mode.
1168    ///
1169    /// Transitions to [`OidcPending`] — you must then call either
1170    /// [`jwks_validator`] (to enable JWKS signature verification) or
1171    /// [`without_jwks_validation`] (to explicitly opt out) before [`build`]
1172    /// becomes available.
1173    ///
1174    /// Note: [`from_open_id_configuration`] implicitly enters OIDC mode, so
1175    /// this method is not needed when using that constructor.
1176    ///
1177    /// [`jwks_validator`]: CliTokenClientBuilder::jwks_validator
1178    /// [`without_jwks_validation`]: CliTokenClientBuilder::without_jwks_validation
1179    /// [`build`]: CliTokenClientBuilder::build
1180    /// [`from_open_id_configuration`]: CliTokenClientBuilder::from_open_id_configuration
1181    #[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
1194// ── OIDC pending → resolved ───────────────────────────────────────────────────
1195
1196impl<C, A, T> CliTokenClientBuilder<C, A, T, OidcPending> {
1197    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1198    ///
1199    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1200    /// When using [`CliTokenClientBuilder::from_open_id_configuration`] the issuer is set
1201    /// automatically from the discovery document and this method is not needed.
1202    ///
1203    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1204    #[must_use]
1205    pub fn issuer(mut self, v: url::Url) -> Self {
1206        self.config.issuer = Some(v);
1207        self
1208    }
1209
1210    /// Enable JWKS signature verification and transition to [`JwksEnabled`].
1211    ///
1212    /// The raw `id_token` string is passed to [`JwksValidator::validate`] after
1213    /// every successful token exchange. If validation fails,
1214    /// [`CliTokenClient::run_authorization_flow`] returns
1215    /// [`AuthError::IdToken`] wrapping [`crate::IdTokenError::JwksValidationFailed`].
1216    #[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    /// Explicitly opt out of JWKS signature verification and transition to [`JwksDisabled`].
1231    ///
1232    /// **Security warning**: without JWKS validation, the `id_token` is not
1233    /// cryptographically authenticated. Any party that can craft a JWT with
1234    /// valid claims (including `"alg":"none"` tokens) will be accepted. Claims
1235    /// (`exp`, `nbf`, `aud`, `iss`) are still validated per RFC 7519, but
1236    /// those checks are only meaningful if the token's authenticity is
1237    /// guaranteed by other means.
1238    ///
1239    /// Use only in test environments or when an out-of-band trust anchor (e.g.,
1240    /// mTLS-secured private network) guarantees token authenticity. In
1241    /// production, always prefer [`jwks_validator`].
1242    ///
1243    /// [`jwks_validator`]: CliTokenClientBuilder::jwks_validator
1244    #[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    /// Configure JWKS validation from an [`OpenIdConfiguration`] and transition
1258    /// to [`JwksEnabled`].
1259    ///
1260    /// Uses `open_id_configuration.jwks_uri()` and the `client_id` already set
1261    /// on this builder as the expected audience. Requires both `client_id` and
1262    /// OIDC mode to be set first — enforced at compile time.
1263    #[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    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1285    ///
1286    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1287    ///
1288    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1289    #[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    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1298    ///
1299    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1300    ///
1301    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1302    #[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    /// Build a [`CliTokenClient`] from the configured builder.
1311    ///
1312    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1313    /// at compile time. This method is infallible. JWKS signature verification
1314    /// is enabled; ID tokens will have their signatures verified on every exchange.
1315    #[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    /// Build a [`CliTokenClient`] from the configured builder.
1330    ///
1331    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1332    /// at compile time. This method is infallible. JWKS signature verification
1333    /// is disabled; claims are still validated per RFC 7519.
1334    #[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    /// Build a [`CliTokenClient`] from the configured builder.
1349    ///
1350    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1351    /// at compile time. This method is infallible.
1352    #[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        // Google-style JWTs always include an `aud` claim (the client ID).
1458        // Ensure we decode them without requiring audience validation.
1459        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        // Verifies the unparameterized alias resolves to the all-unset initial state.
1490        let _builder: CliTokenClientBuilder = CliTokenClient::builder();
1491    }
1492
1493    // NOTE: build_without_client_id, build_without_auth_url, and
1494    // build_without_token_url are intentionally absent — omitting any of these
1495    // fields now produces a *compile error* rather than a runtime Err, so there
1496    // is no runtime behavior to test.
1497
1498    #[test]
1499    fn build_with_valid_inputs_returns_client() {
1500        let _client = valid_builder().build();
1501    }
1502
1503    /// RFC 6749 §5.1: when the token response omits the scope field,
1504    /// the client SHOULD assume the requested scopes were granted.
1505    #[test]
1506    fn rfc_6749_s5_1_scope_fallback_uses_requested_scopes_when_response_omits_scope() {
1507        // parse_scopes is the core helper; fallback logic is:
1508        //   token_response.scope.as_deref().map(parse_scopes).unwrap_or_else(|| scopes.to_vec())
1509        // Test the parse_scopes helper and the fallback identity directly.
1510        let requested = vec![OAuth2Scope::OpenId, OAuth2Scope::Email];
1511        // When scope is absent from response, resolved = requested
1512        let resolved: Vec<OAuth2Scope> = None::<String>
1513            .as_deref()
1514            .map_or_else(|| requested.clone(), parse_scopes);
1515        assert_eq!(resolved, requested);
1516
1517        // When scope IS present in response, it is parsed
1518        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        // iat and exp should be non-epoch values
1545        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    // NOTE: build_with_jwks_validator_but_no_openid_scope is intentionally
1573    // absent — calling jwks_validator() on a NoOidc builder no longer compiles.
1574
1575    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    // NOTE: from_open_id_configuration_without_openid_scope_fails_build is
1586    // intentionally absent — from_open_id_configuration() always returns an
1587    // OidcPending builder, so a NoOidc build is impossible to construct.
1588
1589    #[test]
1590    fn from_open_id_configuration_always_includes_openid_scope() {
1591        let config = make_open_id_configuration();
1592        // from_open_id_configuration enters OidcPending mode and pre-populates
1593        // the openid scope; no explicit scope call needed.
1594        let _client = CliTokenClientBuilder::from_open_id_configuration(&config)
1595            .client_id("test-client")
1596            .without_jwks_validation()
1597            .build();
1598    }
1599}