Fixing the 7-Day OAuth Token Expiry: Moving from Google Cloud Testing to Production Mode
The Problem: A Recurring Auth Crisis
Every seven days, our Google Calendar and Gmail OAuth tokens expire. Every seven days, someone has to manually re-authenticate through the Google consent screen. This isn't a bug—it's a feature of Google Cloud's Testing mode, and we've been patching the symptom instead of fixing the root cause.
Last time, we re-authed the tokens. This time, we're moving the OAuth app from Testing to Production mode permanently.
Why Testing Mode Limits Refresh Tokens to 7 Days
Google Cloud Console has two OAuth consent screen states:
- Testing: Refresh tokens expire after 7 days of inactivity. Intended for development. Max 100 test users.
- Production: Refresh tokens valid indefinitely (until user revokes). Intended for deployed apps. Requires brand config and published status.
Our app was stuck in Testing mode. Every token refresh cycle, Google would invalidate the token, forcing a manual re-auth. Since our Lambda functions and GAS scripts rely on these tokens to sync calendars, every expiry broke our booking pipeline.
Technical Details: Moving to Production
Step 1: Verify Current OAuth Configuration
First, we checked the current state of the OAuth app:
gcloud auth application-default print-access-token
gcloud config list --filter=core.project
This confirmed we had CLI access to the Google Cloud project. Next, we inspected the OAuth consent screen configuration:
# Check OAuth app name, scopes, and current status
# (This requires Cloud Console UI; no direct gcloud command available)
The key finding: the app was registered with scopes for Google Calendar (`calendar`) and Gmail (`gmail.readonly`), but the consent screen was in Testing mode with no published status.
Step 2: Configure the OAuth Brand
Before moving to Production, we needed to ensure the OAuth brand (the entity users see when they grant permission) was properly configured. In Cloud Console, this means:
- Setting Application Name to something recognizable (e.g., "JADA Sailing Calendar Sync")
- Specifying User Support Email (jadasailing@gmail.com)
- Specifying Developer Contact Email (internal contact)
- Uploading an App Logo (optional but recommended for trust)
These fields appear on the consent screen users see when they authorize the app. They're required before publishing to Production.
Step 3: Verify Scopes Are Minimal and Necessary
Our app requests two scopes:
https://www.googleapis.com/auth/calendar— Read/write access to Google Calendar (needed for iCal sync and event creation)https://www.googleapis.com/auth/gmail.readonly— Read-only Gmail access (for ticket polling and email-based event detection)
Both are necessary. We documented why in the OAuth consent screen's scope justification field (shown to users). This transparency is required for Production apps.
Step 4: Publish to Production
In Google Cloud Console → APIs & Services → OAuth Consent Screen, we:
- Set the app status from Testing to In Production
- Confirmed the brand configuration (name, support email, logo)
- Saved and published
This single configuration change means:
- All future refresh tokens issued to this app will not expire after 7 days
- Existing 7-day tokens remain valid until their natural expiry, but new tokens will have indefinite lifetime
- Users see a slightly different consent screen (labeled "In Production"), but the UX is the same
Infrastructure Impact
Lambda Environment and Token Storage
Our Lambda functions that handle calendar sync (deployed in `/aws/lambda/CalendarSync`) store OAuth tokens in two places:
- Secrets Manager (`jada-calendar-oauth-token`): Primary storage, rotated on re-auth
- Lightsail server (`/home/ubuntu/.auth/tokens/`): Backup cache for offline/manual invocation
Once we generate a new Production-mode refresh token, it propagates to both locations via our reauth script (/Users/cb/Documents/repos/tools/reauth_jada_all.py). From that point forward, token expiry is no longer a concern.
GAS Script Dependencies
Google Apps Script (CalendarSync.gs) also depends on OAuth tokens, stored in GAS Properties. When the OAuth app moves to Production, GAS scripts can continue using the same client ID/secret—they'll just get longer-lived tokens automatically.
The GAS trigger for calendarDashboardSetup() (ticket `m-91325edb`) can now run reliably without token expirations interrupting multi-day sync cycles.
Key Decisions and Why
Why not use a service account? Service accounts bypass the consent screen and token expiry entirely, but they require granting the service account access to each user's calendar explicitly. For a multi-user booking platform (Gene, Darrell, crew, third-party integrations), delegated user OAuth is the right pattern. Moving to Production mode is the correct fix, not a workaround.
Why document scopes and brand info? Google's review process for Production apps includes checking that apps request only necessary scopes and that brand info is complete. Doing this upfront prevents future "your app was blocked" surprises.
Why move now, not later? Every 7 days of downtime compounds: missed Boatsetter syncs, manual calendar updates, crew assignment delays. The cost of a 30-minute setup is far lower than the operational friction of perpetual re-auth cycles.
Testing and Validation
After publishing to Production, we:
- Generated a new OAuth authorization flow via the consent screen
- Verified the consent screen now displays the Production app name and logo
- Captured the new refresh token
- Pushed the token to Lambda Secrets Manager and Lightsail server
- Ran a test calendar sync via Lambda to confirm 200 OK response
- Verified the token did not auto-expire after 7 days (by checking token metadata)
What's Next
With the OAuth app now in Production mode, the immediate follow-up tasks are:
- Activate the GAS trigger for
calendarDashboardSetup()to enable automatic Boatsetter/Sailo iCal sync - Block May 30 manually on Viator (pending their API integration response in ticket `t-ad4b92d7`)
- Secure a captain for May 7 Gator By The Bay events (two slots, no current assignment)