From dddbcd1d16c4dce14b0b18d4198163281676d9c3 Mon Sep 17 00:00:00 2001 From: Stephane Segning Lambou Date: Mon, 25 May 2026 02:35:24 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20expand=20usage=20guide=20=E2=80=94=20ar?= =?UTF-8?q?chitecture,=20GHA=20+=20K8s=20recipes,=20troubleshooting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #12. Add `docs/` at repo root with five new pages: `architecture.md` (hook model, token lifecycle per flow, cache layout, sync scheduler, TTY-aware warmup, logging + scrubbing), `github-actions.md` (Keycloak/Auth0/Okta IdP setup, reusable workflow, matrix builds, audience pinning, fork-PR limitations), `kubernetes.md` (CronJob as headline + Job/Deployment, multi-provider pods, IdP setup, RBAC, OpenShift caveats), `local-development.md` (sandbox config, plugin re-export trick, force re-auth, dev-only env subject token), and `troubleshooting.md` (symptom-keyed: model list empty, redirect_uri_mismatch, 403 model discovery, invalid_client, headless hang, token-rotation drift, provenance badge missing, ERR_PNPM_IGNORED_BUILDS). Ship `.github/workflows/opencode-run.yml` as a `workflow_call` reusable workflow consumers can pin via `uses: vymalo/opencode-oauth2/.github/workflows/opencode-run.yml@v0.x`. Slim both READMEs: root README adds a small mermaid diagram and a docs index; package README condenses the federated-identity section to short quick-references plus links to the full recipes in docs/. The canonical configuration field reference stays in the package README. Every claim traces to source — config field defaults, error event names, cache filename patterns, env-var contracts, and the isTokenValid machine-flow split were all read off packages/opencode-oauth2/src/{config,cache,oauth/*,plugin,opencode}.ts rather than guessed. All YAML/JSON snippets parse with js-yaml / JSON.parse. typecheck / test (94/94) / build all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/opencode-run.yml | 124 ++++++++++ README.md | 50 ++-- docs/architecture.md | 275 +++++++++++++++++++++ docs/github-actions.md | 290 ++++++++++++++++++++++ docs/kubernetes.md | 374 +++++++++++++++++++++++++++++ docs/local-development.md | 190 +++++++++++++++ docs/troubleshooting.md | 251 +++++++++++++++++++ packages/opencode-oauth2/README.md | 158 ++++-------- 8 files changed, 1580 insertions(+), 132 deletions(-) create mode 100644 .github/workflows/opencode-run.yml create mode 100644 docs/architecture.md create mode 100644 docs/github-actions.md create mode 100644 docs/kubernetes.md create mode 100644 docs/local-development.md create mode 100644 docs/troubleshooting.md diff --git a/.github/workflows/opencode-run.yml b/.github/workflows/opencode-run.yml new file mode 100644 index 0000000..01eb525 --- /dev/null +++ b/.github/workflows/opencode-run.yml @@ -0,0 +1,124 @@ +name: Reusable — opencode run + +# Consumers call this via: +# +# jobs: +# run: +# uses: vymalo/opencode-oauth2/.github/workflows/opencode-run.yml@v0.2.0 +# with: +# model: miaou/glm-5 +# prompt: "Summarize the changes" +# opencode-config-path: .opencode-ci/opencode.json +# +# Consumer must: +# - grant `permissions: { id-token: write, contents: read }` on the caller job +# - commit an opencode.json at `opencode-config-path` with +# `authFlow: "jwt_bearer"` + `subjectTokenSource: { type: "github_actions" }` +# - have an IdP that trusts GitHub Actions OIDC for the configured audience +# +# See docs/github-actions.md for end-to-end setup. + +on: + workflow_call: + inputs: + model: + description: 'Model id passed to `opencode run --model` (e.g. miaou/glm-5).' + required: true + type: string + prompt: + description: 'Prompt passed to `opencode run`.' + required: true + type: string + opencode-config-path: + description: 'Path (relative to the repo root) to the opencode.json the runner should use.' + required: false + type: string + default: '.opencode-ci/opencode.json' + node-version: + description: 'Node.js version installed on the runner.' + required: false + type: string + default: '22' + runs-on: + description: 'Runner image. Defaults to ubuntu-latest.' + required: false + type: string + default: 'ubuntu-latest' + opencode-version: + description: 'Pinned `opencode` version to install (npm dist-tag or semver). Empty == latest.' + required: false + type: string + default: '' + plugin-version: + description: 'Pinned `@vymalo/opencode-oauth2` version. Empty == latest.' + required: false + type: string + default: '' + outputs: + stdout-artifact: + description: 'Name of the uploaded artifact containing opencode stdout.' + value: ${{ jobs.run.outputs.stdout-artifact }} + +jobs: + run: + name: opencode run + runs-on: ${{ inputs.runs-on }} + permissions: + # OIDC token minting for federated identity (jwt_bearer / token_exchange). + # The caller must also grant id-token: write — workflow_call inherits + # the caller's permissions, but the caller cannot grant more than it has. + id-token: write + contents: read + outputs: + stdout-artifact: opencode-stdout-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - name: Checkout caller repo + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Install opencode + plugin + run: | + set -euo pipefail + opencode_spec="opencode" + if [ -n "${{ inputs.opencode-version }}" ]; then + opencode_spec="opencode@${{ inputs.opencode-version }}" + fi + plugin_spec="@vymalo/opencode-oauth2" + if [ -n "${{ inputs.plugin-version }}" ]; then + plugin_spec="@vymalo/opencode-oauth2@${{ inputs.plugin-version }}" + fi + npm install -g "$opencode_spec" "$plugin_spec" + + - name: Verify opencode config exists + run: | + set -euo pipefail + cfg="${{ inputs.opencode-config-path }}" + if [ ! -f "$cfg" ]; then + echo "::error::opencode config not found at $cfg — commit one (see docs/github-actions.md)" >&2 + exit 1 + fi + # The config dir is what OPENCODE_CONFIG_DIR points to; opencode will + # read opencode.json from there. + echo "OPENCODE_CONFIG_DIR=$(dirname "$cfg")" >> "$GITHUB_ENV" + + - name: opencode run + id: run + run: | + set -euo pipefail + mkdir -p /tmp/opencode-out + opencode run \ + --model "${{ inputs.model }}" \ + "${{ inputs.prompt }}" \ + | tee /tmp/opencode-out/stdout.txt + + - name: Upload stdout as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: opencode-stdout-${{ github.run_id }}-${{ github.run_attempt }} + path: /tmp/opencode-out/stdout.txt + if-no-files-found: warn diff --git a/README.md b/README.md index 258ee67..dc4cb52 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,14 @@ An [OpenCode](https://opencode.ai) plugin that lets you wire up **OpenAI-compati --- +```mermaid +flowchart LR + OC[opencode] -->|chat.headers| Plugin[opencode-oauth2] + Plugin -->|cached token?| Cache[(~/.cache/opencode-oauth2)] + Plugin -->|acquire / refresh| IdP[OAuth server] + Plugin -->|Authorization: Bearer …| Upstream[Provider API] +``` + ## Why Most OpenCode providers assume a static bearer key. That works for hosted SaaS, but breaks down the moment you put your models behind: @@ -18,14 +26,15 @@ Most OpenCode providers assume a static bearer key. That works for hosted SaaS, - a corporate Identity Provider (Keycloak, Auth0, Okta, Azure AD, …) - a self-hosted gateway with short-lived tokens - a multi-tenant setup where each user authenticates as themselves +- a CI runner that has no business carrying a long-lived secret -This plugin closes that gap. It handles the **Authorization Code + PKCE** dance, caches tokens, refreshes them silently, and feeds OpenCode a normal-looking provider with a fresh `Authorization` header on every request. +This plugin closes that gap. It handles the OAuth dance for the flow you need, caches tokens, refreshes silently, and feeds OpenCode a normal-looking provider with a fresh `Authorization` header on every request. ## Features - **Five auth flows**, pick what matches your runtime: - `authorization_code` — interactive PKCE login (default) - - `device_code` — RFC 8628 device authorization, for browserless user auth + - `device_code` — RFC 8628, for browserless user auth - `client_credentials` — machine-to-machine with a `clientSecret` - `jwt_bearer` — RFC 7523 federated identity (GitHub Actions OIDC, Kubernetes SA tokens) — **no long-lived secret in CI** - `token_exchange` — RFC 8693 federated identity with explicit audience targeting @@ -33,17 +42,10 @@ This plugin closes that gap. It handles the **Authorization Code + PKCE** dance, - **Display-name normalization** so `glm-5` shows up as `GLM 5` - **Persistent token cache** with automatic refresh - **`chat.headers` hook** injects bearer tokens per request -- **Strict refresh-token policy** where it makes sense — access-only tokens are rejected by design on user-interactive flows - **Two configuration styles**: per-provider options or a top-level plugin block -### Running in CI / Kubernetes (no long-lived secrets) - -For GitHub Actions and Kubernetes workloads, use the federated identity flows. See the **[Federated identity](packages/opencode-oauth2/README.md#federated-identity-no-long-lived-secrets-in-ci)** section in the package README for end-to-end examples with both `permissions: id-token: write` (GHA) and projected `serviceAccountToken` volumes (K8s). - ## Install -In your OpenCode config: - ```jsonc { "$schema": "https://opencode.ai/config.json", @@ -73,17 +75,33 @@ Then declare a provider: } ``` -See [packages/opencode-oauth2/README.md](packages/opencode-oauth2/README.md) for the full configuration reference (including the alternative `pluginConfig.oauth2ModelSync.servers` layout). +See [packages/opencode-oauth2/README.md](packages/opencode-oauth2/README.md) for the **full configuration reference** (including the alternative `pluginConfig.oauth2ModelSync.servers` layout and every optional field). + +## Documentation + +| Page | When you need it | +| --- | --- | +| [`docs/architecture.md`](docs/architecture.md) | Understand the hooks, token lifecycle per flow, cache layout, sync scheduler, logging | +| [`docs/github-actions.md`](docs/github-actions.md) | CI without stored secrets — Keycloak/Auth0/Okta setup, reusable workflow, matrix, fork-PR limits | +| [`docs/kubernetes.md`](docs/kubernetes.md) | `CronJob` / `Job` / `Deployment` with projected SA tokens, multi-provider pods, RBAC | +| [`docs/local-development.md`](docs/local-development.md) | Sandbox setup, plugin re-export trick, forcing re-auth, dev-only `env` subject token | +| [`docs/troubleshooting.md`](docs/troubleshooting.md) | Symptom-keyed fixes — `redirect_uri_mismatch`, model discovery 403, `invalid_client`, projected-token rotation | + +## Federated identity (CI / Kubernetes) + +For GitHub Actions and Kubernetes workloads, use `jwt_bearer` (or `token_exchange`) with the platform's own short-lived OIDC token as the subject. The plugin re-fetches it on every access-token expiry; nothing long-lived gets cached. + +End-to-end recipes live in [`docs/github-actions.md`](docs/github-actions.md) and [`docs/kubernetes.md`](docs/kubernetes.md). The shipped reusable workflow at [`.github/workflows/opencode-run.yml`](.github/workflows/opencode-run.yml) covers the common `opencode run` case. ## Token Policy -Refresh tokens are **mandatory** — not a nicety. +Refresh tokens are **mandatory** for the flows that issue them. -- Access tokens returned without a `refresh_token` are rejected at exchange time. -- Cached tokens missing `refreshToken` are evicted on load. +- `authorization_code` / `device_code` exchanges that don't return `refresh_token` are rejected. +- Cached tokens missing `refreshToken` are evicted on load (unless they're from `client_credentials` / `jwt_bearer` / `token_exchange`, which don't issue one). - Refresh responses that omit a new `refresh_token` re-use the existing one. -The intent: a session is either fully renewable or it doesn't get cached. No silent fallbacks to short-lived tokens that fail mid-conversation. +The intent: a user-flow session is either fully renewable or it doesn't get cached. Machine flows re-acquire on every expiry; refresh tokens have no role there. ## Workspace Layout @@ -97,7 +115,7 @@ This is a [pnpm](https://pnpm.io) monorepo. ## Development -```bash +```sh pnpm install pnpm build pnpm typecheck @@ -106,7 +124,7 @@ pnpm test Plugin-only iteration: -```bash +```sh pnpm --filter @vymalo/opencode-oauth2 test pnpm --filter @vymalo/opencode-oauth2 build ``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9e260f9 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,275 @@ +# Architecture + +How `@vymalo/opencode-oauth2` actually runs inside OpenCode: what each hook does, how tokens move through the system, where they live on disk, and what to expect when something fails. + +If you just want to copy YAML, jump to the [GitHub Actions](./github-actions.md) or [Kubernetes](./kubernetes.md) cookbooks. This page is for the adopter who needs to reason about failure modes. + +## The two hooks + +The plugin registers exactly two OpenCode hooks: `config` (plugin load) and `chat.headers` (per request). + +### `config` — plugin load + +Runs once when OpenCode boots the plugin. Source: [`packages/opencode-oauth2/src/opencode.ts`](../packages/opencode-oauth2/src/opencode.ts). + +1. **Parse both config shapes.** Walks `config.provider[*].options.oauth2` (per-provider, recommended) and `config.pluginConfig.oauth2ModelSync.servers` (top-level array). Duplicates are de-duplicated by `id`; the provider-embedded shape wins on conflict. +2. **Register managed providers** into OpenCode's `config.provider` map. Each managed provider gets `npm: "@ai-sdk/openai-compatible"` and a normalized `options.baseURL`. +3. **Build the runtime** (`OAuth2ModelSyncPlugin`), `initialize()` (load cache), then `start({ warmup: true })`. +4. **Warmup** iterates servers, attempts `syncServer(id, { interactive: })`, and starts a per-server scheduler (`syncIntervalMinutes`, default 60). +5. **Merge discovered models** into each provider's `models` map. If a server has no cached models yet (cold start, non-interactive warmup, refresh-token expired), it stays empty in OpenCode — the user sees no models for that provider until a chat request triggers on-demand auth. + +The runtime is **rebuilt** if the config signature changes between hook invocations (OpenCode re-runs `config` on certain config edits). Old schedulers are stopped first. + +### `chat.headers` — per request + +Runs on every chat completion. Source: same file, `"chat.headers"` handler. + +1. Resolve the provider id from `input.model.providerID` (preferred) or `input.provider.info.id` (fallback). +2. If the provider isn't one this plugin manages, **return without setting headers** — leaves OpenCode's other providers untouched. +3. `runtime.ensureAccessToken(providerId)` returns a fresh token (cached, refreshed, or freshly acquired). +4. Set `output.headers.Authorization = " "` (default `tokenType` is `Bearer`). +5. **Opportunistic sync trigger.** If the provider has zero cached models *after* the auth call, kick off a background `syncServer(providerId)` so the next request lists models. Failures are swallowed (next request retries). + +This is the slow path: a chat request can block on the OAuth token endpoint round-trip. The fast path is the cache — once a valid access token is on disk, `ensureAccessToken` returns synchronously after a single cache read. + +### Sequence + +```mermaid +sequenceDiagram + autonumber + actor User + participant OC as opencode + participant Plugin as opencode-oauth2 + participant Cache as ~/Library/Caches/opencode-oauth2 + participant IdP as OAuth server + participant Upstream as Provider API + + User->>OC: opencode (boot) + OC->>Plugin: config hook + Plugin->>Cache: load cached state for each server + Plugin->>OC: register provider(s) (no sync if uncached and non-TTY) + Note over Plugin: scheduler started per server + + User->>OC: opencode run --model miaou/glm-5 "..." + OC->>Plugin: chat.headers(model=miaou/glm-5) + Plugin->>Cache: read token for "miaou" + alt token valid + Plugin-->>OC: Authorization: Bearer … + else expired with refresh_token + Plugin->>IdP: POST /token grant_type=refresh_token + IdP-->>Plugin: new access_token + Plugin->>Cache: save new token + Plugin-->>OC: Authorization: Bearer … + else no token / refresh failed + Plugin->>IdP: flow-specific acquire (PKCE / device / JWT bearer / …) + IdP-->>Plugin: access_token (+ maybe refresh_token) + Plugin->>Cache: save token + Plugin-->>OC: Authorization: Bearer … + end + OC->>Upstream: POST /v1/chat/completions + Upstream-->>OC: stream completion +``` + +## Token lifecycle per flow + +`ensureToken` (in [`src/oauth/client.ts`](../packages/opencode-oauth2/src/oauth/client.ts)) is the single dispatch point. The state machine for any cached token is: + +``` +isTokenValid(cached) → yes → use cached + ↓ no + machine flow? → yes → re-acquire (no refresh attempt) + ↓ no (user flow) + cached.refreshToken? → yes → POST grant_type=refresh_token + ↓ ok → use refreshed + ↓ fail → fall through + ↓ no + options.interactive === false → throw (warmup gives up; cached state preserved) + authFlow=device_code → loginDeviceCode() + default → loginInteractive() (browser + PKCE) +``` + +What each flow re-acquires: + +| Flow | Re-acquire path | Interactive? | Refresh token expected? | +| --- | --- | --- | --- | +| `authorization_code` | PKCE: browser → loopback callback → code exchange | yes | **yes** (rejected without one — see [Token Policy](#tokenpolicy)) | +| `device_code` | RFC 8628: request device code → poll token endpoint | yes (user enters code in a browser; the process polls) | **yes** | +| `client_credentials` | POST `grant_type=client_credentials` with `client_secret` | no | no (not requested, not stored) | +| `jwt_bearer` | Resolve subject JWT → POST `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer` | no | no | +| `token_exchange` | Resolve subject JWT → POST `grant_type=urn:ietf:params:oauth:grant-type:token-exchange` (+ optional `audience`) | no | no | + +The machine flows (`client_credentials`, `jwt_bearer`, `token_exchange`) dispatch **before** the refresh branch — re-presenting the platform identity is the canonical way to renew, so a stale refresh token from a different IdP run is never tried. + +### `isTokenValid` policy + +From `OAuthClient.isTokenValid`: + +```ts +if (!token.expiresAt) { + // machine flows: treat undefined expiry as INVALID (re-acquire cheap) + // user flows: treat undefined expiry as VALID (cost of re-auth is high) + return !machineFlows.includes(this.server.authFlow); +} +return Date.now() + tokenExpirySkewMs < token.expiresAt; +``` + +`tokenExpirySkewMs` defaults to `30_000`. A token expiring in less than 30 seconds is treated as already expired, leaving headroom for the upstream round-trip. + +Why the split: `expires_in` is optional in the OAuth spec. If your IdP omits it for `client_credentials`, the plugin doesn't know when the server's idea of the token expires — re-issuing one is a single POST, so we do that. For interactive flows (`authorization_code`, `device_code`), the cost is a browser dance, so we keep the older behavior: assume the cached token is still good until something explicitly fails (a 401 from upstream would surface as a chat error, prompting the user to reset auth manually). + +## TTY-aware warmup + +`OAuth2ModelSyncPlugin.start({ warmup, interactive })`: + +```ts +const interactiveWarmup = + options.interactive ?? Boolean(process.stdin?.isTTY && process.stdout?.isTTY); +``` + +Why: warmup at startup attempts a `syncServer` on every configured server. For uncached `authorization_code` / `device_code` providers, that would try to open a browser or block on device-code polling. In CI or a daemonized run, both stdin and stdout aren't TTYs — so the default falls back to non-interactive, the warmup throws "interactive authentication required", `syncServer` catches the error and preserves cached state. The provider stays in the OpenCode config with whatever models were cached previously (none on cold start). + +When a chat request arrives later, `chat.headers` calls `ensureAccessToken` (with no `interactive` option, so it defaults to interactive in `OAuthClient.ensureToken`), and on-demand auth runs. + +Override when you know better: + +- **`interactive: true`** when running under a process supervisor that doesn't expose a TTY but you do want first-run auth to attempt (rare). +- **`interactive: false`** in CI even on a runner that happens to allocate a pseudo-TTY (some self-hosted runners do) — guarantees no hang on a callback that nobody is going to complete. + +`start()` is called from inside the `config` hook with default options; you only override by constructing the runtime yourself (e.g. embedding `OAuth2ModelSyncPlugin` outside OpenCode). + +## Cache layout + +Source: [`src/cache.ts`](../packages/opencode-oauth2/src/cache.ts). + +### Directory + +| OS | Path | +| --- | --- | +| macOS | `~/Library/Caches/opencode-oauth2//` | +| Linux | `${XDG_CACHE_HOME:-~/.cache}/opencode-oauth2//` | +| Windows | `${LOCALAPPDATA:-~/AppData/Local}/opencode-oauth2//` | + +OpenCode-managed runtimes use `cacheNamespace = "opencode-oauth2-model-sync"` (hard-coded in `opencode.ts`); standalone embedders default to `"oauth2-model-sync"` (`validateConfig` default). + +Directories are created with `0o700`; cache files with `0o600`. Writes are atomic (write to `.tmp`, then `rename`). + +### File shape + +One file per server, named `.json`: + +```json +{ + "serverId": "miaou", + "updatedAt": 1737000000000, + "lastSyncAt": 1737000000000, + "token": { + "accessToken": "eyJ...", + "tokenType": "Bearer", + "refreshToken": "eyJ...", + "scope": "openid profile", + "expiresAt": 1737003600000 + }, + "rawModels": [{ "id": "glm-5", "object": "model" }], + "models": [{ "id": "glm-5", "displayName": "GLM 5" }] +} +``` + +`token.refreshToken` is **optional** in the file shape — `client_credentials` doesn't issue one. `accessToken` and `tokenType` are required; anything missing those gets the entire `token` field evicted on load (see `hasValidTokenShape`). `models` / `rawModels` survive eviction so a stale-but-known model list stays in OpenCode while re-auth happens. + +### Eviction + +The plugin never rotates the cache file itself — it overwrites in place. To force re-auth: + +```sh +# remove just one server's cached state +rm ~/Library/Caches/opencode-oauth2/opencode-oauth2-model-sync/miaou.json + +# nuke everything (all servers, all namespaces) +rm -rf ~/Library/Caches/opencode-oauth2/ +``` + +After deletion, the next `chat.headers` call (or warmup) triggers a fresh acquire. See [local development](./local-development.md#force-reauth) for when you need this. + +## Sync scheduler + +Source: [`src/scheduler.ts`](../packages/opencode-oauth2/src/scheduler.ts), driven from `OAuth2ModelSyncPlugin.start`. + +- One scheduler per server. Interval is `server.syncIntervalMinutes * 60_000` ms; default 60 minutes. +- Each tick calls `syncServer(serverId)` — same path warmup uses, but with no `interactive` override (so it inherits the default behavior of `ensureToken`). +- **Failure handling.** `syncServer` catches every error, logs `sync_failed`, and **does not mutate runtime state**. The previously cached models stay available to OpenCode; the next interval retries. +- If a sync succeeds with a different model set, the cache is updated and OpenCode sees the new models on the next `config` re-run (or immediate if the difference is in display names — those propagate through the merge in `chat.headers`'s opportunistic trigger and the next request). + +You don't normally want to disable the scheduler (there's no setting for it). If the upstream catalog is static, the scheduler is cheap — a `/models` GET every hour. + +## Logging + +Two layers of secret protection: + +### Field-name redaction (logger level) + +In `src/logging.ts`, `redactFields()` walks the structured-fields object and replaces any field whose key matches `/token|secret|password/i` with `"[redacted]"`. Applied to **every** log entry emitted by the plugin's own logger. So an inadvertently-logged `accessToken: "eyJ..."` becomes `accessToken: "[redacted]"` regardless of where it came from. + +### Substring scrubbing (`scrubSecrets`) + +In `src/oauth/http-utils.ts`, `scrubSecrets(text)` masks token-shaped substrings inside arbitrary error bodies: + +- JSON tokens: `"access_token": "eyJ..."`, `"refresh_token": "..."`, `"id_token": "..."`, `"client_secret": "..."`, plus `client_assertion`, `code`, `device_code`, `password`, `assertion`, `subject_token`, `actor_token`. +- Form bodies: `client_secret=...`, `access_token=...`, etc., terminated by `&` or end-of-string. +- `Authorization` header values: `Bearer `, `Basic `. +- Bare JWT-shaped strings: `eyJ..`. + +Applied at every `bodyPreview` site: + +- `oauth_client_credentials_failed` +- `oauth_jwt_bearer_failed`, `oauth_token_exchange_failed` +- `oauth_device_authorization_failed`, `oauth_device_code_poll_failed` +- `model_discovery_error_body` + +So forwarding the plugin's structured logs to a centralized aggregator (Loki, Datadog, etc.) is safe — there's no path where the plugin emits a raw access token through its logger. + +### What only goes to stderr + +Two paths bypass the structured logger and write directly to `process.stderr` so the **terminal user** can see them, but the values never enter centralized log forwarding: + +1. **Browser-open failure during `authorization_code`.** When `openExternalUrl` throws (no display, missing handler), the plugin writes the authorization URL — which contains the `state` nonce — straight to stderr. Logging it through the logger would land the nonce in shared aggregation, which could enable login-CSRF via a forged callback. +2. **Device-code user code + verification URL.** The user needs to read these out of their terminal to complete the flow. They are *also* logged (`oauth_device_code_issued`) — `user_code` is intentionally not redacted because it's an ephemeral single-use code with no value outside the active flow (RFC 8628 §3.2). But stderr is the reliable channel. + +### URL redaction (`redactUrl`) + +Anywhere the plugin logs a URL it ran (`tokenEndpoint`, `modelsUrl`), it goes through `redactUrl` first, which strips `user:pass@` userinfo, the query string, and the fragment. Configs like `https://user:pass@auth.example.com/realms/foo/token` don't leak credentials into the log. + +### Event names you'll actually see + +| Event | When | Notable fields | +| --- | --- | --- | +| `plugin_initialized` | first load | `serverCount` | +| `sync_start` | every scheduler tick or warmup | `serverId`, `interactive` | +| `sync_success` | model fetch succeeded | `modelCount`, `added`, `removed`, `renamed` | +| `sync_failed` | any error during sync | `serverId`, `error` (string) | +| `sync_startup_failed` | warmup failed | `serverId`, `error` | +| `oauth_login_started` | `authorization_code` PKCE began | `issuer`, `authorizationEndpoint` | +| `oauth_login_success` / `oauth_login_failed` | PKCE result | | +| `oauth_refresh_success` / `oauth_refresh_failed` | `refresh_token` exchange | | +| `oauth_device_code_issued` | RFC 8628 step 1 succeeded | `verificationUri`, `userCode`, `expiresIn` | +| `oauth_device_code_success` / `oauth_device_code_poll_failed` / `oauth_device_code_poll_transient_error` | RFC 8628 polling | `consecutiveFailures`, `nextIntervalSeconds` on transient | +| `oauth_client_credentials_started` / `_success` / `_failed` | `client_credentials` flow | `tokenEndpoint`, `status`, `bodyPreview` | +| `oauth_jwt_bearer_started` / `_success` / `_failed` | `jwt_bearer` flow | `subjectTokenSource`, `bodyPreview` | +| `oauth_token_exchange_started` / `_success` / `_failed` | `token_exchange` flow | `subjectTokenSource`, `bodyPreview` | +| `oauth_open_browser_failed` | `xdg-open`/`open`/`start` failed | `error` (URL goes to stderr separately) | +| `model_discovery_error_body` | `/v1/models` returned non-2xx | `modelsUrl`, `status`, `bodyPreview` | +| `model_discovery_empty` | `/v1/models` returned 0 models | `modelsUrl` | + +When OpenCode is the host, the plugin pipes everything through `client.app.log()` *in addition* to stderr (best-effort, non-blocking). Stderr is the reliable channel. + +## Token policy (recap) + +Refresh tokens are mandatory **for the flows that issue them**: + +- `authorization_code`, `device_code`, `refresh_token` responses → must include `refresh_token` (refresh responses can omit it; the previous one is preserved via `fallbackRefreshToken`). +- `client_credentials`, `jwt_bearer`, `token_exchange` → no `refresh_token` requested or stored. Re-authentication is the renewal path. + +If a flow that should have returned a refresh token didn't (`authorization_code` against a misconfigured IdP that suppresses `offline_access`), the exchange throws before the token lands in cache. + +## Provider ID resolution + +In `chat.headers`, the provider id is resolved as `input.model?.providerID ?? input.provider?.info?.id`. The fallback to `provider.info.id` matters when OpenCode flows hand the hook a `model` with an unset `providerID` — older OpenCode versions did this for certain config shapes. With both unset, the hook is a no-op and the request goes out without an `Authorization` header (which would 401 against an OAuth-protected gateway — useful signal in logs). diff --git a/docs/github-actions.md b/docs/github-actions.md new file mode 100644 index 0000000..4e24c64 --- /dev/null +++ b/docs/github-actions.md @@ -0,0 +1,290 @@ +# GitHub Actions cookbook + +Production patterns for running `@vymalo/opencode-oauth2` from CI without baking long-lived API keys into your workflow. The plugin uses the runner's own OIDC token as the subject token for an RFC 7523 `jwt_bearer` (or RFC 8693 `token_exchange`) grant — your OAuth server validates the runner's claims against the GitHub Actions JWKS and mints a short-lived access token. + +If you haven't read [`architecture.md`](./architecture.md), skim the "Token lifecycle per flow" section first. + +## Why federated identity beats stored secrets + +The runner already has a verifiable identity — `https://token.actions.githubusercontent.com` signs a JWT on demand whose claims include `repository`, `workflow`, `ref`, `actor`, `environment`, and an `aud` you choose per job. Your IdP can pin policy to any subset (e.g. *"this client may only be obtained by `repo:vymalo/opencode-oauth2:ref:refs/heads/main`"*). Rotation is free — every workflow run gets a fresh ~10-minute token. + +The plugin re-fetches the OIDC token on every access-token expiry (see `resolveSubjectToken` in [`subject-token.ts`](../packages/opencode-oauth2/src/oauth/subject-token.ts)). Nothing is cached except the IdP-issued access token. + +## IdP setup + +### Keycloak + +This is what the maintainer uses in production (`auth.verif.fyi/realms/camer-digital` advertises both `jwt_bearer` and `token_exchange`). Verified end-to-end against that realm. + +1. **Realm → Identity Providers → Add → OpenID Connect v1.0.** + - **Alias:** `github-actions` (anything; referenced internally only). + - **Discovery endpoint:** `https://token.actions.githubusercontent.com/.well-known/openid-configuration`. + - **Sync mode:** `IMPORT` or `LEGACY` — your call; the plugin doesn't care. + - **Trust Email:** off. + - Save. + +2. **Client (the one the plugin uses).** + - Create a `Public` or `Confidential` client (the plugin supports both — pass `clientSecret` if confidential). + - **Capability config:** + - Disable *Standard flow* (no PKCE here — that's for end users). + - Enable *Service accounts roles* only if you also want `client_credentials` as a fallback. + - **Enable "OAuth 2.0 Token Exchange"** (Keycloak ≥ 18, under *Capability config → Advanced settings*). + - **Advanced settings → Token Exchange:** enabled. + - **Web origins:** leave empty — the plugin never hits the authorization endpoint for this client. + +3. **Token Exchange permissions (the part that's easy to miss).** + - Open the client → *Permissions* → toggle *Permissions Enabled*. + - Open the `token-exchange` permission → *Policies* → add a *Client* policy targeting your GHA-backed identity provider, plus a *User* or *Group* policy if you want claim-based scoping. + - Keycloak's *Token Exchange* docs: https://www.keycloak.org/securing-apps/token-exchange + +4. **Claim mapping (optional but recommended).** + - On the GHA identity provider → *Mappers* → *Add mapper* → *Hardcoded attribute* (or *Claim to attribute*) to surface `repository`, `workflow`, `ref` as user attributes you can audit on. + +5. **Audience.** The audience your workflow requests (`audience:` in `subjectTokenSource`) must equal the **Identity Provider's `Issuer URL`** field, *not* arbitrary. Keycloak's GHA IdP rejects mismatched `aud`. Pin one audience per workflow — see [audience pinning](#audience-pinning) below. + +### Auth0 + +1. **Applications → APIs → Create API.** + - Identifier (== audience): `https://api.example.com` (or anything stable you'll pass as `audience`). + - Token signing: `RS256`. +2. **Applications → Create Application** of type *Machine to Machine* and authorize it for the API above. +3. **Federated identity:** Auth0 supports `urn:ietf:params:oauth:grant-type:jwt-bearer` via the [Custom Database with Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) feature (Enterprise). For most teams, point Auth0 at the GitHub Actions JWKS via an [Action](https://auth0.com/docs/customize/actions) on the token-exchange hook and validate the `iss` / `aud` claims explicitly. +4. **Detailed walkthrough:** https://auth0.com/docs/authenticate/custom-token-exchange — covers the policy DSL and rate limits. + +### Okta + +1. **Security → API → Authorization Servers → Default (or create one) → Claims.** Add a custom claim `repository` mapped from `request.body.assertion.repository` (or whatever Okta is configured to surface from incoming JWTs). +2. **Applications → Create App Integration → API Services.** Generate a client ID/secret pair; this is what the plugin presents as `client_id` (+ `client_secret` if confidential). +3. **Configure JWT Authorization grant.** Okta's docs: https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/ — the *JWT Bearer* path lets you trust an external IdP's signed JWT as the assertion. + +For Auth0 and Okta, defer to vendor docs for the up-to-date click path. The relevant invariant is: **your IdP must accept a JWT signed by `https://token.actions.githubusercontent.com` with `aud` equal to your configured audience, and mint a client-credentials-shaped access token in response.** + +## The reusable workflow + +This repo ships a reusable workflow at [`.github/workflows/opencode-run.yml`](../.github/workflows/opencode-run.yml). Consumers can call it without copy-pasting the setup: + +```yaml +name: AI-assisted analysis +on: + workflow_dispatch: + inputs: + prompt: + description: Prompt to send to opencode + required: true + +permissions: + id-token: write + contents: read + +jobs: + run: + uses: vymalo/opencode-oauth2/.github/workflows/opencode-run.yml@v0.2.0 + with: + model: miaou/glm-5 + prompt: ${{ inputs.prompt }} + opencode-config-path: .opencode-ci/opencode.json +``` + +The reusable workflow handles: + +- Installing pnpm + Node 22. +- `npm install -g opencode @vymalo/opencode-oauth2`. +- Pointing `OPENCODE_CONFIG_DIR` at the directory you specify. +- Running `opencode run --model "" ""`. + +You're responsible for committing `.opencode-ci/opencode.json` (or wherever you pointed `opencode-config-path`) with the `authFlow: "jwt_bearer"` config. + +## Minimal worked example + +`.github/workflows/ai.yml` in your repo: + +```yaml +name: AI summary +on: + pull_request: + types: [opened, synchronize] + +permissions: + id-token: write + contents: read + pull-requests: write + +jobs: + summarize: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npm install -g opencode @vymalo/opencode-oauth2 + - run: opencode run --model "miaou/glm-5" "Summarize the changes in this PR" > summary.md + env: + OPENCODE_CONFIG_DIR: "${{ github.workspace }}/.opencode-ci" + - run: gh pr comment ${{ github.event.pull_request.number }} --body-file summary.md + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +`.opencode-ci/opencode.json` in your repo (committed): + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@vymalo/opencode-oauth2"], + "provider": { + "miaou": { + "name": "Miaou", + "options": { + "baseURL": "https://api.example.com/v1", + "oauth2": { + "issuer": "https://auth.verif.fyi/realms/camer-digital", + "clientId": "opencode-ci", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "github_actions", + "audience": "https://auth.verif.fyi/realms/camer-digital" + } + } + } + } + } +} +``` + +No `clientSecret` field anywhere. No secrets in repo settings. The runner's OIDC token authenticates to Keycloak; Keycloak mints an access token for the `opencode-ci` client. + +## Matrix builds + +One OIDC trust on your IdP, N runners (Linux/macOS, multiple Node versions, etc.). Each matrix leg mints its own OIDC token — no shared state between them. + +```yaml +name: AI on many platforms +on: [workflow_dispatch] + +permissions: + id-token: write + contents: read + +jobs: + run: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + node: [20, 22] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm install -g opencode @vymalo/opencode-oauth2 + - run: opencode run --model "miaou/glm-5" "say hi from ${{ matrix.os }} node${{ matrix.node }}" + env: + OPENCODE_CONFIG_DIR: "${{ github.workspace }}/.opencode-ci" +``` + +The OIDC token's `repository` claim is identical across legs; the `run_id` and `job_workflow_ref` vary. If your IdP policy needs to allow all legs, it should match on `repository` and `workflow`, not `run_id`. + +## Audience pinning + +Use a distinct `audience` per workflow. Why: + +- A token minted with `audience: A` cannot be replayed against an IdP trust expecting `audience: B`. Re-use across unrelated workflows is blocked at the JWT-validation layer. +- An attacker who exfiltrates a single workflow's OIDC token gets a credential scoped only to that audience — they can't use it to obtain access tokens for unrelated clients in your IdP. + +Concrete pattern: + +| Workflow | `audience` value | +| --- | --- | +| `.github/workflows/ai-summary.yml` | `https://auth.example.com/realms/prod/clients/opencode-ai-summary` | +| `.github/workflows/ai-triage.yml` | `https://auth.example.com/realms/prod/clients/opencode-ai-triage` | +| `.github/workflows/nightly-eval.yml` | `https://auth.example.com/realms/prod/clients/opencode-nightly-eval` | + +On the IdP side, each audience corresponds to its own IdP-trust → client mapping, so claim-based policy (`repository:foo/bar`, `workflow:.github/workflows/ai-summary.yml`) can be enforced independently per workflow. + +Keycloak: each workflow gets its own client with its own *Token Exchange permission* policy. The "audience" you pin in YAML is the value Keycloak expects in the assertion's `aud` claim. + +## Fork PR limitations + +`id-token: write` is **not** granted to `pull_request` workflows triggered by forks. From [GitHub's docs](https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect#using-openid-connect-with-reusable-workflows): pull requests from forked repositories cannot mint OIDC tokens, because the fork could otherwise authenticate as the upstream repo's identity. + +Workarounds, in increasing order of risk: + +### 1. Run on `push` to `main` after merge + +The safest pattern. Your AI workflow runs after a maintainer merges. No fork ever touches `id-token: write`. + +```yaml +on: + push: + branches: [main] +``` + +### 2. `pull_request_target` with manual gating + +`pull_request_target` runs in the **upstream** repo's context, so `id-token: write` works. But it also gives the workflow access to the upstream's secrets — and by default checks out the upstream's `main`, not the PR. **Never check out and run untrusted PR code** under this trigger. + +Gate by maintainer approval: + +```yaml +on: + pull_request_target: + types: [labeled] + +jobs: + run: + if: github.event.label.name == 'ai-review-approved' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + # IMPORTANT: explicitly checkout the upstream ref, NOT the PR head. + # Code from the PR is treated as untrusted input. + with: + ref: ${{ github.event.pull_request.base.ref }} + - run: npm install -g opencode @vymalo/opencode-oauth2 + # Read the PR diff but do not execute fork-provided code. + - id: diff + run: gh pr diff ${{ github.event.pull_request.number }} > /tmp/diff.patch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - run: | + opencode run --model "miaou/glm-5" "Review this diff: $(cat /tmp/diff.patch)" + env: + OPENCODE_CONFIG_DIR: "${{ github.workspace }}/.opencode-ci" +``` + +The label-based gate (`if: github.event.label.name == 'ai-review-approved'`) means a maintainer must explicitly opt a PR in. Without it, anyone opening a PR could trigger your AI budget. + +### 3. Restricted-scope reusable workflow + +If you must run on `pull_request` from forks (e.g. for *limited* AI-driven analysis that doesn't need IdP access), the reusable workflow can downgrade to a non-OAuth provider for fork PRs and only use OAuth on `push`/`workflow_dispatch`: + +```yaml +jobs: + run: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: vymalo/opencode-oauth2/.github/workflows/opencode-run.yml@v0.2.0 + with: + model: miaou/glm-5 + prompt: ${{ inputs.prompt }} +``` + +The condition `head.repo.full_name == github.repository` evaluates true only when the PR head is in the same repo (not a fork). + +**Honest tradeoff:** any path that grants fork-PR access to a long-lived IdP credential — including the `pull_request_target` pattern above — has to be defended at the IdP policy layer. The plugin can't make that defense for you. Audit Keycloak's `aud` and `repository` claims, set strict policy, and budget for the worst case where a stolen token gets one round trip to your provider before the IdP times out. + +## Verifying it works + +Run the workflow once manually (`workflow_dispatch`) and look for: + +- `oauth_jwt_bearer_started` log event with `subjectTokenSource: "github_actions"`. +- `oauth_jwt_bearer_success` with `hasExpiry: true` (Keycloak issues `expires_in`). +- `sync_success` with a `modelCount > 0`. + +If you see `subjectTokenSource (github_actions): ACTIONS_ID_TOKEN_REQUEST_URL ... must be set`, the `permissions: id-token: write` block is missing or you're running under a fork PR — see [Fork PR limitations](#fork-pr-limitations). + +If you see `jwt_bearer request failed (401)`, the IdP rejected the assertion — see [troubleshooting](./troubleshooting.md#jwt_bearer-401). diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 0000000..d12f836 --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,374 @@ +# Kubernetes cookbook + +Production patterns for running `@vymalo/opencode-oauth2` from inside a pod, with the pod's projected ServiceAccount token serving as the subject token for an RFC 7523 `jwt_bearer` (or RFC 8693 `token_exchange`) grant. No client secrets in the cluster. + +If you're new to projected SA tokens, the relevant primitive is [`serviceAccountToken` volume sources](https://kubernetes.io/docs/concepts/storage/projected-volumes/#serviceaccounttoken). The kubelet refreshes the file in place; the plugin re-reads it on every access-token expiry via `subjectTokenSource: { type: "kubernetes_sa" }`. Rotation is transparent — no pod restart needed. + +The default token mount path is `/var/run/secrets/tokens/oauth2/token` (`DEFAULT_K8S_SA_TOKEN_PATH` in [`config.ts`](../packages/opencode-oauth2/src/config.ts)). Override with `subjectTokenSource: { type: "kubernetes_sa", tokenPath: "/your/path" }`. + +## CronJob — scheduled AI task + +The headline use case. Run a scheduled summarization / report / analytics job that needs an LLM but has no human in the loop. + +```yaml +apiVersion: batch/v1 +kind: CronJob +metadata: + name: opencode-daily-digest + namespace: ai-jobs +spec: + # 09:00 UTC every weekday — adjust to your timezone via your scheduler's quirks + # (CronJob.spec.timeZone requires k8s 1.27+; otherwise it's UTC). + schedule: "0 9 * * 1-5" + timeZone: "Etc/UTC" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + activeDeadlineSeconds: 1800 + template: + spec: + serviceAccountName: opencode-runner + restartPolicy: Never + containers: + - name: runner + image: ghcr.io/your-org/opencode-with-plugin:0.2.0 + imagePullPolicy: IfNotPresent + env: + - name: OPENCODE_CONFIG_DIR + value: /etc/opencode + # Force non-interactive so the plugin never tries TTY warmup + # logic — CronJobs run with no stdin attached but some shells + # mis-report isTTY. Belt-and-braces; the default detection is + # usually right. + - name: CI + value: "true" + command: + - /bin/sh + - -c + - | + set -eu + opencode run \ + --model "miaou/glm-5" \ + "Summarize yesterday's customer support tickets and post the top 5 to #support-digest" + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 1Gi + volumeMounts: + - name: oauth2-token + mountPath: /var/run/secrets/tokens/oauth2 + readOnly: true + - name: opencode-config + mountPath: /etc/opencode + readOnly: true + - name: opencode-cache + mountPath: /root/.cache/opencode-oauth2 + volumes: + - name: oauth2-token + projected: + sources: + - serviceAccountToken: + path: token + # MUST match what your IdP expects in the JWT `aud` claim. + # See docs/architecture.md and your IdP setup below. + audience: https://auth.example.com/realms/prod + # 1 hour is the default; tune for your job duration. + expirationSeconds: 3600 + - name: opencode-config + configMap: + name: opencode-config + # emptyDir so the access-token cache is per-pod-instance. For a + # short CronJob there's nothing to cache between runs — each pod + # gets a fresh token. Use a PVC if you want cache survival across + # pod restarts (rare for CronJobs). + - name: opencode-cache + emptyDir: {} +``` + +ConfigMap for `OPENCODE_CONFIG_DIR`: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: opencode-config + namespace: ai-jobs +data: + opencode.json: | + { + "$schema": "https://opencode.ai/config.json", + "plugin": ["@vymalo/opencode-oauth2"], + "provider": { + "miaou": { + "name": "Miaou", + "options": { + "baseURL": "https://api.example.com/v1", + "oauth2": { + "issuer": "https://auth.example.com/realms/prod", + "clientId": "k8s-runner", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "kubernetes_sa" + } + } + } + } + } + } +``` + +ServiceAccount (no RBAC bindings needed — see [RBAC](#rbac) below): + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: opencode-runner + namespace: ai-jobs +``` + +## Job — one-shot task + +The minimum example, comparable to what's in the package README. Useful for ad-hoc tasks driven by `kubectl create -f ...`. + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: opencode-summarize-incident + namespace: ai-jobs +spec: + ttlSecondsAfterFinished: 3600 + template: + spec: + serviceAccountName: opencode-runner + restartPolicy: Never + containers: + - name: runner + image: ghcr.io/your-org/opencode-with-plugin:0.2.0 + env: + - name: OPENCODE_CONFIG_DIR + value: /etc/opencode + command: ["opencode", "run", "--model", "miaou/glm-5", "summarize incident-1234"] + volumeMounts: + - { name: oauth2-token, mountPath: /var/run/secrets/tokens/oauth2, readOnly: true } + - { name: opencode-config, mountPath: /etc/opencode, readOnly: true } + volumes: + - name: oauth2-token + projected: + sources: + - serviceAccountToken: + path: token + audience: https://auth.example.com/realms/prod + expirationSeconds: 3600 + - name: opencode-config + configMap: + name: opencode-config +``` + +## Deployment — long-running pod + +For an opencode-backed HTTP service (e.g. a Slack bot, an internal API wrapper) running for days at a time. **The key point: token rotation is fully transparent.** + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: opencode-bot + namespace: ai-jobs +spec: + replicas: 2 + selector: + matchLabels: + app: opencode-bot + template: + metadata: + labels: + app: opencode-bot + spec: + serviceAccountName: opencode-runner + containers: + - name: bot + image: ghcr.io/your-org/opencode-bot:0.5.0 + ports: + - containerPort: 8080 + env: + - name: OPENCODE_CONFIG_DIR + value: /etc/opencode + readinessProbe: + httpGet: { path: /healthz, port: 8080 } + volumeMounts: + - { name: oauth2-token, mountPath: /var/run/secrets/tokens/oauth2, readOnly: true } + - { name: opencode-config, mountPath: /etc/opencode, readOnly: true } + volumes: + - name: oauth2-token + projected: + sources: + - serviceAccountToken: + path: token + audience: https://auth.example.com/realms/prod + # 1 hour. Kubelet refreshes the file ~80% through its lifetime. + expirationSeconds: 3600 + - name: opencode-config + configMap: + name: opencode-config +``` + +### How rotation actually works + +1. Pod starts; kubelet writes a JWT to `/var/run/secrets/tokens/oauth2/token` valid for `expirationSeconds`. +2. Plugin reads it on first auth, exchanges for an access token (also typically 1 hour), caches the access token in `~/.cache/opencode-oauth2/...`. +3. Access token nears expiry → `isTokenValid` returns false → `loginJwtBearer()` → `resolveSubjectToken()` re-reads the file → kubelet may have already rotated it (no-op for the plugin) → POST to IdP → new access token. +4. Kubelet rotates the projected JWT in place, atomically. The plugin sees the new contents on the next read. + +**No pod restart is needed.** If you ever do see auth failures correlated with the SA token's `expirationSeconds` boundary, check kubelet logs for `TokenProjection` errors — the kubelet is responsible for keeping the file fresh. + +## Multiple providers in one pod + +Different audiences, different mount paths, different `subjectTokenSource.tokenPath` per provider entry: + +```yaml +# In the Deployment / Job / CronJob spec: + volumes: + - name: oauth2-tokens + projected: + sources: + - serviceAccountToken: + path: prod-token + audience: https://auth-prod.example.com/realms/main + expirationSeconds: 3600 + - serviceAccountToken: + path: staging-token + audience: https://auth-staging.example.com/realms/main + expirationSeconds: 3600 + - name: opencode-config + configMap: + name: opencode-config + containers: + - name: bot + volumeMounts: + - { name: oauth2-tokens, mountPath: /var/run/secrets/tokens/oauth2, readOnly: true } + - { name: opencode-config, mountPath: /etc/opencode, readOnly: true } +``` + +ConfigMap: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: opencode-config + namespace: ai-jobs +data: + opencode.json: | + { + "$schema": "https://opencode.ai/config.json", + "plugin": ["@vymalo/opencode-oauth2"], + "provider": { + "miaou-prod": { + "name": "Miaou (prod)", + "options": { + "baseURL": "https://api-prod.example.com/v1", + "oauth2": { + "issuer": "https://auth-prod.example.com/realms/main", + "clientId": "k8s-bot", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "kubernetes_sa", + "tokenPath": "/var/run/secrets/tokens/oauth2/prod-token" + } + } + } + }, + "miaou-staging": { + "name": "Miaou (staging)", + "options": { + "baseURL": "https://api-staging.example.com/v1", + "oauth2": { + "issuer": "https://auth-staging.example.com/realms/main", + "clientId": "k8s-bot", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "kubernetes_sa", + "tokenPath": "/var/run/secrets/tokens/oauth2/staging-token" + } + } + } + } + } + } +``` + +Each provider's token has its own audience, so a token leaked from the staging pod can't be replayed against prod IdP trust. + +## IdP setup (Keycloak / Dex) + +Your IdP needs to trust the cluster's OIDC issuer. + +### 1. Discover the cluster's issuer + +```sh +kubectl get --raw /.well-known/openid-configuration | jq . +``` + +Look for `issuer`. Examples: + +- GKE: `https://container.googleapis.com/v1/projects//locations//clusters/` +- EKS: `https://oidc.eks..amazonaws.com/id/` +- AKS: `https://.oic.prod-aks.azure.com///` +- self-managed: whatever `kube-apiserver --service-account-issuer` was set to (commonly `https://kubernetes.default.svc`, which won't resolve outside the cluster — you must run a public-issuer setup for the IdP to fetch JWKS). + +For self-managed clusters where the IdP can't reach the apiserver, you typically front the `/.well-known/openid-configuration` + `/openid/v1/jwks` paths with a public mirror (S3, a public ingress, etc.) — see [kubernetes/cloud-provider-oidc-discovery-mirror](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/#service-account-issuer-discovery). + +### 2. Register with Keycloak + +Same pattern as the GitHub Actions setup (see [docs/github-actions.md#keycloak](./github-actions.md#keycloak)): + +- **Realm → Identity Providers → Add → OpenID Connect v1.0.** +- **Discovery endpoint:** `/.well-known/openid-configuration`. +- Client with **Token Exchange** capability enabled. +- Pin the audience to the IdP's expected value (must equal what you put in `serviceAccountToken.audience`). + +### 3. Register with Dex + +Dex's [federated-token connector](https://dexidp.io/docs/connectors/) handles trusting external OIDC issuers. The relevant config block: + +```yaml +connectors: + - type: oidc + id: kubernetes + name: Kubernetes + config: + issuer: https://oidc.eks.us-east-1.amazonaws.com/id/EXAMPLE + clientID: dex + clientSecret: ... + insecureEnableGroups: false +``` + +For a fuller Dex walkthrough see https://dexidp.io/docs/. + +## RBAC + +**The ServiceAccount needs no Kubernetes RBAC permissions.** The projected token feature is unrelated to RBAC — any default SA can have a projected token mount, and the projected token's audience claim is independent of any RoleBinding / ClusterRoleBinding the SA has on the kube-apiserver. + +Concretely: the SA does not need `get`/`list`/`watch` on anything. It does not even need to be authorized to talk to the kube-apiserver. Its only role is *being the identity the projected token attests to*. + +If you find yourself reaching for `ClusterRoleBinding`, stop. You almost certainly don't need it. The exception is if you're additionally using the cluster's kube-apiserver as the OIDC issuer your IdP federates from (you are), and that requires nothing on the SA's side — the kubelet talks to the apiserver, not your pod. + +## OpenShift + +OpenShift's defaults around projected tokens are largely identical (it ships with the `TokenRequest` API enabled). The notable differences: + +- The default audience for SA tokens minted via `oc create token` is the cluster's own apiserver, not arbitrary. You must explicitly specify `--audience` when scripting, and the projected volume's `audience:` field works the same way as in upstream k8s. +- SCC (SecurityContextConstraints) may restrict `volumeTypes` — by default `projected` is included in `restricted-v2`, but a hardened SCC could remove it. Check with `oc adm policy who-can use scc/`. + +For OpenShift-specific quirks beyond projected tokens (custom CA bundles, idle pod eviction policies, ServiceAccount kubeconfigs), check Red Hat's docs — the maintainer hasn't validated this end-to-end on OpenShift, so don't assume parity with upstream Kubernetes without testing. diff --git a/docs/local-development.md b/docs/local-development.md new file mode 100644 index 0000000..74cf744 --- /dev/null +++ b/docs/local-development.md @@ -0,0 +1,190 @@ +# Local development + +How to iterate on `@vymalo/opencode-oauth2` (or on an opencode setup that uses it) without polluting your shell's `~/.config/opencode/` or fighting cached tokens. + +## Sandbox: scoped OpenCode config + +OpenCode reads its config from `$OPENCODE_CONFIG_DIR` if set, falling back to its built-in default. Point it at a throwaway directory for the duration of your shell session: + +```sh +export OPENCODE_CONFIG_DIR=/tmp/opencode-sandbox +mkdir -p "$OPENCODE_CONFIG_DIR" +``` + +Write your `opencode.json` there: + +```sh +cat > "$OPENCODE_CONFIG_DIR/opencode.json" <<'JSON' +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["./plugins/opencode-oauth2.ts"], + "provider": { + "miaou": { + "name": "Miaou (local)", + "options": { + "baseURL": "https://api.example.com/v1", + "oauth2": { + "issuer": "https://auth.verif.fyi/realms/camer-digital", + "clientId": "opencode-local", + "scopes": ["openid", "profile", "offline_access"], + "redirectPort": 8765 + } + } + } + } +} +JSON +``` + +Now `opencode run --model "miaou/glm-5" "..."` uses the sandbox; your real config is untouched. + +### Plugin re-export trick + +OpenCode loads plugin paths relative to `$OPENCODE_CONFIG_DIR`. To iterate on a locally-checked-out version of the plugin without `npm link`: + +```sh +mkdir -p "$OPENCODE_CONFIG_DIR/plugins" +cat > "$OPENCODE_CONFIG_DIR/plugins/opencode-oauth2.ts" <<'TS' +// Re-export so OpenCode treats this as the plugin module while still +// loading code from your local checkout. +export { default } from "/absolute/path/to/lightbridge-opencode/packages/opencode-oauth2/dist/index.js"; +TS +``` + +Build the plugin in watch mode: + +```sh +pnpm --filter @vymalo/opencode-oauth2 build --watch +``` + +Each `opencode run` picks up the latest `dist/`. No reload, no daemon, no install step. + +If you'd rather import directly from `src/` (TypeScript), set `"type": "module"` and use OpenCode's TS plugin loader — see the OpenCode plugin docs. The `dist/` re-export is the path of least resistance. + +## Fixed `redirectPort` vs random + +The plugin defaults to **random** loopback ports for the `authorization_code` callback (`startLocalCallbackServer` listens on `127.0.0.1:0`, then reads the assigned port). The IdP's allowed-redirect-URIs list must accept the resulting URI. + +| IdP behavior | Use | +| --- | --- | +| Wildcard loopback: `http://127.0.0.1:*/...` (Auth0, Okta with proper config, Keycloak with `+` in valid redirect URIs) | random — `redirectPort` omitted from config | +| Strict literal redirect URI list (Keycloak default; many enterprise IdPs) | fixed `redirectPort: 8765` (any port that's free locally) + register `http://127.0.0.1:8765/oauth2/callback` in the IdP | + +Fixed-port pitfalls: + +- If port 8765 is taken (another process bound to it), the plugin throws on `startLocalCallbackServer`. Pick another port and update both ends. +- A fixed port across multiple machines means each machine collides if multiple users authenticate simultaneously — fine for a single-user laptop, not fine for shared infra. + +When in doubt, register a small range (e.g. ports 8765–8770) in the IdP and let the plugin pick one. The callback path is hardcoded as `/oauth2/callback`. + +## Force re-auth + +Delete the cached state. The plugin's cache directory is laid out as `/opencode-oauth2//.json` — see [architecture.md](./architecture.md#cache-layout) for the full path table. + +macOS: + +```sh +# remove just one server's cached state +rm ~/Library/Caches/opencode-oauth2/opencode-oauth2-model-sync/miaou.json + +# nuke everything (all servers, all namespaces) +rm -rf ~/Library/Caches/opencode-oauth2/ +``` + +Linux: + +```sh +rm ~/.cache/opencode-oauth2/opencode-oauth2-model-sync/miaou.json +# or: +rm -rf ~/.cache/opencode-oauth2/ +``` + +Windows (PowerShell): + +```powershell +Remove-Item "$env:LOCALAPPDATA\opencode-oauth2\opencode-oauth2-model-sync\miaou.json" +``` + +After deletion, the next `opencode run` triggers a fresh acquire (browser launch for `authorization_code`, device-code prompt for `device_code`, etc.). + +### When you specifically need to force re-auth + +- **Scope changes.** Keycloak's `refresh_token` exchange preserves the **originally granted scopes**, ignoring any new `scope` you pass on the refresh request. Adding a scope to your config takes effect only after a fresh login. +- **Audience changes.** Same logic — re-auth, don't refresh. +- **Switching `authFlow`.** Cached refresh tokens from one flow are usually rejected by the IdP if you flip to another. Delete the cache rather than chasing 401s. +- **IdP-side client changes** (rotated `clientSecret`, changed redirect URIs, revoked sessions): the cached refresh token may be invalidated server-side. Delete + re-auth. + +## Dev-only `env` subject-token source + +For testing `jwt_bearer` / `token_exchange` flows without a live GitHub Actions runtime or a Kubernetes cluster, hand the plugin a known-good JWT via an environment variable: + +```sh +# Get a token however you can — kubectl, an Okta test util, a hand-signed +# JWT from your IdP's admin console. The point is the plugin presents this +# as the subject token, exactly as it would in CI/K8s. +export FAKE_JWT="eyJhbGciOiJSUzI1NiIs..." +``` + +Config: + +```jsonc +{ + "provider": { + "miaou": { + "options": { + "baseURL": "https://api.example.com/v1", + "oauth2": { + "issuer": "https://auth.verif.fyi/realms/camer-digital", + "clientId": "opencode-dev", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "env", + "var": "FAKE_JWT" + } + } + } + } + } +} +``` + +Now `opencode run --model "miaou/glm-5" "..."` exercises the full `jwt_bearer` POST against your real IdP, presenting `FAKE_JWT` as the assertion. Useful for: + +- Debugging IdP-side claim mapping / token-exchange policy without spinning up a runner. +- Reproducing 401s observed in CI locally with the same JWT. +- Smoke-testing a new Keycloak client config before pushing to staging. + +The `env` source is intentionally not documented for production — it bypasses the platform's identity attestation. Use `github_actions` or `kubernetes_sa` for anything real. + +## Useful one-liners + +Inspect the current cached state: + +```sh +# macOS +cat ~/Library/Caches/opencode-oauth2/opencode-oauth2-model-sync/miaou.json | jq . + +# Linux +cat ~/.cache/opencode-oauth2/opencode-oauth2-model-sync/miaou.json | jq . +``` + +Tail the plugin's JSON logs (when running opencode with stderr redirected): + +```sh +opencode run --model "miaou/glm-5" "say hi" 2>&1 \ + | jq -Rr 'fromjson? // .' \ + | grep -E '"event":"(oauth|sync|model)' +``` + +Run the test suite while iterating: + +```sh +pnpm --filter @vymalo/opencode-oauth2 test --watch +``` + +Hit a single test file: + +```sh +pnpm --filter @vymalo/opencode-oauth2 test -- src/oauth/client.test.ts +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..14bb170 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,251 @@ +# Troubleshooting + +Symptom-keyed. Each entry covers what's happening internally, where to look in logs, and a diagnostic you can run. + +The plugin emits structured JSON logs to stderr (and through `client.app.log()` when running under OpenCode). Anywhere this guide says "look for `` in the logs", that's an event name in the `event` field of those entries — see [architecture.md → Logging](./architecture.md#logging) for the full table. + +## `/models` doesn't list my provider after install + +**What's happening.** The plugin loaded fine, but warmup at config-hook time ran non-interactively (CI, no TTY, or `interactive: false`) and the cache was empty. `ensureToken` threw `interactive authentication required`; `syncServer` caught it, logged `sync_startup_failed`, and preserved the empty cache. The provider stays registered in OpenCode's config but with no models attached, so the model list is empty. + +**Look for.** + +- `plugin_initialized` — confirms the plugin loaded. +- `sync_startup_failed` with `error: "interactive authentication required for server ..."` — confirms warmup gave up non-interactively. +- Absence of `sync_success` and `model_discovery_*`. + +**Fix.** Trigger auth via an actual `opencode run` (the chat path calls `ensureToken` with `interactive: true` by default), or override warmup interactivity at start time. There's no `pluginConfig` knob for this — if you're embedding the runtime yourself, pass `interactive: true` to `start()`. From the OpenCode-hosted path, you can't override; just run a one-shot to bootstrap. + +```sh +# Bootstrap auth interactively — completes the PKCE browser dance once, +# leaves a refresh token in cache for subsequent non-interactive runs. +opencode run --model "miaou/glm-5" "hello" +``` + +After that, the model list should populate within one scheduler tick (default 60 minutes) or on the next `opencode` restart (warmup picks up the cached refresh token and refreshes silently). + +## `redirect_uri_mismatch` from the IdP during PKCE login + +**What's happening.** The plugin started a loopback callback server on some `127.0.0.1:`, set `redirect_uri=http://127.0.0.1:/oauth2/callback` in the authorize URL, and the IdP rejected the value because it's not in the client's allowed-redirect-URI list. + +**Look for.** The error surfaces in the browser, not the logs — the IdP returns it to the user before the callback runs. The plugin's `oauth_login_started` event will be present; `oauth_login_success` will not. + +**Fix per IdP.** + +| IdP | Add to allowed redirect URIs | +| --- | --- | +| Keycloak | `http://127.0.0.1:*/oauth2/callback` (with `+` wildcard enabled at realm level) **or** pin `redirectPort: 8765` and add `http://127.0.0.1:8765/oauth2/callback` literally | +| Auth0 | `http://localhost` is implicitly allowed for native apps; for explicit registration, pin `redirectPort` and add `http://127.0.0.1:/oauth2/callback` to *Allowed Callback URLs* | +| Okta | Pin `redirectPort` and add `http://127.0.0.1:/oauth2/callback` to *Sign-in redirect URIs* on the OIDC app | + +The plugin's callback path is hard-coded as `/oauth2/callback`. The host is always `127.0.0.1` (not `localhost`) — register the literal `127.0.0.1`, not its DNS alias. + +See [local-development.md → Fixed redirectPort vs random](./local-development.md#fixed-redirectport-vs-random) for the tradeoffs. + +## `model discovery failed (403)` after auth succeeded + +**What's happening.** OAuth succeeded — you have a valid access token — but the upstream `/v1/models` endpoint returned 403. The access token is missing the scope or audience the gateway expects. + +**Look for.** + +- `oauth_*_success` events present (proves auth worked). +- `sync_failed` with `error: "model discovery failed (403) at https://api.example.com/v1/models"`. +- `model_discovery_error_body` with `status: 403` and a `bodyPreview` (token-shaped substrings are masked by `scrubSecrets`). + +**Diagnose.** Copy the access token from the cache and curl `/v1/models` directly: + +```sh +# Pull the access token from the cache (replace with your platform's path). +token=$(jq -r .token.accessToken \ + ~/Library/Caches/opencode-oauth2/opencode-oauth2-model-sync/miaou.json) + +# Hit /v1/models with it. +curl -i \ + -H "Authorization: Bearer $token" \ + -H "Accept: application/json" \ + https://api.example.com/v1/models +``` + +If you get the same 403, decode the JWT to inspect the claims: + +```sh +# Decode the payload (middle segment). +echo "$token" | cut -d. -f2 | base64 -d 2>/dev/null | jq . +``` + +Compare: + +- **`scope`** (or `scp` — depends on IdP) against what your gateway requires. Adjust `scopes:` in `opencode.json` and force re-auth (see [local-development.md → Force re-auth](./local-development.md#force-reauth)). +- **`aud`** against what your gateway validates. For `token_exchange`, set `tokenExchangeAudience` to match. + +## `oauth_client_credentials_failed` 401 with `invalid_client` + +**What's happening.** The IdP rejected the `client_id` + `client_secret` combination. Three common root causes for Keycloak; similar elsewhere. + +**Look for.** + +- `oauth_client_credentials_failed` with `status: 401` and `bodyPreview` containing `invalid_client` or `unauthorized_client`. +- The `bodyPreview` will have token-shaped values masked, but the `error` and `error_description` fields are usually preserved. + +**Diagnose (Keycloak).** + +1. **Service accounts disabled.** In Keycloak admin: *Clients → \ → Capability config*. Ensure **Service accounts roles** is **on**. Without it, the client cannot use `client_credentials` regardless of secret validity. +2. **Wrong secret.** *Credentials* tab → confirm the secret matches `clientSecret` in your config. Rotated secrets in Keycloak invalidate the previous one immediately. +3. **Client type mismatch.** A *Public* client (no secret) cannot use `client_credentials`. Convert to *Confidential* in *Capability config → Client authentication: ON*. + +Reproduce manually: + +```sh +curl -i -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=YOUR_CLIENT&client_secret=YOUR_SECRET" \ + https://auth.example.com/realms/your-realm/protocol/openid-connect/token +``` + +A 200 here with an access token means the issue is in your config (typo in `clientId`/`tokenEndpoint`); a 401 with the same body confirms it's an IdP-side misconfig. + +## `jwt_bearer` 401 — IdP rejects the assertion + +**What's happening.** The IdP received the JWT (subject token) but rejected it. Causes from most to least common: + +1. **`aud` mismatch.** The IdP expects a specific audience in the assertion; what you sent doesn't match. +2. **IdP-trust client misconfigured.** Keycloak's GitHub Actions identity provider has `Issuer URL` ≠ the literal `iss` in the JWT, or a *Token Exchange permission* policy that doesn't allow the requesting client. +3. **JWT expired.** GitHub Actions OIDC tokens are valid for ~10 minutes; if the plugin caches an expired one (shouldn't happen — `resolveSubjectToken` always re-fetches) or your clock skew is severe, the assertion fails signature validation. + +**Look for.** + +- `oauth_jwt_bearer_failed` with `status: 401` and `bodyPreview` containing `invalid_grant` or `invalid_token` and an error description like `assertion is expired` / `audience does not match`. + +**Diagnose.** Look up the configured audience and the JWT's `aud`: + +```sh +# In a GHA job — manually fetch the OIDC token and decode it. +oidc_token=$(curl -sS \ + -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://your-expected-audience" \ + | jq -r .value) +echo "$oidc_token" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, iss, sub, repository, workflow}' +``` + +For `kubernetes_sa`: + +```sh +# From inside the pod. +jwt=$(cat /var/run/secrets/tokens/oauth2/token) +echo "$jwt" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, iss, sub}' +``` + +`aud` must match the IdP's expected audience exactly. See [github-actions.md → audience pinning](./github-actions.md#audience-pinning) and [kubernetes.md → IdP setup](./kubernetes.md#idp-setup-keycloak--dex). + +## Headless context hangs on first run + +**What's happening.** Stdin or stdout reports as a TTY when it isn't (some terminal multiplexers, broken PTY libs, certain CI runners with `tty: true` set). Warmup believes it's interactive, tries to open a browser or start device-code polling, and waits forever for a callback that never arrives. + +**Look for.** `oauth_login_started` event but no `oauth_login_success` for several minutes. Process hangs at startup. + +**Fix.** Currently no environment-variable override. If you're embedding the runtime, pass `interactive: false` to `start()`. If you're running under OpenCode-hosted mode and can't avoid the misdetection, set `CI=true` in the environment — most TTY-detection libs treat that as a signal to fake non-TTY behavior, though the plugin itself reads `process.stdin.isTTY` directly so this is only effective insofar as your shell or process supervisor honors it. + +The principled fix on the embedder side: + +```ts +import { OAuth2ModelSyncPlugin } from "@vymalo/opencode-oauth2"; +const runtime = new OAuth2ModelSyncPlugin(cfg, { /* ... */ }); +await runtime.initialize(); +await runtime.start({ warmup: true, interactive: false }); +``` + +If you're stuck on the OpenCode-hosted path, the workaround is "delete the cache and run a one-shot `opencode run` from a real terminal to populate it, then resume the headless context which will refresh silently". + +## Tokens not rotating in long-running pod + +**What's happening.** The pod's projected SA token rotates fine (kubelet refreshes the file), but the access token from your IdP isn't rotating — same access token keeps being used past its expiry, eventually 401-ing against the upstream. + +**Look for.** + +- `oauth_jwt_bearer_success` with `hasExpiry: false` — the IdP isn't returning `expires_in`, so the plugin treats undefined-expiry as INVALID for machine flows (it should re-acquire every call). If you see this *and* persistent 401s, the problem is downstream. +- `oauth_jwt_bearer_failed` shortly after a successful auth: confirms re-auth is being attempted and failing. + +**Most common root causes.** + +1. **Missing `audience` on the projected token volume.** Without it, the SA token's `aud` defaults to the apiserver, not your IdP. The IdP rejects with `audience mismatch`. Fix: add `audience: ` to the `serviceAccountToken` source — see [kubernetes.md](./kubernetes.md#cronjob--scheduled-ai-task). +2. **Audience mismatch between `serviceAccountToken.audience` and `subjectTokenSource.audience`.** For `kubernetes_sa`, the plugin doesn't pass an `audience` to the IdP — the IdP reads it from the JWT itself. Make sure the SA-token's audience equals the IdP's expected audience. +3. **For GHA: missing `audience` in `subjectTokenSource`.** The `github_actions` source *does* set `audience` on the OIDC request URL — but if you've configured a different `audience` than your IdP expects, the resulting JWT will have the wrong `aud`. Both sides need to agree. + +Diagnose by tailing logs and counting: + +```sh +kubectl logs deploy/opencode-bot --tail=200 \ + | jq -Rr 'fromjson? // empty' \ + | jq -s 'group_by(.event) | map({event: .[0].event, count: length})' +``` + +If `oauth_jwt_bearer_started` count grows steadily but `oauth_jwt_bearer_success` plateaus, you have a failing re-auth. + +## Provenance badge missing on npm + +**What's happening.** The published package on npmjs.com is missing the "Built and signed on GitHub Actions" badge. Either the publish workflow didn't request OIDC, or npm rejected the provenance attestation. + +**Look for.** + +- In the workflow run logs (`Publish to npm` step): any line mentioning `provenance` and `error`. +- On the npm package page: the badge near the version. + +**Causes.** + +1. **`id-token: write` not granted to the job.** npm provenance generation requires OIDC. Confirm the `permissions:` block on the publish job includes `id-token: write`. The shipped [publish.yml](../.github/workflows/publish.yml) sets it at the workflow level — if you derived your own from an earlier version, double-check. +2. **`repository` field in `package.json` doesn't match the publishing workflow's repo.** npm validates the provenance attestation's repo URL against `package.json` `"repository"`. Mismatch → rejected. Fix: + + ```json + { + "repository": { + "type": "git", + "url": "git+https://github.com/vymalo/opencode-oauth2.git" + } + } + ``` +3. **`NPM_CONFIG_PROVENANCE` not set.** The shipped workflow sets it as a belt-and-braces — if you removed the env var, pnpm's `publish --provenance` flag should still work, but some pnpm/npm version combinations silently drop the flag. + +Once fixed, the badge appears on the next published version (you can't backfill provenance for an already-published tarball). + +## `[ERR_PNPM_IGNORED_BUILDS]` on CI install + +**What's happening.** pnpm 11 enforces an opt-in `allowBuilds` list for native build scripts. If your project pulls in a transitive dep with a postinstall build (esbuild, msgpackr-extract, etc.) and `pnpm-workspace.yaml` doesn't allow it, pnpm fails the install on CI with this error. + +**Look for.** Install step output: + +``` +ERR_PNPM_IGNORED_BUILDS: Ignored build scripts: esbuild. +Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts. +``` + +**Fix.** Add the package name to `allowBuilds` in `pnpm-workspace.yaml`: + +```yaml +allowBuilds: + esbuild: true + msgpackr-extract: true +``` + +The shipped `pnpm-workspace.yaml` allows these two by default (they're dependencies of vitest/vite + msgpack speedups). If you add a new dep that triggers the same error, audit the package's postinstall script before adding it — `allowBuilds` is the supply-chain-security gate. + +**Why pnpm 11's behavior matters.** The legacy `onlyBuiltDependencies` array is silently ignored in `--frozen-lockfile` mode. A local `pnpm install` succeeds because pnpm interactively prompts; CI fails because there's no TTY. Always use `allowBuilds` (the explicit object shape) to keep dev and CI consistent. + +## Generic: see exactly what the plugin is doing + +Pretty-print and filter the JSON logs: + +```sh +opencode run --model "miaou/glm-5" "say hi" 2>&1 \ + | jq -Rr 'fromjson? // .' \ + | jq 'select(.event | test("oauth|sync|model"))' +``` + +If you don't see anything OAuth-related at all, the plugin isn't loading. Confirm: + +```sh +opencode --version +npm ls -g | grep opencode-oauth2 +cat $OPENCODE_CONFIG_DIR/opencode.json | jq '.plugin' +``` + +The `plugin` array must include `"@vymalo/opencode-oauth2"` (or a local path that re-exports it — see [local-development.md](./local-development.md#plugin-reexport-trick)). diff --git a/packages/opencode-oauth2/README.md b/packages/opencode-oauth2/README.md index 8707843..5fc12af 100644 --- a/packages/opencode-oauth2/README.md +++ b/packages/opencode-oauth2/README.md @@ -1,6 +1,6 @@ # @vymalo/opencode-oauth2 -OAuth2/OIDC model sync plugin for OpenCode. +OAuth2/OIDC model sync plugin for OpenCode. This is the canonical configuration reference for the plugin. Long-form usage guides (CI cookbooks, Kubernetes manifests, troubleshooting) live in [`/docs/`](../../docs/) at the repo root. ## What It Does @@ -95,6 +95,17 @@ These apply to both config shapes above. | `authorizationEndpoint` | discovered | Override for the authorization endpoint. | | `tokenEndpoint` | discovered | Override for the token endpoint. | | `redirectPort` | random | Fixed port for the local callback server (authorization-code only). | +| `nameOverrides` | `{}` | Map of model id → friendly display name. Applied during catalog normalization. | +| `syncIntervalMinutes` | `60` | Per-server scheduler interval. Failures preserve the last-known-good model list. | +| `jwksUri` | _(unset)_ | Reserved; not currently used at runtime. | + +Plus the top-level `pluginConfig.oauth2ModelSync` block accepts: + +| Field | Default | Notes | +| --- | --- | --- | +| `cacheNamespace` | `"opencode-oauth2-model-sync"` (OpenCode-hosted) / `"oauth2-model-sync"` (standalone) | Subdirectory under the OS cache root. See [architecture.md](../../docs/architecture.md#cache-layout) for the path table per OS. | +| `httpTimeoutMs` | `15000` | Timeout for token-endpoint / `/models` round trips. | +| `tokenExpirySkewMs` | `30000` | Treat a token as expired this many ms before its real `expiresAt`. | ## Federated identity (no long-lived secrets in CI) @@ -109,51 +120,19 @@ The plugin reads the platform JWT at token-acquisition time (never caches it) an | `github_actions` | `ACTIONS_ID_TOKEN_REQUEST_URL` + `ACTIONS_ID_TOKEN_REQUEST_TOKEN` env vars | `audience` | | `kubernetes_sa` | Projected service-account token file (default `/var/run/secrets/tokens/oauth2/token`) | _(optional `tokenPath`)_ | | `file` | Arbitrary file path | `path` | -| `env` | Environment variable | `var` | - -### GitHub Actions - -**1. Register the workflow's OIDC identity with your OAuth server.** For Keycloak, add an Identity Provider of type "OpenID Connect" with: -- Issuer: `https://token.actions.githubusercontent.com` -- Audience: an identifier you choose (used below as `audience`) -- Map claims so a specific `repository:` / `workflow:` subject can mint a token for your client - -Auth0, Okta, and similar all support the same flow — see your IdP's docs for "trust GitHub Actions OIDC tokens". - -**2. Set up the workflow:** +| `env` | Environment variable (dev/test only) | `var` | -```yaml -name: AI-assisted job +End-to-end recipes: -on: - workflow_dispatch: +- **GitHub Actions** — see [`docs/github-actions.md`](../../docs/github-actions.md) for the Keycloak / Auth0 / Okta setup walkthroughs, the reusable workflow at [`.github/workflows/opencode-run.yml`](../../.github/workflows/opencode-run.yml), matrix builds, audience pinning, and fork-PR limitations. +- **Kubernetes** — see [`docs/kubernetes.md`](../../docs/kubernetes.md) for the `CronJob` (headline), `Job`, and `Deployment` manifests, multi-provider pods, IdP setup with Keycloak/Dex, and RBAC notes (spoiler: you need almost none). -permissions: - id-token: write # required — lets the runner mint an OIDC token - contents: read - -jobs: - run: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: | - # opencode + plugin must be installed first; here as an example - npm install -g opencode @vymalo/opencode-oauth2 - - run: opencode run --model "example-ai/glm-5" "summarize the diff" - env: - OPENCODE_CONFIG_DIR: ${{ github.workspace }}/.opencode-ci -``` - -**3. Provide the opencode config** (e.g. `.opencode-ci/opencode.json`): +### Quick GHA reference ```jsonc { - "$schema": "https://opencode.ai/config.json", - "plugin": ["@vymalo/opencode-oauth2"], "provider": { "example-ai": { - "name": "Example AI", "options": { "baseURL": "https://api.example.com/v1", "oauth2": { @@ -172,87 +151,32 @@ jobs: } ``` -**No `clientSecret` anywhere.** The runner presents its short-lived OIDC token; your OAuth server trusts it because you configured GHA as an IdP. The plugin re-fetches the OIDC token on each access-token expiry (cheap and automatic). - -### Kubernetes ServiceAccount Job - -**1. Register the cluster's OIDC issuer with your OAuth server** (same as the GHA setup — add an Identity Provider with the cluster's discovery URL). - -**2. Mount a projected service-account token** with your OAuth server as the audience: - -```yaml -apiVersion: batch/v1 -kind: Job -metadata: - name: opencode-ai-task -spec: - template: - spec: - serviceAccountName: opencode-runner - restartPolicy: Never - containers: - - name: runner - image: ghcr.io/your-org/opencode-with-plugin:latest - env: - - name: OPENCODE_CONFIG_DIR - value: /etc/opencode - command: ["opencode", "run", "--model", "example-ai/glm-5", "summarize the day"] - volumeMounts: - - name: oauth2-token - mountPath: /var/run/secrets/tokens/oauth2 - readOnly: true - - name: opencode-config - mountPath: /etc/opencode - readOnly: true - volumes: - - name: oauth2-token - projected: - sources: - - serviceAccountToken: - path: token - # MUST match the IdP's expected audience exactly. - audience: https://auth.example.com/realms/example - expirationSeconds: 3600 - - name: opencode-config - configMap: - name: opencode-config -``` +Workflow needs `permissions: { id-token: write }`. No `clientSecret` anywhere. -**3. The ConfigMap holds the opencode config:** - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: opencode-config -data: - opencode.json: | - { - "$schema": "https://opencode.ai/config.json", - "plugin": ["@vymalo/opencode-oauth2"], - "provider": { - "example-ai": { - "name": "Example AI", - "options": { - "baseURL": "https://api.example.com/v1", - "oauth2": { - "issuer": "https://auth.example.com/realms/example", - "clientId": "k8s-runner", - "scopes": ["openid"], - "authFlow": "jwt_bearer", - "subjectTokenSource": { - "type": "kubernetes_sa" - } - } +### Quick Kubernetes reference + +```jsonc +{ + "provider": { + "example-ai": { + "options": { + "baseURL": "https://api.example.com/v1", + "oauth2": { + "issuer": "https://auth.example.com/realms/example", + "clientId": "k8s-runner", + "scopes": ["openid"], + "authFlow": "jwt_bearer", + "subjectTokenSource": { + "type": "kubernetes_sa" } } } } + } +} ``` -The projected token at `/var/run/secrets/tokens/oauth2/token` rotates automatically (kubelet refreshes it). The plugin re-reads on every access-token expiry, so rotation is transparent. - -**Note:** the default `subjectTokenSource.tokenPath` is `/var/run/secrets/tokens/oauth2/token`. Override it via `"tokenPath": "..."` if your projected mount uses a different path. +The pod must mount a projected `serviceAccountToken` at `/var/run/secrets/tokens/oauth2/token` with the IdP's expected `audience`. The projected token rotates automatically (kubelet refreshes it); the plugin re-reads on every access-token expiry, so rotation is transparent. Full manifests in [`docs/kubernetes.md`](../../docs/kubernetes.md). ### Choosing between `jwt_bearer` and `token_exchange` @@ -261,10 +185,10 @@ The projected token at `/var/run/secrets/tokens/oauth2/token` rotates automatica ## OAuth Token Requirements -Refresh token support is mandatory. +Refresh tokens are mandatory for the flows that issue them (`authorization_code`, `device_code`). -- Initial token exchange must return `refresh_token`. -- Cached tokens missing `refreshToken` are invalidated. +- Initial token exchange for those flows must return `refresh_token`; missing → rejected. +- Cached tokens missing `refreshToken` are invalidated on load (unless the flow doesn't issue one — `client_credentials`, `jwt_bearer`, `token_exchange`). - Refresh flow preserves the previous refresh token when providers omit it in refresh responses. ## Hooks Used @@ -272,9 +196,11 @@ Refresh token support is mandatory. - `config`: register/patch provider config and merge cached discovered models - `chat.headers`: ensure valid token and set `Authorization` header +See [architecture.md](../../docs/architecture.md#the-two-hooks) for the full hook semantics. + ## Development -```bash +```sh pnpm --filter @vymalo/opencode-oauth2 typecheck pnpm --filter @vymalo/opencode-oauth2 test pnpm --filter @vymalo/opencode-oauth2 build