1use base64::{
5 engine::{general_purpose, general_purpose::URL_SAFE_NO_PAD},
6 Engine as _,
7};
8use chrono::{DateTime, Utc};
9use image::ImageFormat;
10use jsonwebtoken::jwk::JwkSet;
11use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
12use reqwest::blocking::{Client, Response};
13use reqwest::StatusCode;
14use serde::Deserialize;
15use std::fmt;
16use std::fs;
17use std::io::Cursor;
18use std::thread;
19use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
20use url::Url;
21
22use squigit_storage::{canonical_google_issuer, LastLogin, Profile, ProfileStore, GOOGLE_PROVIDER};
23
24use crate::{ProfileError, Result};
25
26use super::credentials::load_google_oauth_config;
27use super::AuthFlowSettings;
28
29const AVATAR_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60);
30const TOKEN_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10 * 60);
31const TOKEN_EXCHANGE_RETRY_DELAYS: [Duration; 6] = [
32 Duration::from_secs(2),
33 Duration::from_secs(5),
34 Duration::from_secs(10),
35 Duration::from_secs(20),
36 Duration::from_secs(40),
37 Duration::from_secs(60),
38];
39
40#[derive(Clone)]
41pub struct GoogleAuthAttempt {
42 auth_url: String,
43 state: String,
44 nonce: String,
45 code_verifier: String,
46 redirect_uri: String,
47 client_id: String,
48 client_secret: Option<String>,
49 token_uri: String,
50 started_at: Instant,
51}
52
53impl fmt::Debug for GoogleAuthAttempt {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.debug_struct("GoogleAuthAttempt")
56 .field("auth_url", &self.auth_url)
57 .field("state", &self.state)
58 .field("redirect_uri", &self.redirect_uri)
59 .field("client_id", &self.client_id)
60 .field("token_uri", &self.token_uri)
61 .field("started_at", &self.started_at)
62 .finish_non_exhaustive()
63 }
64}
65
66impl GoogleAuthAttempt {
67 pub fn auth_url(&self) -> &str {
68 &self.auth_url
69 }
70
71 fn is_expired(&self, timeout: Duration) -> bool {
72 self.started_at.elapsed() > timeout
73 }
74}
75
76#[derive(Deserialize)]
77struct TokenResponse {
78 access_token: Option<String>,
79 id_token: String,
80 scope: Option<String>,
81}
82
83#[derive(Deserialize)]
84struct TokenErrorResponse {
85 error: Option<String>,
86 error_description: Option<String>,
87}
88
89#[derive(Deserialize)]
90struct GoogleIdTokenClaims {
91 iss: String,
92 sub: String,
93 aud: String,
94 exp: u64,
95 iat: u64,
96 nonce: Option<String>,
97 email: Option<String>,
98 email_verified: Option<serde_json::Value>,
99 name: Option<String>,
100 picture: Option<String>,
101}
102
103#[derive(Deserialize)]
104struct OidcUserInfo {
105 sub: String,
106 email: Option<String>,
107 email_verified: Option<serde_json::Value>,
108 name: Option<String>,
109 picture: Option<String>,
110}
111
112fn generate_state_token() -> String {
113 generate_urlsafe_token(32)
114}
115
116fn generate_code_verifier() -> String {
117 generate_urlsafe_token(32)
118}
119
120fn generate_nonce() -> String {
121 generate_urlsafe_token(32)
122}
123
124fn generate_urlsafe_token(byte_len: usize) -> String {
125 use rand::{rngs::OsRng, RngCore};
126
127 let mut bytes = vec![0u8; byte_len];
128 OsRng.fill_bytes(&mut bytes);
129 URL_SAFE_NO_PAD.encode(bytes)
130}
131
132fn code_challenge_s256(code_verifier: &str) -> String {
133 use sha2::{Digest, Sha256};
134
135 let digest = Sha256::digest(code_verifier.as_bytes());
136 URL_SAFE_NO_PAD.encode(digest)
137}
138
139fn jwt_timestamp_to_datetime(value: u64, field: &str) -> Result<DateTime<Utc>> {
140 DateTime::<Utc>::from_timestamp(value as i64, 0).ok_or_else(|| {
141 ProfileError::Auth(format!(
142 "Google ID token contained an invalid '{}' timestamp",
143 field
144 ))
145 })
146}
147
148fn email_verified_is_false(value: Option<&serde_json::Value>) -> bool {
149 match value {
150 Some(serde_json::Value::Bool(false)) => true,
151 Some(serde_json::Value::String(value)) if value.eq_ignore_ascii_case("false") => true,
152 _ => false,
153 }
154}
155
156fn normalize_optional_string(value: Option<String>) -> Option<String> {
157 value
158 .map(|value| value.trim().to_string())
159 .filter(|value| !value.is_empty())
160}
161
162fn granted_scopes(scope: Option<&str>) -> Vec<String> {
163 let scopes = scope
164 .unwrap_or("openid profile email")
165 .split_whitespace()
166 .map(str::to_string)
167 .collect::<Vec<_>>();
168
169 if scopes.is_empty() {
170 vec![
171 "openid".to_string(),
172 "profile".to_string(),
173 "email".to_string(),
174 ]
175 } else {
176 scopes
177 }
178}
179
180fn avatar_target_id(store: &ProfileStore, profile_id: Option<&str>) -> Result<String> {
181 match profile_id {
182 Some(id) => Ok(id.to_string()),
183 None => store.get_active_profile_id()?.ok_or_else(|| {
184 ProfileError::Auth("No active profile and no profile ID provided.".to_string())
185 }),
186 }
187}
188
189fn avatar_temp_path(profile_id: &str) -> std::path::PathBuf {
190 let nonce = SystemTime::now()
191 .duration_since(UNIX_EPOCH)
192 .unwrap_or_default()
193 .as_nanos();
194
195 std::env::temp_dir().join(format!("squigit-avatar-{}-{}.download", profile_id, nonce))
196}
197
198fn download_avatar_data_url(client: &Client, url: &str, profile_id: &str) -> Result<String> {
199 if url.trim().is_empty() {
200 return Err(ProfileError::Auth("Avatar URL is empty.".to_string()));
201 }
202
203 let response = client.get(url).send()?;
204 if !response.status().is_success() {
205 return Err(ProfileError::Auth(format!(
206 "Failed to download avatar: HTTP {}",
207 response.status()
208 )));
209 }
210
211 let bytes = response.bytes()?;
212 if bytes.is_empty() {
213 return Err(ProfileError::Auth(
214 "Downloaded avatar is empty.".to_string(),
215 ));
216 }
217
218 let temp_path = avatar_temp_path(profile_id);
219 fs::write(&temp_path, bytes.as_ref())?;
220
221 let image = image::load_from_memory(bytes.as_ref());
222 let _ = fs::remove_file(&temp_path);
223 let image = image
224 .map_err(|err| ProfileError::Auth(format!("Failed to decode avatar image: {}", err)))?;
225
226 let mut cursor = Cursor::new(Vec::new());
227 image
228 .write_to(&mut cursor, ImageFormat::Png)
229 .map_err(|err| ProfileError::Auth(format!("Failed to encode avatar as PNG: {}", err)))?;
230 let encoded = general_purpose::STANDARD.encode(cursor.into_inner());
231
232 Ok(format!("data:image/png;base64,{}", encoded))
233}
234
235fn hydrate_avatar_once(store: &ProfileStore, url: &str, profile_id: &str) -> Result<String> {
236 let client = Client::builder().timeout(AVATAR_DOWNLOAD_TIMEOUT).build()?;
237 let avatar_base64 = download_avatar_data_url(&client, url, profile_id)?;
238
239 let mut profile = store
240 .get_profile(profile_id)?
241 .ok_or_else(|| ProfileError::ProfileNotFound(profile_id.to_string()))?;
242 profile.avatar_base64 = Some(avatar_base64.clone());
243 profile.avatar_url = Some(url.to_string());
244 store.upsert_profile(&profile)?;
245
246 Ok(avatar_base64)
247}
248
249fn should_retry_avatar_hydration(err: &ProfileError) -> bool {
250 matches!(
251 err,
252 ProfileError::Auth(_) | ProfileError::Io(_) | ProfileError::Network(_)
253 )
254}
255
256pub fn hydrate_avatar(store: &ProfileStore, url: &str, profile_id: Option<&str>) -> Result<String> {
257 let url = url.trim();
258 if url.is_empty() {
259 return Err(ProfileError::Auth("Avatar URL is empty.".to_string()));
260 }
261
262 let target_id = match profile_id {
263 Some(id) => id.to_string(),
264 None => avatar_target_id(store, None)?,
265 };
266 let retry_delays = [1, 2, 4, 8, 16, 30, 60];
267 let mut attempt = 0usize;
268
269 loop {
270 match hydrate_avatar_once(store, url, &target_id) {
271 Ok(avatar_base64) => return Ok(avatar_base64),
272 Err(err) if should_retry_avatar_hydration(&err) => {
273 let delay = retry_delays.get(attempt).copied().unwrap_or(60);
274 attempt = attempt.saturating_add(1);
275 eprintln!(
276 "[auth] Avatar hydration failed for profile {}: {}. Retrying in {}s.",
277 target_id, err, delay
278 );
279 thread::sleep(Duration::from_secs(delay));
280 }
281 Err(err) => return Err(err),
282 }
283 }
284}
285
286fn validate_google_id_token(
287 client: &Client,
288 settings: &AuthFlowSettings,
289 id_token: &str,
290 client_id: &str,
291 expected_nonce: &str,
292) -> Result<GoogleIdTokenClaims> {
293 let header = decode_header(id_token)
294 .map_err(|err| ProfileError::Auth(format!("Failed to decode ID token header: {}", err)))?;
295
296 if header.alg != Algorithm::RS256 {
297 return Err(ProfileError::Auth(format!(
298 "Unexpected Google ID token algorithm: {:?}",
299 header.alg
300 )));
301 }
302
303 let kid = header.kid.ok_or_else(|| {
304 ProfileError::Auth("Google ID token header did not include kid".to_string())
305 })?;
306
307 let jwks_response = client.get(&settings.jwks_url).send()?;
308 if !jwks_response.status().is_success() {
309 return Err(ProfileError::Auth(format!(
310 "Failed to fetch Google JWKS: HTTP {}",
311 jwks_response.status()
312 )));
313 }
314
315 let jwks: JwkSet = jwks_response.json().map_err(|err| {
316 ProfileError::Auth(format!("Failed to decode Google JWKS response: {}", err))
317 })?;
318 let jwk = jwks
319 .find(&kid)
320 .ok_or_else(|| ProfileError::Auth("Google JWKS did not include token kid".to_string()))?;
321 let decoding_key = DecodingKey::from_jwk(jwk)
322 .map_err(|err| ProfileError::Auth(format!("Failed to load Google JWK: {}", err)))?;
323
324 let mut validation = Validation::new(Algorithm::RS256);
325 validation.set_audience(&[client_id]);
326 validation.set_issuer(&["https://accounts.google.com", "accounts.google.com"]);
327 validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
328
329 let token_data = decode::<GoogleIdTokenClaims>(id_token, &decoding_key, &validation)
330 .map_err(|err| ProfileError::Auth(format!("Google ID token validation failed: {}", err)))?;
331 let claims = token_data.claims;
332
333 if claims.nonce.as_deref() != Some(expected_nonce) {
334 return Err(ProfileError::Auth(
335 "Google ID token nonce mismatch".to_string(),
336 ));
337 }
338 if claims.sub.trim().is_empty() {
339 return Err(ProfileError::Auth(
340 "Google ID token did not include a subject".to_string(),
341 ));
342 }
343 if email_verified_is_false(claims.email_verified.as_ref()) {
344 return Err(ProfileError::Auth(
345 "Google account email is not verified".to_string(),
346 ));
347 }
348
349 Ok(claims)
350}
351
352fn complete_display_claims(
353 client: &Client,
354 settings: &AuthFlowSettings,
355 claims: &GoogleIdTokenClaims,
356 access_token: Option<&str>,
357) -> Result<(String, String, Option<String>)> {
358 let mut email = normalize_optional_string(claims.email.clone());
359 let mut name = normalize_optional_string(claims.name.clone());
360 let mut picture = normalize_optional_string(claims.picture.clone());
361
362 if (email.is_none() || name.is_none() || picture.is_none())
363 && access_token.is_some_and(|token| !token.trim().is_empty())
364 {
365 let token = access_token.unwrap();
366 let user_info_res = client
367 .get(&settings.user_info_url)
368 .bearer_auth(token)
369 .send()?;
370
371 if user_info_res.status().is_success() {
372 let user_info: OidcUserInfo = user_info_res.json().map_err(|err| {
373 ProfileError::Auth(format!(
374 "Failed to decode Google UserInfo response: {}",
375 err
376 ))
377 })?;
378
379 if user_info.sub != claims.sub {
380 return Err(ProfileError::Auth(
381 "Google UserInfo subject did not match ID token subject".to_string(),
382 ));
383 }
384 if email_verified_is_false(user_info.email_verified.as_ref()) {
385 return Err(ProfileError::Auth(
386 "Google account email is not verified".to_string(),
387 ));
388 }
389
390 email = email.or_else(|| normalize_optional_string(user_info.email));
391 name = name.or_else(|| normalize_optional_string(user_info.name));
392 picture = picture.or_else(|| normalize_optional_string(user_info.picture));
393 }
394 }
395
396 let email = email.ok_or_else(|| {
397 ProfileError::Auth("Google identity response did not include an email address".to_string())
398 })?;
399
400 Ok((
401 name.unwrap_or_else(|| "Squigit User".to_string()),
402 email,
403 picture,
404 ))
405}
406
407pub fn begin_google_auth_flow(settings: &AuthFlowSettings) -> Result<GoogleAuthAttempt> {
408 let secrets = load_google_oauth_config(settings)?;
409 let state = generate_state_token();
410 let nonce = generate_nonce();
411 let code_verifier = generate_code_verifier();
412 let code_challenge = code_challenge_s256(&code_verifier);
413 let redirect_uri = settings.redirect_uri_for_client_id(&secrets.client_id);
414
415 let mut auth_url = Url::parse(&secrets.auth_uri)?;
416 auth_url
417 .query_pairs_mut()
418 .append_pair("client_id", &secrets.client_id)
419 .append_pair("redirect_uri", &redirect_uri)
420 .append_pair("response_type", "code")
421 .append_pair("scope", "openid profile email")
422 .append_pair("access_type", "online")
423 .append_pair("prompt", "select_account")
424 .append_pair("state", &state)
425 .append_pair("nonce", &nonce)
426 .append_pair("code_challenge", &code_challenge)
427 .append_pair("code_challenge_method", "S256");
428
429 Ok(GoogleAuthAttempt {
430 auth_url: auth_url.to_string(),
431 state,
432 nonce,
433 code_verifier,
434 redirect_uri,
435 client_id: secrets.client_id,
436 client_secret: secrets
437 .client_secret
438 .filter(|secret| !secret.trim().is_empty()),
439 token_uri: secrets.token_uri,
440 started_at: Instant::now(),
441 })
442}
443
444fn redirect_uri_matches(callback_url: &Url, expected_redirect_uri: &str) -> Result<bool> {
445 let expected = Url::parse(expected_redirect_uri)?;
446 Ok(callback_url.scheme() == expected.scheme()
447 && callback_url.username() == expected.username()
448 && callback_url.password() == expected.password()
449 && callback_url.host_str() == expected.host_str()
450 && callback_url.port_or_known_default() == expected.port_or_known_default()
451 && callback_url.path() == expected.path())
452}
453
454fn authorization_code_from_callback(
455 callback_url: &str,
456 expected_redirect_uri: &str,
457 expected_state: &str,
458) -> Result<String> {
459 let url = Url::parse(callback_url).map_err(|err| {
460 ProfileError::Auth(format!("Failed to parse OAuth callback URL: {}", err))
461 })?;
462
463 if !redirect_uri_matches(&url, expected_redirect_uri)? {
464 return Err(ProfileError::Auth(
465 "OAuth callback URL did not match the expected redirect URI".to_string(),
466 ));
467 }
468
469 let returned_state = url
470 .query_pairs()
471 .find(|(key, _)| key == "state")
472 .map(|(_, value)| value.into_owned());
473 if returned_state.as_deref() != Some(expected_state) {
474 return Err(ProfileError::Auth(
475 "OAuth callback state mismatch".to_string(),
476 ));
477 }
478
479 if let Some((_, error_code)) = url.query_pairs().find(|(key, _)| key == "error") {
480 return Err(ProfileError::Auth(format!(
481 "Google sign-in returned an error: {}",
482 error_code
483 )));
484 }
485
486 url.query_pairs()
487 .find(|(key, _)| key == "code")
488 .map(|(_, value)| value.into_owned())
489 .filter(|value| !value.trim().is_empty())
490 .ok_or_else(|| ProfileError::Auth("No authorization code found in callback".to_string()))
491}
492
493fn decode_token_response(response: Response) -> Result<TokenResponse> {
494 response.json().map_err(|err| {
495 ProfileError::Auth(format!("Failed to decode Google token response: {}", err))
496 })
497}
498
499fn read_token_error(response: Response) -> (StatusCode, String) {
500 let status = response.status();
501 let body = response.text().unwrap_or_default();
502 (status, body)
503}
504
505fn token_exchange_error_message(status: StatusCode, body: &str) -> String {
506 let trimmed = body.trim();
507 if let Ok(error) = serde_json::from_str::<TokenErrorResponse>(trimmed) {
508 let mut details = Vec::new();
509 if let Some(code) = error.error.filter(|value| !value.trim().is_empty()) {
510 details.push(code);
511 }
512 if let Some(description) = error
513 .error_description
514 .filter(|value| !value.trim().is_empty())
515 {
516 details.push(description);
517 }
518 if !details.is_empty() {
519 return format!(
520 "Google refused token exchange: HTTP {} ({})",
521 status,
522 details.join(": ")
523 );
524 }
525 }
526
527 if trimmed.is_empty() {
528 return format!("Google refused token exchange: HTTP {}", status);
529 }
530
531 let detail: String = trimmed.chars().take(600).collect();
532 format!(
533 "Google refused token exchange: HTTP {} ({})",
534 status, detail
535 )
536}
537
538fn token_error_mentions_client_secret(body: &str) -> bool {
539 let body = body.to_ascii_lowercase();
540 body.contains("client_secret") || body.contains("client secret")
541}
542
543fn post_token_form(
544 client: &Client,
545 attempt: &GoogleAuthAttempt,
546 token_form: &[(String, String)],
547) -> std::result::Result<Response, reqwest::Error> {
548 client.post(&attempt.token_uri).form(token_form).send()
549}
550
551fn post_token_form_with_retries(
552 client: &Client,
553 attempt: &GoogleAuthAttempt,
554 token_form: &[(String, String)],
555) -> Result<Response> {
556 let max_attempts = TOKEN_EXCHANGE_RETRY_DELAYS.len() + 1;
557
558 for attempt_index in 0..max_attempts {
559 match post_token_form(client, attempt, token_form) {
560 Ok(response) => return Ok(response),
561 Err(err) => {
562 let attempt_number = attempt_index + 1;
563 let Some(delay) = TOKEN_EXCHANGE_RETRY_DELAYS.get(attempt_index) else {
564 return Err(ProfileError::Auth(format!(
565 "Token exchange failed after {max_attempts} attempts: {err}"
566 )));
567 };
568
569 eprintln!(
570 "[auth] Token exchange transport failed on attempt {attempt_number}/{max_attempts}: {err}. Retrying in {}s.",
571 delay.as_secs()
572 );
573 thread::sleep(*delay);
574 }
575 }
576 }
577
578 Err(ProfileError::Auth(
579 "Token exchange failed before Google returned a response".to_string(),
580 ))
581}
582
583fn exchange_authorization_code(
584 client: &Client,
585 attempt: &GoogleAuthAttempt,
586 code: String,
587) -> Result<TokenResponse> {
588 let token_form = vec![
589 ("client_id".to_string(), attempt.client_id.clone()),
590 ("code".to_string(), code),
591 ("code_verifier".to_string(), attempt.code_verifier.clone()),
592 ("grant_type".to_string(), "authorization_code".to_string()),
593 ("redirect_uri".to_string(), attempt.redirect_uri.clone()),
594 ];
595 let token_res = post_token_form_with_retries(client, attempt, &token_form)?;
596
597 if token_res.status().is_success() {
598 return decode_token_response(token_res);
599 }
600
601 let (status, body) = read_token_error(token_res);
602 if token_error_mentions_client_secret(&body) {
603 if let Some(client_secret) = attempt.client_secret.as_deref() {
604 let mut token_form_with_secret = token_form;
605 token_form_with_secret.push(("client_secret".to_string(), client_secret.to_string()));
606 let retry_res = post_token_form_with_retries(client, attempt, &token_form_with_secret)?;
607 if retry_res.status().is_success() {
608 return decode_token_response(retry_res);
609 }
610
611 let (retry_status, retry_body) = read_token_error(retry_res);
612 return Err(ProfileError::Auth(token_exchange_error_message(
613 retry_status,
614 &retry_body,
615 )));
616 }
617 }
618
619 Err(ProfileError::Auth(token_exchange_error_message(
620 status, &body,
621 )))
622}
623
624pub fn complete_google_auth_flow(
625 store: &ProfileStore,
626 settings: &AuthFlowSettings,
627 attempt: GoogleAuthAttempt,
628 callback_url: &str,
629) -> Result<()> {
630 if attempt.is_expired(settings.timeout) {
631 return Err(ProfileError::Auth("Authentication timed out".to_string()));
632 }
633
634 let code =
635 authorization_code_from_callback(callback_url, &attempt.redirect_uri, &attempt.state)?;
636
637 let client = Client::builder()
638 .timeout(TOKEN_EXCHANGE_TIMEOUT)
639 .http1_only()
640 .pool_max_idle_per_host(0)
641 .build()?;
642 let token_data = exchange_authorization_code(&client, &attempt, code)?;
643
644 let claims = validate_google_id_token(
645 &client,
646 settings,
647 &token_data.id_token,
648 &attempt.client_id,
649 &attempt.nonce,
650 )?;
651
652 let (name, email, picture) = complete_display_claims(
653 &client,
654 settings,
655 &claims,
656 token_data.access_token.as_deref(),
657 )?;
658
659 let identity_issuer = canonical_google_issuer(&claims.iss);
660 let mut avatar_url = picture.unwrap_or_default();
661
662 let avatar_url = if avatar_url.trim().is_empty() {
663 None
664 } else {
665 if avatar_url.starts_with("http://")
666 && !avatar_url.starts_with("http://127.0.0.1")
667 && !avatar_url.starts_with("http://localhost")
668 {
669 avatar_url = avatar_url.replacen("http://", "https://", 1);
670 }
671 Some(avatar_url.clone())
672 };
673
674 let mut profile = Profile::new_google(
675 identity_issuer,
676 &claims.sub,
677 &email,
678 &name,
679 None,
680 avatar_url.clone(),
681 );
682 if let Some(existing_profile) = store.get_profile(&profile.id)? {
683 profile.created_at = existing_profile.created_at;
684 }
685 profile.touch();
686 store.upsert_profile(&profile)?;
687
688 let id_token_issued_at = jwt_timestamp_to_datetime(claims.iat, "iat")?;
689 let id_token_expires_at = jwt_timestamp_to_datetime(claims.exp, "exp")?;
690
691 let last_login = LastLogin {
692 profile_id: profile.id.clone(),
693 provider: GOOGLE_PROVIDER.to_string(),
694 issuer: identity_issuer.to_string(),
695 subject: claims.sub.clone(),
696 authenticated_at: Utc::now(),
697 audience: claims.aud.clone(),
698 scope: granted_scopes(token_data.scope.as_deref()),
699 pkce_method: "S256".to_string(),
700 id_token_issued_at,
701 id_token_expires_at,
702 };
703
704 store.record_last_login(last_login)?;
705
706 Ok(())
707}