WSGI script: ~/py/popit3/wsgi_google_oauth.py, deployed to /usr/local/www/wsgi-scripts/wsgi_google_oauth.py on gravlax. Runs the Google OAuth2 authorization code flow and writes the token to WebDAV. google_services.py fetches from WebDAV on RefreshError to self-heal.
See Re-authentication URLs for the renewal link.
Runs as www-data (the web server user). No special daemon user. Secrets (client ID and secret) live in /etc/apache2/conf-available/wsgi-google-oauth.conf on gravlax — not in git, chmod 640 root:www-data.
Token is written to /var/www/webdav/google_tokens/google_token.json (owned admin:www-data, chmod 775). This is served via WebDAV at https://webdav.critchley.biz/google_tokens/google_token.json.
1. GET /authorize-google/ → script redirects browser to Google consent page, stores CSRF state in /tmp/google_auth_states/.
2. User approves scopes on Google's site.
3. Google redirects to https://www.critchley.biz/authorize-google/callback?code=...&state=....
4. Script validates state, exchanges code for token, writes to WebDAV. Shows success page.
5. On next popit3 cron run, google_services.py picks up the fresh token from WebDAV automatically.
RedirectMatch ^/google$ /authorize-google/
WSGIScriptAlias /authorize-google /usr/local/www/wsgi-scripts/wsgi_google_oauth.py
<Location /authorize-google>
SetEnv REDIRECT_URI https://www.critchley.biz/authorize-google/callback
Include /etc/apache2/conf-available/wsgi-google-oauth.conf
</Location>
ProxyPassMatch ^/authorize-google/ !
wsgi-google-oauth.conf contains SetEnv GOOGLE_CLIENT_ID and SetEnv GOOGLE_CLIENT_SECRET. These are read from the WSGI per-request environ (NOT os.environ — see lessons below).
SetEnv in a <Location> block populates the per-request WSGI environ dict passed to the application() function. It does NOT appear in os.environ. All helper functions that need config must accept and use the WSGI environ dict.
def _cfg(wsgi_environ, key, default=None):
return wsgi_environ.get(key) or os.environ.get(key) or default
The google-auth library's _helpers.utcnow() returns a naive (timezone-unaware) datetime. If the token's expiry field is timezone-aware (e.g. 2026-06-25T13:10:49+00:00), comparing them raises TypeError: can't compare offset-naive and offset-aware datetimes.
Fix in google_services.py _dict_to_creds():
if expiry.tzinfo is not None:
expiry = expiry.astimezone(timezone.utc).replace(tzinfo=None)
Client: John Critchley experiments (1048362443290-2qr9pds7lvut0geoilo8pg0fdndqn575.apps.googleusercontent.com). Authorised redirect URI: https://www.critchley.biz/authorize-google/callback.
https://www.googleapis.com/auth/gmail.modify
https://www.googleapis.com/auth/calendar
https://www.googleapis.com/auth/drive
On RefreshError, _load_creds() fetches the token from WebDAV using .netrc credentials for webdav.critchley.biz, saves it to ~/.google_token.json, and retries. No manual intervention needed unless the WebDAV token is also expired.
Incident 2026-07-08: the Google Calendar refresh token (renewed 2026-06-25) had silently expired, and had been failing on every single popit3 run for about two weeks ("Google refresh token invalid — checking WebDAV for fresh token..." followed by "Google Calendar unavailable: invalid_grant: Token has been expired or revoked." — the WebDAV fallback token was equally stale, so self-heal could not kick in). Non-fatal (popit3 exits RC 0 regardless — see popit3/ops), but David Lloyd bookings silently stopped syncing to Google Calendar for that whole period.
Likely root cause: if the Google Cloud OAuth consent screen for "John Critchley experiments" is still in Testing mode, Google caps refresh tokens at 7 days — June 25 → July 8 is suspiciously close to two 7-day cycles. Fix for renewal: visit https://www.critchley.biz/google (you will see "Google hasn't verified this app" — expected for a Testing-mode app, click through). Fix to stop recurrence: either publish the app out of Testing mode in Google Cloud Console, or confirm the account is added as a test user with a longer-lived grant. Not yet done as of 2026-07-08 — worth checking next time this expires.
MyDavidLloydSchedule.py's pipeline-error banner (see popit3/ops for the banner mechanism) now appends a "Renew Google auth" link straight to https://www.critchley.biz/google whenever an error message contains "Google" or "GCal". So next time a token expires, the David Lloyd Bookings report itself tells you what to click, instead of needing to look this page up.