use std::env;
use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::sync::Arc;
use futures::FutureExt as _;
use futures::future::BoxFuture;
pub use reqwest::Url;
use reqwest_middleware::ClientWithMiddleware;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
macro_rules! invalid {
($($arg:tt)*) => {
$crate::CredentialError::invalid(format!($($arg)*))
};
}
macro_rules! bail {
($($arg:tt)*) => {
return Err(invalid!($($arg)*))
};
}
macro_rules! impl_token_source {
($provider:ty) => {
impl From<$provider> for $crate::TokenSource {
fn from(provider: $provider) -> Self {
let provider = ::std::sync::Arc::new(provider);
$crate::TokenSource::named(::std::any::type_name::<$provider>(), move || {
let provider = ::std::sync::Arc::clone(&provider);
async move { provider.token().await }
})
}
}
};
($provider:ty,cached) => {
impl From<$provider> for $crate::TokenSource {
fn from(provider: $provider) -> Self {
let provider = ::std::sync::Arc::new(provider);
let cached = ::std::sync::Arc::clone(&provider);
$crate::TokenSource::named(::std::any::type_name::<$provider>(), move || {
let provider = ::std::sync::Arc::clone(&provider);
async move { provider.token().await }
})
.with_cached(move || {
let provider = ::std::sync::Arc::clone(&cached);
async move { provider.cached_token().await }
})
}
}
};
}
mod error;
mod http;
mod providers;
mod tls;
mod token;
pub use error::{CredentialError, CredentialResult};
#[cfg(feature = "aws")]
pub use providers::AwsAuthProvider;
#[cfg(feature = "kubernetes")]
pub use providers::KubernetesAuthProvider;
#[cfg(feature = "modal")]
pub use providers::ModalAuthProvider;
pub use providers::{
ClientCredentials, ClientCredentialsProvider, ClientGrant, FallbackAuthn, OidcAuthProvider,
RefreshingAuthTokenProvider, WorkloadAuthn, default_auth_url, exchange_for_jwt, exchange_token,
resolve_workload_id, token_endpoint,
};
#[cfg(feature = "device-auth")]
pub use providers::{
DeviceAuthBuilder, DeviceAuthn, DeviceCodeAuth, DeviceTokenStore, FileTokenStore, TokensModel,
};
#[cfg(feature = "gcp")]
pub use providers::{GcpAuthProvider, GcpServiceAuthProvider};
#[cfg(feature = "github")]
pub use providers::{GitHubAuthProvider, fetch_github_oidc_token};
#[cfg(feature = "private-key-auth")]
pub use providers::{PrivateKeyCredentials, PrivateKeyJwtProvider};
pub use tls::install_default_crypto_provider;
pub use token::Token;
pub(crate) const TOKEN_TYPE_ACCESS_TOKEN: &str = "urn:ietf:params:oauth:token-type:access_token";
pub(crate) const TOKEN_TYPE_JWT: &str = "urn:ietf:params:oauth:token-type:jwt";
pub const CLIENT_ASSERTION_TYPE_JWT_BEARER: &str =
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
pub mod oauth_clients {
pub const SPIRAL_SDK: &str = "client_01m3xejegfehktm0ddjcyam4g8";
pub const SPIRAL_STUDIO_DESKTOP: &str = "client_01m3xejegffrsb50dbgwm422xd";
pub const SPIRAL_STUDIO_WEB: &str = "client_01m3xejegfemf9q41vmzj289r9";
pub const SPIRAL_STUDIO_DEV: &str = "client_ZRfbyzcUxVyi";
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuth2TokenRequest {
#[serde(flatten)]
pub client: OAuth2ClientAuthentication,
#[serde(flatten)]
pub grant: OAuth2Grant,
}
impl OAuth2TokenRequest {
#[cfg(feature = "device-auth")]
pub(crate) fn public(client_id: &str, grant: OAuth2Grant) -> Self {
Self {
client: OAuth2ClientAuthentication {
client_id: Some(client_id.to_owned()),
..Default::default()
},
grant,
}
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct OAuth2ClientAuthentication {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_assertion_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_assertion: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "grant_type")]
pub enum OAuth2Grant {
#[serde(rename = "authorization_code")]
AuthorizationCode {
code: String,
redirect_uri: String,
code_verifier: String,
},
#[serde(rename = "client_credentials")]
ClientCredentials {
#[serde(default, skip_serializing_if = "Option::is_none")]
resource: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
authorization_details: Option<String>,
},
#[serde(rename = "urn:ietf:params:oauth:grant-type:jwt-bearer")]
JwtBearer {
assertion: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
resource: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
authorization_details: Option<String>,
},
#[serde(rename = "refresh_token")]
RefreshToken {
refresh_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
organization_id: Option<String>,
},
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode { device_code: String },
#[serde(rename = "urn:ietf:params:oauth:grant-type:token-exchange")]
TokenExchange {
subject_token: String,
subject_token_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
resource: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
audience: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
requested_token_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
authorization_details: Option<String>,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuth2TokenResponse {
pub access_token: Token,
pub token_type: String,
pub expires_in: Option<u64>,
pub refresh_token: Option<String>,
#[serde(default)]
pub resource: Option<String>,
}
impl OAuth2TokenResponse {
pub(crate) fn into_access_token(self) -> Token {
self.access_token.with_resource(self.resource)
}
}
type FetchToken = dyn Fn() -> BoxFuture<'static, CredentialResult<Option<Token>>> + Send + Sync;
#[derive(Clone)]
pub struct TokenSource {
name: &'static str,
token: Arc<FetchToken>,
cached: Option<Arc<FetchToken>>,
}
impl TokenSource {
pub fn new<F, Fut>(token: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
{
Self::named("closure", token)
}
pub fn fixed(token: Token) -> Self {
Self::named("fixed", move || {
let token = token.clone();
async move { Ok(Some(token)) }
})
}
pub fn with_cached<F, Fut>(mut self, cached: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
{
self.cached = Some(fetch_token(cached));
self
}
pub async fn token(&self) -> CredentialResult<Option<Token>> {
(self.token)().await
}
pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
match &self.cached {
Some(cached) => cached().await,
None => Ok(None),
}
}
pub(crate) fn named<F, Fut>(name: &'static str, token: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
{
Self {
name,
token: fetch_token(token),
cached: None,
}
}
}
fn fetch_token<F, Fut>(fetch: F) -> Arc<FetchToken>
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = CredentialResult<Option<Token>>> + Send + 'static,
{
Arc::new(move || fetch().boxed())
}
impl Debug for TokenSource {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter
.debug_tuple("TokenSource")
.field(&self.name)
.finish()
}
}
impl_token_source!(CredentialChain, cached);
struct SelectedCredentials {
provider: TokenSource,
token: Token,
}
pub struct CredentialChain {
providers: Vec<TokenSource>,
selected: Mutex<Option<SelectedCredentials>>,
}
impl CredentialChain {
pub fn new(providers: Vec<TokenSource>) -> Self {
Self {
providers,
selected: Mutex::new(None),
}
}
}
impl Debug for CredentialChain {
fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
formatter
.debug_struct("CredentialChain")
.field("sources", &self.providers.len())
.finish_non_exhaustive()
}
}
impl CredentialChain {
pub async fn token(&self) -> CredentialResult<Option<Token>> {
let mut selected = self.selected.lock().await;
if let Some(credentials) = selected.as_mut() {
if !credentials.token.needs_refresh()? {
return Ok(Some(credentials.token.clone()));
}
let Some(token) = credentials.provider.token().await? else {
return Err(CredentialError::invalid(
"the selected credential source no longer supplies credentials; refusing to switch identities",
));
};
if token.is_expired()? {
return Err(CredentialError::invalid(
"the selected credential source returned an expired token",
));
}
credentials.token = token.clone();
return Ok(Some(token));
}
for provider in &self.providers {
if let Some(token) = provider.token().await? {
if token.is_expired()? {
return Err(CredentialError::invalid(
"the credential source returned an expired token",
));
}
*selected = Some(SelectedCredentials {
provider: provider.clone(),
token: token.clone(),
});
return Ok(Some(token));
}
}
Ok(None)
}
pub async fn cached_token(&self) -> CredentialResult<Option<Token>> {
Ok(self
.selected
.lock()
.await
.as_ref()
.filter(|credentials| !credentials.token.is_expired().unwrap_or(true))
.map(|credentials| credentials.token.clone()))
}
}
#[cfg(feature = "private-key-auth")]
fn private_key_credentials(
client: &ClientWithMiddleware,
auth_url: &Url,
) -> CredentialResult<Option<TokenSource>> {
Ok(Some(
PrivateKeyJwtProvider::new(client.clone(), auth_url)?.into(),
))
}
#[cfg(not(feature = "private-key-auth"))]
fn private_key_credentials(
_client: &ClientWithMiddleware,
_auth_url: &Url,
) -> CredentialResult<Option<TokenSource>> {
if env::var_os("SPIRAL_PRIVATE_KEY").is_some() {
return Err(CredentialError::invalid(
"SPIRAL_PRIVATE_KEY requires the private-key-auth feature",
));
}
Ok(None)
}
pub fn default_credentials(
client: impl Into<ClientWithMiddleware>,
api_url: &Url,
auth_url: &Url,
fallback: Option<TokenSource>,
) -> CredentialResult<TokenSource> {
if env::var_os("SPIRAL_CLIENT_SECRET").is_some() && env::var_os("SPIRAL_CLIENT_ID").is_none() {
return Err(CredentialError::invalid(
"SPIRAL_CLIENT_SECRET requires SPIRAL_CLIENT_ID",
));
}
let client = client.into();
let mut providers: Vec<TokenSource> = env::var("SPIRAL_TOKEN")
.ok()
.filter(|token| !token.is_empty())
.map(|token| TokenSource::fixed(token.into()))
.into_iter()
.collect();
providers.extend(private_key_credentials(&client, auth_url)?);
if env::var_os("SPIRAL_CLIENT_SECRET").is_some() {
providers.push(ClientCredentialsProvider::new(client.clone(), api_url.clone()).into());
}
providers.extend(workload_credentials(&client, api_url, auth_url)?);
if let Some(fallback) = fallback {
providers.push(fallback);
}
Ok(CredentialChain::new(providers).into())
}
#[cfg(not(feature = "workload-auth"))]
fn workload_credentials(
_client: &ClientWithMiddleware,
_api_url: &Url,
_auth_url: &Url,
) -> CredentialResult<Option<TokenSource>> {
if env::var_os("SPIRAL_WORKLOAD_ID").is_some() {
return Err(CredentialError::invalid(
"SPIRAL_WORKLOAD_ID requires the workload-auth feature and the host's platform feature (for example gcp)",
));
}
Ok(None)
}
#[cfg(feature = "workload-auth")]
fn workload_credentials(
client: &ClientWithMiddleware,
_api_url: &Url,
auth_url: &Url,
) -> CredentialResult<Option<TokenSource>> {
let workload_id = resolve_workload_id();
let wid = workload_id.clone().unwrap_or_default();
let token_url = token_endpoint(auth_url)?;
let mut providers: Vec<TokenSource> = vec![
#[cfg(feature = "kubernetes")]
KubernetesAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
#[cfg(feature = "modal")]
ModalAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
#[cfg(feature = "github")]
GitHubAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
#[cfg(feature = "gcp")]
GcpAuthProvider::new(client.clone(), token_url.clone(), wid.clone())
.with_resource(_api_url.as_str())
.into(),
#[cfg(feature = "aws")]
AwsAuthProvider::new(client.clone(), token_url.clone(), wid.clone()).into(),
];
let oidc_position = usize::from(cfg!(feature = "kubernetes"));
providers.insert(
oidc_position,
OidcAuthProvider::new(client.clone(), token_url, wid).into(),
);
Ok(Some(
WorkloadAuthn::new(workload_id, vec![CredentialChain::new(providers).into()]).into(),
))
}