spiral-auth 0.1.12

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

use reqwest_middleware::ClientWithMiddleware as Client;
use serde::Deserialize;
use tracing::debug;
use url::Url;

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

/// Authenticates a workload with a GitHub Actions OIDC token, as a JWT-bearer grant at the
/// authorization server.
/// <https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect>
pub struct GitHubAuthProvider {
    client: Client,
    token_endpoint: Url,
    workload_id: String,
}

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

impl GitHubAuthProvider {
    /// Obtain the workload's tokens at `token_endpoint`, the authorization server's.
    pub fn new(client: impl Into<Client>, token_endpoint: Url, workload_id: String) -> Self {
        GitHubAuthProvider {
            client: client.into(),
            token_endpoint,
            workload_id,
        }
    }
}

#[derive(Deserialize)]
struct GitHubTokenResponse {
    value: String,
}

/// Fetch a raw GitHub Actions OIDC token for the given audience.
///
/// Returns the platform token directly (no Spiral exchange).
/// Requires ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL
/// environment variables.
pub async fn fetch_github_oidc_token(client: &Client, audience: &str) -> CredentialResult<Token> {
    let id_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
        .map_err(|_| invalid!("ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable not set"))?;
    let id_token_request_url = env::var("ACTIONS_ID_TOKEN_REQUEST_URL")
        .map_err(|_| invalid!("ACTIONS_ID_TOKEN_REQUEST_URL environment variable not set"))?;

    let response = client
        .get(format!("{id_token_request_url}&audience={audience}"))
        .header("Authorization", format!("Bearer {id_token}"))
        .send()
        .await?
        .accepted()
        .await?
        .json::<GitHubTokenResponse>()
        .await?;

    Ok(Token::from(response.value))
}

impl GitHubAuthProvider {
    /// A usable token, or `None` when this provider has no credentials to offer.
    pub async fn token(&self) -> CredentialResult<Option<Token>> {
        if env::var("GITHUB_ACTIONS").ok().is_none() {
            return Ok(None);
        }

        // RFC 7523 ยง3: the assertion's audience is the token endpoint it is presented to.
        let github_token =
            fetch_github_oidc_token(&self.client, self.token_endpoint.as_str()).await?;
        let token = jwt_bearer(
            &self.client,
            &self.token_endpoint,
            self.workload_id.clone(),
            github_token.expose_secret().to_string(),
            None,
        )
        .await?;

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

impl_token_source!(GitHubAuthProvider);