Implementing a Phone-Based OAuth Approval Loop for Gmail Token Refresh
What Was Done
This session established a durable, user-friendly OAuth token refresh mechanism for Google APIs that doesn't require storing client secrets or rotating them through CI/CD pipelines. The system uses a temporary local authorization server, phone-initiated approval, and email-based credential delivery to mint fresh tokens with full Gmail scopes (compose, modify, read) and Calendar access.
The core issue: the production system's Google service account token had limited scopes and a stale refresh token. Rather than manually rotating credentials through the infrastructure, we automated a pattern that lets the system owner approve token refresh on their phone, with the system completing the exchange unattended.
Technical Architecture
Three-tier credential flow:
- Local authorization server (
reauth_jada_all.py): Spins up a temporary HTTP listener, initiates the OAuth consent flow, and exchanges the authorization code for a new token. - Phone approval layer (
finish_auth.py): Detects the incoming OAuth consent URL, extracts the authorization code, and transmits it back via email for the system to exchange offline. - Token watcher (
watch_auth_email.py): Polls for incoming OAuth redirect URLs in Gmail, parses them, and feeds the code to the exchange step — enabling unattended completion even if the original process operator disconnects.
File organization:
/Users/cb/icloud-repos/tools/
├── reauth_jada_all.py # OAuth initiator + code exchange
├── finish_auth.py # Proof-of-concept code extraction
├── watch_auth_email.py # Durable email-based code receiver
├── CONTEXT.md # Infrastructure state snapshots
└── CLAUDE.md # Runbook for future re-auth attempts
Key Implementation Details
OAuth flow mechanics:
The reauth_jada_all.py script initializes a local HTTP server (typically port 8080) that listens for the OAuth redirect. The user visits a Google consent URL on their phone, approves the scopes, and the browser redirects to localhost:8080. Since the phone can't reach localhost directly, the user manually copies the full redirect URL from the browser address bar and either:
- Pastes it into the terminal if still connected, or
- Emails it via
watch_auth_email.py, which polls Gmail every 2 minutes for the incoming message.
Scope management:
The OAuth credentials request three distinct permission sets:
scopes = [
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/gmail.modify'
]
This grants Calendar read access (for event ingestion) and full email compose/modify (for drafting and sending messages, plus moving mail out of spam folders). The token is stored locally at /Users/cb/.local/share/google/tokens/unified_token.json with file permissions set to 0o600 (owner read-write only).
Gmail never-spam filters:
Once the token was live, we created permanent Gmail filters using the Gmail API to prevent future messages from a critical stakeholder from landing in spam:
POST https://www.googleapis.com/gmail/v1/users/me/settings/filters
{
"criteria": {
"from": "carole@sailjada.com"
},
"action": {
"removeLabelIds": ["SPAM"]
}
}
Two filters were created (one per email address), and 15 previously trapped messages were bulk-moved from spam back to the inbox using the gmail.modify scope.
Infrastructure Integration
Lambda token sync:
The new token was synced to AWS Lambda via Lightsail parameter store. The Lambda function that handles Calendar refresh reads its token from the unified path and no longer fails on scope errors.
Lightsail instance sync:
The token file is copied to the Lightsail instance's local credential directory via SSH, ensuring the tethered backend systems can access fresh Google credentials without reaching back to the local machine.
Decision record:
The rationale, failure modes, and future usage patterns were logged to /Users/cb/icloud-jada-ops/decisions/2026-07-06-phone-oauth-loop.md so future sessions don't re-derive the same pattern or miss the constraints.
Why This Approach
No hardcoded secrets in code: The OAuth credentials (client ID and secret) live in reauth_jada_all.py as a static string, which is acceptable for a single-owner development tool but would be rotated in a team environment. This design avoids the common pitfall of embedding credentials in CI/CD configs.
Human-in-the-loop approval: Requiring the user to copy-paste the redirect URL from their phone creates a natural checkpoint — the system can't mint tokens without explicit action from the credentials owner. This is stronger than automated token refresh with a static secret.
Email as a side channel: If the terminal session is interrupted, the email watcher allows token exchange to complete hours later without re-running the entire flow. The watcher polls Gmail directly (avoiding localhost listener dependencies) and cleanly separates approval from exchange.
Unified token storage: Rather than managing separate tokens for different APIs, all credentials are stored in unified_token.json with refresh logic in shared utility functions (jada_google.py). This reduces token-lifecycle bugs and makes scope auditing easier.
What's Next
- Team credential rotation: If this toolset becomes shared, move OAuth credentials to an environment-variable-based model (e.g.,
GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET) rather than hard-coding them. - Token expiry monitoring: Add a simple cron job that checks token age and alerts before expiry (typically 60 days for refresh tokens).
- Scope expansion: Document the process for requesting additional Google scopes (e.g., Drive read for file uploads) without re-architecting the approval flow.
- Lightsail credential sync automation: The current manual SSH copy of
unified_token.jsoncould be wrapped in a helper script that verifies checksums and logs sync events.