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/ at the repo root.
- Registers OpenAI-compatible providers into OpenCode config
- Supports five OAuth2 grants:
authorization_code— interactive PKCE login (default)device_code— RFC 8628, for browserless user authclient_credentials— machine-to-machine viaclientSecretjwt_bearer— RFC 7523, federated identity (e.g. GitHub Actions OIDC, K8s SA tokens) — no long-lived secret in CItoken_exchange— RFC 8693, federated identity with explicit audience targeting
- Stores and refreshes provider access tokens
- Fetches and normalizes provider model catalogs
- Injects
Authorizationheaders at chat request time
Add plugin package to OpenCode:
{
"$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",
"clientId": "opencode-client",
"scopes": ["openid", "profile", "offline_access"],
"syncIntervalMinutes": 60,
"nameOverrides": {
"glm-5": "GLM 5"
}
}
}
}
}
}{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@vymalo/opencode-oauth2"],
"pluginConfig": {
"oauth2ModelSync": {
"servers": [
{
"id": "example-ai",
"name": "Example AI",
"issuer": "https://auth.example.com",
"baseURL": "https://api.example.com/v1",
"clientId": "opencode-client",
"scopes": ["openid", "profile", "offline_access"],
"syncIntervalMinutes": 60,
"nameOverrides": {
"glm-5": "GLM 5"
}
}
]
}
}
}These apply to both config shapes above.
| Field | Default | Notes |
|---|---|---|
clientSecret |
(unset) | For confidential clients. Sent as client_secret on every token-endpoint POST. Never logged. Required for authFlow: "client_credentials". Optional but commonly required for jwt_bearer / token_exchange with confidential clients. PKCE is still required for authorization_code. |
authFlow |
"authorization_code" |
One of "authorization_code", "device_code", "client_credentials", "jwt_bearer", "token_exchange". |
pkce |
true |
Send PKCE (code_challenge + code_challenge_method=S256, replayed as code_verifier) on the authorization_code and device_code flows. Leave it on — compliant servers ignore it, and a Keycloak client with PKCE enforced requires it (otherwise the device/authorize request 400s with Missing parameter: code_challenge_method). Set false only for a non-compliant IdP that rejects the extra parameters. No effect on the machine flows. |
subjectTokenSource |
(required for jwt_bearer / token_exchange) |
Where to read the platform JWT to present as the subject token. See Federated identity below. |
tokenExchangeAudience |
(unset) | Optional audience parameter for the token_exchange grant. |
deviceAuthorizationEndpoint |
discovered | Override for the device authorization endpoint. Otherwise discovered from device_authorization_endpoint in the OIDC metadata. Only used when authFlow === "device_code". |
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. |
responseApi |
false |
Route inference through the OpenAI Responses API (/v1/responses) instead of Chat Completions (/v1/chat/completions). When true, the provider is registered with npm: "@ai-sdk/openai" (the native OpenAI provider, whose default model targets Responses since AI SDK v5) rather than @ai-sdk/openai-compatible. The plugin stamps an inert placeholder apiKey so the native provider can be constructed (the real OAuth bearer is still injected per request, so the placeholder is never sent) and wraps the provider's fetch to repair Responses SSE streams that omit output_index/content_index (seen with Envoy AI Gateway; otherwise OpenCode fails with text part <id> not found). Only enable this when your gateway implements the OpenAI Responses contract for the model you use. |
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 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. |
The plugin's log level follows the host's top-level logLevel ("DEBUG" | "INFO" | "WARN" | "ERROR") — set it once in your OpenCode config and the plugin honors the same threshold for both console output and forwarded app.log records. Defaults to "info" when the host doesn't set one.
For CI runners and Kubernetes workloads, the modern best practice is to skip stored client secrets entirely and use the platform's own short-lived OIDC token to authenticate. @vymalo/opencode-oauth2 supports this via the jwt_bearer (RFC 7523) and token_exchange (RFC 8693) grants.
The plugin reads the platform JWT at token-acquisition time (never caches it) and presents it to your OAuth server as proof of identity. The OAuth server validates the JWT signature against the platform's JWKS, applies your IdP's policy, and returns an access token.
subjectTokenSource tells the plugin where to read the JWT:
type |
Reads from | Required fields |
|---|---|---|
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 (dev/test only) | var |
End-to-end recipes:
- GitHub Actions — see
docs/github-actions.mdfor the Keycloak / Auth0 / Okta setup walkthroughs, the reusable workflow at.github/workflows/opencode-run.yml, matrix builds, audience pinning, and fork-PR limitations. - Kubernetes — see
docs/kubernetes.mdfor theCronJob(headline),Job, andDeploymentmanifests, multi-provider pods, IdP setup with Keycloak/Dex, and RBAC notes (spoiler: you need almost none).
{
"provider": {
"example-ai": {
"options": {
"baseURL": "https://api.example.com/v1",
"oauth2": {
"issuer": "https://auth.example.com/realms/example",
"clientId": "ci-runner",
"scopes": ["openid"],
"authFlow": "jwt_bearer",
"subjectTokenSource": {
"type": "github_actions",
"audience": "https://auth.example.com/realms/example"
}
}
}
}
}
}Workflow needs permissions: { id-token: write }. No clientSecret anywhere.
{
"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 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.
jwt_beareris the standard federated grant. Single POST:grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer,assertion=<JWT>. Keycloak, Auth0, Okta all support it. Start here.token_exchange(RFC 8693) is more general — supportssubject_token_type,actor_token,requested_token_type, and an explicitaudienceclaim. Use only when your IdP requires it or when you need the audience targeting (settokenExchangeAudience).
Refresh tokens are mandatory for the flows that issue them (authorization_code, device_code).
- Initial token exchange for those flows must return
refresh_token; missing → rejected. - Cached tokens missing
refreshTokenare 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.
The cached state lives in a single <cacheNamespace>/<serverId>.json that every OpenCode process on the machine shares, and a refresh token is single-use. So the plugin re-reads that file before every refresh decision (warmup, scheduler tick, and the per-request chat.headers path alike) instead of trusting the copy it loaded at startup: the persisted state wins unless the in-memory one is strictly newer, and it is adopted as a whole. Without this, a second process replays a refresh token the first has already rotated, and the IdP's reuse detection revokes the chain out from under both.
Coordinating the refresh itself — single-flight, the cross-process lock, and the retry when the IdP rejects a token another process just rotated — belongs to TokenRuntime in @vymalo/opencode-auth-core, not to this plugin. Details: architecture.md → Shared across processes.
config: register/patch provider config and merge cached discovered modelschat.headers: ensure valid token and setAuthorizationheader
See architecture.md for the full hook semantics.
Since ADR-0017, @vymalo/opencode-lightbridge's
optional register block does everything this plugin does — provider registration + model
discovery via the same shared @vymalo/opencode-provider-sync engine — plus an optional gateway
bearer and OTEL export credential off the same login. Run this plugin standalone when you only need
provider registration, or when it needs to manage several unrelated IdPs via
pluginConfig.oauth2ModelSync.servers; run lightbridge instead when you want the shared credential
too.
If both are configured, configure them for DIFFERENT provider ids — lightbridge's register
module deliberately skips (never registers, never runs its own scheduler) any provider id it detects
this plugin already manages (via pluginConfig.oauth2ModelSync.servers[].id or a provider's own
options.oauth2/options.oauth2ModelSync block), logged at debug. See
docs/lightbridge.md → register.
Shared login, not just a shared engine. If a developer configures the SAME id/issuer/
clientId in an oauth2ModelSync server entry AND in lightbridge's auth/register block (for
DIFFERENT purposes — e.g. this plugin owns one provider, lightbridge's gateway/otel ride the
same IdP for a different provider), logging in through either one makes the human root token
available to the other: lightbridge's root token lives in the exact same
<cacheNamespace>/<serverId>.json file this plugin writes. See
docs/lightbridge.md → One login, shared cache with oauth2.
pnpm --filter @vymalo/opencode-oauth2 typecheck
pnpm --filter @vymalo/opencode-oauth2 test
pnpm --filter @vymalo/opencode-oauth2 buildOpencodeOauth2Plugin(default OpenCode plugin export)createOpencodeOauth2Plugin()(factory for testing / custom wiring)OAuth2ModelSyncPlugin(runtime orchestrator — useful for embedding outside OpenCode)
{ "$schema": "https://opencode.ai/config.json", "plugin": ["@vymalo/opencode-oauth2"] }