Skip to main content

stack_auth/
lib.rs

1#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")]
2#![doc = include_str!("../README.md")]
3// Security lints
4#![deny(unsafe_code)]
5#![warn(clippy::unwrap_used)]
6#![warn(clippy::expect_used)]
7#![warn(clippy::panic)]
8// Prevent mem::forget from bypassing ZeroizeOnDrop
9#![warn(clippy::mem_forget)]
10// Prevent accidental data leaks via output
11#![warn(clippy::print_stdout)]
12#![warn(clippy::print_stderr)]
13#![warn(clippy::dbg_macro)]
14// Code quality
15#![warn(unreachable_pub)]
16#![warn(unused_results)]
17#![warn(clippy::todo)]
18#![warn(clippy::unimplemented)]
19// Relax in tests
20#![cfg_attr(test, allow(clippy::unwrap_used))]
21#![cfg_attr(test, allow(clippy::expect_used))]
22#![cfg_attr(test, allow(clippy::panic))]
23#![cfg_attr(test, allow(unused_results))]
24
25use std::convert::Infallible;
26use std::future::Future;
27#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
28use std::time::Duration;
29
30use vitaminc::protected::OpaqueDebug;
31use zeroize::ZeroizeOnDrop;
32
33mod access_key;
34mod access_key_refresher;
35mod access_key_strategy;
36mod auth_strategy_fn;
37mod authorize_dto;
38mod auto_refresh;
39mod auto_strategy;
40mod device_session_refresher;
41mod device_session_strategy;
42mod oidc_federation_strategy;
43mod oidc_refresher;
44mod refresher;
45mod service_token;
46mod token;
47mod token_store;
48
49// Filesystem-backed device identity and the interactive device-code flow are
50// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`)
51// and the device-code flow launches a browser via `open::that`. Wasm consumers
52// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`.
53#[cfg(not(target_arch = "wasm32"))]
54mod device_client;
55#[cfg(not(target_arch = "wasm32"))]
56mod device_code;
57
58#[cfg(any(test, feature = "test-utils"))]
59mod static_token_strategy;
60
61pub use access_key::{AccessKey, InvalidAccessKey};
62pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
63pub use auth_strategy_fn::AuthStrategyFn;
64pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
65pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder};
66pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder};
67pub use oidc_refresher::{OidcProvider, OidcProviderFn};
68pub use service_token::ServiceToken;
69#[cfg(any(test, feature = "test-utils"))]
70pub use static_token_strategy::StaticTokenStrategy;
71pub use token::Token;
72pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn};
73
74/// Deprecated alias for [`DeviceSessionStrategy`].
75///
76/// Renamed to make the *renewal* (existing CTS session) vs *federation*
77/// ([`OidcFederationStrategy`]) distinction explicit. The old name still
78/// resolves so existing code keeps compiling; it will be removed in a future
79/// major release.
80#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")]
81pub type OAuthStrategy = DeviceSessionStrategy;
82
83/// Deprecated alias for [`DeviceSessionStrategyBuilder`].
84#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")]
85pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder;
86
87#[cfg(not(target_arch = "wasm32"))]
88pub use device_client::{bind_client_device, DeviceClientError};
89#[cfg(not(target_arch = "wasm32"))]
90pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
91
92// Re-exports from stack-profile for backward compatibility.
93#[cfg(not(target_arch = "wasm32"))]
94pub use stack_profile::DeviceIdentity;
95
96/// Token *acquisition* — strategies that produce a [`ServiceToken`].
97///
98/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring
99/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the
100/// closure-shaped impl for callers that source tokens externally
101/// (FFI, custom IPC).
102///
103/// For the *persistence layer* — pluggable storage that slots into an
104/// existing strategy — see [`crate::store`].
105///
106/// All items in this module are also re-exported at the crate root.
107pub mod auth {
108    pub use crate::{
109        AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy,
110        AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder,
111        DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey,
112        OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn,
113        SecretToken, ServiceToken,
114    };
115
116    #[cfg(not(target_arch = "wasm32"))]
117    pub use crate::{
118        bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder,
119        DeviceIdentity, PendingDeviceCode,
120    };
121
122    #[cfg(any(test, feature = "test-utils"))]
123    pub use crate::StaticTokenStrategy;
124
125    // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy`
126    // consumers keep compiling alongside the crate-root aliases. See the
127    // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root.
128    #[allow(deprecated)]
129    pub use crate::{OAuthStrategy, OAuthStrategyBuilder};
130}
131
132/// Token *persistence* — pluggable backends for the service-token cache.
133///
134/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped
135/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`]
136/// for ready-made implementations.
137///
138/// A `TokenStore` plugs into a concrete strategy via that strategy's
139/// builder (e.g.
140/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store))
141/// — it does *not* replace the strategy. For full token acquisition (custom
142/// fetcher, FFI-hosted strategy), see [`crate::auth`].
143///
144/// All items in this module are also re-exported at the crate root.
145pub mod store {
146    pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn};
147}
148
149/// A strategy for obtaining access tokens.
150///
151/// Implementations handle all details of authentication, token caching, and
152/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
153/// they need a valid token.
154///
155/// The trait is designed to be implemented for `&T`, so that callers can use
156/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy.
157///
158/// # Token refresh
159///
160/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`],
161/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
162/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
163/// behaves under concurrent access.
164///
165/// ## Expiry vs usability
166///
167/// A token has two time thresholds:
168///
169/// - **Expired** — the token is within **90 seconds** of its `expires_at`
170///   timestamp. This triggers a preemptive refresh attempt.
171/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
172///   A token can be "expired" (in the preemptive sense) but still "usable"
173///   (the server will still accept it).
174///
175/// ## Concurrent refresh strategies
176///
177/// The gap between "expired" and "unusable" enables two refresh modes:
178///
179/// 1. **Expiring but still usable** — The first caller triggers a background
180///    refresh. Concurrent callers receive the current (still-valid) token
181///    immediately without blocking.
182/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
183///    callers wait until the refresh completes, then all receive the new token.
184///
185/// Only one refresh runs at a time, regardless of how many callers request a
186/// token concurrently.
187///
188/// ## Flow diagram
189///
190/// ```mermaid
191/// flowchart TD
192///     Start["get_token()"] --> Lock["Acquire lock"]
193///     Lock --> Cached{Token cached?}
194///     Cached -- No --> InitAuth["Authenticate
195///     (lock held)"]
196///     InitAuth -- OK --> ReturnNew["Return new token"]
197///     InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
198///     InitAuth -- Err --> ErrAuth["Return error"]
199///     Cached -- Yes --> CheckRefresh{Expired?}
200///
201///     CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
202///
203///     CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
204///     InProgress -- Yes --> WaitOrReturn["Return token if usable,
205///     else wait for refresh"]
206///     WaitOrReturn -- OK --> ReturnOk
207///     WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
208///
209///     InProgress -- No --> HasCred{Refresh credential?}
210///     HasCred -- None --> CheckUsable["Return token if usable,
211///     else TokenExpired"]
212///
213///     HasCred -- Yes --> Usable{Still usable?}
214///
215///     Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
216///     (lock released)"]
217///     NonBlocking --> ReturnOld["Return current token"]
218///
219///     Usable -- "No (fully expired)" --> Blocking["Refresh
220///     (lock held)"]
221///     Blocking -- OK --> ReturnNew2["Return new token"]
222///     Blocking -- Err --> ErrExpired["TokenExpired"]
223/// ```
224#[cfg_attr(doc, aquamarine::aquamarine)]
225#[cfg(not(target_arch = "wasm32"))]
226pub trait AuthStrategy: Send {
227    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
228    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
229}
230
231/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because
232/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are
233/// single-threaded.
234#[cfg(target_arch = "wasm32")]
235pub trait AuthStrategy {
236    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
237    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
238}
239
240/// Marker trait alias for the bounds an owned `AuthStrategy`-providing
241/// credential type `C` must satisfy when held inside a long-lived client
242/// (e.g. `cipherstash_client::ZeroKMS<C>` shared across requests).
243///
244/// - On native targets `C` must be `Send + Sync + 'static` so the client
245///   can be carried across tokio task / `reqwest` worker boundaries.
246/// - On `wasm32` the runtime is single-threaded and the typical credential
247///   backing (a JS callable held by a `JsValue`) cannot cross threads
248///   even in principle, so the `Send + Sync` requirement is dropped and
249///   only `'static` remains.
250///
251/// Implemented via a blanket impl — any type satisfying the per-target
252/// bounds automatically implements `AuthStrategyBounds`. Callers don't
253/// implement it directly.
254///
255/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself,
256/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on
257/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type
258/// without declaring `unsafe impl Send` / `Sync`.
259#[cfg(not(target_arch = "wasm32"))]
260pub trait AuthStrategyBounds: Send + Sync + 'static {}
261#[cfg(not(target_arch = "wasm32"))]
262impl<T: Send + Sync + 'static> AuthStrategyBounds for T {}
263
264#[cfg(target_arch = "wasm32")]
265pub trait AuthStrategyBounds: 'static {}
266#[cfg(target_arch = "wasm32")]
267impl<T: 'static> AuthStrategyBounds for T {}
268
269/// A sensitive token string that is zeroized on drop and hidden from debug output.
270///
271/// `SecretToken` wraps a `String` and enforces two invariants:
272///
273/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
274///   the token goes out of scope, preventing it from lingering in memory.
275/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
276///   the actual value, so tokens won't leak into logs or error messages.
277///
278/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
279/// loaded from configuration or an environment variable).
280#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
281#[serde(transparent)]
282pub struct SecretToken(String);
283
284impl SecretToken {
285    /// Create a new `SecretToken` from a string value.
286    pub fn new(value: impl Into<String>) -> Self {
287        Self(value.into())
288    }
289
290    /// Expose the inner token string for FFI boundaries.
291    pub fn as_str(&self) -> &str {
292        &self.0
293    }
294}
295
296/// Errors that can occur during an authentication flow.
297#[derive(Debug, thiserror::Error, miette::Diagnostic)]
298#[non_exhaustive]
299pub enum AuthError {
300    /// The HTTP request to the auth server failed (network error, timeout, etc.).
301    #[error("HTTP request failed: {0}")]
302    Request(#[from] reqwest::Error),
303    /// The user denied the authorization request.
304    #[error("Authorization was denied")]
305    AccessDenied,
306    /// The grant type was rejected by the server.
307    #[error("Invalid grant")]
308    InvalidGrant,
309    /// The client ID is not recognized.
310    #[error("Invalid client")]
311    InvalidClient,
312    /// A URL could not be parsed.
313    #[error("Invalid URL: {0}")]
314    InvalidUrl(#[from] url::ParseError),
315    /// The requested region is not supported.
316    #[error("Unsupported region: {0}")]
317    Region(#[from] cts_common::RegionError),
318    /// The workspace CRN could not be parsed.
319    #[error("Invalid workspace CRN: {0}")]
320    InvalidCrn(cts_common::InvalidCrn),
321    /// The token issued by the auth server is for a different workspace than
322    /// the one configured on the strategy. Surfaces when the access key was
323    /// minted for a different workspace, or when the wrong CRN was passed.
324    #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")]
325    WorkspaceMismatch {
326        /// The workspace the strategy was configured for (from the CRN).
327        expected_workspace: cts_common::WorkspaceId,
328        /// The workspace the auth server's token actually carries.
329        token_workspace: cts_common::WorkspaceId,
330    },
331    /// The workspace ID could not be parsed.
332    #[error("Invalid workspace ID: {0}")]
333    InvalidWorkspaceId(#[from] cts_common::InvalidWorkspaceId),
334    /// An access key was provided but the workspace CRN is missing.
335    ///
336    /// Set the `CS_WORKSPACE_CRN` environment variable or call
337    /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
338    #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
339    MissingWorkspaceCrn,
340    /// No credentials are available (e.g. not logged in, no access key configured).
341    #[error("Not authenticated")]
342    NotAuthenticated,
343    /// A token (access token or device code) has expired.
344    #[error("Token expired")]
345    TokenExpired,
346    /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
347    #[error("Invalid access key: {0}")]
348    InvalidAccessKey(#[from] access_key::InvalidAccessKey),
349    /// The JWT could not be decoded or its claims are malformed.
350    #[error("Invalid token: {0}")]
351    InvalidToken(String),
352    /// An unexpected error was returned by the auth server.
353    #[error("Server error: {0}")]
354    Server(String),
355    /// A token store operation failed.
356    #[cfg(not(target_arch = "wasm32"))]
357    #[error("Token store error: {0}")]
358    Store(#[from] stack_profile::ProfileError),
359}
360
361impl AuthError {
362    /// Stable machine-readable identifier for surfacing across FFI boundaries
363    /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather
364    /// than `code` to avoid colliding with `miette::Diagnostic::code`, which
365    /// is inherited via `#[derive(Diagnostic)]`.
366    pub fn error_code(&self) -> &'static str {
367        match self {
368            Self::Request(_) => "REQUEST_ERROR",
369            Self::AccessDenied => "ACCESS_DENIED",
370            Self::TokenExpired => "EXPIRED_TOKEN",
371            Self::InvalidGrant => "INVALID_GRANT",
372            Self::InvalidClient => "INVALID_CLIENT",
373            Self::InvalidUrl(_) => "INVALID_URL",
374            Self::Region(_) => "INVALID_REGION",
375            Self::InvalidToken(_) => "INVALID_TOKEN",
376            Self::Server(_) => "SERVER_ERROR",
377            Self::NotAuthenticated => "NOT_AUTHENTICATED",
378            Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN",
379            Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY",
380            Self::InvalidCrn(_) => "INVALID_CRN",
381            Self::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH",
382            Self::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID",
383            #[cfg(not(target_arch = "wasm32"))]
384            Self::Store(_) => "STORE_ERROR",
385        }
386    }
387}
388
389impl From<Infallible> for AuthError {
390    fn from(never: Infallible) -> Self {
391        match never {}
392    }
393}
394
395/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
396///
397/// Returns `Ok(None)` if the variable is not set or empty.
398/// Returns `Ok(Some(url))` if the variable is set and valid.
399/// Returns `Err(_)` if the variable is set but not a valid URL.
400pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
401    match std::env::var("CS_CTS_HOST") {
402        Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
403        _ => Ok(None),
404    }
405}
406
407/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
408/// appends to the path rather than replacing the last segment.
409pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
410    if !url.path().ends_with('/') {
411        url.set_path(&format!("{}/", url.path()));
412    }
413    url
414}
415
416/// Decode a JWT payload by splitting on `.`, base64-decoding the middle
417/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken`
418/// (which pulls `ring`). Signatures are not verified — same posture as the
419/// native path, which calls `insecure_disable_signature_validation()`.
420#[cfg(target_arch = "wasm32")]
421pub(crate) fn decode_jwt_payload_wasm<C>(token: &str) -> Result<C, AuthError>
422where
423    C: serde::de::DeserializeOwned,
424{
425    use base64::Engine;
426    let segments: Vec<&str> = token.split('.').collect();
427    if segments.len() != 3 {
428        return Err(AuthError::InvalidToken(
429            "JWT must have three segments".to_string(),
430        ));
431    }
432    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
433        .decode(segments[1])
434        .map_err(|e| AuthError::InvalidToken(format!("base64 decode failed: {e}")))?;
435    serde_json::from_slice(&payload)
436        .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}")))
437}
438
439/// Create a [`reqwest::Client`] with standard timeouts.
440///
441/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
442/// does not auto-advance time past the connect timeout before the mock server
443/// can respond. On wasm32, reqwest's fetch backend doesn't expose
444/// `connect_timeout`/`pool_*` — the host runtime owns those concerns.
445#[cfg(any(test, feature = "test-utils"))]
446pub(crate) fn http_client() -> reqwest::Client {
447    reqwest::Client::builder()
448        .build()
449        .unwrap_or_else(|_| reqwest::Client::new())
450}
451
452#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
453pub(crate) fn http_client() -> reqwest::Client {
454    reqwest::Client::builder()
455        .connect_timeout(Duration::from_secs(10))
456        .timeout(Duration::from_secs(30))
457        .pool_idle_timeout(Duration::from_secs(5))
458        .pool_max_idle_per_host(10)
459        .build()
460        .unwrap_or_else(|_| reqwest::Client::new())
461}
462
463#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
464pub(crate) fn http_client() -> reqwest::Client {
465    // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned
466    // by the runtime, so `ClientBuilder` doesn't expose them here.
467    reqwest::Client::builder()
468        .build()
469        .unwrap_or_else(|_| reqwest::Client::new())
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn auth_error_code_known_variants() {
478        assert_eq!(AuthError::AccessDenied.error_code(), "ACCESS_DENIED");
479        assert_eq!(AuthError::TokenExpired.error_code(), "EXPIRED_TOKEN");
480        assert_eq!(AuthError::InvalidGrant.error_code(), "INVALID_GRANT");
481        assert_eq!(AuthError::InvalidClient.error_code(), "INVALID_CLIENT");
482        assert_eq!(
483            AuthError::NotAuthenticated.error_code(),
484            "NOT_AUTHENTICATED"
485        );
486        assert_eq!(
487            AuthError::MissingWorkspaceCrn.error_code(),
488            "MISSING_WORKSPACE_CRN"
489        );
490        assert_eq!(AuthError::Server("x".into()).error_code(), "SERVER_ERROR");
491        assert_eq!(
492            AuthError::InvalidToken("malformed".into()).error_code(),
493            "INVALID_TOKEN"
494        );
495    }
496}