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