Fixing Mobile OAuth: Building a Copy-Paste Alternative to Localhost Redirects
When implementing Google OAuth for email automation tooling, the standard redirect flow assumes the user's browser and your server are on the same machine. This assumption breaks immediately on mobile. Here's how I rebuilt the flow to work reliably from a phone, and why the architectural tradeoffs matter.
The Problem: Why Localhost Fails on Mobile
The original implementation used the OAuth consent screen redirect to http://localhost. On a desktop, this works perfectly—Safari approves, Google redirects the browser to the local machine listening on port 80, and the authorization code lands in your backend. On a phone, localhost means "this device"—the phone itself. There's no server there, so the user gets a "can't connect" error, with the authorization code buried in the URL bar behind a compact localhost label.
This isn't a bug; it's the expected failure mode of the redirect-URI design when the authorizing device and the code-redeeming server are different machines. The code is valid and sitting in the phone's browser—it just can't travel back to the Mac automatically.
Technical Solution: Manual Code Transport with PKCE
I built /Users/cb/jada-icm/tools/phone_oauth.py to split the OAuth flow into two halves: authorization (on the phone) and code exchange (on the Mac), with the user copy-pasting the code in between.
Authorization link generation:
python phone_oauth.py --action=link --scope=gmail.readonly,gmail.send,gmail.modify,calendar
This generates a URL with:
- PKCE challenge/method: RFC 7636 proof key for exchange, preventing interception attacks even though the code travels through the user's copy-paste
- State parameter: Cryptographic nonce verified on redemption, protecting against CSRF
- Offline access:
access_type=offlineandprompt=consentrequest a refresh token so the flow succeeds even if the user has prior Gmail approval - Scope whitelisting: Only requests the scopes the automation actually needs, not a kitchen-sink list
The state is written to /Users/cb/jada-icm/pending-flow-state.json before returning the link. This file survives the network round-trip and validates the code when it returns.
Code redemption:
python phone_oauth.py --action=redeem --code='4/0AdkVLPz...' --state='bdtfdyAtYOYQ1wif...'
The redeem action:
- Verifies the state matches the pending flow file (CSRF check)
- Constructs the token exchange request with the PKCE verifier
- POSTs to
https://oauth2.googleapis.com/token - Stores the resulting access and refresh tokens at
/Users/cb/jada-secrets/jada-token.json
Network Resilience: Lightsail Relay Fallback
During testing, the Mac lost network connectivity to Google's token endpoint while redeeming—every TCP connection attempt timed out, even though AWS services remained reachable. This exposed a fragility: if the code redemption fails due to transient Mac-to-Google network issues, the authorization code expires (typically 10 minutes), and the user has to re-authenticate.
I added an automatic fallback in phone_oauth.py that relays the token exchange through the Lightsail bastion box via SSH. If the direct HTTPS connection to accounts.google.com:443 fails, the tool opens an SSH tunnel and routes the HTTP POST through that relay. This works because:
- Lightsail → Google connectivity is independent of the Mac's network path
- SSH tunneling preserves HTTPS/TLS end-to-end (the Mac initiates the TLS handshake locally after the tunnel establishes)
- The fallback is transparent to the user—redemption succeeds in seconds even during Mac network issues
The SSH key is read from ~/.ssh and the Lightsail hostname is configured in /Users/cb/jada-icm/CLAUDE.md as a documented fallback resource.
Integration and Testing
The tool is invoked by the main Gmail automation on-demand when /Users/cb/jada-secrets/jada-token.json doesn't exist or has expired. It's also wired into the nightly test suite:
/Users/cb/icloud-repos/sites/queenofsandiego.com/tests/test_google_token.py validates:
- Token file exists and is valid JSON
- Access token is present and not expired (checked against the mint timestamp)
- Token can reach the Gmail API with a live health-check query
The nightly runner (invoked via tests-run.sh) globs and executes this test as part of the standard CI suite, alerting if Gmail becomes unreachable.
Decision Log and Architecture Decisions
All decisions are documented in /Users/cb/icloud-jada-ops/decisions/phone-oauth-flow.md. Key architectural choices:
- Why PKCE instead of a custom code encryptor: PKCE is a standard, widely supported, and simpler than custom crypto. It mitigates interception even though the user is copy-pasting.
- Why state validation in addition to PKCE: Defense in depth. State guards against CSRF; PKCE guards against code interception. Both should be present.
- Why Lightsail relay instead of client-side curl: The tool is Python-native, so curl as a subprocess adds a dependency and complexity. SSH tunneling through a known bastion is simpler and avoids shell escaping risks.
- Why offline scopes: The refresh token ensures the automation continues working indefinitely. Without it, the token would expire after 1 hour and require manual re-authentication every week.
Incident tracking for network-related redemption failures is logged in /Users/cb/icloud-jada-ops/FIRES.md so patterns can be detected if the Lightsail relay becomes a bottleneck.
What's Next
The flow is now production-ready. Future improvements could include:
- Automating the paste step by embedding a local HTTP listener that accepts cross-origin form posts from a simple web page (moving back toward full-redirect flow but without the localhost assumption)
- Metrics on how often the Lightsail relay is triggered, to decide if it should become the default path
- Testing the flow with other OAuth providers that have different redirect constraints
For now, the copy-paste flow is reliable, understood, and has a safety net for transient network issues. It's a good example of when fighting the framework (trying to make localhost work cross-device) costs more than accepting a constraint and designing around it.
```