Skip to main content

spiral_auth/
lib.rs

1//! Spiral credentials: bearer tokens, the providers that obtain them, and default discovery.
2//!
3//! A [`TokenSource`] supplies the token for each request; every provider converts into one.
4//! Providers that need extra dependencies, such as a cloud SDK or a browser, are behind
5//! features. Signing and TLS use the rustls crypto provider that the `aws-lc` or `ring`
6//! feature selects, unless the application installed one first.
7
8use std::env;
9use std::fmt::{Debug, Formatter, Result as FmtResult};
10use std::sync::Arc;
11
12use futures::FutureExt as _;
13use futures::future::BoxFuture;
14pub use reqwest::Url;
15use reqwest_middleware::ClientWithMiddleware;
16use serde::{Deserialize, Serialize};
17use tokio::sync::Mutex;
18
19/// A [`CredentialError::Invalid`] with a `format!` message.
20macro_rules! invalid {
21    ($($arg:tt)*) => {
22        $crate::CredentialError::invalid(format!($($arg)*))
23    };
24}
25
26/// Return a [`CredentialError::Invalid`] with a `format!` message.
27macro_rules! bail {
28    ($($arg:tt)*) => {
29        return Err(invalid!($($arg)*))
30    };
31}
32
33/// Convert a provider with inherent async `token` (and, with `cached`, `cached_token`) methods
34/// into a [`TokenSource`].
35macro_rules! impl_token_source {
36    ($provider:ty) => {
37        impl From<$provider> for $crate::TokenSource {
38            fn from(provider: $provider) -> Self {
39                let provider = ::std::sync::Arc::new(provider);
40                $crate::TokenSource::named(::std::any::type_name::<$provider>(), move || {
41                    let provider = ::std::sync::Arc::clone(&provider);
42                    async move { provider.token().await }
43                })
44            }
45        }
46    };
47    ($provider:ty,cached) => {
48        impl From<$provider> for $crate::TokenSource {
49            fn from(provider: $provider) -> Self {
50                let provider = ::std::sync::Arc::new(provider);
51                let cached = ::std::sync::Arc::clone(&provider);
52                $crate::TokenSource::named(::std::any::type_name::<$provider>(), move || {
53                    let provider = ::std::sync::Arc::clone(&provider);
54                    async move { provider.token().await }
55                })
56                .with_cached(move || {
57                    let provider = ::std::sync::Arc::clone(&cached);
58                    async move { provider.cached_token().await }
59                })
60            }
61        }
62    };
63}
64
65mod error;
66mod http;
67mod providers;
68mod tls;
69mod token;
70
71pub use error::{CredentialError, CredentialResult};
72#[cfg(feature = "aws")]
73pub use providers::AwsAuthProvider;
74#[cfg(feature = "kubernetes")]
75pub use providers::KubernetesAuthProvider;
76#[cfg(feature = "modal")]
77pub use providers::ModalAuthProvider;
78pub use providers::{
79    ClientCredentials, ClientCredentialsProvider, ClientGrant, FallbackAuthn, OidcAuthProvider,
80    RefreshingAuthTokenProvider, WorkloadAuthn, default_auth_url, exchange_for_jwt, exchange_token,
81    resolve_workload_id, token_endpoint,
82};
83#[cfg(feature = "device-auth")]
84pub use providers::{
85    DeviceAuthBuilder, DeviceAuthn, DeviceCodeAuth, DeviceTokenStore, FileTokenStore, TokensModel,
86};
87#[cfg(feature = "gcp")]
88pub use providers::{GcpAuthProvider, GcpServiceAuthProvider};
89#[cfg(feature = "github")]
90pub use providers::{GitHubAuthProvider, fetch_github_oidc_token};
91#[cfg(feature = "private-key-auth")]
92pub use providers::{PrivateKeyCredentials, PrivateKeyJwtProvider};
93pub use tls::install_default_crypto_provider;
94pub use token::Token;
95
96/// RFC 8693 §3 token type of an OAuth access token.
97pub(crate) const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
98
99/// RFC 8693 §3 token type of a JWT that is not an access token, such as an identity token
100/// for another cloud.
101pub(crate) const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
102
103/// RFC 7523 client authentication using a JWT bearer assertion.
104pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
105    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
106
107/// OAuth client IDs of Spiral's first-party applications, registered on every stack.
108pub mod oauth_clients {
109    /// The Spiral CLI and the SDKs built on this crate.
110    pub const SPIRAL_SDK: &str = "client_01m3xejegfehktm0ddjcyam4g8";
111    /// The Spiral Studio desktop application.
112    pub const SPIRAL_STUDIO_DESKTOP: &str = "client_01m3xejegffrsb50dbgwm422xd";
113    /// The Spiral Studio web application.
114    pub const SPIRAL_STUDIO_WEB: &str = "client_01m3xejegfemf9q41vmzj289r9";
115    /// Local web development against Spiral stacks.
116    pub const SPIRAL_STUDIO_DEV: &str = "client_ZRfbyzcUxVyi";
117}
118
119/// A request to Spiral's RFC 6749 token endpoint.
120#[derive(Debug, Serialize, Deserialize)]
121pub struct OAuth2TokenRequest {
122    #[serde(flatten)]
123    pub client: OAuth2ClientAuthentication,
124    #[serde(flatten)]
125    pub grant: OAuth2Grant,
126}
127
128impl OAuth2TokenRequest {
129    /// A grant requested by a public client, which identifies itself only by `client_id`.
130    #[cfg(feature = "device-auth")]
131    pub(crate) fn public(client_id: &str, grant: OAuth2Grant) -> Self {
132        Self {
133            client: OAuth2ClientAuthentication {
134                client_id: Some(client_id.to_owned()),
135                ..Default::default()
136            },
137            grant,
138        }
139    }
140}
141
142/// Client authentication parameters sent in the token request body.
143#[derive(Debug, Default, Serialize, Deserialize)]
144pub struct OAuth2ClientAuthentication {
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub client_id: Option<String>,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub client_secret: Option<String>,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub client_assertion_type: Option<String>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub client_assertion: Option<String>,
153}
154
155/// The authorization grant presented to the token endpoint.
156#[derive(Debug, Serialize, Deserialize)]
157#[serde(tag = "grant_type")]
158pub enum OAuth2Grant {
159    /// RFC 6749 §4.1.3 authorization code bound to a callback and PKCE verifier.
160    #[serde(rename = "authorization_code")]
161    AuthorizationCode {
162        code: String,
163        redirect_uri: String,
164        code_verifier: String,
165    },
166    #[serde(rename = "client_credentials")]
167    ClientCredentials {
168        /// RFC 8707: the resource server, such as a stack, that the token is for.
169        #[serde(default, skip_serializing_if = "Option::is_none")]
170        resource: Option<String>,
171        /// RFC 9396 §6: a JSON array of authorization details.
172        #[serde(default, skip_serializing_if = "Option::is_none")]
173        authorization_details: Option<String>,
174    },
175    /// RFC 7523 §2.1: a JWT from an issuer the client trusts, such as its host's workload
176    /// identity token, is the grant.
177    #[serde(rename = "urn:ietf:params:oauth:grant-type:jwt-bearer")]
178    JwtBearer {
179        assertion: String,
180        /// RFC 8707: the resource server, such as a stack, that the token is for.
181        #[serde(default, skip_serializing_if = "Option::is_none")]
182        resource: Option<String>,
183        /// RFC 9396 §6: a JSON array of authorization details.
184        #[serde(default, skip_serializing_if = "Option::is_none")]
185        authorization_details: Option<String>,
186    },
187    #[serde(rename = "refresh_token")]
188    RefreshToken {
189        refresh_token: String,
190        /// Extension: switch the session to another organization the user belongs to.
191        #[serde(default, skip_serializing_if = "Option::is_none")]
192        organization_id: Option<String>,
193    },
194    #[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
195    DeviceCode { device_code: String },
196    /// RFC 8693 §2.1: an access token for a token to another resource or audience.
197    #[serde(rename = "urn:ietf:params:oauth:grant-type:token-exchange")]
198    TokenExchange {
199        subject_token: String,
200        subject_token_type: String,
201        /// RFC 8707: the resource server the token is for.
202        #[serde(default, skip_serializing_if = "Option::is_none")]
203        resource: Option<String>,
204        /// RFC 8693 §2.1: the logical name of the token's audience, for one that is not a URL.
205        #[serde(default, skip_serializing_if = "Option::is_none")]
206        audience: Option<String>,
207        /// RFC 8693 §2.1: an access token unless named.
208        #[serde(default, skip_serializing_if = "Option::is_none")]
209        requested_token_type: Option<String>,
210        /// RFC 9396 §6: a JSON array of authorization details.
211        #[serde(default, skip_serializing_if = "Option::is_none")]
212        authorization_details: Option<String>,
213    },
214}
215
216/// An RFC 6749 §5.1 token response.
217#[derive(Debug, Serialize, Deserialize)]
218pub struct OAuth2TokenResponse {
219    pub access_token: Token,
220    pub token_type: String,
221    pub expires_in: Option<u64>,
222    pub refresh_token: Option<String>,
223    /// The resource the access token is for: the API URL to call. An extension response
224    /// parameter (RFC 6749 §5.1) of Spiral's authorization server.
225    #[serde(default)]
226    pub resource: Option<String>,
227}
228
229impl OAuth2TokenResponse {
230    /// The access token, carrying the resource it is for.
231    pub(crate) fn into_access_token(self) -> Token {
232        self.access_token.with_resource(self.resource)
233    }
234}
235
236type FetchToken = dyn Fn() -> BoxFuture<'static, CredentialResult<Option<Token>>> + Send + Sync;
237
238/// A source of bearer tokens, consulted immediately before each authenticated request.
239///
240/// A source wraps an async closure, so any function can supply credentials. The providers in
241/// this crate convert into one with `From`. Clones share the closure.
242#[derive(Clone)]
243pub struct TokenSource {
244    name: &'static str,
245    token: Arc<FetchToken>,
246    cached: Option<Arc<FetchToken>>,
247}
248
249impl TokenSource {
250    /// A source that calls `token` before each request. `token` returns `None` when it has no
251    /// credentials to offer.
252    pub fn new<F, Fut>(token: F) -> Self
253    where
254        F: Fn() -> Fut + Send + Sync + 'static,
255        Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
256    {
257        Self::named("closure", token)
258    }
259
260    /// A source that always returns `token`.
261    pub fn fixed(token: Token) -> Self {
262        Self::named("fixed", move || {
263            let token = token.clone();
264            async move { Ok(Some(token)) }
265        })
266    }
267
268    /// Answer [`cached_token`](Self::cached_token) with `cached`, which must neither make
269    /// network requests nor prompt.
270    pub fn with_cached<F, Fut>(mut self, cached: F) -> Self
271    where
272        F: Fn() -> Fut + Send + Sync + 'static,
273        Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
274    {
275        self.cached = Some(fetch_token(cached));
276        self
277    }
278
279    /// A usable token, or `None` when this source has no credentials to offer.
280    pub async fn token(&self) -> CredentialResult<Option<Token>> {
281        (self.token)().await
282    }
283
284    /// A token this source already holds, without network requests or prompts.
285    pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
286        match &self.cached {
287            Some(cached) => cached().await,
288            None => Ok(None),
289        }
290    }
291
292    pub(crate) fn named<F, Fut>(name: &'static str, token: F) -> Self
293    where
294        F: Fn() -> Fut + Send + Sync + 'static,
295        Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
296    {
297        Self {
298            name,
299            token: fetch_token(token),
300            cached: None,
301        }
302    }
303}
304
305fn fetch_token<F, Fut>(fetch: F) -> Arc<FetchToken>
306where
307    F: Fn() -> Fut + Send + Sync + 'static,
308    Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
309{
310    Arc::new(move || fetch().boxed())
311}
312
313impl Debug for TokenSource {
314    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
315        formatter
316            .debug_tuple("TokenSource")
317            .field(&self.name)
318            .finish()
319    }
320}
321
322impl_token_source!(CredentialChain, cached);
323
324struct SelectedCredentials {
325    provider: TokenSource,
326    token: Token,
327}
328
329/// Ordered credential sources, with one selected identity retained during refresh.
330///
331/// Sources return `None` when unavailable. Errors stop discovery. Concurrent callers share
332/// discovery and refresh, and a selected source is never replaced by another identity.
333pub struct CredentialChain {
334    providers: Vec<TokenSource>,
335    selected: Mutex<Option<SelectedCredentials>>,
336}
337
338impl CredentialChain {
339    /// Search these providers in order, stopping at the first usable credential.
340    pub fn new(providers: Vec<TokenSource>) -> Self {
341        Self {
342            providers,
343            selected: Mutex::new(None),
344        }
345    }
346}
347
348impl Debug for CredentialChain {
349    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
350        formatter
351            .debug_struct("CredentialChain")
352            .field("sources", &self.providers.len())
353            .finish_non_exhaustive()
354    }
355}
356
357impl CredentialChain {
358    /// The selected source's token, choosing the first source with credentials on first use.
359    pub async fn token(&self) -> CredentialResult<Option<Token>> {
360        let mut selected = self.selected.lock().await;
361        if let Some(credentials) = selected.as_mut() {
362            if !credentials.token.needs_refresh()? {
363                return Ok(Some(credentials.token.clone()));
364            }
365            let Some(token) = credentials.provider.token().await? else {
366                return Err(CredentialError::invalid(
367                    "the selected credential source no longer supplies credentials; refusing to switch identities",
368                ));
369            };
370            if token.is_expired()? {
371                return Err(CredentialError::invalid(
372                    "the selected credential source returned an expired token",
373                ));
374            }
375            credentials.token = token.clone();
376            return Ok(Some(token));
377        }
378        for provider in &self.providers {
379            if let Some(token) = provider.token().await? {
380                if token.is_expired()? {
381                    return Err(CredentialError::invalid(
382                        "the credential source returned an expired token",
383                    ));
384                }
385                *selected = Some(SelectedCredentials {
386                    provider: provider.clone(),
387                    token: token.clone(),
388                });
389                return Ok(Some(token));
390            }
391        }
392        Ok(None)
393    }
394
395    /// The selected source's unexpired token, without consulting any source.
396    pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
397        Ok(self
398            .selected
399            .lock()
400            .await
401            .as_ref()
402            .filter(|credentials| !credentials.token.is_expired().unwrap_or(true))
403            .map(|credentials| credentials.token.clone()))
404    }
405}
406
407/// Private-key JWT credentials from `SPIRAL_CLIENT_ID` and `SPIRAL_PRIVATE_KEY`.
408#[cfg(feature = "private-key-auth")]
409fn private_key_credentials(
410    client: &ClientWithMiddleware,
411    auth_url: &Url,
412) -> CredentialResult<Option<TokenSource>> {
413    Ok(Some(
414        PrivateKeyJwtProvider::new(client.clone(), auth_url)?.into(),
415    ))
416}
417
418#[cfg(not(feature = "private-key-auth"))]
419fn private_key_credentials(
420    _client: &ClientWithMiddleware,
421    _auth_url: &Url,
422) -> CredentialResult<Option<TokenSource>> {
423    if env::var_os("SPIRAL_PRIVATE_KEY").is_some() {
424        return Err(CredentialError::invalid(
425            "SPIRAL_PRIVATE_KEY requires the private-key-auth feature",
426        ));
427    }
428    Ok(None)
429}
430
431/// The default credential chain: `SPIRAL_TOKEN`, then a private key (`SPIRAL_CLIENT_ID` with
432/// `SPIRAL_PRIVATE_KEY`), then client credentials (`SPIRAL_CLIENT_SECRET`), then the host's
433/// workload identity, then `fallback`.
434pub fn default_credentials(
435    client: impl Into<ClientWithMiddleware>,
436    api_url: &Url,
437    auth_url: &Url,
438    fallback: Option<TokenSource>,
439) -> CredentialResult<TokenSource> {
440    if env::var_os("SPIRAL_CLIENT_SECRET").is_some() && env::var_os("SPIRAL_CLIENT_ID").is_none() {
441        return Err(CredentialError::invalid(
442            "SPIRAL_CLIENT_SECRET requires SPIRAL_CLIENT_ID",
443        ));
444    }
445    let client = client.into();
446    let mut providers: Vec<TokenSource> = env::var("SPIRAL_TOKEN")
447        .ok()
448        .filter(|token| !token.is_empty())
449        .map(|token| TokenSource::fixed(token.into()))
450        .into_iter()
451        .collect();
452    providers.extend(private_key_credentials(&client, auth_url)?);
453    if env::var_os("SPIRAL_CLIENT_SECRET").is_some() {
454        providers.push(ClientCredentialsProvider::new(client.clone(), api_url.clone()).into());
455    }
456    providers.extend(workload_credentials(&client, api_url, auth_url)?);
457    if let Some(fallback) = fallback {
458        providers.push(fallback);
459    }
460    Ok(CredentialChain::new(providers).into())
461}
462
463#[cfg(not(feature = "workload-auth"))]
464fn workload_credentials(
465    _client: &ClientWithMiddleware,
466    _api_url: &Url,
467    _auth_url: &Url,
468) -> CredentialResult<Option<TokenSource>> {
469    if env::var_os("SPIRAL_WORKLOAD_ID").is_some() {
470        return Err(CredentialError::invalid(
471            "SPIRAL_WORKLOAD_ID requires the workload-auth feature and the host's platform feature (for example gcp)",
472        ));
473    }
474    Ok(None)
475}
476
477#[cfg(feature = "workload-auth")]
478fn workload_credentials(
479    client: &ClientWithMiddleware,
480    _api_url: &Url,
481    auth_url: &Url,
482) -> CredentialResult<Option<TokenSource>> {
483    let workload_id = resolve_workload_id();
484    let wid = workload_id.clone().unwrap_or_default();
485    let token_url = token_endpoint(auth_url)?;
486    let mut providers: Vec<TokenSource> = vec![
487        #[cfg(feature = "kubernetes")]
488        KubernetesAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
489        #[cfg(feature = "modal")]
490        ModalAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
491        #[cfg(feature = "github")]
492        GitHubAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
493        #[cfg(feature = "gcp")]
494        GcpAuthProvider::new(client.clone(), token_url.clone(), wid.clone())
495            .with_resource(_api_url.as_str())
496            .into(),
497        #[cfg(feature = "aws")]
498        AwsAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
499    ];
500    let oidc_position = usize::from(cfg!(feature = "kubernetes"));
501    providers.insert(
502        oidc_position,
503        OidcAuthProvider::new(client.clone(), token_url, wid).into(),
504    );
505    Ok(Some(
506        WorkloadAuthn::new(workload_id, vec![CredentialChain::new(providers).into()]).into(),
507    ))
508}