stack_auth/auth_strategy_fn.rs
1//! [`AuthStrategy`] adapter built from an async closure.
2//!
3//! `AuthStrategyFn` is the closure-shaped impl of the *acquisition layer*
4//! ([`AuthStrategy`]): the closure runs every time a token is requested and
5//! returns a [`ServiceToken`]. Use this when the actual token acquisition
6//! lives outside `stack-auth` — most commonly behind an FFI callback
7//! (a JS `getToken()` reached via Neon, a foreign IPC channel, a hand-rolled
8//! test double).
9//!
10//! Sibling primitive on the *persistence layer* is
11//! [`TokenStoreFn`](crate::TokenStoreFn), which plugs into an existing
12//! strategy to back its cache. `AuthStrategyFn` replaces the whole
13//! acquisition pipeline; `TokenStoreFn` slots into one. See `auth-strategy-handover.md`
14//! at the repo root for the wider design discussion.
15//!
16//! [`cipherstash-client`]: https://docs.rs/cipherstash-client/
17
18use std::future::Future;
19
20use crate::{AuthError, AuthStrategy, ServiceToken};
21
22/// [`AuthStrategy`] backed by a user-supplied async closure that returns
23/// a [`ServiceToken`].
24///
25/// # Example
26///
27/// ```no_run
28/// use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken};
29///
30/// let strategy = AuthStrategyFn::new(|| async {
31/// // Real consumers would call into FFI / IPC / a cached token store.
32/// Ok::<_, AuthError>(ServiceToken::new(SecretToken::new("dummy.jwt.value".to_string())))
33/// });
34/// ```
35///
36/// # When to reach for this vs [`TokenStoreFn`](crate::TokenStoreFn)
37///
38/// - **`AuthStrategyFn`**: you control the *entire* token pipeline — fetch,
39/// refresh, cache. `cipherstash-client` calls your closure and uses
40/// whatever it returns, no further questions asked. Used by FFI bindings
41/// that proxy to a JS-side strategy doing all the work upstream.
42/// - **`TokenStoreFn`**: you want stack-auth's `AccessKeyStrategy` (or
43/// another concrete strategy) to do the HTTP/refresh work, and you just
44/// want to plug in custom persistence (a cookie, a KV blob, Redis).
45pub struct AuthStrategyFn<F> {
46 get_token: F,
47}
48
49impl<F> AuthStrategyFn<F> {
50 /// Build an `AuthStrategyFn` from an async closure. The closure fires
51 /// every time [`AuthStrategy::get_token`] is called on a reference to
52 /// this strategy — typically once per `cipherstash-client` HTTP request,
53 /// modulo any in-process caching the closure does internally.
54 pub fn new(get_token: F) -> Self {
55 Self { get_token }
56 }
57}
58
59#[cfg(not(target_arch = "wasm32"))]
60impl<F, Fut> AuthStrategy for &AuthStrategyFn<F>
61where
62 F: Fn() -> Fut + Send + Sync,
63 Fut: Future<Output = Result<ServiceToken, AuthError>> + Send,
64{
65 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send {
66 (self.get_token)()
67 }
68}
69
70#[cfg(target_arch = "wasm32")]
71impl<F, Fut> AuthStrategy for &AuthStrategyFn<F>
72where
73 F: Fn() -> Fut,
74 Fut: Future<Output = Result<ServiceToken, AuthError>>,
75{
76 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> {
77 (self.get_token)()
78 }
79}
80
81#[cfg(test)]
82#[allow(clippy::unwrap_used)]
83mod tests {
84 use std::sync::atomic::{AtomicUsize, Ordering};
85 use std::sync::Arc;
86
87 use crate::SecretToken;
88
89 use super::*;
90
91 fn dummy_service_token(jwt: &str) -> ServiceToken {
92 ServiceToken::new(SecretToken::new(jwt.to_string()))
93 }
94
95 #[tokio::test]
96 async fn closure_runs_on_each_get_token_call() {
97 let calls = Arc::new(AtomicUsize::new(0));
98 let calls_clone = Arc::clone(&calls);
99 let strategy = AuthStrategyFn::new(move || {
100 let calls = Arc::clone(&calls_clone);
101 async move {
102 let n = calls.fetch_add(1, Ordering::SeqCst);
103 Ok(dummy_service_token(&format!("jwt-{n}")))
104 }
105 });
106
107 let first = (&strategy).get_token().await.unwrap();
108 assert_eq!(
109 first.as_str(),
110 "jwt-0",
111 "first call should yield the first token the closure produced"
112 );
113
114 let second = (&strategy).get_token().await.unwrap();
115 assert_eq!(
116 second.as_str(),
117 "jwt-1",
118 "second call should re-invoke the closure"
119 );
120
121 assert_eq!(
122 calls.load(Ordering::SeqCst),
123 2,
124 "closure should have fired exactly twice"
125 );
126 }
127
128 #[tokio::test]
129 async fn closure_errors_propagate_unchanged() {
130 let strategy = AuthStrategyFn::new(|| async { Err(AuthError::AccessDenied) });
131 let err = (&strategy).get_token().await.unwrap_err();
132 assert!(
133 matches!(err, AuthError::AccessDenied),
134 "AccessDenied from the closure should surface verbatim, got: {err:?}"
135 );
136 }
137}