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));
}
}