Skip to main content

pas_external/oidc/
relying_party.rs

1//! OIDC RP composition root.
2//!
3//! [`RelyingParty<S>`] is the deep-module entry point for OAuth + OIDC
4//! integration. It hides ALL SDK-internal collaborators behind 3
5//! lifecycle methods:
6//!
7//! - [`new`](RelyingParty::new) — discovery + JWKS bootstrap +
8//!   internal `oauth::AuthClient` + [`PasIdTokenVerifier<S>`]
9//!   composition. One call at consumer boot.
10//! - [`start`](RelyingParty::start) — generate state + nonce + PKCE,
11//!   persist in the [`StateStore`], return [`AuthorizationRedirect`].
12//! - [`complete`](RelyingParty::complete) — atomic state-take + token
13//!   exchange + id_token verify, return [`Completion<S>`].
14//!
15//! The consumer never directly imports `oauth::AuthClient` or
16//! [`PasIdTokenVerifier<S>`]; the only port the consumer implements
17//! is [`StateStore`] (the OIDC-specific atomic single-use invariant).
18//! Discovery + JWKS + PKCE + nonce + URL building are all in-process
19//! composition that earned its place inside this struct because the
20//! caller cannot meaningfully customize them.
21//!
22//! ── 3 methods, 4 hidden collaborators ───────────────────────────────────
23//!
24//! | Hidden inside `RelyingParty<S>` | Surfaced to consumer |
25//! |---------------------------------|----------------------|
26//! | [`super::discovery::fetch_discovery`] (boot)         | none |
27//! | [`PasIdTokenVerifier`] (boot + complete)             | none |
28//! | `oauth::AuthClient` (boot + complete)                | none |
29//! | [`crate::pkce`] (every `start`)                      | none |
30//! | nonce generation (every `start`)                     | none |
31//! | state generation (every `start`)                     | [`State`] (round-trip key only) |
32//!
33//! ── Scope contract ──────────────────────────────────────────────────────
34//!
35//! The marker `S` parameter does triple duty:
36//!
37//! 1. Determines the requested-scope string sent to PAS at `start`
38//!    (via [`RequestedScope::SCOPE`]).
39//! 2. Carries through to the verifier, narrowing the post-verify
40//!    [`super::IdAssertion<S>`] PII surface (Phase 10's marker traits
41//!    `HasEmail` / `HasProfile` / `HasPhone` / `HasAddress`).
42//! 3. Propagates into [`Completion<S>`] so the consumer's typed
43//!    handler signature mirrors the requested scope without runtime
44//!    re-derivation.
45//!
46//! Asking for a scope at construction time and being unable to read
47//! claims outside that scope is a single architectural decision
48//! enforced at compile time.
49
50use std::sync::Arc;
51
52use ppoppo_clock::ArcClock;
53use ppoppo_clock::native::WallClock;
54use ppoppo_token::id_token::scopes::{
55    Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
56};
57use ppoppo_token::id_token::Nonce;
58use url::Url;
59
60use super::{
61    discovery::{fetch_discovery, Discovery, DiscoveryError},
62    port::{IdTokenVerifier, IdVerifyError, ScopePiiReader},
63    refresh_outcome::RefreshOutcome,
64    state_store::{
65        AuthorizationRedirect, CallbackParams, Completion, Config, PendingAuthRequest,
66        RelativePath, State, StateStore, StateStoreError,
67    },
68    verifier::PasIdTokenVerifier,
69};
70use crate::oauth::{AuthClient, OAuthConfig};
71use crate::pkce;
72use crate::{EpochEnforcement, JwtVerifier, VerifyConfig};
73
74// ────────────────────────────────────────────────────────────────────────
75// RequestedScope — S → scope-string mapping
76// ────────────────────────────────────────────────────────────────────────
77
78/// Scope marker → scope-parameter mapping for the OIDC `scope` query
79/// parameter sent to PAS at `start`.
80///
81/// Phase 10's [`ScopePiiReader`] gates which PII fields the verifier
82/// hydrates (and therefore which accessors compile on the resulting
83/// [`super::IdAssertion<S>`]). [`RequestedScope`] is the *strictly
84/// stronger* trait the RP requires: every scope marker also has a
85/// canonical string sent on the wire. The two ends meet —
86/// `S = scopes::Email` causes both `scope=openid email` to be sent
87/// AND `assertion.email()` to compile, with the same single type
88/// parameter wiring the contract end-to-end.
89///
90/// **Adding a scope**: implement [`RequestedScope`] for the engine's
91/// new marker type with the exact wire string. The RP does not
92/// validate the string against any allowlist — PAS rejects unknown
93/// scopes at the authorize endpoint.
94pub trait RequestedScope: ScopePiiReader {
95    /// The exact `scope` parameter value sent to PAS, space-separated
96    /// per RFC 6749 §3.3.
97    const SCOPE: &'static str;
98}
99
100impl RequestedScope for Openid {
101    const SCOPE: &'static str = "openid";
102}
103impl RequestedScope for Email {
104    const SCOPE: &'static str = "openid email";
105}
106impl RequestedScope for Profile {
107    const SCOPE: &'static str = "openid profile";
108}
109impl RequestedScope for EmailProfile {
110    const SCOPE: &'static str = "openid email profile";
111}
112impl RequestedScope for EmailProfilePhone {
113    const SCOPE: &'static str = "openid email profile phone";
114}
115impl RequestedScope for EmailProfilePhoneAddress {
116    const SCOPE: &'static str = "openid email profile phone address";
117}
118
119// ────────────────────────────────────────────────────────────────────────
120// RelyingParty
121// ────────────────────────────────────────────────────────────────────────
122
123/// OAuth + OIDC Relying Party composition root.
124///
125/// `S: RequestedScope` propagates the scope contract from the
126/// requested-scope wire parameter through Phase 10's
127/// [`PasIdTokenVerifier<S>`] into the post-verify
128/// [`Completion<S>`]. Constructing
129/// `RelyingParty::<scopes::Email>` narrows the scope-bounded PII
130/// available on the post-login assertion to the email scope claims.
131///
132/// Wrap in `Arc` for axum state — internals are mostly `Arc`-shared
133/// (JWKS cache, reqwest client). The consumer typically constructs
134/// once at boot and stores `Arc<RelyingParty<S>>` in app state.
135pub struct RelyingParty<S: RequestedScope> {
136    config: Config,
137    state_store: Arc<dyn StateStore>,
138    auth_client: AuthClient,
139    verifier: Arc<dyn IdTokenVerifier<S>>,
140    discovery: Discovery,
141    clock: ArcClock,
142}
143
144// `Arc<dyn StateStore>` / `AuthClient` / `Arc<dyn IdTokenVerifier<S>>`
145// don't trivially `Debug`. Manual impl shows only the non-sensitive
146// boot-time configuration so consumers can log RP state without
147// risking credential exposure.
148impl<S: RequestedScope> std::fmt::Debug for RelyingParty<S> {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("RelyingParty")
151            .field("config", &self.config)
152            .field("discovery", &self.discovery)
153            .finish_non_exhaustive()
154    }
155}
156
157/// Construction-time failure surface.
158#[derive(Debug, thiserror::Error)]
159pub enum RelyingPartyInitError {
160    #[error("OIDC discovery fetch failed: {0}")]
161    Discovery(#[from] DiscoveryError),
162    #[error("JWKS fetch failed: {0}")]
163    Jwks(IdVerifyError),
164    #[error("OAuth client construction failed: {0}")]
165    OAuthClient(String),
166}
167
168/// `start` failure surface.
169#[derive(Debug, thiserror::Error)]
170pub enum StartError {
171    #[error("state store failure: {0}")]
172    StateStore(#[from] StateStoreError),
173    #[error("authorize URL construction failed: {0}")]
174    UrlBuild(String),
175}
176
177/// `refresh` failure surface.
178///
179/// Mirrors [`CallbackError`]'s split between credential-rejection and
180/// substrate-failure: 4xx responses indicate a dead refresh_token (the
181/// consumer should clear the session cookies and force re-auth); 5xx
182/// or transport failures are transient (the consumer may retry).
183#[derive(Debug, thiserror::Error)]
184pub enum RefreshError {
185    /// PAS rejected the refresh_token (4xx). Dead credential — the
186    /// consumer must clear the session and start a new authorization
187    /// flow. Token rotation is a no-op when there is no live token to
188    /// rotate.
189    #[error("refresh_token rejected by PAS: {0}")]
190    Rejected(String),
191
192    /// PAS service is degraded (5xx) or transport-level failure
193    /// (timeout, TLS, DNS). The session may still be live; the
194    /// consumer should fail-soft (e.g., return 503 to the browser
195    /// rather than clearing cookies).
196    #[error("refresh transient failure: {0}")]
197    Transient(String),
198}
199
200/// `complete` failure surface.
201///
202/// `StateNotFoundOrConsumed` is the load-bearing CSRF / state-replay
203/// defense: state-store substrate atomicity guarantees that a second
204/// `complete` call with the same `state` lands here regardless of
205/// whether the first call succeeded or failed late.
206#[derive(Debug, thiserror::Error)]
207pub enum CallbackError {
208    /// State key absent from substrate at callback. Indistinguishable
209    /// across "never existed", "already consumed", "TTL-expired" — all
210    /// three are CSRF-equivalent and intentionally collapse into one
211    /// variant.
212    #[error("state not found or already consumed (CSRF defense triggered)")]
213    StateNotFoundOrConsumed,
214
215    #[error("state store failure: {0}")]
216    StateStore(#[from] StateStoreError),
217
218    #[error("token exchange failed: {0}")]
219    TokenExchange(String),
220
221    #[error("id_token verification failed: {0}")]
222    IdToken(#[from] IdVerifyError),
223}
224
225impl<S: RequestedScope> RelyingParty<S> {
226    /// Construct a fully-composed RP.
227    ///
228    /// At boot:
229    /// 1. Fetch the OIDC discovery document from
230    ///    `<config.issuer>/.well-known/openid-configuration`.
231    /// 2. Fetch the JWKS from the discovery's `jwks_uri` and seed
232    ///    [`PasIdTokenVerifier<S>`] with it.
233    /// 3. Construct the internal `oauth::AuthClient` using the
234    ///    discovery's `authorization_endpoint` + `token_endpoint`.
235    /// 4. Store all components for the lifetime of the RP.
236    ///
237    /// # Errors
238    ///
239    /// - [`RelyingPartyInitError::Discovery`] — discovery fetch failed
240    /// - [`RelyingPartyInitError::Jwks`] — JWKS fetch failed
241    /// - [`RelyingPartyInitError::OAuthClient`] — reqwest client build failed
242    pub async fn new(
243        config: Config,
244        state_store: Arc<dyn StateStore>,
245    ) -> Result<Self, RelyingPartyInitError> {
246        let discovery = fetch_discovery(&config.issuer).await?;
247
248        let expectations = VerifyConfig::new(
249            // Trim the trailing slash the `url` crate re-adds to a host-only
250            // `Url`: PAS signs id_tokens — like access tokens — under the
251            // BARE issuer. id_token verify does not compare `iss` YET (Phase
252            // 10.5-10.7), so this trim is a no-op today; doing it now keeps
253            // the OAuth callback correct the moment iss-enforcement lands.
254            // Mirror of the access-token perimeter trim in
255            // `access_token_verifier` — same issuer, same reason.
256            discovery.issuer.as_str().trim_end_matches('/'),
257            config.client_id.clone(),
258        );
259        let verifier_concrete: PasIdTokenVerifier<S> =
260            PasIdTokenVerifier::from_jwks_url(discovery.jwks_uri.to_string(), expectations)
261                .await
262                .map_err(RelyingPartyInitError::Jwks)?;
263        let verifier: Arc<dyn IdTokenVerifier<S>> = Arc::new(verifier_concrete);
264
265        let mut oauth_config = OAuthConfig::new(config.client_id.clone())
266            // A web RP's redirect URI is a real absolute URL with a path, so
267            // the `Url` normalization that bites host-only loopback URIs is a
268            // no-op here; `as_str()` is what has always gone on the wire.
269            .with_redirect_uri(config.redirect_uri.as_str())
270            .with_auth_url(discovery.authorization_endpoint.clone())
271        .with_token_url(discovery.token_endpoint.clone());
272        // RFC 8707 (G2): a PCS-bound RP carries its resource indicator onto
273        // the token + refresh legs so the minted `aud` clears the resource
274        // perimeter. Threaded here so `exchange_code` / `refresh` need no
275        // per-call argument. `start` (below) sends the same value on the
276        // authorize leg.
277        if let Some(resource) = &config.resource {
278            oauth_config = oauth_config.with_resource(resource.clone());
279        }
280        let auth_client = AuthClient::try_new(oauth_config)
281            .map_err(|e| RelyingPartyInitError::OAuthClient(e.to_string()))?;
282
283        Ok(Self {
284            config,
285            state_store,
286            auth_client,
287            verifier,
288            discovery,
289            clock: Arc::new(WallClock),
290        })
291    }
292
293    /// Begin an authorization flow.
294    ///
295    /// Generates fresh state + nonce + PKCE, persists the
296    /// [`PendingAuthRequest`] under the state key (TTL =
297    /// `config.state_ttl`), and returns the authorize URL the
298    /// consumer should redirect the browser to.
299    ///
300    /// `after_login` is the post-login redirect target; the
301    /// [`RelativePath`] newtype prevents open-redirect (RFC 9700
302    /// §4.1.5) at the SDK boundary.
303    ///
304    /// # Errors
305    ///
306    /// - [`StartError::StateStore`] — substrate failure during `put`
307    /// - [`StartError::UrlBuild`] — authorize URL serialization failed
308    pub async fn start(
309        &self,
310        after_login: RelativePath,
311    ) -> Result<AuthorizationRedirect, StartError> {
312        let state_str = pkce::generate_state();
313        let code_verifier = pkce::generate_code_verifier();
314        let code_challenge = pkce::generate_code_challenge(&code_verifier);
315        let nonce = pkce::generate_state();
316
317        let state = State::from_string(state_str.clone());
318        let pending = PendingAuthRequest {
319            code_verifier: code_verifier.clone(),
320            nonce: nonce.clone(),
321            after_login,
322            created_at: self.clock.now(),
323        };
324        self.state_store
325            .put(&state, pending, self.config.state_ttl)
326            .await?;
327
328        let url = build_authorize_url(
329            &self.discovery.authorization_endpoint,
330            &self.config.client_id,
331            &self.config.redirect_uri,
332            &state_str,
333            &code_challenge,
334            S::SCOPE,
335            &nonce,
336            self.config.resource.as_deref(),
337        );
338
339        Ok(AuthorizationRedirect { url, state })
340    }
341
342    /// Complete a callback.
343    ///
344    /// Atomically `take`s the pending state, exchanges the
345    /// authorization `code` for tokens, verifies the returned
346    /// id_token against the stored nonce, and returns the verified
347    /// [`Completion<S>`].
348    ///
349    /// ## Grant validation — deliberately absent here
350    ///
351    /// This method does **not** check the token response's RFC 6749 §5.1
352    /// `scope` echo against `S`, while the native sibling
353    /// ([`NativeAuthFlow::complete`](crate::native::NativeAuthFlow::complete))
354    /// does. That asymmetry is a decision, not an oversight.
355    ///
356    /// The RP's grant validation **is the id_token**. `S` on this side
357    /// selects a bundle of identity *claims*, and step 4 below verifies a
358    /// signed assertion that either carries them or does not — a stronger
359    /// check than a scope string, performed on cryptographic evidence rather
360    /// than on the server's own summary of what it granted. The native flow
361    /// has no id_token to lean on (it requests no `openid`), so the scope
362    /// echo is the only grant evidence available to it.
363    ///
364    /// The two are also checking different things: a claim bundle that came
365    /// back short is visible in the assertion, whereas a *resource* scope
366    /// that came back short is invisible until the resource server refuses
367    /// the call.
368    ///
369    /// # Errors
370    ///
371    /// - [`CallbackError::StateNotFoundOrConsumed`] — CSRF defense
372    ///   (state absent or already consumed)
373    /// - [`CallbackError::StateStore`] — substrate failure during
374    ///   `take`
375    /// - [`CallbackError::TokenExchange`] — PAS token endpoint
376    ///   rejected
377    /// - [`CallbackError::IdToken`] — id_token verification failed
378    pub async fn complete(
379        &self,
380        params: CallbackParams,
381    ) -> Result<Completion<S>, CallbackError> {
382        // 1. Atomic state-take — single-use enforcement
383        let pending = self
384            .state_store
385            .take(&params.state)
386            .await?
387            .ok_or(CallbackError::StateNotFoundOrConsumed)?;
388
389        // 2. Token exchange
390        let tokens = self
391            .auth_client
392            .exchange_code(&params.code, &pending.code_verifier)
393            .await
394            .map_err(|e| CallbackError::TokenExchange(e.to_string()))?;
395
396        // 3. Read id_token from response (OIDC Core §3.1.3.3 — required
397        //    when scope includes `openid`, which RequestedScope's
398        //    `SCOPE` const always does)
399        let id_token = tokens.id_token.as_deref().ok_or_else(|| {
400            CallbackError::TokenExchange("token response missing id_token".to_owned())
401        })?;
402
403        // 4. Verify id_token against the stored nonce
404        let nonce = Nonce::new(pending.nonce.as_str())
405            .map_err(|_| CallbackError::IdToken(IdVerifyError::NonceMismatch))?;
406        let id_assertion = self.verifier.verify(id_token, &nonce).await?;
407
408        Ok(Completion {
409            id_assertion,
410            tokens,
411            redirect_to: pending.after_login,
412        })
413    }
414
415    /// Build the request-perimeter **access-token** verifier from this
416    /// RP's own OIDC discovery metadata.
417    ///
418    /// The login flow ([`Self::complete`]) verifies *id_tokens*; a
419    /// consumer's request perimeter must separately verify the *access
420    /// tokens* PAS issues (RFC 9068). Both are signed by the same PAS
421    /// keyset under the same issuer — values this RP already fetched at
422    /// [`Self::new`]. This factory returns a ready [`JwtVerifier`] wired
423    /// to the discovered `jwks_uri` and `issuer`, so the consumer never
424    /// re-derives them.
425    ///
426    /// **Why it exists.** Before this method every integrator
427    /// hand-assembled the perimeter verifier — string-formatting the
428    /// JWKS URL and re-normalizing the issuer. [`Discovery::issuer`] is a
429    /// [`Url`] whose `as_str()` carries the trailing slash the `url`
430    /// crate normalizes onto a host-only URL, while PAS emits the *bare*
431    /// issuer in the access-token `iss` claim and the engine matches it
432    /// exactly. Getting that trim wrong produced a successful OAuth
433    /// callback followed by a silent 401 on every subsequent request
434    /// (RCW + CTW, 2026-05-27). Trimming **once here, in the SDK**, makes
435    /// that bug class unrepresentable at the call site.
436    ///
437    /// The returned verifier owns a fresh, independent JWKS cache (the
438    /// current per-verifier `ε1` invariant — see
439    /// [`PasIdTokenVerifier`](super::verifier::PasIdTokenVerifier)); the
440    /// `async fn` signature lets a future shared-cache optimization (ε2)
441    /// swap the internals without a breaking change.
442    ///
443    /// **Required stance**: `epoch` is the sv-axis [`EpochEnforcement`]
444    /// declaration (RFC_202607150428 Q3 — silence is unrepresentable).
445    /// Consumers with no epoch substrate (RCW/CTW: no KVRocks, separate
446    /// DB) pass a NAMED [`EpochEnforcement::Unenforced`], which WARNs at
447    /// boot; the gap is bounded by the 1h access-token TTL plus PAS
448    /// refresh-family revocation. Chain
449    /// [`JwtVerifier::with_session_liveness`] /
450    /// [`with_audit`](JwtVerifier::with_audit) on the result before
451    /// storing it behind `Arc<dyn BearerVerifier>`.
452    ///
453    /// # Errors
454    ///
455    /// Returns [`crate::TokenVerifyError::KeysetUnavailable`] if the JWKS
456    /// fetch fails — the perimeter cannot verify without at least one
457    /// usable key snapshot.
458    pub async fn access_token_verifier(
459        &self,
460        epoch: EpochEnforcement,
461    ) -> Result<JwtVerifier, crate::TokenVerifyError> {
462        JwtVerifier::from_jwks_url(
463            self.discovery.jwks_uri.to_string(),
464            VerifyConfig::new(
465                // `discovery.issuer` is a `Url`; `.as_str()` re-adds the
466                // trailing slash the `url` crate normalizes onto a
467                // host-only URL. PAS emits the bare issuer on the
468                // access-token `iss` claim and the engine matches it
469                // exactly (M23) — so trim here, once, so no consumer has to.
470                //
471                // SYMMETRY (both configs trim): the sibling id_token
472                // `VerifyConfig` in `new` above trims identically, and that
473                // is deliberate — PAS signs both token classes' `iss` from
474                // the same bare-issuer config (`ppoppo-token`'s
475                // `engine::encode`); the matching no-trailing-slash invariant
476                // on the published metadata is pinned in
477                // `accounts-api::rest::well_known::discovery`. Access-token
478                // verify does an exact `iss` match; id_token verify does not
479                // check `iss` YET (Phase 10.5-10.7), so a slash-carrying
480                // id_token config "works" today only by omission. Trimming
481                // both keeps this perimeter correct now AND keeps the OAuth
482                // callback correct the moment id_token iss-enforcement lands
483                // — at which point a `…/` id_token config would reject every
484                // real callback.
485                self.discovery.issuer.as_str().trim_end_matches('/'),
486                self.config.client_id.clone(),
487            ),
488            epoch,
489        )
490        .await
491    }
492
493    /// Refresh an existing PAS session.
494    ///
495    /// Exchanges a `refresh_token` for a fresh
496    /// [`oauth::TokenResponse`] (new access_token, possibly rotated
497    /// refresh_token, possibly new id_token). The consumer typically
498    /// invokes this from a dedicated refresh endpoint — it is the
499    /// SSOT for the OAuth refresh-grant exchange so that
500    /// `chat-auth::rp` (and equivalent consumers) never reach into
501    /// `oauth::AuthClient` directly.
502    ///
503    /// **No id_token verification is performed here.** Refresh
504    /// responses MAY include an id_token (OIDC Core §12), but the
505    /// consumer treats refresh as a session-extension mechanism, not a
506    /// re-authentication event — the original
507    /// [`Self::complete`] verified the user identity, and the cookie
508    /// flow that calls this method already trusts the refresh_token
509    /// it just decrypted.
510    ///
511    /// # Errors
512    ///
513    /// - [`RefreshError::Rejected`] — PAS returned 4xx; refresh_token
514    ///   is dead, clear session and force re-auth.
515    /// - [`RefreshError::Transient`] — 5xx or transport failure;
516    ///   session may still be live, fail-soft.
517    pub async fn refresh(
518        &self,
519        refresh_token: &str,
520    ) -> Result<RefreshOutcome, RefreshError> {
521        use crate::pas_port::{PasAuthPort, PasFailure};
522        match self.auth_client.refresh(refresh_token).await {
523            Ok(t) => Ok(RefreshOutcome::from(t)),
524            Err(PasFailure::Rejected { detail, .. }) => Err(RefreshError::Rejected(detail)),
525            Err(PasFailure::ServerError { detail, .. })
526            | Err(PasFailure::Transport { detail }) => Err(RefreshError::Transient(detail)),
527        }
528    }
529
530    /// Build the OIDC RP-Initiated Logout URL — the OP `end_session_endpoint`
531    /// (OIDC RP-Initiated Logout 1.0 §2). The browser navigates here to end
532    /// its OP session: pass the `id_token` the RP holds as the hint, the
533    /// pre-registered `post_logout_redirect_uri` to land on afterward, and an
534    /// optional opaque `state`. Pure — no IO; the caller performs the redirect.
535    ///
536    /// # Errors
537    /// - [`EndSessionError::NotSupported`] — the OP's discovery document
538    ///   advertises no `end_session_endpoint`.
539    pub fn end_session_url(
540        &self,
541        id_token: &str,
542        post_logout_redirect_uri: &Url,
543        state: Option<&str>,
544    ) -> Result<Url, EndSessionError> {
545        let endpoint = self
546            .discovery
547            .end_session_endpoint
548            .as_ref()
549            .ok_or(EndSessionError::NotSupported)?;
550        Ok(build_end_session_url(
551            endpoint,
552            id_token,
553            post_logout_redirect_uri,
554            &self.config.client_id,
555            state,
556        ))
557    }
558}
559
560/// Error from [`RelyingParty::end_session_url`].
561#[derive(Debug, thiserror::Error)]
562pub enum EndSessionError {
563    /// The OP's discovery document advertises no `end_session_endpoint`, so
564    /// RP-Initiated Logout is unavailable against this provider.
565    #[error("the OP advertises no end_session_endpoint (RP-Initiated Logout unsupported)")]
566    NotSupported,
567}
568
569// ────────────────────────────────────────────────────────────────────────
570// URL builder — extracted for boundary-test introspection
571// ────────────────────────────────────────────────────────────────────────
572
573/// Build the OIDC authorize URL.
574///
575/// Pulled out as a free function so the URL-shape boundary test can
576/// re-derive the expected URL deterministically (given fixed inputs)
577/// without round-tripping through `RelyingParty::start` (which
578/// generates fresh randomness each call).
579///
580/// Order of query params is stable to ease test assertions, but
581/// PAS does not depend on order (RFC 6749 §3.1).
582#[allow(clippy::too_many_arguments)]
583fn build_authorize_url(
584    authorization_endpoint: &Url,
585    client_id: &str,
586    redirect_uri: &Url,
587    state: &str,
588    code_challenge: &str,
589    scope: &str,
590    nonce: &str,
591    resource: Option<&str>,
592) -> Url {
593    let mut url = authorization_endpoint.clone();
594    {
595        let mut pairs = url.query_pairs_mut();
596        pairs
597            .append_pair("response_type", "code")
598            .append_pair("client_id", client_id)
599            .append_pair("redirect_uri", redirect_uri.as_str())
600            .append_pair("state", state)
601            .append_pair("code_challenge", code_challenge)
602            .append_pair("code_challenge_method", "S256")
603            .append_pair("scope", scope)
604            .append_pair("nonce", nonce);
605        // RFC 8707 §2: bind the resource at authorize so the code carries it
606        // and the token-leg `aud` is minted against it. Omitted for identity
607        // RPs (RCW/CTW), which keep `aud = client_id`.
608        if let Some(r) = resource {
609            pairs.append_pair("resource", r);
610        }
611    }
612    url
613}
614
615/// Build the OIDC RP-Initiated Logout URL. Free function for the same
616/// boundary-test reason as [`build_authorize_url`]. `query_pairs_mut`
617/// percent-encodes every value (so `state` / the redirect URI are safe).
618fn build_end_session_url(
619    end_session_endpoint: &Url,
620    id_token_hint: &str,
621    post_logout_redirect_uri: &Url,
622    client_id: &str,
623    state: Option<&str>,
624) -> Url {
625    let mut url = end_session_endpoint.clone();
626    {
627        let mut pairs = url.query_pairs_mut();
628        pairs
629            .append_pair("id_token_hint", id_token_hint)
630            .append_pair("post_logout_redirect_uri", post_logout_redirect_uri.as_str())
631            .append_pair("client_id", client_id);
632        if let Some(s) = state {
633            pairs.append_pair("state", s);
634        }
635    }
636    url
637}
638
639#[cfg(test)]
640mod tests {
641    #![allow(clippy::unwrap_used)]
642    use super::*;
643
644    #[test]
645    fn end_session_url_carries_hint_redirect_client_and_state() {
646        let endpoint: Url = "https://accounts.ppoppo.com/oauth/logout".parse().unwrap();
647        let redirect: Url = "https://ppoppo.com/".parse().unwrap();
648        let url = build_end_session_url(&endpoint, "the.id.token", &redirect, "cwc", Some("a b"));
649        let s = url.as_str();
650        assert!(s.starts_with("https://accounts.ppoppo.com/oauth/logout?"), "{s}");
651        assert!(s.contains("id_token_hint=the.id.token"), "{s}");
652        // post_logout_redirect_uri is percent-encoded by query_pairs_mut.
653        assert!(s.contains("post_logout_redirect_uri=https%3A%2F%2Fppoppo.com%2F"), "{s}");
654        assert!(s.contains("client_id=cwc"), "{s}");
655        // form-encoding: space → `+`.
656        assert!(s.contains("state=a+b"), "{s}");
657    }
658
659    #[test]
660    fn end_session_url_omits_state_when_absent() {
661        let endpoint: Url = "https://accounts.ppoppo.com/oauth/logout".parse().unwrap();
662        let redirect: Url = "https://ppoppo.com/".parse().unwrap();
663        let url = build_end_session_url(&endpoint, "tok", &redirect, "cwc", None);
664        assert!(!url.as_str().contains("state="), "{}", url.as_str());
665    }
666
667    fn authorize(resource: Option<&str>) -> String {
668        let endpoint: Url = "https://accounts.ppoppo.com/oauth/authorize".parse().unwrap();
669        let redirect: Url = "https://ppoppo.com/callback".parse().unwrap();
670        build_authorize_url(
671            &endpoint, "cwc", &redirect, "st", "chal", "openid profile", "nc", resource,
672        )
673        .into()
674    }
675
676    #[test]
677    fn authorize_url_binds_resource_when_present() {
678        // Value is percent-encoded by query_pairs_mut (`:` → %3A, `/` → %2F).
679        assert!(
680            authorize(Some("https://api.ppoppo.com/grpc"))
681                .contains("resource=https%3A%2F%2Fapi.ppoppo.com%2Fgrpc"),
682            "PCS-bound RP binds its resource at authorize (RFC 8707 §2)"
683        );
684    }
685
686    #[test]
687    fn authorize_url_omits_resource_for_identity_rps() {
688        assert!(
689            !authorize(None).contains("resource="),
690            "RCW/CTW send no resource → aud stays client_id"
691        );
692    }
693
694    /// `complete` reads `tokens.id_token` and errors when it is absent,
695    /// justified by "OIDC Core §3.1.3.3 — required when scope includes
696    /// `openid`, which `RequestedScope`'s `SCOPE` const always does".
697    ///
698    /// That justification is an invariant over an open trait, held today
699    /// only by the six impls above all happening to start with `openid`.
700    /// Nothing enforced it. An impl added without the prefix would compile,
701    /// request a non-OIDC scope, receive no id_token, and turn every login
702    /// into `TokenExchange("token response missing id_token")` — a message
703    /// pointing at the token endpoint for a fault that is in the scope
704    /// marker.
705    ///
706    /// The native flow now exists precisely for markers that do NOT request
707    /// `openid`, which makes the temptation to add one here concrete.
708    #[test]
709    fn every_requested_scope_asks_for_openid() {
710        // Enumerated by hand because Rust offers no reflection over a
711        // trait's impls; the count guard below is what stops this list from
712        // silently falling behind.
713        let scopes: [(&str, &str); 6] = [
714            ("Openid", Openid::SCOPE),
715            ("Email", Email::SCOPE),
716            ("Profile", Profile::SCOPE),
717            ("EmailProfile", EmailProfile::SCOPE),
718            ("EmailProfilePhone", EmailProfilePhone::SCOPE),
719            ("EmailProfilePhoneAddress", EmailProfilePhoneAddress::SCOPE),
720        ];
721
722        for (name, scope) in scopes {
723            assert!(
724                scope.split(' ').any(|atom| atom == "openid"),
725                "{name}'s SCOPE (`{scope}`) must request `openid` — \
726                 `RelyingParty::complete` requires an id_token. A marker \
727                 requesting resource scopes belongs on `NativeAuthFlow`."
728            );
729        }
730    }
731}