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