pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! OIDC RP state-machine port + value types.
//!
//! ── Why a port ──────────────────────────────────────────────────────────
//!
//! State storage is the load-bearing CSRF / state-replay defense in the
//! OAuth + OIDC RP flow. The substrate must support atomic single-use
//! semantics (TOCTOU-free `put` + `take`). Production substrates: Redis
//! `EVAL` GET+DEL script (or Redis 6.2+ `GETDEL`), Postgres
//! `DELETE … RETURNING`, KVRocks `GETDEL`. Test substrate: in-memory
//! `tokio::sync::Mutex<HashMap>` held across both ops.
//!
//! Single port for OIDC because atomic single-use + TTL is OIDC-specific.
//! Other RP collaborators (`oauth::AuthClient`,
//! [`super::PasIdTokenVerifier`], discovery, JWKS) are in-process
//! composition hidden inside [`super::RelyingParty<S>`].

use std::time::Duration;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use url::Url;

use super::port::{IdAssertion, ScopePiiReader};

// ────────────────────────────────────────────────────────────────────────
// Config
// ────────────────────────────────────────────────────────────────────────

/// PAS OAuth client + RP configuration.
///
/// Construction input to [`super::RelyingParty::new`]. Mirrors the
/// "public OAuth client" pattern (RCW / CTW precedent — no
/// client_secret; PKCE S256 mandatory). TTL knobs for state-store
/// entries are bundled here so a consumer that picks non-default
/// lifetimes does so once at boot, not at every `start`.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Config {
    pub client_id: String,
    pub redirect_uri: Url,
    pub issuer: Url,
    /// State entry TTL in the substrate. Default 10 minutes
    /// (RFC 9700 §4.1.2 guidance).
    pub state_ttl: Duration,
}

impl Config {
    pub fn new(client_id: impl Into<String>, redirect_uri: Url, issuer: Url) -> Self {
        Self {
            client_id: client_id.into(),
            redirect_uri,
            issuer,
            state_ttl: Duration::from_secs(600),
        }
    }

    #[must_use]
    pub fn with_state_ttl(mut self, ttl: Duration) -> Self {
        self.state_ttl = ttl;
        self
    }
}

// ────────────────────────────────────────────────────────────────────────
// State + RelativePath
// ────────────────────────────────────────────────────────────────────────

/// OAuth `state` parameter — random, opaque, single-use.
///
/// Generated fresh in [`super::RelyingParty::start`]; round-tripped
/// through PAS as a query parameter; matched at callback against the
/// stored [`PendingAuthRequest`] via atomic [`StateStore::take`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct State(String);

impl State {
    /// SDK constructor — used by [`super::RelyingParty::start`] for
    /// random generation, and by callback handlers for parsing the
    /// inbound query-string state. The value is treated as opaque
    /// bytes; no character-set validation here (substrate adapters
    /// must accept whatever the SDK generates).
    pub fn from_string(s: String) -> Self {
        Self(s)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Post-login redirect target.
///
/// Newtype-enforced relative path — rejects any string parsing as an
/// absolute URL (scheme present), a protocol-relative URL (leading
/// `//`), or a non-rooted path (must start with `/`). Open-redirect
/// defense at the SDK boundary per RFC 9700 §4.1.5: the consumer's
/// `start_handler` constructs a `RelativePath` from inbound user data,
/// and the type system prevents the consumer from passing through an
/// adversary-controlled absolute URL by accident.
///
/// ```compile_fail,E0277
/// use pas_external::oidc::RelativePath;
///
/// // From `&str` is fallible — direct assignment requires `?` or `unwrap`.
/// fn _compile_fail(_p: &RelativePath) {}
/// let _: RelativePath = "https://evil.com".into();
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RelativePath(String);

impl RelativePath {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl Default for RelativePath {
    fn default() -> Self {
        Self("/".to_owned())
    }
}

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum RelativePathError {
    #[error("relative path must not be protocol-relative (e.g., '//host/path')")]
    ProtocolRelative,
    #[error("relative path must start with '/'")]
    NotRooted,
    #[error("relative path must not contain a scheme (e.g., 'https://...', 'javascript:')")]
    SchemePresent,
    #[error("relative path must not contain control characters")]
    ControlCharacters,
}

impl<'a> TryFrom<&'a str> for RelativePath {
    type Error = RelativePathError;

    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
        if value.starts_with("//") {
            return Err(RelativePathError::ProtocolRelative);
        }
        if !value.starts_with('/') {
            return Err(RelativePathError::NotRooted);
        }
        // Scheme defense: in a relative path, `:` cannot appear in the
        // path component (only in fragment / query). Inspect just the
        // path component (split off `?` / `#`) and reject any colon.
        let path_only = value.split(['?', '#']).next().unwrap_or(value);
        if path_only.contains(':') {
            return Err(RelativePathError::SchemePresent);
        }
        if value.chars().any(char::is_control) {
            return Err(RelativePathError::ControlCharacters);
        }
        Ok(Self(value.to_owned()))
    }
}

impl TryFrom<String> for RelativePath {
    type Error = RelativePathError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        Self::try_from(value.as_str())
    }
}

// Custom Deserialize that runs `try_from` so a substrate that
// round-trips a `PendingAuthRequest` cannot smuggle an absolute URL
// through the deserialization edge. The Serialize derive above
// produces a plain string; this Deserialize re-validates on the way
// back in.
impl<'de> Deserialize<'de> for RelativePath {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        RelativePath::try_from(s).map_err(serde::de::Error::custom)
    }
}

// ────────────────────────────────────────────────────────────────────────
// PendingAuthRequest + AuthorizationRedirect + CallbackParams + Completion
// ────────────────────────────────────────────────────────────────────────

/// Stored state for an in-flight OIDC authorization request.
///
/// Created by [`super::RelyingParty::start`] and persisted via
/// [`StateStore::put`] under the [`State`] key. Atomically consumed by
/// [`StateStore::take`] at callback. Holds the data the callback needs
/// to complete: the PKCE verifier (round-trip with PAS), the nonce
/// (matched against id_token), and the post-login redirect target.
///
/// `code_verifier` and `nonce` are stored as plain strings; the engine
/// `Nonce` wrapper is constructed only at verify time. `created_at`
/// timestamps the `put` side; substrate-enforced TTL is the actual
/// expiry (this field is for audit / observability).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingAuthRequest {
    pub code_verifier: String,
    pub nonce: String,
    pub after_login: RelativePath,
    #[serde(with = "time::serde::rfc3339")]
    pub created_at: OffsetDateTime,
}

/// Authorize URL + state for the consumer to round-trip.
///
/// [`super::RelyingParty::start`] returns this; the consumer's
/// `start_handler` typically: (1) sets the state cookie from
/// `redirect.state`, (2) issues a 302 to `redirect.url`.
#[derive(Debug, Clone)]
pub struct AuthorizationRedirect {
    pub url: Url,
    pub state: State,
}

/// Callback query parameters from PAS.
///
/// PAS appends `?code=…&state=…` to the redirect_uri at successful
/// authentication; the consumer's `callback_handler` parses these from
/// the request and passes them to [`super::RelyingParty::complete`].
#[derive(Debug, Clone)]
pub struct CallbackParams {
    pub code: String,
    pub state: State,
}

/// Verified OIDC authentication outcome.
///
/// [`super::RelyingParty::complete`] returns this; the consumer's
/// `callback_handler` typically: (1) issues session cookies from
/// `tokens` (encrypting refresh_token via [`crate::TokenCipher`]),
/// (2) redirects to `redirect_to` (the `after_login` captured at
/// `start` time).
///
/// `id_assertion` is the verified identity (sub, iss, aud, exp, iat,
/// nonce, plus scope-bounded PII gated by `S`). `tokens` is the raw
/// OAuth response (access_token + refresh_token + expires_in).
/// `redirect_to` is the [`RelativePath`] round-tripped from `start`.
///
/// **Scope narrowing carries through to `id_assertion`**: a
/// `Completion<scopes::Openid>` cannot reach `email()` even via the
/// public `id_assertion` field, because [`IdAssertion::email`] itself
/// requires the `HasEmail` bound on `S`.
///
/// ```compile_fail,E0599
/// use pas_external::oidc::{Completion, Openid};
///
/// fn _compile_fail(c: &Completion<Openid>) -> &str {
///     c.id_assertion.email() // ERROR: method `email` requires `HasEmail`
/// }
/// ```
#[derive(Debug)]
pub struct Completion<S: ScopePiiReader> {
    pub id_assertion: IdAssertion<S>,
    pub tokens: crate::oauth::TokenResponse,
    pub redirect_to: RelativePath,
}

// ────────────────────────────────────────────────────────────────────────
// StateStore port
// ────────────────────────────────────────────────────────────────────────

/// Atomic single-use state-machine storage.
///
/// `put` writes a fresh [`PendingAuthRequest`] under a [`State`] key
/// with substrate-enforced TTL. `take` atomically reads-and-deletes —
/// a successful `take` MUST guarantee no other caller can also succeed
/// for the same key. This is the load-bearing CSRF / state-replay
/// defense (Phase 11.B audit).
///
/// **Substrate atomicity examples**:
/// - Redis: `EVAL` with a GET+DEL script, or Redis 6.2+ `GETDEL`
/// - Postgres: `DELETE FROM oidc_state WHERE state = $1 RETURNING …`
/// - KVRocks: `GETDEL` (Redis-compatible 6.2+ command)
/// - In-memory test: `tokio::sync::Mutex<HashMap>` held across both ops
#[async_trait]
pub trait StateStore: Send + Sync {
    /// Persist `pending` under `state` with `ttl`. Substrate must
    /// expire the entry server-side on TTL (no stale-state leakage).
    ///
    /// Failure modes: substrate-down, write-rejected, etc. Surfaces as
    /// [`StateStoreError`] in the consumer.
    async fn put(
        &self,
        state: &State,
        pending: PendingAuthRequest,
        ttl: Duration,
    ) -> Result<(), StateStoreError>;

    /// Atomically read-and-delete the entry under `state`. Returns
    /// `None` if the entry never existed, was already consumed, or
    /// expired. The three cases are intentionally indistinguishable
    /// to the caller — they all map to
    /// [`super::CallbackError::StateNotFoundOrConsumed`].
    async fn take(
        &self,
        state: &State,
    ) -> Result<Option<PendingAuthRequest>, StateStoreError>;
}

/// Substrate-level state-store failure.
///
/// Distinct from "state not found" (which is `Ok(None)` from `take`).
/// Indicates the substrate itself is unhealthy (network, auth,
/// serialization, etc.). Surfaces as
/// [`super::StartError::StateStore`] or
/// [`super::CallbackError::StateStore`] depending on which side hit
/// it.
#[derive(Debug, thiserror::Error)]
pub enum StateStoreError {
    #[error("state-store substrate failure: {0}")]
    Substrate(String),
    #[error("state-store serialization failure: {0}")]
    Serialization(String),
}

// ────────────────────────────────────────────────────────────────────────
// Tests — RelativePath rejection (open-redirect defense)
// ────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn relative_path_accepts_root() {
        let p = RelativePath::try_from("/").expect("rooted path accepted");
        assert_eq!(p.as_str(), "/");
    }

    #[test]
    fn relative_path_accepts_nested() {
        let p = RelativePath::try_from("/dashboard/settings").expect("nested accepted");
        assert_eq!(p.as_str(), "/dashboard/settings");
    }

    #[test]
    fn relative_path_accepts_query_and_fragment() {
        // `?` and `#` may carry colons (e.g., `?next=https://x`); this
        // is a relative path with a query string, not an absolute URL.
        let p = RelativePath::try_from("/x?y=1#z").expect("query+fragment accepted");
        assert_eq!(p.as_str(), "/x?y=1#z");
    }

    #[test]
    fn relative_path_rejects_https_scheme() {
        assert_eq!(
            RelativePath::try_from("https://evil.com"),
            Err(RelativePathError::NotRooted),
        );
    }

    #[test]
    fn relative_path_rejects_javascript_scheme() {
        // `javascript:` doesn't start with `/` — caught by the
        // NotRooted check before the colon-detector kicks in.
        assert_eq!(
            RelativePath::try_from("javascript:alert(1)"),
            Err(RelativePathError::NotRooted),
        );
    }

    #[test]
    fn relative_path_rejects_protocol_relative() {
        assert_eq!(
            RelativePath::try_from("//evil.com/path"),
            Err(RelativePathError::ProtocolRelative),
        );
    }

    #[test]
    fn relative_path_rejects_colon_smuggled_after_root() {
        // Adversary tries to construct a rooted path that nonetheless
        // smuggles a scheme: `/https:foo`. The colon inside the path
        // component triggers the SchemePresent rejection.
        assert_eq!(
            RelativePath::try_from("/https://x"),
            Err(RelativePathError::SchemePresent),
        );
    }

    #[test]
    fn relative_path_rejects_control_characters() {
        assert_eq!(
            RelativePath::try_from("/path\rwith\nnewline"),
            Err(RelativePathError::ControlCharacters),
        );
    }

    #[test]
    fn relative_path_serde_roundtrip_validates() {
        let p = RelativePath::try_from("/ok").unwrap();
        let json = serde_json::to_string(&p).unwrap();
        let back: RelativePath = serde_json::from_str(&json).unwrap();
        assert_eq!(back.as_str(), "/ok");
    }

    #[test]
    fn relative_path_deserialize_rejects_smuggled_scheme() {
        // A substrate (or attacker who controls deserialized JSON)
        // cannot bypass try_from by deserializing directly.
        let result: Result<RelativePath, _> = serde_json::from_str(r#""https://evil""#);
        assert!(result.is_err(), "smuggled absolute URL must reject on deserialize");
    }
}