Phone-Based OAuth 2.0 Token Redemption Without Localhost Redirect
The Problem
Standard OAuth 2.0 redirect flow assumes a desktop browser can listen on localhost:8080 or similar. When authenticating from a phone to control backend systems running on a separate machine (in this case, a Mac managing JADA automation systems), Google's Authorization Server redirects to http://localhost — which on a phone resolves to "the phone itself," not the remote system. The authorization code sits buried in the phone's address bar, unreachable by the backend.
This is a known constraint, not a bug: RFC 6749 (OAuth 2.0) Section 4.1.2 assumes the user-agent and the token-redeeming system occupy the same network namespace. Cross-device approval requires either:
- A public callback URL (exposes credentials to network)
- Device-pairing code flow (requires separate approval mechanism)
- Manual code copy-paste (user-hostile but simple)
We chose option 3 with hardened transport and state validation.
Solution Architecture
The fix splits OAuth approval from token redemption into two phases:
Phase 1 (Phone): User approves on their device, lands on "can't connect" error page, copies the full redirect URL from the address bar.
Phase 2 (Backend): Backend receives the code via paste, redeems it via a server-to-server HTTPS POST to Google's token endpoint, validates the state parameter, and stores the token securely.
Implementation lives in /Users/cb/jada-icm/tools/phone_oauth.py, which handles:
- PKCE support: Implements Proof Key for Public Clients (RFC 7636), generating
code_challengeandcode_verifierpairs. This prevents authorization code interception attacks, especially important when codes travel over user-copy channels. - State generation: Cryptographically secure random 24-byte state, base64url-encoded, stored in a pending registry file.
- URL parsing: Handles three paste formats: full redirect URLs, compact localhost shorthand, and partial query strings.
- Retry logic: Curl-based token redemption with exponential backoff (3 attempts, 2-second base interval) to survive transient network drops.
Technical Implementation
Generating the Approval Link:
python3 /Users/cb/jada-icm/tools/phone_oauth.py --generate \
--client-id 816564630881-rppcgpu27f9sk5cgnd2vik4686bg6odi.apps.googleusercontent.com \
--scopes gmail.readonly,gmail.send,gmail.modify,calendar \
--output-state ~/jada-ops/pending-oauth.txt
Outputs a full authorization URL with embedded state, code_challenge, code_challenge_method=S256, access_type=offline, and prompt=consent. The state and verifier pair are written to pending-oauth.txt with a 10-minute expiry timestamp.
Redeeming the Code:
python3 /Users/cb/jada-icm/tools/phone_oauth.py --redeem \
"localhost/?state=bdtfdyAtYOYQ1wifFcN2vTFoj1vFQY&code=4/0AdkVLPzs4LlpFCxHQebyb6XpPcJTO8DG8kt1xKIOqeldRQLTN1Dr9um0IDZBcbSUBgC9Rw" \
--output-token ~/jada-secrets/jada-token.json
The script:
- Extracts
stateandcodefrom the pasted URL (handles all three format variants) - Validates state against the pending registry
- Looks up the corresponding
code_verifier - POSTs to Google's token endpoint (
https://oauth2.googleapis.com/token) with verifier and code - On success, writes the token response to
/Users/cb/jada-secrets/jada-token.jsonand logs a timestamp ("mint stamp") for expiry tracking - On failure (network error, invalid code, state mismatch), retries up to 3 times before surfacing the error
Token Lifecycle and Production Publishing
A critical discovery during this session: Google OAuth app status affects token lifetime.
- Testing status: Tokens expire after 7 days, regardless of offline access request
- Production status: Tokens expire after ~3 months (and refresh tokens are permanent until revoked)
The JADA OAuth app was initially in Testing, which meant every token minted during testing self-destructed around July 11, 2026. Moving to Production (via Google API console, marking the app as verified) ensures durable tokens that survive redeployments and maintenance windows.
The jada_google.py health-check tool (invoked by CI/CD) now validates:
tools/jada_google.py --health
This:
- Reads the token from
~/jada-secrets/jada-token.json - Calls
https://www.googleapis.com/oauth2/v1/tokeninfoto confirm validity - Checks scopes against the required set (gmail, calendar)
- Extracts and logs the mint timestamp to compare against expected app status (Testing vs Production)
- Flags expiry warnings if the token is within 3 days of invalidation
Infrastructure and Routing
Token Storage: /Users/cb/jada-secrets/jada-token.json is a local file on the Mac (canonical credential home per [[environments-and-auth.md]]). This keeps API calls (Gmail, Calendar, Drive, Analytics) on the same machine; there is no cross-network credential exposure.
CONTEXT.md Router Entry: Added a new L2 route in /Users/cb/icloud-jada-ops/CONTEXT.md:
| phone-oauth | CB OAuth approval from phone → backend token redemption | tools/phone_oauth.py | pending-oauth.txt + jada-secrets/jada-token.json |
This ensures future team members (or future-me) know where the flow lives and what files are involved.
Test Coverage: Added /Users/cb/icloud-repos/sites/queenofsandiego.com/tests/test_google_token.py, which the nightly CI wrapper invokes to validate the current token is live and held the expected scopes. On failure, it triggers an SMS to CB with the health-check output.
Incident Tracking and Decision Documentation
This session produced two artifacts for future reference:
- Decision Doc:
/Users/cb/icloud-jada-ops/decisions/phone-oauth-flow.mddocuments the cross-device OAuth problem, why localhost redirect fails, and why we chose manual code-pasting + PKCE over device-pairing or public callback. - FIRES Ledger Entry:
/Users/cb/icloud-jada-ops/FIRES.mdnow includes the transient network drop that ate the code redemption attempt, the 7-day Testing token expiry discovery, and the Production publish recovery. This ledger is the source of truth for all infrastructure incidents and anomalies, enabling the nightly diff-review bot (running on Lightsail) to flag recurring failure modes.
Why This Matters
This pattern unlocks secure multi-device OAuth without exposing credentials to third-party callback servers. It's especially valuable for:
- Self-hosted systems: No public domain required; all credential exchange happens server-to-server over HTTPS
- Headless approval: On-call staff can approve new tokens from anywhere (phone, tablet, untrusted laptop)
- Offline-first flows: Copy-paste is human-readable, auditable, and doesn't require additional auth infrastructure
The retry logic and PKCE prove essential when the on-call Mac's network is unstable (common on cellular backup or VPN flap) — a single network hiccup no longer burns the authorization code or forces a full re-approval cycle.
What's Next
Monitor the nightly test_google_token.py` runs to confirm Production tokens are stable. The memory entry [[jada-google-auth-robust.md]] has been updated to reflect the new phone_oauth flow and the critical app-status-to-token-lifetime relationship. Future OAuth integrations (e.g., Google Drive automation) can reuse this same foundation.