fraiseql_auth/providers/
google.rs1use async_trait::async_trait;
3use serde::Deserialize;
4
5use crate::{
6 error::Result,
7 oidc_provider::OidcProvider,
8 provider::{OAuthProvider, TokenResponse, UserInfo},
9};
10
11#[derive(Debug)]
15pub struct GoogleOAuth {
16 oidc: OidcProvider,
17}
18
19#[derive(Debug, Clone, Deserialize)]
21pub struct GoogleUser {
22 pub sub: String,
24 pub email: String,
26 pub email_verified: bool,
28 pub name: Option<String>,
30 pub picture: Option<String>,
32 pub locale: Option<String>,
34}
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct GoogleWorkspaceGroup {
39 pub id: String,
41 pub email: String,
43 pub name: Option<String>,
45 pub description: Option<String>,
47}
48
49impl GoogleOAuth {
50 pub async fn new(
61 client_id: String,
62 client_secret: String,
63 redirect_uri: String,
64 ) -> Result<Self> {
65 let oidc = OidcProvider::new(
66 "google",
67 "https://accounts.google.com",
68 &client_id,
69 &client_secret,
70 &redirect_uri,
71 )
72 .await?;
73
74 Ok(Self { oidc })
75 }
76
77 pub fn map_groups_to_roles(groups: Vec<String>) -> Vec<String> {
85 groups
86 .into_iter()
87 .filter_map(|group| {
88 let group_lower = group.to_lowercase();
89
90 if group_lower.contains("fraiseql-admin")
92 || group_lower.contains("fraiseql-admins")
93 || group_lower.contains("-admin@")
94 || group_lower.contains("-admins@")
95 {
96 return Some("admin".to_string());
97 }
98
99 if group_lower.contains("fraiseql-operator")
101 || group_lower.contains("fraiseql-operators")
102 || group_lower.contains("-operator@")
103 || group_lower.contains("-operators@")
104 {
105 return Some("operator".to_string());
106 }
107
108 if group_lower.contains("fraiseql-viewer")
110 || group_lower.contains("fraiseql-viewers")
111 || group_lower.contains("-viewer@")
112 || group_lower.contains("-viewers@")
113 {
114 return Some("viewer".to_string());
115 }
116
117 None
118 })
119 .collect()
120 }
121
122 pub fn extract_roles_from_domain(email: &str) -> Vec<String> {
126 if email.ends_with("@company.com") {
129 vec!["operator".to_string()]
131 } else {
132 vec!["viewer".to_string()]
133 }
134 }
135}
136
137#[async_trait]
141impl OAuthProvider for GoogleOAuth {
142 fn name(&self) -> &'static str {
143 "google"
144 }
145
146 fn authorization_url(&self, state: &str) -> String {
147 self.oidc.authorization_url(state)
150 }
151
152 async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
153 self.oidc.exchange_code(code).await
154 }
155
156 async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
157 let mut user_info = self.oidc.user_info(access_token).await?;
159
160 let default_roles = Self::extract_roles_from_domain(&user_info.email);
162 user_info.raw_claims["google_default_roles"] = serde_json::json!(default_roles);
163
164 let org_id = user_info
166 .email
167 .split('@')
168 .nth(1)
169 .and_then(|domain| domain.split('.').next())
170 .map(|domain_part| domain_part.to_string());
171
172 if let Some(org_id) = org_id {
173 user_info.raw_claims["org_id"] = serde_json::json!(&org_id);
174 }
175
176 user_info.raw_claims["google_email"] = serde_json::json!(&user_info.email);
184 user_info.raw_claims["google_workspace_available"] =
185 serde_json::json!("Configure Directory API scopes for group sync");
186
187 Ok(user_info)
188 }
189
190 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
191 self.oidc.refresh_token(refresh_token).await
192 }
193
194 async fn revoke_token(&self, token: &str) -> Result<()> {
195 self.oidc.revoke_token(token).await
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 #[allow(clippy::wildcard_imports)]
202 use super::*;
204
205 #[test]
206 fn test_map_google_workspace_groups_to_roles() {
207 let groups = vec![
208 "fraiseql-admins@company.com".to_string(),
209 "fraiseql-operators@company.com".to_string(),
210 "other-group@company.com".to_string(),
211 "fraiseql-viewer@company.com".to_string(),
212 ];
213
214 let roles = GoogleOAuth::map_groups_to_roles(groups);
215
216 assert_eq!(roles.len(), 3);
217 assert!(roles.contains(&"admin".to_string()));
218 assert!(roles.contains(&"operator".to_string()));
219 assert!(roles.contains(&"viewer".to_string()));
220 }
221
222 #[test]
223 fn test_map_groups_case_insensitive() {
224 let groups = vec![
225 "FRAISEQL-ADMINS@COMPANY.COM".to_string(),
226 "FraiseQL-Operators@Company.Com".to_string(),
227 ];
228
229 let roles = GoogleOAuth::map_groups_to_roles(groups);
230
231 assert_eq!(roles.len(), 2);
232 assert!(roles.contains(&"admin".to_string()));
233 assert!(roles.contains(&"operator".to_string()));
234 }
235
236 #[test]
237 fn test_extract_roles_from_domain_company() {
238 let roles = GoogleOAuth::extract_roles_from_domain("user@company.com");
239 assert_eq!(roles, vec!["operator".to_string()]);
240 }
241
242 #[test]
243 fn test_extract_roles_from_domain_external() {
244 let roles = GoogleOAuth::extract_roles_from_domain("user@external.com");
245 assert_eq!(roles, vec!["viewer".to_string()]);
246 }
247
248 #[test]
249 fn test_map_groups_empty() {
250 let roles = GoogleOAuth::map_groups_to_roles(vec![]);
251 assert!(roles.is_empty());
252 }
253}