Files
inbuxa-server/crates/http/src/auth/token_only.rs
T
jcoffey-dev faf3d1e056
ci / fork-checks (pull_request) Successful in 17s
ci / build (pull_request) Successful in 7m41s
Take a token, never a password, outside DAV
Anyone could host a copy of a front end on a server of their own,
collect a person's password there, and replay it as HTTP Basic against
JMAP or the API. Cross-origin rules don't stop that, since a server
isn't a browser, and neither does client registration, since Basic
never goes through OAuth (contract C-23).

JMAP (session, API, upload, download, event source, WebSocket), /api,
/auth/introspect, /auth/userinfo and authenticated /auth/register now
refuse an Authorization: Basic header before looking at the password,
with a 401 whose only challenge is Bearer. A wrong password gets the
same answer as the right one. CalDAV and CardDAV keep Basic, and their
401s still offer it. The sign-in page's /api/auth takes the password in
its body and is unaffected, as is the token endpoint's client
authentication.

Bootstrap and recovery mode accept Basic everywhere, as they keep
permissive CORS. INBUXA_HTTP_BASIC_AUTH=all puts it back everywhere;
dav is the default, and any other value logs a warning and keeps it.
Test builds accept Basic everywhere, since the integration suites sign
in with passwords, and legacy_protocols.py sets the variable.

Tested: unit tests for the paths, and tests/e2e/http_basic_auth.py
against the debug build, 26 checks, including both front ends' sign-in
path and a refused unregistered redirect.
2026-09-29 07:02:05 -07:00

89 lines
2.6 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! Where HTTP Basic authentication is refused (contract C-23).
//!
//! Outside DAV, the HTTP endpoints take a token, never a password: JMAP, the
//! management API, and the OAuth endpoints that authenticate a user
//! (introspection, userinfo, authenticated client registration). CalDAV and
//! CardDAV keep Basic, since that's how calendar and contacts apps sign in.
//! The token endpoint's own client authentication isn't user sign-in and
//! isn't affected.
//!
//! Bootstrap and recovery mode accept Basic everywhere, as they keep
//! permissive CORS (C-16), and `INBUXA_HTTP_BASIC_AUTH=all` puts it back
//! everywhere for an operator who needs it.
use crate::auth::authenticate::HttpHeaders;
use http_proto::HttpRequest;
/// Whether `path` takes a token only when Basic isn't allowed everywhere.
pub fn is_token_only_path(path: &str) -> bool {
let mut segments = path.trim_start_matches('/').split('/');
match segments.next() {
Some("jmap" | "api") => true,
Some("auth") => matches!(
segments.next(),
Some("introspect" | "userinfo" | "register")
),
_ => false,
}
}
/// Whether this request signs in with a password where only a token is
/// accepted.
pub fn is_refused_basic(req: &HttpRequest, basic_auth_everywhere: bool) -> bool {
!basic_auth_everywhere
&& req.authorization_basic().is_some()
&& is_token_only_path(req.uri().path())
}
#[cfg(test)]
mod tests {
use super::is_token_only_path;
#[test]
fn token_only_paths() {
for path in [
"/jmap",
"/jmap/",
"/jmap/session",
"/jmap/upload/a/",
"/jmap/download/a/b/c",
"/jmap/eventsource/",
"/jmap/ws",
"/api",
"/api/account",
"/api/schema",
"/auth/introspect",
"/auth/userinfo",
"/auth/register",
] {
assert!(is_token_only_path(path), "{path} should take a token only");
}
}
#[test]
fn basic_stays_where_apps_need_it() {
for path in [
"/dav/cal/user/",
"/dav/card/user/",
"/.well-known/caldav",
"/.well-known/carddav",
"/.well-known/jmap",
"/auth/token",
"/auth/device",
"/scim/v2/Users",
"/",
"/login",
"/jmapx",
"/apis",
] {
assert!(!is_token_only_path(path), "{path} should be left alone");
}
}
}