Skip to main content

loopauth/
builder.rs

1use crate::error::{AuthError, CallbackError, RefreshError};
2use crate::jwks::{JwksValidator, JwksValidatorStorage, RemoteJwksValidator};
3use crate::oidc::OpenIdConfiguration;
4use crate::token_response::{TokenParser, default_token_parser};
5
6/// Whether JWKS signature verification is performed on received ID tokens.
7///
8/// Constructed by the builder type-state; never constructed directly.
9#[non_exhaustive]
10pub enum OidcJwksConfig {
11    /// Verify the ID token signature against the provider's JWKS endpoint.
12    Enabled(JwksValidatorStorage),
13    /// Skip JWKS signature verification.
14    ///
15    /// Claims (`exp`, `nbf`, `aud`, `iss`) are still validated. Use only when
16    /// you have an out-of-band trust anchor (e.g., a mTLS-secured private network
17    /// or a test environment where real JWKS validation is not possible).
18    Disabled,
19}
20use crate::pages::{
21    ErrorPageRenderer, ErrorRendererStorage, SuccessPageRenderer, SuccessRendererStorage,
22};
23use crate::scope::{OAuth2Scope, RequestScope};
24use crate::server::{
25    CallbackResult, HttpTransport, PortConfig, RenderedHtml, ServerState, Transport, bind_listener,
26};
27use std::sync::Arc;
28use tokio::sync::{Mutex, mpsc, oneshot};
29
30/// Holds the security-critical authorization URL parameters owned by `loopauth`.
31///
32/// Constructed just before the auth URL is finalized; `append_to` writes all
33/// parameters to the URL.  `KEYS` is the authoritative list of reserved
34/// parameter names — [`ExtraAuthParams`] uses it to reject hook-supplied
35/// values that would collide with these fields.
36struct AuthUrlParams<'a> {
37    client_id: &'a str,
38    redirect_uri: &'a url::Url,
39    state_token: &'a str,
40    pkce: &'a crate::pkce::PkceChallenge,
41    nonce: Option<&'a str>,
42    scopes: &'a [OAuth2Scope],
43}
44
45impl AuthUrlParams<'_> {
46    /// The query-parameter keys set by [`AuthUrlParams::append_to`].
47    ///
48    /// Used by [`ExtraAuthParams`] to reject hook-supplied pairs whose keys
49    /// would collide with library-controlled values.
50    const KEYS: &'static [&'static str] = &[
51        "response_type",
52        "client_id",
53        "redirect_uri",
54        "state",
55        "code_challenge",
56        "code_challenge_method",
57        "nonce",
58        "scope",
59    ];
60
61    fn append_to(&self, url: &mut url::Url) {
62        url.query_pairs_mut()
63            .append_pair("response_type", "code")
64            .append_pair("client_id", self.client_id)
65            .append_pair("redirect_uri", self.redirect_uri.as_str())
66            .append_pair("state", self.state_token)
67            .append_pair("code_challenge", &self.pkce.code_challenge)
68            .append_pair("code_challenge_method", self.pkce.code_challenge_method);
69
70        if let Some(nonce) = self.nonce {
71            url.query_pairs_mut().append_pair("nonce", nonce);
72        }
73
74        if !self.scopes.is_empty() {
75            let scope_str = self
76                .scopes
77                .iter()
78                .map(ToString::to_string)
79                .collect::<Vec<_>>()
80                .join(" ");
81            url.query_pairs_mut().append_pair("scope", &scope_str);
82        }
83    }
84}
85
86/// Accumulates extra query parameters to append to the authorization URL.
87///
88/// Passed by `&mut` reference to the callback registered with
89/// [`CliTokenClientBuilder::on_auth_url`].  Call [`ExtraAuthParams::append`]
90/// to add provider-specific parameters such as `access_type=offline` for
91/// Google OAuth 2.0.
92///
93/// The following keys are **reserved** and cannot be overridden via this
94/// interface; any attempt is dropped and a `tracing::warn!` is emitted:
95/// `response_type`, `client_id`, `redirect_uri`, `state`,
96/// `code_challenge`, `code_challenge_method`, `nonce`, `scope`.
97///
98/// The library sets those parameters unconditionally to satisfy RFC 6749
99/// §4.1.1, RFC 7636, and OIDC Core §3.1.2.1 security requirements.
100pub struct ExtraAuthParams {
101    pairs: Vec<(String, String)>,
102}
103
104impl ExtraAuthParams {
105    const fn new() -> Self {
106        Self { pairs: Vec::new() }
107    }
108
109    /// Append a query parameter to the authorization URL.
110    ///
111    /// Parameters whose key matches a reserved name are dropped with a
112    /// `tracing::warn!`; see the type-level docs for the full list of reserved
113    /// keys.
114    pub fn append(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
115        self.pairs.push((key.into(), value.into()));
116        self
117    }
118
119    fn apply_to(self, url: &mut url::Url) {
120        for (key, value) in self.pairs {
121            if AuthUrlParams::KEYS.contains(&key.as_str()) {
122                tracing::warn!(
123                    key = key.as_str(),
124                    "on_auth_url hook attempted to set a reserved parameter; ignoring"
125                );
126            } else {
127                url.query_pairs_mut().append_pair(&key, &value);
128            }
129        }
130    }
131}
132
133type OnAuthUrlCallback = Box<dyn Fn(&mut ExtraAuthParams) + Send + Sync + 'static>;
134type OnUrlCallback = Box<dyn Fn(&url::Url) + Send + Sync + 'static>;
135type OnServerReadyCallback = Box<dyn Fn(u16) + Send + Sync + 'static>;
136
137/// An OAuth 2.0 client identifier.
138#[derive(Debug, Clone)]
139pub struct ClientId(String);
140
141impl ClientId {
142    pub(crate) fn as_str(&self) -> &str {
143        &self.0
144    }
145}
146
147const TIMEOUT_DURATION_IN_SECONDS: u64 = 300;
148const HTTP_CONNECT_TIMEOUT_SECONDS: u64 = 10;
149const HTTP_REQUEST_TIMEOUT_SECONDS: u64 = 30;
150
151/// Acquires OAuth 2.0 provider tokens for CLI applications via the Authorization
152/// Code + PKCE flow.
153///
154/// Construct with [`CliTokenClient::builder`] and call
155/// [`CliTokenClient::run_authorization_flow`] to run the full flow. Use
156/// [`CliTokenClient::refresh`] or [`CliTokenClient::refresh_if_expiring`] to
157/// renew tokens without re-running the authorization flow.
158///
159/// The callback server runs over plain HTTP by default. For providers that
160/// require HTTPS redirect URIs, use
161/// [`CliTokenClientBuilder::use_https_with`] with a [`crate::TlsCertificate`].
162pub struct CliTokenClient {
163    client_id: ClientId,
164    client_secret: Option<String>,
165    auth_url: url::Url,
166    token_url: url::Url,
167    issuer: Option<url::Url>,
168    scopes: Vec<OAuth2Scope>,
169    port_config: PortConfig,
170    success_html: Option<String>,
171    error_html: Option<String>,
172    success_renderer: Option<SuccessRendererStorage>,
173    error_renderer: Option<ErrorRendererStorage>,
174    open_browser: bool,
175    timeout: std::time::Duration,
176    on_auth_url: Option<OnAuthUrlCallback>,
177    on_url: Option<OnUrlCallback>,
178    on_server_ready: Option<OnServerReadyCallback>,
179    oidc_jwks: Option<OidcJwksConfig>,
180    http_client: reqwest::Client,
181    transport: Arc<dyn Transport>,
182    token_parser: TokenParser,
183}
184
185impl CliTokenClient {
186    /// Create a new [`CliTokenClientBuilder`].
187    #[must_use]
188    pub fn builder() -> CliTokenClientBuilder {
189        CliTokenClientBuilder::default()
190    }
191
192    /// Run the full OAuth 2.0 Authorization Code + PKCE flow.
193    ///
194    /// # Errors
195    ///
196    /// Returns `AuthError::ServerBind` if the loopback server cannot bind (including TLS setup failures in HTTPS mode).
197    /// Returns `AuthError::Browser` if `open_browser` is true and the browser fails to open.
198    /// Returns `AuthError::Timeout` if the callback is not received within the configured timeout (default: 5 minutes).
199    /// Returns `AuthError::Callback(CallbackError::StateMismatch)` if the callback state parameter does not match.
200    /// Returns `AuthError::Callback(CallbackError::ProviderError)` if the callback contains an `error` parameter.
201    /// Returns `AuthError::TokenExchange` if the token endpoint returns non-2xx.
202    ///
203    /// # Example
204    ///
205    /// ```
206    /// # #[tokio::main]
207    /// # async fn main() {
208    /// use loopauth::{CliTokenClient, OAuth2Scope};
209    /// use loopauth::test_support::FakeOAuthServer;
210    /// use std::sync::{Arc, Mutex};
211    ///
212    /// let server = FakeOAuthServer::start("my_token").await;
213    /// let (tx, rx) = tokio::sync::oneshot::channel::<url::Url>();
214    /// let tx = Arc::new(Mutex::new(Some(tx)));
215    /// let client = CliTokenClient::builder()
216    ///     .client_id("test-client")
217    ///     .auth_url(server.auth_url())
218    ///     .token_url(server.token_url())
219    ///     .open_browser(false)
220    ///     .on_url(move |url| {
221    ///         if let Some(tx) = tx.lock().unwrap().take() {
222    ///             let _ = tx.send(url.clone());
223    ///         }
224    ///     })
225    ///     .build();
226    ///
227    /// // Spawn a task to fire the redirect (simulates the browser callback)
228    /// tokio::spawn(async move {
229    ///     if let Ok(url) = rx.await {
230    ///         let _ = reqwest::get(url).await;
231    ///     }
232    /// });
233    ///
234    /// let tokens = client.run_authorization_flow().await.unwrap();
235    /// assert_eq!(tokens.access_token().as_str(), "my_token");
236    /// # }
237    /// ```
238    pub async fn run_authorization_flow(&self) -> Result<crate::token::TokenSet, AuthError> {
239        // 1. Bind listener
240        let listener = bind_listener(self.port_config)
241            .await
242            .map_err(AuthError::ServerBind)?;
243
244        // 2. Build redirect URI from listener
245        let redirect_uri_url = self
246            .transport
247            .redirect_uri(&listener)
248            .map_err(AuthError::ServerBind)
249            .and_then(|redirect_uri| {
250                url::Url::parse(&redirect_uri).map_err(AuthError::InvalidUrl)
251            })?;
252
253        // 3. Generate PKCE challenge
254        let pkce = crate::pkce::PkceChallenge::generate();
255
256        // 4. Generate state token
257        let state_token = uuid::Uuid::new_v4().to_string();
258
259        // 5. Generate nonce when OIDC is active (OIDC Core §3.1.2.1)
260        let nonce = self
261            .oidc_jwks
262            .is_some()
263            .then(|| uuid::Uuid::new_v4().to_string());
264
265        // 6. Build auth URL with query params
266        let mut auth_url = self.auth_url.clone();
267        AuthUrlParams {
268            client_id: self.client_id.as_str(),
269            redirect_uri: &redirect_uri_url,
270            state_token: &state_token,
271            pkce: &pkce,
272            nonce: nonce.as_deref(),
273            scopes: &self.scopes,
274        }
275        .append_to(&mut auth_url);
276
277        // 7. Call on_auth_url hook to collect extra parameters
278        if let Some(ref hook) = self.on_auth_url {
279            let mut extras = ExtraAuthParams::new();
280            hook(&mut extras);
281            extras.apply_to(&mut auth_url);
282        }
283
284        // 8. Create channels
285        let (outer_tx, outer_rx) = mpsc::channel::<CallbackResult>(1);
286        let (inner_tx, inner_rx) = mpsc::channel::<RenderedHtml>(1);
287        let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
288
289        // 9. Build ServerState
290        let server_state = ServerState {
291            outer_tx,
292            inner_rx: Arc::new(Mutex::new(Some(inner_rx))),
293            shutdown_tx: Arc::new(Mutex::new(Some(shutdown_tx))),
294        };
295
296        // 10. Spawn callback server
297        let port = listener.local_addr().map_err(AuthError::ServerBind)?.port();
298        let shutdown_arc = Arc::clone(&server_state.shutdown_tx);
299        let transport = Arc::clone(&self.transport);
300        tokio::spawn(async move {
301            transport
302                .run_server(listener, server_state, shutdown_rx)
303                .await
304        });
305
306        // 11. Call on_server_ready hook
307        if let Some(ref hook) = self.on_server_ready {
308            hook(port);
309        }
310
311        // 12. Call on_url hook AFTER server is spawned
312        if let Some(ref hook) = self.on_url {
313            hook(&auth_url);
314        }
315
316        // 13. Open browser or log URL
317        if self.open_browser {
318            webbrowser::open(auth_url.as_str()).map_err(|e| AuthError::Browser(e.to_string()))?;
319        } else {
320            tracing::info!(url = auth_url.as_str(), "authorization URL");
321        }
322
323        // 14-18. Wait for callback, exchange code, send HTML response
324        handle_callback(
325            self,
326            &redirect_uri_url,
327            &state_token,
328            &pkce.code_verifier,
329            nonce.as_deref(),
330            inner_tx,
331            outer_rx,
332            shutdown_arc,
333        )
334        .await
335    }
336
337    /// Exchange a refresh token for a new [`crate::TokenSet`].
338    ///
339    /// # Errors
340    ///
341    /// Returns [`RefreshError::NoRefreshToken`] when `refresh_token` is empty.
342    /// Returns [`RefreshError::TokenExchange`] when the token endpoint returns non-2xx.
343    /// Returns [`RefreshError::Request`] on network failure.
344    ///
345    /// # Example
346    ///
347    /// ```
348    /// # #[tokio::main]
349    /// # async fn main() {
350    /// use loopauth::CliTokenClient;
351    /// use loopauth::test_support::FakeOAuthServer;
352    ///
353    /// let server = FakeOAuthServer::start_with_refresh("new_token", "rt_value").await;
354    /// let client = CliTokenClient::builder()
355    ///     .client_id("test-client")
356    ///     .auth_url(server.auth_url())
357    ///     .token_url(server.token_url())
358    ///     .build();
359    ///
360    /// let tokens = client.refresh("rt_value").await.unwrap();
361    /// assert_eq!(tokens.access_token().as_str(), "new_token");
362    /// # }
363    /// ```
364    pub async fn refresh(
365        &self,
366        refresh_token: &str,
367    ) -> Result<crate::token::TokenSet, RefreshError> {
368        if refresh_token.is_empty() {
369            return Err(RefreshError::NoRefreshToken);
370        }
371        let unvalidated = exchange_refresh_token(
372            &self.http_client,
373            &self.token_url,
374            self.client_id.as_str(),
375            self.client_secret.as_deref(),
376            &self.token_parser,
377            refresh_token,
378            &self.scopes,
379        )
380        .await?;
381        if let Some(oidc_jwks) = &self.oidc_jwks {
382            validate_id_token_if_present(
383                oidc_jwks,
384                unvalidated,
385                self.client_id.as_str(),
386                self.issuer.as_ref().map_or(
387                    crate::oidc::IssuerValidation::Skip,
388                    crate::oidc::IssuerValidation::MustMatch,
389                ),
390            )
391            .await
392            .map_err(RefreshError::IdToken)
393        } else {
394            Ok(unvalidated.into_validated())
395        }
396    }
397
398    /// Refresh `tokens` if they expire within `threshold`; otherwise return [`crate::RefreshOutcome::NotNeeded`].
399    ///
400    /// # Errors
401    ///
402    /// Propagates any error from [`Self::refresh`].
403    ///
404    /// # Example
405    ///
406    /// ```
407    /// # #[tokio::main]
408    /// # async fn main() {
409    /// use loopauth::{CliTokenClient, RefreshOutcome};
410    /// use loopauth::test_support::FakeOAuthServer;
411    /// use std::time::Duration;
412    ///
413    /// let server = FakeOAuthServer::start_with_refresh("new_token", "rt_value").await;
414    /// let client = CliTokenClient::builder()
415    ///     .client_id("test-client")
416    ///     .auth_url(server.auth_url())
417    ///     .token_url(server.token_url())
418    ///     .build();
419    ///
420    /// // Build an already-expired TokenSet so the refresh branch is taken
421    /// let tokens: loopauth::TokenSet<loopauth::Unvalidated> = serde_json::from_value(serde_json::json!({
422    ///     "access_token": "old_token",
423    ///     "token_type": "Bearer",
424    ///     "refresh_token": "rt_value",
425    ///     "expires_at": 0
426    /// })).unwrap();
427    /// let tokens = tokens.into_validated();
428    ///
429    /// let outcome = client.refresh_if_expiring(&tokens, Duration::from_secs(300)).await.unwrap();
430    /// assert!(matches!(outcome, RefreshOutcome::Refreshed(_)));
431    /// # }
432    /// ```
433    pub async fn refresh_if_expiring(
434        &self,
435        tokens: &crate::token::TokenSet,
436        threshold: std::time::Duration,
437    ) -> Result<crate::token::RefreshOutcome, RefreshError> {
438        if !tokens.expires_within(threshold) {
439            return Ok(crate::token::RefreshOutcome::NotNeeded);
440        }
441        let refresh_token = tokens.refresh_token().ok_or(RefreshError::NoRefreshToken)?;
442        let new_tokens = self.refresh(refresh_token.as_str()).await?;
443        Ok(crate::token::RefreshOutcome::Refreshed(Box::new(
444            new_tokens,
445        )))
446    }
447}
448
449/// Parse an `id_token` JWT from a token response, if `openid` was in the requested scopes.
450///
451/// Returns `Ok(None)` when `openid` was not requested or when the provider omitted `id_token`.
452/// Returns `Err(IdTokenError)` when parsing the JWT fails.
453fn parse_oidc_if_requested(
454    id_token: Option<&str>,
455    scopes: &[crate::scope::OAuth2Scope],
456) -> Result<Option<crate::oidc::Token>, crate::error::IdTokenError> {
457    if !scopes.contains(&crate::scope::OAuth2Scope::OpenId) {
458        return Ok(None);
459    }
460    id_token.map(crate::oidc::Token::from_raw_jwt).transpose()
461}
462
463/// Parse a space-separated scope string into a `Vec<OAuth2Scope>`.
464fn parse_scopes(scope_str: &str) -> Vec<OAuth2Scope> {
465    scope_str
466        .split_whitespace()
467        .map(OAuth2Scope::from)
468        .collect()
469}
470
471async fn trigger_shutdown(shutdown_arc: &Arc<Mutex<Option<oneshot::Sender<()>>>>) {
472    let mut guard = shutdown_arc.lock().await;
473    if let Some(tx) = guard.take() {
474        let _ = tx.send(());
475    }
476}
477
478async fn resolve_callback_code(
479    callback_result: CallbackResult,
480    state_token: &str,
481    auth: &CliTokenClient,
482    redirect_uri_url: &url::Url,
483    inner_tx: &mpsc::Sender<RenderedHtml>,
484) -> Result<String, CallbackError> {
485    match validate_callback_code(callback_result, state_token) {
486        Err(err) => {
487            let html = render_error_html(&err.clone().into(), auth, redirect_uri_url).await;
488            let _ = inner_tx.send(RenderedHtml(html)).await;
489            Err(err)
490        }
491        v => v,
492    }
493}
494
495fn validate_callback_code(
496    callback_result: CallbackResult,
497    state_token: &str,
498) -> Result<String, CallbackError> {
499    use subtle::ConstantTimeEq as _;
500
501    match callback_result {
502        CallbackResult::Success { code, state }
503            if state.as_bytes().ct_eq(state_token.as_bytes()).into() =>
504        {
505            Ok(code)
506        }
507        CallbackResult::Success { .. } => Err(CallbackError::StateMismatch),
508        CallbackResult::ProviderError { error, description } => Err(CallbackError::ProviderError {
509            error,
510            description: description.unwrap_or_default(),
511        }),
512    }
513}
514
515/// Validate an ID token that MUST be present; used in the initial authorization flow.
516///
517/// Two-phase validation per RFC 7519 §7.2:
518/// 1. Cryptographic signature check via JWKS (when [`OidcJwksConfig::Enabled`]).
519/// 2. Standard claims: `exp`, `nbf`, `aud`, optionally `iss`, and optionally `nonce`.
520///
521/// Claims are only checked after the signature is verified to prevent accepting
522/// claims from a tampered or unsigned token.
523///
524/// Returns [`crate::error::IdTokenError::NoIdToken`] when the token set carries no `id_token`.
525async fn validate_id_token_required(
526    oidc_jwks: &OidcJwksConfig,
527    token_set: crate::token::TokenSet<crate::token::Unvalidated>,
528    client_id: &str,
529    issuer: crate::oidc::IssuerValidation<'_>,
530    expected_nonce: Option<&str>,
531) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
532    use crate::error::IdTokenError;
533
534    let oidc = token_set.oidc_token().ok_or(IdTokenError::NoIdToken)?;
535
536    if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
537        validator
538            .validate(oidc.raw())
539            .await
540            .map_err(IdTokenError::JwksValidationFailed)?;
541    }
542
543    // RFC 7519 §7.2: validate standard claims after signature check
544    oidc.validate_standard_claims(client_id, issuer, expected_nonce)?;
545
546    Ok(token_set.into_validated())
547}
548
549/// Validate an ID token if present; used in the refresh flow.
550///
551/// Most OIDC providers do not return an `id_token` on refresh. When absent the
552/// token set is promoted directly without validation. When present, full two-phase
553/// validation is performed (signature + standard claims). Nonce is never checked
554/// on refresh (OIDC Core §3.1.3.7).
555async fn validate_id_token_if_present(
556    oidc_jwks: &OidcJwksConfig,
557    token_set: crate::token::TokenSet<crate::token::Unvalidated>,
558    client_id: &str,
559    issuer: crate::oidc::IssuerValidation<'_>,
560) -> Result<crate::token::TokenSet<crate::token::Validated>, crate::error::IdTokenError> {
561    use crate::error::IdTokenError;
562
563    let Some(oidc) = token_set.oidc_token() else {
564        return Ok(token_set.into_validated());
565    };
566
567    if let OidcJwksConfig::Enabled(validator) = oidc_jwks {
568        validator
569            .validate(oidc.raw())
570            .await
571            .map_err(IdTokenError::JwksValidationFailed)?;
572    }
573
574    // RFC 7519 §7.2: validate standard claims after signature check; nonce skipped on refresh
575    oidc.validate_standard_claims(client_id, issuer, None)?;
576
577    Ok(token_set.into_validated())
578}
579
580#[expect(
581    clippy::too_many_arguments,
582    reason = "private orchestrator function; all args are distinct concerns that cannot be bundled without noise"
583)]
584async fn handle_callback(
585    auth: &CliTokenClient,
586    redirect_uri_url: &url::Url,
587    state_token: &str,
588    code_verifier: &str,
589    nonce: Option<&str>,
590    inner_tx: mpsc::Sender<RenderedHtml>,
591    mut outer_rx: mpsc::Receiver<CallbackResult>,
592    shutdown_arc: Arc<Mutex<Option<oneshot::Sender<()>>>>,
593) -> Result<crate::token::TokenSet<crate::token::Validated>, AuthError> {
594    // Wait for callback, racing against timeout and Ctrl+C
595    let callback_result = tokio::select! {
596        result = tokio::time::timeout(auth.timeout, outer_rx.recv()) => {
597            match result {
598                Err(_) => {
599                    trigger_shutdown(&shutdown_arc).await;
600                    return Err(AuthError::Timeout);
601                }
602                Ok(None) => return Err(AuthError::Server("channel closed".to_string())),
603                Ok(Some(r)) => r,
604            }
605        }
606        _ = tokio::signal::ctrl_c() => {
607            trigger_shutdown(&shutdown_arc).await;
608            return Err(AuthError::Cancelled);
609        }
610    };
611
612    // Match callback result - send error HTML before returning Err
613    let code = resolve_callback_code(
614        callback_result,
615        state_token,
616        auth,
617        redirect_uri_url,
618        &inner_tx,
619    )
620    .await?;
621
622    // Exchange code for token - send error HTML on failure
623    let token_set = match exchange_code(
624        &auth.http_client,
625        &auth.token_url,
626        auth.client_id.as_str(),
627        auth.client_secret.as_deref(),
628        &auth.token_parser,
629        &code,
630        redirect_uri_url.as_str(),
631        code_verifier,
632        &auth.scopes,
633    )
634    .await
635    {
636        Ok(ts) => ts,
637        Err(e) => {
638            let html = render_error_html(&e, auth, redirect_uri_url).await;
639            let _ = inner_tx.send(RenderedHtml(html)).await;
640            return Err(e);
641        }
642    };
643
644    // Run JWKS validation when OIDC is configured; otherwise promote directly
645    let token_set = if let Some(oidc_jwks) = &auth.oidc_jwks {
646        match validate_id_token_required(
647            oidc_jwks,
648            token_set,
649            auth.client_id.as_str(),
650            auth.issuer.as_ref().map_or(
651                crate::oidc::IssuerValidation::Skip,
652                crate::oidc::IssuerValidation::MustMatch,
653            ),
654            nonce,
655        )
656        .await
657        .map_err(AuthError::IdToken)
658        {
659            Ok(ts) => ts,
660            Err(e) => {
661                let html = render_error_html(&e, auth, redirect_uri_url).await;
662                let _ = inner_tx.send(RenderedHtml(html)).await;
663                return Err(e);
664            }
665        }
666    } else {
667        token_set.into_validated()
668    };
669
670    // Send success HTML to callback handler (renderer > html string > default)
671    let html = render_success_html(
672        &token_set,
673        token_set.scopes(),
674        redirect_uri_url,
675        auth.client_id.as_str(),
676        auth.success_renderer.as_deref(),
677        auth.success_html.as_deref(),
678    )
679    .await;
680    let _ = inner_tx.send(RenderedHtml(html)).await;
681
682    Ok(token_set)
683}
684
685async fn render_error_html(
686    err: &AuthError,
687    auth: &CliTokenClient,
688    redirect_uri_url: &url::Url,
689) -> String {
690    let ctx = crate::pages::ErrorPageContext::new(
691        err,
692        &auth.scopes,
693        redirect_uri_url,
694        auth.client_id.as_str(),
695    );
696    if let Some(renderer) = auth.error_renderer.as_deref() {
697        renderer.render_error(&ctx).await
698    } else if let Some(html) = auth.error_html.as_deref() {
699        html.to_string()
700    } else {
701        crate::pages::DefaultErrorPageRenderer
702            .render_error(&ctx)
703            .await
704    }
705}
706
707async fn render_success_html(
708    token_set: &crate::token::TokenSet,
709    scopes: &[OAuth2Scope],
710    redirect_uri_url: &url::Url,
711    client_id: &str,
712    success_renderer: Option<&(dyn crate::pages::SuccessPageRenderer + Send + Sync)>,
713    success_html: Option<&str>,
714) -> String {
715    let ctx = crate::pages::PageContext::new(
716        token_set.oidc().map(crate::oidc::Token::claims),
717        scopes,
718        redirect_uri_url,
719        client_id,
720        token_set.expires_at(),
721        token_set.refresh_token().is_some(),
722    );
723    if let Some(renderer) = success_renderer {
724        renderer.render_success(&ctx).await
725    } else if let Some(html) = success_html {
726        html.to_string()
727    } else {
728        crate::pages::DefaultSuccessPageRenderer
729            .render_success(&ctx)
730            .await
731    }
732}
733
734#[expect(
735    clippy::too_many_arguments,
736    reason = "all arguments are distinct OAuth2 code exchange parameters; grouping them would obscure their individual meanings"
737)]
738async fn exchange_code(
739    http_client: &reqwest::Client,
740    token_url: &url::Url,
741    client_id: &str,
742    client_secret: Option<&str>,
743    token_parser: &TokenParser,
744    code: &str,
745    redirect_uri: &str,
746    code_verifier: &str,
747    scopes: &[crate::scope::OAuth2Scope],
748) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, AuthError> {
749    let mut params = vec![
750        ("grant_type", "authorization_code"),
751        ("code", code),
752        ("redirect_uri", redirect_uri),
753        ("client_id", client_id),
754        ("code_verifier", code_verifier),
755    ];
756    if let Some(secret) = client_secret {
757        params.push(("client_secret", secret));
758    }
759
760    let t0 = std::time::SystemTime::now();
761    let response = http_client
762        .post(token_url.as_str())
763        .header(reqwest::header::ACCEPT, "application/json")
764        .form(&params)
765        .send()
766        .await?;
767
768    if !response.status().is_success() {
769        let status = response.status().as_u16();
770        let body_bytes = response.bytes().await.unwrap_or_default();
771        let body = String::from_utf8_lossy(&body_bytes).into_owned();
772        return Err(AuthError::TokenExchange { status, body });
773    }
774
775    let body = response.text().await?;
776    let fields = token_parser(&body).map_err(|e| AuthError::TokenParse(format!("{e}: {body}")))?;
777
778    let expires_at = fields
779        .expires_in
780        .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs)));
781
782    let oidc =
783        parse_oidc_if_requested(fields.id_token.as_deref(), scopes).map_err(AuthError::IdToken)?;
784
785    // RFC 6749 §5.1: if scope omitted, use requested scopes
786    let resolved_scopes = fields
787        .scope
788        .as_deref()
789        .map_or_else(|| scopes.to_vec(), parse_scopes);
790
791    Ok(crate::token::TokenSet::new(
792        fields.access_token,
793        fields.refresh_token,
794        expires_at,
795        fields.token_type.unwrap_or_else(|| "Bearer".to_string()),
796        oidc,
797        resolved_scopes,
798    ))
799}
800
801async fn exchange_refresh_token(
802    http_client: &reqwest::Client,
803    token_url: &url::Url,
804    client_id: &str,
805    client_secret: Option<&str>,
806    token_parser: &TokenParser,
807    refresh_token: &str,
808    scopes: &[crate::scope::OAuth2Scope],
809) -> Result<crate::token::TokenSet<crate::token::Unvalidated>, RefreshError> {
810    // RFC 6749 §6: scope is optional on refresh but required by some providers
811    let scope_str = (!scopes.is_empty()).then(|| {
812        scopes
813            .iter()
814            .map(ToString::to_string)
815            .collect::<Vec<_>>()
816            .join(" ")
817    });
818
819    let mut params = vec![
820        ("grant_type", "refresh_token"),
821        ("refresh_token", refresh_token),
822        ("client_id", client_id),
823    ];
824    if let Some(secret) = client_secret {
825        params.push(("client_secret", secret));
826    }
827    if let Some(ref s) = scope_str {
828        params.push(("scope", s.as_str()));
829    }
830
831    let t0 = std::time::SystemTime::now();
832    let response = http_client
833        .post(token_url.as_str())
834        .header(reqwest::header::ACCEPT, "application/json")
835        .form(&params)
836        .send()
837        .await?; // RefreshError::Request via #[from] reqwest::Error
838
839    if !response.status().is_success() {
840        let status = response.status().as_u16();
841        let body_bytes = response.bytes().await.unwrap_or_default();
842        let body = String::from_utf8_lossy(&body_bytes).into_owned();
843        return Err(RefreshError::TokenExchange { status, body });
844    }
845
846    let body = response.text().await?;
847    let fields =
848        token_parser(&body).map_err(|e| RefreshError::TokenParse(format!("{e}: {body}")))?;
849
850    let expires_at = fields
851        .expires_in
852        .and_then(|secs| t0.checked_add(std::time::Duration::from_secs(secs)));
853
854    let oidc = parse_oidc_if_requested(fields.id_token.as_deref(), scopes)
855        .map_err(RefreshError::IdToken)?;
856
857    // RFC 6749 §5.1: if scope omitted, use requested scopes
858    let resolved_scopes = fields
859        .scope
860        .as_deref()
861        .map_or_else(|| scopes.to_vec(), parse_scopes);
862
863    // RFC 6749 §6: the server MAY issue a new refresh token, in which case
864    // the client MUST discard the old one and replace it with the new one.
865    // When the server omits refresh_token from the response, the original
866    // refresh token remains valid and must be preserved.
867    let resolved_refresh_token = fields
868        .refresh_token
869        .or_else(|| Some(refresh_token.to_string()));
870
871    Ok(crate::token::TokenSet::new(
872        fields.access_token,
873        resolved_refresh_token,
874        expires_at,
875        fields.token_type.unwrap_or_else(|| "Bearer".to_string()),
876        oidc,
877        resolved_scopes,
878    ))
879}
880
881// ── Type-state markers ────────────────────────────────────────────────────────
882//
883// `CliTokenClientBuilder` carries four type parameters that track required
884// configuration at compile time. `build()` is only reachable once all required
885// fields are in their `Has*` state, turning omitted-field bugs into compile
886// errors rather than runtime panics.
887//
888// Three parameters track the individually-required fields:
889//   C — client_id   (NoClientId | HasClientId)
890//   A — auth_url    (NoAuthUrl  | HasAuthUrl)
891//   T — token_url   (NoTokenUrl | HasTokenUrl)
892//
893// One parameter tracks OIDC + JWKS state:
894//   O — oidc        (NoOidc | OidcPending | JwksEnabled | JwksDisabled)
895//
896// OIDC mode is entered via `with_openid_scope()` or `from_open_id_configuration()`,
897// which transitions to `OidcPending`. From `OidcPending`, callers must resolve JWKS
898// by calling either `jwks_validator()` (→ `JwksEnabled`) or `without_jwks_validation()`
899// (→ `JwksDisabled`) before `build()` becomes available. This ensures that opting out
900// of signature verification is always an explicit, visible choice rather than a silent
901// default.
902
903/// Type-state: loopback server uses plain HTTP (default).
904#[non_exhaustive]
905pub struct Http;
906
907/// Type-state: loopback server uses HTTPS.
908///
909/// Created by [`CliTokenClientBuilder::use_https`] (self-signed) or
910/// [`CliTokenClientBuilder::use_https_with`] (user-provided certificate).
911pub struct Https(Option<crate::tls::TlsCertificate>);
912
913/// Converts a scheme type-state marker into a transport implementation.
914///
915/// Keeps `build()` generic over `S` while ensuring the transport is fully
916/// determined by the type.
917pub trait IntoTransport: sealed::Sealed {
918    /// Create the transport implementation for this scheme.
919    fn into_transport(self) -> Arc<dyn Transport>;
920}
921
922impl sealed::Sealed for Http {}
923impl IntoTransport for Http {
924    fn into_transport(self) -> Arc<dyn Transport> {
925        Arc::new(HttpTransport)
926    }
927}
928
929impl sealed::Sealed for Https {}
930impl IntoTransport for Https {
931    fn into_transport(self) -> Arc<dyn Transport> {
932        match self.0 {
933            Some(cert) => Arc::new(crate::server::HttpsCustomTransport {
934                acceptor: cert.acceptor,
935            }),
936            None => Arc::new(crate::server::HttpsSelfSignedTransport),
937        }
938    }
939}
940
941mod sealed {
942    pub trait Sealed {}
943}
944
945/// Type-state: `client_id` not yet provided.
946#[non_exhaustive]
947pub struct NoClientId;
948/// Type-state: `client_id` has been provided.
949#[non_exhaustive]
950pub struct HasClientId(ClientId);
951/// Type-state: `auth_url` not yet provided.
952#[non_exhaustive]
953pub struct NoAuthUrl;
954/// Type-state: `auth_url` has been provided.
955#[non_exhaustive]
956pub struct HasAuthUrl(url::Url);
957/// Type-state: `token_url` not yet provided.
958#[non_exhaustive]
959pub struct NoTokenUrl;
960/// Type-state: `token_url` has been provided.
961#[non_exhaustive]
962pub struct HasTokenUrl(url::Url);
963/// Type-state: OIDC mode not yet engaged; `openid` scope is not included.
964#[non_exhaustive]
965pub struct NoOidc;
966/// Type-state: OIDC mode engaged but JWKS decision not yet made.
967///
968/// Call [`CliTokenClientBuilder::jwks_validator`] to enable signature verification
969/// or [`CliTokenClientBuilder::without_jwks_validation`] to explicitly opt out.
970/// `build()` is not available in this state.
971#[non_exhaustive]
972pub struct OidcPending;
973/// Type-state: OIDC mode engaged with JWKS signature verification enabled.
974///
975/// `build()` is available.
976pub struct JwksEnabled(JwksValidatorStorage);
977/// Type-state: OIDC mode engaged with JWKS signature verification explicitly disabled.
978///
979/// Claims (`exp`, `nbf`, `aud`, `iss`) are still validated. `build()` is available.
980#[non_exhaustive]
981pub struct JwksDisabled;
982
983// All optional builder fields live in a private inner struct. This means the
984// state-transition methods (`client_id`, `auth_url`, `token_url`,
985// `with_openid_scope`) only need to forward one `config` field when
986// reconstructing the builder with a new type, rather than copying every
987// individual optional field.
988struct BuilderConfig {
989    client_secret: Option<String>,
990    issuer: Option<url::Url>,
991    scopes: std::collections::BTreeSet<OAuth2Scope>,
992    port_config: PortConfig,
993    success_html: Option<String>,
994    error_html: Option<String>,
995    success_renderer: Option<SuccessRendererStorage>,
996    error_renderer: Option<ErrorRendererStorage>,
997    open_browser: bool,
998    timeout: std::time::Duration,
999    on_auth_url: Option<OnAuthUrlCallback>,
1000    on_url: Option<OnUrlCallback>,
1001    on_server_ready: Option<OnServerReadyCallback>,
1002    token_parser: Option<TokenParser>,
1003}
1004
1005impl Default for BuilderConfig {
1006    fn default() -> Self {
1007        Self {
1008            client_secret: None,
1009            scopes: std::collections::BTreeSet::new(),
1010            port_config: PortConfig::Random,
1011            success_html: None,
1012            error_html: None,
1013            success_renderer: None,
1014            error_renderer: None,
1015            open_browser: true,
1016            timeout: std::time::Duration::from_secs(TIMEOUT_DURATION_IN_SECONDS),
1017            on_auth_url: None,
1018            on_url: None,
1019            on_server_ready: None,
1020            issuer: None,
1021            token_parser: None,
1022        }
1023    }
1024}
1025
1026/// Builder for [`CliTokenClient`].
1027///
1028/// Obtain via [`CliTokenClient::builder`]. The three required fields `client_id`,
1029/// `auth_url`, and `token_url` are tracked at the type level — [`build`] is only
1030/// callable once all three have been set, so omitting any of them is a **compile
1031/// error**. OIDC mode is tracked separately: JWKS validator methods are only
1032/// available after calling [`with_openid_scope`] or using
1033/// [`from_open_id_configuration`].
1034///
1035/// [`build`]: CliTokenClientBuilder::build
1036/// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
1037/// [`from_open_id_configuration`]: CliTokenClientBuilder::from_open_id_configuration
1038///
1039/// # Defaults
1040///
1041/// | Field | Default |
1042/// |-------|---------|
1043/// | `client_secret` | `None` (public client - PKCE only) |
1044/// | `scopes` | empty (plus `openid` when OIDC mode is engaged) |
1045/// | `port` | OS assigns port (use `port_hint` for soft preference, `require_port` for hard requirement) |
1046/// | `transport` | HTTP (use [`use_https_with`](CliTokenClientBuilder::use_https_with) for trusted HTTPS, or [`use_https`](CliTokenClientBuilder::use_https) for self-signed) |
1047/// | `open_browser` | `true` |
1048/// | `timeout` | 5 minutes |
1049///
1050/// # Page rendering priority
1051///
1052/// Both the success and error pages follow the same three-tier priority:
1053///
1054/// 1. **Custom renderer** - [`CliTokenClientBuilder::success_renderer`] /
1055///    [`CliTokenClientBuilder::error_renderer`] (called dynamically with full context).
1056/// 2. **Custom HTML string** - [`CliTokenClientBuilder::success_html`] /
1057///    [`CliTokenClientBuilder::error_html`] (returned verbatim, no templating).
1058/// 3. **Default embedded page** - used when neither of the above is set.
1059///
1060/// # Example
1061///
1062/// ```no_run
1063/// use loopauth::{CliTokenClient, RequestScope};
1064///
1065/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
1066/// let client = CliTokenClient::builder()
1067///     .client_id("my-client-id")
1068///     .auth_url(url::Url::parse("https://provider.example.com/authorize")?)
1069///     .token_url(url::Url::parse("https://provider.example.com/token")?)
1070///     .with_openid_scope()
1071///     .without_jwks_validation() // or .jwks_validator(Box::new(my_validator))
1072///     .add_scopes([RequestScope::Email, RequestScope::OfflineAccess])
1073///     .on_auth_url(|params| {
1074///         params.append("access_type", "offline");
1075///     })
1076///     .build();
1077///
1078/// let tokens = client.run_authorization_flow().await?;
1079/// println!("access token: {}", tokens.access_token());
1080/// # Ok(())
1081/// # }
1082/// ```
1083pub struct CliTokenClientBuilder<
1084    C = NoClientId,
1085    A = NoAuthUrl,
1086    T = NoTokenUrl,
1087    O = NoOidc,
1088    S = Http,
1089> {
1090    client_id: C,
1091    auth_url: A,
1092    token_url: T,
1093    oidc: O,
1094    scheme: S,
1095    config: BuilderConfig,
1096}
1097
1098impl Default for CliTokenClientBuilder {
1099    fn default() -> Self {
1100        Self {
1101            client_id: NoClientId,
1102            auth_url: NoAuthUrl,
1103            token_url: NoTokenUrl,
1104            oidc: NoOidc,
1105            scheme: Http,
1106            config: BuilderConfig::default(),
1107        }
1108    }
1109}
1110
1111// Named constructor — pre-fills both URLs from an OIDC discovery document and
1112// enters OidcPending mode (adding `openid` to scopes). Placed on the default
1113// (all-unset) state so `CliTokenClientBuilder::from_open_id_configuration`
1114// remains the natural call site.
1115impl CliTokenClientBuilder {
1116    /// Create a builder pre-filled from an [`OpenIdConfiguration`].
1117    ///
1118    /// Sets `auth_url` and `token_url` from the discovery document and
1119    /// automatically enters OIDC mode (equivalent to calling
1120    /// [`with_openid_scope`]). The issuer URL from the discovery document is
1121    /// stored automatically, enabling `iss` claim validation on every received
1122    /// ID token. Callers must still call `.client_id()` before `.build()`.
1123    ///
1124    /// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
1125    #[must_use]
1126    pub fn from_open_id_configuration(
1127        open_id_configuration: &OpenIdConfiguration,
1128    ) -> CliTokenClientBuilder<NoClientId, HasAuthUrl, HasTokenUrl, OidcPending, Http> {
1129        CliTokenClientBuilder {
1130            client_id: NoClientId,
1131            auth_url: HasAuthUrl(open_id_configuration.authorization_endpoint().clone()),
1132            token_url: HasTokenUrl(open_id_configuration.token_endpoint().clone()),
1133            oidc: OidcPending,
1134            scheme: Http,
1135            config: BuilderConfig {
1136                issuer: Some(open_id_configuration.issuer().clone()),
1137                scopes: std::collections::BTreeSet::from([OAuth2Scope::OpenId]),
1138                ..BuilderConfig::default()
1139            },
1140        }
1141    }
1142}
1143
1144// ── Setters available in any state ───────────────────────────────────────────
1145
1146impl<C, A, T, O, S> CliTokenClientBuilder<C, A, T, O, S> {
1147    /// Set the OAuth 2.0 client ID. Required.
1148    #[must_use]
1149    pub fn client_id(self, v: impl Into<String>) -> CliTokenClientBuilder<HasClientId, A, T, O, S> {
1150        CliTokenClientBuilder {
1151            client_id: HasClientId(ClientId(v.into())),
1152            auth_url: self.auth_url,
1153            token_url: self.token_url,
1154            oidc: self.oidc,
1155            scheme: self.scheme,
1156            config: self.config,
1157        }
1158    }
1159
1160    /// Set the authorization endpoint URL. Required.
1161    #[must_use]
1162    pub fn auth_url(self, v: url::Url) -> CliTokenClientBuilder<C, HasAuthUrl, T, O, S> {
1163        CliTokenClientBuilder {
1164            client_id: self.client_id,
1165            auth_url: HasAuthUrl(v),
1166            token_url: self.token_url,
1167            oidc: self.oidc,
1168            scheme: self.scheme,
1169            config: self.config,
1170        }
1171    }
1172
1173    /// Set the token endpoint URL. Required.
1174    #[must_use]
1175    pub fn token_url(self, v: url::Url) -> CliTokenClientBuilder<C, A, HasTokenUrl, O, S> {
1176        CliTokenClientBuilder {
1177            client_id: self.client_id,
1178            auth_url: self.auth_url,
1179            token_url: HasTokenUrl(v),
1180            oidc: self.oidc,
1181            scheme: self.scheme,
1182            config: self.config,
1183        }
1184    }
1185
1186    /// Set the client secret. Optional - omit for public clients using PKCE only.
1187    #[must_use]
1188    pub fn client_secret(mut self, v: impl Into<String>) -> Self {
1189        self.config.client_secret = Some(v.into());
1190        self
1191    }
1192
1193    /// Add OAuth 2.0 scopes to the request.
1194    ///
1195    /// Scopes accumulate across multiple calls and are deduplicated. Call order
1196    /// does not affect the final scope set.
1197    ///
1198    /// [`RequestScope`] intentionally excludes `openid` — use
1199    /// [`with_openid_scope`] to enable OIDC mode and unlock JWKS validator methods.
1200    ///
1201    /// [`with_openid_scope`]: CliTokenClientBuilder::with_openid_scope
1202    /// [`RequestScope`]: crate::RequestScope
1203    #[must_use]
1204    pub fn add_scopes(mut self, v: impl IntoIterator<Item = RequestScope>) -> Self {
1205        self.config
1206            .scopes
1207            .extend(v.into_iter().map(OAuth2Scope::from));
1208        self
1209    }
1210
1211    /// Suggest a preferred local port for the loopback callback server.
1212    ///
1213    /// Falls back to an OS-assigned port if the hint is unavailable.
1214    /// Use [`CliTokenClientBuilder::require_port`] for hard-failure semantics.
1215    #[must_use]
1216    pub const fn port_hint(mut self, v: u16) -> Self {
1217        self.config.port_config = PortConfig::Hint(v);
1218        self
1219    }
1220
1221    /// Require a specific local port for the loopback callback server.
1222    ///
1223    /// When set, [`CliTokenClient::run_authorization_flow`] returns
1224    /// [`AuthError::ServerBind`] if the port cannot be bound, rather than
1225    /// falling back to an OS-assigned port.
1226    ///
1227    /// # Example
1228    ///
1229    /// ```
1230    /// use loopauth::CliTokenClient;
1231    ///
1232    /// let builder = CliTokenClient::builder()
1233    ///     .client_id("my-client")
1234    ///     .auth_url(url::Url::parse("https://provider.example.com/authorize").unwrap())
1235    ///     .token_url(url::Url::parse("https://provider.example.com/token").unwrap())
1236    ///     .require_port(8080);
1237    /// // If port 8080 is unavailable when run_authorization_flow() is called,
1238    /// // it returns Err(AuthError::ServerBind(...)) immediately.
1239    /// ```
1240    #[must_use]
1241    pub const fn require_port(mut self, v: u16) -> Self {
1242        self.config.port_config = PortConfig::Required(v);
1243        self
1244    }
1245
1246    /// Override the default success page with custom HTML, shown after a successful callback.
1247    #[must_use]
1248    pub fn success_html(mut self, v: impl Into<String>) -> Self {
1249        self.config.success_html = Some(v.into());
1250        self
1251    }
1252
1253    /// Override the default error page with custom HTML, shown when the callback contains an error.
1254    #[must_use]
1255    pub fn error_html(mut self, v: impl Into<String>) -> Self {
1256        self.config.error_html = Some(v.into());
1257        self
1258    }
1259
1260    /// Provide a custom [`SuccessPageRenderer`] for dynamic success page rendering.
1261    ///
1262    /// Takes precedence over [`CliTokenClientBuilder::success_html`].
1263    #[must_use]
1264    pub fn success_renderer(mut self, r: impl SuccessPageRenderer + 'static) -> Self {
1265        self.config.success_renderer = Some(Box::new(r));
1266        self
1267    }
1268
1269    /// Provide a custom [`ErrorPageRenderer`] for dynamic error page rendering.
1270    ///
1271    /// Takes precedence over [`CliTokenClientBuilder::error_html`].
1272    #[must_use]
1273    pub fn error_renderer(mut self, r: impl ErrorPageRenderer + 'static) -> Self {
1274        self.config.error_renderer = Some(Box::new(r));
1275        self
1276    }
1277
1278    /// Whether to open the authorization URL in the user's browser (default: `true`).
1279    ///
1280    /// When `false`, the URL is emitted via `tracing::info!` instead - useful for
1281    /// testing or headless environments.
1282    #[must_use]
1283    pub const fn open_browser(mut self, v: bool) -> Self {
1284        self.config.open_browser = v;
1285        self
1286    }
1287
1288    /// Set the maximum time to wait for the authorization callback (default: 5 minutes).
1289    ///
1290    /// Returns [`AuthError::Timeout`] if the deadline is exceeded.
1291    #[must_use]
1292    pub const fn timeout(mut self, v: std::time::Duration) -> Self {
1293        self.config.timeout = v;
1294        self
1295    }
1296
1297    /// Use a custom token response type for non-standard providers.
1298    ///
1299    /// The type `R` must implement [`serde::Deserialize`] and
1300    /// <code>Into<[TokenResponseFields](crate::TokenResponseFields)></code>. It will be deserialized from the
1301    /// token endpoint's JSON response and converted into the standard fields.
1302    /// This is useful for providers like Slack that nest tokens inside a
1303    /// sub-object rather than placing them at the top level.
1304    ///
1305    /// When not called, the standard OAuth 2.0 flat response format is used.
1306    ///
1307    /// [`TokenResponseFields`]: crate::TokenResponseFields
1308    #[must_use]
1309    pub fn token_response_type<R>(mut self) -> Self
1310    where
1311        R: serde::de::DeserializeOwned
1312            + Into<crate::token_response::TokenResponseFields>
1313            + Send
1314            + 'static,
1315    {
1316        self.config.token_parser = Some(crate::token_response::custom_token_parser::<R>());
1317        self
1318    }
1319
1320    /// Register a callback that appends extra query parameters to the authorization URL.
1321    ///
1322    /// The callback receives a `&mut` [`ExtraAuthParams`] and may call
1323    /// [`ExtraAuthParams::append`] to add provider-specific parameters, for
1324    /// example `access_type=offline` required by Google OAuth 2.0.
1325    ///
1326    /// The callback is invoked after PKCE, state, nonce, and scope parameters
1327    /// have already been set.  Parameters with reserved keys
1328    /// (`response_type`, `client_id`, `redirect_uri`, `state`,
1329    /// `code_challenge`, `code_challenge_method`, `nonce`, `scope`) are
1330    /// dropped and a `tracing::warn!` is emitted; the library controls
1331    /// those values unconditionally.
1332    ///
1333    /// # Example
1334    ///
1335    /// ```
1336    /// use loopauth::{CliTokenClient, ExtraAuthParams};
1337    ///
1338    /// let _client = CliTokenClient::builder()
1339    ///     .client_id("my-client")
1340    ///     .auth_url(url::Url::parse("https://accounts.example.com/authorize").unwrap())
1341    ///     .token_url(url::Url::parse("https://accounts.example.com/token").unwrap())
1342    ///     .on_auth_url(|params: &mut ExtraAuthParams| {
1343    ///         params.append("access_type", "offline");
1344    ///     })
1345    ///     .build();
1346    /// ```
1347    #[must_use]
1348    pub fn on_auth_url(mut self, f: impl Fn(&mut ExtraAuthParams) + Send + Sync + 'static) -> Self {
1349        self.config.on_auth_url = Some(Box::new(f));
1350        self
1351    }
1352
1353    /// Fires with the authorization URL string after the loopback server is ready to accept
1354    /// connections. Called regardless of the `open_browser` setting, before the browser opens or
1355    /// the URL is logged. Primary mechanism for headless/CI environments and test harnesses.
1356    #[must_use]
1357    pub fn on_url(mut self, f: impl Fn(&url::Url) + Send + Sync + 'static) -> Self {
1358        self.config.on_url = Some(Box::new(f));
1359        self
1360    }
1361
1362    /// Fires with the bound port number once the loopback callback server is ready to accept
1363    /// connections. Useful for test coordination (wait for port before triggering a browser
1364    /// flow) and custom tooling that needs to know the redirect URI port in advance.
1365    #[must_use]
1366    pub fn on_server_ready(mut self, f: impl Fn(u16) + Send + Sync + 'static) -> Self {
1367        self.config.on_server_ready = Some(Box::new(f));
1368        self
1369    }
1370
1371    /// Serve the loopback callback over HTTPS with a self-signed certificate.
1372    ///
1373    /// A fresh ephemeral certificate valid for `localhost` and `127.0.0.1` is
1374    /// generated each time
1375    /// [`run_authorization_flow`](CliTokenClient::run_authorization_flow) runs.
1376    ///
1377    /// **Note:** browsers will display a certificate warning for the self-signed
1378    /// certificate. Users must click through the warning for the callback to
1379    /// complete. For a seamless experience, use
1380    /// [`use_https_with`](CliTokenClientBuilder::use_https_with) with a
1381    /// locally-trusted certificate from `mkcert`.
1382    #[must_use]
1383    pub fn use_https(self) -> CliTokenClientBuilder<C, A, T, O, Https> {
1384        CliTokenClientBuilder {
1385            client_id: self.client_id,
1386            auth_url: self.auth_url,
1387            token_url: self.token_url,
1388            oidc: self.oidc,
1389            scheme: Https(None),
1390            config: self.config,
1391        }
1392    }
1393
1394    /// Serve the loopback callback over HTTPS with a trusted certificate.
1395    ///
1396    /// Use a [`TlsCertificate`](crate::TlsCertificate) created via
1397    /// [`ensure_localhost`](crate::TlsCertificate::ensure_localhost)
1398    /// (recommended) or
1399    /// [`from_pem_files`](crate::TlsCertificate::from_pem_files). The
1400    /// certificate is validated at construction time, so this method is
1401    /// infallible.
1402    ///
1403    /// # Example
1404    ///
1405    /// ```no_run
1406    /// use loopauth::{CliTokenClient, TlsCertificate};
1407    /// use std::path::PathBuf;
1408    ///
1409    /// # fn run() -> Result<(), Box<dyn std::error::Error>> {
1410    /// // Generates certs via mkcert on first run, loads existing on subsequent runs
1411    /// let tls_dir = PathBuf::from("/home/user/.config/my-cli/tls");
1412    /// let cert = TlsCertificate::ensure_localhost(&tls_dir)?;
1413    ///
1414    /// let client = CliTokenClient::builder()
1415    ///     .client_id("my-client")
1416    ///     .auth_url(url::Url::parse("https://provider.example.com/authorize")?)
1417    ///     .token_url(url::Url::parse("https://provider.example.com/token")?)
1418    ///     .use_https_with(cert)
1419    ///     .build();
1420    /// # Ok(())
1421    /// # }
1422    /// ```
1423    #[must_use]
1424    pub fn use_https_with(
1425        self,
1426        certificate: crate::tls::TlsCertificate,
1427    ) -> CliTokenClientBuilder<C, A, T, O, Https> {
1428        CliTokenClientBuilder {
1429            client_id: self.client_id,
1430            auth_url: self.auth_url,
1431            token_url: self.token_url,
1432            oidc: self.oidc,
1433            scheme: Https(Some(certificate)),
1434            config: self.config,
1435        }
1436    }
1437}
1438
1439// ── OIDC mode transition ──────────────────────────────────────────────────────
1440
1441impl<C, A, T, S> CliTokenClientBuilder<C, A, T, NoOidc, S> {
1442    /// Add `openid` to the requested scopes and enter OIDC mode.
1443    ///
1444    /// Transitions to [`OidcPending`] — you must then call either
1445    /// [`jwks_validator`] (to enable JWKS signature verification) or
1446    /// [`without_jwks_validation`] (to explicitly opt out) before [`build`]
1447    /// becomes available.
1448    ///
1449    /// Note: [`from_open_id_configuration`] implicitly enters OIDC mode, so
1450    /// this method is not needed when using that constructor.
1451    ///
1452    /// [`jwks_validator`]: CliTokenClientBuilder::jwks_validator
1453    /// [`without_jwks_validation`]: CliTokenClientBuilder::without_jwks_validation
1454    /// [`build`]: CliTokenClientBuilder::build
1455    /// [`from_open_id_configuration`]: CliTokenClientBuilder::from_open_id_configuration
1456    #[must_use]
1457    pub fn with_openid_scope(mut self) -> CliTokenClientBuilder<C, A, T, OidcPending, S> {
1458        self.config.scopes.insert(OAuth2Scope::OpenId);
1459        CliTokenClientBuilder {
1460            client_id: self.client_id,
1461            auth_url: self.auth_url,
1462            token_url: self.token_url,
1463            oidc: OidcPending,
1464            scheme: self.scheme,
1465            config: self.config,
1466        }
1467    }
1468}
1469
1470// ── OIDC pending → resolved ───────────────────────────────────────────────────
1471
1472impl<C, A, T, S> CliTokenClientBuilder<C, A, T, OidcPending, S> {
1473    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1474    ///
1475    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1476    /// When using [`CliTokenClientBuilder::from_open_id_configuration`] the issuer is set
1477    /// automatically from the discovery document and this method is not needed.
1478    ///
1479    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1480    #[must_use]
1481    pub fn issuer(mut self, v: url::Url) -> Self {
1482        self.config.issuer = Some(v);
1483        self
1484    }
1485
1486    /// Enable JWKS signature verification and transition to [`JwksEnabled`].
1487    ///
1488    /// The raw `id_token` string is passed to [`JwksValidator::validate`] after
1489    /// every successful token exchange. If validation fails,
1490    /// [`CliTokenClient::run_authorization_flow`] returns
1491    /// [`AuthError::IdToken`] wrapping [`crate::IdTokenError::JwksValidationFailed`].
1492    #[must_use]
1493    pub fn jwks_validator(
1494        self,
1495        v: Box<dyn JwksValidator>,
1496    ) -> CliTokenClientBuilder<C, A, T, JwksEnabled, S> {
1497        CliTokenClientBuilder {
1498            client_id: self.client_id,
1499            auth_url: self.auth_url,
1500            token_url: self.token_url,
1501            oidc: JwksEnabled(v),
1502            scheme: self.scheme,
1503            config: self.config,
1504        }
1505    }
1506
1507    /// Explicitly opt out of JWKS signature verification and transition to [`JwksDisabled`].
1508    ///
1509    /// **Security warning**: without JWKS validation, the `id_token` is not
1510    /// cryptographically authenticated. Any party that can craft a JWT with
1511    /// valid claims (including `"alg":"none"` tokens) will be accepted. Claims
1512    /// (`exp`, `nbf`, `aud`, `iss`) are still validated per RFC 7519, but
1513    /// those checks are only meaningful if the token's authenticity is
1514    /// guaranteed by other means.
1515    ///
1516    /// Use only in test environments or when an out-of-band trust anchor (e.g.,
1517    /// mTLS-secured private network) guarantees token authenticity. In
1518    /// production, always prefer [`jwks_validator`].
1519    ///
1520    /// [`jwks_validator`]: CliTokenClientBuilder::jwks_validator
1521    #[must_use]
1522    pub fn without_jwks_validation(self) -> CliTokenClientBuilder<C, A, T, JwksDisabled, S> {
1523        CliTokenClientBuilder {
1524            client_id: self.client_id,
1525            auth_url: self.auth_url,
1526            token_url: self.token_url,
1527            oidc: JwksDisabled,
1528            scheme: self.scheme,
1529            config: self.config,
1530        }
1531    }
1532}
1533
1534impl<A, T, S> CliTokenClientBuilder<HasClientId, A, T, OidcPending, S> {
1535    /// Configure JWKS validation from an [`OpenIdConfiguration`] and transition
1536    /// to [`JwksEnabled`].
1537    ///
1538    /// Uses `open_id_configuration.jwks_uri()` and the `client_id` already set
1539    /// on this builder as the expected audience. Requires both `client_id` and
1540    /// OIDC mode to be set first — enforced at compile time.
1541    #[must_use]
1542    pub fn with_open_id_configuration_jwks_validator(
1543        self,
1544        open_id_configuration: &OpenIdConfiguration,
1545    ) -> CliTokenClientBuilder<HasClientId, A, T, JwksEnabled, S> {
1546        let client_id = self.client_id.0.as_str().to_owned();
1547        let validator = Box::new(RemoteJwksValidator::from_open_id_configuration(
1548            open_id_configuration,
1549            client_id,
1550        ));
1551        CliTokenClientBuilder {
1552            client_id: self.client_id,
1553            auth_url: self.auth_url,
1554            token_url: self.token_url,
1555            oidc: JwksEnabled(validator),
1556            scheme: self.scheme,
1557            config: self.config,
1558        }
1559    }
1560}
1561
1562impl<C, A, T, S> CliTokenClientBuilder<C, A, T, JwksEnabled, S> {
1563    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1564    ///
1565    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1566    ///
1567    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1568    #[must_use]
1569    pub fn issuer(mut self, v: url::Url) -> Self {
1570        self.config.issuer = Some(v);
1571        self
1572    }
1573}
1574
1575impl<C, A, T, S> CliTokenClientBuilder<C, A, T, JwksDisabled, S> {
1576    /// Set the expected issuer URL for ID token `iss` claim validation (RFC 7519 §4.1.1).
1577    ///
1578    /// When set, the `iss` claim in every returned `id_token` must exactly match this URL.
1579    ///
1580    /// Only available in OIDC mode — `iss` validation only applies to ID tokens.
1581    #[must_use]
1582    pub fn issuer(mut self, v: url::Url) -> Self {
1583        self.config.issuer = Some(v);
1584        self
1585    }
1586}
1587
1588impl<S: IntoTransport> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksEnabled, S> {
1589    /// Build a [`CliTokenClient`] from the configured builder.
1590    ///
1591    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1592    /// at compile time. This method is infallible. JWKS signature verification
1593    /// is enabled; ID tokens will have their signatures verified on every exchange.
1594    #[must_use]
1595    pub fn build(mut self) -> CliTokenClient {
1596        self.config.scopes.insert(OAuth2Scope::OpenId);
1597        build_client(
1598            self.client_id.0,
1599            self.auth_url.0,
1600            self.token_url.0,
1601            self.config,
1602            Some(OidcJwksConfig::Enabled(self.oidc.0)),
1603            self.scheme.into_transport(),
1604        )
1605    }
1606}
1607
1608impl<S: IntoTransport>
1609    CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, JwksDisabled, S>
1610{
1611    /// Build a [`CliTokenClient`] from the configured builder.
1612    ///
1613    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1614    /// at compile time. This method is infallible. JWKS signature verification
1615    /// is disabled; claims are still validated per RFC 7519.
1616    #[must_use]
1617    pub fn build(mut self) -> CliTokenClient {
1618        self.config.scopes.insert(OAuth2Scope::OpenId);
1619        build_client(
1620            self.client_id.0,
1621            self.auth_url.0,
1622            self.token_url.0,
1623            self.config,
1624            Some(OidcJwksConfig::Disabled),
1625            self.scheme.into_transport(),
1626        )
1627    }
1628}
1629
1630impl<S: IntoTransport> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc, S> {
1631    /// Build a [`CliTokenClient`] without OIDC mode.
1632    ///
1633    /// No `openid` scope is added, no `id_token` is expected or validated,
1634    /// and no nonce is generated. Use this path for pure OAuth 2.0
1635    /// access-token-only flows. To enable OIDC, call
1636    /// [`with_openid_scope`](CliTokenClientBuilder::with_openid_scope) before
1637    /// building.
1638    ///
1639    /// All required fields (`client_id`, `auth_url`, `token_url`) are enforced
1640    /// at compile time. This method is infallible.
1641    #[must_use]
1642    pub fn build(self) -> CliTokenClient {
1643        build_client(
1644            self.client_id.0,
1645            self.auth_url.0,
1646            self.token_url.0,
1647            self.config,
1648            None,
1649            self.scheme.into_transport(),
1650        )
1651    }
1652}
1653
1654fn build_client(
1655    client_id: ClientId,
1656    auth_url: url::Url,
1657    token_url: url::Url,
1658    config: BuilderConfig,
1659    oidc_jwks: Option<OidcJwksConfig>,
1660    transport: Arc<dyn Transport>,
1661) -> CliTokenClient {
1662    CliTokenClient {
1663        client_id,
1664        client_secret: config.client_secret,
1665        auth_url,
1666        token_url,
1667        issuer: config.issuer,
1668        scopes: config.scopes.into_iter().collect(),
1669        port_config: config.port_config,
1670        success_html: config.success_html,
1671        error_html: config.error_html,
1672        success_renderer: config.success_renderer,
1673        error_renderer: config.error_renderer,
1674        open_browser: config.open_browser,
1675        timeout: config.timeout,
1676        on_auth_url: config.on_auth_url,
1677        on_url: config.on_url,
1678        on_server_ready: config.on_server_ready,
1679        oidc_jwks,
1680        http_client: reqwest::Client::builder()
1681            .connect_timeout(std::time::Duration::from_secs(HTTP_CONNECT_TIMEOUT_SECONDS))
1682            .timeout(std::time::Duration::from_secs(HTTP_REQUEST_TIMEOUT_SECONDS))
1683            .build()
1684            .unwrap_or_default(),
1685        transport,
1686        token_parser: config.token_parser.unwrap_or_else(default_token_parser),
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    #![expect(
1693        clippy::indexing_slicing,
1694        clippy::expect_used,
1695        clippy::unwrap_used,
1696        reason = "tests do not need to meet production lint standards"
1697    )]
1698
1699    use super::{
1700        AuthUrlParams, CliTokenClient, CliTokenClientBuilder, ExtraAuthParams, HasAuthUrl,
1701        HasClientId, HasTokenUrl, NoOidc, parse_scopes,
1702    };
1703    use crate::jwks::{JwksValidationError, JwksValidator};
1704    use crate::oidc::Token;
1705    use crate::scope::OAuth2Scope;
1706    use async_trait::async_trait;
1707
1708    fn fake_jwt(sub: &str, email: &str) -> String {
1709        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1710        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1711        let claims = URL_SAFE_NO_PAD.encode(format!(
1712            r#"{{"sub":"{sub}","email":"{email}","iss":"https://accounts.example.com","iat":1000000000,"exp":9999999999}}"#
1713        ));
1714        format!("{header}.{claims}.fakesig")
1715    }
1716
1717    fn fake_jwt_google_style(
1718        sub: &str,
1719        email: &str,
1720        name: &str,
1721        picture: &str,
1722        aud: &str,
1723    ) -> String {
1724        use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1725        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"RS256","typ":"JWT"}"#);
1726        let claims = URL_SAFE_NO_PAD.encode(format!(
1727            r#"{{"iss":"https://accounts.google.com","aud":"{aud}","sub":"{sub}","email":"{email}","email_verified":true,"name":"{name}","picture":"{picture}","iat":1000000000,"exp":9999999999}}"#
1728        ));
1729        format!("{header}.{claims}.fakesig")
1730    }
1731
1732    #[test]
1733    fn oidc_token_from_raw_jwt_returns_ok_for_valid_fake_jwt() {
1734        let jwt = fake_jwt("user_42", "user@example.com");
1735        let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for valid fake JWT");
1736        assert_eq!(oidc.claims().sub().as_str(), "user_42");
1737        assert_eq!(
1738            oidc.claims().email().map(crate::oidc::Email::as_str),
1739            Some("user@example.com")
1740        );
1741    }
1742
1743    #[test]
1744    fn oidc_token_from_raw_jwt_returns_err_for_invalid_input() {
1745        let result = Token::from_raw_jwt("not.a.jwt");
1746        assert!(result.is_err(), "expected Err for invalid JWT");
1747    }
1748
1749    #[test]
1750    fn oidc_token_from_raw_jwt_with_aud_claim_returns_ok() {
1751        // Google-style JWTs always include an `aud` claim (the client ID).
1752        // Ensure we decode them without requiring audience validation.
1753        let jwt = fake_jwt_google_style(
1754            "1234567890",
1755            "user@gmail.com",
1756            "Test User",
1757            "https://example.com/photo.jpg",
1758            "my-client-id.apps.googleusercontent.com",
1759        );
1760        let oidc = Token::from_raw_jwt(&jwt).expect("expected Ok for JWT with aud claim");
1761        assert_eq!(oidc.claims().sub().as_str(), "1234567890");
1762        assert_eq!(
1763            oidc.claims().email().map(crate::oidc::Email::as_str),
1764            Some("user@gmail.com")
1765        );
1766        assert_eq!(oidc.claims().name(), Some("Test User"));
1767        assert_eq!(
1768            oidc.claims().picture().map(|p| p.as_url().as_str()),
1769            Some("https://example.com/photo.jpg")
1770        );
1771        assert!(oidc.claims().email().unwrap().is_verified());
1772    }
1773
1774    fn valid_builder() -> CliTokenClientBuilder<HasClientId, HasAuthUrl, HasTokenUrl, NoOidc> {
1775        CliTokenClient::builder()
1776            .client_id("test-client")
1777            .auth_url(url::Url::parse("https://example.com/auth").unwrap())
1778            .token_url(url::Url::parse("https://example.com/token").unwrap())
1779    }
1780
1781    #[test]
1782    fn builder_returns_cli_token_client_builder() {
1783        // Verifies the unparameterized alias resolves to the all-unset initial state.
1784        let _builder: CliTokenClientBuilder = CliTokenClient::builder();
1785    }
1786
1787    // NOTE: build_without_client_id, build_without_auth_url, and
1788    // build_without_token_url are intentionally absent — omitting any of these
1789    // fields now produces a *compile error* rather than a runtime Err, so there
1790    // is no runtime behavior to test.
1791
1792    #[test]
1793    fn build_with_valid_inputs_returns_client() {
1794        let _client = valid_builder().build();
1795    }
1796
1797    /// RFC 6749 §5.1: when the token response omits the scope field,
1798    /// the client SHOULD assume the requested scopes were granted.
1799    #[test]
1800    fn rfc_6749_s5_1_scope_fallback_uses_requested_scopes_when_response_omits_scope() {
1801        // parse_scopes is the core helper; fallback logic is:
1802        //   token_response.scope.as_deref().map(parse_scopes).unwrap_or_else(|| scopes.to_vec())
1803        // Test the parse_scopes helper and the fallback identity directly.
1804        let requested = vec![OAuth2Scope::OpenId, OAuth2Scope::Email];
1805        // When scope is absent from response, resolved = requested
1806        let resolved: Vec<OAuth2Scope> = None::<String>
1807            .as_deref()
1808            .map_or_else(|| requested.clone(), parse_scopes);
1809        assert_eq!(resolved, requested);
1810
1811        // When scope IS present in response, it is parsed
1812        let resolved_from_response: Vec<OAuth2Scope> = Some("openid profile".to_string())
1813            .as_deref()
1814            .map_or_else(|| requested.clone(), parse_scopes);
1815        assert_eq!(
1816            resolved_from_response,
1817            vec![OAuth2Scope::OpenId, OAuth2Scope::Profile]
1818        );
1819    }
1820
1821    #[test]
1822    fn oidc_token_from_raw_jwt_populates_iss_aud_iat_exp() {
1823        let jwt = fake_jwt_google_style(
1824            "sub-iss-test",
1825            "user@example.com",
1826            "Test User",
1827            "https://example.com/photo.jpg",
1828            "my-client-id",
1829        );
1830        let oidc = Token::from_raw_jwt(&jwt).expect("should decode");
1831        let claims = oidc.claims();
1832        assert_eq!(
1833            claims.iss().as_url(),
1834            &url::Url::parse("https://accounts.google.com").unwrap()
1835        );
1836        assert_eq!(claims.aud().len(), 1);
1837        assert_eq!(claims.aud()[0].as_str(), "my-client-id");
1838        // iat and exp should be non-epoch values
1839        assert!(
1840            claims.iat() > std::time::UNIX_EPOCH,
1841            "iat should be after epoch"
1842        );
1843        assert!(
1844            claims.exp() > std::time::UNIX_EPOCH,
1845            "exp should be after epoch"
1846        );
1847    }
1848
1849    struct AcceptAll;
1850
1851    #[async_trait]
1852    impl JwksValidator for AcceptAll {
1853        async fn validate(&self, _raw_token: &str) -> Result<(), JwksValidationError> {
1854            Ok(())
1855        }
1856    }
1857
1858    #[test]
1859    fn build_with_jwks_validator_and_openid_scope_succeeds() {
1860        let _client = valid_builder()
1861            .with_openid_scope()
1862            .jwks_validator(Box::new(AcceptAll))
1863            .build();
1864    }
1865
1866    // NOTE: build_with_jwks_validator_but_no_openid_scope is intentionally
1867    // absent — calling jwks_validator() on a NoOidc builder no longer compiles.
1868
1869    fn make_open_id_configuration() -> crate::oidc::OpenIdConfiguration {
1870        use url::Url;
1871        crate::oidc::OpenIdConfiguration::new_for_test(
1872            Url::parse("https://accounts.example.com").unwrap(),
1873            Url::parse("https://accounts.example.com/authorize").unwrap(),
1874            Url::parse("https://accounts.example.com/token").unwrap(),
1875            Url::parse("https://accounts.example.com/.well-known/jwks.json").unwrap(),
1876        )
1877    }
1878
1879    // NOTE: from_open_id_configuration_without_openid_scope_fails_build is
1880    // intentionally absent — from_open_id_configuration() always returns an
1881    // OidcPending builder, so a NoOidc build is impossible to construct.
1882
1883    #[test]
1884    fn from_open_id_configuration_always_includes_openid_scope() {
1885        let config = make_open_id_configuration();
1886        // from_open_id_configuration enters OidcPending mode and pre-populates
1887        // the openid scope; no explicit scope call needed.
1888        let _client = CliTokenClientBuilder::from_open_id_configuration(&config)
1889            .client_id("test-client")
1890            .without_jwks_validation()
1891            .build();
1892    }
1893
1894    // ── ExtraAuthParams ───────────────────────────────────────────────────────
1895
1896    #[test]
1897    fn extra_auth_params_append_accumulates_pairs() {
1898        let mut params = ExtraAuthParams::new();
1899        params.append("access_type", "offline");
1900        params.append("prompt", "consent");
1901        assert_eq!(params.pairs.len(), 2);
1902        assert_eq!(
1903            params.pairs[0],
1904            ("access_type".to_string(), "offline".to_string())
1905        );
1906        assert_eq!(
1907            params.pairs[1],
1908            ("prompt".to_string(), "consent".to_string())
1909        );
1910    }
1911
1912    #[test]
1913    fn extra_auth_params_apply_to_adds_non_reserved_keys() {
1914        let mut params = ExtraAuthParams::new();
1915        params.append("access_type", "offline");
1916        let mut url = url::Url::parse("https://example.com/auth").unwrap();
1917        params.apply_to(&mut url);
1918        let pairs: Vec<(_, _)> = url.query_pairs().collect();
1919        assert_eq!(pairs.len(), 1);
1920        assert_eq!(pairs[0].0, "access_type");
1921        assert_eq!(pairs[0].1, "offline");
1922    }
1923
1924    #[test]
1925    fn extra_auth_params_apply_to_drops_reserved_keys() {
1926        // Each reserved key should be filtered out; none should appear in the URL.
1927        for reserved in AuthUrlParams::KEYS {
1928            let mut params = ExtraAuthParams::new();
1929            params.append(*reserved, "injected");
1930            let mut url = url::Url::parse("https://example.com/auth").unwrap();
1931            params.apply_to(&mut url);
1932            assert!(
1933                url.query_pairs().next().is_none(),
1934                "reserved key '{reserved}' should have been dropped"
1935            );
1936        }
1937    }
1938
1939    #[test]
1940    fn extra_auth_params_apply_to_passes_non_reserved_and_drops_reserved() {
1941        let mut params = ExtraAuthParams::new();
1942        params.append("state", "injected"); // reserved — dropped
1943        params.append("access_type", "offline"); // not reserved — kept
1944        let mut url = url::Url::parse("https://example.com/auth").unwrap();
1945        params.apply_to(&mut url);
1946        let pairs: Vec<(_, _)> = url.query_pairs().collect();
1947        assert_eq!(pairs.len(), 1);
1948        assert_eq!(pairs[0].0, "access_type");
1949    }
1950}