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(crate::error::NotAuthenticated))?
165            .parse()
166            .map_err(|e: cts_common::RegionError| {
167                AuthError::Server(crate::error::ServerError(e.to_string()))
168            })?;
169        Ok(Crn::new(region, workspace_id))
170    }
171
172    /// Returns the issuer URL from the JWT claims.
173    ///
174    /// The `iss` claim in CipherStash tokens is the CTS host URL for the
175    /// workspace, so this can be used directly as the CTS base URL.
176    pub fn issuer(&self) -> Result<Url, AuthError> {
177        let claims = self.decode_claims()?;
178        claims.iss.parse().map_err(AuthError::from)
179    }
180
181    /// Decode the JWT payload into [`Claims`] without verifying the signature.
182    ///
183    /// This is safe because we already possess the token — we just need to read
184    /// the claims it contains.
185    #[cfg(not(target_arch = "wasm32"))]
186    fn decode_claims(&self) -> Result<Claims, AuthError> {
187        use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
188        use std::collections::HashSet;
189
190        let token_str = self.access_token.as_str();
191        let header = decode_header(token_str).map_err(|e| {
192            AuthError::InvalidToken(crate::error::InvalidToken(format!(
193                "invalid JWT header: {e}"
194            )))
195        })?;
196
197        let dummy_key = DecodingKey::from_secret(&[]);
198        let mut validation = Validation::new(header.alg);
199        validation.validate_exp = false;
200        validation.validate_aud = false;
201        validation.required_spec_claims = HashSet::new();
202        validation.insecure_disable_signature_validation();
203
204        decode(token_str, &dummy_key, &validation)
205            .map(|data| data.claims)
206            .map_err(|e| {
207                AuthError::InvalidToken(crate::error::InvalidToken(format!(
208                    "failed to decode JWT claims: {e}"
209                )))
210            })
211    }
212
213    /// Wasm32 path: decode the JWT payload by splitting + base64 + JSON. We
214    /// don't need the cryptographic backing of `jsonwebtoken` (which pulls
215    /// `ring`) because we only ever read claims from a token we already hold;
216    /// signature validation is `insecure_disable_signature_validation()` on
217    /// native too.
218    #[cfg(target_arch = "wasm32")]
219    fn decode_claims(&self) -> Result<Claims, AuthError> {
220        crate::decode_jwt_payload_wasm(self.access_token.as_str())
221    }
222
223    /// Fuzz-only entry point: run the JWT claims decode (`Token::decode_claims`)
224    /// over an arbitrary string, discarding the claims and keeping only whether it
225    /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds.
226    /// Reading claims from a token we already hold must never panic on a malformed
227    /// token — only return `Err`. See `packages/stack-auth/fuzz`.
228    ///
229    /// `#[doc(hidden)]`: the `doc:stack-auth` task builds with `--all-features`,
230    /// which enables `fuzz` — this keeps the shim out of the generated public docs.
231    #[cfg(feature = "fuzz")]
232    #[doc(hidden)]
233    pub fn fuzz_decode_claims(token: &str) -> Result<(), AuthError> {
234        Token {
235            access_token: SecretToken::new(token),
236            token_type: String::new(),
237            expires_at: 0,
238            refresh_token: None,
239            region: None,
240            client_id: None,
241            device_instance_id: None,
242        }
243        .decode_claims()
244        .map(|_| ())
245    }
246
247    /// Exchange a refresh token for a new [`Token`] via the `/oauth/token`
248    /// endpoint.
249    ///
250    /// This is a static constructor — it takes a bare [`SecretToken`] (the
251    /// refresh token) rather than operating on an existing `Token`. This
252    /// allows callers to manage the refresh token lifecycle independently
253    /// (e.g. taking it out of a cached token for cascade prevention and
254    /// restoring it on failure).
255    ///
256    /// # Errors
257    ///
258    /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired.
259    /// - [`AuthError::InvalidClient`] — the client ID is not recognized.
260    /// - [`AuthError::Request`] — a network error occurred.
261    pub async fn refresh(
262        refresh_token: &SecretToken,
263        base_url: &Url,
264        client_id: &str,
265        device_instance_id: Option<&str>,
266    ) -> Result<Token, AuthError> {
267        let token_url = base_url.join("oauth/token")?;
268
269        tracing::debug!(url = %token_url, "refreshing token");
270
271        let resp = http_client()
272            .post(token_url)
273            .form(&RefreshRequest {
274                grant_type: "refresh_token",
275                client_id,
276                refresh_token: refresh_token.as_str(),
277                device_instance_id,
278            })
279            .send()
280            .await?;
281
282        if !resp.status().is_success() {
283            let err: RefreshErrorResponse = resp.json().await?;
284            tracing::debug!(error = %err.error, "token refresh failed");
285            return Err(match err.error.as_str() {
286                "invalid_grant" => AuthError::InvalidGrant(crate::error::InvalidGrant),
287                "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient),
288                "access_denied" => AuthError::AccessDenied(crate::error::AccessDenied),
289                _ => AuthError::Server(crate::error::ServerError(err.error_description)),
290            });
291        }
292
293        let token_resp: RefreshResponse = resp.json().await?;
294
295        Ok(Token {
296            access_token: token_resp.access_token,
297            token_type: token_resp.token_type,
298            expires_at: now_unix_secs() + token_resp.expires_in,
299            refresh_token: token_resp.refresh_token,
300            region: None,
301            client_id: None,
302            // TODO(CIP-2793): The server should include device_instance_id in the
303            // refresh response. Until then, callers (e.g. DeviceSessionRefresher) must
304            // re-attach it manually after refresh.
305            device_instance_id: None,
306        })
307    }
308}
309
310#[derive(serde::Serialize)]
311struct RefreshRequest<'a> {
312    grant_type: &'a str,
313    client_id: &'a str,
314    refresh_token: &'a str,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    device_instance_id: Option<&'a str>,
317}
318
319#[derive(serde::Deserialize)]
320struct RefreshResponse {
321    access_token: SecretToken,
322    token_type: String,
323    expires_in: u64,
324    #[serde(default)]
325    refresh_token: Option<SecretToken>,
326}
327
328#[derive(serde::Deserialize)]
329struct RefreshErrorResponse {
330    error: String,
331    #[serde(default)]
332    error_description: String,
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::test_support::{claims_with_workspace, jwt_token, raw_token};
339    use crate::AuthError;
340    use mocktail::prelude::*;
341
342    fn make_token(expires_in: u64, refresh: bool) -> Token {
343        Token {
344            access_token: SecretToken::new("test-access-token"),
345            token_type: "Bearer".to_string(),
346            expires_at: now_unix_secs() + expires_in,
347            refresh_token: if refresh {
348                Some(SecretToken::new("test-refresh-token"))
349            } else {
350                None
351            },
352            region: None,
353            client_id: None,
354            device_instance_id: None,
355        }
356    }
357
358    fn refresh_response_json() -> serde_json::Value {
359        serde_json::json!({
360            "access_token": "new-access-token",
361            "token_type": "Bearer",
362            "expires_in": 3600,
363            "refresh_token": "new-refresh-token"
364        })
365    }
366
367    fn error_json(error: &str) -> serde_json::Value {
368        serde_json::json!({
369            "error": error,
370            "error_description": format!("{error} occurred")
371        })
372    }
373
374    async fn start_server(mocks: MockSet) -> MockServer {
375        let server = MockServer::new_http("token-refresh-test").with_mocks(mocks);
376        server.start().await.unwrap();
377        server
378    }
379
380    #[test]
381    fn test_secret_token_debug_does_not_leak() {
382        let token = SecretToken("super_secret_value".to_string());
383        let debug = format!("{:?}", token);
384        assert!(
385            !debug.contains("super_secret_value"),
386            "SecretToken Debug should not contain the secret, got: {debug}"
387        );
388    }
389
390    // ---- is_expired_at / is_usable_at boundary tests ----
391
392    /// A token with an explicit absolute `expires_at`, for driving the `*_at`
393    /// predicates against precise boundary values (unlike `make_token`, which is
394    /// relative to the wall clock).
395    fn token_expiring_at(expires_at: u64) -> Token {
396        Token {
397            access_token: SecretToken::new("t"),
398            token_type: "Bearer".to_string(),
399            expires_at,
400            refresh_token: None,
401            region: None,
402            client_id: None,
403            device_instance_id: None,
404        }
405    }
406
407    #[test]
408    fn is_usable_at_boundary() {
409        let t = token_expiring_at(1000);
410        assert!(t.is_usable_at(999), "before expiry → usable");
411        assert!(!t.is_usable_at(1000), "exactly at expiry → not usable");
412        assert!(!t.is_usable_at(1001), "past expiry → not usable");
413    }
414
415    #[test]
416    fn is_expired_at_leeway_window() {
417        // EXPIRY_LEEWAY_SECS == 90: `is_expired_at` flips to true 90s ahead of
418        // the real expiry timestamp so refresh is triggered preemptively.
419        let t = token_expiring_at(1000);
420        assert!(
421            !t.is_expired_at(909),
422            "just outside the 90s leeway → not expired"
423        );
424        assert!(t.is_expired_at(910), "exactly at the leeway edge → expired");
425        // Inside the leeway window the token reads as "expired" (so a refresh is
426        // triggered) yet is still usable — this is the expired-but-usable state
427        // that drives AutoRefresh's non-blocking refresh path.
428        assert!(
429            t.is_expired_at(950) && t.is_usable_at(950),
430            "inside the leeway: expired but still usable"
431        );
432    }
433
434    #[test]
435    fn is_expired_at_saturates_near_u64_max() {
436        // `is_expired_at` computes `now + EXPIRY_LEEWAY_SECS`; a plain add would
437        // overflow and panic in debug builds. `test_support::raw_token` mints
438        // tokens with `expires_at == u64::MAX`, so the saturating add must hold.
439        let t = token_expiring_at(u64::MAX);
440        assert!(
441            t.is_expired_at(u64::MAX),
442            "saturating_add must not overflow at the u64 ceiling"
443        );
444    }
445
446    // ---- refresh() tests ----
447
448    #[tokio::test]
449    async fn test_refresh_success() {
450        let mut mocks = MockSet::new();
451        mocks.mock(|when, then| {
452            when.post().path("/oauth/token");
453            then.json(refresh_response_json());
454        });
455        let server = start_server(mocks).await;
456        let base_url = server.url("");
457
458        let refresh_token = SecretToken::new("test-refresh-token");
459        let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None)
460            .await
461            .unwrap();
462
463        assert_eq!(refreshed.access_token().as_str(), "new-access-token");
464        assert_eq!(refreshed.token_type(), "Bearer");
465        assert_eq!(
466            refreshed.refresh_token().unwrap().as_str(),
467            "new-refresh-token"
468        );
469        assert!(!refreshed.is_expired());
470        assert!((3598..=3600).contains(&refreshed.expires_in()));
471    }
472
473    #[tokio::test]
474    async fn test_refresh_invalid_grant() {
475        let mut mocks = MockSet::new();
476        mocks.mock(|when, then| {
477            when.post().path("/oauth/token");
478            then.bad_request().json(error_json("invalid_grant"));
479        });
480        let server = start_server(mocks).await;
481        let base_url = server.url("");
482
483        let refresh_token = SecretToken::new("test-refresh-token");
484        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
485            .await
486            .unwrap_err();
487
488        assert!(matches!(err, AuthError::InvalidGrant(_)));
489    }
490
491    #[tokio::test]
492    async fn test_refresh_invalid_client() {
493        let mut mocks = MockSet::new();
494        mocks.mock(|when, then| {
495            when.post().path("/oauth/token");
496            then.bad_request().json(error_json("invalid_client"));
497        });
498        let server = start_server(mocks).await;
499        let base_url = server.url("");
500
501        let refresh_token = SecretToken::new("test-refresh-token");
502        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
503            .await
504            .unwrap_err();
505
506        assert!(matches!(err, AuthError::InvalidClient(_)));
507    }
508
509    #[tokio::test]
510    async fn test_refresh_access_denied() {
511        let mut mocks = MockSet::new();
512        mocks.mock(|when, then| {
513            when.post().path("/oauth/token");
514            then.bad_request().json(error_json("access_denied"));
515        });
516        let server = start_server(mocks).await;
517        let base_url = server.url("");
518
519        let refresh_token = SecretToken::new("test-refresh-token");
520        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
521            .await
522            .unwrap_err();
523
524        assert!(matches!(err, AuthError::AccessDenied(_)));
525    }
526
527    #[tokio::test]
528    async fn test_refresh_unknown_error() {
529        let mut mocks = MockSet::new();
530        mocks.mock(|when, then| {
531            when.post().path("/oauth/token");
532            then.bad_request().json(error_json("something_unexpected"));
533        });
534        let server = start_server(mocks).await;
535        let base_url = server.url("");
536
537        let refresh_token = SecretToken::new("test-refresh-token");
538        let err = Token::refresh(&refresh_token, &base_url, "cli", None)
539            .await
540            .unwrap_err();
541
542        assert!(
543            matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred")
544        );
545    }
546
547    #[tokio::test]
548    async fn test_refresh_response_without_new_refresh_token() {
549        let mut mocks = MockSet::new();
550        mocks.mock(|when, then| {
551            when.post().path("/oauth/token");
552            then.json(serde_json::json!({
553                "access_token": "new-access-token",
554                "token_type": "Bearer",
555                "expires_in": 3600
556            }));
557        });
558        let server = start_server(mocks).await;
559        let base_url = server.url("");
560
561        let refresh_token = SecretToken::new("test-refresh-token");
562        let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None)
563            .await
564            .unwrap();
565
566        assert_eq!(refreshed.access_token().as_str(), "new-access-token");
567        assert!(refreshed.refresh_token().is_none());
568    }
569
570    #[tokio::test]
571    async fn test_refresh_debug_does_not_leak_tokens() {
572        let token = make_token(3600, true);
573        let debug = format!("{:?}", token);
574        assert!(
575            !debug.contains("test-access-token"),
576            "Debug output should not contain access token, got: {debug}"
577        );
578        assert!(
579            !debug.contains("test-refresh-token"),
580            "Debug output should not contain refresh token, got: {debug}"
581        );
582    }
583
584    // ---- decode_claims / workspace_id / issuer tests ----
585
586    fn valid_claims_json() -> serde_json::Value {
587        claims_with_workspace("7366ITCXSAPCH5TN")
588    }
589
590    #[test]
591    fn test_workspace_id_extracts_from_jwt() {
592        let token = jwt_token(valid_claims_json());
593        let ws = token.workspace_id().expect("should extract workspace ID");
594        assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN");
595    }
596
597    #[test]
598    fn test_issuer_extracts_url_from_jwt() {
599        let token = jwt_token(valid_claims_json());
600        let issuer = token.issuer().expect("should extract issuer");
601        assert_eq!(issuer.as_str(), "https://cts.example.com/");
602    }
603
604    #[test]
605    fn test_workspace_id_fails_on_invalid_jwt() {
606        let token = raw_token("not-a-jwt");
607        let err = token.workspace_id().unwrap_err();
608        assert!(matches!(err, AuthError::InvalidToken(_)));
609    }
610
611    #[test]
612    fn test_issuer_fails_on_missing_claims() {
613        let token = jwt_token(serde_json::json!({"sub": "user-123"}));
614        let err = token.issuer().unwrap_err();
615        assert!(matches!(err, AuthError::InvalidToken(_)));
616    }
617
618    #[test]
619    fn test_workspace_crn_derives_from_region_and_workspace() {
620        let mut token = jwt_token(valid_claims_json());
621        token.set_region("ap-southeast-2.aws");
622        let crn = token.workspace_crn().expect("should derive workspace CRN");
623        assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN");
624    }
625
626    #[test]
627    fn test_workspace_crn_fails_without_region() {
628        let token = jwt_token(valid_claims_json());
629        let err = token.workspace_crn().unwrap_err();
630        assert!(matches!(err, AuthError::NotAuthenticated(_)));
631    }
632
633    #[test]
634    fn test_workspace_crn_fails_with_invalid_region() {
635        let mut token = jwt_token(valid_claims_json());
636        token.set_region("invalid-region");
637        let err = token.workspace_crn().unwrap_err();
638        assert!(matches!(err, AuthError::Server(_)));
639    }
640}