zitadel-tui 0.1.3

A terminal UI for managing Zitadel resources
use anyhow::{bail, Context, Result};
use reqwest::{Client, StatusCode};

const SESSION_TOKEN_REMEDIATION: &str = "OIDC device-login session tokens must be JWT access tokens that Zitadel APIs accept. Reconfigure the native app to enable Device Code and JWT access tokens, then run `auth login` again, or use `auth logout` to clear the cached session.";

pub async fn validate_login_session_token(
    client: &Client,
    zitadel_url: &str,
    client_id: &str,
    access_token: &str,
) -> Result<()> {
    ensure_cached_session_is_usable(access_token, client_id, zitadel_url)?;

    let url = format!("{}/auth/v1/users/me", zitadel_url.trim_end_matches('/'));
    let response = client
        .get(url)
        .bearer_auth(access_token)
        .header("Accept", "application/json")
        .send()
        .await
        .context("failed to validate OIDC device-login session token")?;
    let status = response.status();
    let body = response.bytes().await?;

    if status.is_success() {
        return Ok(());
    }

    if status == StatusCode::FORBIDDEN && is_authentication_required_response(&body) {
        bail!(session_token_error(client_id, zitadel_url));
    }

    bail!("session token validation failed ({status})");
}

pub(crate) fn ensure_cached_session_is_usable(
    access_token: &str,
    client_id: &str,
    host: &str,
) -> Result<()> {
    if token_looks_like_jwt(access_token) {
        return Ok(());
    }

    bail!(session_token_error(client_id, host));
}

pub(crate) fn same_host(left: &str, right: &str) -> bool {
    left.trim_end_matches('/') == right.trim_end_matches('/')
}

fn session_token_error(client_id: &str, host: &str) -> String {
    format!(
        "cached device-login session for client `{client_id}` on {host} is not usable for Zitadel APIs. {SESSION_TOKEN_REMEDIATION}"
    )
}

fn token_looks_like_jwt(token: &str) -> bool {
    token.split('.').count() == 3
}

fn is_authentication_required_response(body: &[u8]) -> bool {
    let Ok(json) = serde_json::from_slice::<serde_json::Value>(body) else {
        return false;
    };

    let code_matches = json.get("code").and_then(|value| value.as_i64()) == Some(7);
    let message_matches = json
        .get("message")
        .and_then(|value| value.as_str())
        .map(|message| message.contains("authentication required"))
        .unwrap_or(false);
    let detail_matches = json
        .get("details")
        .and_then(|value| value.as_array())
        .map(|details| {
            details.iter().any(|detail| {
                detail
                    .get("id")
                    .and_then(|value| value.as_str())
                    .map(|id| id == "AUTHZ-Kl3p0")
                    .unwrap_or(false)
            })
        })
        .unwrap_or(false);

    code_matches || message_matches || detail_matches
}