modo/auth/oauth/
google.rs1use axum_extra::extract::cookie::Key;
2
3use crate::cookie::CookieConfig;
4
5use super::{
6 client,
7 config::{CallbackParams, OAuthProviderConfig},
8 profile::UserProfile,
9 provider::OAuthProvider,
10 state::{AuthorizationRequest, OAuthState, build_oauth_cookie, pkce_challenge},
11};
12
13const AUTHORIZE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
14const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
15const USERINFO_URL: &str = "https://www.googleapis.com/oauth2/v2/userinfo";
16const DEFAULT_SCOPES: &[&str] = &["openid", "email", "profile"];
17
18pub struct Google {
24 config: OAuthProviderConfig,
25 cookie_config: CookieConfig,
26 key: Key,
27 http_client: reqwest::Client,
28}
29
30impl Google {
31 pub fn new(
37 config: &OAuthProviderConfig,
38 cookie_config: &CookieConfig,
39 key: &Key,
40 http_client: reqwest::Client,
41 ) -> Self {
42 Self {
43 config: config.clone(),
44 cookie_config: cookie_config.clone(),
45 key: key.clone(),
46 http_client,
47 }
48 }
49
50 fn scopes(&self) -> String {
51 if self.config.scopes.is_empty() {
52 DEFAULT_SCOPES.join(" ")
53 } else {
54 self.config.scopes.join(" ")
55 }
56 }
57}
58
59impl OAuthProvider for Google {
60 fn name(&self) -> &str {
61 "google"
62 }
63
64 fn authorize_url(&self) -> crate::Result<AuthorizationRequest> {
65 let (set_cookie_header, state_nonce, pkce_verifier) =
66 build_oauth_cookie("google", &self.key, &self.cookie_config);
67
68 let challenge = pkce_challenge(&pkce_verifier);
69
70 let redirect_url = format!(
71 "{AUTHORIZE_URL}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
72 urlencoding::encode(&self.config.client_id),
73 urlencoding::encode(&self.config.redirect_uri),
74 urlencoding::encode(&self.scopes()),
75 urlencoding::encode(&state_nonce),
76 urlencoding::encode(&challenge),
77 );
78
79 Ok(AuthorizationRequest {
80 redirect_url,
81 set_cookie_header,
82 })
83 }
84
85 async fn exchange(
86 &self,
87 params: &CallbackParams,
88 state: &OAuthState,
89 ) -> crate::Result<UserProfile> {
90 if state.provider() != "google" {
91 return Err(crate::Error::bad_request("OAuth state provider mismatch"));
92 }
93
94 if params.state != state.state_nonce() {
95 return Err(crate::Error::bad_request("OAuth state nonce mismatch"));
96 }
97
98 #[derive(serde::Deserialize)]
99 struct TokenResponse {
100 access_token: String,
101 }
102
103 let token: TokenResponse = client::post_form(
104 &self.http_client,
105 TOKEN_URL,
106 &[
107 ("grant_type", "authorization_code"),
108 ("code", ¶ms.code),
109 ("redirect_uri", &self.config.redirect_uri),
110 ("client_id", &self.config.client_id),
111 ("client_secret", &self.config.client_secret),
112 ("code_verifier", state.pkce_verifier()),
113 ],
114 )
115 .await?;
116
117 let raw: serde_json::Value =
118 client::get_json(&self.http_client, USERINFO_URL, &token.access_token).await?;
119
120 let provider_user_id = raw["id"]
121 .as_str()
122 .ok_or_else(|| crate::Error::internal("google: missing user id"))?
123 .to_string();
124 let email = raw["email"]
125 .as_str()
126 .ok_or_else(|| crate::Error::internal("google: missing email"))?
127 .to_string();
128 let email_verified = raw["verified_email"].as_bool().unwrap_or(false);
129 let name = raw["name"].as_str().map(|s| s.to_string());
130 let avatar_url = raw["picture"].as_str().map(|s| s.to_string());
131
132 Ok(UserProfile {
133 provider: "google".to_string(),
134 provider_user_id,
135 email,
136 email_verified,
137 name,
138 avatar_url,
139 raw,
140 })
141 }
142}
143
144mod urlencoding {
145 pub fn encode(s: &str) -> String {
146 let mut result = String::with_capacity(s.len());
147 for b in s.bytes() {
148 match b {
149 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
150 result.push(b as char);
151 }
152 _ => {
153 result.push_str(&format!("%{b:02X}"));
154 }
155 }
156 }
157 result
158 }
159}