1use std::convert::Infallible;
14
15use crate::access_key;
16
17pub trait AuthErrorKind: std::error::Error + miette::Diagnostic {
22 fn error_code(&self) -> &'static str;
26
27 fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
30 serde_json::Map::new()
31 }
32}
33
34pub(crate) mod codes {
41 pub(crate) const REQUEST_ERROR: &str = "REQUEST_ERROR";
42 pub(crate) const ACCESS_DENIED: &str = "ACCESS_DENIED";
43 pub(crate) const INVALID_GRANT: &str = "INVALID_GRANT";
44 pub(crate) const INVALID_CLIENT: &str = "INVALID_CLIENT";
45 pub(crate) const INVALID_URL: &str = "INVALID_URL";
46 pub(crate) const INVALID_REGION: &str = "INVALID_REGION";
47 pub(crate) const INVALID_CRN: &str = "INVALID_CRN";
48 pub(crate) const WORKSPACE_MISMATCH: &str = "WORKSPACE_MISMATCH";
49 pub(crate) const INVALID_WORKSPACE_ID: &str = "INVALID_WORKSPACE_ID";
50 pub(crate) const MISSING_WORKSPACE_CRN: &str = "MISSING_WORKSPACE_CRN";
51 pub(crate) const NOT_AUTHENTICATED: &str = "NOT_AUTHENTICATED";
52 pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN";
53 pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY";
54 pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN";
55 pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR";
56 pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED";
57 pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
58 pub(crate) const CUSTOM: &str = "CUSTOM";
59 #[cfg(not(target_arch = "wasm32"))]
60 pub(crate) const STORE_ERROR: &str = "STORE_ERROR";
61}
62
63#[derive(Debug, thiserror::Error, miette::Diagnostic)]
69#[error("HTTP request failed: {0}")]
70pub struct RequestError(pub reqwest::Error);
71impl AuthErrorKind for RequestError {
72 fn error_code(&self) -> &'static str {
73 codes::REQUEST_ERROR
74 }
75}
76
77#[derive(Debug, thiserror::Error, miette::Diagnostic)]
79#[error("Authorization was denied")]
80pub struct AccessDenied;
81impl AuthErrorKind for AccessDenied {
82 fn error_code(&self) -> &'static str {
83 codes::ACCESS_DENIED
84 }
85}
86
87#[derive(Debug, thiserror::Error, miette::Diagnostic)]
89#[error("Invalid grant")]
90pub struct InvalidGrant;
91impl AuthErrorKind for InvalidGrant {
92 fn error_code(&self) -> &'static str {
93 codes::INVALID_GRANT
94 }
95}
96
97#[derive(Debug, thiserror::Error, miette::Diagnostic)]
99#[error("Invalid client")]
100pub struct InvalidClient;
101impl AuthErrorKind for InvalidClient {
102 fn error_code(&self) -> &'static str {
103 codes::INVALID_CLIENT
104 }
105}
106
107#[derive(Debug, thiserror::Error, miette::Diagnostic)]
109#[error("Invalid URL: {0}")]
110pub struct InvalidUrl(pub url::ParseError);
111impl AuthErrorKind for InvalidUrl {
112 fn error_code(&self) -> &'static str {
113 codes::INVALID_URL
114 }
115}
116
117#[derive(Debug, thiserror::Error, miette::Diagnostic)]
119#[error("Unsupported region: {0}")]
120#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))]
121pub struct UnsupportedRegion(pub cts_common::RegionError);
122impl AuthErrorKind for UnsupportedRegion {
123 fn error_code(&self) -> &'static str {
124 codes::INVALID_REGION
125 }
126}
127
128#[derive(Debug, thiserror::Error, miette::Diagnostic)]
130#[error("Invalid workspace CRN: {0}")]
131#[diagnostic(help(
132 "A workspace CRN looks like `crn:<region>:<workspace-id>`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`."
133))]
134pub struct InvalidCrn(pub cts_common::InvalidCrn);
135impl AuthErrorKind for InvalidCrn {
136 fn error_code(&self) -> &'static str {
137 codes::INVALID_CRN
138 }
139}
140
141#[derive(Debug, thiserror::Error, miette::Diagnostic)]
145#[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")]
146#[diagnostic(help(
147 "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to."
148))]
149pub struct WorkspaceMismatch {
150 pub expected_workspace: cts_common::WorkspaceId,
152 pub token_workspace: cts_common::WorkspaceId,
154}
155impl AuthErrorKind for WorkspaceMismatch {
156 fn error_code(&self) -> &'static str {
157 codes::WORKSPACE_MISMATCH
158 }
159 fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
160 [
161 (
162 "expected".to_string(),
163 self.expected_workspace.to_string().into(),
164 ),
165 (
166 "actual".to_string(),
167 self.token_workspace.to_string().into(),
168 ),
169 ]
170 .into_iter()
171 .collect()
172 }
173}
174
175#[derive(Debug, thiserror::Error, miette::Diagnostic)]
177#[error("Invalid workspace ID: {0}")]
178pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId);
179impl AuthErrorKind for InvalidWorkspaceId {
180 fn error_code(&self) -> &'static str {
181 codes::INVALID_WORKSPACE_ID
182 }
183}
184
185#[derive(Debug, thiserror::Error, miette::Diagnostic)]
187#[error(
188 "Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn"
189)]
190#[diagnostic(help(
191 "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`."
192))]
193pub struct MissingWorkspaceCrn;
194impl AuthErrorKind for MissingWorkspaceCrn {
195 fn error_code(&self) -> &'static str {
196 codes::MISSING_WORKSPACE_CRN
197 }
198}
199
200#[derive(Debug, thiserror::Error, miette::Diagnostic)]
202#[error("Not authenticated")]
203#[diagnostic(help(
204 "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth."
205))]
206pub struct NotAuthenticated;
207impl AuthErrorKind for NotAuthenticated {
208 fn error_code(&self) -> &'static str {
209 codes::NOT_AUTHENTICATED
210 }
211}
212
213#[derive(Debug, thiserror::Error, miette::Diagnostic)]
215#[error("Token expired")]
216pub struct TokenExpired;
217impl AuthErrorKind for TokenExpired {
218 fn error_code(&self) -> &'static str {
219 codes::EXPIRED_TOKEN
220 }
221}
222
223#[derive(Debug, thiserror::Error, miette::Diagnostic)]
225#[error("Invalid access key: {0}")]
226#[diagnostic(help("Access keys have the form `CSAK<key-id>.<secret>`."))]
227pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey);
228impl AuthErrorKind for InvalidAccessKeyError {
229 fn error_code(&self) -> &'static str {
230 codes::INVALID_ACCESS_KEY
231 }
232}
233
234#[derive(Debug, thiserror::Error, miette::Diagnostic)]
236#[error("Invalid token: {0}")]
237pub struct InvalidToken(pub String);
238impl AuthErrorKind for InvalidToken {
239 fn error_code(&self) -> &'static str {
240 codes::INVALID_TOKEN
241 }
242}
243
244#[derive(Debug, thiserror::Error, miette::Diagnostic)]
246#[error("Server error: {0}")]
247pub struct ServerError(pub String);
248impl AuthErrorKind for ServerError {
249 fn error_code(&self) -> &'static str {
250 codes::SERVER_ERROR
251 }
252}
253
254#[derive(Debug, thiserror::Error, miette::Diagnostic)]
259#[error("Handle already consumed")]
260pub struct AlreadyConsumed;
261impl AuthErrorKind for AlreadyConsumed {
262 fn error_code(&self) -> &'static str {
263 codes::ALREADY_CONSUMED
264 }
265}
266
267#[derive(Debug, thiserror::Error, miette::Diagnostic)]
271#[error("Internal error: {0}")]
272pub struct InternalError(pub String);
273impl AuthErrorKind for InternalError {
274 fn error_code(&self) -> &'static str {
275 codes::INTERNAL_ERROR
276 }
277}
278
279#[derive(Debug, thiserror::Error, miette::Diagnostic)]
291#[error("{0}")]
292pub struct CustomError(pub String);
293impl AuthErrorKind for CustomError {
294 fn error_code(&self) -> &'static str {
295 codes::CUSTOM
296 }
297}
298
299#[cfg(not(target_arch = "wasm32"))]
301#[derive(Debug, thiserror::Error, miette::Diagnostic)]
302#[error("Token store error: {0}")]
303pub struct StoreError(pub stack_profile::ProfileError);
304#[cfg(not(target_arch = "wasm32"))]
305impl AuthErrorKind for StoreError {
306 fn error_code(&self) -> &'static str {
307 codes::STORE_ERROR
308 }
309}
310
311#[derive(Debug, thiserror::Error, miette::Diagnostic)]
317#[non_exhaustive]
318pub enum AuthError {
319 #[error(transparent)]
320 #[diagnostic(transparent)]
321 Request(#[from] RequestError),
322 #[error(transparent)]
323 #[diagnostic(transparent)]
324 AccessDenied(#[from] AccessDenied),
325 #[error(transparent)]
326 #[diagnostic(transparent)]
327 InvalidGrant(#[from] InvalidGrant),
328 #[error(transparent)]
329 #[diagnostic(transparent)]
330 InvalidClient(#[from] InvalidClient),
331 #[error(transparent)]
332 #[diagnostic(transparent)]
333 InvalidUrl(#[from] InvalidUrl),
334 #[error(transparent)]
335 #[diagnostic(transparent)]
336 Region(#[from] UnsupportedRegion),
337 #[error(transparent)]
338 #[diagnostic(transparent)]
339 InvalidCrn(#[from] InvalidCrn),
340 #[error(transparent)]
341 #[diagnostic(transparent)]
342 WorkspaceMismatch(#[from] WorkspaceMismatch),
343 #[error(transparent)]
344 #[diagnostic(transparent)]
345 InvalidWorkspaceId(#[from] InvalidWorkspaceId),
346 #[error(transparent)]
347 #[diagnostic(transparent)]
348 MissingWorkspaceCrn(#[from] MissingWorkspaceCrn),
349 #[error(transparent)]
350 #[diagnostic(transparent)]
351 NotAuthenticated(#[from] NotAuthenticated),
352 #[error(transparent)]
353 #[diagnostic(transparent)]
354 TokenExpired(#[from] TokenExpired),
355 #[error(transparent)]
356 #[diagnostic(transparent)]
357 InvalidAccessKey(#[from] InvalidAccessKeyError),
358 #[error(transparent)]
359 #[diagnostic(transparent)]
360 InvalidToken(#[from] InvalidToken),
361 #[error(transparent)]
362 #[diagnostic(transparent)]
363 Server(#[from] ServerError),
364 #[error(transparent)]
365 #[diagnostic(transparent)]
366 AlreadyConsumed(#[from] AlreadyConsumed),
367 #[error(transparent)]
368 #[diagnostic(transparent)]
369 Internal(#[from] InternalError),
370 #[error(transparent)]
371 #[diagnostic(transparent)]
372 Custom(#[from] CustomError),
373 #[cfg(not(target_arch = "wasm32"))]
374 #[error(transparent)]
375 #[diagnostic(transparent)]
376 Store(#[from] StoreError),
377}
378
379impl AuthError {
380 pub const ERROR_CODES: &'static [&'static str] = &[
388 codes::REQUEST_ERROR,
389 codes::ACCESS_DENIED,
390 codes::INVALID_GRANT,
391 codes::INVALID_CLIENT,
392 codes::INVALID_URL,
393 codes::INVALID_REGION,
394 codes::INVALID_CRN,
395 codes::WORKSPACE_MISMATCH,
396 codes::INVALID_WORKSPACE_ID,
397 codes::MISSING_WORKSPACE_CRN,
398 codes::NOT_AUTHENTICATED,
399 codes::EXPIRED_TOKEN,
400 codes::INVALID_ACCESS_KEY,
401 codes::INVALID_TOKEN,
402 codes::SERVER_ERROR,
403 codes::ALREADY_CONSUMED,
404 codes::INTERNAL_ERROR,
405 codes::CUSTOM,
406 #[cfg(not(target_arch = "wasm32"))]
408 codes::STORE_ERROR,
409 ];
410
411 fn kind(&self) -> &dyn AuthErrorKind {
413 match self {
414 Self::Request(e) => e,
415 Self::AccessDenied(e) => e,
416 Self::InvalidGrant(e) => e,
417 Self::InvalidClient(e) => e,
418 Self::InvalidUrl(e) => e,
419 Self::Region(e) => e,
420 Self::InvalidCrn(e) => e,
421 Self::WorkspaceMismatch(e) => e,
422 Self::InvalidWorkspaceId(e) => e,
423 Self::MissingWorkspaceCrn(e) => e,
424 Self::NotAuthenticated(e) => e,
425 Self::TokenExpired(e) => e,
426 Self::InvalidAccessKey(e) => e,
427 Self::InvalidToken(e) => e,
428 Self::Server(e) => e,
429 Self::AlreadyConsumed(e) => e,
430 Self::Internal(e) => e,
431 Self::Custom(e) => e,
432 #[cfg(not(target_arch = "wasm32"))]
433 Self::Store(e) => e,
434 }
435 }
436
437 pub fn error_code(&self) -> &'static str {
442 self.kind().error_code()
443 }
444
445 pub fn from_error_code(
469 code: &str,
470 message: impl Into<String>,
471 payload: &serde_json::Map<String, serde_json::Value>,
472 ) -> Self {
473 match code {
474 codes::NOT_AUTHENTICATED => NotAuthenticated.into(),
475 codes::EXPIRED_TOKEN => TokenExpired.into(),
476 codes::ACCESS_DENIED => AccessDenied.into(),
477 codes::INVALID_GRANT => InvalidGrant.into(),
478 codes::INVALID_CLIENT => InvalidClient.into(),
479 codes::MISSING_WORKSPACE_CRN => MissingWorkspaceCrn.into(),
480 codes::ALREADY_CONSUMED => AlreadyConsumed.into(),
481 codes::WORKSPACE_MISMATCH => workspace_mismatch_from_payload(payload)
482 .unwrap_or_else(|| CustomError(message.into()).into()),
483 _ => CustomError(message.into()).into(),
484 }
485 }
486}
487
488fn workspace_mismatch_from_payload(
493 payload: &serde_json::Map<String, serde_json::Value>,
494) -> Option<AuthError> {
495 let parse =
496 |key: &str| -> Option<cts_common::WorkspaceId> { payload.get(key)?.as_str()?.parse().ok() };
497 Some(
498 WorkspaceMismatch {
499 expected_workspace: parse("expected")?,
500 token_workspace: parse("actual")?,
501 }
502 .into(),
503 )
504}
505
506impl serde::Serialize for AuthError {
518 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
519 use miette::Diagnostic;
520 use serde::ser::SerializeMap;
521
522 let kind = self.kind();
523 let mut map = serializer.serialize_map(None)?;
524 for (key, value) in kind.payload() {
529 map.serialize_entry(&key, &value)?;
530 }
531 map.serialize_entry("type", kind.error_code())?;
532 map.serialize_entry("message", &self.to_string())?;
533 if let Some(help) = self.help() {
534 map.serialize_entry("help", &help.to_string())?;
535 }
536 if let Some(url) = self.url() {
537 map.serialize_entry("url", &url.to_string())?;
538 }
539 map.end()
540 }
541}
542
543impl From<reqwest::Error> for AuthError {
549 fn from(e: reqwest::Error) -> Self {
550 Self::Request(RequestError(e))
551 }
552}
553
554impl From<url::ParseError> for AuthError {
555 fn from(e: url::ParseError) -> Self {
556 Self::InvalidUrl(InvalidUrl(e))
557 }
558}
559
560impl From<cts_common::RegionError> for AuthError {
561 fn from(e: cts_common::RegionError) -> Self {
562 Self::Region(UnsupportedRegion(e))
563 }
564}
565
566impl From<cts_common::InvalidCrn> for AuthError {
567 fn from(e: cts_common::InvalidCrn) -> Self {
568 Self::InvalidCrn(InvalidCrn(e))
569 }
570}
571
572impl From<cts_common::InvalidWorkspaceId> for AuthError {
573 fn from(e: cts_common::InvalidWorkspaceId) -> Self {
574 Self::InvalidWorkspaceId(InvalidWorkspaceId(e))
575 }
576}
577
578impl From<access_key::InvalidAccessKey> for AuthError {
579 fn from(e: access_key::InvalidAccessKey) -> Self {
580 Self::InvalidAccessKey(InvalidAccessKeyError(e))
581 }
582}
583
584#[cfg(not(target_arch = "wasm32"))]
585impl From<stack_profile::ProfileError> for AuthError {
586 fn from(e: stack_profile::ProfileError) -> Self {
587 Self::Store(StoreError(e))
588 }
589}
590
591#[cfg(not(target_arch = "wasm32"))]
592impl From<crate::DeviceClientError> for AuthError {
593 fn from(e: crate::DeviceClientError) -> Self {
594 use crate::DeviceClientError as E;
595 match e {
596 E::Profile(e) => e.into(),
600 E::Auth(e) => e,
601 E::Request(e) => e.into(),
602 E::InvalidUrl(e) => e.into(),
603 E::Server { status, body } => {
604 Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}")))
605 }
606 }
607 }
608}
609
610impl From<Infallible> for AuthError {
611 fn from(never: Infallible) -> Self {
612 match never {}
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn serialize_emits_type_message_help_and_payload() {
622 let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap();
623 let actual: cts_common::WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap();
624 let (expected_s, actual_s) = (expected.to_string(), actual.to_string());
625
626 let err = AuthError::WorkspaceMismatch(WorkspaceMismatch {
627 expected_workspace: expected,
628 token_workspace: actual,
629 });
630 let json = serde_json::to_value(&err).unwrap();
631
632 assert_eq!(json["type"], "WORKSPACE_MISMATCH");
634 assert_eq!(json["message"], err.to_string());
635 assert!(json.get("help").is_some(), "help should be serialized");
637 assert_eq!(json["expected"], expected_s);
639 assert_eq!(json["actual"], actual_s);
640 }
641
642 #[test]
643 fn serialize_variant_without_payload_emits_only_generic_fields() {
644 let err = AuthError::MissingWorkspaceCrn(MissingWorkspaceCrn);
645 let json = serde_json::to_value(&err).unwrap();
646
647 assert_eq!(json["type"], "MISSING_WORKSPACE_CRN");
648 assert_eq!(json["message"], err.to_string());
649 assert!(json.get("expected").is_none());
651 assert!(json.get("actual").is_none());
652 }
653
654 #[cfg(not(target_arch = "wasm32"))]
660 #[test]
661 fn device_client_error_maps_to_canonical_auth_error() {
662 use crate::DeviceClientError as E;
663
664 assert_eq!(
666 AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(),
667 codes::ACCESS_DENIED,
668 );
669 assert_eq!(
671 AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(),
672 codes::STORE_ERROR,
673 );
674 assert_eq!(
675 AuthError::from(E::InvalidUrl("not a url".parse::<url::Url>().unwrap_err()))
676 .error_code(),
677 codes::INVALID_URL,
678 );
679 let server = AuthError::from(E::Server {
680 status: 500,
681 body: "boom".to_string(),
682 });
683 assert_eq!(server.error_code(), codes::SERVER_ERROR);
684 assert!(
685 server.to_string().contains("ZeroKMS returned 500: boom"),
686 "server error should preserve the status/body detail: {server}"
687 );
688 }
689}