Skip to main content

squigit_auth/auth/
google.rs

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