fix test cli oauth

This commit is contained in:
2026-03-01 21:27:37 +09:00
parent 6d3e233ee2
commit 32168f14d0
16 changed files with 1201 additions and 49 deletions

View File

@@ -24,3 +24,5 @@ rand = "0.8"
dotenvy = "0.15"
rustyline = "15"
thiserror = "2"
ring = "0.17"
base64 = "0.22"

View File

@@ -1,4 +1,5 @@
/.well-known/* /.well-known/:splat 200
/app/* /index.html 200
/oauth/cli /oauth/cli/index.html 200
/oauth/* /index.html 200
/* /index.html 200

View File

@@ -2,7 +2,7 @@
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "syui.ai",
"client_uri": "https://syui.ai",
"redirect_uris": ["https://syui.ai/oauth/callback"],
"redirect_uris": ["https://syui.ai/oauth/callback", "https://syui.ai/oauth/cli"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],

View File

@@ -0,0 +1,16 @@
[
"3mfyfqevqko22",
"3mfyfqevwlk22",
"3mfyfqew44k22",
"3mfyfqewboi22",
"3mfyfqewhde22",
"3mfyfqewmtz22",
"3mfyfqewsg222",
"3mfyfqewxxe22",
"3mfyfqex5gy22",
"3mfyfqexcw622",
"3mfyfqexigm22",
"3mfyfqexnp322",
"3mfyfqextcx22",
"3mfyfqexyry22"
]

View File

@@ -96,9 +96,12 @@
"3mfswma2goi22",
"3mfsxkrpjtl24",
"3mfsycszelm26",
"3mfsyvpe6iv2a",
"3mfsyvyfppj2c",
"3mftbfmt7e432",
"3mfsz364fq52e",
"3mftbk3gc4y34",
"3mfsyvpe6iv2a",
"3mftblsrpt736",
"3mfszgkcwhl2g",
"3mft7jrokku2i",
"3mft7jwg5rx2k",
@@ -109,9 +112,6 @@
"3mftahzh3dl2u",
"3mftaqxedjp2w",
"3mftaw5vsi72y",
"3mftbfmt7e432",
"3mftbk3gc4y34",
"3mftblsrpt736",
"3mftbpllmzm3a",
"3mftcm2tmnk22",
"3mftcnhl4zk24",

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use super::oauth;
use super::token::{self, Session};
use crate::lexicons::com_atproto_server;
use crate::types::{CreateSessionRequest, CreateSessionResponse};
@@ -38,7 +39,7 @@ pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Res
Ok(())
}
/// Refresh a session (shared logic for user and bot)
/// Refresh a session (shared logic for user and bot, legacy app-password)
async fn do_refresh(session: &Session, pds: &str) -> Result<Session> {
let client = XrpcClient::new(pds);
@@ -55,8 +56,13 @@ async fn do_refresh(session: &Session, pds: &str) -> Result<Session> {
})
}
/// Refresh access token
/// Refresh access token (OAuth-aware: tries OAuth first, falls back to legacy)
pub async fn refresh_session() -> Result<Session> {
if oauth::has_oauth_session(false) {
let (_oauth, session) = oauth::refresh_oauth_session(false).await?;
return Ok(session);
}
let session = token::load_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
@@ -66,8 +72,13 @@ pub async fn refresh_session() -> Result<Session> {
Ok(new_session)
}
/// Refresh bot access token
/// Refresh bot access token (OAuth-aware)
pub async fn refresh_bot_session() -> Result<Session> {
if oauth::has_oauth_session(true) {
let (_oauth, session) = oauth::refresh_oauth_session(true).await?;
return Ok(session);
}
let session = token::load_bot_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");

View File

@@ -368,7 +368,7 @@ async fn poll_once(
// Refresh bot session
let session = auth::refresh_bot_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = XrpcClient::new(pds);
let client = XrpcClient::new_bot(pds);
// Fetch notifications
let notifications = fetch_notifications(&client, &session.access_jwt).await?;

View File

@@ -14,7 +14,7 @@ const COLLECTION_MEMORY: &str = "ai.syui.gpt.memory";
pub async fn get_core(download: bool) -> Result<()> {
let session = auth::refresh_bot_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = XrpcClient::new(pds);
let client = XrpcClient::new_bot(pds);
let record: Record = client
.query_auth(
@@ -41,7 +41,7 @@ pub async fn get_core(download: bool) -> Result<()> {
pub async fn get_memory(download: bool) -> Result<()> {
let session = auth::refresh_bot_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = XrpcClient::new(pds);
let client = XrpcClient::new_bot(pds);
let result: ListRecordsResponse = client
.query_auth(
@@ -79,7 +79,7 @@ pub async fn get_memory(download: bool) -> Result<()> {
pub async fn list_memory() -> Result<()> {
let session = auth::refresh_bot_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = XrpcClient::new(pds);
let client = XrpcClient::new_bot(pds);
let mut cursor: Option<String> = None;
let mut all_records: Vec<Record> = Vec::new();
@@ -142,7 +142,7 @@ pub async fn push(collection_name: &str) -> Result<()> {
let session = auth::refresh_bot_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let did = &session.did;
let client = XrpcClient::new(pds);
let client = XrpcClient::new_bot(pds);
let collection_dir = format!("public/content/{}/{}", did, collection);
if !std::path::Path::new(&collection_dir).exists() {

View File

@@ -12,3 +12,4 @@ pub mod index;
pub mod bot;
pub mod pds;
pub mod gpt;
pub mod oauth;

813
src/commands/oauth.rs Normal file
View File

@@ -0,0 +1,813 @@
use anyhow::{Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use ring::rand::SecureRandom;
use ring::signature::{EcdsaKeyPair, ECDSA_P256_SHA256_FIXED_SIGNING, KeyPair};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{self, Write};
use super::token::{self, Session, BUNDLE_ID};
#[derive(Debug, Deserialize)]
struct SiteConfig {
#[serde(rename = "siteUrl")]
site_url: Option<String>,
}
fn load_site_url() -> Result<String> {
// Try public/config.json in current directory
let config_path = std::path::Path::new("public/config.json");
if config_path.exists() {
let content = std::fs::read_to_string(config_path)?;
let config: SiteConfig = serde_json::from_str(&content)?;
if let Some(url) = config.site_url {
return Ok(url.trim_end_matches('/').to_string());
}
}
anyhow::bail!(
"No siteUrl found in public/config.json. \
Create config.json with {{\"siteUrl\": \"https://example.com\"}}"
);
}
fn percent_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(b as char);
}
_ => {
result.push_str(&format!("%{:02X}", b));
}
}
}
result
}
// --- Data types ---
#[derive(Debug, Deserialize)]
struct AuthServerMetadata {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
pushed_authorization_request_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct ParResponse {
request_uri: String,
#[allow(dead_code)]
expires_in: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
token_type: String,
#[allow(dead_code)]
expires_in: Option<u64>,
sub: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TokenErrorResponse {
error: String,
#[allow(dead_code)]
error_description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DidDocument {
id: String,
#[serde(default)]
service: Vec<DidService>,
}
#[derive(Debug, Deserialize)]
struct DidService {
id: String,
#[serde(rename = "type")]
service_type: String,
#[serde(rename = "serviceEndpoint")]
service_endpoint: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct DpopJwk {
kty: String,
crv: String,
x: String,
y: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthSession {
pub did: String,
pub handle: String,
pub access_token: String,
pub refresh_token: Option<String>,
pub pds: String,
pub token_endpoint: String,
pub issuer: String,
dpop_pkcs8: String,
dpop_jwk: DpopJwk,
}
// --- Handle / DID / PDS resolution ---
async fn resolve_handle_to_did(handle: &str) -> Result<String> {
let client = reqwest::Client::new();
// Try DNS TXT _atproto.{handle} first is complex; use HTTPS resolution
let url = format!(
"https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle={}",
handle
);
let res = client.get(&url).send().await?;
if !res.status().is_success() {
anyhow::bail!("Failed to resolve handle '{}': {}", handle, res.status());
}
#[derive(Deserialize)]
struct R {
did: String,
}
let r: R = res.json().await?;
Ok(r.did)
}
async fn resolve_did_to_pds(did: &str) -> Result<(String, String)> {
let client = reqwest::Client::new();
let doc: DidDocument = if did.starts_with("did:plc:") {
let url = format!("https://plc.directory/{}", did);
let res = client.get(&url).send().await?;
if !res.status().is_success() {
anyhow::bail!("Failed to resolve DID '{}': {}", did, res.status());
}
res.json().await?
} else if did.starts_with("did:web:") {
let domain = did.strip_prefix("did:web:").unwrap();
let url = format!("https://{}/.well-known/did.json", domain);
let res = client.get(&url).send().await?;
res.json().await?
} else {
anyhow::bail!("Unsupported DID method: {}", did);
};
// Find AtprotoPersonalDataServer service
let pds_endpoint = doc
.service
.iter()
.find(|s| s.id == "#atproto_pds" || s.service_type == "AtprotoPersonalDataServer")
.map(|s| s.service_endpoint.clone())
.context("No PDS service found in DID document")?;
Ok((doc.id, pds_endpoint))
}
// --- OAuth metadata ---
async fn fetch_auth_server_metadata(pds_url: &str) -> Result<AuthServerMetadata> {
let client = reqwest::Client::new();
let base = pds_url.trim_end_matches('/');
// Try PDS's own .well-known/oauth-authorization-server first
let url = format!("{}/.well-known/oauth-authorization-server", base);
let res = client.get(&url).send().await?;
if res.status().is_success() {
if let Ok(meta) = res.json::<AuthServerMetadata>().await {
return Ok(meta);
}
}
// Fallback: check oauth-protected-resource for authorization_servers
let pr_url = format!("{}/.well-known/oauth-protected-resource", base);
let pr_res = client.get(&pr_url).send().await?;
if pr_res.status().is_success() {
#[derive(Deserialize)]
struct ProtectedResource {
authorization_servers: Vec<String>,
}
if let Ok(pr) = pr_res.json::<ProtectedResource>().await {
for auth_server in &pr.authorization_servers {
let as_url = format!(
"{}/.well-known/oauth-authorization-server",
auth_server.trim_end_matches('/')
);
let as_res = client.get(&as_url).send().await?;
if as_res.status().is_success() {
return Ok(as_res.json().await?);
}
}
}
}
anyhow::bail!(
"Failed to fetch OAuth authorization server metadata for {}",
base
);
}
// --- PKCE ---
fn generate_pkce() -> Result<(String, String)> {
let rng = ring::rand::SystemRandom::new();
let mut verifier_bytes = [0u8; 32];
rng.fill(&mut verifier_bytes)
.map_err(|_| anyhow::anyhow!("Failed to generate random bytes"))?;
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let challenge_hash = ring::digest::digest(&ring::digest::SHA256, code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(challenge_hash.as_ref());
Ok((code_verifier, code_challenge))
}
// --- DPoP key pair ---
struct DpopKey {
pkcs8_bytes: Vec<u8>,
jwk: DpopJwk,
}
fn generate_dpop_keypair() -> Result<DpopKey> {
let rng = ring::rand::SystemRandom::new();
let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
.map_err(|e| anyhow::anyhow!("Failed to generate ECDSA key: {}", e))?;
let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng)
.map_err(|e| anyhow::anyhow!("Failed to parse generated key: {}", e))?;
// Extract public key (uncompressed: 0x04 || x(32) || y(32))
let pub_key = key_pair.public_key().as_ref();
assert!(pub_key.len() == 65 && pub_key[0] == 0x04);
let x = URL_SAFE_NO_PAD.encode(&pub_key[1..33]);
let y = URL_SAFE_NO_PAD.encode(&pub_key[33..65]);
Ok(DpopKey {
pkcs8_bytes: pkcs8.as_ref().to_vec(),
jwk: DpopJwk {
kty: "EC".to_string(),
crv: "P-256".to_string(),
x,
y,
},
})
}
// --- DPoP proof JWT ---
fn create_dpop_proof(
pkcs8_bytes: &[u8],
jwk: &DpopJwk,
method: &str,
url: &str,
nonce: Option<&str>,
ath: Option<&str>,
) -> Result<String> {
let rng = ring::rand::SystemRandom::new();
let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8_bytes, &rng)
.map_err(|e| anyhow::anyhow!("Failed to load DPoP key: {}", e))?;
// Header
let header = serde_json::json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": &jwk.kty,
"crv": &jwk.crv,
"x": &jwk.x,
"y": &jwk.y,
}
});
// Generate jti
let mut jti_bytes = [0u8; 16];
rng.fill(&mut jti_bytes)
.map_err(|_| anyhow::anyhow!("Failed to generate jti"))?;
let jti = URL_SAFE_NO_PAD.encode(jti_bytes);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let mut payload = serde_json::json!({
"jti": jti,
"htm": method,
"htu": url,
"iat": now,
"exp": now + 120,
});
if let Some(n) = nonce {
payload["nonce"] = serde_json::Value::String(n.to_string());
}
if let Some(a) = ath {
payload["ath"] = serde_json::Value::String(a.to_string());
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature = key_pair
.sign(&rng, signing_input.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to sign DPoP proof: {}", e))?;
// ECDSA_P256_SHA256_FIXED_SIGNING produces r||s (64 bytes), which is exactly what JWS ES256 needs
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.as_ref());
Ok(format!("{}.{}", signing_input, sig_b64))
}
// --- PAR ---
async fn pushed_authorization_request(
par_endpoint: &str,
client_id: &str,
redirect_uri: &str,
code_challenge: &str,
scope: &str,
login_hint: &str,
dpop_key: &DpopKey,
) -> Result<ParResponse> {
let client = reqwest::Client::new();
let mut dpop_nonce: Option<String> = None;
// Try up to 2 times (initial + nonce retry)
for attempt in 0..2 {
let dpop_proof = create_dpop_proof(
&dpop_key.pkcs8_bytes,
&dpop_key.jwk,
"POST",
par_endpoint,
dpop_nonce.as_deref(),
None,
)?;
let params = [
("client_id", client_id),
("redirect_uri", redirect_uri),
("code_challenge", code_challenge),
("code_challenge_method", "S256"),
("response_type", "code"),
("scope", scope),
("login_hint", login_hint),
("state", "cli"),
];
let res = client
.post(par_endpoint)
.header("DPoP", &dpop_proof)
.form(&params)
.send()
.await?;
if res.status().is_success() {
return Ok(res.json().await?);
}
// Check for use_dpop_nonce error
let nonce_header = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body = res.text().await?;
if attempt == 0 {
if let Some(nonce) = nonce_header {
// Check if it's a dpop nonce error
if body.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
}
}
anyhow::bail!("PAR request failed: {}", body);
}
anyhow::bail!("PAR request failed after nonce retry");
}
// --- Token exchange ---
async fn exchange_code(
token_endpoint: &str,
client_id: &str,
redirect_uri: &str,
code: &str,
code_verifier: &str,
dpop_key: &DpopKey,
initial_nonce: Option<&str>,
) -> Result<(TokenResponse, Option<String>)> {
let client = reqwest::Client::new();
let mut dpop_nonce = initial_nonce.map(|s| s.to_string());
for attempt in 0..2 {
let dpop_proof = create_dpop_proof(
&dpop_key.pkcs8_bytes,
&dpop_key.jwk,
"POST",
token_endpoint,
dpop_nonce.as_deref(),
None,
)?;
let mut params = HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("client_id", client_id);
params.insert("redirect_uri", redirect_uri);
params.insert("code", code);
params.insert("code_verifier", code_verifier);
let res = client
.post(token_endpoint)
.header("DPoP", &dpop_proof)
.form(&params)
.send()
.await?;
let new_nonce = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if res.status().is_success() {
let token_res: TokenResponse = res.json().await?;
return Ok((token_res, new_nonce.or(dpop_nonce)));
}
let body = res.text().await?;
if attempt == 0 {
if let Some(nonce) = new_nonce {
if body.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
}
}
// Try to parse error for better message
if let Ok(err) = serde_json::from_str::<TokenErrorResponse>(&body) {
anyhow::bail!("Token exchange failed: {}", err.error);
}
anyhow::bail!("Token exchange failed: {}", body);
}
anyhow::bail!("Token exchange failed after nonce retry");
}
// --- Token refresh ---
pub async fn refresh_oauth_session(is_bot: bool) -> Result<(OAuthSession, Session)> {
let oauth = load_oauth_session(is_bot)?;
let pkcs8_bytes = URL_SAFE_NO_PAD
.decode(&oauth.dpop_pkcs8)
.context("Failed to decode DPoP key")?;
let client = reqwest::Client::new();
let mut dpop_nonce: Option<String> = None;
for attempt in 0..2 {
let dpop_proof = create_dpop_proof(
&pkcs8_bytes,
&oauth.dpop_jwk,
"POST",
&oauth.token_endpoint,
dpop_nonce.as_deref(),
None,
)?;
let params = [
("grant_type", "refresh_token"),
("refresh_token", oauth.refresh_token.as_deref().unwrap_or("")),
("client_id", &oauth.issuer),
];
let site_url = load_site_url()?;
let client_id_url = format!("{}/client-metadata.json", site_url);
let form_params = [
("grant_type", "refresh_token"),
(
"refresh_token",
oauth.refresh_token.as_deref().unwrap_or(""),
),
("client_id", &client_id_url),
];
let res = client
.post(&oauth.token_endpoint)
.header("DPoP", &dpop_proof)
.form(&form_params)
.send()
.await?;
let new_nonce = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
if res.status().is_success() {
let token_res: TokenResponse = res.json().await?;
let sub = token_res.sub.as_deref().unwrap_or(&oauth.did);
let new_oauth = OAuthSession {
did: sub.to_string(),
handle: oauth.handle.clone(),
access_token: token_res.access_token.clone(),
refresh_token: token_res.refresh_token.or(oauth.refresh_token),
pds: oauth.pds.clone(),
token_endpoint: oauth.token_endpoint.clone(),
issuer: oauth.issuer.clone(),
dpop_pkcs8: oauth.dpop_pkcs8.clone(),
dpop_jwk: DpopJwk {
kty: oauth.dpop_jwk.kty.clone(),
crv: oauth.dpop_jwk.crv.clone(),
x: oauth.dpop_jwk.x.clone(),
y: oauth.dpop_jwk.y.clone(),
},
};
save_oauth_session(&new_oauth, is_bot)?;
let pds_host = oauth
.pds
.strip_prefix("https://")
.unwrap_or(&oauth.pds)
.trim_end_matches('/');
let compat = Session {
did: sub.to_string(),
handle: oauth.handle.clone(),
access_jwt: token_res.access_token,
refresh_jwt: new_oauth.refresh_token.clone().unwrap_or_default(),
pds: Some(pds_host.to_string()),
};
if is_bot {
token::save_bot_session(&compat)?;
} else {
token::save_session(&compat)?;
}
return Ok((new_oauth, compat));
}
let body = res.text().await?;
if attempt == 0 {
if let Some(nonce) = new_nonce {
if body.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
let _ = params;
continue;
}
}
}
anyhow::bail!("OAuth token refresh failed: {}", body);
}
anyhow::bail!("OAuth token refresh failed after nonce retry");
}
/// Create a DPoP proof for an API request with optional nonce
pub fn create_dpop_proof_for_request_with_nonce(
oauth: &OAuthSession,
method: &str,
url: &str,
nonce: Option<&str>,
) -> Result<String> {
let pkcs8_bytes = URL_SAFE_NO_PAD
.decode(&oauth.dpop_pkcs8)
.context("Failed to decode DPoP key")?;
// Compute ath (access token hash)
let ath_hash = ring::digest::digest(&ring::digest::SHA256, oauth.access_token.as_bytes());
let ath = URL_SAFE_NO_PAD.encode(ath_hash.as_ref());
create_dpop_proof(&pkcs8_bytes, &oauth.dpop_jwk, method, url, nonce, Some(&ath))
}
// --- Load OAuth session ---
pub fn load_oauth_session(is_bot: bool) -> Result<OAuthSession> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join(BUNDLE_ID);
let filename = if is_bot {
"oauth_bot_session.json"
} else {
"oauth_session.json"
};
let path = config_dir.join(filename);
let content = std::fs::read_to_string(&path)
.with_context(|| format!("OAuth session not found: {:?}", path))?;
let session: OAuthSession = serde_json::from_str(&content)?;
Ok(session)
}
/// Check if OAuth session exists
pub fn has_oauth_session(is_bot: bool) -> bool {
let config_dir = match dirs::config_dir() {
Some(d) => d.join(BUNDLE_ID),
None => return false,
};
let filename = if is_bot {
"oauth_bot_session.json"
} else {
"oauth_session.json"
};
config_dir.join(filename).exists()
}
// --- Save ---
fn save_oauth_session(session: &OAuthSession, is_bot: bool) -> Result<()> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join(BUNDLE_ID);
std::fs::create_dir_all(&config_dir)?;
let filename = if is_bot {
"oauth_bot_session.json"
} else {
"oauth_session.json"
};
let path = config_dir.join(filename);
let content = serde_json::to_string_pretty(session)?;
std::fs::write(&path, content)?;
println!("OAuth session saved to {:?}", path);
Ok(())
}
// --- Main entry ---
pub async fn oauth_login(handle: &str, is_bot: bool) -> Result<()> {
let account_type = if is_bot { "bot" } else { "user" };
println!("Starting OAuth login for {} ({})...", handle, account_type);
// 1. Resolve handle → DID → PDS
println!("Resolving handle...");
let did = resolve_handle_to_did(handle).await?;
println!("DID: {}", did);
let (_, pds_url) = resolve_did_to_pds(&did).await?;
println!("PDS: {}", pds_url);
// 2. Fetch OAuth metadata
println!("Fetching OAuth metadata...");
let meta = fetch_auth_server_metadata(&pds_url).await?;
// 3. Generate PKCE
let (code_verifier, code_challenge) = generate_pkce()?;
// 4. Generate DPoP key pair
let dpop_key = generate_dpop_keypair()?;
// 5. Client metadata (derived from config.json siteUrl)
let site_url = load_site_url()?;
let client_id = format!("{}/client-metadata.json", site_url);
let scope = "atproto transition:generic";
// Try /oauth/cli first, fallback to /oauth/callback
let redirect_candidates = [
format!("{}/oauth/cli", site_url),
format!("{}/oauth/callback", site_url),
];
let mut redirect_uri = String::new();
let mut par_res: Option<ParResponse> = None;
// 6. PAR (try each redirect_uri)
println!("Sending authorization request...");
for candidate in &redirect_candidates {
match pushed_authorization_request(
&meta.pushed_authorization_request_endpoint,
&client_id,
candidate,
&code_challenge,
scope,
&did,
&dpop_key,
)
.await
{
Ok(res) => {
redirect_uri = candidate.clone();
par_res = Some(res);
break;
}
Err(e) => {
let msg = e.to_string();
if msg.contains("Invalid redirect_uri") && candidate != redirect_candidates.last().unwrap() {
println!(" {} not accepted, trying fallback...", candidate);
// Regenerate DPoP key for retry (nonce may have changed)
continue;
}
return Err(e);
}
}
}
let par_res = par_res.context("All redirect_uri candidates rejected by PDS")?;
// 7. Build authorize URL
let auth_url = format!(
"{}?client_id={}&request_uri={}",
meta.authorization_endpoint,
percent_encode(&client_id),
percent_encode(&par_res.request_uri),
);
println!("\nOpen this URL in your browser to authorize:\n");
println!(" {}\n", auth_url);
println!("After authorizing, paste the code from the browser here.");
print!("Code: ");
io::stdout().flush()?;
let mut code = String::new();
io::stdin().read_line(&mut code)?;
let code = code.trim();
if code.is_empty() {
anyhow::bail!("No authorization code provided");
}
// 8. Exchange code for tokens
println!("Exchanging code for tokens...");
let (token_res, _dpop_nonce) = exchange_code(
&meta.token_endpoint,
&client_id,
&redirect_uri,
code,
&code_verifier,
&dpop_key,
None,
)
.await?;
if token_res.token_type.to_lowercase() != "dpop" {
println!(
"Warning: Expected DPoP token type, got '{}'",
token_res.token_type
);
}
let resolved_did = token_res.sub.as_deref().unwrap_or(&did);
// 9. Save OAuth session (DPoP keys + tokens)
let oauth_session = OAuthSession {
did: resolved_did.to_string(),
handle: handle.to_string(),
access_token: token_res.access_token.clone(),
refresh_token: token_res.refresh_token.clone(),
pds: pds_url.clone(),
token_endpoint: meta.token_endpoint.clone(),
issuer: meta.issuer.clone(),
dpop_pkcs8: URL_SAFE_NO_PAD.encode(&dpop_key.pkcs8_bytes),
dpop_jwk: dpop_key.jwk,
};
save_oauth_session(&oauth_session, is_bot)?;
// 10. Save compatible Session (for existing commands)
let pds_host = pds_url
.strip_prefix("https://")
.unwrap_or(&pds_url)
.trim_end_matches('/');
let compat_session = Session {
did: resolved_did.to_string(),
handle: handle.to_string(),
access_jwt: token_res.access_token,
refresh_jwt: token_res.refresh_token.unwrap_or_default(),
pds: Some(pds_host.to_string()),
};
if is_bot {
token::save_bot_session(&compat_session)?;
println!("Bot session saved.");
} else {
token::save_session(&compat_session)?;
println!("Session saved.");
}
println!(
"Logged in as {} ({}) via OAuth",
compat_session.handle, compat_session.did
);
Ok(())
}

View File

@@ -16,7 +16,7 @@ pub async fn push_to_remote(input: &str, collection: &str, is_bot: bool) -> Resu
};
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let did = &session.did;
let client = XrpcClient::new(pds);
let client = if is_bot { XrpcClient::new_bot(pds) } else { XrpcClient::new(pds) };
// Build collection directory path
let collection_dir = format!("{}/{}/{}", input, did, collection);

View File

@@ -119,7 +119,7 @@ pub async fn delete_record(collection: &str, rkey: &str, is_bot: bool) -> Result
auth::refresh_session().await?
};
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = XrpcClient::new(pds);
let client = if is_bot { XrpcClient::new_bot(pds) } else { XrpcClient::new(pds) };
let req = DeleteRecordRequest {
repo: session.did.clone(),

View File

@@ -210,6 +210,15 @@ enum Commands {
#[command(subcommand)]
command: BotCommands,
},
/// OAuth login to ATProto PDS
Oauth {
/// Handle (e.g., syui.syui.ai)
handle: String,
/// Login as bot (saves to bot.json)
#[arg(long)]
bot: bool,
},
}
#[derive(Subcommand)]
@@ -377,6 +386,9 @@ async fn main() -> Result<()> {
}
}
}
Commands::Oauth { handle, bot } => {
commands::oauth::oauth_login(&handle, bot).await?;
}
Commands::Gpt { command } => {
match command {
GptCommands::Core { download } => {

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorization Code - ailog</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0a0a0a;
color: #e0e0e0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.container {
text-align: center;
max-width: 480px;
padding: 2rem;
}
h1 {
font-size: 1.4rem;
margin-bottom: 1.5rem;
color: #fff;
}
.code-box {
background: #1a1a1a;
border: 2px solid #333;
border-radius: 8px;
padding: 1.5rem;
margin: 1.5rem 0;
}
.code {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 1.6rem;
letter-spacing: 0.1em;
color: #fff;
word-break: break-all;
user-select: all;
}
.copy-btn {
background: #333;
color: #fff;
border: 1px solid #555;
border-radius: 6px;
padding: 0.6rem 1.5rem;
font-size: 0.9rem;
cursor: pointer;
margin-top: 1rem;
transition: background 0.2s;
}
.copy-btn:hover { background: #444; }
.copy-btn.copied {
background: #2d5a2d;
border-color: #4a8a4a;
}
.hint {
color: #888;
font-size: 0.85rem;
margin-top: 1.5rem;
line-height: 1.6;
}
.error {
color: #e55;
font-size: 1rem;
}
</style>
</head>
<body>
<div class="container" id="app">
<div class="error">Loading...</div>
</div>
<script>
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const error = params.get('error')
const app = document.getElementById('app')
if (error) {
app.innerHTML = `
<h1>Authorization Error</h1>
<p class="error">${error}</p>
<p class="hint">Close this page and try again from the CLI.</p>
`
} else if (code) {
app.innerHTML = `
<h1>Authorization Complete</h1>
<div class="code-box">
<div class="code" id="auth-code">${code}</div>
<button class="copy-btn" id="copy-btn">Copy</button>
</div>
<p class="hint">Paste this code into your CLI.<br>You may close this page.</p>
`
document.getElementById('copy-btn').addEventListener('click', async () => {
const btn = document.getElementById('copy-btn')
try {
await navigator.clipboard.writeText(code)
btn.textContent = 'Copied!'
btn.classList.add('copied')
setTimeout(() => {
btn.textContent = 'Copy'
btn.classList.remove('copied')
}, 2000)
} catch {
const range = document.createRange()
range.selectNode(document.getElementById('auth-code'))
window.getSelection().removeAllRanges()
window.getSelection().addRange(range)
}
})
} else {
app.innerHTML = `
<h1>Authorization Code</h1>
<p class="error">No code found.</p>
<p class="hint">Start the authorization flow from the CLI.</p>
`
}
</script>
</body>
</html>

View File

@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::commands::oauth::{self, OAuthSession};
use crate::error::{AppError, AtprotoErrorResponse};
use crate::lexicons::{self, Endpoint};
@@ -10,6 +11,7 @@ use crate::lexicons::{self, Endpoint};
pub struct XrpcClient {
inner: reqwest::Client,
pds_host: String,
is_bot: bool,
}
impl XrpcClient {
@@ -18,6 +20,16 @@ impl XrpcClient {
Self {
inner: reqwest::Client::new(),
pds_host: pds_host.to_string(),
is_bot: false,
}
}
/// Create a new XrpcClient for bot usage
pub fn new_bot(pds_host: &str) -> Self {
Self {
inner: reqwest::Client::new(),
pds_host: pds_host.to_string(),
is_bot: true,
}
}
@@ -26,7 +38,25 @@ impl XrpcClient {
lexicons::url(&self.pds_host, endpoint)
}
/// Authenticated GET query
/// Ensure URL has scheme
fn ensure_scheme(url: &str) -> String {
if url.starts_with("https://") || url.starts_with("http://") {
url.to_string()
} else {
format!("https://{}", url)
}
}
/// Try to load OAuth session
fn try_load_oauth(&self) -> Option<OAuthSession> {
if oauth::has_oauth_session(self.is_bot) {
oauth::load_oauth_session(self.is_bot).ok()
} else {
None
}
}
/// Authenticated GET query (with DPoP nonce retry)
pub async fn query_auth<T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -43,6 +73,12 @@ impl XrpcClient {
url.push_str(&qs.join("&"));
}
let full_url = Self::ensure_scheme(&url);
if let Some(oauth) = self.try_load_oauth() {
self.dpop_request_with_retry(&oauth, token, "GET", &url, &full_url, None::<&()>)
.await
} else {
let res = self
.inner
.get(&url)
@@ -50,11 +86,11 @@ impl XrpcClient {
.send()
.await
.context("XRPC authenticated query failed")?;
self.handle_response(res).await
}
}
/// Authenticated POST call (procedure)
/// Authenticated POST call (procedure, with DPoP nonce retry)
pub async fn call<B: Serialize, T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -62,7 +98,12 @@ impl XrpcClient {
token: &str,
) -> Result<T> {
let url = self.url(endpoint);
let full_url = Self::ensure_scheme(&url);
if let Some(oauth) = self.try_load_oauth() {
self.dpop_request_with_retry(&oauth, token, "POST", &url, &full_url, Some(body))
.await
} else {
let res = self
.inner
.post(&url)
@@ -71,9 +112,9 @@ impl XrpcClient {
.send()
.await
.context("XRPC call failed")?;
self.handle_response(res).await
}
}
/// Unauthenticated POST call (e.g. createSession)
pub async fn call_unauth<B: Serialize, T: DeserializeOwned>(
@@ -94,7 +135,7 @@ impl XrpcClient {
self.handle_response(res).await
}
/// Authenticated POST that returns no body (or we ignore the response body)
/// Authenticated POST that returns no body (with DPoP nonce retry)
pub async fn call_no_response<B: Serialize>(
&self,
endpoint: &Endpoint,
@@ -102,7 +143,12 @@ impl XrpcClient {
token: &str,
) -> Result<()> {
let url = self.url(endpoint);
let full_url = Self::ensure_scheme(&url);
if let Some(oauth) = self.try_load_oauth() {
self.dpop_no_response_with_retry(&oauth, token, "POST", &url, &full_url, body)
.await
} else {
let res = self
.inner
.post(&url)
@@ -115,11 +161,12 @@ impl XrpcClient {
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
Ok(())
}
}
/// Authenticated POST with only a bearer token (no JSON body, e.g. refreshSession)
/// This is always Bearer (legacy refresh), not DPoP.
pub async fn call_bearer<T: DeserializeOwned>(
&self,
endpoint: &Endpoint,
@@ -138,13 +185,133 @@ impl XrpcClient {
self.handle_response(res).await
}
/// DPoP-authenticated request with nonce retry (returns deserialized body)
async fn dpop_request_with_retry<B: Serialize, T: DeserializeOwned>(
&self,
oauth: &OAuthSession,
token: &str,
method: &str,
url: &str,
full_url: &str,
body: Option<&B>,
) -> Result<T> {
let mut dpop_nonce: Option<String> = None;
for _attempt in 0..2 {
let dpop_proof = oauth::create_dpop_proof_for_request_with_nonce(
oauth,
method,
full_url,
dpop_nonce.as_deref(),
)?;
let builder = match method {
"GET" => self.inner.get(url),
_ => {
let b = self.inner.post(url);
if let Some(body) = body {
b.json(body)
} else {
b
}
}
};
let res = builder
.header("Authorization", format!("DPoP {}", token))
.header("DPoP", dpop_proof)
.send()
.await
.context("XRPC DPoP request failed")?;
// Check for dpop-nonce requirement
if res.status() == 401 {
if let Some(nonce) = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
{
let body_text = res.text().await.unwrap_or_default();
if body_text.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
return Err(anyhow::anyhow!("XRPC error (401): {}", body_text));
}
}
return self.handle_response(res).await;
}
anyhow::bail!("XRPC DPoP request failed after nonce retry");
}
/// DPoP-authenticated request with nonce retry (no response body)
async fn dpop_no_response_with_retry<B: Serialize>(
&self,
oauth: &OAuthSession,
token: &str,
method: &str,
url: &str,
full_url: &str,
body: &B,
) -> Result<()> {
let mut dpop_nonce: Option<String> = None;
for _attempt in 0..2 {
let dpop_proof = oauth::create_dpop_proof_for_request_with_nonce(
oauth,
method,
full_url,
dpop_nonce.as_deref(),
)?;
let res = self
.inner
.post(url)
.json(body)
.header("Authorization", format!("DPoP {}", token))
.header("DPoP", dpop_proof)
.send()
.await
.context("XRPC DPoP call failed")?;
if res.status() == 401 {
if let Some(nonce) = res
.headers()
.get("dpop-nonce")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
{
let body_text = res.text().await.unwrap_or_default();
if body_text.contains("use_dpop_nonce") {
dpop_nonce = Some(nonce);
continue;
}
return Err(anyhow::anyhow!("XRPC error (401): {}", body_text));
}
}
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
return Ok(());
}
anyhow::bail!("XRPC DPoP call failed after nonce retry");
}
/// Handle an XRPC response: check status, parse ATProto errors, deserialize body.
async fn handle_response<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
if !res.status().is_success() {
return Err(self.parse_error(res).await);
}
let body = res.json::<T>().await.context("Failed to parse XRPC response body")?;
let body = res
.json::<T>()
.await
.context("Failed to parse XRPC response body")?;
Ok(body)
}

View File

@@ -7,6 +7,12 @@ export default defineConfig({
build: {
outDir: '../../dist',
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'src/web/index.html'),
'oauth-cli': resolve(__dirname, 'src/web/oauth/cli/index.html'),
},
},
},
resolve: {
alias: {