Fixing Permanent OAuth Token Expiry in Google Apps Script: Moving from Testing to Production Mode
We hit a recurring pain point this week: Gmail and Google Calendar OAuth tokens expiring after 7 days, forcing manual re-authentication and breaking automated calendar sync across multiple booking platforms. After digging into the root cause, we discovered the issue wasn't a token handling bug—it was Google Cloud's Testing vs. Production mode policy. Here's how we diagnosed it, and what's required to fix it permanently.
The Problem: Why Testing Mode Kills Refresh Tokens
Google Cloud OAuth applications in Testing mode have a hard limitation: refresh tokens expire after 7 days of inactivity. This is by design—it's a security boundary to prevent abandoned apps from maintaining indefinite access to user accounts. Our booking automation infrastructure relies on:
- Google Calendar API (calendar reads/writes via
CalendarSync.gsin Apps Script) - Gmail API (email notifications from
BookingAutomation.gs) - Google Drive API (state file persistence to
gs://jada-state-production)
Each of these scopes required fresh OAuth consent. The token stored in /var/lib/jada_tokens/google_refresh.json on the Lightsail server would silently become invalid after 7 days, causing the Lambda function that reads/updates calendar events to fail without explicit errors—just silent sync failures.
Root Cause Analysis: Infrastructure as Evidence
Our GAS deployments live in the Google Cloud Project jada-booking-prod, with the OAuth consent screen configured but the app never published to Production. We verified this by checking:
gcloud config set project jada-booking-prod
gcloud auth list
# Confirmed: service account auth + user OAuth configured
The OAuth brand config exists (visible in Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client IDs), but the consent screen was left in Testing status. This single configuration choice cascades through the entire system:
- Testing mode → 7-day refresh token expiry → manual re-auth every week
- Production mode → indefinite refresh token validity (revoked only by user or explicit token revocation)
Why This Happened: The Automation Gap
The reauth_jada_all.py script in /Users/cb/Documents/repos/tools/ was written as a band-aid. It handles the mechanics of re-authenticating via OAuth flow:
# Pseudo-structure of reauth_jada_all.py:
# 1. Trigger Google OAuth consent screen
# 2. Exchange authorization code for new refresh token
# 3. Store token in /var/lib/jada_tokens/
# 4. Push token to Lambda environment and Lightsail server
# 5. Verify calendar/email endpoints still work
This works, but it's a symptomatic treatment. Running it every 6 days prevents outages but doesn't solve the underlying constraint. The real fix requires moving the OAuth app from Testing to Production mode in Google Cloud Console.
The Fix: Two-Step Process
Step 1: Update the OAuth Consent Screen (Console UI)
Google Cloud doesn't expose this via gcloud CLI or API—you must use the Cloud Console. Here's what needs to happen:
- Navigate to APIs & Services → OAuth consent screen in the jada-booking-prod project
- Under App information, fill in (if not already present):
- App name: JADA Booking Automation
- User support email: jadasailing@gmail.com
- Developer contact: Same or ops contact
- Under Scopes, confirm these are listed (they should be auto-populated from existing credentials):
https://www.googleapis.com/auth/calendar(Google Calendar)https://www.googleapis.com/auth/gmail.send(Gmail)https://www.googleapis.com/auth/drive.file(Google Drive—for state persistence)
- Click "Publish App" to move from Testing → Production
- Confirm the change—the consent screen status badge should change from blue (Testing) to green (In Production)
Step 2: Re-authenticate Once in Production Mode
Once the app is in Production mode, run the re-auth script one final time. This time, the newly-minted refresh token will be valid indefinitely (until explicitly revoked):
cd /Users/cb/Documents/repos/tools/
python3 reauth_jada_all.py
# Triggers OAuth flow, stores new refresh token
# Pushes to Lambda + Lightsail
# Tests calendar/email endpoints
The token now stored in /var/lib/jada_tokens/google_refresh.json will not expire after 7 days.
Cascading Fixes: What Gets Unblocked
Once the token is permanent, several systems automatically resume working:
- Boatsetter iCal Sync: The
calendarSyncSetup()trigger inCalendarSync.gscan finally write to the JADA Internal calendar without auth failures. Ticketm-91325edbbecomes actionable. - Sailo Auto-Sync: Same mechanism—their iCal pull from our calendar will work reliably.
- Lambda Calendar Updates: The
PUT /calendarendpoint in API Gateway (routed toUpdateCalendarEventLambda) can write crew assignments and block times without token invalidation errors. - Gmail Notifications: Booking confirmations and crew alerts sent via
GmailService.send()won't fail silently.
Why We Do This Once, Not Repeatedly
The reason this is worth 2 minutes of Cloud Console time instead of running reauth_jada_all.py every week:
- Operational reliability: No more silent calendar sync failures because a token expired overnight
- Reduces manual intervention: One OAuth consent → indefinite token validity instead of a recurring task
- Security is maintained: Production mode doesn't weaken security; it just doesn't apply the 7-day cutoff. Token revocation still works, scopes are still enforced, and the app is still auditable.
- Scales with the product: As we add more platforms (Viator, GetMyBoat) that depend on calendar sync, this becomes a critical reliability factor
Verification: Confirming the Fix
After publishing the app and re-authing, verify the change propagated:
# On Lightsail server
cat /var/lib/jada_tokens/google_refresh.json
# Should see a valid refresh token
# Test calendar write