Skip to main content

pas_external/oidc/
state_store.rs

1//! OIDC RP state-machine port + value types.
2//!
3//! ── Why a port ──────────────────────────────────────────────────────────
4//!
5//! State storage is the load-bearing CSRF / state-replay defense in the
6//! OAuth + OIDC RP flow. The substrate must support atomic single-use
7//! semantics (TOCTOU-free `put` + `take`). Production substrates: Redis
8//! `EVAL` GET+DEL script (or Redis 6.2+ `GETDEL`), Postgres
9//! `DELETE … RETURNING`, KVRocks `GETDEL`. Test substrate: in-memory
10//! `tokio::sync::Mutex<HashMap>` held across both ops.
11//!
12//! Single port for OIDC because atomic single-use + TTL is OIDC-specific.
13//! Other RP collaborators (`oauth::AuthClient`,
14//! [`super::PasIdTokenVerifier`], discovery, JWKS) are in-process
15//! composition hidden inside [`super::RelyingParty<S>`].
16
17use std::time::Duration;
18
19use async_trait::async_trait;
20use jiff::Timestamp;
21use serde::{Deserialize, Serialize};
22use url::Url;
23
24use super::port::{IdAssertion, ScopePiiReader};
25
26// ────────────────────────────────────────────────────────────────────────
27// Config
28// ────────────────────────────────────────────────────────────────────────
29
30/// PAS OAuth client + RP configuration.
31///
32/// Construction input to [`super::RelyingParty::new`]. Mirrors the
33/// "public OAuth client" pattern (RCW / CTW precedent — no
34/// client_secret; PKCE S256 mandatory). TTL knobs for state-store
35/// entries are bundled here so a consumer that picks non-default
36/// lifetimes does so once at boot, not at every `start`.
37#[derive(Debug, Clone)]
38#[non_exhaustive]
39pub struct Config {
40    pub client_id: String,
41    pub redirect_uri: Url,
42    pub issuer: Url,
43    /// State entry TTL in the substrate. Default 10 minutes
44    /// (RFC 9700 §4.1.2 guidance).
45    pub state_ttl: Duration,
46    /// RFC 8707 resource indicator (an absolute URI, raw string) this RP
47    /// sends on every OAuth leg — authorize, token, refresh — so PAS mints
48    /// `aud = <resource>` and the resource-server perimeter admits it (G2).
49    /// `None` (the RCW/CTW identity-only default) keeps `aud = client_id`.
50    /// A raw `String`, never a `Url`: PAS byte-matches it against
51    /// `OAUTH_RESOURCE_INDICATORS`, and `Url` would append a trailing slash
52    /// to a host-only URI and miss the match.
53    pub resource: Option<String>,
54}
55
56impl Config {
57    pub fn new(client_id: impl Into<String>, redirect_uri: Url, issuer: Url) -> Self {
58        Self {
59            client_id: client_id.into(),
60            redirect_uri,
61            issuer,
62            state_ttl: Duration::from_secs(600),
63            resource: None,
64        }
65    }
66
67    #[must_use]
68    pub fn with_state_ttl(mut self, ttl: Duration) -> Self {
69        self.state_ttl = ttl;
70        self
71    }
72
73    /// Set the RFC 8707 resource indicator (see [`Self::resource`]). A
74    /// PCS-bound RP (CWC) sets the session-face resource URI here; identity
75    /// RPs leave it unset.
76    #[must_use]
77    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
78        self.resource = Some(resource.into());
79        self
80    }
81}
82
83// ────────────────────────────────────────────────────────────────────────
84// State + RelativePath
85// ────────────────────────────────────────────────────────────────────────
86
87/// OAuth `state` parameter — random, opaque, single-use.
88///
89/// Generated fresh in [`super::RelyingParty::start`]; round-tripped
90/// through PAS as a query parameter; matched at callback against the
91/// stored [`PendingAuthRequest`] via atomic [`StateStore::take`].
92#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
93pub struct State(String);
94
95impl State {
96    /// SDK constructor — used by [`super::RelyingParty::start`] for
97    /// random generation, and by callback handlers for parsing the
98    /// inbound query-string state. The value is treated as opaque
99    /// bytes; no character-set validation here (substrate adapters
100    /// must accept whatever the SDK generates).
101    pub fn from_string(s: String) -> Self {
102        Self(s)
103    }
104
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108}
109
110/// Post-login redirect target.
111///
112/// Newtype-enforced relative path — rejects any string parsing as an
113/// absolute URL (scheme present), a protocol-relative URL (leading
114/// `//`), or a non-rooted path (must start with `/`). Open-redirect
115/// defense at the SDK boundary per RFC 9700 §4.1.5: the consumer's
116/// `start_handler` constructs a `RelativePath` from inbound user data,
117/// and the type system prevents the consumer from passing through an
118/// adversary-controlled absolute URL by accident.
119///
120/// ```compile_fail,E0277
121/// use pas_external::oidc::RelativePath;
122///
123/// // From `&str` is fallible — direct assignment requires `?` or `unwrap`.
124/// fn _compile_fail(_p: &RelativePath) {}
125/// let _: RelativePath = "https://evil.com".into();
126/// ```
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct RelativePath(String);
129
130impl RelativePath {
131    pub fn as_str(&self) -> &str {
132        &self.0
133    }
134}
135
136impl Default for RelativePath {
137    fn default() -> Self {
138        Self("/".to_owned())
139    }
140}
141
142#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
143pub enum RelativePathError {
144    #[error("relative path must not be protocol-relative (e.g., '//host/path')")]
145    ProtocolRelative,
146    #[error("relative path must start with '/'")]
147    NotRooted,
148    #[error("relative path must not contain a scheme (e.g., 'https://...', 'javascript:')")]
149    SchemePresent,
150    #[error("relative path must not contain control characters")]
151    ControlCharacters,
152}
153
154impl<'a> TryFrom<&'a str> for RelativePath {
155    type Error = RelativePathError;
156
157    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
158        if value.starts_with("//") {
159            return Err(RelativePathError::ProtocolRelative);
160        }
161        if !value.starts_with('/') {
162            return Err(RelativePathError::NotRooted);
163        }
164        // Scheme defense: in a relative path, `:` cannot appear in the
165        // path component (only in fragment / query). Inspect just the
166        // path component (split off `?` / `#`) and reject any colon.
167        let path_only = value.split(['?', '#']).next().unwrap_or(value);
168        if path_only.contains(':') {
169            return Err(RelativePathError::SchemePresent);
170        }
171        if value.chars().any(char::is_control) {
172            return Err(RelativePathError::ControlCharacters);
173        }
174        Ok(Self(value.to_owned()))
175    }
176}
177
178impl TryFrom<String> for RelativePath {
179    type Error = RelativePathError;
180
181    fn try_from(value: String) -> Result<Self, Self::Error> {
182        Self::try_from(value.as_str())
183    }
184}
185
186// Custom Deserialize that runs `try_from` so a substrate that
187// round-trips a `PendingAuthRequest` cannot smuggle an absolute URL
188// through the deserialization edge. The Serialize derive above
189// produces a plain string; this Deserialize re-validates on the way
190// back in.
191impl<'de> Deserialize<'de> for RelativePath {
192    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
193        let s = String::deserialize(d)?;
194        RelativePath::try_from(s).map_err(serde::de::Error::custom)
195    }
196}
197
198// ────────────────────────────────────────────────────────────────────────
199// PendingAuthRequest + AuthorizationRedirect + CallbackParams + Completion
200// ────────────────────────────────────────────────────────────────────────
201
202/// Stored state for an in-flight OIDC authorization request.
203///
204/// Created by [`super::RelyingParty::start`] and persisted via
205/// [`StateStore::put`] under the [`State`] key. Atomically consumed by
206/// [`StateStore::take`] at callback. Holds the data the callback needs
207/// to complete: the PKCE verifier (round-trip with PAS), the nonce
208/// (matched against id_token), and the post-login redirect target.
209///
210/// `code_verifier` and `nonce` are stored as plain strings; the engine
211/// `Nonce` wrapper is constructed only at verify time. `created_at`
212/// timestamps the `put` side; substrate-enforced TTL is the actual
213/// expiry (this field is for audit / observability).
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct PendingAuthRequest {
216    pub code_verifier: String,
217    pub nonce: String,
218    pub after_login: RelativePath,
219    pub created_at: Timestamp,
220}
221
222/// Authorize URL + state for the consumer to round-trip.
223///
224/// [`super::RelyingParty::start`] returns this; the consumer's
225/// `start_handler` typically: (1) sets the state cookie from
226/// `redirect.state`, (2) issues a 302 to `redirect.url`.
227#[derive(Debug, Clone)]
228pub struct AuthorizationRedirect {
229    pub url: Url,
230    pub state: State,
231}
232
233/// Callback query parameters from PAS.
234///
235/// PAS appends `?code=…&state=…` to the redirect_uri at successful
236/// authentication; the consumer's `callback_handler` parses these from
237/// the request and passes them to [`super::RelyingParty::complete`].
238#[derive(Debug, Clone)]
239pub struct CallbackParams {
240    pub code: String,
241    pub state: State,
242}
243
244/// Verified OIDC authentication outcome.
245///
246/// [`super::RelyingParty::complete`] returns this; the consumer's
247/// `callback_handler` typically: (1) issues session cookies from
248/// `tokens` (encrypting refresh_token via [`crate::TokenCipher`]),
249/// (2) redirects to `redirect_to` (the `after_login` captured at
250/// `start` time).
251///
252/// `id_assertion` is the verified identity (sub, iss, aud, exp, iat,
253/// nonce, plus scope-bounded PII gated by `S`). `tokens` is the raw
254/// OAuth response (access_token + refresh_token + expires_in).
255/// `redirect_to` is the [`RelativePath`] round-tripped from `start`.
256///
257/// **Scope narrowing carries through to `id_assertion`**: a
258/// `Completion<scopes::Openid>` cannot reach `email()` even via the
259/// public `id_assertion` field, because [`IdAssertion::email`] itself
260/// requires the `HasEmail` bound on `S`.
261///
262/// ```compile_fail,E0599
263/// use pas_external::oidc::{Completion, Openid};
264///
265/// fn _compile_fail(c: &Completion<Openid>) -> &str {
266///     c.id_assertion.email() // ERROR: method `email` requires `HasEmail`
267/// }
268/// ```
269#[derive(Debug)]
270pub struct Completion<S: ScopePiiReader> {
271    pub id_assertion: IdAssertion<S>,
272    pub tokens: crate::oauth::TokenResponse,
273    pub redirect_to: RelativePath,
274}
275
276// ────────────────────────────────────────────────────────────────────────
277// StateStore port
278// ────────────────────────────────────────────────────────────────────────
279
280/// Atomic single-use state-machine storage.
281///
282/// `put` writes a fresh [`PendingAuthRequest`] under a [`State`] key
283/// with substrate-enforced TTL. `take` atomically reads-and-deletes —
284/// a successful `take` MUST guarantee no other caller can also succeed
285/// for the same key. This is the load-bearing CSRF / state-replay
286/// defense (Phase 11.B audit).
287///
288/// **Substrate atomicity examples**:
289/// - Redis: `EVAL` with a GET+DEL script, or Redis 6.2+ `GETDEL`
290/// - Postgres: `DELETE FROM oidc_state WHERE state = $1 RETURNING …`
291/// - KVRocks: `GETDEL` (Redis-compatible 6.2+ command)
292/// - In-memory test: `tokio::sync::Mutex<HashMap>` held across both ops
293#[async_trait]
294pub trait StateStore: Send + Sync {
295    /// Persist `pending` under `state` with `ttl`. Substrate must
296    /// expire the entry server-side on TTL (no stale-state leakage).
297    ///
298    /// Failure modes: substrate-down, write-rejected, etc. Surfaces as
299    /// [`StateStoreError`] in the consumer.
300    async fn put(
301        &self,
302        state: &State,
303        pending: PendingAuthRequest,
304        ttl: Duration,
305    ) -> Result<(), StateStoreError>;
306
307    /// Atomically read-and-delete the entry under `state`. Returns
308    /// `None` if the entry never existed, was already consumed, or
309    /// expired. The three cases are intentionally indistinguishable
310    /// to the caller — they all map to
311    /// [`super::CallbackError::StateNotFoundOrConsumed`].
312    async fn take(
313        &self,
314        state: &State,
315    ) -> Result<Option<PendingAuthRequest>, StateStoreError>;
316}
317
318/// Substrate-level state-store failure.
319///
320/// Distinct from "state not found" (which is `Ok(None)` from `take`).
321/// Indicates the substrate itself is unhealthy (network, auth,
322/// serialization, etc.). Surfaces as
323/// [`super::StartError::StateStore`] or
324/// [`super::CallbackError::StateStore`] depending on which side hit
325/// it.
326#[derive(Debug, thiserror::Error)]
327pub enum StateStoreError {
328    #[error("state-store substrate failure: {0}")]
329    Substrate(String),
330    #[error("state-store serialization failure: {0}")]
331    Serialization(String),
332}
333
334// ────────────────────────────────────────────────────────────────────────
335// Tests — RelativePath rejection (open-redirect defense)
336// ────────────────────────────────────────────────────────────────────────
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    #[test]
343    fn relative_path_accepts_root() {
344        let p = RelativePath::try_from("/").expect("rooted path accepted");
345        assert_eq!(p.as_str(), "/");
346    }
347
348    #[test]
349    fn relative_path_accepts_nested() {
350        let p = RelativePath::try_from("/dashboard/settings").expect("nested accepted");
351        assert_eq!(p.as_str(), "/dashboard/settings");
352    }
353
354    #[test]
355    fn relative_path_accepts_query_and_fragment() {
356        // `?` and `#` may carry colons (e.g., `?next=https://x`); this
357        // is a relative path with a query string, not an absolute URL.
358        let p = RelativePath::try_from("/x?y=1#z").expect("query+fragment accepted");
359        assert_eq!(p.as_str(), "/x?y=1#z");
360    }
361
362    #[test]
363    fn relative_path_rejects_https_scheme() {
364        assert_eq!(
365            RelativePath::try_from("https://evil.com"),
366            Err(RelativePathError::NotRooted),
367        );
368    }
369
370    #[test]
371    fn relative_path_rejects_javascript_scheme() {
372        // `javascript:` doesn't start with `/` — caught by the
373        // NotRooted check before the colon-detector kicks in.
374        assert_eq!(
375            RelativePath::try_from("javascript:alert(1)"),
376            Err(RelativePathError::NotRooted),
377        );
378    }
379
380    #[test]
381    fn relative_path_rejects_protocol_relative() {
382        assert_eq!(
383            RelativePath::try_from("//evil.com/path"),
384            Err(RelativePathError::ProtocolRelative),
385        );
386    }
387
388    #[test]
389    fn relative_path_rejects_colon_smuggled_after_root() {
390        // Adversary tries to construct a rooted path that nonetheless
391        // smuggles a scheme: `/https:foo`. The colon inside the path
392        // component triggers the SchemePresent rejection.
393        assert_eq!(
394            RelativePath::try_from("/https://x"),
395            Err(RelativePathError::SchemePresent),
396        );
397    }
398
399    #[test]
400    fn relative_path_rejects_control_characters() {
401        assert_eq!(
402            RelativePath::try_from("/path\rwith\nnewline"),
403            Err(RelativePathError::ControlCharacters),
404        );
405    }
406
407    #[test]
408    fn relative_path_serde_roundtrip_validates() {
409        let p = RelativePath::try_from("/ok").unwrap();
410        let json = serde_json::to_string(&p).unwrap();
411        let back: RelativePath = serde_json::from_str(&json).unwrap();
412        assert_eq!(back.as_str(), "/ok");
413    }
414
415    #[test]
416    fn relative_path_deserialize_rejects_smuggled_scheme() {
417        // A substrate (or attacker who controls deserialized JSON)
418        // cannot bypass try_from by deserializing directly.
419        let result: Result<RelativePath, _> = serde_json::from_str(r#""https://evil""#);
420        assert!(result.is_err(), "smuggled absolute URL must reject on deserialize");
421    }
422}