fraiseql_auth/providers/google.rs
1//! Google OAuth / OIDC provider implementation using Google Identity Services.
2use 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/// Google OAuth provider wrapper
12///
13/// Handles Google-specific OAuth flows and Workspace group mapping to FraiseQL roles.
14#[derive(Debug)]
15pub struct GoogleOAuth {
16 oidc: OidcProvider,
17}
18
19/// Google user information
20#[derive(Debug, Clone, Deserialize)]
21pub struct GoogleUser {
22 /// Subject — stable, unique Google account identifier
23 pub sub: String,
24 /// Verified email address associated with the Google account
25 pub email: String,
26 /// Whether Google has verified the email address
27 pub email_verified: bool,
28 /// User's full display name
29 pub name: Option<String>,
30 /// URL of the user's profile picture
31 pub picture: Option<String>,
32 /// User's locale (e.g., `"en"`)
33 pub locale: Option<String>,
34}
35
36/// Google Workspace group
37#[derive(Debug, Clone, Deserialize)]
38pub struct GoogleWorkspaceGroup {
39 /// Stable group ID in the Google Workspace directory
40 pub id: String,
41 /// Group email address (used as the primary identifier for role mapping)
42 pub email: String,
43 /// Human-readable group name
44 pub name: Option<String>,
45 /// Optional group description
46 pub description: Option<String>,
47}
48
49impl GoogleOAuth {
50 /// Create a new Google OAuth provider
51 ///
52 /// # Arguments
53 /// * `client_id` - Google OAuth client ID (from Google Cloud Console)
54 /// * `client_secret` - Google OAuth client secret
55 /// * `redirect_uri` - Redirect URI after authentication (e.g., "http://localhost:8000/auth/callback")
56 ///
57 /// # Errors
58 ///
59 /// Returns `AuthError` if OIDC discovery against Google fails.
60 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 /// Map Google Workspace groups to FraiseQL roles
78 ///
79 /// Maps group emails/names to role names based on naming conventions.
80 /// Example: "fraiseql-admins@company.com" -> "admin"
81 ///
82 /// # Arguments
83 /// * `groups` - List of group email addresses
84 #[must_use]
85 pub fn map_groups_to_roles(groups: Vec<String>) -> Vec<String> {
86 groups
87 .into_iter()
88 .filter_map(|group| {
89 let group_lower = group.to_lowercase();
90
91 // Check common admin group names
92 if group_lower.contains("fraiseql-admin")
93 || group_lower.contains("fraiseql-admins")
94 || group_lower.contains("-admin@")
95 || group_lower.contains("-admins@")
96 {
97 return Some("admin".to_string());
98 }
99
100 // Check operator group names
101 if group_lower.contains("fraiseql-operator")
102 || group_lower.contains("fraiseql-operators")
103 || group_lower.contains("-operator@")
104 || group_lower.contains("-operators@")
105 {
106 return Some("operator".to_string());
107 }
108
109 // Check viewer group names
110 if group_lower.contains("fraiseql-viewer")
111 || group_lower.contains("fraiseql-viewers")
112 || group_lower.contains("-viewer@")
113 || group_lower.contains("-viewers@")
114 {
115 return Some("viewer".to_string());
116 }
117
118 None
119 })
120 .collect()
121 }
122
123 /// Check if user belongs to a specific group
124 ///
125 /// Simple email-based check without Directory API (for basic use cases)
126 #[must_use]
127 pub fn extract_roles_from_domain(email: &str) -> Vec<String> {
128 // Default roles based on email domain
129 // This is a fallback when Directory API is not available
130 if email.ends_with("@company.com") {
131 // Company employees get operator role by default
132 vec!["operator".to_string()]
133 } else {
134 vec!["viewer".to_string()]
135 }
136 }
137}
138
139// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
140// its transformed method signatures to satisfy the trait contract
141// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
142#[async_trait]
143impl OAuthProvider for GoogleOAuth {
144 fn name(&self) -> &'static str {
145 "google"
146 }
147
148 fn authorization_url(&self, state: &str) -> String {
149 // Add additional scopes for Workspace directory access if needed
150 // Note: This requires configuration of the authorization URL with scopes
151 self.oidc.authorization_url(state)
152 }
153
154 async fn exchange_code(&self, code: &str) -> Result<TokenResponse> {
155 self.oidc.exchange_code(code).await
156 }
157
158 async fn user_info(&self, access_token: &str) -> Result<UserInfo> {
159 // Get user info from OIDC
160 let mut user_info = self.oidc.user_info(access_token).await?;
161
162 // Extract domain-based roles as fallback
163 let default_roles = Self::extract_roles_from_domain(&user_info.email);
164 user_info.raw_claims["google_default_roles"] = serde_json::json!(default_roles);
165
166 // Extract org_id from email domain
167 let org_id = user_info
168 .email
169 .split('@')
170 .nth(1)
171 .and_then(|domain| domain.split('.').next())
172 .map(|domain_part| domain_part.to_string());
173
174 if let Some(org_id) = org_id {
175 user_info.raw_claims["org_id"] = serde_json::json!(&org_id);
176 }
177
178 // Note: To get Workspace groups, you would need to:
179 // 1. Request additional scopes: https://www.googleapis.com/auth/admin.directory.group.readonly
180 // 2. Use Directory API: GET https://www.googleapis.com/admin/directory/v1/groups?userKey={email}
181 // This requires admin consent and service account setup, so it's not included in basic
182 // setup
183 //
184 // For now, we store the email for later group lookup
185 user_info.raw_claims["google_email"] = serde_json::json!(&user_info.email);
186 user_info.raw_claims["google_workspace_available"] =
187 serde_json::json!("Configure Directory API scopes for group sync");
188
189 Ok(user_info)
190 }
191
192 async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
193 self.oidc.refresh_token(refresh_token).await
194 }
195
196 async fn revoke_token(&self, token: &str) -> Result<()> {
197 self.oidc.revoke_token(token).await
198 }
199}