Skip to main content

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    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                // Check common admin group names
91                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                // Check operator group names
100                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                // Check viewer group names
109                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    /// Check if user belongs to a specific group
123    ///
124    /// Simple email-based check without Directory API (for basic use cases)
125    pub fn extract_roles_from_domain(email: &str) -> Vec<String> {
126        // Default roles based on email domain
127        // This is a fallback when Directory API is not available
128        if email.ends_with("@company.com") {
129            // Company employees get operator role by default
130            vec!["operator".to_string()]
131        } else {
132            vec!["viewer".to_string()]
133        }
134    }
135}
136
137// Reason: OAuthProvider is defined with #[async_trait]; all implementations must match
138// its transformed method signatures to satisfy the trait contract
139// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
140#[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        // Add additional scopes for Workspace directory access if needed
148        // Note: This requires configuration of the authorization URL with scopes
149        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        // Get user info from OIDC
158        let mut user_info = self.oidc.user_info(access_token).await?;
159
160        // Extract domain-based roles as fallback
161        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        // Extract org_id from email domain
165        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        // Note: To get Workspace groups, you would need to:
177        // 1. Request additional scopes: https://www.googleapis.com/auth/admin.directory.group.readonly
178        // 2. Use Directory API: GET https://www.googleapis.com/admin/directory/v1/groups?userKey={email}
179        // This requires admin consent and service account setup, so it's not included in basic
180        // setup
181        //
182        // For now, we store the email for later group lookup
183        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    // Reason: test module — wildcard keeps test boilerplate minimal
203    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}