Skip to main content

schwab_cli/commands/
auth.rs

1use 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}