Fixing Permanent OAuth Token Expiration: Moving Google Cloud App from Testing to Production
We've been fighting a recurring OAuth token expiration issue for months. Every seven days, the refresh token dies, requiring manual re-authentication through the Google consent screen. This isn't a bug in our code—it's a deliberate Google Cloud limitation we've been working around instead of fixing. Here's how we finally addressed it permanently.
The Root Cause: Testing Mode Token Lifecycle
Google Cloud projects in Testing mode automatically expire OAuth 2.0 refresh tokens after 7 days of inactivity. This is by design: testing apps aren't production-grade, so Google assumes you'll rebuild or re-auth frequently. Once you move to Production mode, refresh tokens become persistent and don't auto-expire.
Our previous "fix" was reactive: whenever the token died, we'd manually re-authenticate and push a new token to DynamoDB and S3. That worked for a sprint, but we'd be back here a week later. The real solution required moving the OAuth consent screen from Testing to Production.
What Changed: OAuth Consent Screen Configuration
The OAuth consent screen lives in Google Cloud Console—it's UI-only, not API-managed. We needed to:
- Update the app's User Type from "Internal" to "External"
- Complete the OAuth Consent Screen fields: app name, user support email, developer contact info
- Configure scopes for all services we use: Gmail, Google Calendar, Google Sheets
- Submit for Google verification (typically 24–48 hours for low-risk apps)
- Publish the app to Production once verified
This two-click process in Cloud Console is what unlocks persistent refresh tokens.
Scope Configuration for JADA's Services
We're using three Google APIs across different components. Here's exactly what we authorized:
- Gmail API (
https://www.googleapis.com/auth/gmail.modify): Used by notification jobs to send booking confirmations and crew alerts via jadasailing@gmail.com - Google Calendar API (
https://www.googleapis.com/auth/calendar): Used by calendar sync Lambda functions and theCalendarSync.gsApps Script to read/write JADA Internal calendar - Google Sheets API (
https://www.googleapis.com/auth/spreadsheets): Used by Apps Script for crew manifest and dashboard state sync
Each scope is request-time scoped in our token creation flow. The consent screen now lists all three, so users see exactly what permissions they're granting.
Infrastructure Changes: Token Storage and Refresh
Token state lives in two places, both updated when we re-auth:
- S3 Bucket:
s3://jada-internal-state/oauth/google-tokens.json— contains access and refresh tokens, last refresh timestamp, and scope list - DynamoDB Table:
jada-auth-tokens— mirrors the S3 state for Lambda cold-start performance (avoids S3 API call latency)
Both are updated atomically by the reauth script (/Users/cb/Documents/repos/tools/reauth_jada_all.py) to prevent drift.
The Reauth Script: What It Does
We created a Python script to handle reauth in one step instead of manual console clicking. Here's the flow:
# Check current token expiration and scope coverage
python reauth_jada_all.py --check
# Trigger OAuth 2.0 device flow (generates browser URL)
python reauth_jada_all.py --auth
# Verify token validity against all three services
python reauth_jada_all.py --verify
The script:
- Uses device flow OAuth (no secrets in the code—credentials come from Cloud Console)
- Requests all three scopes in one consent screen, reducing user friction
- Validates the new token by making test calls to Gmail, Calendar, and Sheets APIs
- Writes tokens to both S3 and DynamoDB with a timestamp
- Logs reauth events to CloudWatch for audit trails
Made the script executable: chmod +x reauth_jada_all.py
Why Device Flow Instead of Service Account?
We're using device flow (user-facing OAuth) rather than a service account because:
- Service accounts can't send email as jadasailing@gmail.com—Gmail API requires the actual account to authorize itself
- JADA's shared Gmail account is managed by Carole; we don't own the credentials to create a service account there
- Device flow lets the human (you) approve the token once, then it stays valid for months (now that we're in Production mode)
- Device flow doesn't require embedding client secrets in code
Calendar Sync Lambda: What Was Broken
The calendar Lambda functions (CalendarSync.gs in Apps Script, plus three Python Lambdas for Boatsetter/Viator/Sailo pulling) all check DynamoDB for valid tokens before making API calls. When the token expired every 7 days, all three calendar syncs would fail silently:
if not token_valid(dynamodb_tokens['google-refresh-token']):
log("Token expired, calendar sync skipped")
return {"status": "skipped", "reason": "auth_expired"}
This is why Boatsetter bookings weren't auto-syncing to JADA Internal calendar. The Boatsetter iCal URL was configured in CalendarSync.gs line 47, but the trigger to run that sync never fired. Now that we have persistent tokens, the daily trigger will work reliably.
Cloudwatch Monitoring for Token Health
We added a daily CloudWatch Events rule that triggers a Lambda to check token expiration:
- Rule name:
jada-oauth-token-health-check - Schedule:
cron(0 8 * * ? *)— every day at 8 AM UTC - Action: Lambda checks DynamoDB for token age; if older than 30 days, sends Slack alert to #engineering
This gives us a 2-week buffer before token expiration is likely (refresh tokens can be used indefinitely in Production mode, but we'll reauth annually for security).
What's Next
Once Google approves the Production app submission (24–48 hours), we'll:
- Run
reauth_jada_all.py --authonce to get a new persistent refresh token - Verify all three calendar sync Lambdas pull events successfully
- Confirm Boatsetter/Viator/Sailo iCal syncs are working
- Remove the manual reauth task from your weekly checklist
This is a one-time fix. After this, OAuth token expiration shouldn't be a problem again.