Skip to main content

squigit_auth/auth/
callback_server.rs

1// Copyright 2026 a7mddra
2// SPDX-License-Identifier: Apache-2.0
3
4use 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}