better_auth_api/plugins/oauth/
google.rs1use 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#[derive(Clone, Debug, Default)]
13pub struct GoogleOptions {
14 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 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 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
155fn 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}