Skip to main content

stack_auth/
lib.rs

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