fix test cli oauth
This commit is contained in:
@@ -24,3 +24,5 @@ rand = "0.8"
|
|||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
rustyline = "15"
|
rustyline = "15"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
ring = "0.17"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/.well-known/* /.well-known/:splat 200
|
/.well-known/* /.well-known/:splat 200
|
||||||
/app/* /index.html 200
|
/app/* /index.html 200
|
||||||
|
/oauth/cli /oauth/cli/index.html 200
|
||||||
/oauth/* /index.html 200
|
/oauth/* /index.html 200
|
||||||
/* /index.html 200
|
/* /index.html 200
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"client_id": "https://syui.ai/client-metadata.json",
|
"client_id": "https://syui.ai/client-metadata.json",
|
||||||
"client_name": "syui.ai",
|
"client_name": "syui.ai",
|
||||||
"client_uri": "https://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",
|
"scope": "atproto transition:generic",
|
||||||
"grant_types": ["authorization_code", "refresh_token"],
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
"response_types": ["code"],
|
"response_types": ["code"],
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
"3mfyfqevqko22",
|
||||||
|
"3mfyfqevwlk22",
|
||||||
|
"3mfyfqew44k22",
|
||||||
|
"3mfyfqewboi22",
|
||||||
|
"3mfyfqewhde22",
|
||||||
|
"3mfyfqewmtz22",
|
||||||
|
"3mfyfqewsg222",
|
||||||
|
"3mfyfqewxxe22",
|
||||||
|
"3mfyfqex5gy22",
|
||||||
|
"3mfyfqexcw622",
|
||||||
|
"3mfyfqexigm22",
|
||||||
|
"3mfyfqexnp322",
|
||||||
|
"3mfyfqextcx22",
|
||||||
|
"3mfyfqexyry22"
|
||||||
|
]
|
||||||
@@ -96,9 +96,12 @@
|
|||||||
"3mfswma2goi22",
|
"3mfswma2goi22",
|
||||||
"3mfsxkrpjtl24",
|
"3mfsxkrpjtl24",
|
||||||
"3mfsycszelm26",
|
"3mfsycszelm26",
|
||||||
"3mfsyvpe6iv2a",
|
|
||||||
"3mfsyvyfppj2c",
|
"3mfsyvyfppj2c",
|
||||||
|
"3mftbfmt7e432",
|
||||||
"3mfsz364fq52e",
|
"3mfsz364fq52e",
|
||||||
|
"3mftbk3gc4y34",
|
||||||
|
"3mfsyvpe6iv2a",
|
||||||
|
"3mftblsrpt736",
|
||||||
"3mfszgkcwhl2g",
|
"3mfszgkcwhl2g",
|
||||||
"3mft7jrokku2i",
|
"3mft7jrokku2i",
|
||||||
"3mft7jwg5rx2k",
|
"3mft7jwg5rx2k",
|
||||||
@@ -109,9 +112,6 @@
|
|||||||
"3mftahzh3dl2u",
|
"3mftahzh3dl2u",
|
||||||
"3mftaqxedjp2w",
|
"3mftaqxedjp2w",
|
||||||
"3mftaw5vsi72y",
|
"3mftaw5vsi72y",
|
||||||
"3mftbfmt7e432",
|
|
||||||
"3mftbk3gc4y34",
|
|
||||||
"3mftblsrpt736",
|
|
||||||
"3mftbpllmzm3a",
|
"3mftbpllmzm3a",
|
||||||
"3mftcm2tmnk22",
|
"3mftcm2tmnk22",
|
||||||
"3mftcnhl4zk24",
|
"3mftcnhl4zk24",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::oauth;
|
||||||
use super::token::{self, Session};
|
use super::token::{self, Session};
|
||||||
use crate::lexicons::com_atproto_server;
|
use crate::lexicons::com_atproto_server;
|
||||||
use crate::types::{CreateSessionRequest, CreateSessionResponse};
|
use crate::types::{CreateSessionRequest, CreateSessionResponse};
|
||||||
@@ -38,7 +39,7 @@ pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Res
|
|||||||
Ok(())
|
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> {
|
async fn do_refresh(session: &Session, pds: &str) -> Result<Session> {
|
||||||
let client = XrpcClient::new(pds);
|
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> {
|
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 session = token::load_session()?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
@@ -66,8 +72,13 @@ pub async fn refresh_session() -> Result<Session> {
|
|||||||
Ok(new_session)
|
Ok(new_session)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh bot access token
|
/// Refresh bot access token (OAuth-aware)
|
||||||
pub async fn refresh_bot_session() -> Result<Session> {
|
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 session = token::load_bot_session()?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ async fn poll_once(
|
|||||||
// Refresh bot session
|
// Refresh bot session
|
||||||
let session = auth::refresh_bot_session().await?;
|
let session = auth::refresh_bot_session().await?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
let client = XrpcClient::new(pds);
|
let client = XrpcClient::new_bot(pds);
|
||||||
|
|
||||||
// Fetch notifications
|
// Fetch notifications
|
||||||
let notifications = fetch_notifications(&client, &session.access_jwt).await?;
|
let notifications = fetch_notifications(&client, &session.access_jwt).await?;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const COLLECTION_MEMORY: &str = "ai.syui.gpt.memory";
|
|||||||
pub async fn get_core(download: bool) -> Result<()> {
|
pub async fn get_core(download: bool) -> Result<()> {
|
||||||
let session = auth::refresh_bot_session().await?;
|
let session = auth::refresh_bot_session().await?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
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
|
let record: Record = client
|
||||||
.query_auth(
|
.query_auth(
|
||||||
@@ -41,7 +41,7 @@ pub async fn get_core(download: bool) -> Result<()> {
|
|||||||
pub async fn get_memory(download: bool) -> Result<()> {
|
pub async fn get_memory(download: bool) -> Result<()> {
|
||||||
let session = auth::refresh_bot_session().await?;
|
let session = auth::refresh_bot_session().await?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
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
|
let result: ListRecordsResponse = client
|
||||||
.query_auth(
|
.query_auth(
|
||||||
@@ -79,7 +79,7 @@ pub async fn get_memory(download: bool) -> Result<()> {
|
|||||||
pub async fn list_memory() -> Result<()> {
|
pub async fn list_memory() -> Result<()> {
|
||||||
let session = auth::refresh_bot_session().await?;
|
let session = auth::refresh_bot_session().await?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
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 cursor: Option<String> = None;
|
||||||
let mut all_records: Vec<Record> = Vec::new();
|
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 session = auth::refresh_bot_session().await?;
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
let did = &session.did;
|
let did = &session.did;
|
||||||
let client = XrpcClient::new(pds);
|
let client = XrpcClient::new_bot(pds);
|
||||||
|
|
||||||
let collection_dir = format!("public/content/{}/{}", did, collection);
|
let collection_dir = format!("public/content/{}/{}", did, collection);
|
||||||
if !std::path::Path::new(&collection_dir).exists() {
|
if !std::path::Path::new(&collection_dir).exists() {
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ pub mod index;
|
|||||||
pub mod bot;
|
pub mod bot;
|
||||||
pub mod pds;
|
pub mod pds;
|
||||||
pub mod gpt;
|
pub mod gpt;
|
||||||
|
pub mod oauth;
|
||||||
|
|||||||
813
src/commands/oauth.rs
Normal file
813
src/commands/oauth.rs
Normal 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(¶ms)
|
||||||
|
.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(¶ms)
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
@@ -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 pds = session.pds.as_deref().unwrap_or("bsky.social");
|
||||||
let did = &session.did;
|
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
|
// Build collection directory path
|
||||||
let collection_dir = format!("{}/{}/{}", input, did, collection);
|
let collection_dir = format!("{}/{}/{}", input, did, collection);
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ pub async fn delete_record(collection: &str, rkey: &str, is_bot: bool) -> Result
|
|||||||
auth::refresh_session().await?
|
auth::refresh_session().await?
|
||||||
};
|
};
|
||||||
let pds = session.pds.as_deref().unwrap_or("bsky.social");
|
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 {
|
let req = DeleteRecordRequest {
|
||||||
repo: session.did.clone(),
|
repo: session.did.clone(),
|
||||||
|
|||||||
12
src/main.rs
12
src/main.rs
@@ -210,6 +210,15 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: BotCommands,
|
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)]
|
#[derive(Subcommand)]
|
||||||
@@ -377,6 +386,9 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Oauth { handle, bot } => {
|
||||||
|
commands::oauth::oauth_login(&handle, bot).await?;
|
||||||
|
}
|
||||||
Commands::Gpt { command } => {
|
Commands::Gpt { command } => {
|
||||||
match command {
|
match command {
|
||||||
GptCommands::Core { download } => {
|
GptCommands::Core { download } => {
|
||||||
|
|||||||
123
src/web/oauth/cli/index.html
Normal file
123
src/web/oauth/cli/index.html
Normal 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>
|
||||||
235
src/xrpc.rs
235
src/xrpc.rs
@@ -2,6 +2,7 @@ use anyhow::{Context, Result};
|
|||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::commands::oauth::{self, OAuthSession};
|
||||||
use crate::error::{AppError, AtprotoErrorResponse};
|
use crate::error::{AppError, AtprotoErrorResponse};
|
||||||
use crate::lexicons::{self, Endpoint};
|
use crate::lexicons::{self, Endpoint};
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ use crate::lexicons::{self, Endpoint};
|
|||||||
pub struct XrpcClient {
|
pub struct XrpcClient {
|
||||||
inner: reqwest::Client,
|
inner: reqwest::Client,
|
||||||
pds_host: String,
|
pds_host: String,
|
||||||
|
is_bot: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl XrpcClient {
|
impl XrpcClient {
|
||||||
@@ -18,6 +20,16 @@ impl XrpcClient {
|
|||||||
Self {
|
Self {
|
||||||
inner: reqwest::Client::new(),
|
inner: reqwest::Client::new(),
|
||||||
pds_host: pds_host.to_string(),
|
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)
|
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>(
|
pub async fn query_auth<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
@@ -43,18 +73,24 @@ impl XrpcClient {
|
|||||||
url.push_str(&qs.join("&"));
|
url.push_str(&qs.join("&"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = self
|
let full_url = Self::ensure_scheme(&url);
|
||||||
.inner
|
|
||||||
.get(&url)
|
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("XRPC authenticated query failed")?;
|
|
||||||
|
|
||||||
self.handle_response(res).await
|
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)
|
||||||
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.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>(
|
pub async fn call<B: Serialize, T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
@@ -62,17 +98,22 @@ impl XrpcClient {
|
|||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<T> {
|
) -> Result<T> {
|
||||||
let url = self.url(endpoint);
|
let url = self.url(endpoint);
|
||||||
|
let full_url = Self::ensure_scheme(&url);
|
||||||
|
|
||||||
let res = self
|
if let Some(oauth) = self.try_load_oauth() {
|
||||||
.inner
|
self.dpop_request_with_retry(&oauth, token, "POST", &url, &full_url, Some(body))
|
||||||
.post(&url)
|
.await
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
} else {
|
||||||
.json(body)
|
let res = self
|
||||||
.send()
|
.inner
|
||||||
.await
|
.post(&url)
|
||||||
.context("XRPC call failed")?;
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(body)
|
||||||
self.handle_response(res).await
|
.send()
|
||||||
|
.await
|
||||||
|
.context("XRPC call failed")?;
|
||||||
|
self.handle_response(res).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unauthenticated POST call (e.g. createSession)
|
/// Unauthenticated POST call (e.g. createSession)
|
||||||
@@ -94,7 +135,7 @@ impl XrpcClient {
|
|||||||
self.handle_response(res).await
|
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>(
|
pub async fn call_no_response<B: Serialize>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
@@ -102,24 +143,30 @@ impl XrpcClient {
|
|||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let url = self.url(endpoint);
|
let url = self.url(endpoint);
|
||||||
|
let full_url = Self::ensure_scheme(&url);
|
||||||
|
|
||||||
let res = self
|
if let Some(oauth) = self.try_load_oauth() {
|
||||||
.inner
|
self.dpop_no_response_with_retry(&oauth, token, "POST", &url, &full_url, body)
|
||||||
.post(&url)
|
.await
|
||||||
.header("Authorization", format!("Bearer {}", token))
|
} else {
|
||||||
.json(body)
|
let res = self
|
||||||
.send()
|
.inner
|
||||||
.await
|
.post(&url)
|
||||||
.context("XRPC call failed")?;
|
.header("Authorization", format!("Bearer {}", token))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("XRPC call failed")?;
|
||||||
|
|
||||||
if !res.status().is_success() {
|
if !res.status().is_success() {
|
||||||
return Err(self.parse_error(res).await);
|
return Err(self.parse_error(res).await);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticated POST with only a bearer token (no JSON body, e.g. refreshSession)
|
/// 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>(
|
pub async fn call_bearer<T: DeserializeOwned>(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &Endpoint,
|
endpoint: &Endpoint,
|
||||||
@@ -138,13 +185,133 @@ impl XrpcClient {
|
|||||||
self.handle_response(res).await
|
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.
|
/// Handle an XRPC response: check status, parse ATProto errors, deserialize body.
|
||||||
async fn handle_response<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
|
async fn handle_response<T: DeserializeOwned>(&self, res: reqwest::Response) -> Result<T> {
|
||||||
if !res.status().is_success() {
|
if !res.status().is_success() {
|
||||||
return Err(self.parse_error(res).await);
|
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)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: '../../dist',
|
outDir: '../../dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'src/web/index.html'),
|
||||||
|
'oauth-cli': resolve(__dirname, 'src/web/oauth/cli/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user