bctx-cloud-core 0.1.4

bctx-cloud-core — cloud client and server for Vault sync, dashboard API, billing
Documentation
use crate::server::{AppError, AppState};
use anyhow::anyhow;
use axum::{
    extract::{Query, State},
    response::IntoResponse,
    Json,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct DeviceStartReq {
    pub client_id: String,
}

#[derive(Serialize)]
pub struct DeviceStartResp {
    pub device_code: String,
    pub user_code: String,
    pub verification_uri: String,
    pub expires_in: u64,
    pub interval: u64,
}

pub async fn device_start(
    State(state): State<AppState>,
    Json(req): Json<DeviceStartReq>,
) -> Result<Json<DeviceStartResp>, AppError> {
    let _ = req.client_id;
    let device_code = uuid::Uuid::new_v4().to_string();
    let user_code = gen_user_code();
    let expires_at = {
        let now = chrono::Utc::now() + chrono::Duration::minutes(15);
        now.format("%Y-%m-%dT%H:%M:%SZ").to_string()
    };
    state
        .db
        .store_device_code(&device_code, &user_code, "", &expires_at)
        .map_err(|e| AppError(anyhow!(e)))?;
    Ok(Json(DeviceStartResp {
        device_code,
        user_code: user_code.clone(),
        verification_uri: format!("{}/auth/activate?code={user_code}", state.base_url),
        expires_in: 900,
        interval: 5,
    }))
}

#[derive(Deserialize)]
pub struct TokenPollReq {
    pub grant_type: String,
    pub device_code: String,
    pub client_id: String,
}

#[derive(Serialize)]
pub struct TokenPollResp {
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub expires_in: Option<u64>,
    pub user_id: String,
    pub tier: String,
}

#[derive(Serialize)]
pub struct TokenPollError {
    pub error: String,
}

pub async fn token_poll(
    State(state): State<AppState>,
    Json(req): Json<TokenPollReq>,
) -> Result<Json<TokenPollResp>, AppError> {
    let user_id = state
        .db
        .poll_device_code(&req.device_code)
        .ok_or_else(|| AppError(anyhow!("authorization_pending")))?;
    let user = state
        .db
        .get_user(&user_id)
        .ok_or_else(|| AppError(anyhow!("user not found")))?;
    let token = mint_jwt(&user_id, &state.jwt_secret);
    Ok(Json(TokenPollResp {
        access_token: token,
        refresh_token: None,
        expires_in: Some(86400 * 30),
        user_id,
        tier: user.tier,
    }))
}

#[derive(Deserialize)]
pub struct ActivateQuery {
    pub code: Option<String>,
}

/// GET /auth/activate?code=XXXX-YYYY — serves the browser authorization page
pub async fn activate_page(Query(q): Query<ActivateQuery>) -> impl IntoResponse {
    let code = q.code.unwrap_or_default();
    let html = format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Authorize bctx</title>
  <style>
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
            background: #0f1117; color: #e2e8f0; display: flex;
            align-items: center; justify-content: center; min-height: 100vh; }}
    .card {{ background: #1a1d27; border: 1px solid #2d3148; border-radius: 12px;
             padding: 40px; width: 100%; max-width: 420px; }}
    h1 {{ font-size: 22px; margin-bottom: 8px; }}
    .subtitle {{ color: #94a3b8; font-size: 14px; margin-bottom: 28px; }}
    .code {{ background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
             padding: 16px; text-align: center; font-size: 28px; font-weight: 700;
             letter-spacing: 4px; color: #818cf8; margin-bottom: 24px; font-family: monospace; }}
    label {{ display: block; font-size: 13px; color: #94a3b8; margin-bottom: 6px; }}
    input {{ width: 100%; padding: 10px 14px; border-radius: 8px; border: 1px solid #2d3148;
             background: #0f1117; color: #e2e8f0; font-size: 15px; margin-bottom: 20px; }}
    input:focus {{ outline: none; border-color: #818cf8; }}
    button {{ width: 100%; padding: 12px; background: #818cf8; color: #fff;
              border: none; border-radius: 8px; font-size: 15px; font-weight: 600;
              cursor: pointer; }}
    button:hover {{ background: #6366f1; }}
    .success {{ display: none; text-align: center; padding: 20px 0; }}
    .success h2 {{ color: #4ade80; margin-bottom: 8px; }}
    .success p {{ color: #94a3b8; font-size: 14px; }}
  </style>
</head>
<body>
  <div class="card">
    <h1>Authorize bctx</h1>
    <p class="subtitle">Enter your email to authorize the CLI session.</p>
    <div class="code" id="code-display">{code}</div>
    <div id="form-area">
      <label for="email">Email address</label>
      <input type="email" id="email" placeholder="you@example.com" autocomplete="email">
      <button onclick="authorize()">Authorize</button>
    </div>
    <div class="success" id="success">
      <h2>&#10003; Authorized</h2>
      <p>You can close this tab and return to the terminal.</p>
    </div>
  </div>
  <script>
    async function authorize() {{
      const email = document.getElementById('email').value.trim();
      if (!email) {{ alert('Please enter your email'); return; }}
      const resp = await fetch('/auth/activate', {{
        method: 'POST',
        headers: {{ 'Content-Type': 'application/json' }},
        body: JSON.stringify({{ user_code: '{code}', email }})
      }});
      if (resp.ok) {{
        document.getElementById('form-area').style.display = 'none';
        document.getElementById('success').style.display = 'block';
      }} else {{
        const err = await resp.text();
        alert('Error: ' + err);
      }}
    }}
  </script>
</body>
</html>"#
    );
    axum::response::Response::builder()
        .header("Content-Type", "text/html; charset=utf-8")
        .body(axum::body::Body::from(html))
        .unwrap()
}

#[derive(Deserialize)]
pub struct ActivateReq {
    pub user_code: String,
    pub email: String,
}

#[derive(Serialize)]
pub struct ActivateResp {
    pub ok: bool,
}

pub async fn activate(
    State(state): State<AppState>,
    Json(req): Json<ActivateReq>,
) -> Result<Json<ActivateResp>, AppError> {
    // Look up existing user by email — reuse their ID and tier
    let existing_id = {
        let conn = state.db.conn();
        conn.query_row(
            "SELECT id FROM users WHERE email=?1",
            rusqlite::params![req.email],
            |row| row.get::<_, String>(0),
        )
        .ok()
    };

    let user_id = if let Some(id) = existing_id {
        // Returning user — don't touch their tier
        id
    } else {
        // New user — create with free tier
        let new_id = uuid::Uuid::new_v4().to_string();
        state
            .db
            .upsert_user(&new_id, &req.email, "free")
            .map_err(|e| AppError(anyhow!(e)))?;
        new_id
    };

    let ok = state
        .db
        .authorize_device_code(&req.user_code, &user_id)
        .map_err(|e| AppError(anyhow!(e)))?;
    Ok(Json(ActivateResp { ok }))
}

fn gen_user_code() -> String {
    use std::fmt::Write;
    let id = uuid::Uuid::new_v4().simple().to_string();
    let mut code = String::new();
    let _ = write!(
        code,
        "{}-{}",
        &id[..4].to_uppercase(),
        &id[4..8].to_uppercase()
    );
    code
}

fn mint_jwt(user_id: &str, secret: &str) -> String {
    // Simple HS256-style token: base64(header).base64(payload).base64(sig)
    // In production use a proper JWT library; this is sufficient for the demo tier.
    use std::time::{SystemTime, UNIX_EPOCH};
    let exp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        + 86400 * 30;
    let header = base64_encode(b"{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
    let payload = base64_encode(
        serde_json::json!({"sub": user_id, "exp": exp})
            .to_string()
            .as_bytes(),
    );
    let sig_input = format!("{header}.{payload}");
    let sig = hmac_sha256(sig_input.as_bytes(), secret.as_bytes());
    format!("{header}.{payload}.{}", base64_encode(&sig))
}

pub fn verify_jwt(token: &str, secret: &str) -> Option<String> {
    let parts: Vec<&str> = token.splitn(3, '.').collect();
    if parts.len() != 3 {
        return None;
    }
    let sig_input = format!("{}.{}", parts[0], parts[1]);
    let expected = hmac_sha256(sig_input.as_bytes(), secret.as_bytes());
    let provided = base64_decode(parts[2])?;
    if expected != provided {
        return None;
    }
    let payload_bytes = base64_decode(parts[1])?;
    let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
    let exp = payload["exp"].as_u64()?;
    use std::time::{SystemTime, UNIX_EPOCH};
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs();
    if now > exp {
        return None;
    }
    payload["sub"].as_str().map(String::from)
}

fn base64_encode(data: &[u8]) -> String {
    use std::fmt::Write;
    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::new();
    for chunk in data.chunks(3) {
        let b0 = chunk[0] as usize;
        let b1 = if chunk.len() > 1 {
            chunk[1] as usize
        } else {
            0
        };
        let b2 = if chunk.len() > 2 {
            chunk[2] as usize
        } else {
            0
        };
        let _ = write!(
            out,
            "{}{}{}{}",
            CHARS[b0 >> 2] as char,
            CHARS[((b0 & 3) << 4) | (b1 >> 4)] as char,
            if chunk.len() > 1 {
                CHARS[((b1 & 15) << 2) | (b2 >> 6)] as char
            } else {
                '='
            },
            if chunk.len() > 2 {
                CHARS[b2 & 63] as char
            } else {
                '='
            },
        );
    }
    out
}

fn base64_decode(s: &str) -> Option<Vec<u8>> {
    let s = s.replace('=', "");
    let table: std::collections::HashMap<char, u8> =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
            .chars()
            .enumerate()
            .map(|(i, c)| (c, i as u8))
            .collect();
    let bytes: Vec<u8> = s.chars().filter_map(|c| table.get(&c).copied()).collect();
    let mut out = Vec::new();
    for chunk in bytes.chunks(4) {
        let b0 = chunk[0] as u32;
        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
        let b3 = if chunk.len() > 3 { chunk[3] as u32 } else { 0 };
        out.push(((b0 << 2) | (b1 >> 4)) as u8);
        if chunk.len() > 2 {
            out.push(((b1 << 4) | (b2 >> 2)) as u8);
        }
        if chunk.len() > 3 {
            out.push(((b2 << 6) | b3) as u8);
        }
    }
    Some(out)
}

fn hmac_sha256(data: &[u8], key: &[u8]) -> Vec<u8> {
    // Simple HMAC using blake3 as the PRF (production-grade MAC; not HMAC-SHA256 per spec but secure)
    let keyed = blake3::keyed_hash(&pad_key(key), data);
    keyed.as_bytes().to_vec()
}

fn pad_key(key: &[u8]) -> [u8; 32] {
    let mut out = [0u8; 32];
    let len = key.len().min(32);
    out[..len].copy_from_slice(&key[..len]);
    out
}