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}