Skip to main content

skiff_cli/oauth/
config.rs

1//! OAuth flow selection and redirect URI helpers.
2
3use std::net::{SocketAddr, TcpListener};
4use std::path::PathBuf;
5
6use sha2::{Digest, Sha256};
7use url::Url;
8
9use crate::error::{Error, Result};
10use crate::paths::cache_dir;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum OAuthFlow {
14    Auto,
15    AuthorizationCode,
16    ClientCredentials,
17}
18
19impl OAuthFlow {
20    pub fn as_str(self) -> &'static str {
21        match self {
22            Self::Auto => "auto",
23            Self::AuthorizationCode => "authorization_code",
24            Self::ClientCredentials => "client_credentials",
25        }
26    }
27}
28
29pub fn parse_oauth_flow(s: &str) -> Result<OAuthFlow> {
30    match s {
31        "auto" => Ok(OAuthFlow::Auto),
32        "authorization_code" => Ok(OAuthFlow::AuthorizationCode),
33        "client_credentials" => Ok(OAuthFlow::ClientCredentials),
34        other => Err(Error::usage(format!(
35            "invalid --oauth-flow {other:?}; expected auto|authorization_code|client_credentials"
36        ))),
37    }
38}
39
40/// Resolved OAuth options after secret resolution and validation.
41#[derive(Debug, Clone)]
42pub struct OAuthOptions {
43    pub client_id: Option<String>,
44    pub client_secret: Option<String>,
45    pub client_name: String,
46    pub scope: Option<String>,
47    pub redirect_uri: Option<String>,
48    pub flow: OAuthFlow,
49}
50
51impl OAuthOptions {
52    /// True when auto or explicit client-credentials and both credentials present.
53    pub fn use_client_credentials(&self) -> bool {
54        match self.flow {
55            OAuthFlow::ClientCredentials => true,
56            OAuthFlow::Auto => self.client_id.is_some() && self.client_secret.is_some(),
57            OAuthFlow::AuthorizationCode => false,
58        }
59    }
60}
61
62/// Validate `--oauth-redirect-uri`: http, loopback host, explicit port.
63pub fn validate_redirect_uri(uri: &str) -> Result<Url> {
64    let parsed = Url::parse(uri).map_err(|e| Error::usage(format!("invalid redirect URI: {e}")))?;
65    if parsed.scheme() != "http" {
66        return Err(Error::usage(
67            "oauth redirect URI must use http:// (local callback server)",
68        ));
69    }
70    let host = parsed.host_str().unwrap_or("");
71    if !matches!(host, "localhost" | "127.0.0.1" | "::1") {
72        return Err(Error::usage(format!(
73            "oauth redirect URI host must be loopback (localhost/127.0.0.1/::1), got {host:?}"
74        )));
75    }
76    if parsed.port().is_none() {
77        return Err(Error::usage(
78            "oauth redirect URI must include an explicit port",
79        ));
80    }
81    Ok(parsed)
82}
83
84pub fn find_free_port() -> Result<u16> {
85    let listener = TcpListener::bind("127.0.0.1:0")
86        .map_err(|e| Error::runtime(format!("cannot bind free port: {e}")))?;
87    Ok(listener
88        .local_addr()
89        .map_err(|e| Error::runtime(e.to_string()))?
90        .port())
91}
92
93pub fn port_available(host: &str, port: u16) -> bool {
94    let addr = match format!("{host}:{port}").parse::<SocketAddr>() {
95        Ok(a) => a,
96        Err(_) => {
97            // IPv6 localhost
98            if host == "::1" {
99                SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], port))
100            } else {
101                return false;
102            }
103        }
104    };
105    TcpListener::bind(addr).is_ok()
106}
107
108pub fn server_hash(server_url: &str) -> String {
109    let digest = Sha256::digest(server_url.as_bytes());
110    hex::encode(&digest[..8])
111}
112
113pub fn oauth_dir_for_server(server_url: &str) -> PathBuf {
114    cache_dir().join("oauth").join(server_hash(server_url))
115}
116
117/// Resolve redirect URI: explicit validated URI, sticky cached, or fresh free port.
118pub fn resolve_redirect_uri(explicit: Option<&str>, sticky_uri: Option<&str>) -> Result<String> {
119    if let Some(uri) = explicit {
120        validate_redirect_uri(uri)?;
121        return Ok(uri.to_string());
122    }
123    if let Some(uri) = sticky_uri {
124        // Re-validate sticky URI (file may have been tampered).
125        if let Ok(parsed) = validate_redirect_uri(uri) {
126            let host = parsed.host_str().unwrap_or("127.0.0.1");
127            if let Some(port) = parsed.port() {
128                if port_available(host, port) {
129                    return Ok(uri.to_string());
130                }
131            }
132        }
133    }
134    let port = find_free_port()?;
135    Ok(format!("http://127.0.0.1:{port}/callback"))
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn flow_parse() {
144        assert_eq!(parse_oauth_flow("auto").unwrap(), OAuthFlow::Auto);
145        assert_eq!(
146            parse_oauth_flow("authorization_code").unwrap(),
147            OAuthFlow::AuthorizationCode
148        );
149        assert!(parse_oauth_flow("bogus").is_err());
150    }
151
152    #[test]
153    fn redirect_validation() {
154        assert!(validate_redirect_uri("http://127.0.0.1:18080/callback").is_ok());
155        assert!(validate_redirect_uri("http://localhost:9999/cb").is_ok());
156        assert!(validate_redirect_uri("https://127.0.0.1:18080/callback").is_err());
157        assert!(validate_redirect_uri("http://127.0.0.1/callback").is_err());
158        assert!(validate_redirect_uri("http://example.com:8080/callback").is_err());
159    }
160
161    #[test]
162    fn auto_picks_cc_when_both_creds() {
163        let opts = OAuthOptions {
164            client_id: Some("id".into()),
165            client_secret: Some("sec".into()),
166            client_name: "skiff".into(),
167            scope: None,
168            redirect_uri: None,
169            flow: OAuthFlow::Auto,
170        };
171        assert!(opts.use_client_credentials());
172        let opts2 = OAuthOptions {
173            client_secret: None,
174            flow: OAuthFlow::Auto,
175            ..opts.clone()
176        };
177        assert!(!opts2.use_client_credentials());
178        let opts3 = OAuthOptions {
179            flow: OAuthFlow::AuthorizationCode,
180            ..opts
181        };
182        assert!(!opts3.use_client_credentials());
183    }
184
185    #[test]
186    fn server_hash_stable() {
187        assert_eq!(server_hash("http://x"), server_hash("http://x"));
188        assert_ne!(server_hash("http://x"), server_hash("http://y"));
189        assert_eq!(server_hash("http://x").len(), 16);
190    }
191
192    #[test]
193    fn sticky_redirect_reused_when_port_free() {
194        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
195        let port = listener.local_addr().unwrap().port();
196        drop(listener);
197        let sticky = format!("http://127.0.0.1:{port}/callback");
198        let resolved = resolve_redirect_uri(None, Some(&sticky)).unwrap();
199        assert_eq!(resolved, sticky);
200    }
201
202    #[test]
203    fn sticky_non_loopback_is_ignored() {
204        let sticky = "http://0.0.0.0:19999/callback";
205        let resolved = resolve_redirect_uri(None, Some(sticky)).unwrap();
206        assert!(resolved.starts_with("http://127.0.0.1:"));
207        assert!(resolved.ends_with("/callback"));
208    }
209
210    #[test]
211    fn explicit_redirect_wins_over_sticky() {
212        let explicit = "http://127.0.0.1:18080/callback";
213        let sticky = "http://127.0.0.1:18081/callback";
214        assert_eq!(
215            resolve_redirect_uri(Some(explicit), Some(sticky)).unwrap(),
216            explicit
217        );
218    }
219}