Skip to main content

meerkat_core/auth/
error.rs

1//! Auth error types — generic, provider-neutral.
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6/// Top-level auth error surface.
7///
8/// Provider runtimes map their internal failures into these variants before
9/// returning from `resolve_binding`. Callers inspect [`AuthError::kind`] for
10/// stable wire classification.
11#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)]
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[serde(tag = "kind", rename_all = "snake_case")]
14pub enum AuthError {
15    /// Credential material was requested but none was available
16    /// (env lookup returned `None`, file missing, etc.).
17    #[error("missing secret for auth resolution")]
18    MissingSecret,
19    /// Backend/auth combination is not allowed by the provider runtime.
20    #[error("unsupported combination: backend={backend} auth={auth}")]
21    UnsupportedCombination { backend: String, auth: String },
22    /// A required metadata field (workspace id, account id, etc.) was absent.
23    #[error("missing required metadata: {0}")]
24    MissingRequiredMetadata(String),
25    /// Workspace/account identity did not match the binding constraints.
26    #[error("workspace mismatch")]
27    WorkspaceMismatch,
28    /// Persisted credential material is stale relative to AuthMachine lease truth.
29    #[error("stale credential material")]
30    StaleCredential,
31    /// AuthMachine has admitted the credential only for refresh, not provider use.
32    #[error("credential refresh required")]
33    RefreshRequired,
34    /// Credential material exists, but no AuthMachine lease currently owns it.
35    #[error("auth lease absent")]
36    LeaseAbsent,
37    /// AuthMachine requires an explicit user reauthorization flow.
38    #[error("user reauth required")]
39    UserReauthRequired,
40    /// Credential lease has expired.
41    #[error("credential expired")]
42    Expired,
43    /// Refresh attempt failed (network, revoked token, etc.).
44    #[error("refresh failed: {0}")]
45    RefreshFailed(String),
46    /// Interactive login is needed (OAuth, managed store not populated,
47    /// platform default requiring browser flow, etc.).
48    #[error("interactive login required")]
49    InteractiveLoginRequired,
50    /// Host-owned auth resolver is not available on this surface.
51    #[error("host-owned auth unavailable on this surface")]
52    HostOwnedUnavailable,
53    /// Low-level I/O or parsing failure during resolution.
54    #[error("auth I/O failure: {0}")]
55    Io(String),
56    /// Catch-all for provider-specific diagnostic messages that don't map
57    /// to another variant.
58    #[error("auth error: {0}")]
59    Other(String),
60}
61
62impl AuthError {
63    /// Return the stable discriminant kind for wire classification.
64    pub fn kind(&self) -> AuthErrorKind {
65        match self {
66            Self::MissingSecret => AuthErrorKind::MissingSecret,
67            Self::UnsupportedCombination { .. } => AuthErrorKind::UnsupportedCombination,
68            Self::MissingRequiredMetadata(_) => AuthErrorKind::MissingRequiredMetadata,
69            Self::WorkspaceMismatch => AuthErrorKind::WorkspaceMismatch,
70            Self::StaleCredential => AuthErrorKind::StaleCredential,
71            Self::RefreshRequired => AuthErrorKind::RefreshRequired,
72            Self::LeaseAbsent => AuthErrorKind::LeaseAbsent,
73            Self::UserReauthRequired => AuthErrorKind::UserReauthRequired,
74            Self::Expired => AuthErrorKind::Expired,
75            Self::RefreshFailed(_) => AuthErrorKind::RefreshFailed,
76            Self::InteractiveLoginRequired => AuthErrorKind::InteractiveLoginRequired,
77            Self::HostOwnedUnavailable => AuthErrorKind::HostOwnedUnavailable,
78            Self::Io(_) => AuthErrorKind::Io,
79            Self::Other(_) => AuthErrorKind::Other,
80        }
81    }
82}
83
84/// Stable discriminant for [`AuthError`]. Used in serialized error summaries
85/// and for SDK-facing wire codes.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "snake_case")]
89pub enum AuthErrorKind {
90    MissingSecret,
91    UnsupportedCombination,
92    MissingRequiredMetadata,
93    WorkspaceMismatch,
94    StaleCredential,
95    RefreshRequired,
96    LeaseAbsent,
97    UserReauthRequired,
98    Expired,
99    RefreshFailed,
100    InteractiveLoginRequired,
101    HostOwnedUnavailable,
102    Io,
103    Other,
104}
105
106#[cfg(test)]
107#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn kind_maps_each_variant() {
113        assert_eq!(
114            AuthError::MissingSecret.kind(),
115            AuthErrorKind::MissingSecret
116        );
117        assert_eq!(
118            AuthError::WorkspaceMismatch.kind(),
119            AuthErrorKind::WorkspaceMismatch
120        );
121        assert_eq!(
122            AuthError::StaleCredential.kind(),
123            AuthErrorKind::StaleCredential
124        );
125        assert_eq!(
126            AuthError::RefreshRequired.kind(),
127            AuthErrorKind::RefreshRequired
128        );
129        assert_eq!(AuthError::LeaseAbsent.kind(), AuthErrorKind::LeaseAbsent);
130        assert_eq!(
131            AuthError::UserReauthRequired.kind(),
132            AuthErrorKind::UserReauthRequired
133        );
134        assert_eq!(AuthError::Expired.kind(), AuthErrorKind::Expired);
135        assert_eq!(
136            AuthError::RefreshFailed("x".into()).kind(),
137            AuthErrorKind::RefreshFailed,
138        );
139        assert_eq!(
140            AuthError::InteractiveLoginRequired.kind(),
141            AuthErrorKind::InteractiveLoginRequired,
142        );
143    }
144
145    #[test]
146    fn display_is_stable_nonempty() {
147        for err in [
148            AuthError::MissingSecret,
149            AuthError::UnsupportedCombination {
150                backend: "b".into(),
151                auth: "a".into(),
152            },
153            AuthError::MissingRequiredMetadata("workspace_id".into()),
154            AuthError::WorkspaceMismatch,
155            AuthError::StaleCredential,
156            AuthError::RefreshRequired,
157            AuthError::LeaseAbsent,
158            AuthError::UserReauthRequired,
159            AuthError::Expired,
160            AuthError::RefreshFailed("timeout".into()),
161            AuthError::InteractiveLoginRequired,
162            AuthError::HostOwnedUnavailable,
163            AuthError::Io("file missing".into()),
164            AuthError::Other("x".into()),
165        ] {
166            assert!(!err.to_string().is_empty(), "{err:?}");
167        }
168    }
169
170    #[test]
171    fn error_kind_serde_roundtrip() {
172        let k = AuthErrorKind::MissingSecret;
173        let s = serde_json::to_string(&k).unwrap();
174        assert_eq!(s, "\"missing_secret\"");
175        let back: AuthErrorKind = serde_json::from_str(&s).unwrap();
176        assert_eq!(back, k);
177    }
178}