zitadel-tui 0.1.7

A terminal UI for managing Zitadel resources
use std::path::PathBuf;

use anyhow::{bail, Result};
use reqwest::Client;

use crate::config::AppConfig;

use super::{
    service_account::exchange_service_account,
    session::{ensure_cached_session_is_usable, same_host},
    ResolvedAuth,
};

pub async fn resolve_access_token(
    client: &Client,
    zitadel_url: &str,
    cli_pat: Option<String>,
    cli_service_account_file: Option<PathBuf>,
    config: &AppConfig,
) -> Result<ResolvedAuth> {
    if let Some(pat) = cli_pat {
        return Ok(ResolvedAuth {
            token: pat,
            source: "cli PAT",
        });
    }

    if let Ok(pat) = std::env::var("ZITADEL_TOKEN") {
        if !pat.trim().is_empty() {
            return Ok(ResolvedAuth {
                token: pat,
                source: "env PAT",
            });
        }
    }

    if let Some(pat) = config.pat.clone() {
        return Ok(ResolvedAuth {
            token: pat,
            source: "config PAT",
        });
    }

    if let Some(path) = cli_service_account_file {
        let token = exchange_service_account(client, zitadel_url, path).await?;
        return Ok(ResolvedAuth {
            token,
            source: "cli service account",
        });
    }

    if let Ok(path) = std::env::var("ZITADEL_SERVICE_ACCOUNT_FILE") {
        let token = exchange_service_account(client, zitadel_url, PathBuf::from(path)).await?;
        return Ok(ResolvedAuth {
            token,
            source: "env service account",
        });
    }

    if let Some(path) = config.service_account_file.clone() {
        let token = exchange_service_account(client, zitadel_url, path).await?;
        return Ok(ResolvedAuth {
            token,
            source: "config service account",
        });
    }

    if let Ok(Some(cache)) = crate::token_cache::TokenCache::load() {
        if !same_host(&cache.host, zitadel_url) {
            bail!(
                "no credentials available; use --token, --service-account-file, env vars, config, or `auth login`"
            );
        }
        if !cache.is_expired() {
            ensure_cached_session_is_usable(&cache.access_token, &cache.client_id, &cache.host)?;
            return Ok(ResolvedAuth {
                token: cache.access_token,
                source: "session token",
            });
        }
        if let Some(refresh_token) = &cache.refresh_token {
            if let Ok(tokens) = crate::oidc::refresh_access_token(
                client,
                &cache.host,
                &cache.client_id,
                refresh_token,
            )
            .await
            {
                ensure_cached_session_is_usable(
                    &tokens.access_token,
                    &cache.client_id,
                    &cache.host,
                )?;
                let updated = crate::token_cache::TokenCache {
                    access_token: tokens.access_token.clone(),
                    refresh_token: tokens.refresh_token.or_else(|| Some(refresh_token.clone())),
                    expires_at: Some(crate::oidc::expires_at_from_now(tokens.expires_in)),
                    client_id: cache.client_id,
                    host: cache.host,
                };
                let _ = updated.save();
                return Ok(ResolvedAuth {
                    token: tokens.access_token,
                    source: "session token (refreshed)",
                });
            }
        }
    }

    bail!("no credentials available; use --token, --service-account-file, env vars, config, or `auth login`")
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::Client;
    use std::{
        env,
        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-test-tokens-{unique}.json"))
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn cli_pat_wins_over_env_and_config() {
        let _guard = crate::test_support::env_lock();
        let original_env = env::var("ZITADEL_TOKEN").ok();
        env::set_var("ZITADEL_TOKEN", "env-token");

        let config = AppConfig {
            pat: Some("config-token".to_string()),
            ..AppConfig::default()
        };
        let http = Client::new();
        let auth = resolve_access_token(
            &http,
            "https://zitadel.example.com",
            Some("cli-token".to_string()),
            None,
            &config,
        )
        .await
        .unwrap();

        if let Some(value) = original_env {
            env::set_var("ZITADEL_TOKEN", value);
        } else {
            env::remove_var("ZITADEL_TOKEN");
        }

        assert_eq!(auth.token, "cli-token");
        assert_eq!(auth.source, "cli PAT");
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn blank_env_token_falls_back_to_config_pat() {
        let _guard = crate::test_support::env_lock();
        let original_env = env::var("ZITADEL_TOKEN").ok();
        env::set_var("ZITADEL_TOKEN", "   ");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");

        let config = AppConfig {
            pat: Some("config-token".to_string()),
            ..AppConfig::default()
        };
        let http = Client::new();
        let auth = resolve_access_token(&http, "https://zitadel.example.com", None, None, &config)
            .await
            .unwrap();

        if let Some(value) = original_env {
            env::set_var("ZITADEL_TOKEN", value);
        } else {
            env::remove_var("ZITADEL_TOKEN");
        }

        assert_eq!(auth.token, "config-token");
        assert_eq!(auth.source, "config PAT");
    }

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

        let original_token = env::var("ZITADEL_TOKEN").ok();
        let original_sa = env::var("ZITADEL_SERVICE_ACCOUNT_FILE").ok();
        env::remove_var("ZITADEL_TOKEN");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");

        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: "c1".to_string(),
            host: "https://zitadel.example.com".to_string(),
        };
        cache.save().unwrap();

        let http = Client::new();
        let config = AppConfig::default();
        let auth = resolve_access_token(&http, "https://zitadel.example.com", None, None, &config)
            .await
            .unwrap();

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = std::fs::remove_file(&cache_path);
        if let Some(v) = original_token {
            env::set_var("ZITADEL_TOKEN", v);
        }
        if let Some(v) = original_sa {
            env::set_var("ZITADEL_SERVICE_ACCOUNT_FILE", v);
        }

        assert_eq!(auth.token, "header.payload.signature");
        assert_eq!(auth.source, "session token");
    }

    #[allow(clippy::await_holding_lock)]
    #[tokio::test]
    async fn auto_refreshes_expired_token_cache() {
        let mut server = mockito::Server::new_async().await;
        server
            .mock("POST", "/oauth/v2/token")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(r#"{"access_token":"header.payload.signature","expires_in":3600}"#)
            .create_async()
            .await;

        let _guard = crate::test_support::env_lock();
        let cache_path = temp_cache_path();
        env::set_var("ZITADEL_TUI_TOKEN_CACHE", &cache_path);

        let original_token = env::var("ZITADEL_TOKEN").ok();
        let original_sa = env::var("ZITADEL_SERVICE_ACCOUNT_FILE").ok();
        env::remove_var("ZITADEL_TOKEN");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");

        let cache = crate::token_cache::TokenCache {
            access_token: "expired-token".to_string(),
            refresh_token: Some("my-refresh-token".to_string()),
            expires_at: Some(0),
            client_id: "c1".to_string(),
            host: server.url(),
        };
        cache.save().unwrap();

        let http = Client::new();
        let config = AppConfig::default();
        let auth = resolve_access_token(&http, &server.url(), None, None, &config)
            .await
            .unwrap();

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = std::fs::remove_file(&cache_path);
        if let Some(v) = original_token {
            env::set_var("ZITADEL_TOKEN", v);
        }
        if let Some(v) = original_sa {
            env::set_var("ZITADEL_SERVICE_ACCOUNT_FILE", v);
        }

        assert_eq!(auth.token, "header.payload.signature");
        assert!(auth.source.contains("refreshed"));
    }

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

        let original_token = env::var("ZITADEL_TOKEN").ok();
        let original_sa = env::var("ZITADEL_SERVICE_ACCOUNT_FILE").ok();
        env::remove_var("ZITADEL_TOKEN");
        env::remove_var("ZITADEL_SERVICE_ACCOUNT_FILE");

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let cache = crate::token_cache::TokenCache {
            access_token: "opaque-token".to_string(),
            refresh_token: Some("refresh-token".to_string()),
            expires_at: Some(now + 3600),
            client_id: "c1".to_string(),
            host: "https://zitadel.example.com".to_string(),
        };
        cache.save().unwrap();

        let http = Client::new();
        let config = AppConfig::default();
        let error = resolve_access_token(&http, "https://zitadel.example.com", None, None, &config)
            .await
            .unwrap_err()
            .to_string();

        env::remove_var("ZITADEL_TUI_TOKEN_CACHE");
        let _ = std::fs::remove_file(&cache_path);
        if let Some(v) = original_token {
            env::set_var("ZITADEL_TOKEN", v);
        }
        if let Some(v) = original_sa {
            env::set_var("ZITADEL_SERVICE_ACCOUNT_FILE", v);
        }

        assert!(error.contains("JWT access tokens"));
        assert!(error.contains("auth login"));
    }
}