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    #[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}