offline-cli 0.3.3

The Offline Protocol developer CLI
//! Provision org, application, and API key for CLI workflows.

use anyhow::{bail, Context, Result};

use crate::api::DeveloperClient;
use crate::config::CliConfig;
use crate::output;

pub struct ProvisionOptions<'a> {
    pub app_name: &'a str,
    pub org_name: Option<&'a str>,
    pub key_alias: Option<&'a str>,
    pub product: &'a str,
}

/// Ensure the config has org_id, app_id, and api_key — creating resources when needed.
pub async fn ensure_provisioned(
    config: &mut CliConfig,
    options: ProvisionOptions<'_>,
) -> Result<()> {
    if config.has_api_credentials() {
        return Ok(());
    }

    if config.effective_access_token().is_none() {
        return Err(email_login_required(
            "creating an application and API key",
            config,
        ));
    }
    let token = developer_session(config, "setup").await?;
    let client = DeveloperClient::new(config.effective_api_base())?;

    let org_id = ensure_org(config, &client, &token, options.org_name, options.app_name).await?;
    let app_id = ensure_app(config, &client, &token, &org_id, options.app_name).await?;
    ensure_key(
        config,
        &client,
        &token,
        &app_id,
        options.key_alias.unwrap_or("CLI default"),
        options.product,
    )
    .await?;

    config.save()?;
    Ok(())
}

async fn ensure_org(
    config: &mut CliConfig,
    client: &DeveloperClient,
    token: &str,
    org_name: Option<&str>,
    app_name: &str,
) -> Result<String> {
    if let Some(org_id) = config.effective_org_id() {
        return Ok(org_id);
    }

    let orgs = client.list_organizations(token).await?;
    if let Some(org) = orgs.first() {
        config.org_id = Some(org.id.clone());
        return Ok(org.id.clone());
    }

    let name = org_name
        .map(str::to_string)
        .or_else(|| config.email.as_ref().map(|e| format!("{e}'s organization")))
        .unwrap_or_else(|| format!("{app_name} organization"));

    output::progress(&format!("Creating organization `{name}`..."));
    let org = client.create_organization(token, &name).await?;
    config.org_id = Some(org.id.clone());
    Ok(org.id)
}

async fn ensure_app(
    config: &mut CliConfig,
    client: &DeveloperClient,
    token: &str,
    org_id: &str,
    app_name: &str,
) -> Result<String> {
    if let Some(app_id) = config.effective_app_id() {
        return Ok(app_id);
    }

    let apps = client.list_applications(token).await?;
    if let Some(app) = apps
        .iter()
        .find(|a| a.organization_id.as_deref() == Some(org_id))
    {
        config.app_id = Some(app.id.clone());
        return Ok(app.id.clone());
    }

    output::progress(&format!("Creating application `{app_name}`..."));
    let app = client
        .create_application(token, app_name, org_id, None)
        .await?;
    config.app_id = Some(app.id.clone());
    Ok(app.id)
}

async fn ensure_key(
    config: &mut CliConfig,
    client: &DeveloperClient,
    token: &str,
    app_id: &str,
    alias: &str,
    product: &str,
) -> Result<()> {
    if config.effective_api_key().is_some() {
        return Ok(());
    }

    // Keys are stored hashed, so an existing one can't be read back. Reuse the
    // one this machine created for the app instead of making another.
    if let Some(key) = config.app_keys.get(app_id).cloned() {
        config.api_key = Some(key);
        output::note(&format!("Reusing the API key for application `{app_id}`"));
        return Ok(());
    }

    output::progress(&format!(
        "Creating API key `{alias}` for application `{app_id}`..."
    ));
    let key = client
        .create_api_key(token, app_id, alias, product, "full_access")
        .await?;
    let api_key = key
        .key
        .filter(|k| !k.is_empty())
        .ok_or_else(|| anyhow::anyhow!("create API key response missing key value"))?;
    config.app_keys.insert(app_id.to_string(), api_key.clone());
    config.api_key = Some(api_key);
    Ok(())
}

/// Commands that call the developer-service management routes (`orgs`, `apps`,
/// `keys`, `setup`) need the developer session that `offline login` stores.
/// An application API key (`--api-key`, for CI) can't run them.
pub fn require_access_token(config: &CliConfig, command: &str) -> Result<String> {
    config
        .effective_access_token()
        .ok_or_else(|| email_login_required(&format!("`offline {command}`"), config))
}

/// The developer session for a management command, renewed first when it is
/// past half its lifetime (sliding session: auth-service issues a fresh token
/// of the same session, and it is saved to the config file). An active
/// developer never has to run `offline login --email` again before the
/// session's 90-day cap. A token from `OFFLINE_ACCESS_TOKEN` is used as given.
/// Renewal is best effort: on any failure the current token is used, and the
/// API says when a session has really ended.
pub async fn developer_session(config: &mut CliConfig, command: &str) -> Result<String> {
    let token = require_access_token(config, command)?;
    let from_env = std::env::var(crate::config::storage::ENV_ACCESS_TOKEN)
        .ok()
        .is_some_and(|t| !t.is_empty());
    if from_env || !renewal_due(&token, now_secs()) {
        return Ok(token);
    }
    let client = DeveloperClient::new(config.effective_api_base())?;
    match client.refresh_session(&token).await {
        Ok(Some(fresh)) => {
            config.access_token = Some(fresh.clone());
            config.save()?;
            Ok(fresh)
        }
        Ok(None) | Err(_) => Ok(token),
    }
}

fn now_secs() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// `(iat, exp)` of a JWT, read without verifying it (the server verifies).
fn token_times(token: &str) -> Option<(u64, u64)> {
    use base64::Engine;
    let payload = token.split('.').nth(1)?;
    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload.trim_end_matches('='))
        .ok()?;
    let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    Some((claims.get("iat")?.as_u64()?, claims.get("exp")?.as_u64()?))
}

/// Whether a developer token is past half its lifetime and not yet expired.
fn renewal_due(token: &str, now: u64) -> bool {
    match token_times(token) {
        Some((iat, exp)) if exp > iat => now >= iat + (exp - iat) / 2 && now < exp,
        _ => false,
    }
}

/// Error for a missing developer session. `what` names the command or action.
pub fn email_login_required(what: &str, config: &CliConfig) -> anyhow::Error {
    let why = if config.effective_api_key().is_some() {
        "You are signed in with an app key (for CI), which can't manage organizations, \
         applications or keys."
    } else {
        "You are not signed in."
    };
    anyhow::anyhow!(
        "{what} needs a developer session. {why} Run `offline login` (or \
         `offline login --email` without a browser), then try again."
    )
}

pub fn resolve_app_id(config: &CliConfig, flag: Option<&str>) -> Result<String> {
    flag.map(str::to_string)
        .or_else(|| config.effective_app_id())
        .context("application id required — pass --app-id or run `offline setup`")
}

pub fn resolve_org_id(config: &CliConfig, flag: Option<&str>) -> Result<String> {
    flag.map(str::to_string)
        .or_else(|| config.effective_org_id())
        .context("organization id required — pass --org-id or run `offline setup`")
}

pub async fn client_from_config(config: &CliConfig) -> Result<DeveloperClient> {
    DeveloperClient::new(config.effective_api_base())
}

/// API key product scope when scaffolding (PoL and mesh need broader keys than ID-only).
pub fn product_for_create(mesh_enabled: bool, pol_enabled: bool) -> &'static str {
    if mesh_enabled || pol_enabled {
        "all"
    } else {
        "offline_id"
    }
}

pub async fn ensure_logged_in_for_provision(config: &CliConfig, interactive: bool) -> Result<()> {
    if config.effective_access_token().is_some() || config.has_api_credentials() {
        return Ok(());
    }
    if !interactive {
        bail!(
            "not logged in — run `offline login` first (or set OFFLINE_ACCESS_TOKEN / OFFLINE_API_KEY)"
        );
    }
    crate::auth::login::run_login(crate::auth::login::LoginOptions {
        api_key: None,
        email: None,
        code: None,
        no_browser: false,
    })
    .await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::storage::{test_sync::env_lock, ENV_ACCESS_TOKEN, ENV_API_KEY};

    #[test]
    fn management_commands_ask_for_a_developer_session_after_an_app_key_login() {
        let _lock = env_lock();
        std::env::remove_var(ENV_ACCESS_TOKEN);
        std::env::remove_var(ENV_API_KEY);

        let browser_login = CliConfig {
            api_key: Some("550e8400-e29b-41d4-a716-446655440000".into()),
            app_id: Some("app_abc".into()),
            ..Default::default()
        };
        for command in ["orgs", "apps", "keys"] {
            let err = require_access_token(&browser_login, command)
                .unwrap_err()
                .to_string();
            assert!(err.contains(&format!("`offline {command}`")), "{err}");
            assert!(err.contains("needs a developer session"), "{err}");
            assert!(err.contains("app key (for CI)"), "{err}");
            assert!(err.contains("Run `offline login`"), "{err}");
        }

        let signed_out = CliConfig::default();
        let err = require_access_token(&signed_out, "orgs")
            .unwrap_err()
            .to_string();
        assert!(err.contains("You are not signed in"), "{err}");
        assert!(err.contains("Run `offline login`"), "{err}");

        let email_login = CliConfig {
            access_token: Some("jwt".into()),
            ..Default::default()
        };
        assert_eq!(require_access_token(&email_login, "keys").unwrap(), "jwt");
    }

    fn jwt_with(iat: u64, exp: u64) -> String {
        use base64::Engine;
        let enc = |v: serde_json::Value| {
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v.to_string())
        };
        format!(
            "{}.{}.sig",
            enc(serde_json::json!({ "alg": "RS256" })),
            enc(serde_json::json!({ "iat": iat, "exp": exp, "sub": "7" }))
        )
    }

    #[test]
    fn renewal_is_due_in_the_second_half_of_a_token() {
        let day = 86_400;
        let t = jwt_with(1_000_000, 1_000_000 + 30 * day);
        assert!(!renewal_due(&t, 1_000_000 + 14 * day));
        assert!(renewal_due(&t, 1_000_000 + 15 * day));
        assert!(
            !renewal_due(&t, 1_000_000 + 31 * day),
            "expired: sign in again"
        );
        assert!(!renewal_due("not-a-jwt", 1_000_000));
    }

    #[tokio::test]
    #[allow(clippy::await_holding_lock)]
    async fn a_session_past_half_its_life_is_renewed_and_saved() {
        use crate::config::storage::{ENV_CONFIG_DIR, ENV_ENV};
        use wiremock::matchers::{header, method, path};
        use wiremock::{Mock, MockServer, ResponseTemplate};
        let _lock = env_lock();
        let dir = tempfile::tempdir().unwrap();
        std::env::set_var(ENV_CONFIG_DIR, dir.path());
        std::env::remove_var(ENV_ACCESS_TOKEN);
        std::env::remove_var(ENV_ENV);
        let server = MockServer::start().await;
        std::env::set_var("OFFLINE_API_BASE", format!("{}/api/v1", server.uri()));

        let now = super::now_secs();
        let old = jwt_with(now - 16 * 86_400, now + 14 * 86_400);
        let fresh = jwt_with(now, now + 30 * 86_400);
        Mock::given(method("POST"))
            .and(path("/api/v1/developers/sessions/refresh"))
            .and(header("authorization", format!("Bearer {old}").as_str()))
            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "data": { "renewed": true, "token": fresh, "expires_at": now + 30 * 86_400 }
            })))
            .expect(1)
            .mount(&server)
            .await;

        let mut config = CliConfig {
            access_token: Some(old.clone()),
            ..Default::default()
        };
        config.save().unwrap();
        assert_eq!(developer_session(&mut config, "orgs").await.unwrap(), fresh);
        assert_eq!(
            CliConfig::load().unwrap().access_token.as_deref(),
            Some(fresh.as_str()),
            "saved for the next command"
        );
        // The fresh token is in its first half: no second call.
        assert_eq!(developer_session(&mut config, "orgs").await.unwrap(), fresh);

        // A server that refuses (session ended) leaves the token as it is; the
        // next API call reports the ended session.
        let ended = jwt_with(now - 20 * 86_400, now + 10 * 86_400);
        let refused = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/api/v1/developers/sessions/refresh"))
            .respond_with(ResponseTemplate::new(401).set_body_json(
                serde_json::json!({ "message": "Session has ended. Sign in again." }),
            ))
            .mount(&refused)
            .await;
        std::env::set_var("OFFLINE_API_BASE", format!("{}/api/v1", refused.uri()));
        config.access_token = Some(ended.clone());
        assert_eq!(developer_session(&mut config, "orgs").await.unwrap(), ended);

        std::env::remove_var("OFFLINE_API_BASE");
        std::env::remove_var(ENV_CONFIG_DIR);
        server.verify().await;
    }

    #[tokio::test]
    #[allow(clippy::await_holding_lock)]
    async fn setup_reuses_the_key_this_machine_created_for_the_app() {
        let _lock = env_lock();
        std::env::remove_var(ENV_API_KEY);
        // No server is reachable: a reused key must not make a request.
        let client = DeveloperClient::new("http://127.0.0.1:1/api/v1").unwrap();
        let mut config = CliConfig::default();
        config.app_keys.insert("app_1".into(), "key_1".into());

        ensure_key(&mut config, &client, "jwt", "app_1", "CLI default", "all")
            .await
            .unwrap();
        assert_eq!(config.api_key.as_deref(), Some("key_1"));
    }
}