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}