Skip to main content

bitwarden_access_connector/auth/
session.rs

1//! Session state machine for the access connector.
2//!
3//! ```text
4//! authenticating → active → expired → authenticating  (refresh cycle)
5//!                         → revoked  (terminal: rejected credential)
6//!                         → closed   (terminal: explicit shutdown)
7//! ```
8//!
9//! Entering `Revoked` or `Closed` drops the bearer and clears the key store.
10
11use std::{sync::Arc, time::Duration};
12
13use bitwarden_access_token::AccessToken;
14use bitwarden_core::{OrganizationId, key_management::KeySlotIds};
15use bitwarden_crypto::KeyStore;
16use tokio::{
17    sync::{Mutex, watch},
18    time::Instant,
19};
20
21use crate::{
22    auth::identity::{AuthError, AuthSuccess, IdentityClient},
23    crypto::{CryptoModuleError, unwrap_org_key},
24};
25
26/// Proactive renewal margin, matching bitwarden-auth's `TOKEN_RENEW_MARGIN_SECONDS`.
27const TOKEN_RENEW_MARGIN_SECS: u64 = 5 * 60;
28
29const BACKOFF_BASE: Duration = Duration::from_secs(1);
30
31const BACKOFF_CAP: Duration = Duration::from_secs(30);
32
33/// Renewal attempt count for a `None` deadline.
34const NO_DEADLINE_MAX_TRIES: u32 = 3;
35
36/// Observable phases of the connector session.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub(crate) enum SessionPhase {
39    /// Credential exchange in progress.
40    Authenticating,
41    /// Access token valid; org key installed in the key store.
42    Active,
43    /// A renewal attempt failed without a rejection; renewal will be retried.
44    Expired,
45    /// Terminal: the credential was rejected, so no further auth attempts are made.
46    Revoked,
47    /// Terminal: [`SessionManager::close`] was called.
48    Closed,
49}
50
51/// Terminal session-loss variants exposed to callers.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub(crate) enum SessionLost {
54    /// The session was revoked (credential rejected).
55    Revoked,
56    /// The session was closed via [`SessionManager::close`].
57    Closed,
58}
59
60/// Errors returned by [`SessionManager::bearer`] and [`SessionManager::force_refresh`].
61#[derive(Debug, thiserror::Error)]
62pub(crate) enum SessionError {
63    /// The session is terminally lost (revoked or closed).
64    #[error("session lost: {0:?}")]
65    Lost(SessionLost),
66    /// Renewal failed without a rejection (network, 5xx, malformed response); retryable.
67    #[error("transient renewal error: {0}")]
68    Transient(String),
69}
70
71struct SessionState {
72    phase: SessionPhase,
73    /// The last issued bearer, kept through renewals and cleared only on terminal entry.
74    bearer: Option<String>,
75    /// Monotonic expiry of `bearer`.
76    expires_at: Option<Instant>,
77    /// Shared with the client, so clearing it on terminal entry reaches core too.
78    key_store: KeyStore<KeySlotIds>,
79    /// The organisation from the access token's claim; `None` until the first authentication.
80    organization_id: Option<OrganizationId>,
81    phase_tx: watch::Sender<SessionPhase>,
82}
83
84impl SessionState {
85    /// Whether the stored bearer is within or past the proactive renewal margin.
86    fn needs_renewal(&self) -> bool {
87        match self.expires_at {
88            Some(t) => {
89                let margin = Duration::from_secs(TOKEN_RENEW_MARGIN_SECS);
90                match t.checked_duration_since(Instant::now()) {
91                    Some(remaining) => remaining <= margin,
92                    None => true, // already expired
93                }
94            }
95            None => true,
96        }
97    }
98
99    /// The stored bearer for a session the caller has just seen valid, still under the same guard.
100    /// Without a bearer this is the terminal error for the phase; any other phase is an invariant
101    /// breach (an `Active` session always has a bearer).
102    fn active_bearer(&self) -> Result<String, SessionError> {
103        match (&self.bearer, self.phase) {
104            (Some(bearer), _) => Ok(bearer.clone()),
105            (None, SessionPhase::Revoked) => Err(SessionError::Lost(SessionLost::Revoked)),
106            (None, SessionPhase::Closed) => Err(SessionError::Lost(SessionLost::Closed)),
107            (None, _) => Err(SessionError::Transient("session has no bearer".to_owned())),
108        }
109    }
110
111    fn set_phase(&mut self, phase: SessionPhase) {
112        self.phase = phase;
113        self.phase_tx.send_if_modified(|p| {
114            *p = phase;
115            true
116        });
117    }
118
119    fn apply_success(
120        &mut self,
121        success: AuthSuccess,
122        token: &AccessToken,
123    ) -> Result<(), CryptoModuleError> {
124        use bitwarden_sensitive_value::ExposeSensitive as _;
125
126        let expires_at = Instant::now() + Duration::from_secs(success.expires_in);
127
128        unwrap_org_key(
129            &self.key_store,
130            success.organization_id,
131            token,
132            &success.encrypted_payload,
133        )?;
134
135        self.bearer = Some(success.access_token.expose().to_owned());
136        self.expires_at = Some(expires_at);
137        self.organization_id = Some(success.organization_id);
138        self.set_phase(SessionPhase::Active);
139        Ok(())
140    }
141
142    /// Enter a terminal phase, dropping the bearer and the org key.
143    fn enter_terminal(&mut self, lost: SessionLost) {
144        let phase = match lost {
145            SessionLost::Revoked => SessionPhase::Revoked,
146            SessionLost::Closed => SessionPhase::Closed,
147        };
148        self.set_phase(phase);
149        self.bearer = None;
150        self.expires_at = None;
151        // Clear in place rather than swap, so every clone of the store loses the org key.
152        self.key_store.clear();
153    }
154}
155
156/// Manages the connector session lifecycle. All mutable state sits behind one async `Mutex`, so at
157/// most one renewal is in flight. `Debug` is hand-written to avoid leaking the token or bearer.
158pub(crate) struct SessionManager {
159    state: Mutex<SessionState>,
160    identity: IdentityClient,
161    token: AccessToken,
162}
163
164impl std::fmt::Debug for SessionManager {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("SessionManager").finish_non_exhaustive()
167    }
168}
169
170impl SessionManager {
171    /// Build the manager and authenticate. A rejected credential returns
172    /// `Err(SessionError::Lost(Revoked))`, which callers treat as a fatal startup failure.
173    /// `key_store` is the client's, which the org key is installed into.
174    pub(crate) async fn new(
175        identity: IdentityClient,
176        token: AccessToken,
177        key_store: KeyStore<KeySlotIds>,
178    ) -> Result<Arc<Self>, SessionError> {
179        let (phase_tx, _phase_rx) = watch::channel(SessionPhase::Authenticating);
180
181        let state = SessionState {
182            phase: SessionPhase::Authenticating,
183            bearer: None,
184            expires_at: None,
185            key_store,
186            organization_id: None,
187            phase_tx,
188        };
189
190        let mgr = Arc::new(Self {
191            state: Mutex::new(state),
192            identity,
193            token,
194        });
195
196        mgr.renew_with_backoff(None, None).await?;
197
198        Ok(mgr)
199    }
200
201    pub(crate) async fn phase(&self) -> SessionPhase {
202        self.state.lock().await.phase
203    }
204
205    /// Test accessor for the held key store.
206    #[cfg(test)]
207    pub(crate) async fn key_store(&self) -> KeyStore<KeySlotIds> {
208        self.state.lock().await.key_store.clone()
209    }
210
211    /// `None` only before the first authentication, which [`Self::new`] has already performed.
212    pub(crate) async fn organization_id(&self) -> Option<OrganizationId> {
213        self.state.lock().await.organization_id
214    }
215
216    /// Obtain a valid bearer, renewing as needed. Retries transient errors with capped backoff up
217    /// to `deadline` ([`NO_DEADLINE_MAX_TRIES`] caps a `None` deadline); a rejected credential
218    /// clears secrets and returns `Err(Lost(Revoked))`.
219    pub(crate) async fn bearer(&self, deadline: Option<Instant>) -> Result<String, SessionError> {
220        self.renew_with_backoff(deadline, None).await
221    }
222
223    /// Force a session refresh on the 401 path. A bearer that no longer matches `stale` was already
224    /// renewed concurrently and is returned as-is, as in bitwarden-auth's `resolve_retry`.
225    pub(crate) async fn force_refresh(
226        &self,
227        stale: &str,
228        deadline: Option<Instant>,
229    ) -> Result<String, SessionError> {
230        self.renew_with_backoff(deadline, Some(stale)).await
231    }
232
233    /// Transition to `Closed`, dropping the bearer and the org key.
234    pub(crate) async fn close(&self) {
235        let mut guard = self.state.lock().await;
236        if !matches!(guard.phase, SessionPhase::Revoked | SessionPhase::Closed) {
237            guard.enter_terminal(SessionLost::Closed);
238        }
239    }
240
241    /// Return the stored bearer if it is fresh and not `stale`, otherwise renew with backoff. The
242    /// check runs under the same guard as the renewal, so concurrent callers coalesce onto one
243    /// renewal and a concurrent `close()` cannot slip in between. The mutex is held across the
244    /// identity call and released during the backoff sleep.
245    async fn renew_with_backoff(
246        &self,
247        deadline: Option<Instant>,
248        stale: Option<&str>,
249    ) -> Result<String, SessionError> {
250        let mut delay = BACKOFF_BASE;
251        let mut tries: u32 = 0;
252
253        loop {
254            let mut guard = self.state.lock().await;
255
256            match guard.phase {
257                SessionPhase::Revoked => return Err(SessionError::Lost(SessionLost::Revoked)),
258                SessionPhase::Closed => return Err(SessionError::Lost(SessionLost::Closed)),
259                SessionPhase::Active
260                    if !guard.needs_renewal() && guard.bearer.as_deref() != stale =>
261                {
262                    return guard.active_bearer();
263                }
264                _ => {}
265            }
266
267            guard.set_phase(SessionPhase::Authenticating);
268
269            // Other callers block on the mutex until this attempt finishes.
270            let result = self.identity.authenticate(&self.token).await;
271
272            let (kind, err_msg) = match result {
273                Ok(success) => match guard.apply_success(success, &self.token) {
274                    Ok(()) => {
275                        tracing::info!(retry = tries, "session authenticated");
276                        return guard.active_bearer();
277                    }
278                    // A 200 whose payload does not open is treated like a protocol failure.
279                    Err(e) => ("org key unwrap failed", e.to_string()),
280                },
281                Err(AuthError::Rejected) => {
282                    guard.enter_terminal(SessionLost::Revoked);
283                    tracing::warn!(
284                        "session entered Revoked phase (credential rejected by identity server)"
285                    );
286                    return Err(SessionError::Lost(SessionLost::Revoked));
287                }
288                Err(AuthError::Transient(msg)) => ("transient", msg),
289                Err(AuthError::Protocol) => (
290                    "protocol error",
291                    "identity server returned unexpected response".to_owned(),
292                ),
293            };
294
295            tries += 1;
296
297            // Set Expired so phase-watchers see a transient stall.
298            guard.set_phase(SessionPhase::Expired);
299            drop(guard);
300
301            if deadline.is_none() && tries >= NO_DEADLINE_MAX_TRIES {
302                tracing::warn!(
303                    retry = tries,
304                    "session renewal failed ({kind}): {err_msg}; giving up after {tries} attempts"
305                );
306                return Err(SessionError::Transient(err_msg));
307            }
308
309            let sleep_dur = match deadline {
310                None => delay,
311                Some(dl) => {
312                    let remaining = dl.saturating_duration_since(Instant::now());
313                    if remaining.is_zero() {
314                        tracing::warn!(
315                            retry = tries,
316                            "session renewal deadline exceeded before retry; last error: {err_msg}"
317                        );
318                        return Err(SessionError::Transient(
319                            "renewal deadline exceeded".to_owned(),
320                        ));
321                    }
322                    delay.min(remaining)
323                }
324            };
325
326            tracing::warn!(
327                retry = tries,
328                sleep_ms = sleep_dur.as_millis(),
329                "session renewal failed ({kind}): {err_msg}; retrying after {sleep_dur:?}"
330            );
331            tokio::time::sleep(sleep_dur).await;
332            delay = (delay * 2).min(BACKOFF_CAP);
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use std::time::Duration;
340
341    use bitwarden_crypto::{
342        KeyDecryptable, PrimitiveEncryptable, SymmetricCryptoKey, SymmetricKeyAlgorithm,
343    };
344    use wiremock::{
345        Mock, MockServer, ResponseTemplate,
346        matchers::{method, path},
347    };
348
349    use super::*;
350    use crate::{
351        auth::identity::IdentityClient,
352        test_support::{
353            access_token_tagged, encrypted_payload_for, test_org_id, test_org_slot as org_slot,
354            test_token,
355        },
356    };
357
358    /// `bearer` is a short tag, expanded to a real JWT so the `organization` claim parses.
359    fn success_response(
360        bearer: &str,
361        expires_in: u64,
362        encrypted_payload: &str,
363    ) -> ResponseTemplate {
364        let bearer = access_token_tagged(test_org_id(), bearer);
365        let body = format!(
366            r#"{{"access_token":"{bearer}","expires_in":{expires_in},"encrypted_payload":"{encrypted_payload}"}}"#
367        );
368        ResponseTemplate::new(200)
369            .set_body_string(body)
370            .insert_header("content-type", "application/json")
371    }
372
373    fn rejected_response() -> ResponseTemplate {
374        ResponseTemplate::new(400)
375            .set_body_string(r#"{"error":"invalid_client"}"#)
376            .insert_header("content-type", "application/json")
377    }
378
379    #[tokio::test]
380    async fn successful_auth_populates_bearer_and_org_key() {
381        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
382        let encrypted_payload = encrypted_payload_for(&org_key);
383
384        let server = MockServer::start().await;
385        Mock::given(method("POST"))
386            .and(path("/connect/token"))
387            .respond_with(success_response("my-bearer", 3600, &encrypted_payload))
388            .mount(&server)
389            .await;
390
391        let client = IdentityClient::new(server.uri()).expect("client");
392        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
393            .await
394            .expect("SessionManager::new");
395
396        assert_eq!(mgr.phase().await, SessionPhase::Active);
397
398        let bearer = mgr.bearer(None).await.expect("bearer");
399        assert_eq!(bearer, access_token_tagged(test_org_id(), "my-bearer"));
400        assert_eq!(server.received_requests().await.unwrap().len(), 1);
401
402        // Probe that the org key is installed.
403        let store = mgr.key_store().await;
404        let probe_enc = {
405            let mut ctx = store.context();
406            "probe-value"
407                .encrypt(&mut ctx, org_slot())
408                .expect("encrypt probe")
409        };
410        let decrypted: String = probe_enc.decrypt_with_key(&org_key).expect("decrypt probe");
411        assert_eq!(decrypted, "probe-value");
412    }
413
414    #[tokio::test]
415    async fn expiry_margin_triggers_renewal() {
416        // expires_in=0 puts the first token inside the renewal margin at once.
417        let org_key1 = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
418        let payload1 = encrypted_payload_for(&org_key1);
419        let org_key2 = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
420        let payload2 = encrypted_payload_for(&org_key2);
421
422        let server = MockServer::start().await;
423        Mock::given(method("POST"))
424            .and(path("/connect/token"))
425            .respond_with(success_response("first-token", 0, &payload1))
426            .up_to_n_times(1)
427            .mount(&server)
428            .await;
429        Mock::given(method("POST"))
430            .and(path("/connect/token"))
431            .respond_with(success_response("second-token", 3600, &payload2))
432            .mount(&server)
433            .await;
434
435        let client = IdentityClient::new(server.uri()).expect("client");
436        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
437            .await
438            .expect("SessionManager::new");
439
440        let bearer = mgr.bearer(None).await.expect("bearer");
441        assert_eq!(bearer, access_token_tagged(test_org_id(), "second-token"));
442        assert_eq!(server.received_requests().await.unwrap().len(), 2);
443    }
444
445    #[tokio::test]
446    async fn concurrent_bearer_calls_coalesce_to_single_renewal() {
447        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
448        let payload = encrypted_payload_for(&org_key);
449
450        let server = MockServer::start().await;
451        // Initial auth: expires immediately.
452        Mock::given(method("POST"))
453            .and(path("/connect/token"))
454            .respond_with(success_response("initial-tok", 0, &payload))
455            .up_to_n_times(1)
456            .mount(&server)
457            .await;
458
459        let org_key2 = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
460        let payload2 = encrypted_payload_for(&org_key2);
461        // The delay makes the concurrent callers overlap.
462        Mock::given(method("POST"))
463            .and(path("/connect/token"))
464            .respond_with(
465                success_response("renewed-tok", 3600, &payload2)
466                    .set_delay(Duration::from_millis(50)),
467            )
468            .mount(&server)
469            .await;
470
471        let client = IdentityClient::new(server.uri()).expect("client");
472        let mgr = Arc::new(
473            SessionManager::new(client, test_token(), KeyStore::default())
474                .await
475                .expect("SessionManager::new"),
476        );
477
478        let handles: Vec<_> = (0..5)
479            .map(|_| {
480                let m = Arc::clone(&mgr);
481                tokio::spawn(async move { m.bearer(None).await })
482            })
483            .collect();
484
485        for h in handles {
486            assert_eq!(
487                h.await.expect("spawn").expect("bearer"),
488                access_token_tagged(test_org_id(), "renewed-tok")
489            );
490        }
491
492        // Initial auth plus one renewal.
493        assert_eq!(server.received_requests().await.unwrap().len(), 2);
494    }
495
496    #[tokio::test]
497    async fn invalid_client_at_startup_returns_revoked() {
498        let server = MockServer::start().await;
499        Mock::given(method("POST"))
500            .and(path("/connect/token"))
501            .respond_with(rejected_response())
502            .mount(&server)
503            .await;
504
505        let client = IdentityClient::new(server.uri()).expect("client");
506        let result = SessionManager::new(client, test_token(), KeyStore::default()).await;
507
508        assert!(
509            matches!(result, Err(SessionError::Lost(SessionLost::Revoked))),
510            "expected Lost(Revoked), got {result:?}"
511        );
512    }
513
514    #[tokio::test]
515    async fn revoked_session_short_circuits_without_identity_hits() {
516        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
517        let payload = encrypted_payload_for(&org_key);
518
519        let server = MockServer::start().await;
520        Mock::given(method("POST"))
521            .and(path("/connect/token"))
522            .respond_with(success_response("tok", 3600, &payload))
523            .up_to_n_times(1)
524            .mount(&server)
525            .await;
526        Mock::given(method("POST"))
527            .and(path("/connect/token"))
528            .respond_with(rejected_response())
529            .mount(&server)
530            .await;
531
532        let client = IdentityClient::new(server.uri()).expect("client");
533        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
534            .await
535            .expect("first auth succeeds");
536
537        // Trigger revocation via force_refresh.
538        let _ = mgr
539            .force_refresh(&access_token_tagged(test_org_id(), "tok"), None)
540            .await;
541        assert_eq!(mgr.phase().await, SessionPhase::Revoked);
542
543        let count_before = server.received_requests().await.unwrap().len();
544
545        let err = mgr.bearer(None).await.expect_err("should be lost");
546        assert!(matches!(err, SessionError::Lost(SessionLost::Revoked)));
547
548        assert_eq!(
549            server.received_requests().await.unwrap().len(),
550            count_before
551        );
552    }
553
554    #[tokio::test]
555    async fn close_transitions_to_closed_and_short_circuits() {
556        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
557        let payload = encrypted_payload_for(&org_key);
558
559        let server = MockServer::start().await;
560        Mock::given(method("POST"))
561            .and(path("/connect/token"))
562            .respond_with(success_response("tok", 3600, &payload))
563            .mount(&server)
564            .await;
565
566        let client = IdentityClient::new(server.uri()).expect("client");
567        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
568            .await
569            .expect("auth");
570
571        mgr.close().await;
572        assert_eq!(mgr.phase().await, SessionPhase::Closed);
573
574        let count_before = server.received_requests().await.unwrap().len();
575        let err = mgr.bearer(None).await.expect_err("should be lost");
576        assert!(matches!(err, SessionError::Lost(SessionLost::Closed)));
577        assert_eq!(
578            server.received_requests().await.unwrap().len(),
579            count_before
580        );
581    }
582
583    #[tokio::test]
584    async fn secrets_cleared_on_revoked() {
585        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
586        let payload = encrypted_payload_for(&org_key);
587
588        let server = MockServer::start().await;
589        Mock::given(method("POST"))
590            .and(path("/connect/token"))
591            .respond_with(success_response("tok", 3600, &payload))
592            .up_to_n_times(1)
593            .mount(&server)
594            .await;
595        Mock::given(method("POST"))
596            .and(path("/connect/token"))
597            .respond_with(rejected_response())
598            .mount(&server)
599            .await;
600
601        let client = IdentityClient::new(server.uri()).expect("client");
602        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
603            .await
604            .expect("first auth");
605
606        let _ = mgr
607            .force_refresh(&access_token_tagged(test_org_id(), "tok"), None)
608            .await;
609        assert_eq!(mgr.phase().await, SessionPhase::Revoked);
610
611        let err = mgr.bearer(None).await.expect_err("must be lost");
612        assert!(matches!(err, SessionError::Lost(SessionLost::Revoked)));
613
614        let store = mgr.key_store().await;
615        assert!(
616            !store.context().has_symmetric_key(org_slot()),
617            "org key slot must be cleared after revocation"
618        );
619    }
620
621    /// A key-store clone taken before revocation must also lose the org key.
622    #[tokio::test]
623    async fn key_store_handle_taken_before_revocation_loses_the_org_key() {
624        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
625        let payload = encrypted_payload_for(&org_key);
626
627        let server = MockServer::start().await;
628        Mock::given(method("POST"))
629            .and(path("/connect/token"))
630            .respond_with(success_response("tok", 3600, &payload))
631            .up_to_n_times(1)
632            .mount(&server)
633            .await;
634        Mock::given(method("POST"))
635            .and(path("/connect/token"))
636            .respond_with(rejected_response())
637            .mount(&server)
638            .await;
639
640        let client = IdentityClient::new(server.uri()).expect("client");
641        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
642            .await
643            .expect("first auth");
644
645        // Taken before revocation.
646        let hoisted = mgr.key_store().await;
647        assert!(
648            hoisted.context().has_symmetric_key(org_slot()),
649            "org key must be installed before revocation"
650        );
651
652        let _ = mgr
653            .force_refresh(&access_token_tagged(test_org_id(), "tok"), None)
654            .await;
655        assert_eq!(mgr.phase().await, SessionPhase::Revoked);
656
657        assert!(
658            !hoisted.context().has_symmetric_key(org_slot()),
659            "a handle taken before revocation must not still hold the org key"
660        );
661    }
662
663    /// The same, for the shutdown path.
664    #[tokio::test]
665    async fn key_store_handle_taken_before_close_loses_the_org_key() {
666        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
667        let payload = encrypted_payload_for(&org_key);
668
669        let server = MockServer::start().await;
670        Mock::given(method("POST"))
671            .and(path("/connect/token"))
672            .respond_with(success_response("tok", 3600, &payload))
673            .mount(&server)
674            .await;
675
676        let client = IdentityClient::new(server.uri()).expect("client");
677        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
678            .await
679            .expect("auth");
680
681        let hoisted = mgr.key_store().await;
682        assert!(hoisted.context().has_symmetric_key(org_slot()));
683
684        mgr.close().await;
685
686        assert!(
687            !hoisted.context().has_symmetric_key(org_slot()),
688            "a handle taken before close must not still hold the org key"
689        );
690    }
691
692    #[tokio::test]
693    async fn secrets_cleared_on_closed() {
694        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
695        let payload = encrypted_payload_for(&org_key);
696
697        let server = MockServer::start().await;
698        Mock::given(method("POST"))
699            .and(path("/connect/token"))
700            .respond_with(success_response("tok", 3600, &payload))
701            .mount(&server)
702            .await;
703
704        let client = IdentityClient::new(server.uri()).expect("client");
705        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
706            .await
707            .expect("auth");
708
709        mgr.close().await;
710
711        let store = mgr.key_store().await;
712        assert!(
713            !store.context().has_symmetric_key(org_slot()),
714            "org key slot must be cleared after close"
715        );
716    }
717
718    #[tokio::test]
719    async fn force_refresh_reuses_token_if_already_renewed() {
720        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
721        let payload = encrypted_payload_for(&org_key);
722
723        let server = MockServer::start().await;
724        Mock::given(method("POST"))
725            .and(path("/connect/token"))
726            .respond_with(success_response("fresh-tok", 3600, &payload))
727            .mount(&server)
728            .await;
729
730        let client = IdentityClient::new(server.uri()).expect("client");
731        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
732            .await
733            .expect("auth");
734
735        let count_after_init = server.received_requests().await.unwrap().len();
736
737        // Stored token is "fresh-tok"; "old-stale-value" differs, so it's reused.
738        let result = mgr
739            .force_refresh("old-stale-value", None)
740            .await
741            .expect("force_refresh");
742        assert_eq!(result, access_token_tagged(test_org_id(), "fresh-tok"));
743        assert_eq!(
744            server.received_requests().await.unwrap().len(),
745            count_after_init,
746            "no additional identity call should be made"
747        );
748    }
749
750    #[tokio::test]
751    async fn concurrent_force_refresh_with_same_stale_bearer_renews_once() {
752        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
753        let payload = encrypted_payload_for(&org_key);
754
755        let server = MockServer::start().await;
756        Mock::given(method("POST"))
757            .and(path("/connect/token"))
758            .respond_with(success_response("initial-tok", 3600, &payload))
759            .up_to_n_times(1)
760            .mount(&server)
761            .await;
762        // The delay makes the concurrent callers overlap.
763        Mock::given(method("POST"))
764            .and(path("/connect/token"))
765            .respond_with(
766                success_response("renewed-tok", 3600, &payload)
767                    .set_delay(Duration::from_millis(50)),
768            )
769            .mount(&server)
770            .await;
771
772        let client = IdentityClient::new(server.uri()).expect("client");
773        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
774            .await
775            .expect("SessionManager::new");
776
777        // Hold the state lock until every caller is queued on it, so they all observe the same
778        // stale bearer before any renewal runs.
779        let guard = mgr.state.lock().await;
780        let stale = access_token_tagged(test_org_id(), "initial-tok");
781        let handles: Vec<_> = (0..5)
782            .map(|_| {
783                let m = Arc::clone(&mgr);
784                let stale = stale.clone();
785                tokio::spawn(async move { m.force_refresh(&stale, None).await })
786            })
787            .collect();
788        tokio::time::sleep(Duration::from_millis(20)).await;
789        drop(guard);
790
791        for h in handles {
792            assert_eq!(
793                h.await.expect("spawn").expect("force_refresh"),
794                access_token_tagged(test_org_id(), "renewed-tok")
795            );
796        }
797
798        // Initial auth plus one renewal.
799        assert_eq!(server.received_requests().await.unwrap().len(), 2);
800    }
801
802    #[tokio::test]
803    async fn org_key_re_derived_on_every_refresh() {
804        let org_key1 = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
805        let payload1 = encrypted_payload_for(&org_key1);
806        let org_key2 = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
807        let payload2 = encrypted_payload_for(&org_key2);
808
809        let server = MockServer::start().await;
810        Mock::given(method("POST"))
811            .and(path("/connect/token"))
812            .respond_with(success_response("tok1", 0, &payload1))
813            .up_to_n_times(1)
814            .mount(&server)
815            .await;
816        Mock::given(method("POST"))
817            .and(path("/connect/token"))
818            .respond_with(success_response("tok2", 3600, &payload2))
819            .mount(&server)
820            .await;
821
822        let client = IdentityClient::new(server.uri()).expect("client");
823        let mgr = SessionManager::new(client, test_token(), KeyStore::default())
824            .await
825            .expect("first auth");
826
827        // Trigger renewal (expired token).
828        let _bearer2 = mgr.bearer(None).await.expect("second bearer");
829
830        let store = mgr.key_store().await;
831        let probe_enc = {
832            let mut ctx = store.context();
833            "check-key".encrypt(&mut ctx, org_slot()).expect("encrypt")
834        };
835
836        let ok: Result<String, _> = probe_enc.decrypt_with_key(&org_key2);
837        assert!(ok.is_ok(), "probe must decrypt under the new org key");
838        let fail: Result<String, _> = probe_enc.decrypt_with_key(&org_key1);
839        assert!(
840            fail.is_err(),
841            "probe must NOT decrypt under the old org key"
842        );
843    }
844
845    /// A 200 whose `encrypted_payload` does not open must go through the same backoff and
846    /// `Expired` handling as other renewal failures, not return at once.
847    #[tokio::test]
848    async fn org_key_unwrap_failure_retries_with_backoff_and_expires() {
849        let server = MockServer::start().await;
850        Mock::given(method("POST"))
851            .and(path("/connect/token"))
852            .respond_with(success_response("tok", 3600, "not-a-valid-payload"))
853            .mount(&server)
854            .await;
855
856        let client = IdentityClient::new(server.uri()).expect("client");
857        let (phase_tx, _phase_rx) = watch::channel(SessionPhase::Authenticating);
858        let mgr = SessionManager {
859            state: Mutex::new(SessionState {
860                phase: SessionPhase::Authenticating,
861                bearer: None,
862                expires_at: None,
863                key_store: KeyStore::default(),
864                organization_id: None,
865                phase_tx,
866            }),
867            identity: client,
868            token: test_token(),
869        };
870
871        // Real time: the HTTP mock does not work under paused time. A short deadline bounds the
872        // backoff.
873        let deadline = Instant::now() + Duration::from_millis(1500);
874        let err = mgr
875            .bearer(Some(deadline))
876            .await
877            .expect_err("unwrap must fail");
878        assert!(
879            matches!(err, SessionError::Transient(_)),
880            "expected Transient, got {err:?}"
881        );
882        assert!(
883            server.received_requests().await.unwrap().len() > 1,
884            "must retry with backoff"
885        );
886        assert_eq!(mgr.phase().await, SessionPhase::Expired);
887    }
888
889    #[test]
890    fn active_bearer_maps_phase_and_bearer() {
891        let state = |phase, bearer: Option<&str>| {
892            let (phase_tx, _phase_rx) = watch::channel(phase);
893            SessionState {
894                phase,
895                bearer: bearer.map(str::to_owned),
896                expires_at: None,
897                key_store: KeyStore::default(),
898                organization_id: None,
899                phase_tx,
900            }
901        };
902
903        assert_eq!(
904            state(SessionPhase::Active, Some("x"))
905                .active_bearer()
906                .expect("bearer"),
907            "x"
908        );
909        assert!(matches!(
910            state(SessionPhase::Closed, None).active_bearer(),
911            Err(SessionError::Lost(SessionLost::Closed))
912        ));
913        assert!(matches!(
914            state(SessionPhase::Revoked, None).active_bearer(),
915            Err(SessionError::Lost(SessionLost::Revoked))
916        ));
917        assert!(matches!(
918            state(SessionPhase::Active, None).active_bearer(),
919            Err(SessionError::Transient(_))
920        ));
921    }
922}