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)?
165 .parse()
166 .map_err(|e: cts_common::RegionError| AuthError::Server(e.to_string()))?;
167 Ok(Crn::new(region, workspace_id))
168 }
169
170 pub fn issuer(&self) -> Result<Url, AuthError> {
175 let claims = self.decode_claims()?;
176 claims.iss.parse().map_err(AuthError::from)
177 }
178
179 #[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 #[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 #[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 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 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 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 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 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 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 #[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 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}