Skip to main content

stack_auth/
error.rs

1//! Authentication error types.
2//!
3//! [`AuthError`] is the single canonical error enum. Each variant wraps a
4//! dedicated struct that owns its `Display` message, `miette` diagnostic
5//! (`help`/`url`) and machine-readable code, plus any structured payload — so
6//! per-error logic lives with the error rather than in one central function.
7//!
8//! The enum is a thin dispatcher: `Display`/`Diagnostic` delegate to the inner
9//! struct via `transparent`, and [`AuthError::error_code`] / the `Serialize`
10//! impl delegate through `AuthError::kind`. Ergonomic `From<Foreign>` impls
11//! keep `?` working at call sites that lift a foreign error directly.
12
13use std::convert::Infallible;
14
15use crate::access_key;
16
17/// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant.
18///
19/// Implemented by the per-error structs so each owns its FFI code and any
20/// structured payload; [`AuthError`] dispatches to it via `AuthError::kind`.
21pub trait AuthErrorKind: std::error::Error + miette::Diagnostic {
22    /// Stable machine-readable identifier surfaced across FFI boundaries
23    /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with
24    /// `miette::Diagnostic::code`, inherited via the `Diagnostic` supertrait.
25    fn error_code(&self) -> &'static str;
26
27    /// Extra structured fields for the FFI/TS failure payload, beyond the
28    /// `type`/`message`/`help`/`url` the enum emits generically. None by default.
29    fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
30        serde_json::Map::new()
31    }
32}
33
34/// The stable machine-readable error codes surfaced across FFI (JS `Error.code`,
35/// the `AuthFailure` TS unions). Defined once here so each
36/// [`AuthErrorKind::error_code`] impl and the [`AuthError::ERROR_CODES`] list
37/// reference the same constant rather than repeating a magic string; a code
38/// only ever changes in one place. `auth_error_code_is_stable_for_every_variant`
39/// pins that every variant maps to one of these and that the list is exhaustive.
40pub(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// ---------------------------------------------------------------------------
63// Per-error structs
64// ---------------------------------------------------------------------------
65
66/// The HTTP request to the auth server failed (network error, timeout, etc.).
67#[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/// The user denied the authorization request.
77#[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/// The grant type was rejected by the server.
87#[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/// The client ID is not recognized.
97#[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/// A URL could not be parsed.
107#[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/// The requested region is not supported.
117#[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/// The workspace CRN could not be parsed.
128#[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/// The token issued by the auth server is for a different workspace than the
141/// one configured on the strategy. Surfaces when the access key was minted for
142/// a different workspace, or when the wrong CRN was passed.
143#[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    /// The workspace the strategy was configured for (from the CRN).
150    pub expected_workspace: cts_common::WorkspaceId,
151    /// The workspace the auth server's token actually carries.
152    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/// The workspace ID could not be parsed.
175#[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/// An access key was provided but the workspace CRN is missing.
185#[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/// No credentials are available (e.g. not logged in, no access key configured).
200#[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/// A token (access token or device code) has expired.
213#[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/// The access key string is malformed (e.g. missing `CSAK` prefix or `.`).
223#[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/// The JWT could not be decoded or its claims are malformed.
234#[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/// An unexpected error was returned by the auth server.
244#[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/// A consumable handle (e.g. a device-code poll) was used after it had already
254/// been consumed. A caller bug rather than an auth outcome, but surfaced as an
255/// `AuthError` so it flows through the `Result` contract rather than throwing
256/// across the FFI boundary.
257#[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/// An internal invariant was violated (e.g. a poisoned lock). Should not occur
267/// in correct usage; surfaced rather than panicking so it crosses the FFI
268/// boundary as a `Result` failure.
269#[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/// A token store operation failed.
279#[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// ---------------------------------------------------------------------------
291// The canonical enum
292// ---------------------------------------------------------------------------
293
294/// Errors that can occur during an authentication flow.
295#[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    /// The complete set of codes [`AuthError::error_code`] can return — the
357    /// stable, machine-readable contract surfaced across FFI (JS `Error.code`,
358    /// Node-API codes, the `index.d.ts` / `wasm-inline.d.ts` `AuthFailure`
359    /// unions). The bindings derive their expected union from this constant
360    /// rather than re-scraping the per-error [`AuthErrorKind::error_code`] impls,
361    /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in
362    /// lockstep with what `error_code` actually returns.
363    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        // `Store` (and its code) only exists off-wasm — see the enum above.
382        #[cfg(not(target_arch = "wasm32"))]
383        codes::STORE_ERROR,
384    ];
385
386    /// Dispatch to the wrapped concrete error as a trait object.
387    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    /// Stable machine-readable identifier for surfacing across FFI boundaries
412    /// (e.g. JS `Error.code`, Node-API error codes). Delegates to the wrapped
413    /// error's [`AuthErrorKind::error_code`]; every value it can return is
414    /// listed in [`AuthError::ERROR_CODES`].
415    pub fn error_code(&self) -> &'static str {
416        self.kind().error_code()
417    }
418}
419
420/// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the
421/// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`.
422///
423/// `type`/`message` come from the canonical code and `Display`; `help`/`url`
424/// are captured generically from the `miette::Diagnostic` surface (so
425/// per-variant help stays colocated on the struct); extra structured fields
426/// come from [`AuthErrorKind::payload`].
427///
428/// `serialize_map` lets the wasm binding render this as a plain JS object via
429/// `Serializer::serialize_maps_as_objects(true)`; serde_json renders a JSON
430/// object directly.
431impl 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        // Emit the per-variant payload first, then the fixed diagnostic fields —
439        // so if a future `payload()` key ever collided with `type`/`message`/
440        // `help`/`url`, the diagnostic field (written last) wins rather than being
441        // clobbered. Mirrors the JS side's `{ ...payload, type, error }`.
442        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
457// ---------------------------------------------------------------------------
458// Ergonomic `From<Foreign>` impls — keep `?` working where call sites lift a
459// foreign error straight into `AuthError` (the per-struct wrapping is internal).
460// ---------------------------------------------------------------------------
461
462impl 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            // Every non-`Auth` variant has a canonical `AuthError` equivalent —
511            // route through it so `bind_client_device` failures carry the same
512            // code/help/payload as every other path. `Auth` already is one.
513            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        // Generic fields the enum emits for every variant.
547        assert_eq!(json["type"], "WORKSPACE_MISMATCH");
548        assert_eq!(json["message"], err.to_string());
549        // `help` comes from the miette diagnostic (present on this variant).
550        assert!(json.get("help").is_some(), "help should be serialized");
551        // Structured payload from `AuthErrorKind::payload`.
552        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        // No per-variant payload keys — the payload loop contributes nothing.
564        assert!(json.get("expected").is_none());
565        assert!(json.get("actual").is_none());
566    }
567
568    /// Every constructable `DeviceClientError` variant maps to its canonical
569    /// `AuthError` code so `bind_client_device` failures share the one envelope
570    /// path. (`Request` wraps a `reqwest::Error`, which has no public
571    /// constructor, so it can't be built here — the same gap the exhaustive
572    /// `error_code` test documents.)
573    #[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        // `Auth` unwraps to the inner error unchanged.
579        assert_eq!(
580            AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(),
581            codes::ACCESS_DENIED,
582        );
583        // Non-`Auth` variants route to their canonical `AuthError` equivalent.
584        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}