Skip to content

Commit b912bd7

Browse files
committed
fix oauth flow and session state manager
1 parent e709b91 commit b912bd7

24 files changed

Lines changed: 1166 additions & 153 deletions

File tree

docs/src/content/docs/authoring/blocks/GoogleAuth.mdx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ Runbooks never shells out to the `gcloud` binary. Service account keys are valid
4949
| `defaultZone` | `string` || Default compute zone for subsequent commands. Sets `CLOUDSDK_COMPUTE_ZONE` and `GOOGLE_ZONE` |
5050
| `gcloudConfiguration` | `string` || Pre-select a named gcloud configuration in the gcloud Config tab (supports template expressions) |
5151
| `scopes` | `string[]` | `cloud-platform`, `userinfo.email`, `openid` | OAuth scopes requested by Google Sign-In. When set, also **required** of any auto-detected or gcloud user ADC this block will accept (service-account keys are exempt) |
52-
| `oauthClientId` | `string` || Client ID of a Google Cloud "Desktop app" OAuth client. **Required to enable the Google Sign-In tab** — this build ships no default client. Must be paired with `oauthClientSecret` |
52+
| `oauthClientId` | `string` || Client ID of a Google Cloud "Desktop app" OAuth client. Must be paired with `oauthClientSecret`. Mutually exclusive with `oauthClientFile`. See [Using your own OAuth client](#using-your-own-oauth-client) |
5353
| `oauthClientSecret` | `string` || Client secret issued alongside the Desktop OAuth client. Required whenever `oauthClientId` is set. Per RFC 8252 this value is not confidential; Google simply issues one with every Desktop client |
54+
| `oauthClientFile` | `string` || Path to a Google Cloud Console Desktop-app client JSON download (`client_secret_*.json` with an `installed` object). `~` is expanded. Mutually exclusive with `oauthClientId` / `oauthClientSecret`. Read in the main process only |
5455
| `detectCredentials` | `false \| GoogleCredentialSource[]` | `['env', 'adc']` | Whether and how to detect existing credentials. See [Credential Detection](#credential-detection) |
5556
| `inputsId` | `string \| string[]` || Reference one or more [Inputs](/authoring/blocks/inputs/) blocks for template expressions in props |
5657

@@ -194,7 +195,7 @@ A service account key is long-lived key material. Prefer **Google Sign-In** or t
194195
## Google Sign-In
195196

196197
<Aside type="caution">
197-
**This tab currently requires an author-supplied OAuth client.** Runbooks does not yet ship a registered Google Cloud "Desktop app" client, so in a stock build the **Google Sign-In** tab renders disabled and labeled *(unavailable)*. Set both `oauthClientId` and `oauthClientSecret` (see [Using your own OAuth client](#using-your-own-oauth-client)) to enable it, or use the **Service Account Key** / **gcloud Config** tabs, which work out of the box.
198+
**This tab currently requires an OAuth client.** Runbooks does not yet ship a registered Google Cloud "Desktop app" client, so in a stock build the **Google Sign-In** tab renders disabled and labeled *(unavailable)* unless you supply a client via props, a client JSON file, or operator environment variables (see [Using your own OAuth client](#using-your-own-oauth-client)). The **Service Account Key** / **gcloud Config** tabs work out of the box.
198199
</Aside>
199200

200201
The **Google Sign-In** tab performs the same loopback OAuth flow as `gcloud auth application-default login`:
@@ -211,7 +212,7 @@ The requested scopes default to:
211212

212213
Override them with the `scopes` prop. The tab's **What permissions does this grant?** disclosure always lists exactly what will be requested.
213214

214-
When `scopes` is set, it is also a **requirement** for ambient credentials: auto-detected env/ADC credentials and the gcloud Config tab refuse a user credential whose tokeninfo grant is missing any listed scope. The block shows the missing scopes and offers **Sign in with required scopes** (or a copyable `gcloud auth application-default login --scopes=…` command when Sign-In is unavailable). Defaults are Sign-In request scopes only — they are **not** enforced on ambient ADC unless you set the prop.
215+
When `scopes` is set, it is also a **requirement** for ambient credentials: auto-detected env/ADC credentials and the gcloud Config tab refuse a user credential whose tokeninfo grant is missing any listed scope. The block shows the missing scopes and offers **Sign in with required scopes** (or a copyable `gcloud auth application-default login --client-id-file="$GOOGLE_OAUTH_CLIENT_CREDENTIALS" --scopes=…` command when Sign-In still has no Desktop client). Defaults are Sign-In request scopes only — they are **not** enforced on ambient ADC unless you set the prop.
215216

216217
```mdx
217218
<GoogleAuth
@@ -230,7 +231,15 @@ Because the flow returns a refresh token — not a bare access token — the cre
230231

231232
### Using your own OAuth client
232233

233-
Sign-in requires a registered Google Cloud **Desktop app** OAuth client, and today that means one of your own. Set **both** `oauthClientId` and `oauthClientSecret` to use your organization's client and consent screen:
234+
Sign-in requires a registered Google Cloud **Desktop app** OAuth client. Until Runbooks ships a built-in client, supply one of your own. Resolution order in the main process:
235+
236+
1. Author props `oauthClientId` + `oauthClientSecret`
237+
2. Author prop `oauthClientFile` (path to the Console download JSON)
238+
3. Operator env `GOOGLE_OAUTH_CLIENT_CREDENTIALS` (path to the same JSON)
239+
4. Operator env `GOOGLE_OAUTH_CLIENT_ID` + `GOOGLE_OAUTH_CLIENT_SECRET`
240+
5. Build defaults (empty today)
241+
242+
**Explicit id and secret:**
234243

235244
```mdx
236245
<GoogleAuth
@@ -242,8 +251,31 @@ Sign-in requires a registered Google Cloud **Desktop app** OAuth client, and tod
242251

243252
Both props are required together. `oauthClientId` on its own is refused with an explicit error rather than started: Google issues a client secret with every Desktop client, and without it the resulting user-credentials file cannot be refreshed — every later `gcloud`, client-library, and OpenTofu call would fail at the first token refresh, long after the block reported success.
244253

254+
**Client JSON file** (the Desktop-app download from Google Cloud Console — `{ "installed": { "client_id", "client_secret", … } }`):
255+
256+
```mdx
257+
<GoogleAuth
258+
id="google-auth"
259+
oauthClientFile="~/.config/gcloud/client_secret_example.json"
260+
/>
261+
```
262+
263+
`oauthClientFile` is mutually exclusive with `oauthClientId` / `oauthClientSecret`. The path is read in the main process only (`~` is expanded); the secret never enters the UI. Web-client downloads (`{ "web": … }`) are rejected — Sign-In uses a loopback redirect registered for Desktop clients.
264+
265+
**Operator environment** (machine-local, no runbook change required):
266+
267+
| Variable | Value |
268+
|----------|-------|
269+
| `GOOGLE_OAUTH_CLIENT_CREDENTIALS` | Absolute or `~/…` path to a Desktop-app `client_secret_*.json` |
270+
| `GOOGLE_OAUTH_CLIENT_ID` | Desktop-app client ID (must be paired with the secret) |
271+
| `GOOGLE_OAUTH_CLIENT_SECRET` | Desktop-app client secret |
272+
273+
These are distinct from `GOOGLE_APPLICATION_CREDENTIALS`, which holds *user* or service-account credentials after authentication — not the OAuth app client used to start Sign-In.
274+
275+
**In-session file picker:** when no client is configured yet, the **Google Sign-In** tab stays selectable and labeled *(needs OAuth client)*. The panel offers **Choose Desktop OAuth client JSON** — the same Console download as `oauthClientFile` / `GOOGLE_OAUTH_CLIENT_CREDENTIALS`. The renderer keeps the path only; MAIN reads `installed.client_id` / `installed.client_secret` at sign-in start.
276+
245277
<Aside type="caution">
246-
If no OAuth client is configured — neither by the build nor by these props — the **Google Sign-In** tab is shown but disabled and labeled *(unavailable)* from the moment the block renders, with the message *"OAuth login is not configured for this build."* Use the **Service Account Key** or **gcloud Config** tab, or supply `oauthClientId` and `oauthClientSecret`.
278+
If no OAuth client is configured — neither by the build, author props/file, operator env, nor an in-session pick — Sign-In cannot start until the operator supplies a Desktop client. Use the picker, set `GOOGLE_OAUTH_CLIENT_CREDENTIALS`, or fall back to the **Service Account Key** / **gcloud Config** tabs.
247279
</Aside>
248280

249281
## gcloud Config
@@ -503,15 +535,17 @@ If a federated configuration you rely on is refused, authenticate with a service
503535

504536
| Symptom | Cause and fix |
505537
|---------|---------------|
506-
| **Google Sign-In** tab is greyed out and labeled *(unavailable)* | No OAuth client is configured for this build — the current default. Supply **both** `oauthClientId` and `oauthClientSecret`, or use the Service Account Key or gcloud Config tab |
538+
| **Google Sign-In** tab labeled *(needs OAuth client)* | No OAuth client is configured yet (build default, author props/file, or `GOOGLE_OAUTH_CLIENT_*` env). Choose a Desktop client JSON in the panel, set env, or use the Service Account Key / gcloud Config tab |
507539
| *"oauthClientId was supplied without oauthClientSecret"* | A custom Desktop client needs both. Google issues a secret with every Desktop client; without it the credential cannot be refreshed |
540+
| *"Supply either oauthClientId/oauthClientSecret or oauthClientFile"* | Those props are mutually exclusive — pick one supply path |
541+
| *"OAuth client credentials file is a Web client"* | Download a **Desktop app** client JSON from Google Cloud Console (`installed`), not a Web client (`web`) |
508542
| *No gcloud configurations found* | There is no gcloud configuration directory at the reported path. Run `gcloud init`, or set `CLOUDSDK_CONFIG` to the directory you use |
509543
| A configuration shows **No ADC** and cannot be selected | The configuration has no Application Default Credentials. Run `gcloud auth application-default login` |
510544
| *Not a service account key (expected type: service_account)* | The pasted JSON is user credentials or an OAuth client file, not a service account key. Use the file that Google Cloud produced from **IAM & Admin → Service Accounts → Keys** |
511545
| *…which Runbooks does not accept* / *…is not a Google API endpoint* | The credentials document is a workload identity federation config that fetches its subject token from a file, a URL, or a command, or that points at a host outside `*.googleapis.com`. Runbooks does not run those instructions. Use a service account key, **Sign in with Google**, or `gcloud auth application-default login` |
512546
| *Invalid credentials detected: … are invalid or expired* | The detected credential no longer authenticates. Refresh it (`gcloud auth application-default login`) or authenticate manually |
513-
| *Credentials missing required scopes* | The block's `scopes` prop lists scopes the detected user ADC does not grant (for example Admin SDK scopes). Use **Sign in with required scopes**, or run the shown `gcloud auth application-default login --scopes=…` command and try auto-detection again |
514-
| gcloud Config / confirm fails with *missing required OAuth scopes* | Same check as detection: the selected ADC is too narrow for this block. Re-authenticate with the required scopes via Sign-In or `gcloud auth application-default login --scopes=…` |
547+
| *Credentials missing required scopes* | The block's `scopes` prop lists scopes the detected user ADC does not grant (for example Admin SDK scopes). Use **Sign in with required scopes**, or run the shown `gcloud auth application-default login --client-id-file="$GOOGLE_OAUTH_CLIENT_CREDENTIALS" --scopes=…` command and try auto-detection again |
548+
| gcloud Config / confirm fails with *missing required OAuth scopes* | Same check as detection: the selected ADC is too narrow for this block. Re-authenticate with the required scopes via Sign-In or `gcloud auth application-default login --client-id-file=… --scopes=…` |
515549
| The project picker is empty | The credential cannot list projects. Grant `resourcemanager.projects.list`, or set the `project` prop / type the project ID directly |
516550
| *Project … is not accessible with these credentials* | Advisory warning: the credential authenticated, but Google definitively refused (404/permission denied) on that project. Check the project ID and the principal's IAM roles. An inconclusive answer — a disabled Cloud Resource Manager API, a network blip — deliberately produces **no** warning, because it says nothing about whether your commands will work |
517551
| *Authenticated, but no Google Cloud project is set* | The credential is valid but nothing named a project: no `project` prop, no `core/project` in the gcloud configuration, and no project the principal can enumerate. Commands that need one will fail with *"The project property must be set"*. Set the `project` prop, run `gcloud config set project`, or use **Change project** |

electron/main/ipc/google.ts

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,10 @@ import {
3636
listProjects,
3737
checkProject,
3838
detectEnvCredentials,
39-
DEFAULT_GOOGLE_OAUTH_CLIENT_ID,
40-
DEFAULT_GOOGLE_OAUTH_CLIENT_SECRET,
4139
DEFAULT_GOOGLE_SCOPES,
4240
ENV_PREFIX_PATTERN,
41+
isOAuthClientConfigured,
42+
resolveOAuthClient,
4343
} from "../../../src/domain/google/auth.ts"
4444
import {
4545
evaluateRequiredGoogleScopes,
@@ -722,45 +722,47 @@ export function registerGoogleHandlers(): void {
722722
// -------------------------------------------------------------------------
723723
// Cheap capability probe so the block can render the Google Sign-In tab
724724
// disabled on FIRST paint, instead of only after a click that was always
725-
// going to fail. The client id itself never crosses IPC.
726-
ipcMain.handle("google:oauth-available", () => ({
727-
available: DEFAULT_GOOGLE_OAUTH_CLIENT_ID.length > 0,
728-
}))
725+
// going to fail. Checks build defaults and operator env
726+
// (GOOGLE_OAUTH_CLIENT_*); author props/file skip this probe in the
727+
// renderer. The client id/secret themselves never cross IPC.
728+
ipcMain.handle("google:oauth-available", async () => {
729+
try {
730+
const available = await runtime.runPromise(isOAuthClientConfigured())
731+
return { available }
732+
} catch {
733+
return { available: false }
734+
}
735+
})
729736

730737
ipcMain.handle(
731738
"google:oauth-start",
732739
async (
733740
_event,
734-
params: { clientId?: string; clientSecret?: string; scopes?: string[]; loginHint?: string },
741+
params: {
742+
clientId?: string
743+
clientSecret?: string
744+
clientFile?: string
745+
scopes?: string[]
746+
loginHint?: string
747+
},
735748
) => {
736-
// MAIN owns the defaults; an author-supplied client id is never paired
737-
// with the built-in secret.
738-
const clientId = params.clientId || DEFAULT_GOOGLE_OAUTH_CLIENT_ID
739-
const clientSecret = params.clientId ? params.clientSecret : DEFAULT_GOOGLE_OAUTH_CLIENT_SECRET
740-
741-
if (!clientId) {
742-
return { error: "OAuth login is not configured for this build" }
743-
}
744-
// A Desktop client without its secret cannot be refreshed: the exchange
745-
// may succeed, but the authorized_user document written from it would
746-
// carry client_secret:"" and every later refresh — gcloud, the client
747-
// libraries, the OpenTofu provider — would fail. Refuse up front rather
748-
// than publish a credential that only looks authenticated.
749-
if (!clientSecret) {
750-
return {
751-
error:
752-
"oauthClientId was supplied without oauthClientSecret. Google issues a client secret with every Desktop app client; the credential cannot be refreshed without it.",
753-
}
754-
}
755-
registerSecret(clientSecret)
756-
757-
const scopes = params.scopes?.length ? params.scopes : [...DEFAULT_GOOGLE_SCOPES]
758-
749+
// Resolve props / oauthClientFile / operator env / build defaults in MAIN
750+
// so a client-secret JSON path never needs its contents in the renderer.
759751
try {
752+
const resolved = await runtime.runPromise(
753+
resolveOAuthClient({
754+
...(params.clientId ? { clientId: params.clientId } : {}),
755+
...(params.clientSecret ? { clientSecret: params.clientSecret } : {}),
756+
...(params.clientFile ? { clientFile: params.clientFile } : {}),
757+
}),
758+
)
759+
registerSecret(resolved.clientSecret)
760+
761+
const scopes = params.scopes?.length ? params.scopes : [...DEFAULT_GOOGLE_SCOPES]
760762
const flow = await runtime.runPromise(
761763
startOAuthFlow({
762-
clientId,
763-
clientSecret,
764+
clientId: resolved.clientId,
765+
clientSecret: resolved.clientSecret,
764766
scopes,
765767
...(params.loginHint ? { loginHint: params.loginHint } : {}),
766768
}),

electron/main/ipc/runbook.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
runbookConfig,
1313
executableRegistry,
1414
sessionManager,
15+
vcsSessionMeta,
1516
setExecutableRegistry,
1617
setRunbookConfig,
1718
} from "./runtime.ts"
19+
import { resetGoogleCredentialRegistry } from "./google-credential-registry.ts"
1820
import { ExecutableRegistry } from "../../../src/domain/registry/executable.ts"
1921
import { readFileMetadata, resolveRunbookPath, getContentType, isAllowedAssetExtension } from "../../../src/domain/workspace/file.ts"
2022
import { containsPathTraversal, isContainedInReal } from "../../../src/path-validation.ts"
@@ -94,8 +96,24 @@ export function registerRunbookHandlers(): void {
9496
} catch {
9597
// Path may not exist yet — fall back to the lexical resolution.
9698
}
97-
if (!sessionManager.hasSession()) {
98-
await runtime.runPromise(sessionManager.createSession(sessionDir))
99+
100+
// A different runbook than the one the current session belongs to
101+
// (including "no session yet") gets a fully fresh session: env,
102+
// working dir, AND registered/active git worktrees. Without this, a
103+
// worktree registered by a GitClone block in one runbook stays "active"
104+
// (session/manager.ts's getActiveWorkTreePath) after switching to an
105+
// unrelated runbook in the same running app, so REPO_FILES / worktree
106+
// templates resolve to a stale, possibly already-deleted, checkout.
107+
// Reloading the SAME runbook (watch mode, re-opening the same file)
108+
// must NOT do this — it would wipe env vars a script exported mid-run.
109+
if (sessionManager.getRunbookPath() !== runbookPath) {
110+
await runtime.runPromise(sessionManager.createSession(sessionDir, runbookPath))
111+
// These mirror the same "most recent wins across the whole process"
112+
// pattern as the worktree state above — reset them at the same
113+
// boundary so a Google credential or git-host auth banner from the
114+
// previous runbook can't leak into this one.
115+
resetGoogleCredentialRegistry()
116+
vcsSessionMeta.clear()
99117
} else {
100118
sessionManager.setWorkingDir(sessionDir)
101119
}

electron/shared/channels.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,11 +198,18 @@ export interface IpcChannelMap {
198198
result: { available: boolean }
199199
}
200200
"google:oauth-start": {
201-
// clientId/clientSecret/scopes are optional — MAIN owns the defaults
202-
// (DEFAULT_GOOGLE_OAUTH_CLIENT_ID / DEFAULT_GOOGLE_SCOPES). An author
203-
// clientId must come with its clientSecret: without one the minted
204-
// authorized_user document cannot be refreshed.
205-
params: { clientId?: string; clientSecret?: string; scopes?: string[]; loginHint?: string }
201+
// clientId/clientSecret/clientFile/scopes are optional — MAIN resolves
202+
// the Desktop OAuth client from author props, a client_secret JSON file,
203+
// operator env (GOOGLE_OAUTH_CLIENT_*), or build defaults. An author
204+
// clientId must come with its clientSecret (and not with clientFile):
205+
// without a secret the minted authorized_user document cannot be refreshed.
206+
params: {
207+
clientId?: string
208+
clientSecret?: string
209+
clientFile?: string
210+
scopes?: string[]
211+
loginHint?: string
212+
}
206213
result: { flowId?: string; authUrl?: string; redirectUri?: string; expiresInSeconds?: number; error?: string }
207214
}
208215
"google:oauth-poll": {

0 commit comments

Comments
 (0)