//! Per-launch authentication token for `cqs serve` (#1096, SEC-7).
//!
//! `cqs serve` previously had no authentication — anyone able to reach
//! the bind address got full read access to the project (source, notes,
//! graph, embeddings). The host-header allowlist (#1094 SEC-1) and SQL
//! caps (SEC-3) closed the most acute exploit paths, but on a multi-user
//! box or after a container escape the server was still fully open.
//!
//! This module adds a per-launch token, generated once at startup and
//! checked on every request via three accepted channels:
//!
//! 1. **`Authorization: Bearer <token>` header** — for API clients and
//!    `fetch()` callers that can set headers.
//! 2. **`?token=<token>` query param** — for initial browser navigation
//!    and bookmarks. On match the middleware sets a `cqs_token` cookie
//!    (`HttpOnly; SameSite=Strict; Path=/`) and 302-redirects to the
//!    clean URL, so reload + bookmark both work without leaving the
//!    token in the address bar.
//! 3. **`cqs_token` cookie** — used after the redirect handoff above,
//!    for every subsequent request.
//!
//! The compare is constant-time via [`subtle::ConstantTimeEq`] so a
//! timing oracle can't recover the token byte-by-byte. The token is 32
//! random bytes URL-safe base64-encoded (no padding) — 256 bits of
//! entropy in a paste-friendly 43-character string.
//!
//! `--no-auth` opts out for back-compat with scripted automation; the
//! caller is responsible for emitting the loud-warning banner.

use std::sync::Arc;

use axum::{
    body::Body,
    extract::{Request, State},
    http::{header, HeaderValue, Response, StatusCode, Uri},
    middleware::Next,
    response::{IntoResponse, Redirect},
};
use base64::Engine;
use subtle::ConstantTimeEq;

/// Cookie name set on successful query-param auth and accepted on every
/// subsequent request. Pinned to `cqs_token` so a future second-server
/// instance running in another tab on the same host uses a different
/// cookie path — currently we don't have a server identity, but the
/// name is intentionally distinctive in case we ever do.
const COOKIE_NAME: &str = "cqs_token";

/// Per-launch auth token. Generated by [`AuthToken::random`] at server
/// startup; cloned cheaply for the middleware closure (it's just an
/// `Arc<String>` under the hood).
///
/// Public so `bin/cqs` can construct one in `cmd_serve` and hand it to
/// [`super::run_server`]; the token string itself is private and only
/// surfaced via [`AuthToken::as_str`] for the listening-banner / open-
/// browser URL.
#[derive(Clone, Debug)]
pub struct AuthToken(Arc<String>);

impl AuthToken {
    /// Generate a fresh token: 32 random bytes URL-safe base64-encoded
    /// (no padding). 43 ASCII characters, 256 bits of entropy. Uses
    /// `rand::random::<[u8; 32]>()` — same source the rest of the
    /// codebase uses for non-crypto random IDs (`llm/prompts.rs`,
    /// `convert/naming.rs`). Backed by `getrandom` on Linux, so the
    /// bytes are cryptographically suitable for a session token.
    pub fn random() -> Self {
        let buf: [u8; 32] = rand::random();
        let token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf);
        Self(Arc::new(token))
    }

    /// Construct from a known string. Tests use this so they can pin a
    /// deterministic token; production code uses [`Self::random`].
    #[cfg(test)]
    pub(crate) fn from_string(s: impl Into<String>) -> Self {
        Self(Arc::new(s.into()))
    }

    /// The encoded token as a string. Returned by reference — never
    /// pass through `tracing::info!`/`debug!` (the startup banner is
    /// the one allowed surfacing).
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Constant-time string compare. `subtle::ConstantTimeEq` returns a
/// `Choice` (0 or 1); we collapse to bool. Timing-safe — same number
/// of operations regardless of where the strings differ.
fn ct_eq(a: &str, b: &str) -> bool {
    // Different lengths → not equal, but still iterate to a fixed cost
    // per bound length so we don't leak length via early-return timing.
    // `ct_eq` over `&[u8]` already does this.
    a.as_bytes().ct_eq(b.as_bytes()).into()
}

/// Strip the `token` parameter from the URI's query string for the
/// post-auth redirect. Other query params are preserved in their
/// original order.
fn strip_token_param(uri: &Uri) -> String {
    let path = uri.path();
    let Some(query) = uri.query() else {
        return path.to_string();
    };
    let kept: Vec<&str> = query
        .split('&')
        .filter(|pair| !pair.starts_with("token=") && *pair != "token")
        .collect();
    if kept.is_empty() {
        path.to_string()
    } else {
        format!("{path}?{}", kept.join("&"))
    }
}

/// Extract the token from one of three channels — header, cookie,
/// or query string — and constant-time-compare against the launched
/// token. Returns `None` if none matched (caller emits 401).
///
/// `query_param_used` is set to true when the match came from
/// `?token=<…>`; the caller then sets a cookie and 302-redirects to
/// the clean URL.
fn check_request(req: &Request, expected: &AuthToken) -> AuthOutcome {
    // 1. Authorization: Bearer …
    if let Some(bearer) = req
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
    {
        if ct_eq(bearer, expected.as_str()) {
            return AuthOutcome::Ok;
        }
    }

    // 2. cqs_token cookie. RFC 6265 cookie syntax is name=value pairs
    // separated by `; `. We don't bother with quoted values — the
    // server only ever sets this cookie itself and never quotes it.
    if let Some(cookie_header) = req
        .headers()
        .get(header::COOKIE)
        .and_then(|v| v.to_str().ok())
    {
        let needle = format!("{COOKIE_NAME}=");
        for pair in cookie_header.split(';') {
            if let Some(value) = pair.trim().strip_prefix(&needle) {
                if ct_eq(value, expected.as_str()) {
                    return AuthOutcome::Ok;
                }
            }
        }
    }

    // 3. ?token=… query param. axum's `Query` extractor only deserializes
    // a typed struct; we want raw access without forcing every request
    // path through a fixed type, so we parse the URI's `query()` directly.
    if let Some(query) = req.uri().query() {
        for pair in query.split('&') {
            if let Some(value) = pair.strip_prefix("token=") {
                // URI query values can be percent-encoded; the token
                // alphabet is URL-safe base64 (`A-Z a-z 0-9 - _`) so no
                // percent-encoding is ever needed in practice. Compare
                // verbatim — a percent-encoded match would fail `ct_eq`,
                // which is the conservative choice.
                if ct_eq(value, expected.as_str()) {
                    return AuthOutcome::OkViaQueryParam;
                }
            }
        }
    }

    AuthOutcome::Unauthorized
}

enum AuthOutcome {
    /// Token matched via header or cookie — pass the request through
    /// unchanged.
    Ok,
    /// Token matched via `?token=<…>` query param — set the cookie and
    /// 302-redirect to the same URL with the token stripped.
    OkViaQueryParam,
    /// No token matched. Reject with 401.
    Unauthorized,
}

/// axum middleware: enforce per-launch token on every request.
///
/// Wired via [`axum::middleware::from_fn_with_state`] with a cloned
/// [`AuthToken`] as state. Sits inside the host-header allowlist (so
/// rejected hosts skip the auth check entirely — saves a constant-time
/// compare on a request we'd reject anyway) and outside the
/// compression/trace layers (so 401 responses still get gzip/trace).
pub(crate) async fn enforce_auth(
    State(token): State<AuthToken>,
    req: Request,
    next: Next,
) -> Response<Body> {
    match check_request(&req, &token) {
        AuthOutcome::Ok => next.run(req).await,
        AuthOutcome::OkViaQueryParam => {
            let clean_uri = strip_token_param(req.uri());
            let mut resp = Redirect::to(&clean_uri).into_response();
            // SameSite=Strict so a third-party page can't replay the
            // cookie via embedded `<img src>` etc. HttpOnly so JS on
            // the page can't read it (defence in depth — XSS in a
            // server we control + bind to localhost is unlikely, but
            // free to set). No `Secure` — we're on plain HTTP for
            // localhost; setting Secure here would forbid the cookie
            // from sticking, defeating the handoff.
            let cookie = format!(
                "{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Strict",
                token.as_str()
            );
            // The token alphabet is URL-safe base64 — every byte is a
            // valid HTTP header byte — so HeaderValue construction
            // can't fail. `expect` in this branch is structurally safe.
            let value = HeaderValue::from_str(&cookie).expect(
                "AuthToken alphabet is URL-safe base64; HeaderValue construction infallible",
            );
            resp.headers_mut().insert(header::SET_COOKIE, value);
            resp
        }
        AuthOutcome::Unauthorized => {
            // Body intentionally minimal: no debug data, no token-
            // length leak. Tracing emits method + path (NOT query
            // string — that may carry `?token=` candidates) so
            // operators get a journal trail for 401s without
            // logging token material. (P1.21 / OB-V1.30-2.)
            tracing::warn!(
                method = %req.method(),
                path = %req.uri().path(),
                "serve: rejected unauthenticated request",
            );
            (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn random_token_is_url_safe_base64_43_chars() {
        let t = AuthToken::random();
        let s = t.as_str();
        // 32 bytes → 43 chars in URL-safe base64 with no padding.
        assert_eq!(s.len(), 43);
        for c in s.chars() {
            assert!(
                c.is_ascii_alphanumeric() || c == '-' || c == '_',
                "non-URL-safe char {c} in token"
            );
        }
    }

    #[test]
    fn random_tokens_differ() {
        let a = AuthToken::random();
        let b = AuthToken::random();
        assert_ne!(a.as_str(), b.as_str(), "two random tokens collided");
    }

    #[test]
    fn ct_eq_matches_string_eq() {
        assert!(ct_eq("abc", "abc"));
        assert!(!ct_eq("abc", "abd"));
        assert!(!ct_eq("abc", "ab"));
        assert!(!ct_eq("ab", "abc"));
        assert!(ct_eq("", ""));
        assert!(!ct_eq("a", ""));
    }

    #[test]
    fn strip_token_param_removes_only_token() {
        let uri: Uri = "/api/graph?token=abc&depth=3".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph?depth=3");
    }

    #[test]
    fn strip_token_param_leaves_no_query_when_token_was_only_param() {
        let uri: Uri = "/api/graph?token=abc".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph");
    }

    #[test]
    fn strip_token_param_returns_path_when_no_query() {
        let uri: Uri = "/api/graph".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph");
    }

    #[test]
    fn strip_token_param_preserves_other_params() {
        let uri: Uri = "/api/graph?depth=3&token=abc&limit=5".parse().unwrap();
        assert_eq!(strip_token_param(&uri), "/api/graph?depth=3&limit=5");
    }

    // ===== P2.30: adversarial tests for strip_token_param + check_request =====
    //
    // These pin the CURRENT (intentionally minimal) behavior so a future
    // regression — either tightening or loosening — surfaces loudly.
    // The audit calls out four gaps:
    //   (1) Case-sensitivity: `?Token=…` is kept verbatim.
    //   (2) Percent-encoded key (`?%74oken=…`): not recognised.
    //   (3) Double `&&` between params: empty pair ignored cleanly.
    //   (4) Multiple `token=` params: every one is stripped.
    //
    // Items (1) and (2) leave the token in the post-redirect URL bar
    // (an SEC-7 leakage path). Production fix lands separately —
    // these tests pin the gap so the eventual PR has a clear failure
    // signal to invert.

    #[test]
    #[allow(non_snake_case)]
    fn p2_30_strip_token_param_capital_T_token_NOT_stripped_today() {
        // Pin current behavior: capital `T` fails `starts_with("token=")`,
        // survives into the redirect URL. After the SEC fix lands, this
        // test will fail — invert the assertion at that point.
        let uri: Uri = "/api/graph?Token=abc&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert!(
            stripped.contains("Token="),
            "current behavior: capital `Token=` is not case-folded; \
             SEC-7 leakage: token survives in redirect URL"
        );
    }

    #[test]
    fn p2_30_strip_token_param_percent_encoded_key_NOT_stripped_today() {
        // `%74` is `t`. Pin current behavior: literal byte compare misses.
        // After fix: percent-decode the key, then case-fold.
        // axum's Uri parser keeps the percent-encoded form; we observe
        // the same here.
        let uri: Uri = "/api/graph?%74oken=abc&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert!(
            stripped.contains("%74oken=") || stripped.contains("token="),
            "current behavior: percent-encoded `%74oken=` is not decoded; \
             survives into redirect URL"
        );
    }

    #[test]
    fn p2_30_strip_token_param_handles_double_ampersand() {
        // `?token=abc&&depth=3` — the empty pair between the `&&` is
        // kept by the current `split('&')` filter (empty string fails
        // `starts_with` AND fails the `*pair != "token"` test). Pin the
        // resulting URL shape.
        let uri: Uri = "/api/graph?token=abc&&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        // The empty pair is kept because filter doesn't drop it; the
        // resulting URL is harmless but ugly. Pin it as a regression
        // signal — a future cleanup that *also* drops the empty pair
        // is fine, just want the test to flag the change.
        assert!(
            stripped == "/api/graph?&depth=3" || stripped == "/api/graph?depth=3",
            "double-ampersand handling drifted: got {stripped}"
        );
    }

    #[test]
    fn p2_30_strip_token_param_strips_multiple_token_params() {
        // `?token=a&token=b` — both should be stripped. Pin behavior so
        // a future regression that strips only the first leaves an
        // SEC-7 leak.
        let uri: Uri = "/api/graph?token=a&depth=3&token=b".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert!(
            !stripped.contains("token="),
            "multiple `token=` params must all be stripped; got {stripped}"
        );
    }

    #[test]
    fn p2_30_strip_token_param_empty_value() {
        // `?token=` — pin current behavior: empty-value pair is stripped
        // because `value=""` matches `pair.strip_prefix("token=")`.
        let uri: Uri = "/api/graph?token=&depth=3".parse().unwrap();
        let stripped = strip_token_param(&uri);
        assert_eq!(stripped, "/api/graph?depth=3");
    }

    #[test]
    #[allow(non_snake_case)]
    fn p2_30_check_request_capital_T_token_today_rejects() {
        // The check_request side mirrors the strip side — capital `T`
        // means the param doesn't match `starts_with("token=")`, so the
        // token is never observed. Pin: 401 today (no fall-through).
        // After fix: 200 OkViaQueryParam with case-fold.
        let token = AuthToken::from_string("secretvalue");
        let req = Request::builder()
            .uri("/api/graph?Token=secretvalue")
            .body(axum::body::Body::empty())
            .unwrap();
        match check_request(&req, &token) {
            AuthOutcome::Unauthorized => {}
            AuthOutcome::Ok | AuthOutcome::OkViaQueryParam => {
                panic!(
                    "current behavior expected: capital `Token=` not recognised, request 401s. \
                     If this test starts failing, the case-fold landed and the assertion \
                     should invert."
                );
            }
        }
    }
}
