Skip to main content

pas_external/
native.rs

1//! Native-app OAuth composition root — the non-OIDC sibling of
2//! [`RelyingParty`](crate::oidc::RelyingParty).
3//!
4//! [`NativeAuthFlow<S>`] runs the authorization-code + PKCE flow for a
5//! **native client** (RFC 8252): a desktop or CLI program that opens the
6//! system browser and receives the redirect on a loopback socket it owns.
7//! It requests *resource scopes* — `chat.read`, `contact.read`, … — and no
8//! `openid`, so the authorization server mints no id_token.
9//!
10//! # Why `RelyingParty` cannot serve this
11//!
12//! The OIDC RP is welded to OIDC in three independent places, and a native
13//! resource client breaks all three:
14//!
15//! | `RelyingParty` requires | A native client |
16//! |---|---|
17//! | `S: RequestedScope`, whose every impl is an `openid …` marker | requests resource atoms, never `openid` |
18//! | a `nonce` on every authorize request | has no id_token for a nonce to bind |
19//! | a verified id_token in the token response | receives none — PAS mints one only for `openid` |
20//!
21//! Loosening any of them would weaken the contract CWC / RCW / CTW depend
22//! on. So this is a sibling composition root, not a relaxation — and the
23//! two share their machinery (PKCE, discovery, the OAuth client) rather
24//! than their contract.
25//!
26//! # Shape
27//!
28//! Three calls, and the SDK holds every invariant that is easy to get
29//! wrong:
30//!
31//! ```no_run
32//! # use pas_external::{NativeAuthFlow, NativeConfig, MemoryTokenStore};
33//! # use ppoppo_sdk_core::scopes::ConsentScopes;
34//! # async fn run<S: ConsentScopes>() -> Result<(), Box<dyn std::error::Error>> {
35//! let flow = NativeAuthFlow::<S>::new(
36//!     NativeConfig::new("https://accounts.ppoppo.com".parse()?, "my_app_id")
37//!         .with_resource("https://api.ppoppo.com/grpc"),
38//! )
39//! .await?;
40//!
41//! // 1. Bind your loopback listener, then hand its address to `start`.
42//! let url = flow.start("http://127.0.0.1:54321/callback");
43//! //    …open `url` in the browser, wait for the redirect…
44//! # let raw_query = "";
45//!
46//! // 2. Hand back the raw query string. State verification, the code
47//! //    exchange, and the grant check all happen inside.
48//! let source = flow.complete(raw_query, MemoryTokenStore::new()).await?;
49//! # Ok(()) }
50//! ```
51//!
52//! `source` is a [`ScopedTokenSource<S>`](ppoppo_sdk_core::scopes::ScopedTokenSource)
53//! — feed it straight to a resource client built at the same `S`, and the
54//! credential's tier and the client's tier cannot disagree.
55//!
56//! On a later run there is no code to exchange: the refresh token is
57//! already in the keystore. [`token_source`](NativeAuthFlow::token_source)
58//! is that path, and `complete` is defined in terms of it.
59//!
60//! # What the consumer still owns
61//!
62//! The loopback listener — a socket in the consumer's process, which no
63//! library can hold for it. Everything else (state generation, single-use
64//! state verification, PKCE, callback parsing, the grant check) is here, on
65//! purpose: each is a security invariant that would otherwise be
66//! re-implemented, slightly differently, by every native consumer.
67
68use std::marker::PhantomData;
69use std::sync::Mutex;
70
71use ppoppo_sdk_core::discovery::{Discovery, DiscoveryError, fetch_discovery};
72use ppoppo_sdk_core::scopes::ConsentScopes;
73use url::Url;
74
75use crate::oauth::{AuthClient, OAuthConfig};
76use crate::pkce;
77use crate::refresh_source::{RefreshTokenSource, TokenStore};
78use crate::scope_grant::{ScopeNotCovered, ensure_covers};
79
80// ────────────────────────────────────────────────────────────────────────
81// Config
82// ────────────────────────────────────────────────────────────────────────
83
84/// Boot configuration for a [`NativeAuthFlow`].
85///
86/// Deliberately carries **no redirect URI**: a loopback client binds an
87/// ephemeral port at run time (RFC 8252 §7.3 requires the server to accept
88/// any port on `127.0.0.1` / `[::1]`), so the redirect is not knowable when
89/// the flow is constructed. It is supplied per attempt, to
90/// [`NativeAuthFlow::start`].
91#[derive(Debug, Clone)]
92#[non_exhaustive]
93pub struct NativeConfig {
94    /// The authorization server's issuer URL; discovery hangs off it.
95    pub issuer: Url,
96    /// The registered public-client identifier. No client secret — a native
97    /// app cannot keep one (RFC 8252 §8.5), which is why PKCE is mandatory.
98    pub client_id: String,
99    /// RFC 8707 resource indicator, verbatim. See
100    /// [`Self::with_resource`].
101    pub resource: Option<String>,
102}
103
104impl NativeConfig {
105    /// A config for `client_id` against the authorization server at
106    /// `issuer`.
107    #[must_use]
108    pub fn new(issuer: Url, client_id: impl Into<String>) -> Self {
109        Self { issuer, client_id: client_id.into(), resource: None }
110    }
111
112    /// Bind the token to a specific resource server (RFC 8707), so the
113    /// minted `aud` clears that server's perimeter instead of defaulting to
114    /// the client id.
115    ///
116    /// **Pass the value exactly as registered.** It is matched
117    /// byte-for-byte and never parsed, because parsing is what breaks it:
118    /// round-tripping a host-only URI through `Url` appends a trailing
119    /// slash (`http://localhost:3200` → `http://localhost:3200/`), and PAS
120    /// rejects the slashed form outright with `invalid_target` — verified
121    /// live, 2026-07-21.
122    #[must_use]
123    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
124        self.resource = Some(resource.into());
125        self
126    }
127}
128
129// ────────────────────────────────────────────────────────────────────────
130// Errors
131// ────────────────────────────────────────────────────────────────────────
132
133/// [`NativeAuthFlow::new`] failure surface.
134#[derive(Debug, thiserror::Error)]
135pub enum NativeAuthInitError {
136    /// The authorization server's discovery document could not be fetched
137    /// or did not validate.
138    #[error("discovery fetch failed: {0}")]
139    Discovery(#[from] DiscoveryError),
140    /// The HTTP client could not be constructed (TLS init, resource
141    /// exhaustion).
142    #[error("OAuth client construction failed: {0}")]
143    OAuthClient(String),
144}
145
146/// [`NativeAuthFlow::complete`] failure surface.
147///
148/// Every variant is terminal for the attempt that produced it; none is
149/// retryable with the same callback.
150#[derive(Debug, thiserror::Error)]
151#[non_exhaustive]
152pub enum CallbackError {
153    /// No authorization was pending — `complete` ran without a preceding
154    /// `start`, or a second callback arrived for an attempt already
155    /// consumed.
156    ///
157    /// Load-bearing: the pending slot is taken **unconditionally**, before
158    /// the state is compared, so a replayed callback lands here regardless
159    /// of whether the first attempt succeeded. Single-use is the property;
160    /// this variant is what it looks like from outside.
161    #[error("no authorization is pending (never started, or already consumed)")]
162    NoPendingAuthorization,
163
164    /// The returned `state` did not match the pending one — the CSRF
165    /// defense (RFC 6749 §10.12). The attempt is consumed either way.
166    #[error("state mismatch (CSRF defense triggered)")]
167    StateMismatch,
168
169    /// The authorization server redirected with an error instead of a code
170    /// (RFC 6749 §4.1.2.1) — most commonly `access_denied`, the user
171    /// declining at the consent screen.
172    #[error("authorization denied by the server: {error}{}", .description.as_deref().map(|d| format!(" — {d}")).unwrap_or_default())]
173    AuthorizationDenied {
174        /// The RFC 6749 §4.1.2.1 error code, verbatim.
175        error: String,
176        /// The server's human-readable elaboration, when present.
177        description: Option<String>,
178    },
179
180    /// The redirect query was neither a valid success nor a valid error
181    /// response — no `code`, no `error`, or no `state`.
182    #[error("malformed callback: {0}")]
183    MalformedCallback(&'static str),
184
185    /// The token endpoint rejected the exchange.
186    #[error("token exchange failed: {0}")]
187    TokenExchange(String),
188
189    /// The exchange succeeded but the granted scope does not cover the
190    /// requested tier. Fails here, at sign-in, rather than an hour later on
191    /// the first call that needs the missing atom.
192    #[error(transparent)]
193    ScopeNotCovered(#[from] ScopeNotCovered),
194
195    /// The token response carried no `refresh_token`, so no durable
196    /// credential can be built from it. A native client that cannot renew
197    /// would silently sign the user out within the hour.
198    #[error("token response carried no refresh_token — cannot build a renewable credential")]
199    MissingRefreshToken,
200
201    /// The keystore rejected the write.
202    #[error("token store failure: {0}")]
203    TokenStore(String),
204}
205
206// ────────────────────────────────────────────────────────────────────────
207// The flow
208// ────────────────────────────────────────────────────────────────────────
209
210/// One in-flight authorization attempt.
211///
212/// Held inline rather than behind a port. A `StateStore` seam exists on the
213/// OIDC RP because a web relying party is multi-user and multi-request — it
214/// must correlate a callback arriving on any process against state written
215/// by any other. A native client has one user and at most one attempt in
216/// flight, so a port here would model a reality this client does not have.
217struct PendingAuthorization {
218    state: String,
219    code_verifier: String,
220    /// Captured at `start` because RFC 6749 §4.1.3 requires the token-leg
221    /// `redirect_uri` to be identical to the authorize-leg one — and with an
222    /// ephemeral loopback port, only this attempt knows what that was.
223    redirect_uri: String,
224}
225
226/// Native-app OAuth composition root. See the [module docs](self).
227///
228/// `S` is the scope tier: it determines what goes on the authorize wire,
229/// what the grant is checked against, and what the yielded token source is
230/// witnessed for — one type parameter across the whole credential story.
231pub struct NativeAuthFlow<S: ConsentScopes> {
232    config: NativeConfig,
233    discovery: Discovery,
234    pending: Mutex<Option<PendingAuthorization>>,
235    _scope: PhantomData<S>,
236}
237
238impl<S: ConsentScopes> std::fmt::Debug for NativeAuthFlow<S> {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        // Never the pending attempt: it holds the PKCE code_verifier.
241        f.debug_struct("NativeAuthFlow")
242            .field("config", &self.config)
243            .field("discovery", &self.discovery)
244            .finish_non_exhaustive()
245    }
246}
247
248impl<S: ConsentScopes> NativeAuthFlow<S> {
249    /// Construct a flow, fetching the authorization server's discovery
250    /// document once.
251    ///
252    /// No OAuth client is built here — a native client's redirect URI does
253    /// not exist until a loopback socket is bound, so the client is
254    /// assembled per use from the discovered endpoints.
255    ///
256    /// # Errors
257    ///
258    /// [`NativeAuthInitError::Discovery`] if the document cannot be fetched
259    /// or fails RFC 8414 §3.3 issuer validation.
260    pub async fn new(config: NativeConfig) -> Result<Self, NativeAuthInitError> {
261        let discovery = fetch_discovery(&config.issuer).await?;
262        Ok(Self { config, discovery, pending: Mutex::new(None), _scope: PhantomData })
263    }
264
265    /// Begin an authorization attempt; returns the URL to open in the
266    /// browser.
267    ///
268    /// `redirect_uri` is the loopback address the consumer has **already
269    /// bound**, forwarded verbatim to both legs of the flow. Pass it
270    /// exactly as the listener reports it — it is never parsed, for the
271    /// same byte-identity reason as
272    /// [`NativeConfig::with_resource`], and RFC 6749 §4.1.3 requires the
273    /// token leg to repeat it unchanged.
274    ///
275    /// Any attempt already pending is **discarded**: at most one
276    /// authorization can be in flight, and the newest supersedes it. The
277    /// abandoned attempt's state can then never be completed, which is the
278    /// correct outcome for a flow the user restarted.
279    ///
280    /// Emits **no `nonce`** — there is no id_token for one to bind to.
281    #[must_use]
282    pub fn start(&self, redirect_uri: &str) -> Url {
283        let state = pkce::generate_state();
284        let code_verifier = pkce::generate_code_verifier();
285        let code_challenge = pkce::generate_code_challenge(&code_verifier);
286
287        let url = build_native_authorize_url(
288            &self.discovery.authorization_endpoint,
289            &self.config.client_id,
290            redirect_uri,
291            &state,
292            &code_challenge,
293            &S::scope_line(),
294            self.config.resource.as_deref(),
295        );
296
297        // Poisoning can only happen if a panic unwound while the slot was
298        // held; the slot holds no invariant worth preserving across that, so
299        // recover rather than propagate a panic into a sign-in path.
300        let mut slot = self.pending.lock().unwrap_or_else(|e| e.into_inner());
301        *slot = Some(PendingAuthorization {
302            state,
303            code_verifier,
304            redirect_uri: redirect_uri.to_owned(),
305        });
306
307        url
308    }
309
310    /// A renewing token source over an **already-held** refresh token.
311    ///
312    /// This is the restart path: a native app relaunching with a credential
313    /// in its keystore has no authorization code to exchange, and needs no
314    /// browser round trip. `store` must already hold the refresh token.
315    ///
316    /// The returned source is witnessed for `S` on the same terms as
317    /// [`complete`](Self::complete)'s — the guarantee attaches to *use*, and
318    /// every renewal re-checks the grant. So this constructor needs no proof
319    /// from its caller and still cannot yield an unchecked token.
320    ///
321    /// # Errors
322    ///
323    /// [`NativeAuthInitError::OAuthClient`] if the HTTP client cannot be
324    /// built.
325    pub fn token_source<T: TokenStore>(
326        &self,
327        store: T,
328    ) -> Result<RefreshTokenSource<AuthClient, T, S>, NativeAuthInitError> {
329        let client = AuthClient::try_new(self.oauth_config())
330            .map_err(|e| NativeAuthInitError::OAuthClient(e.to_string()))?;
331        Ok(RefreshTokenSource::new(client, store))
332    }
333
334    /// Complete an attempt from the raw redirect query string.
335    ///
336    /// `callback_query` is everything after the `?` in the redirect the
337    /// loopback listener received — passed through untouched. This method
338    /// parses it, enforces single-use `state`, exchanges the code with the
339    /// stored PKCE verifier, checks the granted scope covers `S`, persists
340    /// the refresh token into `store`, and returns the renewing source.
341    ///
342    /// # Errors
343    ///
344    /// See [`CallbackError`]. Note that
345    /// [`AuthorizationDenied`](CallbackError::AuthorizationDenied) is the
346    /// ordinary "user said no" outcome, not a defect.
347    pub async fn complete<T: TokenStore>(
348        &self,
349        callback_query: &str,
350        store: T,
351    ) -> Result<RefreshTokenSource<AuthClient, T, S>, CallbackError> {
352        // 1. Consume the attempt FIRST, unconditionally. Taking before
353        //    comparing is what makes `state` single-use: a replayed callback
354        //    finds an empty slot no matter how the first one ended. It also
355        //    removes any oracle in the comparison below — by the time we
356        //    compare, there is nothing left to probe for.
357        let pending = {
358            let mut slot = self.pending.lock().unwrap_or_else(|e| e.into_inner());
359            slot.take()
360        }
361        .ok_or(CallbackError::NoPendingAuthorization)?;
362
363        let callback = CallbackParams::parse(callback_query)?;
364
365        // 2. CSRF: the returned state must be the one we issued. Checked
366        //    before the error branch too — an unsolicited `error=` redirect
367        //    is as forgeable as an unsolicited `code=` one.
368        if callback.state != pending.state {
369            return Err(CallbackError::StateMismatch);
370        }
371
372        // 3. RFC 6749 §4.1.2.1 — the server may redirect with an error
373        //    instead of a code. `access_denied` is the consent screen being
374        //    declined, which is a normal outcome and must not read as a bug.
375        let code = match callback.outcome {
376            CallbackOutcome::Code(code) => code,
377            CallbackOutcome::Error { error, description } => {
378                return Err(CallbackError::AuthorizationDenied { error, description });
379            }
380        };
381
382        // 4. Exchange, with the redirect_uri this attempt used (§4.1.3).
383        let exchange_client = AuthClient::try_new(
384            self.oauth_config().with_redirect_uri(pending.redirect_uri),
385        )
386        .map_err(|e| CallbackError::TokenExchange(e.to_string()))?;
387
388        let tokens = exchange_client
389            .exchange_code(&code, &pending.code_verifier)
390            .await
391            .map_err(|e| CallbackError::TokenExchange(e.to_string()))?;
392
393        // 5. The acquisition-leg grant check. Runs before anything is
394        //    persisted: a credential at the wrong tier should not reach the
395        //    keystore at all.
396        ensure_covers::<S>(tokens.scope.as_deref())?;
397
398        let refresh_token = tokens.refresh_token.ok_or(CallbackError::MissingRefreshToken)?;
399        store
400            .save(&refresh_token)
401            .await
402            .map_err(|e| CallbackError::TokenStore(e.to_string()))?;
403
404        // ponytail: the access token just minted is discarded, so the first
405        // use costs one extra refresh round trip (and one RTR rotation).
406        // Priming the source with it would buy that back, but adds a
407        // serve-once-then-refresh branch to `fetch_token` — the exact
408        // function carrying the yanked-0.5.2 `Send` footgun — and cannot
409        // seed the downstream TokenCache anyway, which is built later by the
410        // resource client. Prime it here if sign-in latency ever matters.
411        self.token_source(store)
412            .map_err(|e| CallbackError::TokenExchange(e.to_string()))
413    }
414
415    /// The endpoints + client identity shared by every client this flow
416    /// builds. Carries no `redirect_uri`: only the code-exchange leg needs
417    /// one, and it supplies its own from the pending attempt.
418    fn oauth_config(&self) -> OAuthConfig {
419        let mut config = OAuthConfig::new(self.config.client_id.clone())
420            .with_auth_url(self.discovery.authorization_endpoint.clone())
421            .with_token_url(self.discovery.token_endpoint.clone());
422        if let Some(resource) = &self.config.resource {
423            config = config.with_resource(resource.clone());
424        }
425        config
426    }
427}
428
429// ────────────────────────────────────────────────────────────────────────
430// Callback parsing
431// ────────────────────────────────────────────────────────────────────────
432
433enum CallbackOutcome {
434    Code(String),
435    Error { error: String, description: Option<String> },
436}
437
438struct CallbackParams {
439    state: String,
440    outcome: CallbackOutcome,
441}
442
443impl CallbackParams {
444    /// Parse a redirect query string into a success or an error response.
445    ///
446    /// `state` is required in both shapes — PAS echoes it on the error
447    /// redirect too (RFC 6749 §4.1.2.1), and without it an attacker could
448    /// cancel an in-flight sign-in with an unsolicited `error=` redirect.
449    fn parse(query: &str) -> Result<Self, CallbackError> {
450        let mut state = None;
451        let mut code = None;
452        let mut error = None;
453        let mut description = None;
454
455        for (key, value) in url::form_urlencoded::parse(query.as_bytes()) {
456            // First occurrence wins: a duplicated parameter is a smuggling
457            // attempt, and taking the last would let an appended copy
458            // override the one the server sent.
459            match key.as_ref() {
460                "state" if state.is_none() => state = Some(value.into_owned()),
461                "code" if code.is_none() => code = Some(value.into_owned()),
462                "error" if error.is_none() => error = Some(value.into_owned()),
463                "error_description" if description.is_none() => {
464                    description = Some(value.into_owned());
465                }
466                _ => {}
467            }
468        }
469
470        let state = state.ok_or(CallbackError::MalformedCallback("no `state` parameter"))?;
471
472        let outcome = match (code, error) {
473            // A redirect carrying both is ambiguous; refuse rather than pick.
474            (Some(_), Some(_)) => {
475                return Err(CallbackError::MalformedCallback(
476                    "carries both `code` and `error`",
477                ));
478            }
479            (Some(code), None) => CallbackOutcome::Code(code),
480            (None, Some(error)) => CallbackOutcome::Error { error, description },
481            (None, None) => {
482                return Err(CallbackError::MalformedCallback(
483                    "carries neither `code` nor `error`",
484                ));
485            }
486        };
487
488        Ok(Self { state, outcome })
489    }
490}
491
492// ────────────────────────────────────────────────────────────────────────
493// URL builder — extracted for boundary-test introspection
494// ────────────────────────────────────────────────────────────────────────
495
496/// Build the native authorize URL.
497///
498/// A free function, mirroring the OIDC sibling, so the boundary test can
499/// assert the exact wire parameters without the randomness `start`
500/// generates on every call.
501///
502/// Distinct from `oidc::build_authorize_url` in exactly one way that
503/// matters: **no `nonce`**. That parameter is unconditional on the OIDC
504/// side, and emitting it here would be a request for an id_token this flow
505/// neither asks for nor could verify.
506fn build_native_authorize_url(
507    authorization_endpoint: &Url,
508    client_id: &str,
509    redirect_uri: &str,
510    state: &str,
511    code_challenge: &str,
512    scope: &str,
513    resource: Option<&str>,
514) -> Url {
515    let mut url = authorization_endpoint.clone();
516    {
517        let mut pairs = url.query_pairs_mut();
518        pairs
519            .append_pair("response_type", "code")
520            .append_pair("client_id", client_id)
521            .append_pair("redirect_uri", redirect_uri)
522            .append_pair("state", state)
523            .append_pair("code_challenge", code_challenge)
524            .append_pair("code_challenge_method", "S256")
525            .append_pair("scope", scope);
526        // RFC 8707 §2 — bind the resource at authorize so the code carries it
527        // and the token-leg `aud` is minted against it.
528        if let Some(r) = resource {
529            pairs.append_pair("resource", r);
530        }
531    }
532    url
533}
534
535#[cfg(test)]
536#[allow(clippy::unwrap_used)]
537mod tests {
538    use super::*;
539
540    struct Notify;
541    impl ConsentScopes for Notify {
542        const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
543    }
544
545    fn authorize(resource: Option<&str>) -> String {
546        build_native_authorize_url(
547            &"http://localhost:3100/oauth/authorize".parse().unwrap(),
548            "cnc_01ky26fzxzjvzpy3",
549            "http://127.0.0.1:54321/callback",
550            "st",
551            "chal",
552            &Notify::scope_line(),
553            resource,
554        )
555        .into()
556    }
557
558    /// The invariant this whole flow exists for. `RelyingParty` emits a
559    /// nonce unconditionally; PAS mints no id_token without `openid`, so a
560    /// nonce here would request something that can never come back.
561    #[test]
562    fn authorize_url_emits_no_nonce() {
563        assert!(!authorize(None).contains("nonce"), "native flow must not request an id_token");
564    }
565
566    #[test]
567    fn authorize_url_carries_the_tier_scope_line() {
568        // Space-separated per RFC 6749 §3.3; form-encoded as `+`.
569        assert!(
570            authorize(None).contains("scope=chat.read+contact.read+chat.ack"),
571            "{}",
572            authorize(None)
573        );
574    }
575
576    #[test]
577    fn authorize_url_carries_pkce_s256_and_no_secret() {
578        let url = authorize(None);
579        assert!(url.contains("code_challenge=chal"));
580        assert!(url.contains("code_challenge_method=S256"));
581        assert!(!url.contains("client_secret"), "a native client is a public client");
582    }
583
584    /// The live-verified trap: PAS rejects `http://localhost:3200/` — one
585    /// trailing slash — with `invalid_target`. The value must reach the wire
586    /// exactly as configured.
587    #[test]
588    fn authorize_url_carries_the_resource_byte_identically() {
589        let url = authorize(Some("http://localhost:3200"));
590        // Decode the parameter back and compare to the input — asserting on
591        // the encoded form would pass for a value that merely *contains* the
592        // right prefix.
593        let got = Url::parse(&url)
594            .unwrap()
595            .query_pairs()
596            .find(|(k, _)| k == "resource")
597            .map(|(_, v)| v.into_owned());
598        assert_eq!(
599            got.as_deref(),
600            Some("http://localhost:3200"),
601            "the resource must reach the wire byte-identically — PAS rejects \
602             the trailing-slash form with `invalid_target` (verified live)"
603        );
604    }
605
606    #[test]
607    fn authorize_url_carries_the_redirect_uri_byte_identically() {
608        // Same discipline as `resource`, and RFC 6749 §4.1.3 additionally
609        // requires the token leg to repeat this value unchanged — which is
610        // why `start` stores the raw string rather than a parsed `Url`.
611        let raw = "http://127.0.0.1:54321/callback";
612        let got = Url::parse(&authorize(None))
613            .unwrap()
614            .query_pairs()
615            .find(|(k, _)| k == "redirect_uri")
616            .map(|(_, v)| v.into_owned());
617        assert_eq!(got.as_deref(), Some(raw));
618    }
619
620    #[test]
621    fn authorize_url_omits_resource_when_absent() {
622        assert!(!authorize(None).contains("resource="));
623    }
624
625    #[test]
626    fn callback_parses_a_success_redirect() {
627        let p = CallbackParams::parse("code=abc&state=xyz").unwrap();
628        assert_eq!(p.state, "xyz");
629        assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "abc"));
630    }
631
632    /// RFC 6749 §4.1.2.1 — the user declining consent is a redirect, not a
633    /// transport failure, and must not read as a malformed callback.
634    #[test]
635    fn callback_parses_an_error_redirect() {
636        let p = CallbackParams::parse(
637            "error=access_denied&error_description=User+declined&state=xyz",
638        )
639        .unwrap();
640        match p.outcome {
641            CallbackOutcome::Error { error, description } => {
642                assert_eq!(error, "access_denied");
643                assert_eq!(description.as_deref(), Some("User declined"));
644            }
645            CallbackOutcome::Code(_) => panic!("must parse as an error redirect"),
646        }
647    }
648
649    #[test]
650    fn callback_without_state_is_malformed() {
651        // Required on BOTH shapes: without it, an unsolicited `error=`
652        // redirect could cancel someone else's in-flight sign-in.
653        assert!(matches!(
654            CallbackParams::parse("code=abc"),
655            Err(CallbackError::MalformedCallback(_))
656        ));
657        assert!(matches!(
658            CallbackParams::parse("error=access_denied"),
659            Err(CallbackError::MalformedCallback(_))
660        ));
661    }
662
663    #[test]
664    fn callback_with_neither_code_nor_error_is_malformed() {
665        assert!(matches!(
666            CallbackParams::parse("state=xyz"),
667            Err(CallbackError::MalformedCallback(_))
668        ));
669    }
670
671    #[test]
672    fn callback_with_both_code_and_error_is_refused() {
673        // Ambiguous. Picking either one is a decision the server did not make.
674        assert!(matches!(
675            CallbackParams::parse("code=abc&error=access_denied&state=xyz"),
676            Err(CallbackError::MalformedCallback(_))
677        ));
678    }
679
680    #[test]
681    fn duplicate_parameters_take_the_first_occurrence() {
682        // An appended copy must not override what the server sent.
683        let p = CallbackParams::parse("code=real&state=xyz&code=injected").unwrap();
684        assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "real"));
685    }
686
687    #[test]
688    fn callback_values_are_percent_decoded() {
689        let p = CallbackParams::parse("code=a%2Fb%2Bc&state=s%20t").unwrap();
690        assert_eq!(p.state, "s t");
691        assert!(matches!(p.outcome, CallbackOutcome::Code(c) if c == "a/b+c"));
692    }
693}