Skip to main content

pas_external/
refresh_source.rs

1//! # Renewing `TokenSource` — refresh-grant + rotated-token persistence
2//!
3//! A [`RefreshTokenSource`] is a [`ppoppo_sdk_core::token_cache::TokenSource`]
4//! for clients that hold a **durable refresh token** and want a
5//! [`ppoppo_sdk_core::token_cache::TokenCache`] to renew the access token on
6//! its own: on cache lapse the cache calls [`TokenSource::fetch_token`], which
7//! runs the OAuth `refresh_token` grant, **persists the rotated refresh token**
8//! (PAS applies RTR — RFC 9700 §2.2.2 — so every refresh returns a new token),
9//! and hands back the fresh access token + its TTL.
10//!
11//! The persistence seam is [`TokenStore`]: the consumer plugs its keystore —
12//! CNC uses the macOS Keychain; a short-lived process can use
13//! [`MemoryTokenStore`]. RCW/CTW do **not** use this type — their OIDC RP flow
14//! refreshes per request and re-stores inline; this is for native / CLI clients
15//! that feed a long-lived `TokenCache`.
16//!
17//! ## The `Send` discipline
18//!
19//! [`TokenStore`] and [`TokenSource`] are `#[async_trait]` (their futures are
20//! boxed `+ Send`), and [`crate::pas_port::PasAuthPort::refresh`] is `+ Send`.
21//! So [`RefreshTokenSource`]'s `fetch_token` future is `Send` **by
22//! construction** — the impl only compiles if its body holds `Send` values
23//! across every `await`. Never introduce an `async` closure on this path: it
24//! carries a higher-ranked lifetime whose `Send`-ness fails to prove on a
25//! multi-threaded runtime (the yanked-0.5.2 lesson). A `#[tokio::test(flavor =
26//! "multi_thread")]` that `spawn`s the future guards it.
27
28use std::marker::PhantomData;
29use std::time::Duration;
30
31use async_trait::async_trait;
32use ppoppo_sdk_core::scopes::{ConsentScopes, ScopedTokenSource};
33use ppoppo_sdk_core::token_cache::{TokenCacheError, TokenSource};
34
35use crate::pas_port::{PasAuthPort, PasFailure};
36use crate::scope_grant::ensure_covers;
37#[cfg(doc)]
38use crate::oauth::AuthClient;
39
40/// A [`TokenStore`] backend error. Consumers wrap their native keystore error
41/// in the message; it is tunnelled through [`TokenCacheError::Source`] so the
42/// SDK layer can surface it intact.
43#[derive(Debug, thiserror::Error)]
44#[error("token store error: {0}")]
45pub struct TokenStoreError(String);
46
47impl TokenStoreError {
48    /// Wrap a backend failure message.
49    #[must_use]
50    pub fn new(message: impl Into<String>) -> Self {
51        Self(message.into())
52    }
53}
54
55/// Durable persistence seam for a rotated refresh token. All methods are
56/// `Send`-safe for multi-threaded runtimes.
57///
58/// The consumer plugs the keystore: CNC → macOS Keychain, a short-lived process
59/// → [`MemoryTokenStore`]. The store owns exactly one refresh token per session
60/// (the latest, after rotation).
61#[async_trait]
62pub trait TokenStore: Send + Sync {
63    /// The currently-persisted refresh token, or `None` if unauthenticated.
64    async fn load(&self) -> Result<Option<String>, TokenStoreError>;
65    /// Persist a (freshly-rotated) refresh token, replacing any prior one.
66    async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError>;
67    /// Drop the persisted refresh token (logout / terminal refresh failure).
68    async fn clear(&self) -> Result<(), TokenStoreError>;
69}
70
71/// An ephemeral in-memory [`TokenStore`] — a reference adapter and test double.
72///
73/// **Not durable**: the token lives only for the process lifetime. Real clients
74/// plug a persistent keystore (Keychain, encrypted DB column). Useful for a
75/// short-lived CLI invocation or a consumer's tests.
76#[derive(Default)]
77pub struct MemoryTokenStore {
78    inner: std::sync::Mutex<Option<String>>,
79}
80
81// Hand-written so the refresh token never reaches a log line. The derived
82// impl printed `Some("<the actual refresh token>")` — a durable credential,
83// rendered by the one trait people reach for while debugging. Reports
84// whether a credential is held, which is the only part worth seeing.
85impl std::fmt::Debug for MemoryTokenStore {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        let held = self
88            .inner
89            .lock()
90            .map(|g| g.is_some())
91            .unwrap_or_else(|e| e.into_inner().is_some());
92        f.debug_struct("MemoryTokenStore")
93            .field("refresh_token", &if held { "[redacted]" } else { "[none]" })
94            .finish()
95    }
96}
97
98impl MemoryTokenStore {
99    /// An empty store (unauthenticated).
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// A store pre-seeded with an initial refresh token (e.g. straight from the
106    /// authorization_code exchange, before the first renewal).
107    #[must_use]
108    pub fn with_token(refresh_token: impl Into<String>) -> Self {
109        Self { inner: std::sync::Mutex::new(Some(refresh_token.into())) }
110    }
111}
112
113#[async_trait]
114impl TokenStore for MemoryTokenStore {
115    async fn load(&self) -> Result<Option<String>, TokenStoreError> {
116        let guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
117        Ok(guard.clone())
118    }
119
120    async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError> {
121        let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
122        *guard = Some(refresh_token.to_owned());
123        Ok(())
124    }
125
126    async fn clear(&self) -> Result<(), TokenStoreError> {
127        let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
128        *guard = None;
129        Ok(())
130    }
131}
132
133/// A renewing [`TokenSource`]: mints access tokens via the OAuth `refresh_token`
134/// grant and persists the rotated refresh token through a [`TokenStore`].
135///
136/// Depends on the [`PasAuthPort`] *port* (not the concrete `AuthClient`), so it
137/// is unit-tested against an in-memory adapter with no HTTP. Wire it into a
138/// [`ppoppo_sdk_core::token_cache::TokenCache`] so the cache renews on lapse.
139///
140/// ## The scope parameter — a per-refresh check, not a construction-time one
141///
142/// `Sc` is the tier this credential is expected to carry. Every refresh
143/// validates the server's scope echo against it ([`ensure_covers`]) before
144/// handing the access token back, so a grant that narrows **mid-session** —
145/// which OAuth 2.1 §1.3.2 explicitly permits — surfaces as a terminal error
146/// rather than as an unexplained 403 from the resource server later.
147///
148/// This is why the check lives here and not in a wrapper:
149/// [`TokenSource::fetch_token`] returns only `(String, Duration)`, so nothing
150/// composed *around* this type can ever see the scope echo. The seam has to be
151/// inside.
152///
153/// That also makes [`ScopedTokenSource<Sc>`] a stronger witness than a
154/// construction-time check would be. The marker holds for every instance
155/// because the guarantee is intrinsic to *use*: any token this source yields
156/// has just been checked. [`new`](Self::new) therefore needs no proof from its
157/// caller — there is no window in which an unchecked token escapes.
158pub struct RefreshTokenSource<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> {
159    auth: P,
160    store: T,
161    _scope: PhantomData<Sc>,
162}
163
164impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> RefreshTokenSource<P, T, Sc> {
165    /// `auth` performs the refresh grant (typically an [`AuthClient`] carrying
166    /// the token endpoint + client_id + RFC 8707 resource); `store` holds the
167    /// durable refresh token (seed it with the initial token first).
168    ///
169    /// `Sc` is usually inferred from the surrounding binding; give it
170    /// explicitly (`RefreshTokenSource::<_, _, Notify>::new(..)`) where it is
171    /// not.
172    ///
173    /// Most consumers do not call this directly — [`AuthClient`] cannot be
174    /// constructed outside this crate, so a native client obtains a ready
175    /// source from
176    /// [`NativeAuthFlow::token_source`](crate::NativeAuthFlow::token_source)
177    /// or [`NativeAuthFlow::complete`](crate::NativeAuthFlow::complete). This
178    /// constructor is for consumers supplying their own [`PasAuthPort`].
179    pub fn new(auth: P, store: T) -> Self {
180        Self { auth, store, _scope: PhantomData }
181    }
182}
183
184// Neither `P` nor `T` is required to be `Debug` (a consumer's keystore
185// adapter usually is not), so this reports the tier and nothing else — which
186// is the field actually worth seeing when a credential misbehaves.
187impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> std::fmt::Debug
188    for RefreshTokenSource<P, T, Sc>
189{
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("RefreshTokenSource")
192            .field("scope", &Sc::scope_line())
193            .finish_non_exhaustive()
194    }
195}
196
197/// The witness: every token this source yields has passed [`ensure_covers`]
198/// against `Sc`. See the type's docs for why the guarantee attaches to use
199/// rather than construction.
200impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> ScopedTokenSource<Sc>
201    for RefreshTokenSource<P, T, Sc>
202{
203}
204
205#[async_trait]
206impl<P: PasAuthPort, T: TokenStore, Sc: ConsentScopes> TokenSource
207    for RefreshTokenSource<P, T, Sc>
208{
209    async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError> {
210        let refresh_token = self
211            .store
212            .load()
213            .await
214            .map_err(|e| TokenCacheError::Source(Box::new(e)))?
215            .ok_or_else(|| {
216                TokenCacheError::Source(Box::new(TokenStoreError::new(
217                    "no refresh token stored — client is not authenticated",
218                )))
219            })?;
220
221        match self.auth.refresh(&refresh_token).await {
222            Ok(response) => {
223                // The refresh-leg grant check (OAuth 2.1 §1.3.2 permits a
224                // refresh response to narrow the scope). Runs BEFORE the
225                // rotated token is persisted and before the access token is
226                // handed out: if the tier is gone, neither the caller nor the
227                // store should advance on a credential that no longer means
228                // what `Sc` says it means.
229                //
230                // Deliberately does NOT clear the store. Unlike the 4xx arm
231                // below, this credential is not dead — it is merely narrower
232                // than this source's tier. Clearing would conflate "the server
233                // reduced your grant" with "your session was revoked", and
234                // destroy a refresh token that is still perfectly valid for a
235                // client built at a lower tier. Recovery is re-authorization
236                // (which re-consents and mints a new token anyway), and that is
237                // the consumer's call to make.
238                ensure_covers::<Sc>(response.scope.as_deref())
239                    .map_err(|e| TokenCacheError::Fetch(format!("refresh {e}")))?;
240
241                // RTR: persist the rotated token so the NEXT renewal presents it.
242                // Absent (a non-rotating server) → keep the current token.
243                if let Some(rotated) = response.refresh_token.as_deref() {
244                    self.store
245                        .save(rotated)
246                        .await
247                        .map_err(|e| TokenCacheError::Source(Box::new(e)))?;
248                }
249                let ttl = Duration::from_secs(response.expires_in.unwrap_or(3600));
250                Ok((response.access_token, ttl))
251            }
252            // 4xx: the refresh token is dead (expired / revoked / logged out
253            // elsewhere, incl. RTR family-invalidation). Terminal — clear the
254            // store so the next load reads "unauthenticated" rather than
255            // replaying a dead token; the consumer must re-authenticate.
256            Err(PasFailure::Rejected { detail, .. }) => {
257                // Best-effort: we are already returning a terminal error.
258                let _ = self.store.clear().await;
259                Err(TokenCacheError::Fetch(format!(
260                    "refresh rejected (credential dead): {detail}"
261                )))
262            }
263            // 5xx / transport: transient. Keep the token; the caller may retry.
264            Err(PasFailure::ServerError { detail, .. }) => {
265                Err(TokenCacheError::Fetch(format!("PAS refresh 5xx: {detail}")))
266            }
267            Err(PasFailure::Transport { detail }) => {
268                Err(TokenCacheError::Fetch(format!("PAS refresh transport error: {detail}")))
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275#[allow(clippy::unwrap_used, clippy::expect_used)]
276mod tests {
277    use super::*;
278    use crate::oauth::TokenResponse;
279    use std::sync::Mutex;
280
281    /// The tier under test — mirrors `pcs_session::scopes::Notify`, which is
282    /// not reachable from here (no SDK↔SDK edge, by design).
283    struct Notify;
284    impl ConsentScopes for Notify {
285        const SCOPES: &'static [&'static str] = &["chat.read", "contact.read", "chat.ack"];
286    }
287
288    /// Shorthand: the concrete source used by every test below.
289    fn source<T: TokenStore>(auth: MockAuth, store: T) -> RefreshTokenSource<MockAuth, T, Notify> {
290        RefreshTokenSource::new(auth, store)
291    }
292
293    /// A canned `PasAuthPort` — returns a preset outcome, recording the refresh
294    /// token it was presented so a test can assert the rotation chain.
295    struct MockAuth {
296        outcome: Mutex<Result<TokenResponse, PasFailure>>,
297        seen: Mutex<Vec<String>>,
298    }
299
300    impl MockAuth {
301        fn ok(access: &str, rotated: Option<&str>, expires_in: Option<u64>) -> Self {
302            Self::with_scope(access, rotated, expires_in, None)
303        }
304
305        /// `scope` is the RFC 6749 §5.1 grant echo: `None` = granted as
306        /// requested, `Some` = the exact atoms the server conceded.
307        fn with_scope(
308            access: &str,
309            rotated: Option<&str>,
310            expires_in: Option<u64>,
311            scope: Option<&str>,
312        ) -> Self {
313            Self {
314                outcome: Mutex::new(Ok(TokenResponse {
315                    access_token: access.to_owned(),
316                    token_type: "Bearer".to_owned(),
317                    expires_in,
318                    refresh_token: rotated.map(str::to_owned),
319                    id_token: None,
320                    scope: scope.map(str::to_owned),
321                })),
322                seen: Mutex::new(Vec::new()),
323            }
324        }
325        fn rejected() -> Self {
326            Self {
327                outcome: Mutex::new(Err(PasFailure::Rejected {
328                    status: 400,
329                    detail: "invalid_grant".to_owned(),
330                })),
331                seen: Mutex::new(Vec::new()),
332            }
333        }
334    }
335
336    // `PasAuthPort::refresh` is native RPITIT (`-> impl Future + Send`), not
337    // `#[async_trait]` — the mock matches that shape. The returned future owns
338    // `token` and borrows `&self` (Send, since `MockAuth: Sync`).
339    impl PasAuthPort for MockAuth {
340        fn refresh(
341            &self,
342            refresh_token: &str,
343        ) -> impl std::future::Future<Output = Result<TokenResponse, PasFailure>> + Send {
344            let token = refresh_token.to_owned();
345            async move {
346                self.seen.lock().unwrap().push(token);
347                self.outcome.lock().unwrap().clone()
348            }
349        }
350    }
351
352    #[tokio::test]
353    async fn rotation_is_persisted_to_the_store() {
354        let auth = MockAuth::ok("AT-1", Some("RT-2"), Some(900));
355        let store = MemoryTokenStore::with_token("RT-1");
356        let source = source(auth, store);
357
358        let (access, ttl) = source.fetch_token().await.expect("fetch");
359        assert_eq!(access, "AT-1");
360        assert_eq!(ttl, Duration::from_secs(900));
361        // The presented token was RT-1; the rotated RT-2 is now persisted.
362        assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1"]);
363        assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-2"));
364
365        // A second renewal presents the rotated token, not the original.
366        let _ = source.fetch_token().await.expect("second fetch");
367        assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1", "RT-2"]);
368    }
369
370    #[tokio::test]
371    async fn absent_rotation_keeps_the_current_token() {
372        let auth = MockAuth::ok("AT-1", None, None);
373        let store = MemoryTokenStore::with_token("RT-1");
374        let source = source(auth, store);
375
376        let (_, ttl) = source.fetch_token().await.expect("fetch");
377        assert_eq!(ttl, Duration::from_secs(3600), "missing expires_in defaults to 1h");
378        assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-1"));
379    }
380
381    #[tokio::test]
382    async fn missing_credential_is_a_source_error() {
383        let auth = MockAuth::ok("AT", Some("RT-2"), None);
384        let source = source(auth, MemoryTokenStore::new());
385        let err = source.fetch_token().await.expect_err("empty store must fail");
386        assert!(matches!(err, TokenCacheError::Source(_)));
387    }
388
389    #[tokio::test]
390    async fn rejected_refresh_clears_the_store() {
391        let auth = MockAuth::rejected();
392        let store = MemoryTokenStore::with_token("RT-dead");
393        let source = source(auth, store);
394
395        let err = source.fetch_token().await.expect_err("dead credential must fail");
396        assert!(matches!(err, TokenCacheError::Fetch(_)));
397        // The dead token is cleared so no retry replays it.
398        assert_eq!(source.store.load().await.unwrap(), None);
399    }
400
401    /// OAuth 2.1 §1.3.2 lets a refresh response narrow the scope. Before the
402    /// check existed, this returned a perfectly good-looking access token and
403    /// the loss surfaced as an unexplained 403 from PCS on the next call that
404    /// happened to need `chat.ack`.
405    #[tokio::test]
406    async fn narrowed_refresh_is_a_terminal_error_naming_the_lost_atom() {
407        let auth = MockAuth::with_scope("AT-1", Some("RT-2"), None, Some("chat.read contact.read"));
408        let store = MemoryTokenStore::with_token("RT-1");
409        let source = source(auth, store);
410
411        let err = source.fetch_token().await.expect_err("a narrowed grant must not pass");
412        assert!(matches!(err, TokenCacheError::Fetch(_)));
413        assert!(err.to_string().contains("chat.ack"), "must name the lost atom: {err}");
414
415        // The credential is NOT cleared: it is narrower, not dead. Recovery is
416        // re-authorization, which the consumer decides — clearing here would
417        // conflate a reduced grant with a revoked session.
418        assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-1"));
419    }
420
421    /// The rotated token must not be persisted when the grant check fails —
422    /// otherwise the store advances to a token whose tier no longer matches
423    /// this source, and the next refresh reports the same failure from a
424    /// different starting point.
425    #[tokio::test]
426    async fn a_failed_grant_check_does_not_advance_the_stored_token() {
427        let auth = MockAuth::with_scope("AT-1", Some("RT-2"), None, Some("chat.read"));
428        let source = source(auth, MemoryTokenStore::with_token("RT-1"));
429
430        let _ = source.fetch_token().await.expect_err("narrowed grant");
431        assert_eq!(
432            source.store.load().await.unwrap().as_deref(),
433            Some("RT-1"),
434            "rotation must not be persisted past a failed grant check"
435        );
436    }
437
438    /// RFC 6749 §5.1: an omitted echo means "granted as requested". A
439    /// published SDK must accept that rather than fail closed on it.
440    #[tokio::test]
441    async fn absent_scope_echo_passes_the_grant_check() {
442        let source = source(
443            MockAuth::ok("AT-1", Some("RT-2"), None),
444            MemoryTokenStore::with_token("RT-1"),
445        );
446        let (access, _) = source.fetch_token().await.expect("absent echo == granted as requested");
447        assert_eq!(access, "AT-1");
448    }
449
450    /// A server granting more than asked is not an error.
451    #[tokio::test]
452    async fn superset_scope_echo_passes_the_grant_check() {
453        let auth = MockAuth::with_scope(
454            "AT-1",
455            None,
456            None,
457            Some("chat.read contact.read chat.ack chat.send"),
458        );
459        let source = source(auth, MemoryTokenStore::with_token("RT-1"));
460        assert!(source.fetch_token().await.is_ok());
461    }
462
463    /// The load-bearing `Send` guard: on a multi-thread runtime, `spawn`
464    /// requires the future be `Send`. This only compiles if `fetch_token`'s
465    /// body is `Send` across every await (the yanked-0.5.2 regression).
466    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
467    async fn fetch_token_future_is_send() {
468        let source = source(
469            MockAuth::ok("AT", Some("RT-2"), None),
470            MemoryTokenStore::with_token("RT-1"),
471        );
472        let handle = tokio::spawn(async move { source.fetch_token().await });
473        let (access, _) = handle.await.expect("join").expect("fetch");
474        assert_eq!(access, "AT");
475    }
476}