Skip to main content

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}