Skip to main content

better_auth_api/plugins/oauth/
google.rs

1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2use better_auth_core::{AuthError, AuthResult, NativeRequest};
3use jsonwebtoken::{Algorithm, DecodingKey, crypto};
4use serde_json::Value;
5
6use super::OAuthIdTokenVerifier;
7use crate::plugins::json_body;
8
9pub(super) const JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs";
10
11/// Google audience options. Authorization and token requests use the primary client ID.
12#[derive(Clone, Debug, Default)]
13pub struct GoogleOptions {
14    /// Additional accepted ID-token audiences after `OAuthProvider::client_id`.
15    pub additional_client_ids: Vec<String>,
16}
17
18pub(crate) struct VerifiedGoogleClaims(Value);
19
20impl VerifiedGoogleClaims {
21    pub(crate) fn into_value(self) -> Value {
22        self.0
23    }
24
25    pub(super) fn matches_hosted_domain(&self, domain: Option<&str>) -> bool {
26        hosted_domain_allowed(domain, &self.0)
27    }
28}
29
30pub(super) struct AcceptedIdToken(String);
31
32impl AcceptedIdToken {
33    pub(super) async fn verify(
34        verifier: &dyn OAuthIdTokenVerifier,
35        token: &str,
36        nonce: Option<&str>,
37        context: Option<NativeRequest<'_>>,
38    ) -> Option<Self> {
39        // The configured verifier owns this application's token trust policy.
40        Self::from_result(token, verifier.verify_id_token(token, nonce, context).await)
41    }
42
43    pub(super) fn from_result<E>(token: &str, result: Result<bool, E>) -> Option<Self> {
44        result.unwrap_or(false).then(|| Self(token.to_owned()))
45    }
46
47    pub(super) fn claims(self) -> AuthResult<VerifiedGoogleClaims> {
48        self.value().map(VerifiedGoogleClaims)
49    }
50
51    pub(super) fn value(self) -> AuthResult<Value> {
52        let payload = self
53            .0
54            .split('.')
55            .nth(1)
56            .ok_or_else(|| AuthError::internal("Verified ID token has no claims"))?;
57        let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|error| {
58            AuthError::internal(format!("Invalid verified ID claims encoding: {error}"))
59        })?;
60        let claims = serde_json::from_slice(&bytes)?;
61        Ok(claims)
62    }
63}
64
65pub(crate) fn hosted_domain_allowed(domain: Option<&str>, claims: &Value) -> bool {
66    domain
67        .filter(|domain| !domain.is_empty())
68        .is_none_or(|domain| {
69            claims
70                .get("hd")
71                .and_then(Value::as_str)
72                .filter(|value| !value.is_empty())
73                .is_some_and(|value| domain == "*" || domain == value)
74        })
75}
76
77pub(crate) async fn verify(
78    token: &str,
79    audience: &[String],
80    nonce: Option<&str>,
81    jwks_url: &str,
82) -> Option<VerifiedGoogleClaims> {
83    let (signed, signature) = token.rsplit_once('.')?;
84    let (protected, payload) = signed.split_once('.')?;
85    let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(protected).ok()?).ok()?;
86    if header.get("alg").and_then(Value::as_str) != Some("RS256") {
87        return None;
88    }
89    if let Some(critical) = header.get("crit") {
90        let extensions = critical.as_array()?;
91        if extensions.is_empty()
92            || extensions.iter().any(|value| value.as_str() != Some("b64"))
93            || header.get("b64").and_then(Value::as_bool) != Some(true)
94        {
95            return None;
96        }
97    }
98    let kid = header
99        .get("kid")
100        .filter(|value| json_body::is_truthy(value));
101    let keys: Value = reqwest::Client::new()
102        .get(jwks_url)
103        .send()
104        .await
105        .ok()?
106        .error_for_status()
107        .ok()?
108        .json()
109        .await
110        .ok()?;
111    let keys = keys.get("keys")?.as_array()?;
112    let keys = keys
113        .iter()
114        .filter(|key| kid.is_none_or(|kid| key.get("kid") == Some(kid)))
115        .map(import_google_key)
116        .collect::<Option<Vec<_>>>()?;
117    for (key, modulus_bits) in keys {
118        if modulus_bits < 2048 {
119            continue;
120        }
121        if crypto::verify(signature, signed.as_bytes(), &key, Algorithm::RS256).ok() != Some(true) {
122            continue;
123        }
124        let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
125        return valid_claims(&claims, audience, nonce).then_some(VerifiedGoogleClaims(claims));
126    }
127    None
128}
129
130fn import_google_key(key: &Value) -> Option<(DecodingKey, usize)> {
131    if key.get("kty").and_then(Value::as_str) != Some("RSA")
132        || key.get("ext").is_some_and(|value| !value.is_boolean())
133        || key.get("d").is_some()
134    {
135        return None;
136    }
137    if let Some(operations) = key.get("key_ops") {
138        let operations = operations.as_array()?;
139        if operations.len() != 1 || operations.first().and_then(Value::as_str) != Some("verify") {
140            return None;
141        }
142    }
143    // Google's importJWK(jwk, "RS256") ignores alg/use, but WebCrypto enforces key_ops and ext.
144    let modulus = key.get("n")?.as_str()?;
145    let exponent = key.get("e")?.as_str()?;
146    let bytes = URL_SAFE_NO_PAD.decode(modulus).ok()?;
147    let (first, byte) = bytes.iter().enumerate().find(|(_, byte)| **byte != 0)?;
148    let modulus_bits = (bytes.len() - first) * 8 - byte.leading_zeros() as usize;
149    Some((
150        DecodingKey::from_rsa_components(modulus, exponent).ok()?,
151        modulus_bits,
152    ))
153}
154
155// jose accepts fractional NumericDates; jsonwebtoken's JWT decoder rounds these to integers.
156fn valid_claims(claims: &Value, audience: &[String], nonce: Option<&str>) -> bool {
157    if !matches!(
158        claims.get("iss").and_then(Value::as_str),
159        Some("https://accounts.google.com" | "accounts.google.com")
160    ) {
161        return false;
162    }
163    let audience_matches = match claims.get("aud") {
164        Some(Value::String(value)) => audience.contains(value),
165        Some(Value::Array(values)) => values
166            .iter()
167            .filter_map(Value::as_str)
168            .any(|value| audience.iter().any(|expected| expected == value)),
169        _ => false,
170    };
171    if nonce
172        .filter(|nonce| !nonce.is_empty())
173        .is_some_and(|nonce| claims.get("nonce").and_then(Value::as_str) != Some(nonce))
174    {
175        return false;
176    }
177    if !audience_matches {
178        return false;
179    }
180    let now = chrono::Utc::now().timestamp() as f64;
181    let Some(issued) = claims.get("iat").and_then(Value::as_f64) else {
182        return false;
183    };
184    issued <= now
185        && now - issued <= 3600.0
186        && claims
187            .get("exp")
188            .is_none_or(|value| value.as_f64().is_some_and(|expiry| expiry > now))
189        && claims
190            .get("nbf")
191            .is_none_or(|value| value.as_f64().is_some_and(|start| start <= now))
192}