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>,
}
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>✓ 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> {
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 {
id
} else {
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 {
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> {
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
}