# Google OAuth — Hermes Google Workspace Integration

## Current state (as of 2026-06-12)

- **Token path:** `~/.hermes/google_token.json` (chmod 600)
- **Client ID:** `890850960489-5hejo1gd3apvpn1nu1183ohk4u9rlnev.apps.googleusercontent.com`
- **Client secret:** in `~/.hermes/google_client_secret.json` (`installed` app type)
- **Auth URL:** `https://accounts.google.com/o/oauth2/auth`
- **Token URL:** `https://oauth2.googleapis.com/token`
- **Redirect URI:** `http://localhost` (NOT `http://localhost:1` — that was a misconfiguration)

## Installed app vs Web app OAuth

This is an **installed app** OAuth client (not a web app). Key differences:
- No PKCE required by default — but PKCE is supported and should be used for safety
- Redirect URI `http://localhost` (port 80) — not `http://localhost:1` or `http://127.0.0.1:1`
- `localhost:1` triggers `ERR_UNSAFE_PORT` in Chrome — this is normal, the code is in the URL bar before the error

## Test users — unverified OAuth apps

The `hermes-lfcs` Google Cloud project is in **testing mode** (unverified). Unverified apps only allow explicitly added test users:

1. Go to [console.cloud.google.com](https://console.cloud.google.com) → **APIs & Services → OAuth consent screen**
2. Select the app → **Test users** → **Add Users**
3. Add `mcloughlinmichael.r@gmail.com`
4. Save

Without this, all auth attempts return `403: access_denied` even with a valid code.

## Auth URL — building it manually

The `setup.py` scripts in the skill profiles have broad scopes hardcoded. Build the URL with only the scopes you need:

```python
import urllib.parse

params = {
    'response_type': 'code',
    'client_id': '890850960489-5hejo1gd3apvpn1nu1183ohk4u9rlnev.apps.googleusercontent.com',
    'redirect_uri': 'http://localhost',
    'scope': 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly',
    'access_type': 'offline',
    'prompt': 'consent',
    'state': '<random_state>',  # optional but recommended
    'code_challenge': '<pkce_challenge>',  # optional if not using PKCE
    'code_challenge_method': 'S256'  # required if code_challenge present
}
url = 'https://accounts.google.com/o/oauth2/auth?' + urllib.parse.urlencode(params)
```

**For morning-digest (readonly):** `gmail.readonly` + `calendar.readonly` only.

## Token exchange

Authorization codes are **single-use** and expire quickly (~60 seconds). Exchange immediately after getting the code from the browser redirect.

**With PKCE:**
```python
import urllib.request, urllib.parse, json, hashlib, base64, os

code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode()
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()

data = urllib.parse.urlencode({
    'code': '<AUTH_CODE>',
    'client_id': '<CLIENT_ID>',
    'client_secret': '<CLIENT_SECRET>',
    'redirect_uri': 'http://localhost',
    'grant_type': 'authorization_code',
    'code_verifier': code_verifier
}).encode()

req = urllib.request.Request('https://oauth2.googleapis.com/token', data=data,
    headers={'Content-Type': 'application/x-www-form-urlencoded'})
resp = json.loads(urllib.request.urlopen(req).read())
```

**Without PKCE** (this installed app client works without it):
```python
data = urllib.parse.urlencode({
    'code': '<AUTH_CODE>',
    'client_id': '<CLIENT_ID>',
    'client_secret': '<CLIENT_SECRET>',
    'redirect_uri': 'http://localhost',
    'grant_type': 'authorization_code'
}).encode()
```

## Token structure

The saved token (`~/.hermes/google_token.json`) contains:
- `access_token` — expires in ~1 hour
- `refresh_token` — long-lived, used to get new access tokens
- `expiry` — ISO timestamp when access_token expires
- `scopes` — array of granted scopes
- `account` — often empty for installed apps (user info requires a separate `userinfo` call)

## Token refresh

```python
data = urllib.parse.urlencode({
    'client_id': '<CLIENT_ID>',
    'client_secret': '<CLIENT_SECRET>',
    'refresh_token': '<REFRESH_TOKEN>',
    'grant_type': 'refresh_token'
}).encode()
```

## Verifying token — real API call

```python
import urllib.request
req = urllib.request.Request('https://www.googleapis.com/gmail/v1/users/me/profile',
    headers={'Authorization': 'Bearer ' + token['access_token']})
with urllib.request.urlopen(req) as r:
    print(json.loads(r.read()).get('emailAddress',''))
```

## Scope audit (2026-06-12)

Old broad-scopes token (expired May 11 2026) had 10 scopes:
`gmail.readonly`, `gmail.modify`, `gmail.send`, `calendar`, `drive.readonly`, `drive.file`, `documents`, `documents.readonly`, `spreadsheets`, `contacts.readonly`

Morning-digest only needs: `gmail.readonly` + `calendar.readonly`

## Multiple Google accounts — keeping tokens separate

If managing multiple Google accounts, store tokens at different paths:
- `~/.hermes/google_token.json` — primary (mcloughlinmichael.r@gmail.com)
- Other accounts — use distinct token paths, pass via `GOOGLE_TOKEN_PATH` env var

Never overwrite the wrong token file. Confirm `account` field or do a test API call before assuming a token belongs to a particular account.