Skip to main content

cairn_cli/cli/
auth.rs

1use clap::{Args, Subcommand};
2use serde_json::json;
3
4use crate::client::BackpacClient;
5use crate::config::Credentials;
6use crate::crypto;
7use crate::errors::CairnError;
8
9use super::{output_json, Cli};
10
11#[derive(Args, Debug)]
12pub struct AuthArgs {
13    #[command(subcommand)]
14    pub command: AuthCommands,
15}
16
17#[derive(Subcommand, Debug)]
18pub enum AuthCommands {
19    /// Request a cryptographic challenge for wallet signing.
20    Challenge {
21        /// Wallet address to authenticate
22        #[arg(long)]
23        wallet: String,
24
25        /// Chain family (e.g., eip155:1, solana:mainnet)
26        #[arg(long)]
27        chain: String,
28
29        /// DID identifier for the agent
30        #[arg(long)]
31        did: String,
32    },
33
34    /// Submit signed challenge to receive a JWT.
35    Connect {
36        /// Wallet address
37        #[arg(long)]
38        wallet: String,
39
40        /// Chain family
41        #[arg(long)]
42        chain: String,
43
44        /// DID identifier
45        #[arg(long)]
46        did: String,
47
48        /// Nonce from the challenge response
49        #[arg(long)]
50        nonce: String,
51
52        /// Signature (hex or base58) of the challenge message (required unless --key-file is provided)
53        #[arg(long)]
54        signature: Option<String>,
55
56        /// Path to private key file for auto-signing (optional)
57        #[arg(long)]
58        key_file: Option<String>,
59    },
60
61    /// Refresh the current JWT token.
62    Refresh,
63
64    /// Check the status of the current authentication session.
65    Status,
66}
67
68impl AuthArgs {
69    pub async fn execute(&self, cli: &Cli) -> Result<(), CairnError> {
70        match &self.command {
71            AuthCommands::Challenge {
72                wallet,
73                chain,
74                did,
75            } => {
76                let client = BackpacClient::new(cli.jwt.as_deref(), cli.api_url.as_deref(), cli.worker_url.as_deref());
77                let body = json!({
78                    "walletAddress": wallet,
79                    "chain": chain,
80                    "did": did
81                });
82                let result = client.post("/v1/agents/challenge", &body).await?;
83                output_json(&result, &cli.output);
84                Ok(())
85            }
86
87            AuthCommands::Connect {
88                wallet,
89                chain,
90                did,
91                nonce,
92                signature,
93                key_file,
94            } => {
95                let client = BackpacClient::new(cli.jwt.as_deref(), cli.api_url.as_deref(), cli.worker_url.as_deref());
96
97                // Use signature from flag, or auto-sign if key_file is provided
98                let sig = if let Some(kf) = key_file {
99                    let key_bytes = crypto::read_key_file(kf)?;
100                    let normalized_wallet = if chain.starts_with("eip155:") {
101                        wallet.to_lowercase()
102                    } else {
103                        wallet.clone()
104                    };
105                    let challenge_msg = format!(
106                        "Welcome to Backpac Agent Access.\n\n\
107                         Please sign this message to verify ownership of this wallet and establish a session.\n\n\
108                         Wallet: {}\nDID: {}\nChain: {}\nNonce: {}",
109                        normalized_wallet,
110                        did,
111                        chain,
112                        nonce
113                    );
114                    if chain.starts_with("eip155:") {
115                        crypto::sign_eip4361_message(&challenge_msg, &hex::encode(&key_bytes))?
116                    } else {
117                        // Ed25519: extract secret key (first 32 bytes for Solana 64-byte keypairs)
118                        let secret = if key_bytes.len() == 64 {
119                            &key_bytes[..32]
120                        } else {
121                            &key_bytes
122                        };
123                        let sig_bytes = crypto::ed25519_sign(challenge_msg.as_bytes(), secret)?;
124                        bs58::encode(sig_bytes).into_string()
125                    }
126                } else if let Some(s) = signature {
127                    s.clone()
128                } else {
129                    return Err(CairnError::InvalidInput(
130                        "Either --signature or --key-file must be provided".to_string(),
131                    ));
132                };
133
134                let body = json!({
135                    "walletAddress": wallet,
136                    "chain": chain,
137                    "did": did,
138                    "nonce": nonce,
139                    "signature": sig
140                });
141                let result = client.post("/v1/agents/connect", &body).await?;
142
143                // Persist credentials
144                if let Some(jwt) = result.get("token").and_then(|v| v.as_str()) {
145                    let creds = Credentials {
146                        jwt: jwt.to_string(),
147                        agent_id: result
148                            .get("agentId")
149                            .and_then(|v| v.as_str())
150                            .map(String::from),
151                        wallet: Some(wallet.clone()),
152                        expires_at: result
153                            .get("expires_at")
154                            .and_then(|v| v.as_str())
155                            .map(String::from),
156                    };
157                    creds.save()?;
158                }
159
160                output_json(&result, &cli.output);
161                Ok(())
162            }
163
164            AuthCommands::Refresh => {
165                let client = BackpacClient::new(cli.jwt.as_deref(), cli.api_url.as_deref(), cli.worker_url.as_deref());
166                let result = client.post("/v1/agents/refresh", &json!({})).await?;
167
168                // Update saved credentials with new JWT
169                if let Some(jwt) = result.get("jwt").and_then(|v| v.as_str()) {
170                    if let Some(mut creds) = Credentials::load() {
171                        creds.jwt = jwt.to_string();
172                        creds.expires_at = result
173                            .get("expires_at")
174                            .and_then(|v| v.as_str())
175                            .map(String::from);
176                        creds.save()?;
177                    }
178                }
179
180                output_json(&result, &cli.output);
181                Ok(())
182            }
183
184            AuthCommands::Status => {
185                let client = BackpacClient::new(cli.jwt.as_deref(), cli.api_url.as_deref(), None);
186                let result = client.get("/v1/agents/status").await?;
187                output_json(&result, &cli.output);
188                Ok(())
189            }
190        }
191    }
192}