Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Co-Authored-By: <agent model name> <noreply@anthropic.com>
| [docs/notion-v3-transactions.md](docs/notion-v3-transactions.md) | Debugging Notion v3 400 errors, transaction patterns, ID disambiguation |
| [docs/markdown-rendering.md](docs/markdown-rendering.md) | Adding markdown rendering to an integration (decision framework, shared utilities, implementation checklist) |
| [docs/web-ui.md](docs/web-ui.md) | Modifying the web config UI |
| [docs/google-setup.md](docs/google-setup.md) | Unified Google Workspace OAuth setup (BYO client, token fan-out) |

## Skills

Expand Down
19 changes: 19 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,25 @@ var envMapping = map[string]map[string]string{
},
}

// googleWorkspaceIntegrations lists the integrations that share one Google
// Cloud OAuth client. The shared GOOGLE_OAUTH_CLIENT_ID / _SECRET env vars are
// fanned out to each of them so a headless/Docker deployment can configure the
// whole Google Workspace suite with two variables. Env names match the hosted
// Switchboard product for parity.
var googleWorkspaceIntegrations = []string{
"gmail", "gcal", "gdrive", "gdocs", "gsheets", "gslides",
"gforms", "gtasks", "gchat", "gpeople", "gmeet",
}

func init() {
for _, name := range googleWorkspaceIntegrations {
envMapping[name] = map[string]string{
mcp.CredKeyClientID: "GOOGLE_OAUTH_CLIENT_ID",
mcp.CredKeyClientSecret: "GOOGLE_OAUTH_CLIENT_SECRET",
}
}
}

// EnvMapping returns the env var mapping table. Useful for documentation and debugging.
func EnvMapping() map[string]map[string]string {
return envMapping
Expand Down
31 changes: 30 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,29 @@ func TestEnvOverrides_OverridesEmptyCredentials(t *testing.T) {
assert.Equal(t, "dd_env_app", m.cfg.Integrations["datadog"].Credentials["app_key"])
}

func TestEnvOverrides_GoogleSharedClientFansOut(t *testing.T) {
m, _ := newTestManager(t)
m.envLookup = func(key string) string {
switch key {
case "GOOGLE_OAUTH_CLIENT_ID":
return "shared-client-id"
case "GOOGLE_OAUTH_CLIENT_SECRET":
return "shared-secret"
default:
return ""
}
}

require.NoError(t, m.Load())

for _, name := range googleWorkspaceIntegrations {
ic := m.cfg.Integrations[name]
require.NotNil(t, ic, "integration %q missing", name)
assert.Equal(t, "shared-client-id", ic.Credentials[mcp.CredKeyClientID], "client_id for %q", name)
assert.Equal(t, "shared-secret", ic.Credentials[mcp.CredKeyClientSecret], "client_secret for %q", name)
}
}

func TestEnvOverrides_OverridesExistingValues(t *testing.T) {
m, path := newTestManager(t)

Expand Down Expand Up @@ -529,7 +552,13 @@ func TestEnvMapping_ReturnsMapping(t *testing.T) {
assert.Equal(t, "VERCEL_TEAM_ID", m["vercel"]["team_id"])
assert.Equal(t, "VERCEL_TEAM_SLUG", m["vercel"]["team_slug"])
assert.Equal(t, "VERCEL_BASE_URL", m["vercel"]["base_url"])
assert.Len(t, m, 28)
// 28 base integrations + 11 Google Workspace services sharing the
// GOOGLE_OAUTH_CLIENT_ID/SECRET env vars.
assert.Len(t, m, 39)
for _, name := range googleWorkspaceIntegrations {
assert.Equal(t, "GOOGLE_OAUTH_CLIENT_ID", m[name][mcp.CredKeyClientID])
assert.Equal(t, "GOOGLE_OAUTH_CLIENT_SECRET", m[name][mcp.CredKeyClientSecret])
}
}

func TestToolGlobs_PersistThroughSaveLoad(t *testing.T) {
Expand Down
155 changes: 155 additions & 0 deletions docs/google-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Google Workspace Setup

Switchboard connects to Google Workspace services (Gmail, Calendar, Drive,
Docs, Sheets, Slides, Forms, Tasks, Chat, Contacts, and Meet) using **your
own** Google Cloud OAuth client. You create one OAuth client, enable the APIs
you need, sign in once, and every selected service is configured from that
single consent.

Because you bring your own client, there is no verification or CASA security
assessment required — the app runs in your own Google Cloud project against
your own account.

The unified setup page lives at **`/integrations/google/setup`** in the web UI.

---

## 1. Create a Google Cloud project

1. Open the [Google Cloud Console](https://console.cloud.google.com/).
2. Create a new project (or reuse an existing one) and select it.

## 2. Enable the APIs you want

Enable an API for each service you plan to connect. In the console go to
**APIs & Services → Library** and enable the ones you need:

| Service | API to enable |
|-----------|-----------------------------------|
| Gmail | Gmail API |
| Calendar | Google Calendar API |
| Drive | Google Drive API |
| Docs | Google Docs API |
| Sheets | Google Sheets API |
| Slides | Google Slides API |
| Forms | Google Forms API |
| Tasks | Google Tasks API |
| Chat | Google Chat API |
| Contacts | People API |
| Meet | Google Meet API |

> **⚠️ Enabling an API is separate from granting a scope.** Signing in and
> approving the consent screen only grants your token the *scopes*; it does
> **not** enable the APIs. If an API is not enabled in your project, calls to it
> return `403 "<Service> API has not been used in project ... before or it is
> disabled"` — even though your token is perfectly valid. On the setup page
> these services show a yellow **"Enable API"** badge (not "Invalid token").
> Enable the API from the Library, wait a minute for it to propagate, then
> reload the page.

You can enable more later; just re-run the sign-in and Switchboard will request
the additional scopes incrementally (it sends `include_granted_scopes=true`, so
previously granted access is preserved).

## 3. Configure the OAuth consent screen

1. Go to **APIs & Services → OAuth consent screen**.
2. Choose **External** user type.
3. Fill in the required app name and support email.
4. Add yourself as a **Test user** (under the Audience/Test users section).

> **⚠️ 7-day refresh-token expiry in Testing.** While the consent screen is in
> **Testing** status, Google expires refresh tokens after **7 days**. When that
> happens Switchboard's stored tokens stop refreshing and you'll see "Invalid
> token" badges — just sign in again from the setup page.
>
> To avoid the weekly re-auth, **publish your own app** (OAuth consent screen →
> **Publish app**). For a self-hosted, single-user setup you generally do not
> need Google's verification for personal use, and publishing removes the 7-day
> refresh-token cap.

## 4. Create the OAuth client

1. Go to **APIs & Services → Credentials**.
2. Click **Create Credentials → OAuth client ID**.
3. Set the application type to **Web application**.
4. Under **Authorized redirect URIs**, add:

```
http://localhost:3847/api/google/oauth/callback
```

Replace `3847` with your configured port if you run Switchboard on a
different one (the default is `3847`; the setup page shows the exact URI to
register).
5. Create the client and copy the **Client ID** and **Client secret**.

## 5. Enter the client in Switchboard

You have two options:

### Option A — Web UI

1. Open `/integrations/google/setup`.
2. Paste the Client ID and Client Secret and save.
3. Check the services you want, click **Sign in with Google**, and complete the
consent screen. You'll be redirected back and every granted service is
configured automatically.

### Option B — Environment variables

Set the shared client once and it fans out to all Google services:

```sh
export GOOGLE_OAUTH_CLIENT_ID="123456789-abc.apps.googleusercontent.com"
export GOOGLE_OAUTH_CLIENT_SECRET="GOCSPX-..."
```

Then complete the sign-in from the web UI to obtain tokens.

---

## Scopes requested per service

| Service | Scopes |
|-----------|--------|
| Gmail | `https://mail.google.com/` |
| Calendar | `.../auth/calendar` |
| Drive | `.../auth/drive` |
| Docs | `.../auth/documents` |
| Sheets | `.../auth/spreadsheets` |
| Slides | `.../auth/presentations` |
| Forms | `.../auth/forms.body`, `.../auth/forms.responses.readonly` |
| Tasks | `.../auth/tasks` |
| Chat | `.../auth/chat.spaces.readonly`, `.../auth/chat.messages` |
| Contacts | `.../auth/contacts`, `.../auth/contacts.other.readonly`, `.../auth/directory.readonly` |
| Meet | `.../auth/meetings.space.created`, `.../auth/meetings.space.readonly`, `.../auth/meetings.space.settings` |

(`...` = `https://www.googleapis.com`.) Only the scopes for the services you
select are requested. If Google grants a subset (partial consent), Switchboard
enables only the services whose scopes were actually granted.

---

## How token storage works

Signing in once obtains a single access/refresh token pair covering all granted
scopes. Switchboard writes that pair into the config entry for **each** granted
service, so every Google adapter can refresh independently. On a 401 each
adapter refreshes its own copy of the token and persists the rotated value.

## Troubleshooting

- **Yellow "Enable API" badge** — your token is valid but the service's API is
not enabled in your Cloud project. Enable it from **APIs & Services →
Library** (see step 2), wait ~1 minute, then reload the page. This is the most
common first-run issue: granting scopes on the consent screen does not enable
the APIs.
- **"Invalid token" badge after ~7 days** — your consent screen is in Testing
status. Sign in again, or publish your app (see step 3).
- **`redirect_uri_mismatch`** — the redirect URI registered in the console must
exactly match `http://localhost:<port>/api/google/oauth/callback`, including
the port shown on the setup page.
- **A service you enabled isn't connected** — make sure you both enabled its API
(step 2) and left it checked during sign-in; partial grants only enable
services whose scopes were approved.
1 change: 1 addition & 0 deletions docs/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- `GET /integrations/github/setup` — GitHub Device Flow OAuth
- `GET /integrations/linear/setup` — Linear OAuth (PKCE)
- `GET /integrations/sentry/setup` — Sentry Device Flow OAuth
- `GET /integrations/google/setup` — Unified Google Workspace setup (one OAuth client, one sign-in, fans tokens out to all selected Google services). See [google-setup.md](google-setup.md). The 11 per-service pages (`/integrations/gmail/setup`, `/integrations/gcal/setup`, etc.) remain but link back to this unified page.
- `GET /integrations/slack/setup` — Slack token extraction (Chrome auto-extract, manual browser snippet, direct entry)
- `GET /integrations/notion/setup` — Notion token_v2 entry (browser snippet extraction, manual entry)
- `GET /integrations/postgres/setup` — Postgres default plus additional aliased connections
Expand Down
Loading
Loading