# Google Auth Reference

The `google` plugin connects one Google Calendar per registered Google account,
using a **Desktop (installed app)** OAuth client whose id and secret ship in
brand config. This file is the decision record and the vendor runbook.

Decided 2026-07-18, superseding the earlier hosted-HTTPS-callback model. That model is gone from the tree; see
["What was removed"](#what-was-removed) for what to expect on an old device.

---

## Vendor runbook: creating the client (one-time per brand)

Not an end-user step. Run once per brand; each brand is a separate provisioning
follow-up, mirroring Outlook 1479/1485.

1. Sign in to the [Google Cloud console](https://console.cloud.google.com) with
   the brand's Google account and select the brand's **existing** project — not
   a new one, so the consent screen and any verification progress carry over.
   For `sitedesk-code` that project is `Sitedesk Calendar`.
2. Enable the **Google Calendar API** (APIs & Services → Library). It is the
   only API this plugin calls.
3. Configure the **OAuth consent screen**: user type **External**, app name and
   support email set to the brand's, and the single scope from
   ["Scope"](#scope) below. Publishing status starts at **Testing**.
4. **Add test users** while the app is in Testing. Consent is refused for anyone
   not on that list, including the operator, and the refusal renders as an
   access-denied screen that reads like a flow bug.
5. **Credentials → Create credentials → OAuth client ID**, application type
   **Desktop app**, named for the brand. Register redirect `http://127.0.0.1`.
6. Copy the **Client ID** and **Client secret** into that brand's `brand.json`:

   ```jsonc
   // brands/<brand>/brand.json
   "googleClientId": "<the client id>",
   "googleClientSecret": "<the client secret>"
   ```

7. **Run the loopback pre-flight below before publishing.** It is the only step
   that catches a wrong client type, and it costs seconds.
8. Re-bundle and publish the installer. No file is placed on any device by hand.

### Pre-flight: confirm the client accepts loopback

The failure this catches is a **Web** client pasted in where a Desktop one
belongs. Nothing else in the chain notices: the brand bundles, the installer
stamps, the plugin boots reporting both values configured, and the consent URL
builds. The first symptom is a customer completing consent and hitting
`redirect_uri_mismatch`, which reads as an expired code rather than a wrong
client type.

The discriminator is that a genuine Desktop client accepts a loopback redirect
**without any redirect being registered on it**, because RFC 8252 §7.3 makes the
port irrelevant for the `127.0.0.1` literal. A client that refuses loopback is
therefore not a usable Desktop client, whatever the console labels it.

Sends the client id only, never the secret. Non-mutating.

```bash
CID=$(node -e 'console.log(JSON.parse(require("fs").readFileSync("brands/<brand>/brand.json","utf8")).googleClientId.trim())')
curl -s -D - -o /dev/null \
  "https://accounts.google.com/o/oauth2/v2/auth?client_id=${CID}&redirect_uri=http%3A%2F%2F127.0.0.1%3A8571&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar" \
  | grep -i '^location:' | grep -q 'authError' && echo REJECTED || echo ACCEPTED
```

The two words report **what Google did with the redirect**, not whether you have
a problem. Which of them is the good news depends on which redirect you sent, so
read them against the table below rather than by tone.

`ACCEPTED` means Google took the redirect and moved the browser to the sign-in
flow. `REJECTED` means it bounced to an error page; decode the `authError`
payload to see which error:

```bash
# ...append to the curl above instead of the grep chain
| sed -E 's/.*authError=([^&]*).*/\1/' | tr '_-' '/+' | base64 -d | head -c 200
```

- `redirect_uri_mismatch` — the client will not take a loopback redirect. It is
  a Web client, or a Desktop client whose configuration was altered. Create a
  **Desktop app** client and use that pair.
- `invalid_request` — the redirect in the probe itself is malformed. Check the
  URL-encoding rather than the client.

**Both outcomes return HTTP 302**, so the status code is not the signal and a
probe that checks it reports success on a broken client. The `authError`
parameter is the signal.

Run the probe **twice**, and read the pair:

| Run | Redirect sent | Required result | What it establishes |
|---|---|---|---|
| Subject | `http://127.0.0.1:8571` | `ACCEPTED` | The client takes loopback, so it is a usable Desktop client |
| Control | `https://example.invalid/cb` | `REJECTED` (`invalid_request`) | The probe can still detect a rejection |

`REJECTED` on the control is the **expected and required** outcome — it is not a
fault, and nothing about the client needs changing when you see it. Its only job
is to prove the probe is still measuring something.

The failure mode it guards against is a probe that has silently stopped
measuring: if the `grep` chain breaks — Google renames the error parameter, or
stops emitting a `location:` header — the command still exits cleanly and still
prints `ACCEPTED`. Silent green. `ACCEPTED` on **both** runs means the probe is
dead and the subject run proves nothing; fix the probe before trusting it.

Never act on the control run alone. Reading its `REJECTED` as a verdict on the
client would send you to replace a credential that is working.

Trailing whitespace on a pasted id is a real and silent failure; nothing in the
chain trims it. The `.trim()` above is for the probe only, so the probe can pass
while the shipped value is broken. Confirm the stored value is clean:

```bash
node -e 'const v=JSON.parse(require("fs").readFileSync("brands/<brand>/brand.json","utf8")).googleClientId; console.log(v===v.trim()?"clean":"WHITESPACE")'
```

**The client secret is shown exactly once.** The console displays only the last
four characters afterwards ("Viewing and downloading client secrets is no longer
available"). Capture it at creation straight into `brand.json`, or plan to
rotate — rotation is free and has no downtime: **Add secret**, store the new
one, delete the old. Never paste it into a chat transcript or a shell command
that lands in history.

Confirm the stamp took from the boot line, which reports presence and never
values:

```bash
grep -m1 'google-mcp. boot' ~/.<brand>-code/logs/server.log
# ... clientIdConfigured=true clientSecretConfigured=true
```

Either reading `false` on a brand that ships both is the mis-stamp signature.

### Connecting an account, once the brand ships a client

`google-account-register` returns a consent URL. The user opens it on any device
and approves. What happens next depends on **where** they approved, and both
paths are supported.

**On the install's own machine.** Register binds a single-shot listener on
`127.0.0.1` at an OS-assigned port and puts that port in the consent URL, so
Google's redirect reaches it. The listener verifies `state`, completes the
exchange in-process, and renders a page naming the connected account. Nothing is
pasted. The page asks the user to return to the chat and say so, because the
agent has no callback and learns the outcome from
`google-account-register-poll`.

**On another device.** A phone cannot route to the install's loopback, so the
redirect fails to load exactly as before. The user copies the whole address-bar
URL and passes it to `google-account-register-submit`, which exchanges it
in-process. This path is not a degraded mode; it is the designed route for the
Pi deployment, where the admin always consents from a phone.

That failed-page step remains a real UX cost on the cross-device path, priced
and accepted: some mobile browsers hide the URL of a failed navigation. It is
the price of a client type whose secret can legitimately ship to customer
devices, and it cannot be removed — the phone has no route to the install.

**The port is no longer fixed**, and the consent URL is no longer deterministic.
RFC 8252 §7.3 is what permits this: Google ignores the port when matching a
loopback redirect, so binding `:0` is free. It also retires a latent collision,
since a fixed port is shared by every brand and two co-resident brands would
contend for it. `http://127.0.0.1:8571` survives as the fallback when the bind
fails, which keeps an install that cannot bind working exactly as it did before.

Because the port varies per registration, the pending entry stores the redirect
it issued and the exchange sends that stored value back. Google compares the two
byte for byte, so a registration whose listener bound a port cannot be completed
against the constant.

---

## Why this shape

### Device-code is rejected (scope allowlist)

Outlook uses Microsoft's `/devicecode` grant, so the operator never needs a
browser on the box. That grant does not work for Calendar. Google's flow for TV
and limited-input devices serves only a fixed allowlist. Verbatim from
[OAuth 2.0 for TV and Limited-Input Device Applications](https://developers.google.com/identity/protocols/oauth2/limited-input-device):

> The OAuth 2.0 flow for devices is supported only for the following scopes:
>
> **OpenID Connect, Google Sign-In:** `email`, `openid`, `profile`
> **Drive API:** `https://www.googleapis.com/auth/drive.appdata`, `https://www.googleapis.com/auth/drive.file`
> **YouTube API:** `https://www.googleapis.com/auth/youtube`, `https://www.googleapis.com/auth/youtube.readonly`

The Calendar scope is absent, so a device-flow request carrying it is refused.
The flow is therefore authorization-code + PKCE.

### Out-of-band is dead, so a redirect is mandatory

Google blocked new out-of-band use on 28 Feb 2022 and all existing clients on
31 Jan 2023 ([OOB migration guide](https://developers.google.com/identity/protocols/oauth2/resources/oob-migration)).
A `redirect_uri=urn:ietf:wg:oauth:2.0:oob` request is refused. Consent must land
on a real redirect URI.

### The secret cannot be avoided, so the client type must sanction shipping it

Google requires `client_secret` at the token endpoint for **every** client type,
including Web clients using PKCE. There is no public-client path, so any install
performing its own exchange holds one.

A Desktop client is the one type where Google explicitly sanctions embedding
that secret in distributed software — "the client secret is obviously not
treated as a secret". `brand.json` is therefore a legitimate carrier, and the
installer stamps `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` **together or
not at all**. A client id without its secret builds a consent URL the user can
complete but an exchange that cannot, which fails later and less legibly than
not offering the flow.

The secret ships to a **public** npm registry. That is the accepted trade: the
residual risk is app impersonation and quota abuse against the brand's client
id, **not** token theft, because PKCE still binds each code to its verifier.

Rejected, each once and for a reason:

- **Customer-owned apps** (the QuickBooks pattern at `quickbooks/PLUGIN.md:71`,
  where the operator registers their own app). Unlike Intuit, Google demands app
  verification for the sensitive Calendar scope — homepage, privacy policy,
  domain ownership, demo video — which the target customer cannot produce.
- **A vendor-hosted exchange broker.** Best security and best UX, but it means
  operating a credential-bearing service for every customer.
- **A Web client with the secret shipped anyway.** A Web-app secret is genuinely
  confidential in Google's model; publishing it would be a real credential leak
  rather than a sanctioned embed.
- **A house-only secret file** (`platform/config/google-house.env`, the earlier
  hosted-callback model). Requires a manual placement per install, so it cannot reach a
  customer device and contradicts installer-only doctrine.

### The redirect is loopback, and the IP literal is mandatory

Desktop clients permit only loopback redirects. `https` is rejected outright and
custom URI schemes are withdrawn ("no longer supported due to the risk of app
impersonation"), so this is the entire option space:

```
http://127.0.0.1:8571
```

**Never `localhost`.** RFC 8252 §7.3 makes the port irrelevant when matching a
loopback redirect, but only for the IP literals `127.0.0.1` and `::1`; the
hostname is matched literally. Register `http://127.0.0.1` on the client.

The port is fixed and arbitrary. Google ignores it for loopback and nothing
listens on it, so a constant keeps consent URLs deterministic and one register's
logs comparable with another's.

---

## Scope

One scope, chosen to match the Outlook calendar tool surface (read plus create,
update, cancel, respond, free-busy), no broader:

| Purpose | Scope | Class |
|---|---|---|
| Calendar read + event create/update/cancel/respond/free-busy | `https://www.googleapis.com/auth/calendar` | sensitive |

The account's email is read from `GET /calendars/primary` (whose id is the
address) rather than by requesting `openid`/`email`/`profile`, so consent stays
minimal.

A refresh token requires `access_type=offline` plus `prompt=consent` on the
consent URL; those are request parameters, not scopes.

The narrower alternative is `calendar.events` + `calendar.readonly` +
`calendar.freebusy`. It has never been exercised. All Calendar scopes are
*sensitive*, not *restricted*, so narrowing would not change the verification
path.

## Verification: the light kind, not the security assessment

Calendar is a **sensitive** scope, not a restricted one. A production app using
it needs only OAuth **brand/consent-screen verification** (own the domain;
Google reviews app name, logo, and scope justification). It does **not** need
the annual third-party security assessment, which applies only to *restricted*
scopes such as the Gmail read/modify family. Dropping the Gmail surface is
exactly what keeps a brand off that review — mail runs on the `email` plugin's
IMAP/SMTP path instead.

The two publishing states, and what each costs:

- **Testing** (no verification): works immediately for up to **100 named test
  accounts**, each seeing the unverified-app warning and re-consenting about
  every 7 days. Fine for a pilot.
- **In production + brand-verified** (no cost, no security firm, typically days
  to a couple of weeks): the 100-account cap lifts, the warning is gone, and
  refresh tokens are long-lived. This is the real deployment state and the
  target for each brand. Start it early, because Google's review is the slow
  part.

The 7-day Testing cap is measured, not predicted — see below. It is Google
forcing re-consent until the brand is verified, **not** a refresh failure, and
the health signal names it distinctly.

### Recorded round-trip (2026-07-18)

Run against the since-retired `sitedesk-code` Web client. The client type has
changed, but these facts are properties of the Calendar scope and the consent
screen, not of the client type, so they carry forward. Values deliberately not
recorded.

| Check | Result |
|---|---|
| Calendar scope granted via browser consent | **yes** — `scope=https://www.googleapis.com/auth/calendar` returned on the redirect and echoed in the token response |
| Refresh token issued (`access_type=offline`, `prompt=consent`) | **yes** — `refresh_token` present |
| Access-token lifetime | `expires_in: 3599` (1 hour) |
| Refresh-token lifetime in **Testing** | `refresh_token_expires_in: 604799` — **6.99 days**, confirming the 7-day Testing cap empirically |
| Device-code refusal | not run empirically; rests on Google's published allowlist above. Changes no decision |

The 604799 figure is the concrete argument for completing brand verification: in
Testing, every connected account silently re-consents weekly.

---

## Token store and pending-state layout

Mirrors Outlook's per-account encrypted layout: one AES-256-CBC `.key` per
account, `tokens.enc` under `accounts/<id>/`, mode 0700 dirs and 0600 files,
atomic temp+rename writes.

```
data/accounts/<accountId>/secrets/google/
├── .key                          # 32 random bytes, base64 — per-account AES-256-CBC key
├── accounts/
│   └── <userId>/tokens.enc       # AES-256-CBC token JSON blob per registered Google account
└── pending-oauth.enc             # transient: PKCE verifier between register and submit
```

Two divergences from Outlook are load-bearing:

- **Pending state is a PKCE verifier, not a device code.** Register writes it;
  `register-submit` reads and clears it. Single-slot per account, refuses a
  second concurrent register with an `in-progress` signal, cleared on any
  terminal outcome.
- **The concurrent-refresh race is not reproduced.** Outlook's token writer used
  a fixed `.tmp` name and could wipe valid tokens when two refreshes raced. The
  Google writer uses a unique temp name per write, and its refresh catch never
  clears valid tokens on an IO error. A losing concurrent writer must not wipe
  credentials.

Refresh runs **in-process** against Google, within 5 minutes of access-token
expiry. No `platform/ui` code mints, exchanges or refreshes a credential; its
only Google surface is the standing pending audit below, which reads the age of
`pending-oauth.enc` and never decrypts it.

---

## Standing audits

Both states these cover are unobservable by any event: an abandoned registration
fires nothing, and a refresh token expiring is a date passing, not an action.
`runAccountRegisterPoll` emits `op=pending-stranded` only when somebody polls —
and the case worth catching is the one nobody polls.

- **Stranded pending.** In-process in `platform/ui`
  (`server/google-pending-audit.ts`), every 5 minutes, over every account uuid
  under `data/accounts`. Any `pending-oauth.enc` at or past
  `PENDING_LIFETIME_SEC` (600s) emits
  `[google-oauth] op=pending-stranded account=<id> ageSec=<N>`. It runs in-process
  rather than in the spawned script precisely so the signal does not depend on a
  spawn being healthy — the same rationale as Outlook's `devicecode-stranded`
  audit. A pending file that vanishes mid-scan (a concurrent submit clearing it)
  is skipped per-account; it must never abort the cycle, because that would
  suppress the heartbeat and read as the audit being dead.
- **Refresh TTL.** Spawned `mcp/dist/scripts/account-audit.js`, hourly, one
  `[google-account-audit] account=<id> email=<addr> refreshTtlDays=<D> status=<…>`
  per connected Google account. It is a spawned script, not in-process, because
  classifying a token means decrypting the blob with the per-account AES key and
  `TokenStore` is the only holder of that contract. An unreadable blob is skipped,
  never fatal — one corrupt store must not blind the audit to every other account.

**Heartbeats, and why they are not optional.** Each audit emits a heartbeat every
cycle, including empty ones. Without it, an audit that has stopped running looks
exactly like an audit reporting all-clear, and the operator learns nothing until
an account has already fallen off. The absence of a heartbeat across more than
one interval is the failure signal. A pending cycle that throws logs
`op=pending-audit-error` — a deliberately distinct token, so a broken cycle is
never miscounted as a healthy heartbeat by a reader grepping `op=pending-audit`.

**What `refreshTtlDays` is, exactly.** Days remaining on Google's 180-day
inactivity revocation clock, computed as `180 - daysSince(lastRefresh)` and
clamped so a clock that ran ahead cannot report more than 180. `TokenBlob` stores
no refresh-token expiry because Google issues none, so this inactivity clock is
the only bounded quantity available locally. It does **not** predict the other
revocation causes — the user revoking access, a password change, the app being
unpublished — none of which are visible from disk. `refreshToken: null` is
reported `expired` with zero days however recently it was written, since such a
connection dies with its access token about an hour out.

Diagnostic path:

```
~/<brand>-code/platform/scripts/logs-read.sh --tail server 200 \
  | grep -E '\[google-oauth\]|\[google-account-audit\]'
```

---

## What was removed

The earlier hosted-callback model built a hosted HTTPS callback at
`https://<brand-public-host>/api/google/oauth/callback`, served by the
`platform/ui` server, plus a refresh route and a house-only secret file. A
Desktop client cannot register an HTTPS redirect, so all of it is deleted:
`platform/ui/server/routes/google-oauth.ts` and its test, the
`/api/google/oauth/callback` allowlist entry, the mount in `server/index.ts`,
and the `MAXY_UI_INTERNAL_PORT` dependency. No OAuth surface remains in
`platform/ui` — no route, no exchange, no credential — and a test asserts the
public host still refuses that path. The one `platform/ui` file that mentions
Google is `server/google-pending-audit.ts`, which reads the age of a pending file
and is not reachable over HTTP at all.

**Devices touched by the earlier plugin may still hold
`platform/config/google-house.env`, containing the retired Web client's live
secret.** Nothing reads it and nothing removes it. Delete it on each such
device.

### Provisioned clients

| Brand | Client type | Redirect URI | Client ID | Status |
|---|---|---|---|---|
| `sitedesk-code` | ~~Web application~~ **RETIRED** | ~~`https://public.sitedesk.online/api/google/oauth/callback`~~ | `1032860640027-…` | **Do not use.** A Web client cannot register a loopback redirect, so this id fails `redirect_uri_mismatch` under the Desktop flow. Cleared from `brand.json`. Delete the client in the console, which revokes its secret with it — it was exposed to shell history during the manual round-trip, so it is retired rather than rotated. |
| `sitedesk-code` | Desktop app | `http://127.0.0.1` | *(not yet created)* | Awaiting provisioning in the **existing** `Sitedesk Calendar` project. |

No brand currently ships a populated `googleClientId`/`googleClientSecret`; the
plugin is inert everywhere until one does. Per-brand next step for
`sitedesk-code`: create the Desktop client per the runbook above, then start
**brand/consent-screen verification** to lift the 100-test-user cap and the
7-day re-consent. That review is the slow part; everything else is minutes.
