schwab_cli/commands/
auth.rs1use anyhow::{Context, Result};
2use console::Style;
3use schwab_api::ClientConfig;
4use serde_json::json;
5use std::time::Duration;
6
7use crate::auth_callback::capture_redirect_code;
8use crate::cli::AuthCommands;
9use crate::config::RuntimeConfig;
10use crate::output::ResponseEnvelope;
11
12pub async fn run(runtime: &RuntimeConfig, command: AuthCommands) -> Result<()> {
13 match command {
14 AuthCommands::Login { code } => login(runtime, code).await,
15 AuthCommands::Status => status(runtime).await,
16 AuthCommands::Refresh => refresh(runtime).await,
17 AuthCommands::Logout => logout(runtime).await,
18 }
19}
20
21async fn login(runtime: &RuntimeConfig, code: Option<String>) -> Result<()> {
22 if runtime.dry_run {
23 let envelope = ResponseEnvelope::ok(
24 "auth login",
25 json!({ "dry_run": true, "message": "Would run OAuth login flow" }),
26 );
27 runtime.emit(envelope);
28 return Ok(());
29 }
30
31 let config = ClientConfig::from_env().context("Missing SCHWAB_APP_KEY / SCHWAB_APP_SECRET")?;
32 let oauth = schwab_api::OAuthClient::new(config.clone());
33 let authorize_url = oauth.authorize_url();
34
35 let auth_code = if let Some(c) = code {
36 c
37 } else {
38 let redirect_uri = config.redirect_uri.clone();
39 let use_https = redirect_uri.starts_with("https://");
40
41 println!(
42 "{}",
43 Style::new().cyan().apply_to("Starting local OAuth callback listener…")
44 );
45 if use_https {
46 println!(
47 "{}",
48 Style::new().yellow().apply_to(
49 "Listening on https://127.0.0.1:8182. \
50 When redirected, accept the self-signed certificate (Advanced → Proceed)."
51 )
52 );
53 }
54
55 let capture = tokio::spawn(capture_redirect_code(
56 redirect_uri,
57 Duration::from_secs(120),
58 ));
59
60 if runtime.is_tty() {
61 inquire::Confirm::new("Ready to open Schwab login in your browser?")
62 .with_default(true)
63 .with_help_message("Authorization codes expire in ~30 seconds after redirect")
64 .prompt()?;
65 }
66
67 let _ = webbrowser::open(&authorize_url);
68 println!(
69 "{}",
70 Style::new().dim().apply_to(
71 "Complete login in the browser. The CLI will capture the redirect automatically."
72 )
73 );
74
75 match capture.await {
76 Ok(Ok(Some(code))) => code,
77 Ok(Ok(None)) | Ok(Err(_)) | Err(_) if runtime.is_tty() => {
78 println!(
79 "{}",
80 Style::new().yellow().apply_to(
81 "Auto-capture did not finish. After accepting the certificate in the browser, \
82 paste the redirect URL immediately (codes expire in ~30 seconds)."
83 )
84 );
85 inquire::Text::new("Paste redirect URL or authorization code")
86 .with_help_message("From https://127.0.0.1:8182/?code=… in the address bar")
87 .prompt()?
88 }
89 Ok(Ok(None)) => anyhow::bail!(
90 "OAuth callback timed out. Run: schwab auth login --code '<redirect-url>'"
91 ),
92 Ok(Err(e)) => return Err(e),
93 Err(e) => return Err(e.into()),
94 }
95 };
96
97 let parsed_code = extract_auth_code(&auth_code);
98 let tokens = oauth.exchange_code(&parsed_code).await.map_err(|e| {
99 anyhow::anyhow!(
100 "Token exchange failed: {e}. \
101 Authorization codes expire in ~30 seconds — run `schwab auth login` again and complete quickly. \
102 Also verify SCHWAB_REDIRECT_URI is exactly https://127.0.0.1:8182"
103 )
104 })?;
105
106 let envelope = ResponseEnvelope::ok(
107 "auth login",
108 json!({
109 "authenticated": true,
110 "expires_at": tokens.expires_at,
111 "expires_in_seconds": tokens.expires_in_seconds(),
112 "token_path": oauth.store().path(),
113 }),
114 )
115 .with_next_actions(vec![
116 "schwab auth status --json".into(),
117 "schwab accounts numbers --json".into(),
118 ]);
119 runtime.emit(envelope);
120 Ok(())
121}
122
123async fn status(runtime: &RuntimeConfig) -> Result<()> {
124 let config = ClientConfig::from_env().context("Missing Schwab app credentials")?;
125 let oauth = schwab_api::OAuthClient::new(config);
126 let tokens = oauth.status().await?;
127
128 let data = match tokens {
129 Some(t) => json!({
130 "authenticated": true,
131 "expires_at": t.expires_at,
132 "expires_in_seconds": t.expires_in_seconds(),
133 "expired": t.is_expired(),
134 "token_path": oauth.store().path(),
135 }),
136 None => json!({
137 "authenticated": false,
138 "token_path": oauth.store().path(),
139 "hint": "Browser login alone is not enough — run `schwab auth login` and paste the redirect URL containing code="
140 }),
141 };
142
143 let mut envelope = ResponseEnvelope::ok("auth status", data);
144 if !envelope.data["authenticated"].as_bool().unwrap_or(false) {
145 envelope.next_actions = vec!["schwab auth login".into()];
146 }
147 runtime.emit(envelope);
148 Ok(())
149}
150
151async fn refresh(runtime: &RuntimeConfig) -> Result<()> {
152 use crate::safety::require_mutation_approval;
153 require_mutation_approval(runtime, "auth refresh", "Refresh OAuth access token.")?;
154 if runtime.dry_run {
155 runtime.emit(ResponseEnvelope::ok(
156 "auth refresh",
157 json!({ "dry_run": true }),
158 ));
159 return Ok(());
160 }
161
162 let config = ClientConfig::from_env()?;
163 let oauth = schwab_api::OAuthClient::new(config);
164 let tokens = oauth.refresh().await?;
165
166 runtime.emit(ResponseEnvelope::ok(
167 "auth refresh",
168 json!({
169 "expires_at": tokens.expires_at,
170 "expires_in_seconds": tokens.expires_in_seconds(),
171 }),
172 ));
173 Ok(())
174}
175
176async fn logout(runtime: &RuntimeConfig) -> Result<()> {
177 use crate::safety::require_mutation_approval;
178 require_mutation_approval(runtime, "auth logout", "Delete stored OAuth tokens.")?;
179 if runtime.dry_run {
180 runtime.emit(ResponseEnvelope::ok("auth logout", json!({ "dry_run": true })));
181 return Ok(());
182 }
183
184 let config = ClientConfig::from_env()?;
185 let oauth = schwab_api::OAuthClient::new(config);
186 oauth.logout().await?;
187 runtime.emit(ResponseEnvelope::ok(
188 "auth logout",
189 json!({ "cleared": true, "token_path": oauth.store().path() }),
190 ));
191 Ok(())
192}
193
194fn extract_auth_code(raw: &str) -> String {
195 let normalized: String = raw.chars().filter(|c| !c.is_whitespace()).collect();
196 let code = if let Some(idx) = normalized.find("code=") {
197 let rest = &normalized[idx + 5..];
198 rest.split('&')
199 .next()
200 .unwrap_or(rest)
201 .trim_end_matches('/')
202 } else {
203 normalized.as_str()
204 };
205 urlencoding::decode(code)
206 .map(|c| c.into_owned())
207 .unwrap_or_else(|_| code.to_string())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn extracts_code_from_redirect_url() {
216 let url = "https://127.0.0.1:8182/?code=ABC123&session=xyz";
217 assert_eq!(extract_auth_code(url), "ABC123");
218 }
219
220 #[test]
221 fn extracts_code_with_line_breaks() {
222 let url = "https://127.0.0.1:8182/?code=ABC%40123&session=abc-\ndef";
223 assert_eq!(extract_auth_code(url), "ABC@123");
224 }
225}