Skip to content

Commit 627a91d

Browse files
add user email to google mcp
Signed-off-by: Michael Clifford <[email protected]>
1 parent 6b33b78 commit 627a91d

File tree

6 files changed

+164
-12
lines changed

6 files changed

+164
-12
lines changed

components/backend/handlers/oauth.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,18 @@ func HandleOAuth2Callback(c *gin.Context) {
298298
callbackData.ExpiresIn = tokenData.ExpiresIn
299299
callbackData.TokenType = tokenData.TokenType
300300

301+
// Fetch user email from provider (if supported)
302+
userEmail := ""
303+
if provider == "google" {
304+
email, err := fetchGoogleUserEmail(c.Request.Context(), tokenData.AccessToken)
305+
if err != nil {
306+
log.Printf("Warning: Failed to fetch user email from Google: %v", err)
307+
} else {
308+
userEmail = email
309+
log.Printf("Fetched user email from Google OAuth: %s", userEmail)
310+
}
311+
}
312+
301313
// Parse and validate session context from signed state parameter
302314
stateData, err := validateAndParseOAuthState(state)
303315
if err != nil {
@@ -319,6 +331,7 @@ func HandleOAuth2Callback(c *gin.Context) {
319331
tokenData.AccessToken,
320332
tokenData.RefreshToken,
321333
tokenData.ExpiresIn,
334+
userEmail,
322335
)
323336
if err != nil {
324337
log.Printf("Failed to store credentials in Secret: %v", err)
@@ -397,6 +410,45 @@ func exchangeOAuthCode(ctx context.Context, provider *OAuthProvider, code string
397410
return &tokenResp, nil
398411
}
399412

413+
// GoogleUserInfo represents the minimal user info response from Google (email only)
414+
type GoogleUserInfo struct {
415+
Email string `json:"email"`
416+
VerifiedEmail bool `json:"verified_email"`
417+
}
418+
419+
// fetchGoogleUserEmail fetches the user's email from Google's userinfo endpoint
420+
func fetchGoogleUserEmail(ctx context.Context, accessToken string) (string, error) {
421+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil)
422+
if err != nil {
423+
return "", fmt.Errorf("failed to create request: %w", err)
424+
}
425+
426+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
427+
428+
client := &http.Client{Timeout: 10 * time.Second}
429+
resp, err := client.Do(req)
430+
if err != nil {
431+
return "", fmt.Errorf("failed to fetch user info: %w", err)
432+
}
433+
defer resp.Body.Close()
434+
435+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
436+
body, _ := io.ReadAll(resp.Body)
437+
return "", fmt.Errorf("userinfo request failed with status %d: %s", resp.StatusCode, string(body))
438+
}
439+
440+
var userInfo GoogleUserInfo
441+
if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil {
442+
return "", fmt.Errorf("failed to decode user info: %w", err)
443+
}
444+
445+
if userInfo.Email == "" {
446+
return "", fmt.Errorf("no email in user info response")
447+
}
448+
449+
return userInfo.Email, nil
450+
}
451+
400452
// storeOAuthCallback stores OAuth callback data in a Secret for retrieval by MCP or other consumers
401453
func storeOAuthCallback(ctx context.Context, state string, data *OAuthCallbackData) error {
402454
if state == "" {
@@ -662,7 +714,7 @@ func validateAndParseOAuthState(state string) (*OAuthStateData, error) {
662714
// Secret name: {sessionName}-{provider}-oauth (e.g., agentic-session-123-google-oauth)
663715
// This allows the session pod to mount or read the credentials from its own namespace
664716
// The Secret is owned by the AgenticSession CR, so it's automatically deleted when the session is deleted
665-
func storeCredentialsInSecret(ctx context.Context, projectName, sessionName, provider, accessToken, refreshToken string, expiresIn int64) error {
717+
func storeCredentialsInSecret(ctx context.Context, projectName, sessionName, provider, accessToken, refreshToken string, expiresIn int64, userEmail string) error {
666718
secretName := fmt.Sprintf("%s-%s-oauth", sessionName, provider)
667719

668720
// Get OAuth provider config for client_id and client_secret
@@ -733,6 +785,11 @@ func storeCredentialsInSecret(ctx context.Context, projectName, sessionName, pro
733785
},
734786
}
735787

788+
// Add user email to Secret data if available
789+
if userEmail != "" {
790+
secret.Data["user_email"] = []byte(userEmail)
791+
}
792+
736793
// Try to create the Secret
737794
_, err = K8sClient.CoreV1().Secrets(projectName).Create(ctx, secret, v1.CreateOptions{})
738795
if err != nil {
@@ -742,12 +799,12 @@ func storeCredentialsInSecret(ctx context.Context, projectName, sessionName, pro
742799
if err != nil {
743800
return fmt.Errorf("failed to update Secret %s/%s: %w", projectName, secretName, err)
744801
}
745-
log.Printf("✓ Updated OAuth credentials Secret %s/%s", projectName, secretName)
802+
log.Printf("✓ Updated OAuth credentials Secret %s/%s (email: %s)", projectName, secretName, userEmail)
746803
} else {
747804
return fmt.Errorf("failed to create Secret %s/%s: %w", projectName, secretName, err)
748805
}
749806
} else {
750-
log.Printf("✓ Created OAuth credentials Secret %s/%s", projectName, secretName)
807+
log.Printf("✓ Created OAuth credentials Secret %s/%s (email: %s)", projectName, secretName, userEmail)
751808
}
752809

753810
return nil

components/backend/handlers/sessions.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,19 @@ func CreateSession(c *gin.Context) {
663663
displayName = s
664664
}
665665
}
666+
667+
// Extract email from authenticated user
668+
email := ""
669+
if v, ok := c.Get("userEmail"); ok {
670+
if s, ok2 := v.(string); ok2 {
671+
email = strings.TrimSpace(s)
672+
}
673+
}
674+
// Fallback to userID if no explicit email (userID is often the email)
675+
if email == "" {
676+
email = uid
677+
}
678+
666679
groups := []string{}
667680
if v, ok := c.Get("userGroups"); ok {
668681
if gg, ok2 := v.([]string); ok2 {
@@ -679,6 +692,7 @@ func CreateSession(c *gin.Context) {
679692
session["spec"].(map[string]interface{})["userContext"] = map[string]interface{}{
680693
"userId": uid,
681694
"displayName": displayName,
695+
"email": email,
682696
"groups": groups,
683697
}
684698
}

components/backend/types/common.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type GitRepository struct {
1212
type UserContext struct {
1313
UserID string `json:"userId" binding:"required"`
1414
DisplayName string `json:"displayName" binding:"required"`
15+
Email string `json:"email,omitempty"`
1516
Groups []string `json:"groups" binding:"required"`
1617
}
1718

components/operator/internal/handlers/sessions.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -982,15 +982,19 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
982982
// Extract userContext for observability and auditing
983983
userID := ""
984984
userName := ""
985+
userEmail := ""
985986
if userContext, found, _ := unstructured.NestedMap(spec, "userContext"); found {
986987
if v, ok := userContext["userId"].(string); ok {
987988
userID = strings.TrimSpace(v)
988989
}
989990
if v, ok := userContext["displayName"].(string); ok {
990991
userName = strings.TrimSpace(v)
991992
}
993+
if v, ok := userContext["email"].(string); ok {
994+
userEmail = strings.TrimSpace(v)
995+
}
992996
}
993-
log.Printf("Session %s initiated by user: %s (userId: %s)", name, userName, userID)
997+
log.Printf("Session %s initiated by user: %s (userId: %s, email: %s)", name, userName, userID, userEmail)
994998

995999
// Create the Job
9961000
job := &batchv1.Job{
@@ -1108,6 +1112,29 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
11081112
},
11091113

11101114
Env: func() []corev1.EnvVar {
1115+
// Determine Google email priority:
1116+
// 1. From OAuth secret (if user completed OAuth flow)
1117+
// 2. From session userContext (if provided at creation)
1118+
// 3. Default to [email protected]
1119+
googleEmail := "[email protected]"
1120+
1121+
// Try to read email from OAuth secret first
1122+
googleOAuthSecretName := fmt.Sprintf("%s-google-oauth", name)
1123+
if oauthSecret, err := config.K8sClient.CoreV1().Secrets(sessionNamespace).Get(context.TODO(), googleOAuthSecretName, v1.GetOptions{}); err == nil {
1124+
if oauthSecret.Data != nil {
1125+
if emailBytes, ok := oauthSecret.Data["user_email"]; ok && len(emailBytes) > 0 {
1126+
googleEmail = string(emailBytes)
1127+
log.Printf("Using Google email from OAuth secret for session %s: %s", name, googleEmail)
1128+
}
1129+
}
1130+
}
1131+
1132+
// Fallback to session userContext email if OAuth email not available
1133+
if googleEmail == "[email protected]" && userEmail != "" {
1134+
googleEmail = userEmail
1135+
log.Printf("Using Google email from session userContext for session %s: %s", name, googleEmail)
1136+
}
1137+
11111138
base := []corev1.EnvVar{
11121139
{Name: "DEBUG", Value: "true"},
11131140
{Name: "INTERACTIVE", Value: fmt.Sprintf("%t", interactive)},
@@ -1122,6 +1149,8 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
11221149
// Google OAuth client credentials for workspace-mcp
11231150
{Name: "GOOGLE_OAUTH_CLIENT_ID", Value: os.Getenv("GOOGLE_OAUTH_CLIENT_ID")},
11241151
{Name: "GOOGLE_OAUTH_CLIENT_SECRET", Value: os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET")},
1152+
// User email for Google Workspace MCP tools (defaults to [email protected])
1153+
{Name: "USER_GOOGLE_EMAIL", Value: googleEmail},
11251154
}
11261155

11271156
// Add user context for observability and auditing (Langfuse userId, logs, etc.)

components/runners/claude-code-runner/.mcp.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"GOOGLE_MCP_CREDENTIALS_DIR": "${GOOGLE_MCP_CREDENTIALS_DIR}",
2323
"MCP_SINGLE_USER_MODE": "1",
2424
"GOOGLE_OAUTH_CLIENT_ID": "${GOOGLE_OAUTH_CLIENT_ID}",
25-
"GOOGLE_OAUTH_CLIENT_SECRET": "${GOOGLE_OAUTH_CLIENT_SECRET}"
25+
"GOOGLE_OAUTH_CLIENT_SECRET": "${GOOGLE_OAUTH_CLIENT_SECRET}",
26+
"USER_GOOGLE_EMAIL": "${USER_GOOGLE_EMAIL}"
2627
}
2728
}
2829
}

components/runners/claude-code-runner/adapter.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ async def initialize(self, context: RunnerContext):
8484
self.context = context
8585
logger.info(f"Initialized Claude Code adapter for session {context.session_id}")
8686

87+
# Set default USER_GOOGLE_EMAIL if not provided
88+
if not os.getenv("USER_GOOGLE_EMAIL"):
89+
os.environ["USER_GOOGLE_EMAIL"] = "[email protected]"
90+
logger.info("USER_GOOGLE_EMAIL not set, using default: [email protected]")
91+
else:
92+
logger.info(f"USER_GOOGLE_EMAIL set to: {os.getenv('USER_GOOGLE_EMAIL')}")
93+
8794
# Copy Google OAuth credentials from mounted Secret to writable workspace location
8895
await self._setup_google_credentials()
8996

@@ -1520,19 +1527,21 @@ async def _try_copy_google_credentials(self) -> bool:
15201527

15211528
async def refresh_google_credentials(self) -> bool:
15221529
"""Check for and copy new Google OAuth credentials.
1523-
1530+
15241531
Call this method periodically (e.g., before processing a message) to detect
15251532
when a user completes the OAuth flow and credentials become available.
1526-
1533+
15271534
Kubernetes automatically updates the mounted secret volume when the secret
15281535
changes (typically within ~60 seconds), so this will pick up new credentials
15291536
without requiring a pod restart.
1530-
1537+
1538+
Also updates USER_GOOGLE_EMAIL environment variable from credentials if available.
1539+
15311540
Returns:
15321541
True if new credentials were found and copied, False otherwise.
15331542
"""
15341543
dest_path = Path("/workspace/.google_workspace_mcp/credentials/credentials.json")
1535-
1544+
15361545
# If we already have credentials in workspace, check if source is newer
15371546
if dest_path.exists():
15381547
secret_path = Path("/app/.google_workspace_mcp/credentials/credentials.json")
@@ -1541,13 +1550,54 @@ async def refresh_google_credentials(self) -> bool:
15411550
# Compare modification times - secret mount updates when K8s syncs
15421551
if secret_path.stat().st_mtime > dest_path.stat().st_mtime:
15431552
logging.info("Detected updated Google OAuth credentials, refreshing...")
1544-
return await self._try_copy_google_credentials()
1553+
if await self._try_copy_google_credentials():
1554+
# Update USER_GOOGLE_EMAIL from the new credentials
1555+
self._update_email_from_credentials(dest_path)
1556+
return True
15451557
except OSError:
15461558
pass
1559+
# Always try to update email even if file didn't change (in case env var is still default)
1560+
self._update_email_from_credentials(dest_path)
15471561
return False
1548-
1562+
15491563
# No credentials yet, try to copy
15501564
if await self._try_copy_google_credentials():
15511565
logging.info("✓ Google OAuth credentials now available (user completed authentication)")
1566+
# Update USER_GOOGLE_EMAIL from the new credentials
1567+
self._update_email_from_credentials(dest_path)
15521568
return True
1553-
return False
1569+
return False
1570+
1571+
def _update_email_from_credentials(self, creds_path: Path):
1572+
"""Read user email from credentials file and update USER_GOOGLE_EMAIL env var.
1573+
1574+
The email is extracted from the OAuth credentials stored by the backend.
1575+
"""
1576+
try:
1577+
# First check if there's a user_email file in the secret mount
1578+
secret_email_path = Path("/app/.google_workspace_mcp/credentials/user_email")
1579+
if secret_email_path.exists():
1580+
email = secret_email_path.read_text().strip()
1581+
if email and email != "[email protected]":
1582+
current_email = os.getenv("USER_GOOGLE_EMAIL", "")
1583+
if current_email != email:
1584+
os.environ["USER_GOOGLE_EMAIL"] = email
1585+
logging.info(f"Updated USER_GOOGLE_EMAIL from secret: {email}")
1586+
return
1587+
1588+
# Fallback: try to parse email from credentials.json (some OAuth providers include it)
1589+
if creds_path.exists() and creds_path.stat().st_size > 0:
1590+
try:
1591+
with open(creds_path, 'r') as f:
1592+
creds = _json.load(f)
1593+
# Some OAuth responses include email in the credentials
1594+
email = creds.get('email') or creds.get('user_email')
1595+
if email and email != "[email protected]":
1596+
current_email = os.getenv("USER_GOOGLE_EMAIL", "")
1597+
if current_email != email:
1598+
os.environ["USER_GOOGLE_EMAIL"] = email
1599+
logging.info(f"Updated USER_GOOGLE_EMAIL from credentials.json: {email}")
1600+
except (_json.JSONDecodeError, KeyError):
1601+
pass
1602+
except Exception as e:
1603+
logging.debug(f"Could not update USER_GOOGLE_EMAIL from credentials: {e}")

0 commit comments

Comments
 (0)