Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-http/src/auth.rs

//! Who is asking, over a transport with no keys.
//!
//! # The whole of it
//!
//! ```text
//! no Authorization header        →  Principal::anonymous()      (and the
//!                                   allow_anonymous switch)
//! Authorization: Basic <u:tok>   →  AuthTable::principal_by_token(tok)
//! Authorization: Bearer <tok>    →  AuthTable::principal_by_token(tok)
//! ```
//!
//! and then **`AuthTable::may`**, which is where SSH ends up too. There is no
//! HTTP ACL. This module produces a [`Principal`] and stops.
//!
//! # Why the Basic username is ignored
//!
//! Because the ACL model says the username is decorative and identity comes from the
//! credential, and that rule does not get weaker on a second transport. HTTP
//! Basic has no way to send a password without a username, so `git` will send
//! *something*; making that something authoritative would mean a caller could
//! select an identity by typing it, which is the exact property SSH's design
//! spends a paragraph avoiding.
//!
//! The practical shape is the one every forge already trained people into:
//!
//! ```text
//! Username for 'http://gunnar': anything
//! Password for 'http://gunnar': gunnar_pat_…
//! ```
//!
//! # Cleartext
//!
//! gunnar terminates no TLS. A token in an `Authorization` header on a
//! plain connection is a password on the wire, so by default a request carrying
//! credentials is **refused** unless a proxy in front attests TLS with
//! `X-Forwarded-Proto: https`. See [`Cleartext`]. Anonymous read is unaffected:
//! there is no credential to lose, and what a stranger can read is public by
//! definition.

use gunnar_core::{AuthTable, Principal};

/// The header a proxy uses to say what scheme the client actually spoke.
pub const FORWARDED_PROTO: &str = "x-forwarded-proto";

/// What gunnar requires before it will read a credential off the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Cleartext {
    /// **The default.** A request carrying credentials is refused unless
    /// `X-Forwarded-Proto: https` says a terminator in front of gunnar spoke
    /// TLS to the client.
    ///
    /// Trusting a header is a claim about deployment, and it is the operator's
    /// to make: it is true when gunnar is reachable only through the proxy, and
    /// false when it is reachable directly, in which case a client can set the
    /// header itself. gunnar cannot check that from inside, which is why the
    /// alternative below exists and is explicit rather than inferred.
    #[default]
    RequireProxyTls,
    /// Accept credentials on a plain connection: loopback, a test, or a link
    /// the operator vouches for.
    Allowed,
}

/// The credential presented, if any.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential {
    /// No `Authorization` header at all.
    None,
    /// A token secret, from `Basic` (the password half) or `Bearer`.
    Token(String),
}

/// Why authentication failed.
///
/// Three variants, and the difference between them is the difference between
/// three HTTP status codes, so folding them would fold the responses.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
    /// The `Authorization` header was malformed: not `Basic`/`Bearer`, not
    /// base64, or not UTF-8. **401**, so the client may try again.
    #[error("the Authorization header could not be read")]
    Malformed,
    /// A credential was presented, and it authenticates nobody. **401.**
    #[error("no principal holds this credential")]
    Unknown,
    /// A credential was presented over cleartext and [`Cleartext`] forbids it.
    /// **403**, not 401: retrying will not help, and inviting a retry would
    /// invite the client to send the password again.
    #[error(
        "gunnar will not read a credential from a plain HTTP request; put a TLS \
         terminator in front of it, or start the server with cleartext credentials \
         explicitly allowed"
    )]
    Cleartext,
    /// No credential, and anonymous access is switched off at the listener.
    /// **401**, because authenticating is exactly what would help.
    #[error("anonymous access is disabled on this server")]
    AnonymousDisabled,
}

/// Read the credential out of an `Authorization` header value.
///
/// Anything that is not `Basic` or `Bearer` is [`AuthError::Malformed`] rather
/// than silently ignored: a header gunnar cannot read is a request whose intent
/// it does not know, and serving it as *anonymous* would quietly downgrade a
/// caller who believed they had authenticated.
pub fn credential(header: Option<&str>) -> Result<Credential, AuthError> {
    use base64::Engine as _;

    let Some(header) = header else {
        return Ok(Credential::None);
    };
    let (scheme, value) = header.split_once(' ').ok_or(AuthError::Malformed)?;
    if scheme.eq_ignore_ascii_case("bearer") {
        return Ok(Credential::Token(value.trim().to_owned()));
    }
    if !scheme.eq_ignore_ascii_case("basic") {
        return Err(AuthError::Malformed);
    }
    let decoded = base64::engine::general_purpose::STANDARD
        .decode(value.trim())
        .map_err(|_| AuthError::Malformed)?;
    let decoded = String::from_utf8(decoded).map_err(|_| AuthError::Malformed)?;
    // The password half. The username is decorative; see the module docs.
    let (_user, secret) = decoded.split_once(':').ok_or(AuthError::Malformed)?;
    Ok(Credential::Token(secret.to_owned()))
}

/// Does this request count as having reached gunnar over TLS?
///
/// `native_tls` is the listener's own knowledge: when gunnar terminated the
/// handshake itself there is nothing to trust and nothing to configure — the
/// connection *is* TLS, whatever any header claims. The header path stays for
/// the proxy-terminated shape.
pub fn over_tls(cleartext: Cleartext, forwarded_proto: Option<&str>, native_tls: bool) -> bool {
    if native_tls {
        return true;
    }
    match cleartext {
        Cleartext::Allowed => true,
        Cleartext::RequireProxyTls => {
            forwarded_proto.is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
        }
    }
}

/// Turn a credential into the principal that will face the ACL.
///
/// `allow_anonymous` is the **same switch** SSH reads
/// (`SshConfig::allow_anonymous`) and must be given the same value; the
/// server binary owns one flag and hands it to both listeners. A stranger with
/// no credential becomes [`Principal::anonymous`], whose entire authority is
/// rule 0 of [`AuthTable::may`]: `Read`, on a `Public` repository, nothing
/// anywhere else.
///
/// Returns the token hash alongside the principal so the caller can record the
/// use ([`AuthTable::touch_token`]) without hashing the secret a second time —
/// and so that what goes in a log line is the hash and never the secret.
pub fn principal_for(
    auth: &AuthTable,
    credential: &Credential,
    allow_anonymous: bool,
    over_tls: bool,
) -> Result<(Principal, Option<String>), AuthenticateError> {
    match credential {
        Credential::None => {
            if !allow_anonymous {
                return Err(AuthError::AnonymousDisabled.into());
            }
            Ok((Principal::anonymous(), None))
        }
        Credential::Token(secret) => {
            if !over_tls {
                return Err(AuthError::Cleartext.into());
            }
            match auth.principal_by_token(secret)? {
                // `gunnar_core::token_hash` and not a hash computed here: the
                // stored form of a credential has one definition, and this one
                // goes into a log line and into `touch_token`.
                Some(principal) => Ok((principal, Some(gunnar_core::token_hash(secret)))),
                None => Err(AuthError::Unknown.into()),
            }
        }
    }
}

/// Authentication failed, or the identity table could not be read.
///
/// The second is **not** the first, for the reason `gunnar-core` spells out at
/// length: the table lives in storage, a read can fail, and answering a storage
/// fault with "your credential is wrong" sends the user to reset a password
/// that was never the problem. This one is a 503.
#[derive(Debug, thiserror::Error)]
pub enum AuthenticateError {
    /// The credential itself.
    #[error(transparent)]
    Auth(#[from] AuthError),
    /// The identity table could not be read.
    #[error("the gunnar control plane could not answer: {0}")]
    Store(#[from] gunnar_core::Error),
}

#[cfg(test)]
mod tests {
    use super::*;
    use base64::Engine as _;

    fn basic(user: &str, password: &str) -> String {
        format!(
            "Basic {}",
            base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}"))
        )
    }

    #[test]
    fn basic_takes_the_password_and_ignores_the_username() {
        assert_eq!(
            credential(Some(&basic("anything", "gunnar_pat_abc"))),
            Ok(Credential::Token("gunnar_pat_abc".into()))
        );
        // The username genuinely does not reach the answer: two different
        // usernames with one password produce one credential.
        assert_eq!(
            credential(Some(&basic("alice", "gunnar_pat_abc"))),
            credential(Some(&basic("x-access-token", "gunnar_pat_abc")))
        );
        // A password containing a colon survives intact; splitting on the last
        // colon instead of the first would truncate it, and the token would
        // simply never authenticate.
        assert_eq!(
            credential(Some(&basic("u", "a:b:c"))),
            Ok(Credential::Token("a:b:c".into()))
        );
    }

    #[test]
    fn bearer_is_accepted_and_anything_else_is_refused() {
        assert_eq!(
            credential(Some("Bearer gunnar_pat_abc")),
            Ok(Credential::Token("gunnar_pat_abc".into()))
        );
        assert_eq!(credential(None), Ok(Credential::None));
        for bad in [
            "Digest nonce=1",
            "Negotiate abcdef",
            "Basic !!!not-base64!!!",
            "Basic",
            &format!(
                "Basic {}",
                base64::engine::general_purpose::STANDARD.encode("no-colon-here")
            ),
        ] {
            assert_eq!(
                credential(Some(bad)),
                Err(AuthError::Malformed),
                "{bad:?} must be refused rather than served as anonymous"
            );
        }
    }

    #[test]
    fn cleartext_requires_the_proxy_to_say_https() {
        assert!(!over_tls(Cleartext::RequireProxyTls, None, false));
        // The listener's own handshake outranks every header and every policy.
        assert!(over_tls(Cleartext::RequireProxyTls, None, true));
        assert!(over_tls(Cleartext::RequireProxyTls, Some("http"), true));
        assert!(!over_tls(Cleartext::RequireProxyTls, Some("http"), false));
        assert!(over_tls(Cleartext::RequireProxyTls, Some("https"), false));
        assert!(over_tls(Cleartext::RequireProxyTls, Some("HTTPS"), false));
        // The escape hatch, and it must be the only thing that makes a plain
        // request acceptable.
        assert!(over_tls(Cleartext::Allowed, None, false));
    }
}