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

nordisk/gunnar-ui

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