Google OAuth Agent Skill
Self-hosted Google OAuth authentication for any Google API (Gmail, Calendar, Drive, Ads, etc.).
Overview
This skill provides a complete OAuth 2.0 flow for Google APIs without external dependencies like Nango. It:
- Runs a local callback server on port 3000
- Generates dynamic callback URLs for Shakudo deployments
- Handles token exchange and storage
- Supports automatic token refresh
- Works with any Google API scope
Quick Start
# 1. Set up credentials (one-time)
export GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
export GOOGLE_CLIENT_SECRET="your-client-secret"
# 2. Run OAuth flow for Gmail
cd scripts/
python auth_flow.py --scopes gmail_full
# 3. Tokens saved to .env, ready to use
Prerequisites
Python Dependencies
pip install httpx python-dotenv
Google Cloud OAuth App (One-Time Setup)
- Go to Google Cloud Console
- Create a project (or select existing)
- Click Create Credentials → OAuth Client ID
- Select Web application
- Add redirect URI (see Callback URL below)
- Copy the Client ID and Client Secret
Enable Required APIs
For the Google APIs you want to use:
Callback URL
The callback URL is dynamically generated based on the MY_JOB_ID environment variable (set automatically in Shakudo deployments).
Pattern: https://nextjs-{first-6-chars-of-job-id}.dev.hyperplane.dev/oauth/callback
Example: If MY_JOB_ID=abc123xyz, callback URL is:
https://nextjs-abc123.dev.hyperplane.dev/oauth/callback
For local development (no MY_JOB_ID):
https://nextjs-000000.dev.hyperplane.dev/oauth/callback
Add this URL to your Google OAuth app's authorized redirect URIs.
Supported Scopes
| Preset | Scope URL | Description |
|---|---|---|
gmail_full |
https://mail.google.com/ |
Full Gmail access (read, send, delete) |
gmail_readonly |
https://www.googleapis.com/auth/gmail.readonly |
Read-only Gmail access |
gmail_send |
https://www.googleapis.com/auth/gmail.send |
Send emails only |
gmail_compose |
https://www.googleapis.com/auth/gmail.compose |
Compose and send |
gmail_labels |
https://www.googleapis.com/auth/gmail.labels |
Manage labels |
calendar |
https://www.googleapis.com/auth/calendar |
Full calendar access |
calendar_readonly |
https://www.googleapis.com/auth/calendar.readonly |
Read-only calendar |
drive |
https://www.googleapis.com/auth/drive |
Full Drive access |
drive_readonly |
https://www.googleapis.com/auth/drive.readonly |
Read-only Drive |
ads |
https://www.googleapis.com/auth/adwords |
Google Ads API |
sheets |
https://www.googleapis.com/auth/spreadsheets |
Google Sheets |
docs |
https://www.googleapis.com/auth/documents |
Google Docs |
Custom scopes: You can also use full scope URLs directly:
python auth_flow.py --scopes "https://www.googleapis.com/auth/userinfo.email"
Environment Variables
| Variable | Required | Description |
|---|---|---|
GOOGLE_CLIENT_ID |
Yes* | OAuth client ID from Google Cloud Console |
GOOGLE_CLIENT_SECRET |
Yes* | OAuth client secret |
GOOGLE_ADS_CLIENT_ID |
Fallback | Alternative credential prefix (marketing TUI compatibility) |
GOOGLE_ADS_CLIENT_SECRET |
Fallback | Alternative credential prefix (marketing TUI compatibility) |
GOOGLE_ACCESS_TOKEN |
Auto | Access token (populated after OAuth flow) |
GOOGLE_REFRESH_TOKEN |
Auto | Refresh token (for offline access) |
GOOGLE_TOKEN_EXPIRY |
Auto | ISO 8601 datetime when access token expires |
GOOGLE_SCOPES |
Auto | Space-separated scopes that were authorized |
GOOGLE_OAUTH_ENV_PATH |
No | Custom path to .env file (default: ./.env) |
MY_JOB_ID |
No | Shakudo job ID (auto-set in deployments) |
*The scripts check GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET first, then fall back to GOOGLE_ADS_CLIENT_ID / GOOGLE_ADS_CLIENT_SECRET for compatibility with the marketing TUI.
User Experience Flow
First-Time Setup
User: "Connect my Gmail account"
Agent: "I don't see Gmail credentials configured. Let me help you set up OAuth.
Do you have a Google Cloud OAuth Client ID and Secret?"
User: "No"
Agent: "No problem. Here's how to create one:
1. Go to https://console.cloud.google.com/apis/credentials
2. Create OAuth 2.0 Client ID (Web application)
3. Add redirect URI: https://nextjs-XXXXXX.dev.hyperplane.dev/oauth/callback
4. Copy the Client ID and Client Secret
Let me know when you have them."
User: "Got it. Client ID is XXX, Secret is YYY"
Agent: [Saves credentials to .env]
"Starting OAuth server...
Please open this URL in your browser to authorize Gmail access:
https://accounts.google.com/o/oauth2/v2/auth?client_id=...
Waiting for authorization... (5 min timeout)"
[User authorizes in browser, gets redirected to callback]
Agent: "Authorization received! Exchanging for tokens...
Gmail connected successfully!
• Access token: saved (expires in 1 hour)
• Refresh token: saved (auto-renews)
• Scopes: gmail.readonly, gmail.send
You can now ask me to read or send emails."
Subsequent Usage
User: "Check my unread emails"
Agent: [Loads tokens from .env]
[Auto-refreshes if access token expired]
[Calls Gmail API]
"You have 3 unread emails:
1. From: boss@company.com - Subject: Q1 Report
..."
Token Expired / Revoked
User: "Send an email"
Agent: [Detects refresh token no longer works]
"Your Gmail authorization has expired. Let me reconnect..."
[Restarts OAuth flow]
Script Reference
oauth_server.py
Callback server for receiving OAuth redirects.
# Start server and wait for callback
python oauth_server.py --wait --timeout 300
# Just start server (for debugging)
python oauth_server.py
Python API:
from oauth_server import OAuthServer, GOOGLE_SCOPE_PRESETS
server = OAuthServer()
if server.start():
# Generate auth URL
url, state = server.generate_auth_url(
client_id="your-client-id",
scopes=["gmail_full", "calendar"],
access_type="offline",
prompt="consent"
)
print(f"Open: {url}")
# Wait for callback
result = server.wait_for_callback(timeout=300)
if result.success:
print(f"Code: {result.code}")
else:
print(f"Error: {result.error}")
server.stop()
auth_flow.py
Complete OAuth authorization flow.
# Interactive flow (recommended)
python auth_flow.py --scopes gmail_full
# Multiple scopes
python auth_flow.py --scopes gmail_full calendar drive
# Just generate URL (for manual flow)
python auth_flow.py --scopes gmail_full --url-only
# Exchange code manually
python auth_flow.py --exchange-code "4/0AY0e-g..."
# List available scope presets
python auth_flow.py --list-scopes
token_refresh.py
Token status and refresh.
# Check token status
python token_refresh.py --status
# Force refresh access token
python token_refresh.py --refresh
# Auto-refresh only if needed (silent, for scripts)
python token_refresh.py --auto
# Get valid access token (refreshes if needed)
TOKEN=$(python token_refresh.py --get-token)
Python API:
from token_refresh import get_valid_token
# Get valid access token (auto-refreshes if needed)
token = get_valid_token()
if token:
# Use token with Google APIs
headers = {"Authorization": f"Bearer {token}"}
Integration Examples
With google_workspace_mcp
from token_refresh import get_valid_token
# Get token for google_workspace_mcp
access_token = get_valid_token()
if not access_token:
print("Need to run OAuth flow first")
exit(1)
# Configure google_workspace_mcp to use this token
# (implementation depends on how you've integrated it)
With Gmail API Directly
import httpx
from token_refresh import get_valid_token
token = get_valid_token()
if not token:
raise RuntimeError("No valid token")
async with httpx.AsyncClient() as client:
response = await client.get(
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
headers={"Authorization": f"Bearer {token}"},
params={"maxResults": 10}
)
messages = response.json()
With Google Calendar API
import httpx
from token_refresh import get_valid_token
token = get_valid_token()
async with httpx.AsyncClient() as client:
response = await client.get(
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
headers={"Authorization": f"Bearer {token}"},
params={"maxResults": 10}
)
events = response.json()
Error Handling
| Error | Cause | Solution |
|---|---|---|
| "Port 3000 is already in use" | Another service on port 3000 | Stop the other service |
| "Authorization timed out" | User didn't complete auth in time | Run auth flow again |
| "User declined permission" | User clicked "Deny" | Run auth flow again, accept permissions |
| "Invalid client credentials" | Wrong client ID/secret | Check credentials in Google Cloud Console |
| "Token refresh failed: invalid_grant" | Refresh token revoked | Run full auth flow again |
| "No refresh token available" | Didn't use access_type=offline |
Re-run auth with --scopes (uses offline by default) |
Security Notes
- Never commit credentials - Use
.envfiles and add to.gitignore - Refresh tokens persist - Treat refresh tokens like passwords
- Scope minimization - Only request scopes you need
- Token storage - Tokens are stored in plaintext
.envfiles - HTTPS callbacks - Google requires HTTPS for production callback URLs
Troubleshooting
"redirect_uri_mismatch" Error
The callback URL in your Google OAuth app doesn't match. Check:
- Your
MY_JOB_IDenvironment variable - The redirect URI in Google Cloud Console
Tokens Not Persisting
Check that:
.envfile is writableGOOGLE_OAUTH_ENV_PATHis set correctly (if using custom path)- No file permission issues
Auto-Refresh Not Working
- Check you have a refresh token:
python token_refresh.py --status - Verify client credentials are correct
- Ensure the refresh token hasn't been revoked
Files
google-oauth/
├── SKILL.md # This documentation
├── .env.example # Credential template
└── scripts/
├── oauth_server.py # Callback server (port 3000)
├── auth_flow.py # Authorization flow
└── token_refresh.py # Token refresh utility