Fixing Permanent OAuth Token Expiration: Moving JADA's Google Cloud App from Testing to Production Mode

The Problem: A 7-Day Refresh Token Death Cycle

We've been stuck in a recurring authentication nightmare. Every week or so, our Google Calendar and Gmail integrations stop working because refresh tokens expire after exactly 7 days. The symptom: manual re-authentication required, downtime on calendar syncs, and angry captain/crew notifications failing to send. We'd patch it by re-authing, but the root cause was never addressed.

After digging into the Google Cloud Console, I found the culprit: the JADA app is configured in Testing mode, which is explicitly designed for development. Google hard-caps refresh token lifetime at 7 days in Testing mode as a security measure. The fix isn't code—it's moving the app to Production mode in the OAuth consent screen.

Why This Matters: Testing vs. Production Mode

Google Cloud's OAuth consent screen has two modes:

  • Testing mode: Refresh tokens expire in 7 days. Meant for dev teams iterating quickly. No external verification needed.
  • Production mode: Refresh tokens valid for 6 months (or until user revokes). Requires app verification but eliminates the refresh cycle.

We were operating under the assumption that re-authing was a temporary fix, but Testing mode is a permanent constraint. Every 7 days, the token would silently expire, and the next calendar write or email send would fail. The integration appears to work, then suddenly breaks.

Technical Details: What We're Authenticating

Our authentication flow touches three main systems:

  • Google Calendar API (via Google Apps Script): Used by CalendarSync.gs to sync Boatsetter bookings into JADA's master calendar. The trigger was never activated, but once fixed, it needs persistent auth.
  • Gmail API (via Apps Script): Sends captain/crew notifications for upcoming sails. Token revocation stops all outbound notifications.
  • Google Sheets API (via Apps Script): Reads crew dispatch state from the JADA Internal sheet at /Users/cb/Documents/repos/tools/state.json (exported from Sheets).

All three use the same OAuth 2.0 consent screen configuration. Moving to Production mode fixes all three simultaneously.

The Fix: Two Steps in Google Cloud Console

The actual fix requires exactly two clicks, but the prerequisites are critical:

Step 1: Verify Your App Identity (One-Time)

Google requires app verification before moving to Production. This involves:

  • Confirming domain ownership (for tech.queenofsandiego.com and jada-sailing.appspot.com)
  • Providing a privacy policy URL
  • Describing how we use scopes (calendar, email, sheets)

Since this is an internal tool (crew/captain use only, no external users), we qualify for the "internal app" exemption and don't need full third-party verification. However, we still need to declare intent in the consent screen.

Step 2: Move Consent Screen to Production

Once verified, navigate to Google Cloud Console → APIs & Services → OAuth consent screen:

  • Change the radio button from "Testing" to "Production"
  • Save and publish
  • Existing users must re-authenticate once; new tokens will be valid for 6 months

Infrastructure & Deployment Impact

This change affects the following resources:

  • Google Cloud Project ID: jada-sailing (managed via gcloud CLI)
  • OAuth 2.0 Client ID: Stored in the Google Cloud Console; no manual rotation needed
  • Apps Script Deployments:
    • CalendarSync.gs (ID: AKfycbyXXXXXXXXX—exact ID in GAS editor settings)
    • DashboardNotifier.gs (similar pattern)
  • Scopes Required (in appsscript.json):
    • https://www.googleapis.com/auth/calendar
    • https://www.googleapis.com/auth/gmail.send
    • https://www.googleapis.com/auth/spreadsheets

No changes needed to Lambda, DynamoDB, S3, or CloudFront. This is purely a Google Cloud OAuth configuration change.

Testing the Fix

After publishing to Production, verify with:


# Check current token scopes and expiry
gcloud auth list
gcloud auth application-default print-access-token

# In Google Apps Script editor, manually trigger CalendarSync:
# - Open CalendarSync.gs
# - Click Run → calendarDashboardSetup()
# - Check Execution log for success

# Verify calendar sync worked:
# - Add a test event in Boatsetter
# - Check JADA Internal calendar for iCal pull

Once activated, the GAS trigger calendarDashboardSetup() will run on a schedule (currently set to hourly) and pull Boatsetter events into the master calendar automatically.

Why We Didn't Catch This Sooner

The Testing mode constraint isn't obvious in day-to-day usage. The app works fine until token expiry hits. We had a workaround (re-auth), so the systemic issue stayed hidden. The real lesson: when you set up OAuth for production use, move it to Production mode immediately. Testing mode is for feature validation, not operational deployments.

What's Next

  • Move OAuth consent screen to Production mode (2 clicks in Cloud Console)
  • Re-authenticate all Apps Script deployments once (each will prompt for permissions once)
  • Activate the CalendarSync trigger to auto-sync Boatsetter iCal
  • Document the scopes and consent screen status in the engineering wiki to prevent this regression
  • Monitor token refresh logs for the next 6 months to confirm tokens stay valid

After this change, we should see zero token expiration events for the next half-year, eliminating a major source of weekend notification failures.