use std::convert::Infallible;
use crate::access_key;
pub trait AuthErrorKind: std::error::Error + miette::Diagnostic {
fn error_code(&self) -> &'static str;
fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
}
pub(crate) mod codes {
pub(crate) const REQUEST_ERROR: &str = "REQUEST_ERROR";
pub(crate) const ACCESS_DENIED: &str = "ACCESS_DENIED";
pub(crate) const INVALID_GRANT: &str = "INVALID_GRANT";
pub(crate) const INVALID_CLIENT: &str = "INVALID_CLIENT";
pub(crate) const INVALID_URL: &str = "INVALID_URL";
pub(crate) const INVALID_REGION: &str = "INVALID_REGION";
pub(crate) const INVALID_CRN: &str = "INVALID_CRN";
pub(crate) const WORKSPACE_MISMATCH: &str = "WORKSPACE_MISMATCH";
pub(crate) const INVALID_WORKSPACE_ID: &str = "INVALID_WORKSPACE_ID";
pub(crate) const MISSING_WORKSPACE_CRN: &str = "MISSING_WORKSPACE_CRN";
pub(crate) const NOT_AUTHENTICATED: &str = "NOT_AUTHENTICATED";
pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN";
pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY";
pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN";
pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR";
pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED";
pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR";
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const STORE_ERROR: &str = "STORE_ERROR";
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("HTTP request failed: {0}")]
pub struct RequestError(pub reqwest::Error);
impl AuthErrorKind for RequestError {
fn error_code(&self) -> &'static str {
codes::REQUEST_ERROR
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Authorization was denied")]
pub struct AccessDenied;
impl AuthErrorKind for AccessDenied {
fn error_code(&self) -> &'static str {
codes::ACCESS_DENIED
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid grant")]
pub struct InvalidGrant;
impl AuthErrorKind for InvalidGrant {
fn error_code(&self) -> &'static str {
codes::INVALID_GRANT
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid client")]
pub struct InvalidClient;
impl AuthErrorKind for InvalidClient {
fn error_code(&self) -> &'static str {
codes::INVALID_CLIENT
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid URL: {0}")]
pub struct InvalidUrl(pub url::ParseError);
impl AuthErrorKind for InvalidUrl {
fn error_code(&self) -> &'static str {
codes::INVALID_URL
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Unsupported region: {0}")]
#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))]
pub struct UnsupportedRegion(pub cts_common::RegionError);
impl AuthErrorKind for UnsupportedRegion {
fn error_code(&self) -> &'static str {
codes::INVALID_REGION
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid workspace CRN: {0}")]
#[diagnostic(help(
"A workspace CRN looks like `crn:<region>:<workspace-id>`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`."
))]
pub struct InvalidCrn(pub cts_common::InvalidCrn);
impl AuthErrorKind for InvalidCrn {
fn error_code(&self) -> &'static str {
codes::INVALID_CRN
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")]
#[diagnostic(help(
"The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to."
))]
pub struct WorkspaceMismatch {
pub expected_workspace: cts_common::WorkspaceId,
pub token_workspace: cts_common::WorkspaceId,
}
impl AuthErrorKind for WorkspaceMismatch {
fn error_code(&self) -> &'static str {
codes::WORKSPACE_MISMATCH
}
fn payload(&self) -> serde_json::Map<String, serde_json::Value> {
[
(
"expected".to_string(),
self.expected_workspace.to_string().into(),
),
(
"actual".to_string(),
self.token_workspace.to_string().into(),
),
]
.into_iter()
.collect()
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid workspace ID: {0}")]
pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId);
impl AuthErrorKind for InvalidWorkspaceId {
fn error_code(&self) -> &'static str {
codes::INVALID_WORKSPACE_ID
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error(
"Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn"
)]
#[diagnostic(help(
"Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`."
))]
pub struct MissingWorkspaceCrn;
impl AuthErrorKind for MissingWorkspaceCrn {
fn error_code(&self) -> &'static str {
codes::MISSING_WORKSPACE_CRN
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Not authenticated")]
#[diagnostic(help(
"Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth."
))]
pub struct NotAuthenticated;
impl AuthErrorKind for NotAuthenticated {
fn error_code(&self) -> &'static str {
codes::NOT_AUTHENTICATED
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Token expired")]
pub struct TokenExpired;
impl AuthErrorKind for TokenExpired {
fn error_code(&self) -> &'static str {
codes::EXPIRED_TOKEN
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid access key: {0}")]
#[diagnostic(help("Access keys have the form `CSAK<key-id>.<secret>`."))]
pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey);
impl AuthErrorKind for InvalidAccessKeyError {
fn error_code(&self) -> &'static str {
codes::INVALID_ACCESS_KEY
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Invalid token: {0}")]
pub struct InvalidToken(pub String);
impl AuthErrorKind for InvalidToken {
fn error_code(&self) -> &'static str {
codes::INVALID_TOKEN
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Server error: {0}")]
pub struct ServerError(pub String);
impl AuthErrorKind for ServerError {
fn error_code(&self) -> &'static str {
codes::SERVER_ERROR
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Handle already consumed")]
pub struct AlreadyConsumed;
impl AuthErrorKind for AlreadyConsumed {
fn error_code(&self) -> &'static str {
codes::ALREADY_CONSUMED
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Internal error: {0}")]
pub struct InternalError(pub String);
impl AuthErrorKind for InternalError {
fn error_code(&self) -> &'static str {
codes::INTERNAL_ERROR
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("Token store error: {0}")]
pub struct StoreError(pub stack_profile::ProfileError);
#[cfg(not(target_arch = "wasm32"))]
impl AuthErrorKind for StoreError {
fn error_code(&self) -> &'static str {
codes::STORE_ERROR
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum AuthError {
#[error(transparent)]
#[diagnostic(transparent)]
Request(#[from] RequestError),
#[error(transparent)]
#[diagnostic(transparent)]
AccessDenied(#[from] AccessDenied),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidGrant(#[from] InvalidGrant),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidClient(#[from] InvalidClient),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidUrl(#[from] InvalidUrl),
#[error(transparent)]
#[diagnostic(transparent)]
Region(#[from] UnsupportedRegion),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidCrn(#[from] InvalidCrn),
#[error(transparent)]
#[diagnostic(transparent)]
WorkspaceMismatch(#[from] WorkspaceMismatch),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidWorkspaceId(#[from] InvalidWorkspaceId),
#[error(transparent)]
#[diagnostic(transparent)]
MissingWorkspaceCrn(#[from] MissingWorkspaceCrn),
#[error(transparent)]
#[diagnostic(transparent)]
NotAuthenticated(#[from] NotAuthenticated),
#[error(transparent)]
#[diagnostic(transparent)]
TokenExpired(#[from] TokenExpired),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidAccessKey(#[from] InvalidAccessKeyError),
#[error(transparent)]
#[diagnostic(transparent)]
InvalidToken(#[from] InvalidToken),
#[error(transparent)]
#[diagnostic(transparent)]
Server(#[from] ServerError),
#[error(transparent)]
#[diagnostic(transparent)]
AlreadyConsumed(#[from] AlreadyConsumed),
#[error(transparent)]
#[diagnostic(transparent)]
Internal(#[from] InternalError),
#[cfg(not(target_arch = "wasm32"))]
#[error(transparent)]
#[diagnostic(transparent)]
Store(#[from] StoreError),
}
impl AuthError {
pub const ERROR_CODES: &'static [&'static str] = &[
codes::REQUEST_ERROR,
codes::ACCESS_DENIED,
codes::INVALID_GRANT,
codes::INVALID_CLIENT,
codes::INVALID_URL,
codes::INVALID_REGION,
codes::INVALID_CRN,
codes::WORKSPACE_MISMATCH,
codes::INVALID_WORKSPACE_ID,
codes::MISSING_WORKSPACE_CRN,
codes::NOT_AUTHENTICATED,
codes::EXPIRED_TOKEN,
codes::INVALID_ACCESS_KEY,
codes::INVALID_TOKEN,
codes::SERVER_ERROR,
codes::ALREADY_CONSUMED,
codes::INTERNAL_ERROR,
#[cfg(not(target_arch = "wasm32"))]
codes::STORE_ERROR,
];
fn kind(&self) -> &dyn AuthErrorKind {
match self {
Self::Request(e) => e,
Self::AccessDenied(e) => e,
Self::InvalidGrant(e) => e,
Self::InvalidClient(e) => e,
Self::InvalidUrl(e) => e,
Self::Region(e) => e,
Self::InvalidCrn(e) => e,
Self::WorkspaceMismatch(e) => e,
Self::InvalidWorkspaceId(e) => e,
Self::MissingWorkspaceCrn(e) => e,
Self::NotAuthenticated(e) => e,
Self::TokenExpired(e) => e,
Self::InvalidAccessKey(e) => e,
Self::InvalidToken(e) => e,
Self::Server(e) => e,
Self::AlreadyConsumed(e) => e,
Self::Internal(e) => e,
#[cfg(not(target_arch = "wasm32"))]
Self::Store(e) => e,
}
}
pub fn error_code(&self) -> &'static str {
self.kind().error_code()
}
}
impl serde::Serialize for AuthError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use miette::Diagnostic;
use serde::ser::SerializeMap;
let kind = self.kind();
let mut map = serializer.serialize_map(None)?;
for (key, value) in kind.payload() {
map.serialize_entry(&key, &value)?;
}
map.serialize_entry("type", kind.error_code())?;
map.serialize_entry("message", &self.to_string())?;
if let Some(help) = self.help() {
map.serialize_entry("help", &help.to_string())?;
}
if let Some(url) = self.url() {
map.serialize_entry("url", &url.to_string())?;
}
map.end()
}
}
impl From<reqwest::Error> for AuthError {
fn from(e: reqwest::Error) -> Self {
Self::Request(RequestError(e))
}
}
impl From<url::ParseError> for AuthError {
fn from(e: url::ParseError) -> Self {
Self::InvalidUrl(InvalidUrl(e))
}
}
impl From<cts_common::RegionError> for AuthError {
fn from(e: cts_common::RegionError) -> Self {
Self::Region(UnsupportedRegion(e))
}
}
impl From<cts_common::InvalidCrn> for AuthError {
fn from(e: cts_common::InvalidCrn) -> Self {
Self::InvalidCrn(InvalidCrn(e))
}
}
impl From<cts_common::InvalidWorkspaceId> for AuthError {
fn from(e: cts_common::InvalidWorkspaceId) -> Self {
Self::InvalidWorkspaceId(InvalidWorkspaceId(e))
}
}
impl From<access_key::InvalidAccessKey> for AuthError {
fn from(e: access_key::InvalidAccessKey) -> Self {
Self::InvalidAccessKey(InvalidAccessKeyError(e))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl From<stack_profile::ProfileError> for AuthError {
fn from(e: stack_profile::ProfileError) -> Self {
Self::Store(StoreError(e))
}
}
#[cfg(not(target_arch = "wasm32"))]
impl From<crate::DeviceClientError> for AuthError {
fn from(e: crate::DeviceClientError) -> Self {
use crate::DeviceClientError as E;
match e {
E::Profile(e) => e.into(),
E::Auth(e) => e,
E::Request(e) => e.into(),
E::InvalidUrl(e) => e.into(),
E::Server { status, body } => {
Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}")))
}
}
}
}
impl From<Infallible> for AuthError {
fn from(never: Infallible) -> Self {
match never {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serialize_emits_type_message_help_and_payload() {
let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap();
let actual: cts_common::WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap();
let (expected_s, actual_s) = (expected.to_string(), actual.to_string());
let err = AuthError::WorkspaceMismatch(WorkspaceMismatch {
expected_workspace: expected,
token_workspace: actual,
});
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "WORKSPACE_MISMATCH");
assert_eq!(json["message"], err.to_string());
assert!(json.get("help").is_some(), "help should be serialized");
assert_eq!(json["expected"], expected_s);
assert_eq!(json["actual"], actual_s);
}
#[test]
fn serialize_variant_without_payload_emits_only_generic_fields() {
let err = AuthError::MissingWorkspaceCrn(MissingWorkspaceCrn);
let json = serde_json::to_value(&err).unwrap();
assert_eq!(json["type"], "MISSING_WORKSPACE_CRN");
assert_eq!(json["message"], err.to_string());
assert!(json.get("expected").is_none());
assert!(json.get("actual").is_none());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn device_client_error_maps_to_canonical_auth_error() {
use crate::DeviceClientError as E;
assert_eq!(
AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(),
codes::ACCESS_DENIED,
);
assert_eq!(
AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(),
codes::STORE_ERROR,
);
assert_eq!(
AuthError::from(E::InvalidUrl("not a url".parse::<url::Url>().unwrap_err()))
.error_code(),
codes::INVALID_URL,
);
let server = AuthError::from(E::Server {
status: 500,
body: "boom".to_string(),
});
assert_eq!(server.error_code(), codes::SERVER_ERROR);
assert!(
server.to_string().contains("ZeroKMS returned 500: boom"),
"server error should preserve the status/body detail: {server}"
);
}
}