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