use std::sync::Arc;

use axum::extract::rejection::JsonRejection;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Json, Router};
use chrono::{DateTime, Utc};
use cookie::time::Duration;
use cookie::{Cookie, CookieJar, Key, SameSite};
use fabro_redact::DisplaySafeUrl;
use fabro_static::EnvVars;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity};
use fabro_util::dev_token::validate_dev_token_format;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info, warn};

use crate::auth::{GithubEndpoints, browser_shell};
use crate::error::ApiError;
use crate::jwt_auth::{AuthMode, auth_method_name, dev_token_matches};
use crate::principal_middleware::{
    RequestAuth, RequestAuthContext, UserProfile, require_authenticated_user,
};
use crate::server::AppState;

pub const SESSION_COOKIE_NAME: &str = "__fabro_session";
const OAUTH_STATE_COOKIE_NAME: &str = "fabro_oauth_state";
const OAUTH_STATE_TTL_MINUTES: i64 = 30;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SessionCookie {
    pub v:           u8,
    pub login:       String,
    pub auth_method: AuthMethod,
    pub identity:    IdpIdentity,
    pub name:        String,
    pub email:       String,
    pub avatar_url:  String,
    pub user_url:    String,
    pub iat:         i64,
    pub exp:         i64,
}

#[derive(Deserialize)]
struct OAuthCallbackParams {
    code:  Option<String>,
    state: Option<String>,
    error: Option<String>,
}

#[derive(Deserialize)]
struct LoginGithubParams {
    return_to: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct OAuthStateCookie {
    state:     String,
    exp:       i64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    return_to: Option<String>,
}

#[derive(Deserialize)]
struct DevTokenLoginRequest {
    token: String,
}

#[derive(Serialize)]
struct AuthConfigResponse {
    methods: Vec<String>,
}

#[derive(Serialize)]
struct AuthMeResponse {
    user:      SessionUser,
    provider:  String,
    #[serde(rename = "demoMode")]
    demo_mode: bool,
}

#[derive(Serialize)]
struct AuthSessionsResponse {
    sessions: Vec<AuthSession>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct AuthSession {
    id:           String,
    kind:         &'static str,
    current:      bool,
    provider:     String,
    login:        String,
    label:        String,
    user_agent:   Option<String>,
    created_at:   DateTime<Utc>,
    last_seen_at: DateTime<Utc>,
    expires_at:   DateTime<Utc>,
    revocable:    bool,
}

#[derive(Serialize)]
struct SessionUser {
    login:       String,
    name:        String,
    email:       String,
    #[serde(rename = "idpIssuer", skip_serializing_if = "Option::is_none")]
    idp_issuer:  Option<String>,
    #[serde(rename = "idpSubject", skip_serializing_if = "Option::is_none")]
    idp_subject: Option<String>,
    #[serde(rename = "avatarUrl")]
    avatar_url:  String,
    #[serde(rename = "userUrl")]
    user_url:    String,
}

#[derive(Deserialize)]
struct GitHubTokenResponse {
    access_token: String,
}

#[derive(Deserialize)]
struct GitHubUser {
    id:         i64,
    login:      String,
    name:       Option<String>,
    avatar_url: String,
}

#[derive(Deserialize)]
struct GitHubEmail {
    email:    String,
    primary:  bool,
    verified: bool,
}

pub fn routes() -> Router<Arc<AppState>> {
    Router::new()
        .route("/login/dev-token", post(login_dev_token))
        .route("/login/github", get(login_github))
        .route("/callback/github", get(callback_github))
        .route("/logout", post(logout))
}

pub fn api_routes() -> Router<Arc<AppState>> {
    Router::new()
        .route("/auth/config", get(auth_config))
        .route("/auth/me", get(auth_me))
        .route("/auth/sessions", get(list_auth_sessions))
        .route("/auth/sessions/{id}", delete(delete_auth_session))
}

pub fn parse_cookie_header(headers: &HeaderMap) -> CookieJar {
    let mut jar = CookieJar::new();
    if let Some(raw) = headers
        .get(header::COOKIE)
        .and_then(|value| value.to_str().ok())
    {
        for part in raw.split(';') {
            if let Ok(cookie) = Cookie::parse_encoded(part.trim().to_string()) {
                jar.add_original(cookie.into_owned());
            }
        }
    }
    jar
}

pub fn read_private_session(headers: &HeaderMap, key: &Key) -> Option<SessionCookie> {
    let jar = parse_cookie_header(headers);
    let cookie = jar.private(key).get(SESSION_COOKIE_NAME)?;
    let session: SessionCookie = serde_json::from_str(cookie.value()).ok()?;
    if session.v != 2 || session.exp <= chrono::Utc::now().timestamp() {
        return None;
    }
    Some(session)
}

pub(crate) fn session_cookie_present(headers: &HeaderMap) -> bool {
    parse_cookie_header(headers)
        .get(SESSION_COOKIE_NAME)
        .is_some()
}

pub(crate) fn auth_context_from_session(session: &SessionCookie) -> RequestAuthContext {
    RequestAuthContext::authenticated_user(
        session.identity.clone(),
        session.login.clone(),
        session.auth_method,
        UserProfile {
            name:       session.name.clone(),
            email:      session.email.clone(),
            avatar_url: session.avatar_url.clone(),
            user_url:   session.user_url.clone(),
        },
    )
}

fn read_private_oauth_state(headers: &HeaderMap, key: &Key) -> Option<OAuthStateCookie> {
    let jar = parse_cookie_header(headers);
    jar.private(key)
        .get(OAUTH_STATE_COOKIE_NAME)
        .and_then(|cookie| serde_json::from_str(cookie.value()).ok())
        .filter(|state: &OAuthStateCookie| state.exp > chrono::Utc::now().timestamp())
}

fn add_oauth_state_cookie(jar: &mut CookieJar, key: &Key, state: &OAuthStateCookie, secure: bool) {
    jar.private_mut(key).add(
        Cookie::build((
            OAUTH_STATE_COOKIE_NAME,
            serde_json::to_string(&state).unwrap_or_default(),
        ))
        .path("/auth")
        .http_only(true)
        .same_site(SameSite::Lax)
        .secure(secure)
        .max_age(Duration::minutes(OAUTH_STATE_TTL_MINUTES))
        .build(),
    );
}

fn remove_oauth_state_cookie(jar: &mut CookieJar, key: &Key, secure: bool) {
    jar.private_mut(key).remove(
        Cookie::build((OAUTH_STATE_COOKIE_NAME, ""))
            .path("/auth")
            .http_only(true)
            .secure(secure)
            .build(),
    );
}

fn append_jar_delta(headers: &mut HeaderMap, jar: &CookieJar) {
    for cookie in jar.delta() {
        if let Ok(value) = HeaderValue::from_str(&cookie.encoded().to_string()) {
            headers.append(header::SET_COOKIE, value);
        }
    }
}

fn json_response(status: StatusCode, body: serde_json::Value) -> Response {
    (status, Json(body)).into_response()
}

fn static_error_page(message: &'static str) -> Response {
    browser_shell(
        StatusCode::BAD_REQUEST,
        "Sign-in failed",
        &format!(
            r#"
<div>
  <p class="eyebrow error">Sign-in failed</p>
  <h1>We couldn't complete sign-in</h1>
</div>
<p>{message}</p>
<a class="button" href="/login">Back to sign in</a>
"#
        ),
    )
}

fn sanitize_return_to(return_to: Option<String>) -> Option<String> {
    match return_to {
        Some(path) if matches!(path.as_str(), "/auth/cli/start" | "/auth/cli/resume") => Some(path),
        Some(_) => {
            warn!("Ignoring unsupported OAuth return_to path");
            None
        }
        None => None,
    }
}

fn oauth_error_redirect(path: &str, state: &str, error: &str, error_description: &str) -> String {
    const QUERY_VALUE_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC.remove(b'_').remove(b'-');
    let error = utf8_percent_encode(error, QUERY_VALUE_ENCODE_SET);
    let error_description = utf8_percent_encode(error_description, QUERY_VALUE_ENCODE_SET);
    let state = utf8_percent_encode(state, QUERY_VALUE_ENCODE_SET);
    format!("{path}?error={error}&error_description={error_description}&state={state}")
}

fn callback_error_redirect(
    return_to: Option<&str>,
    fallback: &'static str,
    state: &str,
    error: &'static str,
    error_description: &'static str,
) -> Response {
    match return_to {
        Some(path) => Redirect::to(&oauth_error_redirect(path, state, error, error_description))
            .into_response(),
        None => Redirect::to(fallback).into_response(),
    }
}

fn auth_methods_from_mode(auth_mode: &AuthMode) -> Vec<String> {
    let AuthMode::Enabled(config) = auth_mode;
    config
        .methods
        .iter()
        .map(|method| auth_method_name(*method).to_string())
        .collect()
}

fn auth_method_enabled(auth_mode: &AuthMode, method: ServerAuthMethod) -> bool {
    let AuthMode::Enabled(config) = auth_mode;
    config.methods.contains(&method)
}

fn dev_token_from_mode(auth_mode: &AuthMode) -> Option<String> {
    let AuthMode::Enabled(config) = auth_mode;
    config.dev_token.clone()
}

fn session_provider(auth_method: AuthMethod) -> &'static str {
    match auth_method {
        AuthMethod::DevToken => "dev-token",
        AuthMethod::Github => "github",
    }
}

fn session_cookie_secure(state: &AppState) -> bool {
    state
        .canonical_origin()
        .is_ok_and(|web_url| web_url.starts_with("https://"))
}

fn redacted_url_for_log(url: &str) -> String {
    DisplaySafeUrl::parse(url)
        .map_or_else(|_| "<invalid url>".to_string(), |url| url.redacted_string())
}

fn session_timestamp(timestamp: i64) -> Result<DateTime<Utc>, ApiError> {
    DateTime::from_timestamp(timestamp, 0).ok_or_else(|| {
        ApiError::new(
            StatusCode::INTERNAL_SERVER_ERROR,
            "Authenticated session timestamp is out of range.",
        )
    })
}

async fn login_dev_token(
    State(state): State<Arc<AppState>>,
    Extension(auth_mode): Extension<AuthMode>,
    RequestAuth(auth_slot): RequestAuth,
    payload: Result<Json<DevTokenLoginRequest>, JsonRejection>,
) -> Response {
    let Ok(Json(payload)) = payload else {
        auth_slot.replace(RequestAuthContext::invalid());
        return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
    };
    let expected = dev_token_from_mode(&auth_mode);
    let Some(expected) = expected else {
        auth_slot.replace(RequestAuthContext::invalid());
        return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
    };

    if !validate_dev_token_format(&payload.token) || !dev_token_matches(&payload.token, &expected) {
        auth_slot.replace(RequestAuthContext::invalid());
        return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
    }

    let Some(session_key) = state.session_key() else {
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "SESSION_SECRET is not configured"}),
        );
    };

    let now = chrono::Utc::now();
    let session = SessionCookie {
        v:           2,
        login:       "dev".to_string(),
        auth_method: AuthMethod::DevToken,
        identity:    IdpIdentity::new("fabro:dev", "dev").expect("non-empty dev identity"),
        name:        "Development User".to_string(),
        email:       "dev@localhost".to_string(),
        avatar_url:  "/images/logo.svg".to_string(),
        user_url:    String::new(),
        iat:         now.timestamp(),
        exp:         (now + chrono::Duration::days(30)).timestamp(),
    };
    auth_slot.replace(auth_context_from_session(&session));

    let mut jar = CookieJar::new();
    jar.private_mut(&session_key).add(
        Cookie::build((
            SESSION_COOKIE_NAME,
            serde_json::to_string(&session).unwrap_or_default(),
        ))
        .path("/")
        .http_only(true)
        .same_site(SameSite::Lax)
        .secure(session_cookie_secure(state.as_ref()))
        .max_age(Duration::days(30))
        .build(),
    );

    let mut response = Json(json!({ "ok": true })).into_response();
    append_jar_delta(response.headers_mut(), &jar);
    response
}

async fn auth_config(Extension(auth_mode): Extension<AuthMode>) -> Response {
    Json(AuthConfigResponse {
        methods: auth_methods_from_mode(&auth_mode),
    })
    .into_response()
}

#[expect(
    clippy::disallowed_types,
    reason = "GitHub OAuth authorize URL is raw browser redirect transit; logs use DisplaySafeUrl."
)]
async fn login_github(
    State(state): State<Arc<AppState>>,
    Extension(auth_mode): Extension<AuthMode>,
    Extension(github_endpoints): Extension<Arc<GithubEndpoints>>,
    Query(params): Query<LoginGithubParams>,
) -> Response {
    if !auth_method_enabled(&auth_mode, ServerAuthMethod::Github) {
        return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
    }
    let Some(session_key) = state.session_key() else {
        warn!("OAuth login failed: SESSION_SECRET not configured");
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "SESSION_SECRET is not configured"}),
        );
    };
    let settings = state.server_settings();
    let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else {
        warn!("OAuth login failed: client_id not configured");
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "GitHub App client_id is not configured"}),
        );
    };
    let client_id = client_id.clone();
    let web_url = match state.canonical_origin() {
        Ok(web_url) => web_url,
        Err(err) => {
            warn!(error = %err, "OAuth login failed: server.web.url is invalid");
            return json_response(StatusCode::CONFLICT, json!({"error": err}));
        }
    };

    let state_token = format!("fabro-{}", ulid::Ulid::new());
    let redirect_uri = format!("{web_url}/auth/callback/github");
    let authorize_url = fabro_http::Url::parse_with_params(
        github_endpoints
            .oauth_base
            .join("login/oauth/authorize")
            .expect("GitHub authorize URL should be valid")
            .as_str(),
        &[
            ("client_id", client_id.as_str()),
            ("redirect_uri", redirect_uri.as_str()),
            ("scope", "read:user user:email"),
            ("state", state_token.as_str()),
        ],
    )
    .expect("GitHub authorize URL should be valid");

    let safe_redirect_uri = redacted_url_for_log(&redirect_uri);
    debug!(redirect_uri = %safe_redirect_uri, "OAuth login redirecting to GitHub");

    let mut jar = CookieJar::new();
    add_oauth_state_cookie(
        &mut jar,
        &session_key,
        &OAuthStateCookie {
            state:     state_token,
            exp:       (chrono::Utc::now() + chrono::Duration::minutes(OAUTH_STATE_TTL_MINUTES))
                .timestamp(),
            return_to: sanitize_return_to(params.return_to),
        },
        session_cookie_secure(state.as_ref()),
    );
    let mut response = Redirect::to(authorize_url.as_str()).into_response();
    append_jar_delta(response.headers_mut(), &jar);
    response
}

async fn callback_github(
    State(state): State<Arc<AppState>>,
    Extension(auth_mode): Extension<AuthMode>,
    Extension(github_endpoints): Extension<Arc<GithubEndpoints>>,
    RequestAuth(auth_slot): RequestAuth,
    Query(params): Query<OAuthCallbackParams>,
    headers: HeaderMap,
) -> Response {
    auth_slot.replace(RequestAuthContext::invalid());

    if !auth_method_enabled(&auth_mode, ServerAuthMethod::Github) {
        return json_response(StatusCode::UNAUTHORIZED, json!({"error": "Unauthorized"}));
    }
    let Some(session_key) = state.session_key() else {
        error!("OAuth callback failed: SESSION_SECRET not configured");
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "SESSION_SECRET is not configured"}),
        );
    };
    let settings = state.server_settings();
    let stored_state = read_private_oauth_state(&headers, &session_key);
    let Some(stored_state) = stored_state else {
        warn!("OAuth callback failed: state cookie missing or invalid");
        return static_error_page(
            "Your login took too long or was tampered with. Please start again.",
        );
    };
    if stored_state.state.as_str() != params.state.as_deref().unwrap_or_default() {
        warn!("OAuth callback failed: state mismatch");
        return static_error_page(
            "Your login took too long or was tampered with. Please start again.",
        );
    }

    if let Some(error_code) = params.error.as_deref() {
        let (error, error_description, fallback) = match error_code {
            "unauthorized" => (
                "unauthorized",
                "Login not permitted",
                "/login?error=unauthorized",
            ),
            "access_denied" => (
                "access_denied",
                "Authorization denied",
                "/login?error=access_denied",
            ),
            _ => (
                "server_error",
                "Could not complete GitHub sign-in",
                "/login?error=server_error",
            ),
        };
        let mut jar = CookieJar::new();
        remove_oauth_state_cookie(
            &mut jar,
            &session_key,
            session_cookie_secure(state.as_ref()),
        );
        let mut response = callback_error_redirect(
            stored_state.return_to.as_deref(),
            fallback,
            &stored_state.state,
            error,
            error_description,
        );
        append_jar_delta(response.headers_mut(), &jar);
        return response;
    }

    let Some(code) = params.code.as_deref() else {
        warn!("OAuth callback failed: code missing from successful callback");
        return callback_error_redirect(
            stored_state.return_to.as_deref(),
            "/login?error=server_error",
            &stored_state.state,
            "server_error",
            "Could not complete GitHub sign-in",
        );
    };
    let state_param = params
        .state
        .as_deref()
        .expect("validated oauth callback state should exist");

    let Some(client_id) = settings.server.integrations.github.client_id.as_ref() else {
        error!("OAuth callback failed: client_id not configured");
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "GitHub App client_id is not configured"}),
        );
    };
    let client_id = client_id.clone();
    let client_secret = match state.vault_secret(EnvVars::GITHUB_APP_CLIENT_SECRET).await {
        Ok(value) => value,
        Err(err) => {
            error!(error = ?err, "OAuth callback failed: secret store unavailable");
            return json_response(
                StatusCode::INTERNAL_SERVER_ERROR,
                json!({"error": "secret store operation failed"}),
            );
        }
    };
    let Some(client_secret) = client_secret else {
        error!("OAuth callback failed: GITHUB_APP_CLIENT_SECRET not configured");
        return json_response(
            StatusCode::CONFLICT,
            json!({"error": "GITHUB_APP_CLIENT_SECRET is not configured"}),
        );
    };
    let web_url = match state.canonical_origin() {
        Ok(web_url) => web_url,
        Err(err) => {
            error!(error = %err, "OAuth callback failed: server.web.url is invalid");
            return json_response(StatusCode::CONFLICT, json!({"error": err}));
        }
    };

    let http = match fabro_http::http_client() {
        Ok(http) => http,
        Err(err) => {
            error!(error = %err, "OAuth callback failed: could not build GitHub HTTP client");
            return json_response(
                StatusCode::SERVICE_UNAVAILABLE,
                json!({"error": format!("Failed to build GitHub HTTP client: {err}")}),
            );
        }
    };
    let token = match http
        .post(
            github_endpoints
                .oauth_base
                .join("login/oauth/access_token")
                .expect("GitHub token URL should be valid"),
        )
        .header(header::ACCEPT, "application/json")
        .form(&[
            ("client_id", client_id.as_str()),
            ("client_secret", client_secret.as_str()),
            ("code", code),
            (
                "redirect_uri",
                format!("{web_url}/auth/callback/github").as_str(),
            ),
            ("state", state_param),
        ])
        .send()
        .await
    {
        Ok(response) if response.status().is_success() => {
            match response.json::<GitHubTokenResponse>().await {
                Ok(token) => token.access_token,
                Err(err) => {
                    error!(error = %err, "OAuth callback failed: could not parse GitHub token response");
                    return callback_error_redirect(
                        stored_state.return_to.as_deref(),
                        "/login?error=server_error",
                        &stored_state.state,
                        "server_error",
                        "Could not complete GitHub sign-in",
                    );
                }
            }
        }
        Ok(response) => {
            let status = response.status();
            error!(status = %status, "OAuth callback failed: GitHub token exchange returned error");
            return callback_error_redirect(
                stored_state.return_to.as_deref(),
                "/login?error=server_error",
                &stored_state.state,
                "server_error",
                "Could not complete GitHub sign-in",
            );
        }
        Err(err) => {
            error!(error = %err, "OAuth callback failed: GitHub token exchange request failed");
            return callback_error_redirect(
                stored_state.return_to.as_deref(),
                "/login?error=server_error",
                &stored_state.state,
                "server_error",
                "Could not complete GitHub sign-in",
            );
        }
    };

    let auth_header = format!("Bearer {token}");
    let profile = match http
        .get(
            github_endpoints
                .api_base
                .join("user")
                .expect("GitHub user URL should be valid"),
        )
        .header(header::AUTHORIZATION, &auth_header)
        .header(header::USER_AGENT, "fabro-server")
        .send()
        .await
    {
        Ok(response) if response.status().is_success() => match response.json::<GitHubUser>().await
        {
            Ok(profile) => profile,
            Err(err) => {
                error!(error = %err, "OAuth callback failed: could not parse GitHub user response");
                return callback_error_redirect(
                    stored_state.return_to.as_deref(),
                    "/login?error=server_error",
                    &stored_state.state,
                    "server_error",
                    "Could not complete GitHub sign-in",
                );
            }
        },
        Ok(response) => {
            let status = response.status();
            error!(status = %status, "OAuth callback failed: GitHub user lookup returned error");
            return callback_error_redirect(
                stored_state.return_to.as_deref(),
                "/login?error=server_error",
                &stored_state.state,
                "server_error",
                "Could not complete GitHub sign-in",
            );
        }
        Err(err) => {
            error!(error = %err, "OAuth callback failed: GitHub user lookup request failed");
            return callback_error_redirect(
                stored_state.return_to.as_deref(),
                "/login?error=server_error",
                &stored_state.state,
                "server_error",
                "Could not complete GitHub sign-in",
            );
        }
    };

    let emails = match http
        .get(
            github_endpoints
                .api_base
                .join("user/emails")
                .expect("GitHub emails URL should be valid"),
        )
        .header(header::AUTHORIZATION, &auth_header)
        .header(header::USER_AGENT, "fabro-server")
        .send()
        .await
    {
        Ok(response) if response.status().is_success() => response
            .json::<Vec<GitHubEmail>>()
            .await
            .unwrap_or_default(),
        _ => Vec::new(),
    };

    let allowed_usernames = settings.server.auth.github.allowed_usernames.clone();
    if !allowed_usernames.iter().any(|user| user == &profile.login) {
        warn!(login = %profile.login, "OAuth callback denied: username not in allowlist");
        return callback_error_redirect(
            stored_state.return_to.as_deref(),
            "/login?error=unauthorized",
            &stored_state.state,
            "unauthorized",
            "Login not permitted",
        );
    }

    let primary_email = emails
        .iter()
        .find(|email| email.primary && email.verified)
        .map(|email| email.email.clone())
        .unwrap_or_default();
    let now = chrono::Utc::now();
    let session = SessionCookie {
        v:           2,
        login:       profile.login.clone(),
        auth_method: AuthMethod::Github,
        identity:    IdpIdentity::new("https://github.com", profile.id.to_string())
            .expect("GitHub profile id should produce a valid identity"),
        name:        profile.name.unwrap_or_else(|| profile.login.clone()),
        email:       primary_email,
        avatar_url:  profile.avatar_url,
        user_url:    format!("https://github.com/{}", profile.login),
        iat:         now.timestamp(),
        exp:         (now + chrono::Duration::days(30)).timestamp(),
    };
    auth_slot.replace(auth_context_from_session(&session));

    info!(login = %session.login, "OAuth login succeeded");

    let mut jar = CookieJar::new();
    jar.private_mut(&session_key).add(
        Cookie::build((
            SESSION_COOKIE_NAME,
            serde_json::to_string(&session).unwrap_or_default(),
        ))
        .path("/")
        .http_only(true)
        .same_site(SameSite::Lax)
        .secure(session_cookie_secure(state.as_ref()))
        .max_age(Duration::days(30))
        .build(),
    );
    remove_oauth_state_cookie(
        &mut jar,
        &session_key,
        session_cookie_secure(state.as_ref()),
    );
    let redirect_target = stored_state
        .return_to
        .as_deref()
        .unwrap_or("/runs")
        .to_string();
    let mut response = Redirect::to(&redirect_target).into_response();
    append_jar_delta(response.headers_mut(), &jar);
    response
}

async fn logout(
    State(state): State<Arc<AppState>>,
    RequestAuth(auth_slot): RequestAuth,
    headers: HeaderMap,
) -> Response {
    info!("User logged out");
    let mut jar = CookieJar::new();
    if let Some(key) = state.session_key() {
        if let Some(session) = read_private_session(&headers, &key) {
            auth_slot.replace(auth_context_from_session(&session));
        } else if session_cookie_present(&headers) {
            auth_slot.replace(RequestAuthContext::invalid());
        }
        jar.private_mut(&key).remove(
            Cookie::build((SESSION_COOKIE_NAME, ""))
                .path("/")
                .http_only(true)
                .secure(session_cookie_secure(state.as_ref()))
                .build(),
        );
    }
    let mut response = Redirect::to("/login").into_response();
    append_jar_delta(response.headers_mut(), &jar);
    response
}

async fn auth_me(RequestAuth(auth_slot): RequestAuth, headers: HeaderMap) -> Response {
    let authenticated = match require_authenticated_user(&auth_slot) {
        Ok(authenticated) => authenticated,
        Err(err) => {
            warn!(
                has_cookie = headers.get(header::COOKIE).is_some(),
                "Auth check failed: authenticated subject missing"
            );
            return err.into_response();
        }
    };
    let demo_mode = parse_cookie_header(&headers)
        .get("fabro-demo")
        .is_some_and(|cookie| cookie.value() == "1");
    Json(AuthMeResponse {
        user: SessionUser {
            login:       authenticated.principal.login.clone(),
            name:        authenticated.profile.name,
            email:       authenticated.profile.email,
            idp_issuer:  Some(authenticated.principal.identity.issuer().to_string()),
            idp_subject: Some(authenticated.principal.identity.subject().to_string()),
            avatar_url:  authenticated.profile.avatar_url,
            user_url:    authenticated.profile.user_url,
        },
        provider: session_provider(authenticated.principal.auth_method).to_string(),
        demo_mode,
    })
    .into_response()
}

async fn list_auth_sessions(
    State(state): State<Arc<AppState>>,
    RequestAuth(auth_slot): RequestAuth,
    headers: HeaderMap,
) -> Response {
    let authenticated = match require_authenticated_user(&auth_slot) {
        Ok(authenticated) => authenticated,
        Err(err) => return err.into_response(),
    };
    let now = Utc::now();
    let mut sessions = Vec::new();

    if let Some(key) = state.session_key() {
        if let Some(session) = read_private_session(&headers, &key) {
            let issued_at = match session_timestamp(session.iat) {
                Ok(timestamp) => timestamp,
                Err(err) => return err.into_response(),
            };
            let expires_at = match session_timestamp(session.exp) {
                Ok(timestamp) => timestamp,
                Err(err) => return err.into_response(),
            };
            sessions.push(AuthSession {
                id: "browser:current".to_string(),
                kind: "browser",
                current: true,
                provider: session_provider(session.auth_method).to_string(),
                login: session.login,
                label: "This browser".to_string(),
                user_agent: None,
                created_at: issued_at,
                last_seen_at: issued_at,
                expires_at,
                revocable: false,
            });
        }
    }

    let cli_sessions = match state
        .stores
        .auth_sessions
        .active_cli_sessions(&authenticated.principal.identity, now)
        .await
    {
        Ok(sessions) => sessions,
        Err(err) => {
            error!(error = %err, "Failed to load auth sessions");
            return ApiError::new(
                StatusCode::INTERNAL_SERVER_ERROR,
                "Failed to list auth sessions.",
            )
            .into_response();
        }
    };

    sessions.extend(cli_sessions.into_iter().map(|active| AuthSession {
        id:           format!("cli:{}", active.session.id),
        kind:         "cli",
        current:      false,
        provider:     "github".to_string(),
        login:        active.session.login,
        label:        "Fabro CLI".to_string(),
        user_agent:   Some(active.session.user_agent),
        created_at:   active.session.created_at,
        last_seen_at: active.session.last_used_at,
        expires_at:   active.expires_at,
        revocable:    true,
    }));
    sessions.sort_by(|left, right| {
        right
            .current
            .cmp(&left.current)
            .then_with(|| right.last_seen_at.cmp(&left.last_seen_at))
    });

    Json(AuthSessionsResponse { sessions }).into_response()
}

async fn delete_auth_session(
    State(state): State<Arc<AppState>>,
    RequestAuth(auth_slot): RequestAuth,
    Path(id): Path<String>,
) -> Response {
    let authenticated = match require_authenticated_user(&auth_slot) {
        Ok(authenticated) => authenticated,
        Err(err) => return err.into_response(),
    };

    if id == "browser:current" {
        return ApiError::bad_request("Browser sessions cannot be revoked by this API version.")
            .into_response();
    }

    let Some(raw_session_id) = id.strip_prefix("cli:") else {
        return ApiError::not_found("Auth session not found.").into_response();
    };
    let Ok(session_id) = uuid::Uuid::parse_str(raw_session_id) else {
        return ApiError::bad_request("Malformed CLI auth session id.").into_response();
    };

    let deleted = match state
        .stores
        .auth_sessions
        .delete_active_session_for_identity(
            &authenticated.principal.identity,
            session_id,
            Utc::now(),
        )
        .await
    {
        Ok(deleted) => deleted,
        Err(err) => {
            error!(error = %err, "Failed to revoke auth session");
            return ApiError::new(
                StatusCode::INTERNAL_SERVER_ERROR,
                "Failed to revoke auth session.",
            )
            .into_response();
        }
    };
    if deleted == 0 {
        return ApiError::not_found("Auth session not found.").into_response();
    }

    StatusCode::NO_CONTENT.into_response()
}

#[cfg(test)]
mod tests {
    use std::sync::{Arc, Mutex};

    use axum::Extension;
    use axum::body::{Body, to_bytes};
    use axum::http::{HeaderMap, Request, StatusCode, header};
    use axum_extra::extract::cookie::Key;
    use fabro_config::{RunLayer, ServerSettingsBuilder};
    use fabro_static::EnvVars;
    use fabro_types::settings::server::ServerAuthMethod;
    use fabro_types::{AuthMethod, IdpIdentity, Principal};
    use fabro_vault::SecretType;
    use serde_json::json;
    use tower::ServiceExt;

    use super::{
        OAUTH_STATE_TTL_MINUTES, api_routes, read_private_oauth_state, read_private_session, routes,
    };
    use crate::auth::{self, AuthErrorCode, GithubEndpoints};
    use crate::jwt_auth::{AuthMode, ConfiguredAuth};
    use crate::principal_middleware::{AuthStatus, RequestAuthContext};
    use crate::server;

    const DEV_TOKEN: &str =
        "fabro_dev_abababababababababababababababababababababababababababababababab";

    fn test_cookie_key() -> Key {
        auth::derive_cookie_key(b"web-auth-test-key-material-0123456789")
            .expect("test key should derive")
    }

    #[test]
    fn redacted_url_for_log_masks_oauth_state_query_values() {
        assert_eq!(
            super::redacted_url_for_log(
                "https://fabro.example.test/auth/callback?state=abc&code=def&keep=1"
            ),
            "https://fabro.example.test/auth/callback?state=****&code=****&keep=1"
        );
    }

    fn dev_token_auth_mode() -> AuthMode {
        AuthMode::Enabled(ConfiguredAuth {
            methods:    vec![ServerAuthMethod::DevToken],
            dev_token:  Some(DEV_TOKEN.to_string()),
            jwt_key:    Some(test_jwt_key()),
            jwt_issuer: Some("https://fabro.example".to_string()),
        })
    }

    fn github_auth_mode() -> AuthMode {
        AuthMode::Enabled(ConfiguredAuth {
            methods:    vec![ServerAuthMethod::Github],
            dev_token:  None,
            jwt_key:    Some(test_jwt_key()),
            jwt_issuer: Some("https://fabro.example".to_string()),
        })
    }

    fn test_jwt_key() -> auth::JwtSigningKey {
        auth::derive_jwt_key(b"web-auth-test-key-material-0123456789")
            .expect("test JWT key should derive")
    }

    fn default_settings() -> fabro_types::ServerSettings {
        ServerSettingsBuilder::from_toml(
            r#"
_version = 1

[server.auth]
methods = ["dev-token"]
"#,
        )
        .expect("default test settings should resolve")
    }

    fn github_settings(web_url: &str) -> fabro_types::ServerSettings {
        ServerSettingsBuilder::from_toml(&format!(
            r#"
_version = 1

[server.web]
enabled = true
url = "{web_url}"

[server.auth]
methods = ["github"]

[server.auth.github]
allowed_usernames = ["octocat"]

[server.integrations.github]
client_id = "github-client-id"
"#
        ))
        .expect("github settings should resolve")
    }

    fn test_auth_router_with_settings(
        settings: fabro_types::ServerSettings,
        auth_mode: AuthMode,
    ) -> axum::Router {
        let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
            settings,
            RunLayer::default(),
            Some("web-auth-test-key-material-0123456789"),
        );
        let translation_state = state.clone();
        let principal_state = state.clone();
        axum::Router::new()
            .nest("/auth", routes())
            .nest(
                "/api/v1",
                api_routes().layer(axum::middleware::from_fn_with_state(
                    principal_state,
                    crate::principal_middleware::principal_middleware,
                )),
            )
            .layer(axum::middleware::from_fn_with_state(
                translation_state,
                crate::auth::auth_translation_middleware,
            ))
            .layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
            .layer(Extension(auth_mode))
            .with_state(state)
    }

    fn test_auth_router_with_capture(
        settings: fabro_types::ServerSettings,
        auth_mode: AuthMode,
    ) -> (axum::Router, Arc<Mutex<Vec<RequestAuthContext>>>) {
        let captured = Arc::new(Mutex::new(Vec::new()));
        let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
            settings,
            RunLayer::default(),
            Some("web-auth-test-key-material-0123456789"),
        );
        let app = axum::Router::new()
            .nest("/auth", routes())
            .layer(axum::middleware::from_fn_with_state(
                Arc::clone(&captured),
                crate::test_support::capture_auth_context,
            ))
            .layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
            .layer(Extension(auth_mode))
            .with_state(state);
        (app, captured)
    }

    fn test_auth_router(_key: &Key, auth_mode: AuthMode) -> axum::Router {
        test_auth_router_with_settings(default_settings(), auth_mode)
    }

    macro_rules! response_json {
        ($response:expr) => {
            fabro_test::expect_axum_json($response, StatusCode::OK, concat!(file!(), ":", line!()))
        };
    }

    macro_rules! assert_status {
        ($response:expr, $expected:expr) => {
            fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
        };
    }

    macro_rules! checked_response {
        ($response:expr, $expected:expr) => {
            fabro_test::expect_axum_status($response, $expected, concat!(file!(), ":", line!()))
        };
    }

    #[tokio::test]
    async fn login_dev_token_mints_session_with_dev_token_provider() {
        let key = test_cookie_key();
        let app = test_auth_router(&key, dev_token_auth_mode());

        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/auth/login/dev-token")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(json!({ "token": DEV_TOKEN }).to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        let response = checked_response!(response, StatusCode::OK).await;

        let session_cookie = response
            .headers()
            .get(header::SET_COOKIE)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.split(';').next())
            .expect("session cookie should be set")
            .to_string();

        let mut cookie_headers = axum::http::HeaderMap::new();
        cookie_headers.insert(
            header::COOKIE,
            axum::http::HeaderValue::from_str(&session_cookie).unwrap(),
        );
        let session = read_private_session(&cookie_headers, &key).expect("session should decode");
        assert_eq!(session.auth_method, AuthMethod::DevToken);
        assert_eq!(session.v, 2);
        assert_eq!(
            session.identity,
            IdpIdentity::new("fabro:dev", "dev").unwrap()
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/v1/auth/me")
                    .header(header::COOKIE, &session_cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let body = response_json!(response).await;
        assert_eq!(body["provider"], "dev-token");
        assert_eq!(body["user"]["login"], "dev");
        assert_eq!(body["user"]["idpIssuer"], "fabro:dev");
        assert_eq!(body["user"]["idpSubject"], "dev");
    }

    #[tokio::test]
    async fn auth_me_accepts_cli_jwt_with_empty_profile_urls() {
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );
        let token = auth::issue(
            &test_jwt_key(),
            "https://fabro.example",
            &auth::JwtSubject {
                identity:    IdpIdentity::new("https://github.com", "12345").unwrap(),
                login:       "octocat".to_string(),
                name:        "The Octocat".to_string(),
                email:       "octocat@example.com".to_string(),
                avatar_url:  String::new(),
                user_url:    String::new(),
                auth_method: AuthMethod::Github,
            },
            chrono::Duration::minutes(10),
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/v1/auth/me")
                    .header(header::AUTHORIZATION, format!("Bearer {token}"))
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        let body = response_json!(response).await;
        assert_eq!(body["provider"], "github");
        assert_eq!(body["user"]["login"], "octocat");
        assert_eq!(body["user"]["avatarUrl"], "");
        assert_eq!(body["user"]["userUrl"], "");
    }

    #[tokio::test]
    async fn auth_me_returns_unauthorized_under_demo_mode_without_jwt() {
        let app = test_auth_router_with_settings(default_settings(), dev_token_auth_mode());

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/v1/auth/me")
                    .header(header::COOKIE, "fabro-demo=1")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_status!(response, StatusCode::UNAUTHORIZED).await;
    }

    #[tokio::test]
    async fn login_dev_token_rejects_invalid_token() {
        let key = test_cookie_key();
        let app = test_auth_router(&key, dev_token_auth_mode());

        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/auth/login/dev-token")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(
                        json!({ "token": "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" })
                            .to_string(),
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_status!(response, StatusCode::UNAUTHORIZED).await;
    }

    #[tokio::test]
    async fn login_dev_token_stamps_public_auth_context() {
        let (app, captured) =
            test_auth_router_with_capture(default_settings(), dev_token_auth_mode());

        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/auth/login/dev-token")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(json!({ "token": DEV_TOKEN }).to_string()))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_status!(response, StatusCode::OK).await;

        let response = app
            .oneshot(
                Request::builder()
                    .method("POST")
                    .uri("/auth/login/dev-token")
                    .header(header::CONTENT_TYPE, "application/json")
                    .body(Body::from(
                        json!({ "token": "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" })
                            .to_string(),
                    ))
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_status!(response, StatusCode::UNAUTHORIZED).await;

        let contexts = captured.lock().expect("captured auth contexts").clone();
        assert_eq!(contexts[0].auth_status, AuthStatus::Authenticated);
        assert!(matches!(contexts[0].principal, Some(Principal::User(_))));
        assert_eq!(contexts[1].auth_status, AuthStatus::Invalid);
        assert_eq!(
            contexts[1].auth_error_code,
            Some(AuthErrorCode::Unauthorized)
        );
    }

    #[tokio::test]
    async fn auth_config_returns_dev_token_method() {
        let key = test_cookie_key();
        let app = test_auth_router(&key, dev_token_auth_mode());

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/v1/auth/config")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let body = response_json!(response).await;
        assert_eq!(body, json!({ "methods": ["dev-token"] }));
    }

    #[tokio::test]
    async fn auth_config_returns_real_methods_when_demo_cookie_set() {
        let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
            github_settings("https://fabro.example"),
            RunLayer::default(),
            Some("web-auth-test-key-material-0123456789"),
        );
        let app = server::build_router_with_options(
            state,
            &github_auth_mode(),
            server::RouterOptions::default(),
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/api/v1/auth/config")
                    .header(header::COOKIE, "fabro-demo=1")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let body = response_json!(response).await;
        assert_eq!(body, json!({ "methods": ["github"] }));
    }

    #[tokio::test]
    async fn login_github_sets_secure_state_cookie_for_https_web_url() {
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/login/github")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        let response = checked_response!(response, StatusCode::SEE_OTHER).await;
        let set_cookie = response
            .headers()
            .get(header::SET_COOKIE)
            .and_then(|value| value.to_str().ok())
            .expect("oauth state cookie should be set");
        assert!(
            set_cookie.contains("Secure"),
            "state cookie should be marked Secure: {set_cookie}"
        );
    }

    #[tokio::test]
    async fn login_github_persists_allowed_cli_return_to_in_state_cookie() {
        let key = test_cookie_key();
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );
        let now = chrono::Utc::now().timestamp();

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/login/github?return_to=/auth/cli/resume")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let cookie = response
            .headers()
            .get(header::SET_COOKIE)
            .and_then(|value| value.to_str().ok())
            .and_then(|value| value.split(';').next())
            .expect("oauth state cookie should be set")
            .to_string();

        let mut headers = HeaderMap::new();
        headers.insert(header::COOKIE, cookie.parse().unwrap());
        let oauth_state =
            read_private_oauth_state(&headers, &key).expect("oauth state should decode");
        assert_eq!(oauth_state.return_to.as_deref(), Some("/auth/cli/resume"));
        assert!(oauth_state.exp >= now + (29 * 60));
        assert!(oauth_state.exp <= now + (OAUTH_STATE_TTL_MINUTES * 60) + 5);
    }

    #[tokio::test]
    async fn login_github_uses_injected_github_endpoints() {
        let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
            github_settings("https://fabro.example"),
            RunLayer::default(),
            Some("web-auth-test-key-material-0123456789"),
        );
        let app = crate::server::build_router_with_options(
            state,
            &github_auth_mode(),
            crate::server::RouterOptions {
                web_enabled:       true,
                github_endpoints:  Some(Arc::new(GithubEndpoints::with_bases(
                    "http://127.0.0.1:12345/"
                        .parse()
                        .expect("oauth base should parse"),
                    "http://127.0.0.1:12345/api/"
                        .parse()
                        .expect("api base should parse"),
                ))),
                static_asset_root: None,
                watch_web:         false,
            },
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/login/github")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        let location = response
            .headers()
            .get(header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .expect("redirect location should be set");
        assert!(location.starts_with("http://127.0.0.1:12345/login/oauth/authorize?"));
    }

    #[tokio::test]
    async fn callback_github_rejects_plain_oauth_state_cookie() {
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/callback/github?code=test-code&state=fabro-test-state")
                    .header(header::COOKIE, "fabro_oauth_state=fabro-test-state")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
        let html = String::from_utf8(body.to_vec()).unwrap();
        assert!(
            html.contains("Your login took too long or was tampered with. Please start again.")
        );
    }

    #[tokio::test]
    async fn callback_github_rejects_expired_oauth_state_cookie_after_35_minutes() {
        let key = test_cookie_key();
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );
        let mut jar = cookie::CookieJar::new();
        super::add_oauth_state_cookie(
            &mut jar,
            &key,
            &super::OAuthStateCookie {
                state:     "fabro-test-state".to_string(),
                exp:       (chrono::Utc::now() - chrono::Duration::minutes(5)).timestamp(),
                return_to: Some("/auth/cli/resume".to_string()),
            },
            true,
        );
        let cookie = jar
            .delta()
            .next()
            .expect("private oauth cookie should exist")
            .encoded()
            .to_string();

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/callback/github?code=test-code&state=fabro-test-state")
                    .header(header::COOKIE, cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
        let html = String::from_utf8(body.to_vec()).unwrap();
        assert!(
            html.contains("Your login took too long or was tampered with. Please start again.")
        );
    }

    #[tokio::test]
    async fn callback_github_forwards_sanitized_error_to_cli_return_to() {
        let key = test_cookie_key();
        let app = test_auth_router_with_settings(
            github_settings("https://fabro.example"),
            github_auth_mode(),
        );
        let mut jar = cookie::CookieJar::new();
        super::add_oauth_state_cookie(
            &mut jar,
            &key,
            &super::OAuthStateCookie {
                state:     "fabro-test-state".to_string(),
                exp:       (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(),
                return_to: Some("/auth/cli/resume".to_string()),
            },
            true,
        );
        let cookie = jar
            .delta()
            .next()
            .expect("private oauth cookie should exist")
            .encoded()
            .to_string();

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/callback/github?error=access_denied&error_description=%3Cscript%3Eboom%3C%2Fscript%3E&state=fabro-test-state")
                    .header(header::COOKIE, cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_eq!(response.status(), StatusCode::SEE_OTHER);
        assert_eq!(
            response
                .headers()
                .get(header::LOCATION)
                .and_then(|v| v.to_str().ok()),
            Some(
                "/auth/cli/resume?error=access_denied&error_description=Authorization%20denied&state=fabro-test-state"
            )
        );
    }

    #[tokio::test]
    async fn callback_github_reads_client_secret_from_vault() {
        let github = httpmock::MockServer::start_async().await;
        let token = github
            .mock_async(|when, then| {
                when.method(httpmock::Method::POST)
                    .path("/login/oauth/access_token")
                    .body_includes("client_secret=vault-client-secret");
                then.status(200)
                    .header("content-type", "application/json")
                    .json_body(json!({ "access_token": "gho_test" }));
            })
            .await;
        let user = github
            .mock_async(|when, then| {
                when.method(httpmock::Method::GET)
                    .path("/api/user")
                    .header("authorization", "Bearer gho_test");
                then.status(200)
                    .header("content-type", "application/json")
                    .json_body(json!({
                        "id": 12345,
                        "login": "octocat",
                        "name": "The Octocat",
                        "avatar_url": "https://github.example/avatar.png"
                    }));
            })
            .await;
        let emails = github
            .mock_async(|when, then| {
                when.method(httpmock::Method::GET).path("/api/user/emails");
                then.status(200)
                    .header("content-type", "application/json")
                    .json_body(json!([]));
            })
            .await;
        let state = crate::test_support::test_app_state_with_runtime_settings_and_session_key(
            github_settings("https://fabro.example"),
            RunLayer::default(),
            Some("web-auth-test-key-material-0123456789"),
        );
        state
            .stores
            .vault
            .set(
                EnvVars::GITHUB_APP_CLIENT_SECRET,
                "vault-client-secret",
                SecretType::Token,
                None,
            )
            .await
            .unwrap();
        let app =
            server::build_router_with_options(state, &github_auth_mode(), server::RouterOptions {
                web_enabled: true,
                github_endpoints: Some(Arc::new(GithubEndpoints::with_bases(
                    github.url("/").parse().expect("oauth base should parse"),
                    github.url("/api/").parse().expect("api base should parse"),
                ))),
                ..server::RouterOptions::default()
            });
        let key = test_cookie_key();
        let mut jar = cookie::CookieJar::new();
        super::add_oauth_state_cookie(
            &mut jar,
            &key,
            &super::OAuthStateCookie {
                state:     "fabro-test-state".to_string(),
                exp:       (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(),
                return_to: None,
            },
            true,
        );
        let cookie = jar
            .delta()
            .next()
            .expect("private oauth cookie should exist")
            .encoded()
            .to_string();

        let response = app
            .oneshot(
                Request::builder()
                    .uri("/auth/callback/github?code=test-code&state=fabro-test-state")
                    .header(header::COOKIE, cookie)
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();

        assert_status!(response, StatusCode::SEE_OTHER).await;
        token.assert_async().await;
        user.assert_async().await;
        emails.assert_async().await;
    }

    #[test]
    fn read_private_session_rejects_v1_cookies() {
        let key = test_cookie_key();
        let mut jar = cookie::CookieJar::new();
        jar.private_mut(&key).add(cookie::Cookie::new(
            super::SESSION_COOKIE_NAME,
            json!({
                "v": 1,
                "login": "dev",
                "auth_method": "dev_token",
                "name": "Development User",
                "email": "dev@localhost",
                "avatar_url": "/images/logo.svg",
                "user_url": "",
                "identity": null,
                "iat": chrono::Utc::now().timestamp(),
                "exp": chrono::Utc::now().timestamp() + 60,
            })
            .to_string(),
        ));
        let encoded = jar
            .delta()
            .next()
            .expect("private cookie should exist")
            .encoded()
            .to_string();

        let mut headers = axum::http::HeaderMap::new();
        headers.insert(header::COOKIE, encoded.parse().unwrap());
        assert!(read_private_session(&headers, &key).is_none());
    }

    #[test]
    fn read_private_session_rejects_invalid_identity_payload() {
        let key = test_cookie_key();
        let mut jar = cookie::CookieJar::new();
        jar.private_mut(&key).add(cookie::Cookie::new(
            super::SESSION_COOKIE_NAME,
            json!({
                "v": 2,
                "login": "octocat",
                "auth_method": "github",
                "name": "The Octocat",
                "email": "octocat@example.com",
                "avatar_url": "/images/logo.svg",
                "user_url": "https://github.com/octocat",
                "identity": {
                    "issuer": "",
                    "subject": "12345"
                },
                "iat": chrono::Utc::now().timestamp(),
                "exp": chrono::Utc::now().timestamp() + 60,
            })
            .to_string(),
        ));
        let encoded = jar
            .delta()
            .next()
            .expect("private cookie should exist")
            .encoded()
            .to_string();

        let mut headers = axum::http::HeaderMap::new();
        headers.insert(header::COOKIE, encoded.parse().unwrap());
        assert!(read_private_session(&headers, &key).is_none());
    }
}
