lfsx-server 1.23.3

A fast, lightweight, secure Git LFS server
Documentation
use std::path::PathBuf;
use std::time::Duration;

use base64::Engine;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;

use crate::config::AzureCredential;
use crate::error::Error;
use crate::storage::s3::keyspace::token::{Cached, Issued, issued};

pub(crate) const VERSION: &str = "2022-11-02";

const STORAGE_SCOPE: &str = "https://storage.azure.com/.default";
const STORAGE_RESOURCE: &str = "https://storage.azure.com/";
const MANAGED_IDENTITY_ENDPOINT: &str = "http://169.254.169.254/metadata/identity/oauth2/token";
const DEFAULT_AUTHORITY: &str = "https://login.microsoftonline.com/";

pub(crate) enum Credential {
    Key { account: String, key: Vec<u8> },
    Sas(String),
    Identity(Identity),
}

pub(crate) enum Scope<'a> {
    Container,
    Blob(&'a str),
}

impl Credential {
    pub(crate) fn new(account: &str, credential: &AzureCredential) -> Result<Self, Error> {
        Ok(match credential {
            AzureCredential::AccountKey(key) => Self::Key {
                account: account.to_owned(),
                key: base64::engine::general_purpose::STANDARD
                    .decode(key.trim())
                    .map_err(|_| Error::Misconfigured("LFSX_AZURE_ACCOUNT_KEY is not base64"))?,
            },
            AzureCredential::Sas(token) => Self::Sas(token.trim_start_matches('?').to_owned()),
            AzureCredential::Identity => Self::Identity(Identity::from_env()),
        })
    }

    pub(crate) fn sas(
        &self,
        container: &str,
        scope: Scope<'_>,
        permissions: &str,
        lifetime: Duration,
    ) -> Option<String> {
        let Self::Key { account, key } = self else {
            return None;
        };

        let expiry = (time::OffsetDateTime::now_utc() + lifetime)
            .replace_nanosecond(0)
            .ok()?
            .format(&time::format_description::well_known::Rfc3339)
            .ok()?;

        let (resource, kind) = match scope {
            Scope::Container => (format!("/blob/{account}/{container}"), "c"),
            Scope::Blob(blob) => (format!("/blob/{account}/{container}/{blob}"), "b"),
        };

        let unsigned = [
            permissions,
            "",
            &expiry,
            &resource,
            "",
            "",
            "",
            VERSION,
            kind,
            "",
            "",
            "",
            "",
            "",
            "",
            "",
        ]
        .join("\n");

        let mut mac = Hmac::<Sha256>::new_from_slice(key).ok()?;
        mac.update(unsigned.as_bytes());
        let signature =
            base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());

        Some(
            form_urlencoded::Serializer::new(String::new())
                .append_pair("sv", VERSION)
                .append_pair("sp", permissions)
                .append_pair("se", &expiry)
                .append_pair("sr", kind)
                .append_pair("sig", &signature)
                .finish(),
        )
    }
}

pub(crate) struct Identity {
    source: Source,
    cached: Cached,
}

pub(crate) enum Source {
    Workload {
        authority: String,
        tenant: String,
        client_id: String,
        token_file: PathBuf,
    },
    Managed {
        endpoint: String,
        client_id: Option<String>,
    },
}

impl Identity {
    pub(crate) fn new(source: Source) -> Self {
        Self {
            source,
            cached: Cached::default(),
        }
    }

    fn from_env() -> Self {
        let var = |name: &str| std::env::var(name).ok().filter(|value| !value.is_empty());

        let source = match (
            var("AZURE_FEDERATED_TOKEN_FILE"),
            var("AZURE_TENANT_ID"),
            var("AZURE_CLIENT_ID"),
        ) {
            (Some(token_file), Some(tenant), Some(client_id)) => Source::Workload {
                authority: var("AZURE_AUTHORITY_HOST").unwrap_or_else(|| DEFAULT_AUTHORITY.into()),
                tenant,
                client_id,
                token_file: token_file.into(),
            },
            (_, _, client_id) => Source::Managed {
                endpoint: MANAGED_IDENTITY_ENDPOINT.into(),
                client_id,
            },
        };

        Self::new(source)
    }

    pub(crate) async fn token(&self, client: &reqwest::Client) -> Result<String, Error> {
        self.cached.get(self.fetch(client)).await
    }

    async fn fetch(&self, client: &reqwest::Client) -> Result<Issued, Error> {
        let request = match &self.source {
            Source::Workload {
                authority,
                tenant,
                client_id,
                token_file,
            } => {
                let assertion = tokio::fs::read_to_string(token_file).await?;
                let body = form_urlencoded::Serializer::new(String::new())
                    .append_pair("grant_type", "client_credentials")
                    .append_pair("client_id", client_id)
                    .append_pair("scope", STORAGE_SCOPE)
                    .append_pair(
                        "client_assertion_type",
                        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                    )
                    .append_pair("client_assertion", assertion.trim())
                    .finish();

                client
                    .post(format!(
                        "{}/{tenant}/oauth2/v2.0/token",
                        authority.trim_end_matches('/')
                    ))
                    .header(
                        reqwest::header::CONTENT_TYPE,
                        "application/x-www-form-urlencoded",
                    )
                    .body(body)
            }
            Source::Managed {
                endpoint,
                client_id,
            } => {
                let mut url = reqwest::Url::parse(endpoint).map_err(|_| {
                    Error::Misconfigured("the managed identity endpoint is not a URL")
                })?;
                url.query_pairs_mut()
                    .append_pair("api-version", "2018-02-01")
                    .append_pair("resource", STORAGE_RESOURCE);
                if let Some(client_id) = client_id {
                    url.query_pairs_mut().append_pair("client_id", client_id);
                }

                client.get(url).header("Metadata", "true")
            }
        };

        issued(request).await
    }
}