sylphx-cli 0.2.10

Sylphx Platform CLI — dogfoods the Rust Management SDK
//! Standards-bound OAuth session lifecycle for the Rust CLI.
//!
//! Discovery owns endpoint selection. Stored credentials carry the exact
//! issuer and endpoints that were admitted at login, so later commands never
//! invent an auth host or follow a cross-origin token endpoint.

use anyhow::{anyhow, bail, Context, Result};
use reqwest::{Client, Url};
use serde::Deserialize;
use serde_json::{json, Value};

use crate::credentials::{now_epoch_seconds, OAuthSession};

const REFRESH_SKEW_SECS: u64 = 300;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthMetadata {
    pub issuer: String,
    pub token_endpoint: String,
    pub revocation_endpoint: String,
    pub device_authorization_endpoint: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthTokenSet {
    pub access_token: String,
    pub refresh_token: String,
    pub expires_in: u64,
    pub refresh_expires_at: Option<String>,
    pub scope: Option<String>,
}

#[derive(Debug, Deserialize)]
struct AuthorizationServerMetadata {
    issuer: String,
    token_endpoint: String,
    revocation_endpoint: String,
    device_authorization_endpoint: String,
    #[serde(default)]
    grant_types_supported: Vec<String>,
    #[serde(default)]
    token_endpoint_auth_methods_supported: Vec<String>,
}

pub fn http_client() -> Result<Client> {
    Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .context("build OAuth HTTP client")
}

pub async fn discover(http: &Client, auth_base: &str) -> Result<OAuthMetadata> {
    let expected_issuer = issuer_from_auth_base(auth_base)?;
    let metadata_url = format!(
        "{}/.well-known/oauth-authorization-server",
        expected_issuer.trim_end_matches('/')
    );
    let response = http
        .get(&metadata_url)
        .send()
        .await
        .with_context(|| format!("OAuth discovery GET {metadata_url}"))?;
    if !response.status().is_success() {
        bail!(
            "OAuth discovery failed HTTP {} at {}",
            response.status(),
            metadata_url
        );
    }
    let document: AuthorizationServerMetadata = response
        .json()
        .await
        .with_context(|| format!("decode OAuth metadata from {metadata_url}"))?;

    let issuer = document.issuer.trim_end_matches('/').to_string();
    if issuer != expected_issuer {
        bail!("OAuth issuer mismatch: expected {expected_issuer}, received {issuer}");
    }
    if !document
        .grant_types_supported
        .iter()
        .any(|grant| grant == "refresh_token")
    {
        bail!("OAuth server does not advertise refresh_token");
    }
    if !document
        .token_endpoint_auth_methods_supported
        .iter()
        .any(|method| method == "none")
    {
        bail!("OAuth server does not admit a public-client token endpoint");
    }

    for (kind, endpoint) in [
        ("token", document.token_endpoint.as_str()),
        ("revocation", document.revocation_endpoint.as_str()),
        (
            "device authorization",
            document.device_authorization_endpoint.as_str(),
        ),
    ] {
        validate_bound_endpoint(&issuer, endpoint)
            .with_context(|| format!("invalid {kind} endpoint"))?;
    }

    Ok(OAuthMetadata {
        issuer,
        token_endpoint: document.token_endpoint,
        revocation_endpoint: document.revocation_endpoint,
        device_authorization_endpoint: document.device_authorization_endpoint,
    })
}

pub fn token_set_from_value(body: &Value) -> Result<OAuthTokenSet> {
    let access_token = string_field(body, &["access_token", "accessToken"])
        .ok_or_else(|| anyhow!("OAuth token response is missing access_token"))?;
    let refresh_token = string_field(body, &["refresh_token", "refreshToken"])
        .ok_or_else(|| anyhow!("OAuth token response is missing rotating refresh_token"))?;
    let expires_in = body
        .get("expires_in")
        .or_else(|| body.get("expiresIn"))
        .and_then(Value::as_u64)
        .ok_or_else(|| anyhow!("OAuth token response is missing expires_in"))?;
    if expires_in == 0 {
        bail!("OAuth token response has zero expires_in");
    }
    let refresh_expires_at = string_field(body, &["refresh_expires_at", "refreshExpiresAt"]);
    let scope = body
        .get("scope")
        .and_then(Value::as_str)
        .map(str::to_string);
    Ok(OAuthTokenSet {
        access_token,
        refresh_token,
        expires_in,
        refresh_expires_at,
        scope,
    })
}

pub fn session_from_token_set(
    metadata: &OAuthMetadata,
    client_id: &str,
    tokens: &OAuthTokenSet,
    now: u64,
) -> OAuthSession {
    OAuthSession {
        issuer: metadata.issuer.clone(),
        token_endpoint: metadata.token_endpoint.clone(),
        revocation_endpoint: metadata.revocation_endpoint.clone(),
        client_id: client_id.to_string(),
        refresh_token: tokens.refresh_token.clone(),
        access_expires_at: Some(now.saturating_add(tokens.expires_in)),
        refresh_expires_at: tokens.refresh_expires_at.clone(),
        scope: tokens.scope.clone(),
    }
}

pub async fn refresh(http: &Client, session: &OAuthSession) -> Result<OAuthTokenSet> {
    validate_stored_session(session)?;
    let response = http
        .post(&session.token_endpoint)
        .json(&json!({
            "grant_type": "refresh_token",
            "refresh_token": session.refresh_token,
            "client_id": session.client_id,
        }))
        .send()
        .await
        .context("OAuth refresh request failed")?;
    let status = response.status();
    let body: Value = response
        .json()
        .await
        .context("decode OAuth refresh response")?;
    if !status.is_success() {
        let code = body
            .get("error")
            .and_then(Value::as_str)
            .unwrap_or("oauth_refresh_failed");
        bail!("OAuth refresh rejected ({code}, HTTP {status}); run `sylphx login` again");
    }
    token_set_from_value(&body)
}

pub async fn revoke(http: &Client, session: &OAuthSession, token: &str, hint: &str) -> Result<()> {
    validate_stored_session(session)?;
    let response = http
        .post(&session.revocation_endpoint)
        .json(&json!({
            "token": token,
            "token_type_hint": hint,
            "client_id": session.client_id,
        }))
        .send()
        .await
        .context("OAuth revocation request failed")?;
    if !response.status().is_success() {
        bail!(
            "OAuth revocation rejected HTTP {}; stored credentials were retained",
            response.status()
        );
    }
    Ok(())
}

pub fn should_refresh(access_expires_at: Option<u64>, now: u64) -> bool {
    access_expires_at.is_some_and(|expires_at| expires_at <= now.saturating_add(REFRESH_SKEW_SECS))
}

pub fn now() -> Result<u64> {
    now_epoch_seconds()
}

fn validate_stored_session(session: &OAuthSession) -> Result<()> {
    let issuer = session.issuer.trim_end_matches('/');
    if issuer.is_empty() || session.client_id.trim().is_empty() || session.refresh_token.is_empty()
    {
        bail!("stored OAuth session is incomplete; run `sylphx login` again");
    }
    validate_bound_endpoint(issuer, &session.token_endpoint)
        .context("stored token endpoint is no longer bound to the issuer")?;
    validate_bound_endpoint(issuer, &session.revocation_endpoint)
        .context("stored revocation endpoint is no longer bound to the issuer")?;
    Ok(())
}

fn issuer_from_auth_base(auth_base: &str) -> Result<String> {
    let parsed = Url::parse(auth_base).context("parse OAuth auth base URL")?;
    validate_transport(&parsed)?;
    let host = parsed
        .host_str()
        .ok_or_else(|| anyhow!("OAuth auth base has no host"))?;
    let mut issuer = format!("{}://{}", parsed.scheme(), format_host(host));
    if let Some(port) = parsed.port() {
        issuer.push(':');
        issuer.push_str(&port.to_string());
    }
    Ok(issuer)
}

fn validate_bound_endpoint(issuer: &str, endpoint: &str) -> Result<()> {
    let issuer = Url::parse(issuer).context("parse OAuth issuer")?;
    let endpoint = Url::parse(endpoint).context("parse OAuth endpoint")?;
    validate_transport(&issuer)?;
    validate_transport(&endpoint)?;
    if issuer.scheme() != endpoint.scheme()
        || issuer.host_str() != endpoint.host_str()
        || issuer.port_or_known_default() != endpoint.port_or_known_default()
    {
        bail!("endpoint origin does not match OAuth issuer");
    }
    if endpoint.username() != "" || endpoint.password().is_some() || endpoint.fragment().is_some() {
        bail!("OAuth endpoint contains forbidden authority or fragment components");
    }
    Ok(())
}

fn validate_transport(url: &Url) -> Result<()> {
    if url.scheme() == "https" {
        return Ok(());
    }
    if url.scheme() == "http"
        && url
            .host_str()
            .is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1"))
    {
        return Ok(());
    }
    bail!("OAuth endpoints require HTTPS (HTTP is allowed only on loopback)")
}

fn format_host(host: &str) -> String {
    if host.contains(':') {
        format!("[{host}]")
    } else {
        host.to_string()
    }
}

fn string_field(body: &Value, keys: &[&str]) -> Option<String> {
    keys.iter()
        .find_map(|key| body.get(*key).and_then(Value::as_str))
        .map(str::to_string)
        .filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rejects_cross_origin_token_endpoint() {
        let error = validate_bound_endpoint(
            "https://tenant.api.sylphx.com",
            "https://evil.example/oauth/token",
        )
        .unwrap_err();
        assert!(error.to_string().contains("origin"));
    }

    #[test]
    fn accepts_https_and_loopback_only() {
        assert!(validate_bound_endpoint(
            "https://tenant.api.sylphx.com",
            "https://tenant.api.sylphx.com/v1/oauth/token"
        )
        .is_ok());
        assert!(validate_bound_endpoint(
            "http://127.0.0.1:8080",
            "http://127.0.0.1:8080/v1/oauth/token"
        )
        .is_ok());
        assert!(issuer_from_auth_base("http://tenant.example/v1").is_err());
    }

    #[test]
    fn requires_rotating_refresh_token_and_expiry() {
        let missing = json!({"access_token": "at", "expires_in": 900});
        assert!(token_set_from_value(&missing).is_err());
        let valid = json!({
            "access_token": "at",
            "refresh_token": "rt",
            "expires_in": 900
        });
        assert_eq!(token_set_from_value(&valid).unwrap().expires_in, 900);
    }

    #[test]
    fn refreshes_before_a_command_can_outlive_the_access_token() {
        assert!(!should_refresh(Some(1_001), 700));
        assert!(should_refresh(Some(1_000), 700));
        assert!(!should_refresh(None, 700));
    }
}