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