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 fn decode_claims(&self) -> Result<Claims, AuthError> {
187 crate::decode_jwt_payload(self.access_token.as_str())
188 }
189
190 #[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 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 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 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 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 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 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 #[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 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}