Skip to main content

jacquard_oauth/
session.rs

1use std::{str::FromStr, sync::Arc};
2
3use chrono::TimeDelta;
4
5use crate::{
6    atproto::{AtprotoClientMetadata, atproto_client_metadata},
7    authstore::ClientAuthStore,
8    dpop::DpopExt,
9    keyset::Keyset,
10    request::{OAuthMetadata, refresh},
11    resolver::OAuthResolver,
12    scopes::Scopes,
13    types::TokenSet,
14};
15
16use dashmap::DashMap;
17use jacquard_common::{
18    IntoStatic,
19    bos::{BosStr, DefaultStr},
20    deps::fluent_uri::Uri,
21    http_client::HttpClient,
22    session::{SessionKey, SessionStoreError},
23    types::{did::Did, string::Datetime},
24};
25use jose_jwk::Key;
26use mini_moka::sync::Cache as MokaCache;
27use serde::{Deserialize, Serialize};
28use smol_str::{SmolStr, ToSmolStr, format_smolstr};
29use tokio::sync::Mutex;
30
31/// Provides DPoP key material and per-server nonces to the DPoP proof-building machinery.
32///
33/// This trait abstracts over two different holders of DPoP state: [`DpopReqData`] (used
34/// during the initial authorization request, where only an authserver nonce is tracked) and
35/// [`DpopClientData`] (used in active sessions, where both authserver and host nonces are
36/// maintained). Implementors must store nonces durably so that the next request to the same
37/// server includes the most recently observed nonce.
38pub trait DpopDataSource {
39    /// Return the private JWK used to sign DPoP proofs.
40    fn key(&self) -> &Key;
41    /// Return the most recently observed nonce from the authorization server, if any.
42    fn authserver_nonce(&self) -> Option<&str>;
43    /// Persist a new nonce received from the authorization server.
44    fn set_authserver_nonce(&mut self, nonce: SmolStr);
45    /// Return the most recently observed nonce from the resource server (PDS), if any.
46    fn host_nonce(&self) -> Option<&str>;
47    /// Persist a new nonce received from the resource server (PDS).
48    fn set_host_nonce(&mut self, nonce: SmolStr);
49}
50
51/// Persisted information about an OAuth session. Used to resume an active session.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(bound(
54    serialize = "S: serde::Serialize + BosStr + Ord",
55    deserialize = "S: serde::Deserialize<'de> + BosStr + AsRef<str>"
56))]
57pub struct ClientSessionData<S: BosStr = DefaultStr> {
58    /// DID of the authenticated account; serves as the primary key for session storage
59    /// because only one active session per account is assumed.
60    pub account_did: Did<S>,
61
62    /// Opaque identifier that distinguishes this session from other sessions for the same account.
63    ///
64    /// Reuses the random `state` token generated during the PAR flow.
65    pub session_id: S,
66
67    /// Base URL of the resource server (PDS): scheme, host, and port only
68    pub host_url: Uri<String>,
69
70    /// Base URL of the authorization server (PDS or entryway): scheme, host, and port only
71    pub authserver_url: S,
72
73    /// Full URL of the authorization server's token endpoint.
74    pub authserver_token_endpoint: S,
75
76    /// Full URL of the authorization server's revocation endpoint, if advertised.
77    #[serde(skip_serializing_if = "std::option::Option::is_none")]
78    pub authserver_revocation_endpoint: Option<S>,
79
80    /// The set of OAuth scopes approved for this session, as returned in the initial token response.
81    pub scopes: Scopes<S>,
82
83    /// DPoP key and nonce state for ongoing requests in this session.
84    #[serde(flatten)]
85    pub dpop_data: DpopClientData,
86
87    /// Current token set (access token, refresh token, expiry, etc.).
88    #[serde(flatten)]
89    pub token_set: TokenSet<S>,
90
91    /// Fully expanded scopes with include scopes resolved.
92    ///
93    /// This is populated eagerly at session creation when `scope-check` is enabled.
94    /// It is `None` when scope checking is not enabled, when no eager resolution was
95    /// performed, or when reading older persisted sessions that predate this field.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub resolved_scopes: Option<Vec<crate::scopes::Scope<smol_str::SmolStr>>>,
98}
99
100impl<S: BosStr + Ord + IntoStatic + AsRef<str>> IntoStatic for ClientSessionData<S>
101where
102    S::Output: BosStr + Ord + AsRef<str>,
103{
104    type Output = ClientSessionData<S::Output>;
105
106    fn into_static(self) -> Self::Output {
107        let resolved_scopes = self.resolved_scopes;
108
109        ClientSessionData {
110            authserver_url: self.authserver_url.into_static(),
111            authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
112            authserver_revocation_endpoint: self
113                .authserver_revocation_endpoint
114                .map(IntoStatic::into_static),
115            scopes: self.scopes.into_static(),
116            dpop_data: self.dpop_data,
117            token_set: self.token_set.into_static(),
118            account_did: self.account_did.into_static(),
119            session_id: self.session_id.into_static(),
120            host_url: self.host_url.clone(),
121            resolved_scopes,
122        }
123    }
124}
125
126impl<S: BosStr + Ord + AsRef<str>> ClientSessionData<S> {
127    /// Update this session's token set and, if the new token set includes scopes, replace the scope list.
128    ///
129    /// Called after a successful token refresh so that any scope changes returned by the server
130    /// are reflected in the persisted session without requiring a full re-authentication.
131    ///
132    /// This method is only available on `DefaultStr`-backed sessions (the common case for
133    /// in-memory sessions). Zero-copy borrowed sessions are read-only by nature and would
134    /// not be refreshed in place.
135    pub fn update_with_tokens(&mut self, token_set: &TokenSet<S>)
136    where
137        S: FromStr + Clone + From<SmolStr> + AsRef<str>,
138        S::Err: std::fmt::Debug,
139    {
140        if let Some(scope_str) = token_set.scope.as_ref() {
141            // Parse scopes from the returned scope string, converting to the appropriate backing type
142            let scopes_smol = Scopes::new(scope_str.as_ref().to_smolstr())
143                .expect("server returned invalid scopes in token refresh");
144            self.scopes = scopes_smol.convert();
145        }
146        self.token_set = token_set.clone();
147    }
148}
149
150/// DPoP state for an active OAuth session, persisted alongside the token set.
151///
152/// Both nonces must be written back to the store after each request so that the next
153/// request to the same server includes the correct replay-protection nonce.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
155pub struct DpopClientData {
156    /// The private JWK bound to this session; used to sign all DPoP proofs.
157    pub dpop_key: Key,
158    /// Most recently observed DPoP nonce from the authorization server.
159    pub dpop_authserver_nonce: SmolStr,
160    /// Most recently observed DPoP nonce from the resource server (PDS).
161    pub dpop_host_nonce: SmolStr,
162}
163
164impl DpopDataSource for DpopClientData {
165    fn key(&self) -> &Key {
166        &self.dpop_key
167    }
168
169    fn authserver_nonce(&self) -> Option<&str> {
170        Some(self.dpop_authserver_nonce.as_ref())
171    }
172
173    fn host_nonce(&self) -> Option<&str> {
174        Some(self.dpop_host_nonce.as_ref())
175    }
176
177    fn set_authserver_nonce(&mut self, nonce: SmolStr) {
178        self.dpop_authserver_nonce = nonce;
179    }
180
181    fn set_host_nonce(&mut self, nonce: SmolStr) {
182        self.dpop_host_nonce = nonce;
183    }
184}
185
186/// Transient state created during the PAR flow and consumed by the callback handler.
187///
188/// This struct is persisted to the auth store between [`crate::request::par`] and
189/// [`crate::client::OAuthClient::callback`] so that the callback can verify the
190/// `state`, reconstruct the token exchange, and create a full [`ClientSessionData`].
191#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(bound(
193    serialize = "S: serde::Serialize + BosStr + Ord",
194    deserialize = "S: serde::Deserialize<'de> + BosStr + AsRef<str>"
195))]
196pub struct AuthRequestData<S: BosStr = DefaultStr> {
197    /// Random identifier generated for this authorization request; used as the primary key
198    /// for storing and looking up this record during the callback.
199    pub state: S,
200
201    /// Base URL of the authorization server that was selected for this flow.
202    pub authserver_url: S,
203
204    /// If the flow was initiated with a DID or handle, the resolved DID is stored here
205    /// so it can be compared against the `sub` in the token response.
206    #[serde(skip_serializing_if = "std::option::Option::is_none")]
207    pub account_did: Option<Did<S>>,
208
209    /// OAuth scopes requested for this authorization.
210    pub scopes: Scopes<S>,
211
212    /// The PAR `request_uri` returned by the authorization server; included in the redirect URL.
213    pub request_uri: S,
214
215    /// Full URL of the authorization server's token endpoint.
216    pub authserver_token_endpoint: S,
217
218    /// Full URL of the authorization server's revocation endpoint, if advertised.
219    #[serde(skip_serializing_if = "std::option::Option::is_none")]
220    pub authserver_revocation_endpoint: Option<S>,
221
222    /// The PKCE code verifier whose SHA-256 hash was sent as the code challenge; required
223    /// at the token exchange step to prove the initiator of the auth request.
224    pub pkce_verifier: S,
225
226    /// DPoP key and any authserver nonce observed during the PAR request.
227    #[serde(flatten)]
228    pub dpop_data: DpopReqData,
229}
230
231impl<S: BosStr + Ord + IntoStatic + AsRef<str>> IntoStatic for AuthRequestData<S>
232where
233    S::Output: BosStr + Ord + AsRef<str>,
234{
235    type Output = AuthRequestData<S::Output>;
236
237    fn into_static(self) -> AuthRequestData<S::Output> {
238        AuthRequestData {
239            request_uri: self.request_uri.into_static(),
240            authserver_token_endpoint: self.authserver_token_endpoint.into_static(),
241            authserver_revocation_endpoint: self
242                .authserver_revocation_endpoint
243                .map(|s| s.into_static()),
244            pkce_verifier: self.pkce_verifier.into_static(),
245            dpop_data: self.dpop_data,
246            state: self.state.into_static(),
247            authserver_url: self.authserver_url.into_static(),
248            account_did: self.account_did.into_static(),
249            scopes: self.scopes.into_static(),
250        }
251    }
252}
253
254/// DPoP state for an in-progress authorization request (PAR through code exchange).
255///
256/// Unlike [`DpopClientData`], this struct only tracks the authserver nonce—no resource-server
257/// nonce is needed until a full session is established.
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259pub struct DpopReqData {
260    /// The private JWK generated fresh for this authorization request and session.
261    pub dpop_key: Key,
262    /// DPoP nonce received from the authorization server during the PAR exchange, if any.
263    pub dpop_authserver_nonce: Option<SmolStr>,
264}
265
266impl DpopDataSource for DpopReqData {
267    fn key(&self) -> &Key {
268        &self.dpop_key
269    }
270
271    fn authserver_nonce(&self) -> Option<&str> {
272        self.dpop_authserver_nonce.as_ref().map(|n| n.as_ref())
273    }
274
275    fn host_nonce(&self) -> Option<&str> {
276        None
277    }
278
279    fn set_authserver_nonce(&mut self, nonce: SmolStr) {
280        self.dpop_authserver_nonce = Some(nonce);
281    }
282
283    fn set_host_nonce(&mut self, _nonce: SmolStr) {}
284}
285
286/// Static configuration for an OAuth client: the signing keyset and registered client metadata.
287///
288/// `ClientData` is constructed once at startup and shared (via `Arc`) across all sessions
289/// managed by the same [`crate::client::OAuthClient`].
290#[derive(Clone, Debug)]
291pub struct ClientData<S>
292where
293    S: BosStr + FromStr + Ord,
294    <S as FromStr>::Err: core::fmt::Debug,
295{
296    /// Optional private key set used for `private_key_jwt` client authentication.
297    /// When `None`, the `none` authentication method is used instead.
298    pub keyset: Option<Keyset>,
299    /// AT Protocol-specific client registration metadata (redirect URIs, scopes, etc.).
300    pub config: AtprotoClientMetadata<S>,
301}
302
303impl<S> IntoStatic for ClientData<S>
304where
305    S: BosStr + FromStr + Ord + IntoStatic,
306    S::Output: BosStr + Ord + FromStr,
307    <S as FromStr>::Err: core::fmt::Debug,
308    <S::Output as FromStr>::Err: core::fmt::Debug,
309{
310    type Output = ClientData<S::Output>;
311    fn into_static(self) -> ClientData<S::Output> {
312        ClientData {
313            keyset: self.keyset,
314            config: self.config.into_static(),
315        }
316    }
317}
318
319impl<S: BosStr + FromStr + Ord> ClientData<S>
320where
321    <S as FromStr>::Err: core::fmt::Debug,
322{
323    /// Create `ClientData` with an optional signing keyset and the given client metadata.
324    pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<S>) -> Self {
325        Self { keyset, config }
326    }
327
328    /// Create `ClientData` without a signing keyset, relying on the `none` auth method.
329    ///
330    /// Suitable for public clients (e.g., single-page applications or native apps) that
331    /// cannot securely store a private key.
332    pub fn new_public(config: AtprotoClientMetadata<S>) -> Self {
333        Self {
334            keyset: None,
335            config,
336        }
337    }
338}
339
340/// A bundle of client configuration and an active session, used for operations that need both.
341///
342/// `ClientSession` is a convenience type that pairs a [`ClientData`] with a
343/// [`ClientSessionData`] so that methods like `metadata` can access both without requiring
344/// callers to pass them separately.
345pub struct ClientSession<S: BosStr = DefaultStr>
346where
347    S: FromStr + Ord,
348    <S as FromStr>::Err: core::fmt::Debug,
349{
350    /// Optional signing keyset, forwarded from [`ClientData`].
351    pub keyset: Option<Keyset>,
352    /// Client registration metadata, forwarded from [`ClientData`].
353    pub config: AtprotoClientMetadata<S>,
354    /// The session state for the authenticated account.
355    pub session_data: ClientSessionData<S>,
356}
357
358impl<S: BosStr> ClientSession<S>
359where
360    S: FromStr + Ord + Clone,
361    <S as FromStr>::Err: core::fmt::Debug,
362{
363    /// Construct a `ClientSession` from a [`ClientData`] and an active session.
364    pub fn new(
365        ClientData { keyset, config }: ClientData<S>,
366        session_data: ClientSessionData<S>,
367    ) -> Self {
368        Self {
369            keyset,
370            config,
371            session_data,
372        }
373    }
374
375    /// Fetch and assemble an [`OAuthMetadata`] for the authorization server of this session.
376    pub async fn metadata<T: HttpClient + OAuthResolver + Send + Sync>(
377        &self,
378        client: &T,
379    ) -> Result<OAuthMetadata<S>, Error>
380    where
381        S: IntoStatic,
382    {
383        Ok(OAuthMetadata {
384            server_metadata: client
385                .get_authorization_server_metadata(self.session_data.authserver_url.as_ref())
386                .await
387                .map_err(|e| Error::ServerAgent(crate::request::RequestError::resolver(e)))?,
388            client_metadata: atproto_client_metadata(&self.config, &self.keyset).unwrap(),
389            keyset: self.keyset.clone(),
390        })
391    }
392}
393
394/// Errors that can occur during OAuth session management.
395#[derive(thiserror::Error, Debug, miette::Diagnostic)]
396#[non_exhaustive]
397pub enum Error {
398    /// A token-endpoint or metadata operation failed.
399    #[error(transparent)]
400    #[diagnostic(code(jacquard_oauth::session::request))]
401    ServerAgent(#[from] crate::request::RequestError),
402    /// The backing session store returned an error.
403    #[error(transparent)]
404    #[diagnostic(code(jacquard_oauth::session::storage))]
405    Store(#[from] SessionStoreError),
406    /// The requested session does not exist in the store.
407    #[error("session does not exist")]
408    #[diagnostic(code(jacquard_oauth::session::not_found))]
409    SessionNotFound,
410    /// Token refresh failed with a permanent error (e.g., `invalid_grant`); the session
411    /// has already been removed from the store and the user must re-authenticate.
412    #[error("session refresh failed permanently")]
413    #[diagnostic(
414        code(jacquard_oauth::session::refresh_failed),
415        help("the session has been cleared - user must re-authenticate")
416    )]
417    RefreshFailed(#[source] crate::request::RequestError),
418}
419
420impl Error {
421    /// Returns true if this error indicates a permanent auth failure
422    /// where the user needs to re-authenticate.
423    pub fn is_permanent(&self) -> bool {
424        match self {
425            Error::RefreshFailed(_) => true,
426            Error::SessionNotFound => true,
427            Error::ServerAgent(e) => e.is_permanent(),
428            Error::Store(_) => false,
429        }
430    }
431}
432
433/// Central coordinator for OAuth session storage and token refresh.
434///
435/// `SessionRegistry` wraps the [`ClientAuthStore`] and provides serialized token refresh:
436/// concurrent refresh attempts for the same `(DID, session_id)` pair are coalesced behind
437/// a per-key `Mutex` stored in `pending`, so only one refresh request is issued to the
438/// authorization server even when many concurrent requests detect an expired token.
439pub struct SessionRegistry<T, S, Str>
440where
441    T: OAuthResolver,
442    S: ClientAuthStore,
443    Str: BosStr + FromStr + Ord,
444    <Str as FromStr>::Err: core::fmt::Debug,
445{
446    /// Backing store for persisting session data across process restarts.
447    pub store: Arc<S>,
448    /// Shared resolver used to fetch authorization server metadata during refresh.
449    pub client: Arc<T>,
450    /// Static client configuration (keyset and registration metadata).
451    pub client_data: ClientData<Str>,
452    /// Per-`(DID, session_id)` mutex that serializes concurrent refresh attempts.
453    pending: DashMap<SmolStr, Arc<Mutex<()>>>,
454    /// In-memory cache of restored sessions, keyed by `SessionKey`.
455    /// Prevents each `restore` from creating a fresh `OAuthSession` with its
456    /// own DPoP nonce state, which causes conflicts when multiple rapid
457    /// requests hit the same PDS session. Lazily initialized.
458    #[allow(dead_code)]
459    pub(crate) session_cache:
460        std::sync::OnceLock<MokaCache<SessionKey, crate::client::OAuthSession<T, S>>>,
461}
462
463impl<T, S, Str> SessionRegistry<T, S, Str>
464where
465    S: ClientAuthStore,
466    T: OAuthResolver,
467    Str: BosStr + FromStr + Ord,
468    <Str as FromStr>::Err: core::fmt::Debug,
469{
470    /// Create a new registry, taking ownership of the store.
471    pub fn new(store: S, client: Arc<T>, client_data: ClientData<Str>) -> Self {
472        let store = Arc::new(store);
473        Self {
474            store,
475            client,
476            client_data,
477            pending: DashMap::new(),
478            session_cache: std::sync::OnceLock::new(),
479        }
480    }
481
482    /// Create a new registry from an already-`Arc`-wrapped store.
483    ///
484    /// Use this variant when the store needs to be accessed from outside the registry,
485    /// for example to expose session listing or administration functionality.
486    pub fn new_shared(store: Arc<S>, client: Arc<T>, client_data: ClientData<Str>) -> Self {
487        Self {
488            store,
489            client,
490            client_data,
491            pending: DashMap::new(),
492            session_cache: std::sync::OnceLock::new(),
493        }
494    }
495}
496
497impl<T, S, Str> SessionRegistry<T, S, Str>
498where
499    S: ClientAuthStore + Send + Sync + 'static,
500    T: OAuthResolver + DpopExt + Send + Sync + 'static,
501    Str: BosStr + FromStr + Ord + Clone,
502    <Str as FromStr>::Err: core::fmt::Debug,
503{
504    pub(crate) fn cache(&self) -> &MokaCache<SessionKey, crate::client::OAuthSession<T, S>> {
505        self.session_cache.get_or_init(|| {
506            MokaCache::builder()
507                .time_to_live(std::time::Duration::from_secs(30))
508                .max_capacity(64)
509                .build()
510        })
511    }
512
513    async fn get_refreshed<D: BosStr + Send + Sync>(
514        &self,
515        did: &Did<D>,
516        session_id: &str,
517    ) -> Result<ClientSessionData, Error> {
518        let key = format_smolstr!("{}_{}", did, session_id);
519        let lock = self
520            .pending
521            .entry(key)
522            .or_insert_with(|| Arc::new(Mutex::new(())))
523            .clone();
524        let _guard = lock.lock().await;
525
526        let session = self
527            .store
528            .get_session(did, session_id)
529            .await?
530            .ok_or(Error::SessionNotFound)?;
531
532        // Check if token is still valid with a 60-second buffer before expiry.
533        // This triggers proactive refresh before the token actually expires,
534        // avoiding the race condition where a token expires mid-request.
535        const EXPIRY_BUFFER_SECS: i64 = 60;
536        if let Some(expires_at) = &session.token_set.expires_at {
537            let now_with_buffer = Datetime::now()
538                .as_ref()
539                .checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS))
540                .map(Datetime::new)
541                .unwrap_or_else(Datetime::now);
542            if expires_at > &now_with_buffer {
543                return Ok(session);
544            }
545        }
546        let metadata =
547            OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?;
548        match refresh(self.client.as_ref(), session, &metadata).await {
549            Ok(refreshed) => {
550                self.store.upsert_session(refreshed.clone()).await?;
551                Ok(refreshed)
552            }
553            Err(e) if e.is_permanent() => {
554                // Session is permanently dead - clean it up
555                let _ = self.store.delete_session(did, session_id).await;
556                Err(Error::RefreshFailed(e))
557            }
558            Err(e) => Err(Error::ServerAgent(e)),
559        }
560    }
561    /// Retrieve a session from the store, optionally refreshing it first.
562    ///
563    /// When `refresh` is `true`, proactively
564    /// renews the token if it is within 60 seconds of expiry. When `false`, returns the session
565    /// data as-is without contacting the authorization server.
566    pub async fn get<D: BosStr + Send + Sync>(
567        &self,
568        did: &Did<D>,
569        session_id: &str,
570        refresh: bool,
571    ) -> Result<ClientSessionData, Error> {
572        if refresh {
573            self.get_refreshed(did, session_id).await
574        } else {
575            // TODO: cached?
576            self.store
577                .get_session(did, session_id)
578                .await?
579                .ok_or(Error::SessionNotFound)
580        }
581    }
582    /// Persist an updated session to the backing store.
583    pub async fn set(&self, value: ClientSessionData) -> Result<(), Error> {
584        self.store.upsert_session(value).await?;
585        Ok(())
586    }
587    /// Delete a session from the backing store.
588    pub async fn del<D: BosStr + Send + Sync>(
589        &self,
590        did: &Did<D>,
591        session_id: &str,
592    ) -> Result<(), Error> {
593        self.store.delete_session(did, session_id).await?;
594        Ok(())
595    }
596}