spiral-auth 0.1.7

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

use cloud_detect::ProviderId;
use reqwest::Client;
use tracing::debug;
use url::Url;

use super::{cloud_provider_id, jwt_bearer, token_endpoint};
use crate::http::{Accepted as _, shared_client};
use crate::{CredentialResult, Token};

/// Authenticates a workload with its GCP host's metadata-server ID token, as a JWT-bearer
/// grant at the authorization server.
/// <https://cloud.google.com/docs/authentication/get-id-token>
///
/// You can get an ID token from the metadata server when your code is running on the following
/// Google Cloud services:
///     - Compute Engine
///     - App Engine standard environment
///     - App Engine flexible environment
///     - Cloud Run functions
///     - Cloud Run
///     - Google Kubernetes Engine
///     - Cloud Build
pub struct GcpAuthProvider {
    client: Client,
    token_endpoint: Url,
    workload_id: String,
    resource: Option<String>,
}

impl Debug for GcpAuthProvider {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GcpAuthProvider").finish()
    }
}

impl GcpAuthProvider {
    /// Obtain the workload's tokens at `token_endpoint`, the authorization server's.
    pub fn new(token_endpoint: Url, workload_id: String) -> Self {
        GcpAuthProvider {
            client: shared_client(),
            token_endpoint,
            workload_id,
            resource: None,
        }
    }

    /// Request a token for the API resource named by `resource` (RFC 8707).
    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
        self.resource = Some(resource.into());
        self
    }
}

impl GcpAuthProvider {
    /// A usable token, or `None` when this provider has no credentials to offer.
    pub async fn token(&self) -> CredentialResult<Option<Token>> {
        if !matches!(cloud_provider_id().await, Some(ProviderId::GCP)) {
            return Ok(None);
        }

        // RFC 7523 §3: the assertion's audience is the token endpoint it is presented to.
        let gcp_token = metadata_identity_token(&self.client, self.token_endpoint.as_str()).await?;
        let token = jwt_bearer(
            &self.client,
            &self.token_endpoint,
            self.workload_id.clone(),
            gcp_token.expose_secret().to_string(),
            self.resource.clone(),
        )
        .await?;

        debug!("GcpAuthProvider obtained a workload token");
        Ok(Some(token))
    }
}

impl_token_source!(GcpAuthProvider);

/// Authenticates one registered service client with the GCP host identity.
pub struct GcpServiceAuthProvider {
    client: Client,
    token_endpoint: Url,
    client_id: String,
    resource: Option<String>,
}

impl GcpServiceAuthProvider {
    /// Obtain tokens for the client `client_id` from the authorization server at `auth_url`,
    /// presenting this GCP host's Google ID token as an RFC 7523 JWT-bearer grant. `resource`
    /// names the RFC 8707 resource the token is for.
    pub fn new(
        auth_url: &Url,
        client_id: String,
        resource: Option<String>,
    ) -> CredentialResult<Self> {
        Ok(Self {
            client: shared_client(),
            token_endpoint: token_endpoint(auth_url)?,
            client_id,
            resource,
        })
    }

    /// A token for the service client.
    pub async fn token(&self) -> CredentialResult<Option<Token>> {
        // Servers build this provider only on GCP, so it does not probe for the cloud: the probe
        // caches its first answer, and one that times out at startup would leave the service
        // without tokens for the life of its process.
        // RFC 7523 §3: the assertion's audience is the token endpoint it is presented to.
        let assertion = metadata_identity_token(&self.client, self.token_endpoint.as_str()).await?;
        let token = jwt_bearer(
            &self.client,
            &self.token_endpoint,
            self.client_id.clone(),
            assertion.expose_secret().to_string(),
            self.resource.clone(),
        )
        .await?;

        debug!(client_id = %self.client_id, "exchanged GCP host identity for service token");
        Ok(Some(token))
    }
}

impl Debug for GcpServiceAuthProvider {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GcpServiceAuthProvider")
            .field("client_id", &self.client_id)
            .finish_non_exhaustive()
    }
}

impl_token_source!(GcpServiceAuthProvider);

async fn metadata_identity_token(client: &Client, audience: &str) -> CredentialResult<Token> {
    let response = client
        .get(format!(
            "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}"
        ))
        .header("Metadata-Flavor", "Google")
        .send()
        .await?
        .accepted()
        .await?
        .text()
        .await?;
    Ok(Token::from(response))
}