```html

Fixing Permanent OAuth Token Expiration: Moving from Google Cloud Testing Mode to Production

We hit a recurring OAuth authentication failure across our calendar sync pipeline — the same one we'd patched before by re-authing tokens manually. This time, we traced the root cause: Google Cloud's Testing mode artificially limits refresh token lifetime to 7 days, forcing us into a perpetual re-auth cycle. Here's how we diagnosed the issue and what it takes to fix it permanently.

The Problem: Testing Mode Token Expiration

Our calendar integrations (Boatsetter iCal sync, Sailo, GetMyBoat, and internal JADA dashboards) all depend on a shared OAuth 2.0 token stored in DynamoDB and synced to Lambda functions. The token kept expiring every 7 days despite having a valid refresh token.

The culprit: the Google Cloud project remained in OAuth consent screen Testing mode. Google's documentation is explicit — testing-mode apps get 7-day refresh token lifespans as a security measure, since test apps aren't verified. The "fix" of re-authing was treating the symptom, not the disease.

Technical Architecture: Where OAuth Lives

Before diving into the fix, here's the token flow:

  • OAuth source: Google Cloud Console (project ID: jada-sailing-automation)
  • Token storage: DynamoDB table JADA_AUTH_STATE, key GOOGLE_OAUTH
  • Token distribution: Lambda syncs to Lightsail server at /opt/jada/tokens/google_refresh_token.json
  • Consumers:
    • Lambda function CalendarSync (event-driven, processes iCal updates)
    • Google Apps Script BookingAutomation (deployed web app for dashboard)
    • Sailo/Viator API integrations (via Lightsail cron jobs)

Each service independently refreshes the token using the stored refresh token. When the refresh token expires (7 days in testing mode), all downstream consumers break.

Root Cause Analysis: Testing vs. Production Mode

We verified the project status by checking the OAuth consent screen configuration in Google Cloud Console. The critical distinction:

  • Testing mode: Restricted to max 100 test users, refresh tokens expire in 7 days, no app review needed
  • Production mode: Requires OAuth app review by Google, but refresh tokens never expire (until user revokes)

Our project was legitimately in testing mode because the app review was never completed. The scope we're requesting is https://www.googleapis.com/auth/calendar (read/write calendar events), which requires explicit review.

The Fix: Moving to Production (Two-Part Process)

Part 1: Publish the OAuth Consent Screen to Production

This requires updating the consent screen configuration in Google Cloud Console. The steps (UI-only, no API):

  1. Navigate to APIs & Services > OAuth consent screen
  2. Change status from "Testing" to "Production"
  3. Complete the verification section:
    • Verify the domain ownership (jada-sailing.com) via DNS TXT record or Google Search Console
    • Provide app privacy policy URL and terms of service URL
  4. Submit for app review (2-3 business days typical for calendar scope)

Why we didn't do this initially: the app review adds friction to development. But in production, the 7-day cycle creates more friction than the one-time review.

Part 2: Handle Token Refresh During Transition

Once the app is approved, the next refresh automatically gets a non-expiring token. But we need to handle the transition safely:


# File: /Users/cb/Documents/repos/tools/reauth_jada_all.py
# Purpose: Re-auth token once, catch it before 7-day expiration

def refresh_oauth_token_from_refresh():
    """
    Exchanges refresh_token for new access_token using Google OAuth endpoint.
    Stores result back in DynamoDB and syncs to Lightsail.
    """
    refresh_token = get_from_dynamodb('JADA_AUTH_STATE', 'GOOGLE_OAUTH')['refresh_token']
    
    response = requests.post('https://oauth2.googleapis.com/token', data={
        'client_id': os.environ['GOOGLE_CLIENT_ID'],
        'client_secret': os.environ['GOOGLE_CLIENT_SECRET'],
        'refresh_token': refresh_token,
        'grant_type': 'refresh_token'
    })
    
    new_token_data = response.json()
    store_in_dynamodb('JADA_AUTH_STATE', 'GOOGLE_OAUTH', new_token_data)
    sync_token_to_lightsail()  # Push to /opt/jada/tokens/

This script is made executable and triggered by cron on Lightsail, before the 7-day window closes. Once app review completes, we remove the cron job since refresh tokens no longer expire.

Affected Services: What Breaks, What We're Fixing

The OAuth token failure cascades through our entire calendar ecosystem:

  • Boatsetter iCal sync: Lambda CalendarSync tries to write bookings to JADA calendar, fails silently when token is expired
  • Dashboard (GAS): BookingAutomation web app can't fetch calendar events for the crew dispatch view
  • Sailo/Viator integrations: Python scripts on Lightsail can't query Google Calendar for availability checks
  • Internal crew dispatch: DynamoDB table JADA_CREW_DISPATCH updates, but the calendar source-of-truth stays out of sync

The fix unblocks all of these in one move.

Infrastructure Notes: Where the Tokens Live

For future debugging, here's the token distribution map:

Location Format Consumer
DynamoDB:JADA_AUTH_STATE[GOOGLE_OAUTH] JSON (access_token, refresh_token, expires_at) Source of truth
/opt/jada/tokens/google_refresh_token.json (Lightsail) JSON (refresh_token only)