Skip to main content

spiral_auth/providers/
mod.rs

1use std::fmt::Debug;
2use std::sync::Arc;
3#[cfg(any(feature = "aws", feature = "gcp"))]
4use std::time::Duration;
5
6use async_trait::async_trait;
7use parking_lot::RwLock;
8
9#[cfg(feature = "aws")]
10mod aws;
11mod client;
12#[cfg(feature = "device-auth")]
13mod device;
14#[cfg(feature = "gcp")]
15mod gcp;
16#[cfg(feature = "github")]
17mod github;
18#[cfg(feature = "kubernetes")]
19mod kubernetes;
20#[cfg(feature = "modal")]
21mod modal;
22mod oidc;
23#[cfg(feature = "private-key-auth")]
24mod private_key;
25mod service;
26
27#[cfg(feature = "aws")]
28pub use aws::AwsAuthProvider;
29pub use client::{ClientCredentials, ClientCredentialsProvider};
30#[cfg(any(feature = "aws", feature = "gcp"))]
31use cloud_detect::{ProviderId, detect};
32#[cfg(feature = "device-auth")]
33pub use device::{
34    DeviceAuthBuilder, DeviceAuthn, DeviceCodeAuth, DeviceTokenStore, FileTokenStore, TokensModel,
35};
36#[cfg(feature = "gcp")]
37pub use gcp::{GcpAuthProvider, GcpServiceAuthProvider};
38#[cfg(feature = "github")]
39pub use github::{GitHubAuthProvider, fetch_github_oidc_token};
40#[cfg(feature = "kubernetes")]
41pub use kubernetes::KubernetesAuthProvider;
42#[cfg(feature = "modal")]
43pub use modal::ModalAuthProvider;
44pub use oidc::OidcAuthProvider;
45#[cfg(feature = "private-key-auth")]
46pub use private_key::{PrivateKeyCredentials, PrivateKeyJwtProvider};
47use reqwest_middleware::ClientWithMiddleware as Client;
48pub use service::RefreshingAuthTokenProvider;
49#[cfg(any(feature = "aws", feature = "gcp"))]
50use tokio::{sync::OnceCell, time::timeout};
51use url::Url;
52
53use crate::http::Accepted as _;
54use crate::{
55    CredentialResult, OAuth2ClientAuthentication, OAuth2Grant, OAuth2TokenRequest,
56    OAuth2TokenResponse, TOKEN_TYPE_ACCESS_TOKEN, TOKEN_TYPE_JWT, Token, TokenSource,
57};
58
59/// Spiral's authorization server, which signs users in for every stack.
60///
61/// `SPIRAL_AUTH_URL` overrides it; development mode uses the local server.
62pub fn default_auth_url() -> Url {
63    let configured = std::env::var("SPIRAL_AUTH_URL")
64        .ok()
65        .and_then(|url| Url::parse(&url).ok());
66    configured.unwrap_or_else(|| {
67        let url = if std::env::var("SPIRAL__DEV").is_ok_and(|value| !value.is_empty()) {
68            "http://localhost:4280"
69        } else {
70            "https://auth.spiraldb.com"
71        };
72        Url::parse(url).expect("valid default authorization server URL")
73    })
74}
75
76#[cfg(any(feature = "aws", feature = "gcp"))]
77static CLOUD_PROVIDER_ID: OnceCell<Option<ProviderId>> = OnceCell::const_new();
78
79#[cfg(any(feature = "aws", feature = "gcp"))]
80async fn cloud_provider_id() -> &'static Option<ProviderId> {
81    CLOUD_PROVIDER_ID
82        .get_or_init(|| async {
83            let duration = Duration::from_millis(
84                std::env::var("SPIRAL__CLOUD_DETECT_TIMEOUT_MS")
85                    .ok()
86                    .and_then(|x| x.parse::<u64>().ok())
87                    .unwrap_or(50),
88            );
89            timeout(duration, detect(Some(duration))).await.ok()
90        })
91        .await
92}
93
94/// Resolve the workload identity from the SPIRAL_WORKLOAD_ID environment variable.
95/// Returns None if the variable is unset or empty.
96pub fn resolve_workload_id() -> Option<String> {
97    std::env::var("SPIRAL_WORKLOAD_ID")
98        .ok()
99        .filter(|s| !s.is_empty())
100}
101
102/// The token endpoint of the authorization server at `auth_url`.
103pub fn token_endpoint(auth_url: &Url) -> CredentialResult<Url> {
104    auth_url
105        .join("/oauth/token")
106        .map_err(|error| invalid!("invalid url {error}"))
107}
108
109/// RFC 7523 §2.1: obtain the token of the client `client_id` by presenting `assertion`, a JWT
110/// from the client's platform whose audience is `token_endpoint`, as the JWT-bearer grant.
111pub(crate) async fn jwt_bearer(
112    client: &Client,
113    token_endpoint: &Url,
114    client_id: String,
115    assertion: String,
116    resource: Option<String>,
117) -> CredentialResult<Token> {
118    let response = request_token(
119        client,
120        token_endpoint,
121        &OAuth2TokenRequest {
122            client: OAuth2ClientAuthentication {
123                client_id: Some(client_id),
124                ..Default::default()
125            },
126            grant: OAuth2Grant::JwtBearer {
127                assertion,
128                resource,
129                authorization_details: None,
130            },
131        },
132    )
133    .await?;
134    Ok(response.into_access_token())
135}
136
137/// A registered client that obtains its own tokens with RFC 9396 authorization details: the
138/// grant that the client authorized, for a resource server to enforce.
139#[async_trait]
140pub trait ClientGrant: Send + Sync {
141    /// A token for `resource` that carries `authorization_details`, a JSON array.
142    async fn grant(
143        &self,
144        resource: &str,
145        authorization_details: &serde_json::Value,
146    ) -> CredentialResult<OAuth2TokenResponse>;
147}
148
149/// The token endpoint's response to `request`.
150pub(crate) async fn request_token(
151    client: &Client,
152    token_endpoint: &Url,
153    request: &OAuth2TokenRequest,
154) -> CredentialResult<OAuth2TokenResponse> {
155    Ok(client
156        .post(token_endpoint.clone())
157        .form(request)
158        .send()
159        .await?
160        .accepted()
161        .await?
162        .json::<OAuth2TokenResponse>()
163        .await?)
164}
165
166/// RFC 8693 §2.1: narrow `subject`, an access token from the authorization server at
167/// `token_endpoint`, to `resources` (RFC 8707 §2 lets a request name several). The narrowed
168/// token keeps the subject's claims and expiry, and names only `resources`, which the subject
169/// must name.
170pub async fn exchange_token(
171    client: &Client,
172    token_endpoint: &Url,
173    subject: &Token,
174    resources: &[&str],
175) -> CredentialResult<Token> {
176    let mut form = vec![
177        (
178            "grant_type",
179            "urn:ietf:params:oauth:grant-type:token-exchange",
180        ),
181        ("subject_token", subject.expose_secret()),
182        ("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN),
183    ];
184    form.extend(resources.iter().map(|resource| ("resource", *resource)));
185    let response = client
186        .post(token_endpoint.clone())
187        .form(&form)
188        .send()
189        .await?
190        .accepted()
191        .await?
192        .json::<OAuth2TokenResponse>()
193        .await?;
194    Ok(response.into_access_token())
195}
196
197/// RFC 8693 §2.1: exchange `subject`, an access token from the authorization server at
198/// `token_endpoint`, for a JWT for `audience`, which is not a Spiral API, such as another
199/// cloud's token service. The RFC 9396 `authorization_details` say what the JWT speaks for.
200pub async fn exchange_for_jwt(
201    client: &Client,
202    token_endpoint: &Url,
203    subject: &Token,
204    audience: &str,
205    authorization_details: &serde_json::Value,
206) -> CredentialResult<Token> {
207    let response = client
208        .post(token_endpoint.clone())
209        .form(&OAuth2TokenRequest {
210            client: OAuth2ClientAuthentication::default(),
211            grant: OAuth2Grant::TokenExchange {
212                subject_token: subject.expose_secret().to_owned(),
213                subject_token_type: TOKEN_TYPE_ACCESS_TOKEN.to_owned(),
214                resource: None,
215                audience: Some(audience.to_owned()),
216                requested_token_type: Some(TOKEN_TYPE_JWT.to_owned()),
217                authorization_details: Some(authorization_details.to_string()),
218            },
219        })
220        .send()
221        .await?
222        .accepted()
223        .await?
224        .json::<OAuth2TokenResponse>()
225        .await?;
226    Ok(response.access_token)
227}
228
229/// Groups all workload-identity providers and warns if none of them detected a valid token.
230///
231/// This lets the outer FallbackAuthn continue to DeviceAuthn while still surfacing a diagnostic
232/// when SPIRAL_WORKLOAD_ID is set but the environment lacks a usable platform token.
233///
234/// When `workload_id` is `None` (i.e. `SPIRAL_WORKLOAD_ID` is not set), the providers are
235/// skipped entirely. If a workload ID is present but no provider returns a token, an error
236/// is returned.
237pub struct WorkloadAuthn {
238    workload_id: Option<String>,
239    providers: Vec<TokenSource>,
240}
241
242impl Debug for WorkloadAuthn {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("WorkloadAuthn")
245            .field("providers", &self.providers)
246            .finish()
247    }
248}
249
250impl WorkloadAuthn {
251    pub fn new(workload_id: Option<String>, providers: Vec<TokenSource>) -> Self {
252        WorkloadAuthn {
253            workload_id,
254            providers,
255        }
256    }
257}
258
259impl WorkloadAuthn {
260    /// A usable token, or `None` when this provider has no credentials to offer.
261    pub async fn token(&self) -> CredentialResult<Option<Token>> {
262        if self.workload_id.is_none() {
263            // SPIRAL_OIDC_PROVIDER_TOKEN requires SPIRAL_WORKLOAD_ID to identify the principal
264            // for token exchange. Catch this misconfiguration early.
265            let has_oidc_token =
266                std::env::var("SPIRAL_OIDC_PROVIDER_TOKEN").is_ok_and(|s| !s.is_empty());
267            if has_oidc_token {
268                bail!(
269                    "SPIRAL_OIDC_PROVIDER_TOKEN is set but SPIRAL_WORKLOAD_ID is not. \
270                     Both are required for custom OIDC authentication."
271                );
272            }
273            return Ok(None);
274        }
275
276        for provider in &self.providers {
277            if let Some(token) = provider.token().await? {
278                return Ok(Some(token));
279            }
280        }
281
282        bail!("SPIRAL_WORKLOAD_ID is set but no workload identity provider detected a valid token")
283    }
284}
285
286impl_token_source!(WorkloadAuthn);
287
288struct CachedToken {
289    token: Token,
290    provider: TokenSource,
291}
292
293pub struct FallbackAuthn {
294    providers: Arc<[TokenSource]>,
295    cached: RwLock<Option<CachedToken>>,
296}
297
298impl Debug for FallbackAuthn {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("FallbackAuthn")
301            .field("providers", &self.providers)
302            .finish()
303    }
304}
305
306impl FallbackAuthn {
307    pub fn new(providers: Arc<[TokenSource]>) -> Self {
308        FallbackAuthn {
309            providers,
310            cached: RwLock::new(None),
311        }
312    }
313}
314
315impl FallbackAuthn {
316    /// A usable token, or `None` when this provider has no credentials to offer.
317    pub async fn token(&self) -> CredentialResult<Option<Token>> {
318        {
319            let cached = self.cached.read();
320
321            // If token doesn't need refresh yet, return it
322            if let Some(cached_token) = cached.as_ref()
323                && !cached_token.token.needs_refresh()?
324            {
325                return Ok(Some(cached_token.token.clone()));
326            }
327        }
328
329        // Always use previously successful provider, if it exists
330        let cached_provider = self.cached.read().as_ref().map(|c| c.provider.clone());
331        if let Some(provider) = cached_provider {
332            if let Some(token) = provider.token().await? {
333                let mut cached = self.cached.write();
334                *cached = Some(CachedToken {
335                    token: token.clone(),
336                    provider,
337                });
338
339                return Ok(Some(token));
340            }
341
342            bail!("Previously resolved authn provider return no token");
343        }
344
345        // If we never succeeded, try all providers.
346        for provider in self.providers.iter() {
347            tracing::debug!(?provider, "trying authn provider");
348            if let Some(token) = provider.token().await? {
349                let mut cached = self.cached.write();
350                *cached = Some(CachedToken {
351                    token: token.clone(),
352                    provider: provider.clone(),
353                });
354                return Ok(Some(token));
355            }
356        }
357
358        Ok(None)
359    }
360
361    /// A token this provider already holds, without network requests or prompts.
362    pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
363        let cached = self.cached.read();
364        if let Some(ct) = cached.as_ref()
365            && !ct.token.is_expired()?
366        {
367            return Ok(Some(ct.token.clone()));
368        }
369        Ok(None)
370    }
371}
372
373impl_token_source!(FallbackAuthn, cached);
374
375#[cfg(test)]
376mod tests {
377    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
378    use std::time::Duration;
379
380    use serde_json::json;
381    use serial_test::serial;
382
383    use super::*;
384    use crate::token::unsigned_jwt;
385
386    fn create_test_token(expires_in_seconds: i64) -> Token {
387        let now = std::time::SystemTime::now()
388            .duration_since(std::time::UNIX_EPOCH)
389            .unwrap()
390            .as_secs() as i64;
391        unsigned_jwt(json!({ "sub": "test_user", "exp": now + expires_in_seconds, "iat": now }))
392    }
393
394    struct MockAuthn {
395        token: Option<Token>,
396        call_count: AtomicUsize,
397    }
398
399    impl MockAuthn {
400        fn new(token: Option<Token>) -> Self {
401            Self {
402                token,
403                call_count: AtomicUsize::new(0),
404            }
405        }
406
407        fn call_count(&self) -> usize {
408            self.call_count.load(Ordering::SeqCst)
409        }
410    }
411
412    impl Debug for MockAuthn {
413        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414            f.debug_struct("MockAuthn").finish()
415        }
416    }
417
418    impl MockAuthn {
419        /// A usable token, or `None` when this provider has no credentials to offer.
420        pub async fn token(&self) -> CredentialResult<Option<Token>> {
421            self.call_count.fetch_add(1, Ordering::SeqCst);
422            Ok(self.token.clone())
423        }
424    }
425
426    impl_token_source!(Arc<MockAuthn>);
427
428    #[tokio::test]
429    async fn test_fallback_caches_token() {
430        // Create a mock token that expires in 1 hour (won't trigger refresh)
431        let token = create_test_token(3600);
432
433        // Verify the token is not expired
434        assert!(!token.is_expired().unwrap(), "Token should not be expired");
435
436        let mock1 = Arc::new(MockAuthn::new(None));
437        let mock2 = Arc::new(MockAuthn::new(Some(token.clone())));
438        let mock3 = Arc::new(MockAuthn::new(None));
439
440        let providers: Arc<[TokenSource]> =
441            Arc::new([mock1.clone().into(), mock2.clone().into(), mock3.into()]);
442
443        let fallback = FallbackAuthn::new(providers);
444
445        // First call should try providers and cache result
446        let result1 = fallback.token().await.unwrap();
447        assert!(result1.is_some());
448        assert_eq!(mock1.call_count(), 1);
449        assert_eq!(mock2.call_count(), 1);
450
451        // Second call should use cached token without calling providers
452        let result2 = fallback.token().await.unwrap();
453        assert!(result2.is_some());
454        assert_eq!(mock1.call_count(), 1);
455        assert_eq!(mock2.call_count(), 1);
456
457        // Token values should match
458        assert_eq!(
459            result1.unwrap().expose_secret(),
460            result2.unwrap().expose_secret()
461        );
462    }
463
464    #[tokio::test]
465    async fn test_fallback_remembers_successful_provider() {
466        // Create an expired token (expired 1 hour ago) and a fresh token
467        let expired_token = create_test_token(-3600);
468        let fresh_token = create_test_token(3600);
469
470        let mock1 = Arc::new(MockAuthn::new(None));
471        let mock2 = Arc::new(MockAuthn::new(Some(fresh_token.clone())));
472
473        let providers: Arc<[TokenSource]> = Arc::new([mock1.clone().into(), mock2.clone().into()]);
474
475        let fallback = FallbackAuthn::new(providers);
476
477        // First call
478        fallback.token().await.unwrap();
479        assert_eq!(mock1.call_count(), 1);
480        assert_eq!(mock2.call_count(), 1);
481
482        // Replace cached token with an expired one to simulate token expiration
483        {
484            let mut cached = fallback.cached.write();
485            *cached = Some(CachedToken {
486                token: expired_token,
487                provider: mock2.clone().into(),
488            });
489        }
490
491        // Second call should detect expired token and try mock2 first (the previously successful
492        // provider)
493        fallback.token().await.unwrap();
494        // mock1 should not be called again since we remember mock2 succeeded
495        assert_eq!(mock1.call_count(), 1);
496        assert_eq!(mock2.call_count(), 2);
497    }
498
499    #[tokio::test]
500    async fn test_fallback_no_providers_succeed() {
501        let mock1 = Arc::new(MockAuthn::new(None));
502        let mock2 = Arc::new(MockAuthn::new(None));
503
504        let providers: Arc<[TokenSource]> = Arc::new([mock1.clone().into(), mock2.clone().into()]);
505
506        let fallback = FallbackAuthn::new(providers);
507
508        let result = fallback.token().await.unwrap();
509        assert!(result.is_none());
510        assert_eq!(mock1.call_count(), 1);
511        assert_eq!(mock2.call_count(), 1);
512    }
513
514    #[tokio::test]
515    async fn test_fail_if_cant_refresh() {
516        #[derive(Debug)]
517        struct SingleUseAuthn {
518            counter: AtomicBool,
519        }
520
521        impl SingleUseAuthn {
522            fn new() -> Self {
523                Self {
524                    counter: AtomicBool::new(true),
525                }
526            }
527        }
528
529        impl SingleUseAuthn {
530            /// A usable token, or `None` when this provider has no credentials to offer.
531            pub async fn token(&self) -> CredentialResult<Option<Token>> {
532                if self.counter.fetch_and(false, Ordering::SeqCst) {
533                    Ok(Some(create_test_token(1)))
534                } else {
535                    Ok(None)
536                }
537            }
538        }
539
540        impl_token_source!(SingleUseAuthn);
541
542        let single_use = TokenSource::from(SingleUseAuthn::new());
543        let mock = Arc::new(MockAuthn::new(None));
544        let fallback = FallbackAuthn::new([mock.clone().into(), single_use].into());
545
546        let result = fallback.token().await.unwrap();
547        assert!(result.is_some());
548        assert_eq!(mock.call_count(), 1);
549
550        tokio::time::sleep(Duration::from_secs(1)).await;
551
552        let second = fallback.token().await;
553        assert_eq!(mock.call_count(), 1);
554        assert!(second.is_err());
555    }
556
557    #[test]
558    #[serial]
559    fn test_resolve_workload_id() {
560        // SAFETY: Test is marked #[serial] so no concurrent env var access.
561        unsafe { std::env::remove_var("SPIRAL_WORKLOAD_ID") };
562
563        // Unset env var returns None.
564        assert_eq!(resolve_workload_id(), None);
565
566        // Env var is returned when set.
567        unsafe { std::env::set_var("SPIRAL_WORKLOAD_ID", "from-env") };
568        assert_eq!(resolve_workload_id(), Some("from-env".to_string()));
569
570        // Empty env var is treated as unset.
571        unsafe { std::env::set_var("SPIRAL_WORKLOAD_ID", "") };
572        assert_eq!(resolve_workload_id(), None);
573
574        // Clean up.
575        unsafe { std::env::remove_var("SPIRAL_WORKLOAD_ID") };
576    }
577}