lfsx-server 1.23.3

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

use axum::Json;
use axum::extract::State;
use axum::http::header::{AUTHORIZATION, COOKIE, SET_COOKIE};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::{Hmac, KeyInit, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;

use super::{Refused, tokens};
use crate::config::Auth;
use crate::error::Error;
use crate::state::Shared;

const KEY: &str = "session.key";
const COOKIE_NAME: &str = "lfsx_session";
const LIFETIME: Duration = Duration::from_secs(12 * 60 * 60);

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "via", content = "name", rename_all = "lowercase")]
pub enum Viewer {
    Token(String),
    Forge(String),
}

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    viewer: Viewer,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    issued: Option<String>,
    expires: i64,
}

#[derive(Debug, Deserialize)]
pub struct SignIn {
    token: String,
}

async fn key(state: &Shared) -> Result<&[u8], Error> {
    state
        .session_key
        .get_or_try_init(|| kept_key(state))
        .await
        .map(Vec::as_slice)
}

async fn kept_key(state: &Shared) -> Result<Vec<u8>, Error> {
    if let Some(key) = state.store.read_meta(KEY).await? {
        return Ok(key);
    }

    let mut key = vec![0u8; 32];
    getrandom::fill(&mut key).expect("the operating system has a random number generator");
    state.store.write_meta(KEY, key).await?;

    state
        .store
        .read_meta(KEY)
        .await?
        .ok_or(Error::Misconfigured(
            "the session key could not be kept in the store",
        ))
}

fn mac(key: &[u8]) -> Hmac<Sha256> {
    Hmac::<Sha256>::new_from_slice(key).expect("HMAC takes a key of any length")
}

fn now() -> i64 {
    time::OffsetDateTime::now_utc().unix_timestamp()
}

fn seal(key: &[u8], viewer: Viewer, issued: Option<String>) -> Result<String, Error> {
    let claims = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&Claims {
        viewer,
        issued,
        expires: now() + LIFETIME.as_secs() as i64,
    })?);
    let mut mac = mac(key);
    mac.update(claims.as_bytes());

    Ok(format!(
        "{claims}.{}",
        URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
    ))
}

fn open(key: &[u8], sealed: &str) -> Option<Claims> {
    let (claims, signature) = sealed.split_once('.')?;
    let mut mac = mac(key);
    mac.update(claims.as_bytes());
    mac.verify_slice(&URL_SAFE_NO_PAD.decode(signature).ok()?)
        .ok()?;

    let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(claims).ok()?).ok()?;
    (claims.expires > now()).then_some(claims)
}

fn presented(headers: &HeaderMap) -> Option<&str> {
    headers
        .get_all(COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .flat_map(|value| value.split(';'))
        .find_map(|pair| pair.trim().strip_prefix(COOKIE_NAME)?.strip_prefix('='))
}

pub(crate) async fn viewer(state: &Shared, headers: &HeaderMap) -> Result<Option<Viewer>, Error> {
    let Some(sealed) = presented(headers) else {
        return Ok(None);
    };

    let Some(claims) = open(key(state).await?, sealed) else {
        return Ok(None);
    };

    match (&claims.viewer, &claims.issued) {
        (Viewer::Forge(_), _) => Ok(Some(claims.viewer)),
        (Viewer::Token(_), Some(hash)) if tokens::still_issued(&state.store, hash).await? => {
            Ok(Some(claims.viewer))
        }
        (Viewer::Token(_), _) => Ok(None),
    }
}

fn cookie(state: &Shared, value: &str, max_age: u64) -> HeaderValue {
    let secure = state
        .config
        .public_url
        .as_deref()
        .is_some_and(|url| url.starts_with("https://"));

    HeaderValue::from_str(&format!(
        "{COOKIE_NAME}={value}; Path=/-/; Max-Age={max_age}; HttpOnly; SameSite=Strict{}",
        if secure { "; Secure" } else { "" }
    ))
    .expect("a cookie built from base64 and fixed text is a valid header")
}

async fn admitted(state: &Shared, token: &str) -> Result<(Viewer, Option<String>), Error> {
    if let Some(issued) = tokens::holder(&state.store, token).await? {
        let hash = issued.hash().to_owned();
        return Ok((Viewer::Token(issued.name), Some(hash)));
    }

    let (Auth::Forge { .. }, Some(admins)) = (
        &state.config.auth,
        state
            .config
            .dashboard
            .as_ref()
            .and_then(|dashboard| dashboard.admins.as_ref()),
    ) else {
        return Err(Error::Unauthenticated);
    };

    let mut headers = HeaderMap::new();
    headers.insert(
        AUTHORIZATION,
        HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| Error::Unauthenticated)?,
    );
    state
        .authorizer
        .forge_permission(&headers, admins)
        .await?
        .require_admin()?;

    Ok((
        Viewer::Forge(state.authorizer.actor(&headers).await?.0),
        None,
    ))
}

pub(crate) async fn sign_in(
    State(state): State<Shared>,
    Json(SignIn { token }): Json<SignIn>,
) -> Result<Response, Refused> {
    if state.config.dashboard.is_none() {
        return Err(Error::NotServed.into());
    }

    let (viewer, issued) = admitted(&state, token.trim()).await?;
    let sealed = seal(key(&state).await?, viewer.clone(), issued)?;
    tracing::info!(?viewer, "signed in to the dashboard");

    Ok((
        [(SET_COOKIE, cookie(&state, &sealed, LIFETIME.as_secs()))],
        Json(viewer),
    )
        .into_response())
}

pub(crate) async fn current(
    State(state): State<Shared>,
    headers: HeaderMap,
) -> Result<Json<Viewer>, Refused> {
    if state.config.dashboard.is_none() {
        return Err(Error::NotServed.into());
    }

    Ok(Json(
        viewer(&state, &headers)
            .await?
            .ok_or(Error::Unauthenticated)?,
    ))
}

pub(crate) async fn sign_out(State(state): State<Shared>) -> Response {
    (
        StatusCode::NO_CONTENT,
        [(SET_COOKIE, cookie(&state, "", 0))],
    )
        .into_response()
}

#[cfg(test)]
mod tests;