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
13const EXPIRY_LEEWAY_SECS: u64 = 90;
19
20fn now_unix_secs() -> u64 {
26 use crate::clock::{Clock, SystemClock};
27 SystemClock.now_unix_secs()
28}
29
30#[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 pub fn access_token(&self) -> &SecretToken {
55 &self.access_token
56 }
57
58 pub fn token_type(&self) -> &str {
60 &self.token_type
61 }
62
63 pub fn expires_at(&self) -> u64 {
65 self.expires_at
66 }
67
68 pub fn expires_in(&self) -> u64 {
70 self.expires_at.saturating_sub(now_unix_secs())
71 }
72
73 pub fn is_expired(&self) -> bool {
82 self.is_expired_at(now_unix_secs())
83 }
84
85 pub(crate) fn is_expired_at(&self, now: u64) -> bool {
91 now.saturating_add(EXPIRY_LEEWAY_SECS) >= self.expires_at
92 }
93
94 pub fn is_usable(&self) -> bool {
99 self.is_usable_at(now_unix_secs())
100 }
101
102 pub(crate) fn is_usable_at(&self, now: u64) -> bool {
105 now < self.expires_at
106 }
107
108 pub fn refresh_token(&self) -> Option<&SecretToken> {
110 self.refresh_token.as_ref()
111 }
112
113 pub fn take_refresh_token(&mut self) -> Option<SecretToken> {
115 self.refresh_token.take()
116 }
117
118 pub fn region(&self) -> Option<&str> {
120 self.region.as_deref()
121 }
122
123 pub fn client_id(&self) -> Option<&str> {
125 self.client_id.as_deref()
126 }
127
128 pub(crate) fn set_region(&mut self, region: impl Into<String>) {
130 self.region = Some(region.into());
131 }
132
133 pub(crate) fn set_client_id(&mut self, client_id: impl Into<String>) {
135 self.client_id = Some(client_id.into());
136 }
137
138 pub fn device_instance_id(&self) -> Option<&str> {
140 self.device_instance_id.as_deref()
141 }
142
143 pub(crate) fn set_device_instance_id(&mut self, id: impl Into<String>) {
145 self.device_instance_id = Some(id.into());
146 }
147
148 pub fn workspace_id(&self) -> Result<WorkspaceId, AuthError> {
153 self.decode_claims().map(|c| c.workspace)
154 }
155
156 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 pub fn issuer(&self) -> Result<Url, AuthError> {
177 let claims = self.decode_claims()?;
178 claims.iss.parse().map_err(AuthError::from)
179 }
180
181 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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}