Fixing OAuth Token Expiry in Google Apps Script: Moving from Testing to Production Mode
The Problem
Our Google Apps Script (GAS) project managing calendar synchronization kept requiring manual re-authentication every 7 days. The root cause wasn't a bug in our code—it was a configuration issue in Google Cloud Console: the OAuth consent screen was stuck in Testing mode, which artificially caps refresh token lifetime to 7 days regardless of your token configuration.
This affected three critical workflows:
- CalendarSync.gs — syncing Boatsetter iCal bookings into JADA Internal calendar
- Lambda calendar endpoint — reading crew dispatch events from Calendar API
- Viator/Sailo supplier integrations — blocking booked dates across multiple platforms
Every 7 days, Gmail OAuth and Calendar API scopes would revoke, breaking all downstream automation until we manually clicked through the Google login flow again.
Root Cause: Testing vs. Production Mode
Google Cloud's OAuth consent screen has two modes:
- Testing: Unlimited test users, but refresh tokens expire in 7 days. Intended for development only.
- Production: Requires user consent verification and app review, but refresh tokens are valid indefinitely (until user revokes).
Our project was configured as Testing because the initial setup was done during development and never transitioned to Production. This is a common gotcha—the default state works fine locally, so it stays that way until automation breaks in production.
Technical Details: What Needed to Change
Step 1: Verify Current OAuth Configuration
First, we verified the current state via CLI:
gcloud auth list
gcloud config get-value project
gcloud iam service-accounts list
This confirmed we had CLI access to the Google Cloud project. We then checked the OAuth app's current consent screen status through the Cloud Console (this is UI-only; there's no API endpoint to programmatically read the consent screen mode).
Step 2: Identify Required Scopes
Our GAS project uses these OAuth scopes in appsscript.json:
https://www.googleapis.com/auth/calendar— read/write calendar eventshttps://www.googleapis.com/auth/gmail.readonly— read booking confirmation emailshttps://www.googleapis.com/auth/spreadsheets— read/write booking state in Sheets
All three are considered "sensitive" scopes by Google, meaning Production mode requires explicit review. However, since this is an internal tool used only by JADA staff (not public-facing), the review process is straightforward.
Step 3: Prepare the OAuth Consent Screen for Production
The transition requires these fields in Cloud Console → OAuth consent screen:
- App name: "JADA Sailing Calendar Sync"
- User support email: jadasailing@gmail.com
- Scopes: calendar, gmail.readonly, spreadsheets (all marked as sensitive)
- Developer contact information: jadasailing@gmail.com
Critically, we do not need to publish to Google's public app gallery. We're using the "Internal" user type, which means only JADA staff can access it—Google doesn't require the same level of scrutiny for internal apps.
Step 4: Update Token Storage and Refresh Logic
Our existing token refresh mechanism in /Users/cb/Documents/repos/tools/reauth_jada_all.py works correctly; it just needs to be run once after Publishing to Production. The script handles:
- Initiating the OAuth flow (user clicks the login link)
- Capturing the authorization code from the redirect
- Exchanging the code for an access token + refresh token
- Storing the refresh token in AWS Secrets Manager at
jada/google/refresh_token - Syncing the token to the Lightsail server running the calendar Lambda
The Python script uses the google-auth-oauthlib library to handle the OAuth 2.0 authorization code flow. Once Production mode is enabled and the consent screen is updated, the token generated by this script will have an indefinite lifetime (until manually revoked).
Step 5: Update Lambda Environment Variables
The Lambda function at arn:aws:lambda:us-east-1:ACCOUNT_ID:function:calendar-sync reads the refresh token from environment variable GOOGLE_REFRESH_TOKEN. After obtaining a new token in Production mode, we update this via:
aws lambda update-function-configuration \
--function-name calendar-sync \
--environment Variables={GOOGLE_REFRESH_TOKEN=<new_token>,GOOGLE_CLIENT_ID=<id>,GOOGLE_CLIENT_SECRET=<secret>}
Note: The CLIENT_ID and CLIENT_SECRET don't change; only the REFRESH_TOKEN is new.
Why This Approach
- Permanent fix vs. band-aid: We fixed the root cause (Testing mode) instead of just re-authing every 7 days.
- No code changes: Our existing GAS and Lambda code works unchanged; this is purely a Google Cloud configuration change.
- Internal-only scope: We don't need full public app review; Google's internal app path is faster.
- Minimal friction: Once transitioned, no further manual intervention is needed—the refresh token stays valid indefinitely.
Implementation Path
- Navigate to Google Cloud Console → APIs & Services → OAuth consent screen
- Update app name, support email, and developer contact (jadasailing@gmail.com)
- Ensure all three scopes (calendar, gmail.readonly, spreadsheets) are listed as "sensitive"
- Set user type to "Internal"
- Click "Publish to Production" — Google will verify the app is internal and approve in minutes
- Run
python3 reauth_jada_all.pyto generate a new refresh token valid indefinitely - Update Lambda environment variable with the new refresh token
- Test the calendar sync endpoints to confirm all scopes work
Next Steps
After Production mode is enabled and the new token is deployed:
- Verify
CalendarSync.gscan write to JADA Internal calendar without token expiry - Confirm Lambda calendar endpoint returns events for all crew dispatch queries
- Test Boatsetter iCal sync trigger (activate
calendarDashboardSetup()in GAS editor) - Monitor Secrets Manager refresh token age to catch any future issues
This change eliminates a recurring operational headache and ensures our booking automation runs reliably without manual re-authentication cycles.