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::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}