zitadel-tui 0.1.4

A terminal UI for managing Zitadel resources
use anyhow::Result;
use serde_json::Value;

use crate::{
    auth::{resolve_access_token, validate_login_session_token},
    cli::{AuthAction, AuthCommand, Cli},
    client::ZitadelClient,
    config::AppConfig,
    oidc, token_cache,
};

use super::shared::{resolved_host, resolved_host_or_cache};

pub async fn execute_auth_command(
    command: &AuthCommand,
    args: &Cli,
    config: &AppConfig,
) -> Result<Value> {
    match &command.action {
        AuthAction::Login(login_args) => {
            let host = resolved_host(args, config)?;
            let client_id = if let Some(id) = &login_args.client_id {
                id.clone()
            } else if let Some(id) = &config.device_client_id {
                id.clone()
            } else {
                let id = prompt_for_client_id()?;
                let mut updated = config.clone();
                updated.device_client_id = Some(id.clone());
                let _ = updated.save_to_canonical_path();
                id
            };

            let http = reqwest::Client::new();
            let auth_resp = oidc::device_authorize(&http, &host, &client_id).await?;

            eprintln!("\nOpen this URL in your browser:");
            eprintln!(
                "  {}",
                auth_resp
                    .verification_uri_complete
                    .as_deref()
                    .unwrap_or(&auth_resp.verification_uri)
            );
            eprintln!(
                "\nOr go to {} and enter code: {}\n",
                auth_resp.verification_uri, auth_resp.user_code
            );

            let mut interval = auth_resp.interval;
            let tokens = loop {
                tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
                match oidc::poll_for_token(&http, &host, &client_id, &auth_resp.device_code).await {
                    Ok(tokens) => break tokens,
                    Err(oidc::PollError::Pending) => {
                        eprint!(".");
                    }
                    Err(oidc::PollError::SlowDown) => {
                        interval += 5;
                        eprint!(".");
                    }
                    Err(oidc::PollError::Fatal(error)) => return Err(error),
                }
            };
            eprintln!("\nAuthenticated.");

            persist_login_session(&http, &host, &client_id, tokens).await?;

            Ok(serde_json::json!({
                "status": "authenticated",
                "host": host,
                "token_cache": token_cache::TokenCache::path()?.display().to_string(),
            }))
        }
        AuthAction::Logout => {
            token_cache::TokenCache::clear()?;
            Ok(serde_json::json!({ "status": "logged out" }))
        }
        AuthAction::Status => {
            let host = resolved_host_or_cache(args, config)?;
            let http = reqwest::Client::new();
            let auth = resolve_access_token(
                &http,
                &host,
                args.token.clone(),
                args.service_account_file.clone(),
                config,
            )
            .await?;
            let client = ZitadelClient::new(host.clone(), auth.token)?;
            let me = client.whoami().await?;
            let (user_id, login_name) = auth_status_identity(&me);
            Ok(serde_json::json!({
                "host": host,
                "auth_source": auth.source,
                "user_id": user_id,
                "login_name": login_name,
            }))
        }
    }
}

fn auth_status_identity(me: &Value) -> (Value, Value) {
    let user = me.get("user").unwrap_or(&Value::Null);
    let user_id = user
        .get("userId")
        .or_else(|| user.get("id"))
        .cloned()
        .unwrap_or(Value::Null);
    let login_name = user
        .get("preferredLoginName")
        .or_else(|| user.get("userName"))
        .or_else(|| user.get("loginName"))
        .cloned()
        .unwrap_or(Value::Null);

    (user_id, login_name)
}

fn prompt_for_client_id() -> Result<String> {
    eprint!("Zitadel native app client ID: ");
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    let id = input.trim().to_string();
    if id.is_empty() {
        anyhow::bail!("client ID cannot be empty");
    }
    Ok(id)
}

pub async fn persist_login_session(
    http: &reqwest::Client,
    host: &str,
    client_id: &str,
    tokens: oidc::OidcTokens,
) -> Result<()> {
    validate_login_session_token(http, host, client_id, &tokens.access_token).await?;

    let cache = token_cache::TokenCache {
        access_token: tokens.access_token,
        refresh_token: tokens.refresh_token,
        expires_at: Some(oidc::expires_at_from_now(tokens.expires_in)),
        client_id: client_id.to_string(),
        host: host.to_string(),
    };
    cache.save()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::Cli;
    use clap::Parser;
    use mockito::Server;
    use std::{
        env, fs,
        time::{SystemTime, UNIX_EPOCH},
    };

    fn temp_cache_path() -> std::path::PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        env::temp_dir().join(format!("zitadel-tui-main-test-tokens-{unique}.json"))
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn persist_login_session_saves_cache_after_successful_auth_probe() {
        let _guard = crate::test_support::env_lock();
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let mut server = Server::new_async().await;
        let auth_probe = server
            .mock("GET", "/auth/v1/users/me")
            .match_header("authorization", "Bearer header.payload.signature")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"user":{"userId":"u1"}}"#)
            .create_async()
            .await;

        let http = reqwest::Client::new();
        persist_login_session(
            &http,
            &server.url(),
            "native-app-id",
            oidc::OidcTokens {
                access_token: "header.payload.signature".to_string(),
                refresh_token: Some("refresh-token".to_string()),
                expires_in: 3600,
            },
        )
        .await
        .unwrap();

        auth_probe.assert_async().await;
        let cache = crate::token_cache::TokenCache::load()
            .unwrap()
            .expect("cache entry");
        assert_eq!(cache.access_token, "header.payload.signature");
        assert_eq!(cache.refresh_token.as_deref(), Some("refresh-token"));
        assert_eq!(cache.client_id, "native-app-id");
        assert_eq!(cache.host, server.url());

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = fs::remove_file(cache_path);
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn persist_login_session_rejects_unusable_device_tokens_without_writing_cache() {
        let _guard = crate::test_support::env_lock();
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let http = reqwest::Client::new();
        let error = persist_login_session(
            &http,
            "https://zitadel.example.com",
            "native-app-id",
            oidc::OidcTokens {
                access_token: "opaque-token".to_string(),
                refresh_token: Some("refresh-token".to_string()),
                expires_in: 3600,
            },
        )
        .await
        .unwrap_err()
        .to_string();

        assert!(error.contains("JWT access tokens"));
        assert!(crate::token_cache::TokenCache::load().unwrap().is_none());

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = fs::remove_file(cache_path);
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn persist_login_session_rejects_auth_api_probe_failures_without_writing_cache() {
        let _guard = crate::test_support::env_lock();
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let mut server = Server::new_async().await;
        let auth_probe = server
            .mock("GET", "/auth/v1/users/me")
            .match_header("authorization", "Bearer header.payload.signature")
            .with_status(401)
            .with_header("content-type", "application/json")
            .with_body(r#"{"message":"bad token"}"#)
            .create_async()
            .await;

        let http = reqwest::Client::new();
        let error = persist_login_session(
            &http,
            &server.url(),
            "native-app-id",
            oidc::OidcTokens {
                access_token: "header.payload.signature".to_string(),
                refresh_token: Some("refresh-token".to_string()),
                expires_in: 3600,
            },
        )
        .await
        .unwrap_err()
        .to_string();

        auth_probe.assert_async().await;
        assert!(error.contains("session token validation failed"));
        assert!(crate::token_cache::TokenCache::load().unwrap().is_none());

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = fs::remove_file(cache_path);
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn auth_status_uses_valid_cached_session_tokens() {
        let _guard = crate::test_support::env_lock();
        let original_host = env::var("ZITADEL_URL").ok();
        env::remove_var("ZITADEL_URL");
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let mut server = Server::new_async().await;
        let _whoami = server
            .mock("GET", "/auth/v1/users/me")
            .match_header("authorization", "Bearer header.payload.signature")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"user":{"userId":"u-123","preferredLoginName":"admin@example.com"}}"#)
            .create_async()
            .await;

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let cache = crate::token_cache::TokenCache {
            access_token: "header.payload.signature".to_string(),
            refresh_token: None,
            expires_at: Some(now + 3600),
            client_id: "native-app-id".to_string(),
            host: server.url(),
        };
        cache.save().unwrap();

        let args = Cli::parse_from(["zitadel-tui", "--once", "auth", "status"]);
        let command = match &args.command {
            Some(crate::cli::Command::Auth(command)) => command.clone(),
            other => panic!("unexpected command: {other:?}"),
        };

        let result = execute_auth_command(&command, &args, &AppConfig::default())
            .await
            .unwrap();

        assert_eq!(result["host"], server.url());
        assert_eq!(result["auth_source"], "session token");
        assert_eq!(result["user_id"], "u-123");
        assert_eq!(result["login_name"], "admin@example.com");

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = fs::remove_file(cache_path);
        if let Some(host) = original_host {
            env::set_var("ZITADEL_URL", host);
        }
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn auth_status_extracts_machine_user_id_from_id_field() {
        let _guard = crate::test_support::env_lock();
        let original_host = env::var("ZITADEL_URL").ok();
        let original_token = env::var("ZITADEL_TOKEN").ok();
        let original_service_account = env::var("ZITADEL_SERVICE_ACCOUNT_FILE").ok();
        env::remove_var("ZITADEL_TOKEN");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");

        let mut server = Server::new_async().await;
        let _whoami = server
            .mock("GET", "/auth/v1/users/me")
            .match_header("authorization", "Bearer service-account-token")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(
                r#"{"user":{"id":"355223427969778852","preferredLoginName":"zitadel-admin-sa","machine":{"name":"Admin"}}}"#,
            )
            .create_async()
            .await;

        env::set_var("ZITADEL_URL", server.url());

        let args = Cli::parse_from([
            "zitadel-tui",
            "--once",
            "--token",
            "service-account-token",
            "auth",
            "status",
        ]);
        let command = match &args.command {
            Some(crate::cli::Command::Auth(command)) => command.clone(),
            other => panic!("unexpected command: {other:?}"),
        };

        let result = execute_auth_command(&command, &args, &AppConfig::default())
            .await
            .unwrap();

        assert_eq!(result["host"], server.url());
        assert_eq!(result["auth_source"], "cli PAT");
        assert_eq!(result["user_id"], "355223427969778852");
        assert_eq!(result["login_name"], "zitadel-admin-sa");

        if let Some(host) = original_host {
            env::set_var("ZITADEL_URL", host);
        } else {
            env::remove_var("ZITADEL_URL");
        }
        if let Some(token) = original_token {
            env::set_var("ZITADEL_TOKEN", token);
        }
        if let Some(path) = original_service_account {
            env::set_var("ZITADEL_SERVICE_ACCOUNT_FILE", path);
        }
    }
}