Files
log/src/commands/auth.rs
2025-07-16 09:32:45 +09:00

691 lines
23 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use anyhow::{Result, Context};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
pub admin: AdminConfig,
pub jetstream: JetstreamConfig,
pub collections: CollectionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminConfig {
pub did: String,
pub handle: String,
pub access_jwt: String,
pub refresh_jwt: String,
pub pds: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JetstreamConfig {
pub url: String,
pub collections: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionConfig {
pub base: String, // Base collection name like "ai.syui.log"
}
impl CollectionConfig {
// Collection name builders
pub fn comment(&self) -> String {
self.base.clone()
}
pub fn user(&self) -> String {
format!("{}.user", self.base)
}
#[allow(dead_code)]
pub fn chat(&self) -> String {
format!("{}.chat", self.base)
}
pub fn chat_lang(&self) -> String {
format!("{}.chat.lang", self.base)
}
pub fn chat_comment(&self) -> String {
format!("{}.chat.comment", self.base)
}
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
admin: AdminConfig {
did: String::new(),
handle: String::new(),
access_jwt: String::new(),
refresh_jwt: String::new(),
pds: "https://bsky.social".to_string(),
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
collections: vec!["ai.syui.log".to_string()],
},
collections: CollectionConfig {
base: "ai.syui.log".to_string(),
},
}
}
}
fn get_config_path() -> Result<PathBuf> {
let home = std::env::var("HOME").context("HOME environment variable not set")?;
let config_dir = PathBuf::from(home).join(".config").join("syui").join("ai").join("log");
// Create directory if it doesn't exist
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("config.json"))
}
#[allow(dead_code)]
pub async fn init() -> Result<()> {
init_with_pds(None).await
}
pub async fn init_with_options(
pds_override: Option<String>,
handle_override: Option<String>,
use_password: bool,
access_jwt_override: Option<String>,
refresh_jwt_override: Option<String>
) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
if config_path.exists() {
println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow());
return Ok(());
}
// Validate options
if let (Some(_), Some(_)) = (&access_jwt_override, &refresh_jwt_override) {
if use_password {
println!("{}", "⚠️ Cannot use both --password and JWT tokens. Choose one method.".yellow());
return Ok(());
}
} else if access_jwt_override.is_some() || refresh_jwt_override.is_some() {
println!("{}", "❌ Both --access-jwt and --refresh-jwt must be provided together.".red());
return Ok(());
}
println!("{}", "📋 Please provide your ATProto credentials:".cyan());
// Get handle
let handle = if let Some(h) = handle_override {
h
} else {
print!("Handle (e.g., your.handle.bsky.social): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
input.trim().to_string()
};
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Get credentials
let (access_jwt, refresh_jwt) = if let (Some(access), Some(refresh)) = (access_jwt_override, refresh_jwt_override) {
println!("{}", "🔑 Using provided JWT tokens".cyan());
(access, refresh)
} else if use_password {
println!("{}", "🔒 Using password authentication".cyan());
authenticate_with_password(&handle, &pds_url).await?
} else {
// Interactive JWT input (legacy behavior)
print!("Access JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut access_jwt = String::new();
std::io::stdin().read_line(&mut access_jwt)?;
let access_jwt = access_jwt.trim().to_string();
print!("Refresh JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut refresh_jwt = String::new();
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
(access_jwt, refresh_jwt)
};
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
admin: AdminConfig {
did: did.clone(),
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
collections: vec!["ai.syui.log".to_string()],
},
collections: generate_collection_config(),
};
// Save config
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, config_json)?;
println!("{}", "✅ Authentication configured successfully!".green());
println!("📁 Config saved to: {}", config_path.display());
println!("👤 Authenticated as: {} ({})", handle, did);
Ok(())
}
#[allow(dead_code)]
pub async fn init_with_pds(pds_override: Option<String>) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
if config_path.exists() {
println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow());
return Ok(());
}
println!("{}", "📋 Please provide your ATProto credentials:".cyan());
// Get user input
print!("Handle (e.g., your.handle.bsky.social): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut handle = String::new();
std::io::stdin().read_line(&mut handle)?;
let handle = handle.trim().to_string();
print!("Access JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut access_jwt = String::new();
std::io::stdin().read_line(&mut access_jwt)?;
let access_jwt = access_jwt.trim().to_string();
print!("Refresh JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut refresh_jwt = String::new();
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
// Use provided PDS override
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
// Auto-detect from handle suffix
if handle.ends_with(".syu.is") || handle.ends_with(".syui.ai") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
admin: AdminConfig {
did: did.clone(),
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
collections: vec!["ai.syui.log".to_string()],
},
collections: generate_collection_config(),
};
// Save config
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, config_json)?;
println!("{}", "✅ Authentication configured successfully!".green());
println!("📁 Config saved to: {}", config_path.display());
println!("👤 Authenticated as: {} ({})", handle, did);
Ok(())
}
#[allow(dead_code)]
async fn resolve_did(handle: &str) -> Result<String> {
let client = reqwest::Client::new();
// Use appropriate API based on handle domain
let api_base = if handle.ends_with(".syu.is") {
"https://bsky.syu.is"
} else {
"https://public.api.bsky.app"
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to resolve handle: {}", response.status()));
}
let profile: serde_json::Value = response.json().await?;
let did = profile["did"].as_str()
.ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?;
Ok(did.to_string())
}
async fn resolve_did_with_pds(handle: &str, pds_url: &str) -> Result<String> {
let client = reqwest::Client::new();
// Try to use the PDS API first
let api_base = if pds_url.contains("syu.is") {
"https://bsky.syu.is"
} else if pds_url.contains("bsky.social") {
"https://public.api.bsky.app"
} else {
// For custom PDS, try to construct API URL
pds_url
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to resolve handle using PDS {}: {}", pds_url, response.status()));
}
let profile: serde_json::Value = response.json().await?;
let did = profile["did"].as_str()
.ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?;
Ok(did.to_string())
}
async fn authenticate_with_password(handle: &str, pds_url: &str) -> Result<(String, String)> {
use std::io::{self, Write};
// Get password securely
print!("Password: ");
io::stdout().flush()?;
let password = rpassword::read_password()
.context("Failed to read password")?;
if password.is_empty() {
return Err(anyhow::anyhow!("Password cannot be empty"));
}
println!("{}", "🔐 Authenticating with ATProto server...".cyan());
let client = reqwest::Client::new();
let auth_url = format!("{}/xrpc/com.atproto.server.createSession", pds_url);
let auth_request = serde_json::json!({
"identifier": handle,
"password": password
});
let response = client
.post(&auth_url)
.header("Content-Type", "application/json")
.json(&auth_request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
if status.as_u16() == 401 {
return Err(anyhow::anyhow!("Authentication failed: Invalid handle or password"));
} else if status.as_u16() == 400 {
return Err(anyhow::anyhow!("Authentication failed: Bad request (check handle format)"));
} else {
return Err(anyhow::anyhow!("Authentication failed: {} - {}", status, error_text));
}
}
let auth_response: serde_json::Value = response.json().await?;
let access_jwt = auth_response["accessJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No access JWT in response"))?
.to_string();
let refresh_jwt = auth_response["refreshJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No refresh JWT in response"))?
.to_string();
println!("{}", "✅ Password authentication successful".green());
Ok((access_jwt, refresh_jwt))
}
pub async fn status() -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
println!("{}", "❌ Not authenticated. Run 'ailog auth init' first.".red());
return Ok(());
}
let config_json = fs::read_to_string(&config_path)?;
let config: AuthConfig = serde_json::from_str(&config_json)?;
println!("{}", "🔐 Authentication Status".cyan().bold());
println!("─────────────────────────");
println!("📁 Config: {}", config_path.display());
println!("👤 Handle: {}", config.admin.handle.green());
println!("🆔 DID: {}", config.admin.did);
println!("🌐 PDS: {}", config.admin.pds);
println!("📡 Jetstream: {}", config.jetstream.url);
println!("📂 Collections: {}", config.jetstream.collections.join(", "));
// Test API access
println!("\n{}", "🧪 Testing API access...".cyan());
match test_api_access_with_auth(&config).await {
Ok(_) => println!("{}", "✅ API access successful".green()),
Err(e) => {
println!("{}", format!("❌ Authenticated API access failed: {}", e).red());
// Fallback to public API test
println!("{}", "🔄 Trying public API access...".cyan());
match test_api_access(&config).await {
Ok(_) => println!("{}", "✅ Public API access successful".green()),
Err(e2) => println!("{}", format!("❌ Public API access also failed: {}", e2).red()),
}
}
}
Ok(())
}
async fn test_api_access(config: &AuthConfig) -> Result<()> {
let client = reqwest::Client::new();
// Use appropriate API based on handle domain
let api_base = if config.admin.handle.ends_with(".syu.is") {
"https://bsky.syu.is"
} else {
"https://public.api.bsky.app"
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(&config.admin.handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("API request failed: {}", response.status()));
}
Ok(())
}
pub async fn logout() -> Result<()> {
let config_path = get_config_path()?;
if !config_path.exists() {
println!("{}", " Already logged out.".blue());
return Ok(());
}
println!("{}", "🔓 Logging out...".cyan());
// Remove config file
fs::remove_file(&config_path)?;
println!("{}", "✅ Logged out successfully!".green());
println!("🗑️ Configuration removed from: {}", config_path.display());
Ok(())
}
// Load config helper function for other modules
pub fn load_config() -> Result<AuthConfig> {
let config_path = get_config_path()?;
if !config_path.exists() {
return Err(anyhow::anyhow!("Not authenticated. Run 'ailog auth init' first."));
}
let config_json = fs::read_to_string(&config_path)?;
// Try to load as new format first, then migrate if needed
match serde_json::from_str::<AuthConfig>(&config_json) {
Ok(mut config) => {
// Update collection configuration
update_config_collections(&mut config);
Ok(config)
}
Err(e) => {
println!("{}", format!("Parse error: {}, attempting migration...", e).yellow());
// Try to migrate from old format
migrate_config_if_needed(&config_path, &config_json)
}
}
}
fn migrate_config_if_needed(config_path: &std::path::Path, config_json: &str) -> Result<AuthConfig> {
// Try to parse as old format and migrate to new simple format
let mut old_config: serde_json::Value = serde_json::from_str(config_json)?;
// Migrate old collections structure to new base-only structure
if let Some(collections) = old_config.get_mut("collections") {
// Extract base collection name from comment field or use default
let base_collection = collections.get("comment")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log")
.to_string();
// Replace entire collections structure with new format
old_config["collections"] = serde_json::json!({
"base": base_collection
});
}
// Save migrated config
let migrated_config_json = serde_json::to_string_pretty(&old_config)?;
fs::write(config_path, migrated_config_json)?;
// Parse as new format
let mut config: AuthConfig = serde_json::from_value(old_config)?;
update_config_collections(&mut config);
println!("{}", "✅ Configuration migrated to new simplified format".green());
Ok(config)
}
// Load config with automatic token refresh
pub async fn load_config_with_refresh() -> Result<AuthConfig> {
let mut config = load_config()?;
let old_access_jwt = config.admin.access_jwt.clone();
// Always try to refresh token to avoid any expiration issues
println!("{}", "🔄 Refreshing access token...".yellow());
println!("📍 Current access JWT: {}...", &old_access_jwt[..30.min(old_access_jwt.len())]);
// Try to refresh the token
match refresh_access_token(&mut config).await {
Ok(_) => {
if config.admin.access_jwt != old_access_jwt {
println!("{}", "✅ Token refreshed with new JWT".green());
println!("📍 New access JWT: {}...", &config.admin.access_jwt[..30.min(config.admin.access_jwt.len())]);
save_config(&config)?;
println!("{}", "💾 Config saved to disk".green());
} else {
println!("{}", " Token refresh returned same JWT (still valid)".cyan());
}
}
Err(e) => {
// If refresh fails, test if current token is still valid
if let Ok(_) = test_api_access_with_auth(&config).await {
println!("{}", " Refresh failed but current token is still valid".cyan());
} else {
return Err(anyhow::anyhow!("Token expired and refresh failed: {}. Please run 'ailog auth init' again.", e));
}
}
}
// Update collection configuration
update_config_collections(&mut config);
Ok(config)
}
async fn test_api_access_with_auth(config: &AuthConfig) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(&config.collections.comment()));
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", config.admin.access_jwt))
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("API request failed: {}", response.status()));
}
Ok(())
}
async fn refresh_access_token(config: &mut AuthConfig) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.admin.pds);
println!("🔑 Refreshing token at: {}", url);
println!("🔑 Using refresh JWT: {}...", &config.admin.refresh_jwt[..20.min(config.admin.refresh_jwt.len())]);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.admin.refresh_jwt))
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await?;
return Err(anyhow::anyhow!("Token refresh failed: {} - {}", status, error_text));
}
let refresh_response: serde_json::Value = response.json().await?;
// Update tokens
if let Some(access_jwt) = refresh_response["accessJwt"].as_str() {
config.admin.access_jwt = access_jwt.to_string();
println!("✅ New access JWT: {}...", &access_jwt[..20.min(access_jwt.len())]);
} else {
println!("⚠️ No accessJwt in refresh response");
}
if let Some(refresh_jwt) = refresh_response["refreshJwt"].as_str() {
config.admin.refresh_jwt = refresh_jwt.to_string();
println!("✅ New refresh JWT: {}...", &refresh_jwt[..20.min(refresh_jwt.len())]);
} else {
println!("⚠️ No refreshJwt in refresh response");
}
Ok(())
}
fn save_config(config: &AuthConfig) -> Result<()> {
let config_path = get_config_path()?;
println!("💾 Saving config to: {}", config_path.display());
// Read old config to compare
let old_config = if config_path.exists() {
fs::read_to_string(&config_path).ok()
} else {
None
};
let config_json = serde_json::to_string_pretty(config)?;
fs::write(&config_path, &config_json)?;
// Verify the write was successful
let saved_content = fs::read_to_string(&config_path)?;
if saved_content == config_json {
println!("✅ Config successfully saved to {}", config_path.display());
// Compare tokens if old config exists
if let Some(old) = old_config {
if let (Ok(old_json), Ok(new_json)) = (
serde_json::from_str::<AuthConfig>(&old),
serde_json::from_str::<AuthConfig>(&config_json)
) {
if old_json.admin.access_jwt != new_json.admin.access_jwt {
println!("📝 Access JWT was updated in file");
println!(" Old: {}...", &old_json.admin.access_jwt[..30.min(old_json.admin.access_jwt.len())]);
println!(" New: {}...", &new_json.admin.access_jwt[..30.min(new_json.admin.access_jwt.len())]);
}
if old_json.admin.refresh_jwt != new_json.admin.refresh_jwt {
println!("📝 Refresh JWT was updated in file");
}
}
}
} else {
println!("❌ Config save verification failed!");
}
Ok(())
}
// Generate collection config from environment
fn generate_collection_config() -> CollectionConfig {
// Use VITE_OAUTH_COLLECTION for unified configuration
let base = std::env::var("VITE_OAUTH_COLLECTION")
.unwrap_or_else(|_| "ai.syui.log".to_string());
CollectionConfig {
base,
}
}
// Update existing config with collection settings
pub fn update_config_collections(config: &mut AuthConfig) {
config.collections = generate_collection_config();
// Also update jetstream collections to monitor the comment collection
config.jetstream.collections = vec![config.collections.comment()];
}