#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")]
#![doc = include_str!("../README.md")]
#![deny(unsafe_code)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::expect_used)]
#![warn(clippy::panic)]
#![warn(clippy::mem_forget)]
#![warn(clippy::print_stdout)]
#![warn(clippy::print_stderr)]
#![warn(clippy::dbg_macro)]
#![warn(unreachable_pub)]
#![warn(unused_results)]
#![warn(clippy::todo)]
#![warn(clippy::unimplemented)]
#![cfg_attr(test, allow(clippy::unwrap_used))]
#![cfg_attr(test, allow(clippy::expect_used))]
#![cfg_attr(test, allow(clippy::panic))]
#![cfg_attr(test, allow(unused_results))]
use std::future::Future;
#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
use std::time::Duration;
use vitaminc::protected::OpaqueDebug;
use zeroize::ZeroizeOnDrop;
mod access_key;
mod access_key_refresher;
mod access_key_strategy;
mod auth_strategy_fn;
mod authorize_dto;
mod auto_refresh;
mod auto_strategy;
mod clock;
mod device_session_refresher;
mod device_session_strategy;
mod error;
mod oidc_federation_strategy;
mod oidc_refresher;
mod refresher;
mod service_token;
mod token;
mod token_store;
#[cfg(not(target_arch = "wasm32"))]
pub use error::StoreError;
pub use error::{
AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError,
InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl,
InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError,
TokenExpired, UnsupportedRegion, WorkspaceMismatch,
};
#[cfg(not(target_arch = "wasm32"))]
mod device_client;
#[cfg(not(target_arch = "wasm32"))]
mod device_code;
#[cfg(any(test, feature = "test-utils"))]
mod static_token_strategy;
#[cfg(test)]
mod test_support;
pub use access_key::{AccessKey, InvalidAccessKey};
pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
pub use auth_strategy_fn::AuthStrategyFn;
pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder};
pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder};
pub use oidc_refresher::{OidcProvider, OidcProviderFn};
pub use service_token::ServiceToken;
#[cfg(any(test, feature = "test-utils"))]
pub use static_token_strategy::StaticTokenStrategy;
pub use token::Token;
pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn};
#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")]
pub type OAuthStrategy = DeviceSessionStrategy;
#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")]
pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder;
#[cfg(not(target_arch = "wasm32"))]
pub use device_client::{bind_client_device, DeviceClientError};
#[cfg(not(target_arch = "wasm32"))]
pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
#[cfg(not(target_arch = "wasm32"))]
pub use stack_profile::DeviceIdentity;
pub mod auth {
pub use crate::{
AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy,
AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder,
DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey,
OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn,
SecretToken, ServiceToken,
};
#[cfg(not(target_arch = "wasm32"))]
pub use crate::{
bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder,
DeviceIdentity, PendingDeviceCode,
};
#[cfg(any(test, feature = "test-utils"))]
pub use crate::StaticTokenStrategy;
#[allow(deprecated)]
pub use crate::{OAuthStrategy, OAuthStrategyBuilder};
}
pub mod store {
pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn};
}
#[cfg_attr(doc, aquamarine::aquamarine)]
#[cfg(not(target_arch = "wasm32"))]
pub trait AuthStrategy: Send {
fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
}
#[cfg(target_arch = "wasm32")]
pub trait AuthStrategy {
fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
}
#[cfg(not(target_arch = "wasm32"))]
pub trait AuthStrategyBounds: Send + Sync + 'static {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + Sync + 'static> AuthStrategyBounds for T {}
#[cfg(target_arch = "wasm32")]
pub trait AuthStrategyBounds: 'static {}
#[cfg(target_arch = "wasm32")]
impl<T: 'static> AuthStrategyBounds for T {}
#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
#[serde(transparent)]
pub struct SecretToken(String);
impl SecretToken {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
match std::env::var("CS_CTS_HOST") {
Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
_ => Ok(None),
}
}
pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
if !url.path().ends_with('/') {
url.set_path(&format!("{}/", url.path()));
}
url
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn decode_jwt_payload_wasm<C>(token: &str) -> Result<C, AuthError>
where
C: serde::de::DeserializeOwned,
{
use base64::Engine;
let segments: Vec<&str> = token.split('.').collect();
if segments.len() != 3 {
return Err(AuthError::InvalidToken(error::InvalidToken(
"JWT must have three segments".to_string(),
)));
}
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(segments[1])
.map_err(|e| {
AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}")))
})?;
serde_json::from_slice(&payload).map_err(|e| {
AuthError::InvalidToken(error::InvalidToken(format!(
"failed to decode JWT claims: {e}"
)))
})
}
#[cfg(any(test, feature = "test-utils"))]
pub(crate) fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
pub(crate) fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.pool_idle_timeout(Duration::from_secs(5))
.pool_max_idle_per_host(10)
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
pub(crate) fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::unwrap_used)]
fn auth_error_code_is_stable_for_every_variant() {
use std::collections::BTreeSet;
let workspace = "ZVATKW3VHMFG27DY"
.parse::<cts_common::WorkspaceId>()
.unwrap();
let cases: Vec<(AuthError, &str)> = vec![
(
AuthError::AccessDenied(crate::error::AccessDenied),
"ACCESS_DENIED",
),
(
AuthError::TokenExpired(crate::error::TokenExpired),
"EXPIRED_TOKEN",
),
(
AuthError::InvalidGrant(crate::error::InvalidGrant),
"INVALID_GRANT",
),
(
AuthError::InvalidClient(crate::error::InvalidClient),
"INVALID_CLIENT",
),
(
AuthError::NotAuthenticated(crate::error::NotAuthenticated),
"NOT_AUTHENTICATED",
),
(
AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
"MISSING_WORKSPACE_CRN",
),
(
AuthError::AlreadyConsumed(crate::error::AlreadyConsumed),
"ALREADY_CONSUMED",
),
(
AuthError::Server(crate::error::ServerError("boom".into())),
"SERVER_ERROR",
),
(
AuthError::Internal(crate::error::InternalError("boom".into())),
"INTERNAL_ERROR",
),
(
AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())),
"INVALID_TOKEN",
),
(
AuthError::Custom(crate::error::CustomError("boom".into())),
"CUSTOM",
),
(
AuthError::from("not a url".parse::<url::Url>().unwrap_err()),
"INVALID_URL",
),
(
AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
"INVALID_REGION",
),
(
AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
"INVALID_CRN",
),
(
AuthError::from("!".parse::<cts_common::WorkspaceId>().unwrap_err()),
"INVALID_WORKSPACE_ID",
),
(
AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
"INVALID_ACCESS_KEY",
),
(
AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
expected_workspace: workspace,
token_workspace: workspace,
}),
"WORKSPACE_MISMATCH",
),
#[cfg(not(target_arch = "wasm32"))]
(
AuthError::from(stack_profile::ProfileError::HomeDirNotFound),
"STORE_ERROR",
),
];
let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect();
let mut from_variants: BTreeSet<&str> = BTreeSet::new();
for (err, expected) in cases {
assert_eq!(err.error_code(), expected, "error_code for {err:?}");
assert!(
declared.contains(expected),
"{expected} is returned by error_code() but missing from AuthError::ERROR_CODES",
);
from_variants.insert(expected);
}
from_variants.insert("REQUEST_ERROR");
assert_eq!(
declared, from_variants,
"AuthError::ERROR_CODES drifted from the codes error_code() returns",
);
}
#[test]
fn from_error_code_maps_known_codes_and_falls_back_to_custom() {
use crate::AuthErrorKind;
let empty = serde_json::Map::new();
for code in [
"NOT_AUTHENTICATED",
"EXPIRED_TOKEN",
"ACCESS_DENIED",
"INVALID_GRANT",
"INVALID_CLIENT",
"MISSING_WORKSPACE_CRN",
"ALREADY_CONSUMED",
] {
let err = AuthError::from_error_code(code, "unused for unit variants", &empty);
assert_eq!(err.error_code(), code, "unit code should round-trip");
assert!(
!matches!(err, AuthError::Custom(_)),
"{code} should map to its typed variant, not Custom",
);
}
let workspace = "ZVATKW3VHMFG27DY"
.parse::<cts_common::WorkspaceId>()
.unwrap();
let payload = crate::error::WorkspaceMismatch {
expected_workspace: workspace,
token_workspace: workspace,
}
.payload();
let err = AuthError::from_error_code("WORKSPACE_MISMATCH", "unused", &payload);
assert_eq!(err.error_code(), "WORKSPACE_MISMATCH");
assert!(!matches!(err, AuthError::Custom(_)));
for code in [
"SERVER_ERROR",
"REQUEST_ERROR",
"WORKSPACE_MISMATCH",
"SOME_UNRECOGNISED_CODE",
] {
let err = AuthError::from_error_code(code, "Server error: boom", &empty);
assert_eq!(err.error_code(), "CUSTOM", "{code} should map to Custom");
assert_eq!(
err.to_string(),
"Server error: boom",
"Custom preserves the wire message verbatim",
);
}
}
#[test]
fn annotated_variants_expose_diagnostic_help() {
use miette::Diagnostic;
let workspace = "ZVATKW3VHMFG27DY"
.parse::<cts_common::WorkspaceId>()
.unwrap();
let with_help: Vec<(AuthError, &str)> = vec![
(
AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
"supported region",
),
(
AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
"crn:<region>:<workspace-id>",
),
(
AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
expected_workspace: workspace,
token_workspace: workspace,
}),
"different workspace",
),
(
AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
"CS_WORKSPACE_CRN",
),
(
AuthError::NotAuthenticated(crate::error::NotAuthenticated),
"stash login",
),
(
AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
"CSAK<key-id>.<secret>",
),
];
for (err, substring) in with_help {
let help = err.help().map(|h| h.to_string());
assert!(
help.as_deref().is_some_and(|h| h.contains(substring)),
"{err:?} should carry help containing {substring:?}, got: {help:?}",
);
}
for err in [
AuthError::TokenExpired(crate::error::TokenExpired),
AuthError::InvalidToken(crate::error::InvalidToken("malformed".to_string())),
] {
assert!(
err.help().is_none(),
"{err:?} has no #[diagnostic(help)] and should report None",
);
}
}
}