# Google OAuth

Per-user Google consent - the requiresOAuth contract, scope gating, connection status, and test mocks.

The `google/*` endpoints (Gmail, Calendar, Drive, Contacts) are the platform's per-user OAuth surface: each signed-in user connects their own Google account, and the platform stores and auto-refreshes their tokens. Consent is incremental - users grant scopes one feature at a time, and the platform unions newly granted scopes with previously granted ones, so per-feature acquisition compounds instead of resetting.

This page is the contract for wiring `google/*` calls: building a "Connect Google" button, handling the `requiresOAuth` response, gating UI per scope, and mocking the OAuth surface in tests. For the general integration client, see [external APIs](/guides/external-apis) and the [integrations reference](/sdk-reference/client/integrations).

## Billing must be `'user'`

The scaffold ships Google with `billing: 'user'`. That setting is non-negotiable for `google/*`:

```ts
// src/integrations.ts
export const integrations: Record<string, { billing: 'developer' | 'user' }> = {
  google: { billing: 'user' },   // already in scaffold - never change to 'developer'
}
```

OAuth tokens are stored per user, keyed by the JWT subject. With `'developer'` billing the proxy forwards the **owner's** JWT for every call - so every visitor's clicks would read and write the *owner's* connected Gmail, Drive, and Calendar, regardless of who is signed in client-side. `'user'` billing is what makes each call operate on the caller's own account.

## There is no separate auth-url endpoint

To get an `authUrl` for a "Connect Google" button, POST the real endpoint whose scope you need. When the user isn't authorized, the response carries an `authUrl` built for exactly that endpoint's scopes; when they are, it carries the data. One code path serves both the connect button and the data load:

| Feature intent   | Endpoint to POST              |
| ---------------- | ----------------------------- |
| Calendar feature | `google/calendar-list-events` |
| Gmail read       | `google/gmail-list`           |
| Gmail compose    | `google/gmail-send`           |
| Drive feature    | `google/drive-list`           |
| Contacts feature | `google/contacts-list`        |

Each `google/*` endpoint requests the **scope set its feature needs** - and every one of those sets is a single scope targeting exactly one Google API surface. Posting to `google/calendar-list-events` requests calendar events access only - it does not also request Gmail access, even if your app needs both. The set is per *feature*, not per verb: `google/gmail-send` requests `gmail.modify`, the platform's one Gmail write scope, so consenting from the Send button lights `gmailModify` too. On top of the feature scope, every auth URL always appends the `openid` / `userinfo.email` / `userinfo.profile` identity scopes. Scopes still accumulate one feature at a time through incremental consent; do not try to collect them all up front.

## The `requiresOAuth` response is success-shaped

When a `google/*` endpoint is called without stored tokens, without a required scope, or with a stored token Google rejects **at call time**, the api-worker returns the OAuth-required payload as a **normal handler result** - HTTP 200, wrapped in the standard success envelope:

```ts
{
  success: true,             // yes, true - this is not an error envelope
  data: {
    requiresOAuth: true,
    provider: 'google',
    scopes: string[],        // the scopes this call needs
    authUrl: string          // send the user here to grant consent
  }
}
```

One shape covers all three of those classes - no stored tokens, insufficient scope (Google 403), and a token Google rejects on use (401) - so a single check handles them. On that hard 401 the platform also clears the stale token row, so the next status poll honestly reports "not connected".

One revocation path does **not** take this shape. When the hour-scale access token has expired and the platform's **refresh** of it fails - the classic case: the user revoked the app in their Google account settings while idle - the refresh error surfaces as a plain integration failure: HTTP 502, `success: false`, `error: 'upstream_provider_error'`, with a `message` beginning `Token refresh failed`. The token row is **not** cleared on this path, so connection status keeps reporting `connected: true` until the user re-consents. Treat that signature as an OAuth recovery case - surface the reconnect flow - not as a retryable provider error.

**Check `result.data?.requiresOAuth`, and treat a failed token refresh as reconnect too.** The SDK forwards the api-worker's `data` field as-is, so the OAuth fields sit one level down. A `{ success: false, error: 'requiresOAuth', connectUrl }` shape does not exist - the field is `authUrl`, and it rides inside `data` on a successful response. On the `success === false` branch, the one OAuth case is the refresh-failure signature above; the rest of that branch is network, validation, and proxy errors.

Client pattern - unwrap with `data ?? result` so the same code handles the nested envelope and any flattened variant:

```ts
const result = await integration.post('google/gmail-send', { to, subject, content })
if (!result.success) {
  // Revoked while idle: a failed token refresh needs re-consent, not a retry.
  if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt()
  return
}
const payload = (result.data ?? result) as Record<string, unknown>
if (payload?.requiresOAuth && typeof payload.authUrl === 'string') {
  window.open(payload.authUrl, 'google-auth', 'width=500,height=600')
  // After the popup closes, refresh status and retry the call.
  return
}
// Otherwise `payload` is the upstream Google response -
// e.g. for calendar-list-events, `payload.events` is the events array.
```

Apply this unwrap to every `google/*` call rather than reading `result.requiresOAuth` directly.

## Gate per feature, never on a composite

Gate each UI feature on its own scope flag. A composite `isConnected` boolean that ANDs multiple scopes creates a deadlock: the user grants calendar via the connect button, status reports `calendar: true, gmailSend: false`, the composite gate stays false, the UI shows "not connected", and the user can never reach the Send button that would request `gmail.modify`. The connection state is permanently stuck.

```tsx
// ❌ Deadlock - user can never satisfy the gate one grant at a time
const isConnected = status?.google?.connected
  && status?.google?.calendar
  && status?.google?.gmailSend
if (!isConnected) return <ConnectGoogleButton />

// ✅ Per-feature gating - calendar UI appears the moment calendar is granted;
//    the Gmail write scope is requested lazily when the user clicks Send.
{status?.google?.calendar && <EventsList onSendRecap={attemptSend} />}

async function attemptSend(to: string, subject: string, content: string) {
  const result = await integration.post('google/gmail-send', { to, subject, content })
  if (!result.success) {
    if (result.message?.startsWith('Token refresh failed')) showReconnectPrompt()
    return
  }
  const payload = (result.data ?? result) as Record<string, unknown>
  if (payload?.requiresOAuth && typeof payload.authUrl === 'string') {
    window.open(payload.authUrl as string, 'google-auth', 'width=500,height=600')
    // After the popup closes, refresh status and retry the send.
  }
}
```

The rule: render each feature whose scope is granted; on actions that need a not-yet-granted scope, attempt the call - the `requiresOAuth` response carries an `authUrl` pre-built for the missing scope set - then retry after consent. Because the platform unions newly granted scopes with existing ones, per-feature acquisition compounds correctly.

## Connection status

`GET /api/integrations/status` (authenticated) returns per-scope flags so UIs can render accurate badges:

```ts
{
  google: {
    connected: boolean,      // a Google account is linked
    gmailSend: boolean,      // can send mail
    gmailRead: boolean,      // can read mail
    gmailModify: boolean,    // can mutate the mailbox: archive, mark read/unread, trash
    calendar: boolean,
    drive: boolean,
    contacts: boolean,
    gmail: boolean,          // aggregate: gmailSend || gmailRead
    email?: string           // connected account email, when known
  }
}
```

Broader scopes imply narrower ones: a token granted `gmail.modify` (or full mail access) reports `gmailSend` and `gmailRead` as `true` automatically, and the broad calendar/drive/contacts scopes light their flags too. The implication runs one way only - a send-only `gmail.send` token does **not** light `gmailModify` - but the platform's own consent flow never mints such a token: `google/gmail-send` requests `gmail.modify`, so consenting through the Send flow lights all three Gmail flags at once. A send-only token can only predate that flow or come from a grant made elsewhere. Gate mailbox-mutation UI (archive, mark read/unread, trash) on `gmailModify`, not on `gmail`.

Use `email` for an "Acting as ..." chip without an extra round-trip.

## Disconnect

`DELETE /api/integrations/oauth/google/disconnect` (authenticated) revokes and clears the current user's stored Google tokens:

```ts
await fetch('/api/integrations/oauth/google/disconnect', {
  method: 'DELETE',
  headers: { Authorization: `Bearer ${await getAuthToken()}` },
})
```

## Testing - mock the OAuth branches

Real Google round-trips are **deploy-and-manual only** - automated tests must not perform live consent flows. Mock the OAuth surface with `page.route(...)` instead. The disconnected state is the easy half: fresh test accounts always show "Connect", so a smoke spec can assert it with no mocks. The branches that fail silently in production if untested are the connected-state UI and the `requiresOAuth` recovery prompt.

Minimum coverage, three recipes:

```ts
// 1. Connected state renders Disconnect + data UI
await page.route('**/api/integrations/status', (route) =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ google: { connected: true, calendar: true, gmailSend: true } }),
  })
)
await page.route('**/api/integrations/google/calendar-list-events', (route) =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      success: true,
      data: { events: [{ id: 'e1', summary: 'mock event' }] },
    }),
  })
)
// → assert the Disconnect button is visible, the mock event renders, Send is enabled

// 2. requiresOAuth recovery - note the nested `data` envelope
await page.route('**/api/integrations/google/gmail-send', (route) =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      success: true,
      data: {
        requiresOAuth: true,
        provider: 'google',
        // The real payload carries the endpoint's scope set as full URLs -
        // for gmail-send that is gmail.modify, not gmail.send.
        scopes: ['https://www.googleapis.com/auth/gmail.modify'],
        authUrl: 'https://accounts.google.com/o/oauth2/v2/auth?mock',
      },
    }),
  })
)
// → assert the reconnect prompt appears, the page does not crash, no infinite retry loop

// 3. Disconnect hits the right endpoint
let disconnectCalled = false
await page.route('**/api/integrations/oauth/google/disconnect', (route) => {
  disconnectCalled = true
  route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true }) })
})
// → click Disconnect, assert disconnectCalled === true, the banner flips back to "Connect"
```

When real Google round-trips are not exercised, record that gap explicitly in your test report or findings notes so it is paper-trailed instead of forgotten. Mocking exactly this external boundary is the sanctioned carve-out from the no-mocking rule - see [testing](/guides/testing) - because a real call would mutate provider state and require live consent.

## Next steps

* [External APIs](/guides/external-apis) - the integration client, billing routing, and discovery.
* [Integrations reference](/sdk-reference/client/integrations) - envelope types and OAuth endpoints.
* [Testing](/guides/testing) - the wider test discipline these mocks slot into.
