Skip to main content

stack_auth/
lib.rs

1//! Authentication strategies for [CipherStash](https://cipherstash.com) services.
2//!
3//! All strategies implement the [`AuthStrategy`] trait, which provides a single
4//! [`get_token`](AuthStrategy::get_token) method that returns a valid
5//! [`ServiceToken`]. Token caching and refresh are handled automatically.
6//!
7//! # Strategies
8//!
9//! | Strategy | Use case | Credentials |
10//! |---|---|---|
11//! | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` |
12//! | [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region |
13//! | [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) |
14//! | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser |
15//! | `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is |
16//!
17//! # Quick start
18//!
19//! For most applications, [`AutoStrategy`] is the simplest way to get started:
20//!
21//! ```no_run
22//! use stack_auth::AutoStrategy;
23//!
24//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
25//! let strategy = AutoStrategy::detect()?;
26//! // That's it — get_token() handles the rest.
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! For service-to-service authentication with an access key:
32//!
33//! ```no_run
34//! use stack_auth::AccessKeyStrategy;
35//! use cts_common::Region;
36//!
37//! # fn run() -> Result<(), Box<dyn std::error::Error>> {
38//! let region = Region::aws("ap-southeast-2")?;
39//! let key = "CSAKkeyId.keySecret".parse()?;
40//! let strategy = AccessKeyStrategy::new(region, key)?;
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! # Security
46//!
47//! Sensitive values ([`SecretToken`]) are automatically zeroized when dropped
48//! and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental
49//! leaks in logs.
50
51// Security lints
52#![deny(unsafe_code)]
53#![warn(clippy::unwrap_used)]
54#![warn(clippy::expect_used)]
55#![warn(clippy::panic)]
56// Prevent mem::forget from bypassing ZeroizeOnDrop
57#![warn(clippy::mem_forget)]
58// Prevent accidental data leaks via output
59#![warn(clippy::print_stdout)]
60#![warn(clippy::print_stderr)]
61#![warn(clippy::dbg_macro)]
62// Code quality
63#![warn(unreachable_pub)]
64#![warn(unused_results)]
65#![warn(clippy::todo)]
66#![warn(clippy::unimplemented)]
67// Relax in tests
68#![cfg_attr(test, allow(clippy::unwrap_used))]
69#![cfg_attr(test, allow(clippy::expect_used))]
70#![cfg_attr(test, allow(clippy::panic))]
71#![cfg_attr(test, allow(unused_results))]
72
73use std::convert::Infallible;
74use std::future::Future;
75#[cfg(not(any(test, feature = "test-utils")))]
76use std::time::Duration;
77
78use vitaminc::protected::OpaqueDebug;
79use zeroize::ZeroizeOnDrop;
80
81mod access_key;
82mod access_key_refresher;
83mod access_key_strategy;
84mod auto_refresh;
85mod auto_strategy;
86mod device_code;
87mod oauth_refresher;
88mod oauth_strategy;
89mod refresher;
90mod service_token;
91mod token;
92
93#[cfg(any(test, feature = "test-utils"))]
94mod static_token_strategy;
95
96pub use access_key::{AccessKey, InvalidAccessKey};
97pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
98pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
99pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
100pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder};
101pub use service_token::ServiceToken;
102#[cfg(any(test, feature = "test-utils"))]
103pub use static_token_strategy::StaticTokenStrategy;
104pub use token::Token;
105
106// Re-exports from stack-profile for backward compatibility.
107pub use stack_profile::DeviceIdentity;
108
109/// A strategy for obtaining access tokens.
110///
111/// Implementations handle all details of authentication, token caching, and
112/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
113/// they need a valid token.
114///
115/// The trait is designed to be implemented for `&T`, so that callers can use
116/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy.
117pub trait AuthStrategy: Send {
118    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
119    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
120}
121
122/// A sensitive token string that is zeroized on drop and hidden from debug output.
123///
124/// `SecretToken` wraps a `String` and enforces two invariants:
125///
126/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
127///   the token goes out of scope, preventing it from lingering in memory.
128/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
129///   the actual value, so tokens won't leak into logs or error messages.
130///
131/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
132/// loaded from configuration or an environment variable).
133#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
134#[serde(transparent)]
135pub struct SecretToken(String);
136
137impl SecretToken {
138    /// Create a new `SecretToken` from a string value.
139    pub fn new(value: impl Into<String>) -> Self {
140        Self(value.into())
141    }
142
143    /// Expose the inner token string for FFI boundaries.
144    pub fn as_str(&self) -> &str {
145        &self.0
146    }
147}
148
149/// Errors that can occur during an authentication flow.
150#[derive(Debug, thiserror::Error, miette::Diagnostic)]
151#[non_exhaustive]
152pub enum AuthError {
153    /// The HTTP request to the auth server failed (network error, timeout, etc.).
154    #[error("HTTP request failed: {0}")]
155    Request(#[from] reqwest::Error),
156    /// The user denied the authorization request.
157    #[error("Authorization was denied")]
158    AccessDenied,
159    /// The grant type was rejected by the server.
160    #[error("Invalid grant")]
161    InvalidGrant,
162    /// The client ID is not recognized.
163    #[error("Invalid client")]
164    InvalidClient,
165    /// A URL could not be parsed.
166    #[error("Invalid URL: {0}")]
167    InvalidUrl(#[from] url::ParseError),
168    /// The requested region is not supported.
169    #[error("Unsupported region: {0}")]
170    Region(#[from] cts_common::RegionError),
171    /// The workspace CRN could not be parsed.
172    #[error("Invalid workspace CRN: {0}")]
173    InvalidCrn(cts_common::InvalidCrn),
174    /// An access key was provided but the workspace CRN is missing.
175    ///
176    /// Set the `CS_WORKSPACE_CRN` environment variable or call
177    /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
178    #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
179    MissingWorkspaceCrn,
180    /// No credentials are available (e.g. not logged in, no access key configured).
181    #[error("Not authenticated")]
182    NotAuthenticated,
183    /// A token (access token or device code) has expired.
184    #[error("Token expired")]
185    TokenExpired,
186    /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
187    #[error("Invalid access key: {0}")]
188    InvalidAccessKey(#[from] access_key::InvalidAccessKey),
189    /// The JWT could not be decoded or its claims are malformed.
190    #[error("Invalid token: {0}")]
191    InvalidToken(String),
192    /// An unexpected error was returned by the auth server.
193    #[error("Server error: {0}")]
194    Server(String),
195    /// A token store operation failed.
196    #[error("Token store error: {0}")]
197    Store(#[from] stack_profile::ProfileError),
198}
199
200impl From<Infallible> for AuthError {
201    fn from(never: Infallible) -> Self {
202        match never {}
203    }
204}
205
206/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
207///
208/// Returns `Ok(None)` if the variable is not set or empty.
209/// Returns `Ok(Some(url))` if the variable is set and valid.
210/// Returns `Err(_)` if the variable is set but not a valid URL.
211pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
212    match std::env::var("CS_CTS_HOST") {
213        Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
214        _ => Ok(None),
215    }
216}
217
218/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
219/// appends to the path rather than replacing the last segment.
220pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
221    if !url.path().ends_with('/') {
222        url.set_path(&format!("{}/", url.path()));
223    }
224    url
225}
226
227/// Create a [`reqwest::Client`] with standard timeouts.
228///
229/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
230/// does not auto-advance time past the connect timeout before the mock server
231/// can respond.
232pub(crate) fn http_client() -> reqwest::Client {
233    #[cfg(any(test, feature = "test-utils"))]
234    {
235        reqwest::Client::builder()
236            .pool_max_idle_per_host(10)
237            .build()
238            .unwrap_or_else(|_| reqwest::Client::new())
239    }
240    #[cfg(not(any(test, feature = "test-utils")))]
241    {
242        reqwest::Client::builder()
243            .connect_timeout(Duration::from_secs(10))
244            .timeout(Duration::from_secs(30))
245            .pool_idle_timeout(Duration::from_secs(5))
246            .pool_max_idle_per_host(10)
247            .build()
248            .unwrap_or_else(|_| reqwest::Client::new())
249    }
250}