crates/gunnar-ui-web/src/auth.rs
//! What the console does when the server says no.
//!
//! Pure decisions, no `web-sys`, so the whole thing is unit-tested on the host.
//! The reason this is a module rather than a few `if`s at the call sites is the
//! redirect loop: sending a 401 to `/login` is right, sending a 403 there is
//! not, and sending anything there while already on `/login` is a browser that
//! spins forever.
/// Whether this deployment expects anyone to log in.
///
/// Read from `/auth/status`, and `Unknown` until that answers — the console
/// must not guess in the meantime, because guessing `Passkey` redirects an
/// operator away from an open console and guessing `Open` shows an empty
/// dashboard instead of a login page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
Unknown,
/// No passkey configured. Only sane on loopback, and the console says so.
Open,
Passkey,
}
/// What to do about a refused request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
/// Send the operator to the login page, and back here afterwards.
RedirectToLogin { next: String },
/// Logged in, but not allowed. Say so; do not offer a login that cannot help.
Forbidden,
/// Render the failure where it happened.
Show,
}
/// Decide what a refusal means.
///
/// A 403 never redirects: the operator is authenticated and logging in again
/// changes nothing. A 401 redirects only when there is a login page to reach
/// and the console is not already on it.
pub fn unauthorized_action(status: u16, mode: AuthMode, current_path: &str) -> Action {
match status {
403 => Action::Forbidden,
401 if mode == AuthMode::Passkey && current_path != "/login" => Action::RedirectToLogin {
next: safe_next(current_path).unwrap_or_else(|| "/".to_owned()),
},
_ => Action::Show,
}
}
/// **How long the console waits before asking `/auth/status` again while gunnar restarts**
/// (GUN-SESSION-RESTART).
pub const RESTART_POLL_MS: u32 = 3000;
/// **Ask `/auth/status` again in this many milliseconds**, or `None`: the server said
/// gunnar is restarting (`restarting: true`), so the session is not known yet, and the
/// console neither shows a login nor calls it lost.
pub fn ask_status_again_in_ms(restarting: bool) -> Option<u32> {
restarting.then_some(RESTART_POLL_MS)
}
/// Where it is safe to return after logging in.
///
/// Only a rooted, single-slash local path, so a crafted link cannot turn a
/// successful login into a redirect off-site. Credential-shaped query
/// parameters are dropped: they would otherwise ride into the address bar, and
/// from there into history and any referrer.
pub fn safe_next(raw: &str) -> Option<String> {
const CREDENTIAL_ISH: &[&str] = &[
"token",
"access_token",
"bearer",
"api_key",
"apikey",
"key",
"secret",
"password",
"code",
];
if !raw.starts_with('/') || raw.starts_with("//") || raw.starts_with("/\\") {
return None;
}
let (path, query) = match raw.split_once('?') {
Some((path, query)) => (path, Some(query)),
None => (raw, None),
};
let kept: Vec<&str> = query
.into_iter()
.flat_map(|q| q.split('&'))
.filter(|pair| !pair.is_empty())
.filter(|pair| {
let name = pair.split_once('=').map_or(*pair, |(name, _)| name);
!CREDENTIAL_ISH.contains(&name.to_ascii_lowercase().as_str())
})
.collect();
Some(if kept.is_empty() {
path.to_owned()
} else {
format!("{path}?{}", kept.join("&"))
})
}
/// The address of the login page, carrying where to come back to.
pub fn login_url(next: &str) -> String {
match safe_next(next) {
Some(next) if next != "/" => format!("/login?next={}", percent_encode(&next)),
_ => "/login".to_owned(),
}
}
/// Percent-encode everything that is not unreserved or a path separator.
pub fn percent_encode(value: &str) -> String {
value
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
(b as char).to_string()
}
other => format!("%{other:02X}"),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_forbidden_request_never_offers_a_login() {
// The operator is already authenticated; a login page cannot help, and
// sending them there reads as "your session broke" when it did not.
assert_eq!(
unauthorized_action(403, AuthMode::Passkey, "/repos"),
Action::Forbidden
);
}
#[test]
fn an_unauthenticated_request_returns_to_where_it_was() {
assert_eq!(
unauthorized_action(401, AuthMode::Passkey, "/repos"),
Action::RedirectToLogin {
next: "/repos".to_owned()
}
);
}
#[test]
fn the_login_page_does_not_redirect_to_itself() {
// Otherwise a failing status call spins the browser forever.
assert_eq!(
unauthorized_action(401, AuthMode::Passkey, "/login"),
Action::Show
);
}
#[test]
fn an_open_console_shows_the_failure_rather_than_a_login_page() {
// With passkey off there is no login page worth reaching.
assert_eq!(
unauthorized_action(401, AuthMode::Open, "/repos"),
Action::Show
);
}
#[test]
fn nothing_is_decided_before_the_mode_is_known() {
assert_eq!(
unauthorized_action(401, AuthMode::Unknown, "/repos"),
Action::Show
);
}
#[test]
fn other_failures_are_shown_where_they_happened() {
for status in [400, 404, 409, 500, 502, 503] {
assert_eq!(
unauthorized_action(status, AuthMode::Passkey, "/repos"),
Action::Show
);
}
}
#[test]
fn a_destination_must_stay_on_this_site() {
assert_eq!(safe_next("/repos"), Some("/repos".to_owned()));
assert_eq!(safe_next("//evil.test/"), None);
assert_eq!(safe_next("/\\evil.test"), None);
assert_eq!(safe_next("https://evil.test"), None);
}
#[test]
fn credential_shaped_parameters_never_reach_the_address_bar() {
assert_eq!(
safe_next("/repos?token=abc&sort=name"),
Some("/repos?sort=name".to_owned())
);
assert_eq!(safe_next("/repos?secret=x"), Some("/repos".to_owned()));
}
/// ★ **GUN-SESSION-RESTART**: while gunnar restarts the server says `restarting`
/// (503 `{"code":"restarting"}`, and `/auth/status` with `restarting: true`). The
/// console never calls that a lost session, never sends it to the login page, and
/// asks `/auth/status` again until gunnar answers.
#[test]
fn a_restart_is_never_a_lost_session_and_the_status_is_asked_again() {
let said = crate::refusal::restarting_summary(503, crate::refusal::RESTARTING_CODE).expect("the restart's own words");
assert!(said.contains("starting"), "{said}");
assert!(!said.contains("no longer valid"), "{said}");
assert_eq!(crate::refusal::restarting_summary(503, ""), None, "any other 503 keeps its words");
assert_eq!(ask_status_again_in_ms(true), Some(RESTART_POLL_MS));
assert_eq!(ask_status_again_in_ms(false), None, "nothing to wait for");
assert_eq!(unauthorized_action(503, AuthMode::Passkey, "/repos"), Action::Show, "no login offered for a restart");
}
#[test]
fn the_login_url_omits_a_pointless_next() {
assert_eq!(login_url("/"), "/login");
assert_eq!(login_url("https://evil.test"), "/login");
assert_eq!(
login_url("/repos?sort=name"),
"/login?next=/repos%3Fsort%3Dname"
);
}
}