1use 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
19macro_rules! invalid {
21 ($($arg:tt)*) => {
22 $crate::CredentialError::invalid(format!($($arg)*))
23 };
24}
25
26macro_rules! bail {
28 ($($arg:tt)*) => {
29 return Err(invalid!($($arg)*))
30 };
31}
32
33macro_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
96pub(crate) const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
98
99pub(crate) const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
102
103pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
105 "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
106
107pub mod oauth_clients {
109 pub const SPIRAL_SDK: &str = "client_01m3xejegfehktm0ddjcyam4g8";
111 pub const SPIRAL_STUDIO_DESKTOP: &str = "client_01m3xejegffrsb50dbgwm422xd";
113 pub const SPIRAL_STUDIO_WEB: &str = "client_01m3xejegfemf9q41vmzj289r9";
115 pub const SPIRAL_STUDIO_DEV: &str = "client_ZRfbyzcUxVyi";
117}
118
119#[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 #[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#[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#[derive(Debug, Serialize, Deserialize)]
157#[serde(tag = "grant_type")]
158pub enum OAuth2Grant {
159 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
170 resource: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 authorization_details: Option<String>,
174 },
175 #[serde(rename = "urn:ietf:params:oauth:grant-type:jwt-bearer")]
178 JwtBearer {
179 assertion: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 resource: Option<String>,
183 #[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 #[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 #[serde(rename = "urn:ietf:params:oauth:grant-type:token-exchange")]
198 TokenExchange {
199 subject_token: String,
200 subject_token_type: String,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 resource: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 audience: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 requested_token_type: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 authorization_details: Option<String>,
213 },
214}
215
216#[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 #[serde(default)]
226 pub resource: Option<String>,
227}
228
229impl OAuth2TokenResponse {
230 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#[derive(Clone)]
243pub struct TokenSource {
244 name: &'static str,
245 token: Arc<FetchToken>,
246 cached: Option<Arc<FetchToken>>,
247}
248
249impl TokenSource {
250 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 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 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 pub async fn token(&self) -> CredentialResult<Option<Token>> {
281 (self.token)().await
282 }
283
284 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
329pub struct CredentialChain {
334 providers: Vec<TokenSource>,
335 selected: Mutex<Option<SelectedCredentials>>,
336}
337
338impl CredentialChain {
339 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 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 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#[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
431pub 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}