Skip to main content

stack_auth/
token.rs

1use cts_common::claims::Claims;
2use cts_common::{Crn, Region, WorkspaceId};
3use url::Url;
4
5use crate::{http_client, AuthError, SecretToken};
6
7#[cfg(not(target_arch = "wasm32"))]
8impl stack_profile::ProfileData for Token {
9    const FILENAME: &'static str = "auth.json";
10    const MODE: Option<u32> = Some(0o600);
11}
12
13/// How many seconds before expiry [`Token::is_expired`] returns `true`.
14///
15/// This leeway triggers preemptive refresh well before the token becomes
16/// unusable, giving the HTTP refresh call time to complete while concurrent
17/// callers can still use the current token.
18const EXPIRY_LEEWAY_SECS: u64 = 90;
19
20/// The current Unix time in whole seconds, from the system wall clock.
21///
22/// Delegates to [`SystemClock`](crate::clock::SystemClock) so the crate has a
23/// single definition of "now"; the `*_at` methods take an explicit `now` for
24/// tests that drive a [`Clock`](crate::clock::Clock).
25fn now_unix_secs() -> u64 {
26    use crate::clock::{Clock, SystemClock};
27    SystemClock.now_unix_secs()
28}
29
30/// An access token returned by a successful authentication flow.
31///
32/// The token contains a [`SecretToken`] (the bearer credential), a token type
33/// (typically `"Bearer"`), and an absolute expiry timestamp.
34#[derive(Debug, serde::Serialize, serde::Deserialize)]
35pub struct Token {
36    pub(crate) access_token: SecretToken,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub(crate) refresh_token: Option<SecretToken>,
39    pub(crate) token_type: String,
40    pub(crate) expires_at: u64,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub(crate) region: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub(crate) client_id: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub(crate) device_instance_id: Option<String>,
47}
48
49impl Token {
50    /// Returns a reference to the access token credential.
51    ///
52    /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked.
53    /// Pass it to API clients that need the raw bearer token.
54    pub fn access_token(&self) -> &SecretToken {
55        &self.access_token
56    }
57
58    /// The token type (e.g. `"Bearer"`).
59    pub fn token_type(&self) -> &str {
60        &self.token_type
61    }
62
63    /// The absolute epoch timestamp when the token expires.
64    pub fn expires_at(&self) -> u64 {
65        self.expires_at
66    }
67
68    /// How many seconds until the token expires (computed from the current time).
69    pub fn expires_in(&self) -> u64 {
70        self.expires_at.saturating_sub(now_unix_secs())
71    }
72
73    /// Returns `true` if the token has expired (with 90 seconds of leeway).
74    ///
75    /// The 90-second leeway triggers preemptive refresh well before the token
76    /// becomes unusable, giving the HTTP refresh call plenty of time to complete
77    /// while the current token is still valid for concurrent callers.
78    ///
79    /// For checking whether the token is still usable as a bearer credential,
80    /// use [`is_usable`](Self::is_usable) instead.
81    pub fn is_expired(&self) -> bool {
82        self.is_expired_at(now_unix_secs())
83    }
84
85    /// [`is_expired`](Self::is_expired) evaluated against an explicit `now`
86    /// (seconds since the Unix epoch) rather than the wall clock.
87    ///
88    /// Used internally so [`AutoRefresh`](crate::auto_refresh::AutoRefresh) can
89    /// drive expiry from an injected [`Clock`](crate::clock::Clock).
90    pub(crate) fn is_expired_at(&self, now: u64) -> bool {
91        now.saturating_add(EXPIRY_LEEWAY_SECS) >= self.expires_at
92    }
93
94    /// Returns `true` if the token is still usable (before the actual expiry timestamp).
95    ///
96    /// Unlike [`is_expired`](Self::is_expired) which includes 90s leeway for preemptive
97    /// refresh, this only returns `false` when the token has genuinely expired.
98    pub fn is_usable(&self) -> bool {
99        self.is_usable_at(now_unix_secs())
100    }
101
102    /// [`is_usable`](Self::is_usable) evaluated against an explicit `now`
103    /// (seconds since the Unix epoch) rather than the wall clock.
104    pub(crate) fn is_usable_at(&self, now: u64) -> bool {
105        now < self.expires_at
106    }
107
108    /// Returns a reference to the refresh token, if one was provided.
109    pub fn refresh_token(&self) -> Option<&SecretToken> {
110        self.refresh_token.as_ref()
111    }
112
113    /// Takes the refresh token out, leaving `None` in its place.
114    pub fn take_refresh_token(&mut self) -> Option<SecretToken> {
115        self.refresh_token.take()
116    }
117
118    /// Returns the stored region identifier, if any.
119    pub fn region(&self) -> Option<&str> {
120        self.region.as_deref()
121    }
122
123    /// Returns the stored client ID, if any.
124    pub fn client_id(&self) -> Option<&str> {
125        self.client_id.as_deref()
126    }
127
128    /// Set the region identifier on this token.
129    pub(crate) fn set_region(&mut self, region: impl Into<String>) {
130        self.region = Some(region.into());
131    }
132
133    /// Set the client ID on this token.
134    pub(crate) fn set_client_id(&mut self, client_id: impl Into<String>) {
135        self.client_id = Some(client_id.into());
136    }
137
138    /// Returns the stored device instance ID, if any.
139    pub fn device_instance_id(&self) -> Option<&str> {
140        self.device_instance_id.as_deref()
141    }
142
143    /// Set the device instance ID on this token.
144    pub(crate) fn set_device_instance_id(&mut self, id: impl Into<String>) {
145        self.device_instance_id = Some(id.into());
146    }
147
148    /// Returns the workspace ID from the JWT claims.
149    ///
150    /// The access token is decoded (without signature verification) to extract
151    /// the `workspace` claim.
152    pub fn workspace_id(&self) -> Result<WorkspaceId, AuthError> {
153        self.decode_claims().map(|c| c.workspace)
154    }
155
156    /// Returns the workspace CRN derived from the token's region and workspace ID.
157    ///
158    /// The region is set during the device code flow, and the workspace ID is
159    /// extracted from the JWT `workspace` claim.
160    pub fn workspace_crn(&self) -> Result<Crn, AuthError> {
161        let workspace_id = self.workspace_id()?;
162        let region: Region = self
163            .region()
164            .ok_or(AuthError::NotAuthenticated)?
165            .parse()
166            .map_err(|e: cts_common::RegionError| AuthError::Server(e.to_string()))?;
167        Ok(Crn::new(region, workspace_id))
168    }
169
170    /// Returns the issuer URL from the JWT claims.
171    ///
172    /// The `iss` claim in CipherStash tokens is the CTS host URL for the
173    /// workspace, so this can be used directly as the CTS base URL.
174    pub fn issuer(&self) -> Result<Url, AuthError> {
175        let claims = self.decode_claims()?;
176        claims.iss.parse().map_err(AuthError::from)
177    }
178
179    /// Decode the JWT payload into [`Claims`] without verifying the signature.
180    ///
181    /// This is safe because we already possess the token — we just need to read
182    /// the claims it contains.
183    #[cfg(not(target_arch = "wasm32"))]
184    fn decode_claims(&self) -> Result<Claims, AuthError> {
185        use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
186        use std::collections::HashSet;
187
188        let token_str = self.access_token.as_str();
189        let header = decode_header(token_str)
190            .map_err(|e| AuthError::InvalidToken(format!("invalid JWT header: {e}")))?;
191
192        let dummy_key = DecodingKey::from_secret(&[]);
193        let mut validation = Validation::new(header.alg);
194        validation.validate_exp = false;
195        validation.validate_aud = false;
196        validation.required_spec_claims = HashSet::new();
197        validation.insecure_disable_signature_validation();
198
199        decode(token_str, &dummy_key, &validation)
200            .map(|data| data.claims)
201            .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}")))
202    }
203
204    /// Wasm32 path: decode the JWT payload by splitting + base64 + JSON. We
205    /// don't need the cryptographic backing of `jsonwebtoken` (which pulls
206    /// `ring`) because we only ever read claims from a token we already hold;
207    /// signature validation is `insecure_disable_signature_validation()` on
208    /// native too.
209    #[cfg(target_arch = "wasm32")]
210    fn decode_claims(&self) -> Result<Claims, AuthError> {
211        crate::decode_jwt_payload_wasm(self.access_token.as_str())
212    }
213
214    /// Fuzz-only entry point: run the JWT claims decode (`Token::decode_claims`)
215    /// over an arbitrary string, discarding the claims and keeping only whether it
216    /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds.
217    /// Reading claims from a token we already hold must never panic on a malformed
218    /// token — only return `Err`. See `packages/stack-auth/fuzz`.
219    ///
220    /// `#[doc(hidden)]`: the `doc:stack-auth` task builds with `--all-features`,
221    /// which enables `fuzz` — this keeps the shim out of the generated public docs.
222    #[cfg(feature = "fuzz")]
223    #[doc(hidden)]
224    pub fn fuzz_decode_claims(token: &str) -> Result<(), AuthError> {
225        Token {
226            access_token: SecretToken::new(token),
227            token_type: String::new(),
228            expires_at: 0,
229            refresh_token: None,
230            region: None,
231            client_id: None,
232            device_instance_id: None,
233        }
234        .decode_claims()
235        .map(|_| ())
236    }
237
238    /// Exchange a refresh token for a new [`Token`] via the `/oauth/token`
239    /// endpoint.
240    ///
241    /// This is a static constructor — it takes a bare [`SecretToken`] (the
242    /// refresh token) rather than operating on an existing `Token`. This
243    /// allows callers to manage the refresh token lifecycle independently
244    /// (e.g. taking it out of a cached token for cascade prevention and
245    /// restoring it on failure).
246    ///
247    /// # Errors
248    ///
249    /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired.
250    /// - [`AuthError::InvalidClient`] — the client ID is not recognized.
251    /// - [`AuthError::Request`] — a network error occurred.
252    pub async fn refresh(
253        refresh_token: &SecretToken,
254        base_url: &Url,
255        client_id: &str,
256        device_instance_id: Option<&str>,
257    ) -> Result<Token, AuthError> {
258        let token_url = base_url.join("oauth/token")?;
259
260        tracing::debug!(url = %token_url, "refreshing token");
261
262        let resp = http_client()
263            .post(token_url)
264            .form(&RefreshRequest {
265                grant_type: "refresh_token",
266                client_id,
267                refresh_token: refresh_token.as_str(),
268                device_instance_id,
269            })
270            .send()
271            .await?;
272
273        if !resp.status().is_success() {
274            let err: RefreshErrorResponse = resp.json().await?;
275            tracing::debug!(error = %err.error, "token refresh failed");
276            return Err(match err.error.as_str() {
277                "invalid_grant" => AuthError::InvalidGrant,
278                "invalid_client" => AuthError::InvalidClient,
279                "access_denied" => AuthError::AccessDenied,
280                _ => AuthError::Server(err.error_description),
281            });
282        }
283
284        let token_resp: RefreshResponse = resp.json().await?;
285
286        Ok(Token {
287            access_token: token_resp.access_token,
288            token_type: token_resp.token_type,
289            expires_at: now_unix_secs() + token_resp.expires_in,
290            refresh_token: token_resp.refresh_token,
291            region: None,
292            client_id: None,
293            // TODO(CIP-2793): The server should include device_instance_id in the
294            // refresh response. Until then, callers (e.g. DeviceSessionRefresher) must
295            // re-attach it manually after refresh.
296            device_instance_id: None,
297        })
298    }
299}
300
301#[derive(serde::Serialize)]
302struct RefreshRequest<'a> {
303    grant_type: &'a str,
304    client_id: &'a str,
305    refresh_token: &'a str,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    device_instance_id: Option<&'a str>,
308}
309
310#[derive(serde::Deserialize)]
311struct RefreshResponse {
312    access_token: SecretToken,
313    token_type: String,
314    expires_in: u64,
315    #[serde(default)]
316    refresh_token: Option<SecretToken>,
317}
318
319#[derive(serde::Deserialize)]
320struct RefreshErrorResponse {
321    error: String,
322    #[serde(default)]
323    error_description: String,
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::test_support::{claims_with_workspace, jwt_token, raw_token};
330    use crate::AuthError;
331    use mocktail::prelude::*;
332
333    fn make_token(expires_in: u64, refresh: bool) -> Token {
334        Token {
335            access_token: SecretToken::new("test-access-token"),
336            token_type: "Bearer".to_string(),
337            expires_at: now_unix_secs() + expires_in,
338            refresh_token: if refresh {
339                Some(SecretToken::new("test-refresh-token"))
340            } else {
341                None
342            },
343            region: None,
344            client_id: None,
345            device_instance_id: None,
346        }
347    }
348
349    fn refresh_response_json() -> serde_json::Value {
350        serde_json::json!({
351            "access_token": "new-access-token",
352            "token_type": "Bearer",
353            "expires_in": 3600,
354            "refresh_token": "new-refresh-token"
355        })
356    }
357
358    fn error_json(error: &str) -> serde_json::Value {
359        serde_json::json!({
360            "error": error,
361            "error_description": format!("{error} occurred")
362        })
363    }
364
365    async fn start_server(mocks: MockSet) -> MockServer {
366        let server = MockServer::new_http("token-refresh-test").with_mocks(mocks);
367        server.start().await.unwrap();
368        server
369    }
370
371    #[test]
372    fn test_secret_token_debug_does_not_leak() {
373        let token = SecretToken("super_secret_value".to_string());
374        let debug = format!("{:?}", token);
375        assert!(
376            !debug.contains("super_secret_value"),
377            "SecretToken Debug should not contain the secret, got: {debug}"
378        );
379    }
380
381    // ---- is_expired_at / is_usable_at boundary tests ----
382
383    /// A token with an explicit absolute `expires_at`, for driving the `*_at`
384    /// predicates against precise boundary values (unlike `make_token`, which is
385    /// relative to the wall clock).
386    fn token_expiring_at(expires_at: u64) -> Token {
387        Token {
388            access_token: SecretToken::new("t"),
389            token_type: "Bearer".to_string(),
390            expires_at,
391            refresh_token: None,
392            region: None,
393            client_id: None,
394            device_instance_id: None,
395        }
396    }
397
398    #[test]
399    fn is_usable_at_boundary() {
400        let t = token_expiring_at(1000);
401        assert!(t.is_usable_at(999), "before expiry → usable");
402        assert!(!t.is_usable_at(1000), "exactly at expiry → not usable");
403        assert!(!t.is_usable_at(1001), "past expiry → not usable");
404    }
405
406    #[test]
407    fn is_expired_at_leeway_window() {
408        // EXPIRY_LEEWAY_SECS == 90: `is_expired_at` flips to true 90s ahead of
409        // the real expiry timestamp so refresh is triggered preemptively.
410        let t = token_expiring_at(1000);
411        assert!(
412            !t.is_expired_at(909),
413            "just outside the 90s leeway → not expired"
414        );
415        assert!(t.is_expired_at(910), "exactly at the leeway edge → expired");
416        // Inside the leeway window the token reads as "expired" (so a refresh is
417        // triggered) yet is still usable — this is the expired-but-usable state
418        // that drives AutoRefresh's non-blocking refresh path.
419        assert!(
420            t.is_expired_at(950) && t.is_usable_at(950),
421            "inside the leeway: expired but still usable"
422        );
423    }
424
425    #[test]
426    fn is_expired_at_saturates_near_u64_max() {
427        // `is_expired_at` computes `now + EXPIRY_LEEWAY_SECS`; a plain add would
428        // overflow and panic in debug builds. `test_support::raw_token` mints
429        // tokens with `expires_at == u64::MAX`, so the saturating add must hold.
430        let t = token_expiring_at(u64::MAX);
431        assert!(
432            t.is_expired_at(u64::MAX),
433            "saturating_add must not overflow at the u64 ceiling"
434        );
435    }
436
437    // ---- refresh() tests ----
438
439    #[tokio::test]
440    async fn test_refresh_success() {
441        let mut mocks = MockSet::new();
442        mocks.mock(|when, then| {
443            when.post().path("/oauth/token");
444            then.json(refresh_response_json());
445        });
446        let server = start_server(mocks).await;
447        let base_url = server.url("");
448
449        let refresh_token = SecretToken::new("test-refresh-token");
450        let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None)
451            .await
452            .unwrap();
453
454        assert_eq!(refreshed.access_token().as_str(), "new-access-token");
455        assert_eq!(refreshed.token_type(), "Bearer");
456        assert_eq!(
457            refreshed.refresh_token().unwrap().as_str(),
458            "new-refresh-token"
459        );
460        assert!(!refreshed.is_expired());
461        assert!((3598..=3600).contains(&refreshed.expires_in()));
462    }
463
464    #[tokio::test]
465    async fn test_refresh_invalid_grant() {
466        let mut mocks = MockSet::new();
467        mocks.mock(|when, then| {
468            when.post().path("/oauth/token");
469            then.bad_request().json(error_json("invalid_grant"));
470        });
471        let server = start_server(mocks).await;
472        let base_url = server.url("");
473
474        let refresh_token = SecretToken::new("test-refresh-token");
475        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
476            .await
477            .unwrap_err();
478
479        assert!(matches!(err, AuthError::InvalidGrant));
480    }
481
482    #[tokio::test]
483    async fn test_refresh_invalid_client() {
484        let mut mocks = MockSet::new();
485        mocks.mock(|when, then| {
486            when.post().path("/oauth/token");
487            then.bad_request().json(error_json("invalid_client"));
488        });
489        let server = start_server(mocks).await;
490        let base_url = server.url("");
491
492        let refresh_token = SecretToken::new("test-refresh-token");
493        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
494            .await
495            .unwrap_err();
496
497        assert!(matches!(err, AuthError::InvalidClient));
498    }
499
500    #[tokio::test]
501    async fn test_refresh_access_denied() {
502        let mut mocks = MockSet::new();
503        mocks.mock(|when, then| {
504            when.post().path("/oauth/token");
505            then.bad_request().json(error_json("access_denied"));
506        });
507        let server = start_server(mocks).await;
508        let base_url = server.url("");
509
510        let refresh_token = SecretToken::new("test-refresh-token");
511        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
512            .await
513            .unwrap_err();
514
515        assert!(matches!(err, AuthError::AccessDenied));
516    }
517
518    #[tokio::test]
519    async fn test_refresh_unknown_error() {
520        let mut mocks = MockSet::new();
521        mocks.mock(|when, then| {
522            when.post().path("/oauth/token");
523            then.bad_request().json(error_json("something_unexpected"));
524        });
525        let server = start_server(mocks).await;
526        let base_url = server.url("");
527
528        let refresh_token = SecretToken::new("test-refresh-token");
529        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
530            .await
531            .unwrap_err();
532
533        assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred"));
534    }
535
536    #[tokio::test]
537    async fn test_refresh_response_without_new_refresh_token() {
538        let mut mocks = MockSet::new();
539        mocks.mock(|when, then| {
540            when.post().path("/oauth/token");
541            then.json(serde_json::json!({
542                "access_token": "new-access-token",
543                "token_type": "Bearer",
544                "expires_in": 3600
545            }));
546        });
547        let server = start_server(mocks).await;
548        let base_url = server.url("");
549
550        let refresh_token = SecretToken::new("test-refresh-token");
551        let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None)
552            .await
553            .unwrap();
554
555        assert_eq!(refreshed.access_token().as_str(), "new-access-token");
556        assert!(refreshed.refresh_token().is_none());
557    }
558
559    #[tokio::test]
560    async fn test_refresh_debug_does_not_leak_tokens() {
561        let token = make_token(3600, true);
562        let debug = format!("{:?}", token);
563        assert!(
564            !debug.contains("test-access-token"),
565            "Debug output should not contain access token, got: {debug}"
566        );
567        assert!(
568            !debug.contains("test-refresh-token"),
569            "Debug output should not contain refresh token, got: {debug}"
570        );
571    }
572
573    // ---- decode_claims / workspace_id / issuer tests ----
574
575    fn valid_claims_json() -> serde_json::Value {
576        claims_with_workspace("7366ITCXSAPCH5TN")
577    }
578
579    #[test]
580    fn test_workspace_id_extracts_from_jwt() {
581        let token = jwt_token(valid_claims_json());
582        let ws = token.workspace_id().expect("should extract workspace ID");
583        assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN");
584    }
585
586    #[test]
587    fn test_issuer_extracts_url_from_jwt() {
588        let token = jwt_token(valid_claims_json());
589        let issuer = token.issuer().expect("should extract issuer");
590        assert_eq!(issuer.as_str(), "https://cts.example.com/");
591    }
592
593    #[test]
594    fn test_workspace_id_fails_on_invalid_jwt() {
595        let token = raw_token("not-a-jwt");
596        let err = token.workspace_id().unwrap_err();
597        assert!(matches!(err, AuthError::InvalidToken(_)));
598    }
599
600    #[test]
601    fn test_issuer_fails_on_missing_claims() {
602        let token = jwt_token(serde_json::json!({"sub": "user-123"}));
603        let err = token.issuer().unwrap_err();
604        assert!(matches!(err, AuthError::InvalidToken(_)));
605    }
606
607    #[test]
608    fn test_workspace_crn_derives_from_region_and_workspace() {
609        let mut token = jwt_token(valid_claims_json());
610        token.set_region("ap-southeast-2.aws");
611        let crn = token.workspace_crn().expect("should derive workspace CRN");
612        assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN");
613    }
614
615    #[test]
616    fn test_workspace_crn_fails_without_region() {
617        let token = jwt_token(valid_claims_json());
618        let err = token.workspace_crn().unwrap_err();
619        assert!(matches!(err, AuthError::NotAuthenticated));
620    }
621
622    #[test]
623    fn test_workspace_crn_fails_with_invalid_region() {
624        let mut token = jwt_token(valid_claims_json());
625        token.set_region("invalid-region");
626        let err = token.workspace_crn().unwrap_err();
627        assert!(matches!(err, AuthError::Server(_)));
628    }
629}