use std::fmt::{Debug, Formatter};
use reqwest_middleware::ClientWithMiddleware as Client;
use secrecy::{ExposeSecret as _, SecretString};
use url::Url;
use crate::http::Accepted as _;
use crate::{
CredentialResult, OAuth2ClientAuthentication, OAuth2Grant, OAuth2TokenRequest,
OAuth2TokenResponse, Token,
};
#[derive(Clone)]
pub struct ClientCredentials {
client_id: String,
client_secret: SecretString,
}
impl ClientCredentials {
pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
Self {
client_id: client_id.into(),
client_secret: SecretString::from(client_secret.into()),
}
}
fn client_id(&self) -> &str {
&self.client_id
}
fn client_secret(&self) -> &str {
self.client_secret.expose_secret()
}
}
impl Debug for ClientCredentials {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientCredentials")
.field("client_id", &self.client_id)
.finish_non_exhaustive()
}
}
pub struct ClientCredentialsProvider {
client: Client,
api_url: Url,
credentials: Option<ClientCredentials>,
}
impl ClientCredentialsProvider {
pub fn new(client: impl Into<Client>, api_url: Url) -> Self {
ClientCredentialsProvider {
client: client.into(),
api_url,
credentials: None,
}
}
pub fn with_credentials(
client: impl Into<Client>,
api_url: Url,
credentials: ClientCredentials,
) -> Self {
ClientCredentialsProvider {
client: client.into(),
api_url,
credentials: Some(credentials),
}
}
}
impl Debug for ClientCredentialsProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientCredentialsProvider").finish()
}
}
impl ClientCredentialsProvider {
pub async fn token(&self) -> CredentialResult<Option<Token>> {
let (client_id, client_secret) = match &self.credentials {
Some(credentials) => (
credentials.client_id().to_owned(),
credentials.client_secret().to_owned(),
),
None => {
let Ok(client_id) = std::env::var("SPIRAL_CLIENT_ID") else {
return Ok(None);
};
let Ok(client_secret) = std::env::var("SPIRAL_CLIENT_SECRET") else {
bail!("SPIRAL_CLIENT_SECRET environment variable expected but not set")
};
(client_id, client_secret)
}
};
let response = self
.client
.post(
self.api_url
.join("/oauth/token")
.map_err(|e| invalid!("invalid url {e}"))?,
)
.form(&OAuth2TokenRequest {
client: OAuth2ClientAuthentication {
client_id: Some(client_id),
client_secret: Some(client_secret),
..Default::default()
},
grant: OAuth2Grant::ClientCredentials {
resource: None,
authorization_details: None,
},
})
.send()
.await?
.accepted()
.await?
.json::<OAuth2TokenResponse>()
.await?;
Ok(Some(response.into_access_token()))
}
}
impl_token_source!(ClientCredentialsProvider);
#[cfg(test)]
mod tests {
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::redirect::Policy;
use serde_json::json;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::install_default_crypto_provider;
#[tokio::test]
#[expect(
clippy::disallowed_methods,
reason = "Verify that auth uses a caller-owned transport"
)]
async fn token_requests_use_the_supplied_transport() {
install_default_crypto_provider();
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(header("x-host-transport", "present"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"access_token": "issued", "token_type": "Bearer", "expires_in": 900
})))
.expect(1)
.mount(&server)
.await;
let mut headers = HeaderMap::new();
headers.insert("x-host-transport", HeaderValue::from_static("present"));
let http = reqwest::Client::builder()
.redirect(Policy::none())
.default_headers(headers)
.build()
.unwrap();
let provider = ClientCredentialsProvider::with_credentials(
http,
server.uri().parse().unwrap(),
ClientCredentials::new("client", "secret"),
);
assert_eq!(
provider.token().await.unwrap().unwrap().expose_secret(),
"issued"
);
}
#[test]
fn client_credentials_debug_output_redacts_the_secret() {
let credentials = ClientCredentials::new("client-id", "client-secret");
let debug = format!("{credentials:?}");
assert!(debug.contains("client-id"));
assert!(!debug.contains("client-secret"));
}
}