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    pub(crate) const CUSTOM: &str = "CUSTOM";
59    #[cfg(not(target_arch = "wasm32"))]
60    pub(crate) const STORE_ERROR: &str = "STORE_ERROR";
61}
62
63// ---------------------------------------------------------------------------
64// Per-error structs
65// ---------------------------------------------------------------------------
66
67/// The HTTP request to the auth server failed (network error, timeout, etc.).
68#[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/// The user denied the authorization request.
78#[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/// The grant type was rejected by the server.
88#[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/// The client ID is not recognized.
98#[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/// A URL could not be parsed.
108#[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/// The requested region is not supported.
118#[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/// The workspace CRN could not be parsed.
129#[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/// The token issued by the auth server is for a different workspace than the
142/// one configured on the strategy. Surfaces when the access key was minted for
143/// a different workspace, or when the wrong CRN was passed.
144#[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    /// The workspace the strategy was configured for (from the CRN).
151    pub expected_workspace: cts_common::WorkspaceId,
152    /// The workspace the auth server's token actually carries.
153    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/// The workspace ID could not be parsed.
176#[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/// An access key was provided but the workspace CRN is missing.
186#[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/// No credentials are available (e.g. not logged in, no access key configured).
201#[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/// A token (access token or device code) has expired.
214#[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/// The access key string is malformed (e.g. missing `CSAK` prefix or `.`).
224#[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/// The JWT could not be decoded or its claims are malformed.
235#[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/// An unexpected error was returned by the auth server.
245#[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/// A consumable handle (e.g. a device-code poll) was used after it had already
255/// been consumed. A caller bug rather than an auth outcome, but surfaced as an
256/// `AuthError` so it flows through the `Result` contract rather than throwing
257/// across the FFI boundary.
258#[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/// An internal invariant was violated (e.g. a poisoned lock). Should not occur
268/// in correct usage; surfaced rather than panicking so it crosses the FFI
269/// boundary as a `Result` failure.
270#[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/// An auth failure that doesn't correspond to a specific [`AuthError`] variant.
280///
281/// The catch-all for an error outside the standard set — a custom
282/// [`AuthStrategy`](crate::AuthStrategy) surfacing its own failure, or an FFI
283/// adaptor reconstructing a failure whose `type` code it can't rebuild into a
284/// typed variant (a variant that wraps a foreign error, or an unrecognised
285/// code). Mirrors serde's `Error::custom`: it carries the already-rendered
286/// message verbatim (its `Display` is that message, with no added prefix), so
287/// a reconstructed error reads exactly as it did on the far side of the
288/// boundary. It serializes as `{ type: "CUSTOM", ... }`, so consumers switching
289/// on the failure code must handle it.
290#[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/// A token store operation failed.
300#[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// ---------------------------------------------------------------------------
312// The canonical enum
313// ---------------------------------------------------------------------------
314
315/// Errors that can occur during an authentication flow.
316#[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    /// The complete set of codes [`AuthError::error_code`] can return — the
381    /// stable, machine-readable contract surfaced across FFI (JS `Error.code`,
382    /// Node-API codes, the `index.d.ts` / `wasm-inline.d.ts` `AuthFailure`
383    /// unions). The bindings derive their expected union from this constant
384    /// rather than re-scraping the per-error [`AuthErrorKind::error_code`] impls,
385    /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in
386    /// lockstep with what `error_code` actually returns.
387    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        // `Store` (and its code) only exists off-wasm — see the enum above.
407        #[cfg(not(target_arch = "wasm32"))]
408        codes::STORE_ERROR,
409    ];
410
411    /// Dispatch to the wrapped concrete error as a trait object.
412    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    /// Stable machine-readable identifier for surfacing across FFI boundaries
438    /// (e.g. JS `Error.code`, Node-API error codes). Delegates to the wrapped
439    /// error's [`AuthErrorKind::error_code`]; every value it can return is
440    /// listed in [`AuthError::ERROR_CODES`].
441    pub fn error_code(&self) -> &'static str {
442        self.kind().error_code()
443    }
444
445    /// Reconstruct an `AuthError` from its stable FFI wire form — the `type`
446    /// code, rendered `message`, and structured `payload` a serialized
447    /// [`AuthError`] carries across the boundary (e.g. the `{ failure }` a
448    /// JS-supplied auth strategy returns; `payload` is the extra fields
449    /// [`AuthErrorKind::payload`] emits alongside `type`/`message`).
450    ///
451    /// This is the inverse an adaptor needs so that failures cross back into
452    /// Rust as real `AuthError`s rather than being flattened to a single opaque
453    /// variant:
454    ///
455    /// - the fixed-message unit codes map straight back to their variant;
456    /// - `WORKSPACE_MISMATCH` rebuilds from its `expected`/`actual` payload;
457    /// - every other code maps to [`AuthError::Custom`] (mirrors serde's
458    ///   `Error::custom`), because the variants that wrap a foreign error
459    ///   (`RequestError`, `InvalidUrl`, `UnsupportedRegion`, …) have no
460    ///   constructor from a string, and `message` is the rendered `Display`
461    ///   (e.g. `"Server error: …"`) — re-wrapping it in a prefixing variant
462    ///   would double the prefix.
463    ///
464    /// `Custom` stores the message verbatim, so a reconstructed error still
465    /// reads exactly as it did on the far side. `error_code()` round-trips
466    /// exactly for the mapped codes and is `CUSTOM` otherwise. Pass an empty map
467    /// for `payload` when there are no structured fields.
468    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
488/// Rebuild a [`WorkspaceMismatch`] from the `expected`/`actual` fields
489/// [`WorkspaceMismatch::payload`] emits. Returns `None` if either field is
490/// absent or not a parseable workspace ID, so the caller can fall back to
491/// [`AuthError::Custom`].
492fn 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
506/// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the
507/// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`.
508///
509/// `type`/`message` come from the canonical code and `Display`; `help`/`url`
510/// are captured generically from the `miette::Diagnostic` surface (so
511/// per-variant help stays colocated on the struct); extra structured fields
512/// come from [`AuthErrorKind::payload`].
513///
514/// `serialize_map` lets the wasm binding render this as a plain JS object via
515/// `Serializer::serialize_maps_as_objects(true)`; serde_json renders a JSON
516/// object directly.
517impl 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        // Emit the per-variant payload first, then the fixed diagnostic fields —
525        // so if a future `payload()` key ever collided with `type`/`message`/
526        // `help`/`url`, the diagnostic field (written last) wins rather than being
527        // clobbered. Mirrors the JS side's `{ ...payload, type, error }`.
528        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
543// ---------------------------------------------------------------------------
544// Ergonomic `From<Foreign>` impls — keep `?` working where call sites lift a
545// foreign error straight into `AuthError` (the per-struct wrapping is internal).
546// ---------------------------------------------------------------------------
547
548impl 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            // Every non-`Auth` variant has a canonical `AuthError` equivalent —
597            // route through it so `bind_client_device` failures carry the same
598            // code/help/payload as every other path. `Auth` already is one.
599            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        // Generic fields the enum emits for every variant.
633        assert_eq!(json["type"], "WORKSPACE_MISMATCH");
634        assert_eq!(json["message"], err.to_string());
635        // `help` comes from the miette diagnostic (present on this variant).
636        assert!(json.get("help").is_some(), "help should be serialized");
637        // Structured payload from `AuthErrorKind::payload`.
638        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        // No per-variant payload keys — the payload loop contributes nothing.
650        assert!(json.get("expected").is_none());
651        assert!(json.get("actual").is_none());
652    }
653
654    /// Every constructable `DeviceClientError` variant maps to its canonical
655    /// `AuthError` code so `bind_client_device` failures share the one envelope
656    /// path. (`Request` wraps a `reqwest::Error`, which has no public
657    /// constructor, so it can't be built here — the same gap the exhaustive
658    /// `error_code` test documents.)
659    #[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        // `Auth` unwraps to the inner error unchanged.
665        assert_eq!(
666            AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(),
667            codes::ACCESS_DENIED,
668        );
669        // Non-`Auth` variants route to their canonical `AuthError` equivalent.
670        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}