Fixing Google OAuth Token Expiry in Production: Moving from Testing to Published Consent Screen

We discovered a critical issue in our calendar synchronization infrastructure: OAuth refresh tokens were expiring every 7 days despite being issued with offline access. The root cause? Our Google Cloud OAuth application was stuck in Testing mode, which enforces a hard 7-day refresh token TTL. This post details the diagnosis, the permanent fix, and why this matters for calendar integrations across Boatsetter, Sailo, and internal systems.

The Problem: Why We Keep Re-authing

Multiple systems depend on Google Calendar OAuth tokens stored in DynamoDB and synced to AWS Lambda functions running on Lightsail:

  • /Users/cb/Documents/repos/tools/reauth_jada_all.py — manual re-authentication script
  • Lambda functions invoking CalendarSync.gs in Apps Script
  • Google Sheets operations requiring calendar read/write permissions

Every 7 days, these tokens became invalid. The band-aid was running the reauth script and pushing new tokens to the Lambda environment. This was unsustainable and pointed to a deeper architectural issue.

Root Cause: Testing vs. Published OAuth Apps

Google Cloud distinguishes between two OAuth consent screen states:

  • Testing mode: Designed for development. Refresh tokens expire in 7 days. Limits to 100 test users.
  • Published mode: Production application. Refresh tokens are long-lived (years, until revoked). No user limits.

Our project had all the infrastructure for published mode—scopes configured, OAuth brand settings defined—but the consent screen was never moved out of Testing. This meant every token exchange resulted in a short-lived refresh token, regardless of the access_type=offline parameter.

Technical Diagnosis Process

We verified this through the following checks:

gcloud auth list
# Confirmed gcloud CLI was authenticated to the correct GCP project

gcloud projects describe $PROJECT_ID --format="value(projectNumber)"
# Retrieved project number for API calls

# Checked OAuth consent screen via Cloud Console UI
# (API-only approach doesn't expose consent screen settings)

Token inspection in DynamoDB showed:

  • Tokens created with expires_in: 3600 (1 hour access token)
  • Refresh token present but hitting 7-day expiry wall
  • No way to distinguish between a 7-day expiry (Testing) vs. indefinite (Published) from the token itself

The Fix: Publishing the OAuth Consent Screen

The solution requires moving the consent screen from Testing to Published mode in the Cloud Console. This is a UI-only operation (no API automation available), but it's straightforward:

  1. Navigate to Google Cloud ConsoleAPIs & ServicesOAuth consent screen
  2. Verify that all required fields are populated:
    • App name, user support email, developer contact info
    • Scopes: https://www.googleapis.com/auth/calendar, https://www.googleapis.com/auth/spreadsheets, https://www.googleapis.com/auth/gmail.readonly
    • OAuth brand configuration (logo, privacy policy, terms of service URIs)
  3. Click "Publish app" to move to Production

Once published, all future OAuth flows will issue refresh tokens with indefinite TTL (subject only to user revocation).

Infrastructure Impact

This change affects the following components but requires no code changes:

  • Lambda environment: OAuth tokens synced via /tokens directory on Lightsail (sourced from DynamoDB) will no longer expire every 7 days
  • Apps Script deployments: CalendarSync.gs` and `BookingAutomation.gs will maintain valid OAuth context indefinitely
  • Boatsetter/Sailo iCal sync triggers: Once calendarDashboardSetup() is invoked (see separate ticket `m-91325edb`), the sync will run continuously without token failures
  • reauth_jada_all.py: This script can still be used for manual token refresh (e.g., scope changes), but 7-day forced re-auth becomes unnecessary

Why This Matters: Architecture Decision

We deliberately chose to store OAuth tokens in DynamoDB rather than OAuth2-as-a-service providers because:

  • Tokens are used by Lambda functions that don't have browser-based OAuth redirect capability
  • A single canonical token (stored in DynamoDB, synced to Lightsail) provides observability—we can audit when tokens were last used, log failures, and understand token lifecycle
  • Apps Script execution context can read the token from DynamoDB directly, avoiding additional service-to-service hops

The weakness was assuming the token refresh policy would be stable. Moving to Published consent screen addresses this.

Testing and Validation

After publishing the consent screen:

  1. Run a fresh OAuth flow to obtain a new refresh token
  2. Store it in DynamoDB under the calendar token key
  3. Sync to Lightsail: aws s3 sync s3://jada-tokens /tokens --region us-east-1
  4. Invoke Lambda calendar endpoint and confirm success
  5. Wait 8+ days and verify token still works (confirming it's not a 7-day expiry token)

Commands to verify token status (after publishing):

# Check token creation timestamp in DynamoDB
aws dynamodb get-item \
  --table-name OAuthTokens \
  --key '{"service": {"S": "google_calendar"}}' \
  --region us-east-1

# Verify Lambda can invoke calendar endpoint
curl -H "Authorization: Bearer $API_KEY" \
  https://api.sailjada.com/calendar/events?date=2024-05-30

What's Next

  • Publish OAuth consent screen (Cloud Console, 1-2 clicks, no code review needed)
  • Obtain new refresh token via updated reauth_jada_all.py
  • Update DynamoDB and Lightsail tokens with long-lived token
  • Activate Boatsetter/Sailo sync triggers by invoking calendarDashboardSetup() in Apps Script (separate ticket `m-91325edb`)
  • Remove 7-day re-auth from rotation—this becomes a non-issue going forward

This is a foundational fix that eliminates a recurring operational task and positions the calendar sync infrastructure for long-term stability.