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(¶ms.state)
386 .await?
387 .ok_or(CallbackError::StateNotFoundOrConsumed)?;
388
389 // 2. Token exchange
390 let tokens = self
391 .auth_client
392 .exchange_code(¶ms.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}