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//! # Token refresh
52//!
53//! All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`],
54//! [`AutoStrategy`]) share the same internal refresh engine. See the
55//! [`AuthStrategy`] trait docs for a full description of the concurrency model
56//! and flow diagram.
57
58// Security lints
59#![deny(unsafe_code)]
60#![warn(clippy::unwrap_used)]
61#![warn(clippy::expect_used)]
62#![warn(clippy::panic)]
63// Prevent mem::forget from bypassing ZeroizeOnDrop
64#![warn(clippy::mem_forget)]
65// Prevent accidental data leaks via output
66#![warn(clippy::print_stdout)]
67#![warn(clippy::print_stderr)]
68#![warn(clippy::dbg_macro)]
69// Code quality
70#![warn(unreachable_pub)]
71#![warn(unused_results)]
72#![warn(clippy::todo)]
73#![warn(clippy::unimplemented)]
74// Relax in tests
75#![cfg_attr(test, allow(clippy::unwrap_used))]
76#![cfg_attr(test, allow(clippy::expect_used))]
77#![cfg_attr(test, allow(clippy::panic))]
78#![cfg_attr(test, allow(unused_results))]
79
80use std::convert::Infallible;
81use std::future::Future;
82#[cfg(not(any(test, feature = "test-utils")))]
83use std::time::Duration;
84
85use vitaminc::protected::OpaqueDebug;
86use zeroize::ZeroizeOnDrop;
87
88mod access_key;
89mod access_key_refresher;
90mod access_key_strategy;
91mod auto_refresh;
92mod auto_strategy;
93mod device_code;
94mod oauth_refresher;
95mod oauth_strategy;
96mod refresher;
97mod service_token;
98mod token;
99
100#[cfg(any(test, feature = "test-utils"))]
101mod static_token_strategy;
102
103pub use access_key::{AccessKey, InvalidAccessKey};
104pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
105pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
106pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
107pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder};
108pub use service_token::ServiceToken;
109#[cfg(any(test, feature = "test-utils"))]
110pub use static_token_strategy::StaticTokenStrategy;
111pub use token::Token;
112
113// Re-exports from stack-profile for backward compatibility.
114pub use stack_profile::DeviceIdentity;
115
116/// A strategy for obtaining access tokens.
117///
118/// Implementations handle all details of authentication, token caching, and
119/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
120/// they need a valid token.
121///
122/// The trait is designed to be implemented for `&T`, so that callers can use
123/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy.
124///
125/// # Token refresh
126///
127/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`],
128/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
129/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
130/// behaves under concurrent access.
131///
132/// ## Expiry vs usability
133///
134/// A token has two time thresholds:
135///
136/// - **Expired** — the token is within **90 seconds** of its `expires_at`
137/// timestamp. This triggers a preemptive refresh attempt.
138/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
139/// A token can be "expired" (in the preemptive sense) but still "usable"
140/// (the server will still accept it).
141///
142/// ## Concurrent refresh strategies
143///
144/// The gap between "expired" and "unusable" enables two refresh modes:
145///
146/// 1. **Expiring but still usable** — The first caller triggers a background
147/// refresh. Concurrent callers receive the current (still-valid) token
148/// immediately without blocking.
149/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
150/// callers wait until the refresh completes, then all receive the new token.
151///
152/// Only one refresh runs at a time, regardless of how many callers request a
153/// token concurrently.
154///
155/// ## Flow diagram
156///
157/// ```mermaid
158/// flowchart TD
159/// Start["get_token()"] --> Lock["Acquire lock"]
160/// Lock --> Cached{Token cached?}
161/// Cached -- No --> InitAuth["Authenticate
162/// (lock held)"]
163/// InitAuth -- OK --> ReturnNew["Return new token"]
164/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
165/// InitAuth -- Err --> ErrAuth["Return error"]
166/// Cached -- Yes --> CheckRefresh{Expired?}
167///
168/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
169///
170/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
171/// InProgress -- Yes --> WaitOrReturn["Return token if usable,
172/// else wait for refresh"]
173/// WaitOrReturn -- OK --> ReturnOk
174/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
175///
176/// InProgress -- No --> HasCred{Refresh credential?}
177/// HasCred -- None --> CheckUsable["Return token if usable,
178/// else TokenExpired"]
179///
180/// HasCred -- Yes --> Usable{Still usable?}
181///
182/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
183/// (lock released)"]
184/// NonBlocking --> ReturnOld["Return current token"]
185///
186/// Usable -- "No (fully expired)" --> Blocking["Refresh
187/// (lock held)"]
188/// Blocking -- OK --> ReturnNew2["Return new token"]
189/// Blocking -- Err --> ErrExpired["TokenExpired"]
190/// ```
191#[cfg_attr(doc, aquamarine::aquamarine)]
192pub trait AuthStrategy: Send {
193 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
194 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
195}
196
197/// A sensitive token string that is zeroized on drop and hidden from debug output.
198///
199/// `SecretToken` wraps a `String` and enforces two invariants:
200///
201/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
202/// the token goes out of scope, preventing it from lingering in memory.
203/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
204/// the actual value, so tokens won't leak into logs or error messages.
205///
206/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
207/// loaded from configuration or an environment variable).
208#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
209#[serde(transparent)]
210pub struct SecretToken(String);
211
212impl SecretToken {
213 /// Create a new `SecretToken` from a string value.
214 pub fn new(value: impl Into<String>) -> Self {
215 Self(value.into())
216 }
217
218 /// Expose the inner token string for FFI boundaries.
219 pub fn as_str(&self) -> &str {
220 &self.0
221 }
222}
223
224/// Errors that can occur during an authentication flow.
225#[derive(Debug, thiserror::Error, miette::Diagnostic)]
226#[non_exhaustive]
227pub enum AuthError {
228 /// The HTTP request to the auth server failed (network error, timeout, etc.).
229 #[error("HTTP request failed: {0}")]
230 Request(#[from] reqwest::Error),
231 /// The user denied the authorization request.
232 #[error("Authorization was denied")]
233 AccessDenied,
234 /// The grant type was rejected by the server.
235 #[error("Invalid grant")]
236 InvalidGrant,
237 /// The client ID is not recognized.
238 #[error("Invalid client")]
239 InvalidClient,
240 /// A URL could not be parsed.
241 #[error("Invalid URL: {0}")]
242 InvalidUrl(#[from] url::ParseError),
243 /// The requested region is not supported.
244 #[error("Unsupported region: {0}")]
245 Region(#[from] cts_common::RegionError),
246 /// The workspace CRN could not be parsed.
247 #[error("Invalid workspace CRN: {0}")]
248 InvalidCrn(cts_common::InvalidCrn),
249 /// An access key was provided but the workspace CRN is missing.
250 ///
251 /// Set the `CS_WORKSPACE_CRN` environment variable or call
252 /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
253 #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
254 MissingWorkspaceCrn,
255 /// No credentials are available (e.g. not logged in, no access key configured).
256 #[error("Not authenticated")]
257 NotAuthenticated,
258 /// A token (access token or device code) has expired.
259 #[error("Token expired")]
260 TokenExpired,
261 /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
262 #[error("Invalid access key: {0}")]
263 InvalidAccessKey(#[from] access_key::InvalidAccessKey),
264 /// The JWT could not be decoded or its claims are malformed.
265 #[error("Invalid token: {0}")]
266 InvalidToken(String),
267 /// An unexpected error was returned by the auth server.
268 #[error("Server error: {0}")]
269 Server(String),
270 /// A token store operation failed.
271 #[error("Token store error: {0}")]
272 Store(#[from] stack_profile::ProfileError),
273}
274
275impl From<Infallible> for AuthError {
276 fn from(never: Infallible) -> Self {
277 match never {}
278 }
279}
280
281/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
282///
283/// Returns `Ok(None)` if the variable is not set or empty.
284/// Returns `Ok(Some(url))` if the variable is set and valid.
285/// Returns `Err(_)` if the variable is set but not a valid URL.
286pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
287 match std::env::var("CS_CTS_HOST") {
288 Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
289 _ => Ok(None),
290 }
291}
292
293/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
294/// appends to the path rather than replacing the last segment.
295pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
296 if !url.path().ends_with('/') {
297 url.set_path(&format!("{}/", url.path()));
298 }
299 url
300}
301
302/// Create a [`reqwest::Client`] with standard timeouts.
303///
304/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
305/// does not auto-advance time past the connect timeout before the mock server
306/// can respond.
307pub(crate) fn http_client() -> reqwest::Client {
308 #[cfg(any(test, feature = "test-utils"))]
309 {
310 reqwest::Client::builder()
311 .pool_max_idle_per_host(10)
312 .build()
313 .unwrap_or_else(|_| reqwest::Client::new())
314 }
315 #[cfg(not(any(test, feature = "test-utils")))]
316 {
317 reqwest::Client::builder()
318 .connect_timeout(Duration::from_secs(10))
319 .timeout(Duration::from_secs(30))
320 .pool_idle_timeout(Duration::from_secs(5))
321 .pool_max_idle_per_host(10)
322 .build()
323 .unwrap_or_else(|_| reqwest::Client::new())
324 }
325}