Skip to content

fix(runner): write Google credentials as {email}.json with timezone-naive expiry#1557

Merged
markturansky merged 1 commit into
mainfrom
fix/google-oauth-credential-format
May 11, 2026
Merged

fix(runner): write Google credentials as {email}.json with timezone-naive expiry#1557
markturansky merged 1 commit into
mainfrom
fix/google-oauth-credential-format

Conversation

@markturansky
Copy link
Copy Markdown
Contributor

@markturansky markturansky commented May 11, 2026

Summary

  • Strip trailing Z from expiresAt before writing to credential file — workspace-mcp's datetime.fromisoformat() fails on Python 3.10 with the Z suffix, causing it to treat the expiry as None and the token as invalid
  • Write credential file as {email}.json instead of credentials.json — workspace-mcp expects this naming convention for proper user identification in single-user mode
  • Scan credentials directory for any .json file in check_mcp_authentication instead of hardcoding credentials.json

Fixes the UAT Google OAuth failure where workspace-mcp rejected valid pre-fetched credentials and fell back to its own inaccessible OAuth flow (PKCE mismatch with backend callback).

Root Cause

Two issues caused workspace-mcp to reject valid pre-fetched Google OAuth credentials:

  1. Expiry format: Backend returns expiresAt in RFC3339 (2026-05-11T19:58:05Z), but workspace-mcp parses it via datetime.fromisoformat() which fails on Python 3.10 with the trailing Z. workspace-mcp then falls back to its own OAuth flow.

  2. Credential filename: Runner wrote credentials.json, but workspace-mcp's list_users() expects {email}.json. In single-user mode it finds and loads any .json file, but the user identifier becomes credentials instead of the actual email.

When workspace-mcp falls back to its own OAuth flow, it generates PKCE code_challenge + hex state, but the backend's /oauth2callback doesn't have the matching code_verifier, causing Google to return "Missing code verifier".

Test plan

  • All 681 runner tests pass (0 failures)
  • test_google_oauth_access_token_format verifies email-based filename and Z-stripped expiry
  • test_mcp_auth.py Google workspace auth tests updated for directory scanning
  • Deploy to UAT and verify Google Drive works in a new session

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Enhanced Google Workspace MCP authentication to support multiple credential configurations and per-user OAuth token storage.
    • Added support for reading credentials from directory-based structures with fallback options.
  • Bug Fixes

    • Improved credential file handling and token expiration management with better error logging.
  • Tests

    • Refactored authentication tests to use real temporary credential directories for more accurate validation.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

Refactored Google Workspace MCP credential handling from single hardcoded credentials.json files to multi-file directory scanning. Credentials are now written per-user (OAuth) or as service account JSON to a configurable directory, then loaded by scanning sorted *.json files and selecting the first valid candidate.

Changes

Google Workspace Credential Storage and Loading

Layer / File(s) Summary
Credential Path Constants
ambient_runner/bridges/claude/mcp.py, ambient_runner/platform/auth.py
Add _WORKSPACE_CREDS_DIR, _SECRET_CREDS_DIR (mcp.py) and _GOOGLE_WORKSPACE_CREDS_DIR, _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE (auth.py) to define credential search and fallback locations.
Credential Writing Logic
ambient_runner/platform/auth.py (lines 427–462)
For OAuth tokens, write per-user JSON files ({user_email}.json) to the workspace credentials directory with trimmed expiresAt, parsed expiry, and 0600 permissions. For service accounts, write to legacy credentials file. Clean up stale credentials.json when switching to per-user filenames.
Credential Loading Helper
ambient_runner/bridges/claude/mcp.py (lines 175–184)
Introduce _load_credential_file(path) to safely read JSON credential files, skip missing/empty files, and log warnings on parse failures.
Credential Selection
ambient_runner/bridges/claude/mcp.py (lines 229–239)
In check_mcp_authentication("google-workspace"), scan sorted *.json files in both credential directories and use the first valid credential instead of reading a single credentials.json.
Authentication Tests
tests/test_mcp_auth.py
Update test fixture setup to monkeypatch credential directories to tmp_path, write real credential JSON files, and assert on (is_auth, msg) outcomes for missing, valid, placeholder-email, and expired-with-refresh cases.
Credential Preservation Tests
tests/test_shared_session_credentials.py
Align test assertions to patch _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE constant, write to temporary credential directories, and verify per-email JSON file creation with token, refresh_token, and expiry fields.

Sequence Diagram

sequenceDiagram
    participant User as Runtime/User
    participant Writer as populate_runtime_credentials<br/>(auth.py)
    participant FS as Credential Storage<br/>(Directories/Files)
    participant Reader as check_mcp_authentication<br/>(mcp.py)
    participant Validator as Credential Validation

    User->>Writer: Provide OAuth token or service account
    Writer->>FS: Write OAuth: {email}.json with access/refresh/expiry
    Writer->>FS: Write ServiceAccount: legacy credentials.json
    Writer->>FS: Clean stale credentials.json if switching to per-user
    
    Reader->>FS: Scan *.json files in creds directories (sorted)
    FS-->>Reader: List of credential files
    Reader->>Reader: Iterate files, load each via _load_credential_file()
    Reader->>Validator: Pass first valid credential for email check
    Validator-->>Reader: Return is_auth, message
    Reader-->>User: Authentication result
Loading

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error, 1 warning)

Check name Status Explanation Resolution
Security And Secret Handling ❌ Error Path traversal: user_email unsanitized in filename. Malicious email like ../../../tmp/evil writes creds outside directory. Non-atomic writes create race condition. Sanitize email or use Path.name. Use atomic writes: temp file + chmod + os.replace() + delete legacy. Add regression test.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Title follows Conventional Commits format with type 'fix' and scope 'runner', accurately describing the main changes: writing Google credentials as email-based JSON files and handling timezone-naive expiry.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Performance And Algorithmic Complexity ✅ Passed No performance regressions. Credential scanning iterates ~1-2 files with early break. File I/O only at startup/refresh. No N+1, unbounded growth, or expensive loops.
Kubernetes Resource Safety ✅ Passed PR contains only Python source and unit tests (mcp.py, auth.py, test files). No Kubernetes resource manifests found. Kubernetes Resource Safety check is not applicable.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/google-oauth-credential-format
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/google-oauth-credential-format

Comment @coderabbitai help to get the list of available commands and usage tips.

@netlify
Copy link
Copy Markdown

netlify Bot commented May 11, 2026

Deploy Preview for cheerful-kitten-f556a0 failed.

Name Link
🔨 Latest commit 5aa1978
🔍 Latest deploy log https://app.netlify.com/projects/cheerful-kitten-f556a0/deploys/6a023a9119d3400008b37eee

@markturansky
Copy link
Copy Markdown
Contributor Author

Review: fix(runner): write Google credentials as {email}.json with timezone-naive expiry

Clean, targeted fix with solid root cause analysis. The three-part fix (expiry format, filename, directory scan) maps directly to the two root causes. Test quality improvement (real filesystem over mocks) is a net positive.

Two minor things worth a look:


🟡 _read_google_credentials(json_file, json_file) — same path passed twice

candidate = _read_google_credentials(json_file, json_file)

_read_google_credentials is designed as a two-location fallback — try workspace_path, fall back to secret_path. Passing the same file for both is functionally correct (we found it via glob, so it exists, so workspace_path always wins), but it misuses the function's interface and will confuse whoever reads this next.

Since we already know the file exists at this point, consider either:

  • A single-path helper: _read_google_credential_file(json_file)
  • Or _read_google_credentials(json_file, Path("/dev/null")) to make the intent explicit

🟢 No log when scanning multiple .json files

The directory scan silently picks the first valid file alphabetically. For single-user mode this is fine, but in practice the credentials dir could accumulate stale files. A one-liner log of which file was chosen would make debugging much easier:

logger.debug("Using Google credentials from %s", json_file)

Z-stripping is consistent with _parse_token_expiry

Worth noting for reviewers: _parse_token_expiry in mcp.py already handles timezone-naive strings — if dt.tzinfo is None it adds UTC. So writing a naive expiry string is safe for our own read path too. The fix targets workspace-mcp's external parser, but there's no regression risk on our side.


✅ Test improvements are solid

Replacing the mock-based tests with real tmp_path + monkeypatch tests means the scanning logic is actually exercised. The assert written["expiry"] == "2099-01-01T00:00:00" assertion directly pins the Z-stripping behavior — good regression guard.

LGTM pending the _read_google_credentials cleanup.

Copy link
Copy Markdown
Contributor

@jsell-rh jsell-rh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observations

  1. _read_google_credentials(json_file, json_file) — both args are the same path now. Works because the workspace/secret fallback moved to the outer loop, but the function will try the same file twice on a read failure. Could pass a single-path helper or skip the redundant retry.

  2. sorted(creds_dir.glob("*.json")) priority — if both credentials.json and user@example.com.json exist, alphabetical sort picks the legacy file first. The write path cleans up the legacy file so this shouldn't happen in practice, but worth noting.

  3. import shutil inside finally (test_shared_session_credentials.py:660) — move to top-of-file imports.

None of these are blocking.


jsell-rh's Ambient Review Bot <jsell+ambient-review-bot@redhat.com>

…aive expiry

workspace-mcp expects credential files named {email}.json (not credentials.json)
and parses expiry via datetime.fromisoformat() which fails with trailing Z on
Python 3.10. This caused workspace-mcp to reject valid pre-fetched credentials
and fall back to its own inaccessible OAuth flow with a PKCE mismatch.

- Strip trailing Z from expiresAt before writing to credential file
- Write credential file as {email}.json instead of credentials.json
- Clean up legacy credentials.json when email-based file is written
- Update check_mcp_authentication to scan credentials directory for any .json file

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@markturansky markturansky force-pushed the fix/google-oauth-credential-format branch from c76dc9e to 5aa1978 Compare May 11, 2026 20:22
@markturansky markturansky marked this pull request as ready for review May 11, 2026 20:23
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

CodeRabbit chat interactions are restricted to organization members for this repository. Ask an organization member to interact with CodeRabbit, or set chat.allow_non_org_members: true in your configuration.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py`:
- Around line 230-239: Prefer the current user's credential file before scanning
the directory: when iterating creds_dir in (_WORKSPACE_CREDS_DIR,
_SECRET_CREDS_DIR) first check for a file matching USER_GOOGLE_EMAIL + ".json"
and attempt to load it via _load_credential_file; if that returns a valid
candidate assign to creds and break out. Only if no USER_GOOGLE_EMAIL.json is
present or valid, fall back to the existing sorted glob("*.json") loop that
loads the first valid candidate. Ensure you reference creds, creds_dir,
_load_credential_file, USER_GOOGLE_EMAIL, _WORKSPACE_CREDS_DIR and
_SECRET_CREDS_DIR so the change is applied in the same block and retains the
existing break logic.

In `@components/runners/ambient-runner/ambient_runner/platform/auth.py`:
- Around line 446-451: The current write to creds_file can leave readers with
partial JSON; update the logic in the block that references
_GOOGLE_WORKSPACE_LEGACY_CREDS_FILE, creds_filename, creds_file and creds_data
to perform an atomic write: create a temporary file in the same directory (e.g.,
creds_file.with_suffix(".tmp" or similar)), write creds_data to that temp file,
fsync if possible, set permissions via chmod(0o600) on the temp file, then
atomically replace the target with os.replace(temp_path, creds_file) and only
after successful replace unlink _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE if needed;
ensure you handle exceptions so a failed write leaves the original file
untouched.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f9a620a0-f065-443a-a2a9-07ab4bbb6f25

📥 Commits

Reviewing files that changed from the base of the PR and between 19840da and 5aa1978.

📒 Files selected for processing (4)
  • components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py
  • components/runners/ambient-runner/ambient_runner/platform/auth.py
  • components/runners/ambient-runner/tests/test_mcp_auth.py
  • components/runners/ambient-runner/tests/test_shared_session_credentials.py

Comment on lines +230 to +239
for creds_dir in (_WORKSPACE_CREDS_DIR, _SECRET_CREDS_DIR):
if creds_dir.is_dir():
for json_file in sorted(creds_dir.glob("*.json")):
candidate = _load_credential_file(json_file)
if candidate is not None:
logger.debug("Using Google credentials from %s", json_file)
creds = candidate
break
if creds is not None:
break
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prefer the current user's credential file before scanning the directory.

This loop picks the first readable *.json alphabetically and ignores USER_GOOGLE_EMAIL. In a reused/shared workspace with multiple {email}.json files, /mcp/status can validate the wrong user's token and return misleading auth state for the active user. Prefer <USER_GOOGLE_EMAIL>.json first, then fall back to the directory scan only for legacy recovery. A regression test with two credential files would lock this down.

Proposed direction
     if server_name == "google-workspace":
         creds = None
+        configured_user_email = os.environ.get("USER_GOOGLE_EMAIL", "").strip()
         for creds_dir in (_WORKSPACE_CREDS_DIR, _SECRET_CREDS_DIR):
             if creds_dir.is_dir():
+                preferred_file = (
+                    creds_dir / f"{configured_user_email}.json"
+                    if configured_user_email
+                    and configured_user_email != "user@example.com"
+                    else None
+                )
+                if preferred_file is not None:
+                    candidate = _load_credential_file(preferred_file)
+                    if candidate is not None:
+                        logger.debug("Using Google credentials from %s", preferred_file)
+                        creds = candidate
+                        break
                 for json_file in sorted(creds_dir.glob("*.json")):
+                    if preferred_file is not None and json_file == preferred_file:
+                        continue
                     candidate = _load_credential_file(json_file)
                     if candidate is not None:
                         logger.debug("Using Google credentials from %s", json_file)
                         creds = candidate
                         break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for creds_dir in (_WORKSPACE_CREDS_DIR, _SECRET_CREDS_DIR):
if creds_dir.is_dir():
for json_file in sorted(creds_dir.glob("*.json")):
candidate = _load_credential_file(json_file)
if candidate is not None:
logger.debug("Using Google credentials from %s", json_file)
creds = candidate
break
if creds is not None:
break
creds = None
configured_user_email = os.environ.get("USER_GOOGLE_EMAIL", "").strip()
for creds_dir in (_WORKSPACE_CREDS_DIR, _SECRET_CREDS_DIR):
if creds_dir.is_dir():
preferred_file = (
creds_dir / f"{configured_user_email}.json"
if configured_user_email
and configured_user_email != "user@example.com"
else None
)
if preferred_file is not None:
candidate = _load_credential_file(preferred_file)
if candidate is not None:
logger.debug("Using Google credentials from %s", preferred_file)
creds = candidate
break
for json_file in sorted(creds_dir.glob("*.json")):
if preferred_file is not None and json_file == preferred_file:
continue
candidate = _load_credential_file(json_file)
if candidate is not None:
logger.debug("Using Google credentials from %s", json_file)
creds = candidate
break
if creds is not None:
break
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/runners/ambient-runner/ambient_runner/bridges/claude/mcp.py`
around lines 230 - 239, Prefer the current user's credential file before
scanning the directory: when iterating creds_dir in (_WORKSPACE_CREDS_DIR,
_SECRET_CREDS_DIR) first check for a file matching USER_GOOGLE_EMAIL + ".json"
and attempt to load it via _load_credential_file; if that returns a valid
candidate assign to creds and break out. Only if no USER_GOOGLE_EMAIL.json is
present or valid, fall back to the existing sorted glob("*.json") loop that
loads the first valid candidate. Ensure you reference creds, creds_dir,
_load_credential_file, USER_GOOGLE_EMAIL, _WORKSPACE_CREDS_DIR and
_SECRET_CREDS_DIR so the change is applied in the same block and retains the
existing break logic.

Comment on lines +446 to +451
if _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.exists() and creds_filename != "credentials.json":
_GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.unlink(missing_ok=True)
logger.info("Removed legacy credentials.json in favor of %s", creds_filename)
with open(creds_file, "w") as f:
_json.dump(creds_data, f, indent=2)
_GOOGLE_WORKSPACE_CREDS_FILE.chmod(0o600)
logger.info("Updated Google credentials file for workspace-mcp")
creds_file.chmod(0o600)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Write the Google credential file atomically.

workspace-mcp reads this file from another process. Writing the target in place and deleting credentials.json first creates a window where readers can see empty/partial JSON, and a failed write can leave the session with no usable Google credential file at all. Write to a temp file in the same directory, chmod it, os.replace() it, then remove the legacy file after the replace succeeds.

Safer update pattern
-                if _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.exists() and creds_filename != "credentials.json":
-                    _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.unlink(missing_ok=True)
-                    logger.info("Removed legacy credentials.json in favor of %s", creds_filename)
-                with open(creds_file, "w") as f:
+                temp_creds_file = creds_file.with_suffix(f"{creds_file.suffix}.tmp")
+                with open(temp_creds_file, "w") as f:
                     _json.dump(creds_data, f, indent=2)
-                creds_file.chmod(0o600)
+                temp_creds_file.chmod(0o600)
+                os.replace(temp_creds_file, creds_file)
+                if (
+                    creds_filename != "credentials.json"
+                    and _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.exists()
+                ):
+                    _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE.unlink(missing_ok=True)
+                    logger.info(
+                        "Removed legacy credentials.json in favor of %s",
+                        creds_filename,
+                    )
                 logger.info("Updated Google credentials file for workspace-mcp: %s", creds_filename)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/runners/ambient-runner/ambient_runner/platform/auth.py` around
lines 446 - 451, The current write to creds_file can leave readers with partial
JSON; update the logic in the block that references
_GOOGLE_WORKSPACE_LEGACY_CREDS_FILE, creds_filename, creds_file and creds_data
to perform an atomic write: create a temporary file in the same directory (e.g.,
creds_file.with_suffix(".tmp" or similar)), write creds_data to that temp file,
fsync if possible, set permissions via chmod(0o600) on the temp file, then
atomically replace the target with os.replace(temp_path, creds_file) and only
after successful replace unlink _GOOGLE_WORKSPACE_LEGACY_CREDS_FILE if needed;
ensure you handle exceptions so a failed write leaves the original file
untouched.

@markturansky markturansky merged commit 96c7843 into main May 11, 2026
55 of 59 checks passed
@markturansky markturansky deleted the fix/google-oauth-credential-format branch May 11, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants