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,
}
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(());
}
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(())
}
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))
}
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)
}
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()?))
}
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,
}
}
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())
}
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"
);
assert_eq!(developer_session(&mut config, "orgs").await.unwrap(), fresh);
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);
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"));
}
}