spiral-auth 0.1.12

Spiral credentials: bearer tokens and the providers that obtain them
Documentation
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,
};

/// A client ID and secret used for OAuth2 client-credentials authentication.
#[derive(Clone)]
pub struct ClientCredentials {
    client_id: String,
    client_secret: SecretString,
}

impl ClientCredentials {
    /// Create credentials that are owned by one Spiral client.
    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,
        }
    }

    /// Create a provider with credentials scoped to this provider instance.
    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 {
    /// A usable token, or `None` when this provider has no credentials to offer.
    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)
            }
        };

        // Exchange client credentials for a token
        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"));
    }
}