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 #[cfg(not(target_arch = "wasm32"))]
59 pub(crate) const STORE_ERROR: &str = "STORE_ERROR";
60}
61
62#[derive(Debug, thiserror::Error, miette::Diagnostic)]
68#[error("HTTP request failed: {0}")]
69pub struct RequestError(pub reqwest::Error);
70impl AuthErrorKind for RequestError {
71 fn error_code(&self) -> &'static str {
72 codes::REQUEST_ERROR
73 }
74}
75
76#[derive(Debug, thiserror::Error, miette::Diagnostic)]
78#[error("Authorization was denied")]
79pub struct AccessDenied;
80impl AuthErrorKind for AccessDenied {
81 fn error_code(&self) -> &'static str {
82 codes::ACCESS_DENIED
83 }
84}
85
86#[derive(Debug, thiserror::Error, miette::Diagnostic)]
88#[error("Invalid grant")]
89pub struct InvalidGrant;
90impl AuthErrorKind for InvalidGrant {
91 fn error_code(&self) -> &'static str {
92 codes::INVALID_GRANT
93 }
94}
95
96#[derive(Debug, thiserror::Error, miette::Diagnostic)]
98#[error("Invalid client")]
99pub struct InvalidClient;
100impl AuthErrorKind for InvalidClient {
101 fn error_code(&self) -> &'static str {
102 codes::INVALID_CLIENT
103 }
104}
105
106#[derive(Debug, thiserror::Error, miette::Diagnostic)]
108#[error("Invalid URL: {0}")]
109pub struct InvalidUrl(pub url::ParseError);
110impl AuthErrorKind for InvalidUrl {
111 fn error_code(&self) -> &'static str {
112 codes::INVALID_URL
113 }
114}
115
116#[derive(Debug, thiserror::Error, miette::Diagnostic)]
118#[error("Unsupported region: {0}")]
119#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))]
120pub struct UnsupportedRegion(pub cts_common::RegionError);
121impl AuthErrorKind for UnsupportedRegion {
122 fn error_code(&self) -> &'static str {
123 codes::INVALID_REGION
124 }
125}
126
127#[derive(Debug, thiserror::Error, miette::Diagnostic)]
129#[error("Invalid workspace CRN: {0}")]
130#[diagnostic(help(
131 "A workspace CRN looks like `crn:<region>:<workspace-id>`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`."
132))]
133pub struct InvalidCrn(pub cts_common::InvalidCrn);
134impl AuthErrorKind for InvalidCrn {
135 fn error_code(&self) -> &'static str {
136 codes::INVALID_CRN
137 }
138}
139
140#[derive(Debug, thiserror::Error, miette::Diagnostic)]
144#[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")]
145#[diagnostic(help(
146 "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to."
147))]
148pub struct WorkspaceMismatch {
149 pub expected_workspace: cts_common::WorkspaceId,
151 pub token_workspace: cts_common::WorkspaceId,
153}
154impl AuthErrorKind for WorkspaceMismatch {
155 fn error_code(&self) -> &'static str {
156 codes::WORKSPACE_MISMATCH
157 }
158 fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
159 [
160 (
161 "expected".to_string(),
162 self.expected_workspace.to_string().into(),
163 ),
164 (
165 "actual".to_string(),
166 self.token_workspace.to_string().into(),
167 ),
168 ]
169 .into_iter()
170 .collect()
171 }
172}
173
174#[derive(Debug, thiserror::Error, miette::Diagnostic)]
176#[error("Invalid workspace ID: {0}")]
177pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId);
178impl AuthErrorKind for InvalidWorkspaceId {
179 fn error_code(&self) -> &'static str {
180 codes::INVALID_WORKSPACE_ID
181 }
182}
183
184#[derive(Debug, thiserror::Error, miette::Diagnostic)]
186#[error(
187 "Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn"
188)]
189#[diagnostic(help(
190 "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`."
191))]
192pub struct MissingWorkspaceCrn;
193impl AuthErrorKind for MissingWorkspaceCrn {
194 fn error_code(&self) -> &'static str {
195 codes::MISSING_WORKSPACE_CRN
196 }
197}
198
199#[derive(Debug, thiserror::Error, miette::Diagnostic)]
201#[error("Not authenticated")]
202#[diagnostic(help(
203 "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth."
204))]
205pub struct NotAuthenticated;
206impl AuthErrorKind for NotAuthenticated {
207 fn error_code(&self) -> &'static str {
208 codes::NOT_AUTHENTICATED
209 }
210}
211
212#[derive(Debug, thiserror::Error, miette::Diagnostic)]
214#[error("Token expired")]
215pub struct TokenExpired;
216impl AuthErrorKind for TokenExpired {
217 fn error_code(&self) -> &'static str {
218 codes::EXPIRED_TOKEN
219 }
220}
221
222#[derive(Debug, thiserror::Error, miette::Diagnostic)]
224#[error("Invalid access key: {0}")]
225#[diagnostic(help("Access keys have the form `CSAK<key-id>.<secret>`."))]
226pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey);
227impl AuthErrorKind for InvalidAccessKeyError {
228 fn error_code(&self) -> &'static str {
229 codes::INVALID_ACCESS_KEY
230 }
231}
232
233#[derive(Debug, thiserror::Error, miette::Diagnostic)]
235#[error("Invalid token: {0}")]
236pub struct InvalidToken(pub String);
237impl AuthErrorKind for InvalidToken {
238 fn error_code(&self) -> &'static str {
239 codes::INVALID_TOKEN
240 }
241}
242
243#[derive(Debug, thiserror::Error, miette::Diagnostic)]
245#[error("Server error: {0}")]
246pub struct ServerError(pub String);
247impl AuthErrorKind for ServerError {
248 fn error_code(&self) -> &'static str {
249 codes::SERVER_ERROR
250 }
251}
252
253#[derive(Debug, thiserror::Error, miette::Diagnostic)]
258#[error("Handle already consumed")]
259pub struct AlreadyConsumed;
260impl AuthErrorKind for AlreadyConsumed {
261 fn error_code(&self) -> &'static str {
262 codes::ALREADY_CONSUMED
263 }
264}
265
266#[derive(Debug, thiserror::Error, miette::Diagnostic)]
270#[error("Internal error: {0}")]
271pub struct InternalError(pub String);
272impl AuthErrorKind for InternalError {
273 fn error_code(&self) -> &'static str {
274 codes::INTERNAL_ERROR
275 }
276}
277
278#[cfg(not(target_arch = "wasm32"))]
280#[derive(Debug, thiserror::Error, miette::Diagnostic)]
281#[error("Token store error: {0}")]
282pub struct StoreError(pub stack_profile::ProfileError);
283#[cfg(not(target_arch = "wasm32"))]
284impl AuthErrorKind for StoreError {
285 fn error_code(&self) -> &'static str {
286 codes::STORE_ERROR
287 }
288}
289
290#[derive(Debug, thiserror::Error, miette::Diagnostic)]
296#[non_exhaustive]
297pub enum AuthError {
298 #[error(transparent)]
299 #[diagnostic(transparent)]
300 Request(#[from] RequestError),
301 #[error(transparent)]
302 #[diagnostic(transparent)]
303 AccessDenied(#[from] AccessDenied),
304 #[error(transparent)]
305 #[diagnostic(transparent)]
306 InvalidGrant(#[from] InvalidGrant),
307 #[error(transparent)]
308 #[diagnostic(transparent)]
309 InvalidClient(#[from] InvalidClient),
310 #[error(transparent)]
311 #[diagnostic(transparent)]
312 InvalidUrl(#[from] InvalidUrl),
313 #[error(transparent)]
314 #[diagnostic(transparent)]
315 Region(#[from] UnsupportedRegion),
316 #[error(transparent)]
317 #[diagnostic(transparent)]
318 InvalidCrn(#[from] InvalidCrn),
319 #[error(transparent)]
320 #[diagnostic(transparent)]
321 WorkspaceMismatch(#[from] WorkspaceMismatch),
322 #[error(transparent)]
323 #[diagnostic(transparent)]
324 InvalidWorkspaceId(#[from] InvalidWorkspaceId),
325 #[error(transparent)]
326 #[diagnostic(transparent)]
327 MissingWorkspaceCrn(#[from] MissingWorkspaceCrn),
328 #[error(transparent)]
329 #[diagnostic(transparent)]
330 NotAuthenticated(#[from] NotAuthenticated),
331 #[error(transparent)]
332 #[diagnostic(transparent)]
333 TokenExpired(#[from] TokenExpired),
334 #[error(transparent)]
335 #[diagnostic(transparent)]
336 InvalidAccessKey(#[from] InvalidAccessKeyError),
337 #[error(transparent)]
338 #[diagnostic(transparent)]
339 InvalidToken(#[from] InvalidToken),
340 #[error(transparent)]
341 #[diagnostic(transparent)]
342 Server(#[from] ServerError),
343 #[error(transparent)]
344 #[diagnostic(transparent)]
345 AlreadyConsumed(#[from] AlreadyConsumed),
346 #[error(transparent)]
347 #[diagnostic(transparent)]
348 Internal(#[from] InternalError),
349 #[cfg(not(target_arch = "wasm32"))]
350 #[error(transparent)]
351 #[diagnostic(transparent)]
352 Store(#[from] StoreError),
353}
354
355impl AuthError {
356 pub const ERROR_CODES: &'static [&'static str] = &[
364 codes::REQUEST_ERROR,
365 codes::ACCESS_DENIED,
366 codes::INVALID_GRANT,
367 codes::INVALID_CLIENT,
368 codes::INVALID_URL,
369 codes::INVALID_REGION,
370 codes::INVALID_CRN,
371 codes::WORKSPACE_MISMATCH,
372 codes::INVALID_WORKSPACE_ID,
373 codes::MISSING_WORKSPACE_CRN,
374 codes::NOT_AUTHENTICATED,
375 codes::EXPIRED_TOKEN,
376 codes::INVALID_ACCESS_KEY,
377 codes::INVALID_TOKEN,
378 codes::SERVER_ERROR,
379 codes::ALREADY_CONSUMED,
380 codes::INTERNAL_ERROR,
381 #[cfg(not(target_arch = "wasm32"))]
383 codes::STORE_ERROR,
384 ];
385
386 fn kind(&self) -> &dyn AuthErrorKind {
388 match self {
389 Self::Request(e) => e,
390 Self::AccessDenied(e) => e,
391 Self::InvalidGrant(e) => e,
392 Self::InvalidClient(e) => e,
393 Self::InvalidUrl(e) => e,
394 Self::Region(e) => e,
395 Self::InvalidCrn(e) => e,
396 Self::WorkspaceMismatch(e) => e,
397 Self::InvalidWorkspaceId(e) => e,
398 Self::MissingWorkspaceCrn(e) => e,
399 Self::NotAuthenticated(e) => e,
400 Self::TokenExpired(e) => e,
401 Self::InvalidAccessKey(e) => e,
402 Self::InvalidToken(e) => e,
403 Self::Server(e) => e,
404 Self::AlreadyConsumed(e) => e,
405 Self::Internal(e) => e,
406 #[cfg(not(target_arch = "wasm32"))]
407 Self::Store(e) => e,
408 }
409 }
410
411 pub fn error_code(&self) -> &'static str {
416 self.kind().error_code()
417 }
418}
419
420impl serde::Serialize for AuthError {
432 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
433 use miette::Diagnostic;
434 use serde::ser::SerializeMap;
435
436 let kind = self.kind();
437 let mut map = serializer.serialize_map(None)?;
438 for (key, value) in kind.payload() {
443 map.serialize_entry(&key, &value)?;
444 }
445 map.serialize_entry("type", kind.error_code())?;
446 map.serialize_entry("message", &self.to_string())?;
447 if let Some(help) = self.help() {
448 map.serialize_entry("help", &help.to_string())?;
449 }
450 if let Some(url) = self.url() {
451 map.serialize_entry("url", &url.to_string())?;
452 }
453 map.end()
454 }
455}
456
457impl From<reqwest::Error> for AuthError {
463 fn from(e: reqwest::Error) -> Self {
464 Self::Request(RequestError(e))
465 }
466}
467
468impl From<url::ParseError> for AuthError {
469 fn from(e: url::ParseError) -> Self {
470 Self::InvalidUrl(InvalidUrl(e))
471 }
472}
473
474impl From<cts_common::RegionError> for AuthError {
475 fn from(e: cts_common::RegionError) -> Self {
476 Self::Region(UnsupportedRegion(e))
477 }
478}
479
480impl From<cts_common::InvalidCrn> for AuthError {
481 fn from(e: cts_common::InvalidCrn) -> Self {
482 Self::InvalidCrn(InvalidCrn(e))
483 }
484}
485
486impl From<cts_common::InvalidWorkspaceId> for AuthError {
487 fn from(e: cts_common::InvalidWorkspaceId) -> Self {
488 Self::InvalidWorkspaceId(InvalidWorkspaceId(e))
489 }
490}
491
492impl From<access_key::InvalidAccessKey> for AuthError {
493 fn from(e: access_key::InvalidAccessKey) -> Self {
494 Self::InvalidAccessKey(InvalidAccessKeyError(e))
495 }
496}
497
498#[cfg(not(target_arch = "wasm32"))]
499impl From<stack_profile::ProfileError> for AuthError {
500 fn from(e: stack_profile::ProfileError) -> Self {
501 Self::Store(StoreError(e))
502 }
503}
504
505#[cfg(not(target_arch = "wasm32"))]
506impl From<crate::DeviceClientError> for AuthError {
507 fn from(e: crate::DeviceClientError) -> Self {
508 use crate::DeviceClientError as E;
509 match e {
510 E::Profile(e) => e.into(),
514 E::Auth(e) => e,
515 E::Request(e) => e.into(),
516 E::InvalidUrl(e) => e.into(),
517 E::Server { status, body } => {
518 Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}")))
519 }
520 }
521 }
522}
523
524impl From<Infallible> for AuthError {
525 fn from(never: Infallible) -> Self {
526 match never {}
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn serialize_emits_type_message_help_and_payload() {
536 let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap();
537 let actual: cts_common::WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap();
538 let (expected_s, actual_s) = (expected.to_string(), actual.to_string());
539
540 let err = AuthError::WorkspaceMismatch(WorkspaceMismatch {
541 expected_workspace: expected,
542 token_workspace: actual,
543 });
544 let json = serde_json::to_value(&err).unwrap();
545
546 assert_eq!(json["type"], "WORKSPACE_MISMATCH");
548 assert_eq!(json["message"], err.to_string());
549 assert!(json.get("help").is_some(), "help should be serialized");
551 assert_eq!(json["expected"], expected_s);
553 assert_eq!(json["actual"], actual_s);
554 }
555
556 #[test]
557 fn serialize_variant_without_payload_emits_only_generic_fields() {
558 let err = AuthError::MissingWorkspaceCrn(MissingWorkspaceCrn);
559 let json = serde_json::to_value(&err).unwrap();
560
561 assert_eq!(json["type"], "MISSING_WORKSPACE_CRN");
562 assert_eq!(json["message"], err.to_string());
563 assert!(json.get("expected").is_none());
565 assert!(json.get("actual").is_none());
566 }
567
568 #[cfg(not(target_arch = "wasm32"))]
574 #[test]
575 fn device_client_error_maps_to_canonical_auth_error() {
576 use crate::DeviceClientError as E;
577
578 assert_eq!(
580 AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(),
581 codes::ACCESS_DENIED,
582 );
583 assert_eq!(
585 AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(),
586 codes::STORE_ERROR,
587 );
588 assert_eq!(
589 AuthError::from(E::InvalidUrl("not a url".parse::<url::Url>().unwrap_err()))
590 .error_code(),
591 codes::INVALID_URL,
592 );
593 let server = AuthError::from(E::Server {
594 status: 500,
595 body: "boom".to_string(),
596 });
597 assert_eq!(server.error_code(), codes::SERVER_ERROR);
598 assert!(
599 server.to_string().contains("ZeroKMS returned 500: boom"),
600 "server error should preserve the status/body detail: {server}"
601 );
602 }
603}