squigit_auth/auth/
callback_server.rs1use std::fmt;
5use std::sync::{Arc, OnceLock};
6use std::time::Duration;
7
8use reqwest::blocking::Client;
9use tiny_http::{Header, Request, Response, Server, StatusCode};
10use url::Url;
11
12use crate::{ProfileError, Result};
13
14use super::CredentialsSource;
15
16const DEFAULT_USER_INFO_URL: &str = "https://openidconnect.googleapis.com/v1/userinfo";
17const DEFAULT_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs";
18const DEFAULT_AUTH_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(4 * 60 * 60);
19const DEFAULT_REDIRECT_URI: &str = "http://127.0.0.1";
20const LOOPBACK_HOST: &str = "127.0.0.1";
21const LOOPBACK_RECV_INTERVAL: Duration = Duration::from_millis(250);
22const SQUIGIT_APP_DOMAIN: &str = "squigit.app";
23const SQUIGIT_APP_STATUS_PAGE_URL: &str = "https://squigit.app/login/popup-google-auth/";
24const GITHUB_PAGES_STATUS_PAGE_URL: &str = "https://squigit-org.github.io/login/popup-google-auth/";
25const SQUIGIT_APP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
26static SQUIGIT_APP_DOMAIN_AVAILABLE: OnceLock<bool> = OnceLock::new();
27
28pub type BrowserOpener = Arc<dyn Fn(&str) -> Result<()> + Send + Sync>;
29
30#[derive(Clone)]
31pub struct AuthFlowSettings {
32 pub redirect_uri: String,
33 pub status_page_url: String,
34 pub user_info_url: String,
35 pub jwks_url: String,
36 pub timeout: Duration,
37 pub credentials_source: CredentialsSource,
38 pub open_browser: BrowserOpener,
39}
40
41impl fmt::Debug for AuthFlowSettings {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.debug_struct("AuthFlowSettings")
44 .field("redirect_uri", &self.redirect_uri)
45 .field("status_page_url", &self.status_page_url)
46 .field("user_info_url", &self.user_info_url)
47 .field("jwks_url", &self.jwks_url)
48 .field("timeout", &self.timeout)
49 .field("credentials_source", &self.credentials_source)
50 .finish()
51 }
52}
53
54impl AuthFlowSettings {
55 pub fn new(open_browser: BrowserOpener) -> Self {
56 Self {
57 redirect_uri: DEFAULT_REDIRECT_URI.to_string(),
58 status_page_url: google_auth_status_page_url(),
59 user_info_url: DEFAULT_USER_INFO_URL.to_string(),
60 jwks_url: DEFAULT_JWKS_URL.to_string(),
61 timeout: DEFAULT_AUTH_ATTEMPT_TIMEOUT,
62 credentials_source: CredentialsSource::Auto,
63 open_browser,
64 }
65 }
66
67 pub fn redirect_uri_for_client_id(&self, _client_id: &str) -> String {
68 self.redirect_uri.clone()
69 }
70}
71
72pub fn google_auth_status_page_url() -> String {
73 if squigit_app_domain_available() {
74 SQUIGIT_APP_STATUS_PAGE_URL.to_string()
75 } else {
76 GITHUB_PAGES_STATUS_PAGE_URL.to_string()
77 }
78}
79
80pub fn google_auth_status_page_url_for(base_url: &str, page: LoopbackAuthPage) -> String {
81 let mut url = Url::parse(base_url)
82 .or_else(|_| Url::parse(GITHUB_PAGES_STATUS_PAGE_URL))
83 .expect("fallback Google auth status URL is valid");
84 url.set_query(None);
85 url.set_fragment(Some(page.fragment()));
86 url.to_string()
87}
88
89pub struct LoopbackAuthServer {
90 server: Server,
91 origin: String,
92 redirect_uri: String,
93 redirect_path: String,
94}
95
96pub struct LoopbackAuthRequest {
97 callback_url: String,
98 request: Request,
99}
100
101#[derive(Clone, Copy, Debug, Eq, PartialEq)]
102pub enum LoopbackAuthPage {
103 Success,
104 Invalid,
105}
106
107impl LoopbackAuthPage {
108 fn fragment(self) -> &'static str {
109 match self {
110 LoopbackAuthPage::Success => "success",
111 LoopbackAuthPage::Invalid => "invalid",
112 }
113 }
114}
115
116impl LoopbackAuthServer {
117 pub fn bind() -> Result<Self> {
118 let server = Server::http((LOOPBACK_HOST, 0)).map_err(|err| {
119 ProfileError::Auth(format!(
120 "Failed to start local Google auth callback server: {err}"
121 ))
122 })?;
123 let addr = server.server_addr().to_ip().ok_or_else(|| {
124 ProfileError::Auth(
125 "Local Google auth callback server did not bind to an IP address".to_string(),
126 )
127 })?;
128 let origin = format!("http://{}:{}", LOOPBACK_HOST, addr.port());
129 let redirect_uri = origin.clone();
130 let redirect_path = Url::parse(&redirect_uri)
131 .map(|url| url.path().to_string())
132 .unwrap_or_else(|_| "/".to_string());
133
134 Ok(Self {
135 server,
136 origin,
137 redirect_uri,
138 redirect_path,
139 })
140 }
141
142 pub fn redirect_uri(&self) -> &str {
143 &self.redirect_uri
144 }
145
146 pub fn recv_timeout(&self) -> Result<Option<LoopbackAuthRequest>> {
147 let Some(request) = self.server.recv_timeout(LOOPBACK_RECV_INTERVAL)? else {
148 return Ok(None);
149 };
150
151 match self.callback_url_for_request(&request) {
152 Ok(callback_url) => Ok(Some(LoopbackAuthRequest {
153 callback_url,
154 request,
155 })),
156 Err(_) => {
157 let _ = request.respond(not_found_response());
158 Ok(None)
159 }
160 }
161 }
162
163 fn callback_url_for_request(&self, request: &Request) -> Result<String> {
164 let raw_url = request.url();
165 let callback_url = if raw_url.starts_with("http://") || raw_url.starts_with("https://") {
166 raw_url.to_string()
167 } else {
168 format!("{}{}", self.origin, raw_url)
169 };
170 let parsed = Url::parse(&callback_url)?;
171
172 if parsed.scheme() != "http"
173 || parsed.host_str() != Some(LOOPBACK_HOST)
174 || parsed.path() != self.redirect_path
175 {
176 return Err(ProfileError::Auth(
177 "Ignoring non-OAuth loopback request".to_string(),
178 ));
179 }
180
181 Ok(callback_url)
182 }
183}
184
185impl LoopbackAuthRequest {
186 pub fn callback_url(&self) -> &str {
187 &self.callback_url
188 }
189
190 pub fn redirect(self, location: &str) -> Result<()> {
191 self.request
192 .respond(redirect_response(location))
193 .map_err(ProfileError::Io)
194 }
195}
196
197fn not_found_response() -> Response<std::io::Cursor<Vec<u8>>> {
198 Response::from_string("Not found")
199 .with_status_code(StatusCode(404))
200 .with_header(text_header())
201}
202
203fn redirect_response(location: &str) -> Response<std::io::Cursor<Vec<u8>>> {
204 Response::from_string("")
205 .with_status_code(StatusCode(302))
206 .with_header(location_header(location))
207 .with_header(connection_close_header())
208 .with_header(cache_header())
209 .with_header(referrer_header())
210}
211
212fn text_header() -> Header {
213 Header::from_bytes(&b"Content-Type"[..], &b"text/plain; charset=utf-8"[..]).unwrap()
214}
215
216fn location_header(location: &str) -> Header {
217 Header::from_bytes(&b"Location"[..], location.as_bytes()).unwrap()
218}
219
220fn connection_close_header() -> Header {
221 Header::from_bytes(&b"Connection"[..], &b"close"[..]).unwrap()
222}
223
224fn cache_header() -> Header {
225 Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap()
226}
227
228fn referrer_header() -> Header {
229 Header::from_bytes(&b"Referrer-Policy"[..], &b"no-referrer"[..]).unwrap()
230}
231
232fn squigit_app_domain_available() -> bool {
233 *SQUIGIT_APP_DOMAIN_AVAILABLE.get_or_init(|| {
234 Client::builder()
235 .timeout(SQUIGIT_APP_PROBE_TIMEOUT)
236 .build()
237 .and_then(|client| client.head(format!("https://{SQUIGIT_APP_DOMAIN}/")).send())
238 .is_ok_and(|response| response.status().as_u16() < 500)
239 })
240}