skiff_cli/oauth/
config.rs1use 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#[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 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
62pub 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 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
117pub 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 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}