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
59pub 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
94pub fn resolve_workload_id() -> Option<String> {
97 std::env::var("SPIRAL_WORKLOAD_ID")
98 .ok()
99 .filter(|s| !s.is_empty())
100}
101
102pub 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
109pub(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#[async_trait]
140pub trait ClientGrant: Send + Sync {
141 async fn grant(
143 &self,
144 resource: &str,
145 authorization_details: &serde_json::Value,
146 ) -> CredentialResult<OAuth2TokenResponse>;
147}
148
149pub(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
166pub 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
197pub 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
229pub 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 pub async fn token(&self) -> CredentialResult<Option<Token>> {
262 if self.workload_id.is_none() {
263 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 pub async fn token(&self) -> CredentialResult<Option<Token>> {
318 {
319 let cached = self.cached.read();
320
321 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 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 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 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 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 let token = create_test_token(3600);
432
433 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 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 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 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 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 fallback.token().await.unwrap();
479 assert_eq!(mock1.call_count(), 1);
480 assert_eq!(mock2.call_count(), 1);
481
482 {
484 let mut cached = fallback.cached.write();
485 *cached = Some(CachedToken {
486 token: expired_token,
487 provider: mock2.clone().into(),
488 });
489 }
490
491 fallback.token().await.unwrap();
494 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 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 unsafe { std::env::remove_var("SPIRAL_WORKLOAD_ID") };
562
563 assert_eq!(resolve_workload_id(), None);
565
566 unsafe { std::env::set_var("SPIRAL_WORKLOAD_ID", "from-env") };
568 assert_eq!(resolve_workload_id(), Some("from-env".to_string()));
569
570 unsafe { std::env::set_var("SPIRAL_WORKLOAD_ID", "") };
572 assert_eq!(resolve_workload_id(), None);
573
574 unsafe { std::env::remove_var("SPIRAL_WORKLOAD_ID") };
576 }
577}