spiral-auth 0.1.9

Spiral credentials: bearer tokens and the providers that obtain them
Documentation
use std::fmt::{Debug, Formatter};

use reqwest::Client;
use secrecy::{ExposeSecret as _, SecretString};
use url::Url;

use crate::http::{Accepted as _, shared_client};
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(api_url: Url) -> Self {
        ClientCredentialsProvider {
            client: shared_client(),
            api_url,
            credentials: None,
        }
    }

    /// Create a provider with credentials scoped to this provider instance.
    pub fn with_credentials(api_url: Url, credentials: ClientCredentials) -> Self {
        ClientCredentialsProvider {
            client: shared_client(),
            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 },
            })
            .send()
            .await?
            .accepted()
            .await?
            .json::<OAuth2TokenResponse>()
            .await?;

        Ok(Some(response.into_access_token()))
    }
}

impl_token_source!(ClientCredentialsProvider);

#[cfg(test)]
mod tests {
    use super::*;

    #[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"));
    }
}