Status: implemented (issue #67) — see ADR-0011 (Accepted) and packages/opencode-repo-auth/.
Goal, in one line: give local-dev OpenCode requests the repo-as-principal attribution CI already has — a developer authenticates once as themselves, and every gateway request from an enrolled repo carries a project-scoped bearer, so Authorino bills and access-controls the request to the repo's project instead of the developer's person.
This is the client half of epic #64. It builds on @vymalo/opencode-auth-core (extracted in #66) and reuses its security-critical OAuth machinery — no forked token code.
On an enrolled repo (one whose opencode.json declares a projectId on a provider), the plugin:
- Resolves the repo's git identity — worktree-aware, credentials stripped — for logging and repo correlation (v1 derives nothing from it; see Out of scope / deferred).
- Ensures the developer's human token via
auth-core'sTokenRuntime(device_code/authorization_code,offline_access), cached per human. - Exchanges it once per project — a single RFC 8693 token exchange presenting
project_idas a form param — and caches the SPI-sealed project token per project (human-<hash(human:projectId)>.json, OS cache dir,0o600, atomic rename, NTFS-safe key). - Injects
Authorization: Bearer <project-token>on the gateway provider only (guarded by managed provider id), both at config time (the config-object handshake for@vymalo/opencode-models-info) and per request (chat.headers).
It no-ops (never crashes, never touches other providers) when: the repo has no origin remote, the provider has no options.meta.repoAuth block, the block has no projectId, or the provider is already managed by @vymalo/opencode-oauth2.
The ticket body describes the original design: resolve git origin → Source id → exchange the human token to aud = <base>/sources/src-XXX. That design is dead. Two comments on #67 reframed it, and the IdP side has since shipped the final shape:
| Revision | Flow | Status |
|---|---|---|
| Ticket body | git remote → Source id → exchange to aud=/sources/src-XXX |
Superseded |
| Comment 1 (2026-07-07) | mint request_id via authz POST /api/v1/idp/requests → exchange with request_id |
Superseded (SPI simplified) |
| Comment 2 (2026-07-07) — authoritative | single Keycloak token exchange presenting project_id form param; SPI seals {account_id, project_id} |
Live — confirmed by lightbridge-keycloak-spi architecture |
Consequences for the plugin:
- No
aud=Source, no mint step, no client-side resolve call. Theproject_idis declared inopencode.json(options.meta.repoAuth.projectId) — a repo may belong to many projects, so it is never derived from the remote. The SPI's backend resolver (lightbridge-authzPOST /idp/v1/resolve-context) does the membership check server-side. - Cache is keyed by
project_id(hashed), not by Source. - The ticket's "resolve-by-remote" stays deferred (see Out of scope / deferred); the git module in v1 exists for repo identity/logging and worktree correctness.
The plugin must produce exactly this request at the Keycloak token endpoint:
POST <tokenEndpoint>
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
client_id=opencode-cli
subject_token=<human JWT>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
project_id=<declared project id>
- No
audienceparam. The SPI rejects a self-audience ("Requested audience not available") — this is whyauth-core's existingexchangeToAudiencecannot be used as-is (see The auth-core gap). - The SPI (
LightbridgeTokenExchangeProvider, order 100) claims the request whenproject_idis present, calls the authz resolver with{subject, project_id}, and on success returns a JWT sealed with{account_id, project_id}claims (aud= the requesting client). On non-membership / unknown project / resolver error it fails closed — no token. - Without
project_id, Keycloak's built-inStandardTokenExchangeProviderhandles the exchange unchanged — the plugin never sends a bare exchange.
Mirrors @vymalo/opencode-oauth2's config hook (opencode.ts) and @vymalo/opencode-models-info's options.meta opt-in (config.ts):
- Resolve repo identity (worktree-aware, read off disk — see git.ts) → normalized
originremote. Emitsrepo_auth_remote_resolved(redacted) orrepo_auth_remote_missing. Log-only in v1. - Walk
config.provider; a provider opts in viaoptions.meta.repoAuth. Parse + validate the block (see Config shape). Providers without it are skipped (repo_auth_provider_skipped). - oauth2-conflict guard: if the same provider also carries
options.oauth2/options.oauth2ModelSync, skip it withrepo_auth_skipped_oauth2_provider(warn). The two plugins must never manage the same provider — documented + guarded. - Build the runtime over
auth-core: oneTokenRuntimekeyed by the human identity,cacheDir = <cacheRoot>/opencode-repo-auth(own on-disk location, per the ticket's namespace requirement — same pattern oauth2 uses to keep<root>/opencode-oauth2). - Refresh-only warmup (mirrors oauth2's
propagateCachedBearer,opencode.ts:482):ensure(human, { interactive: false })→exchangeTo(projectId, humanToken, { project_id }). If a fresh interactive login would be required, skip the stamp (repo_auth_bearer_propagation_skipped_no_token) — never block startup on a browser/device prompt. - Stamp
provider.options.headers.Authorizationwith the project bearer — unless the user already set anAuthorizationheader (case-insensitive), which always wins. This is the config-object handshake that lets@vymalo/opencode-models-infofetch an OAuth2-protectedmeta.modelsInfoUrl. A stale value here is harmless:chat.headersoverwrites per request. - Rebuild on config-signature change (OpenCode re-runs
configon config edits) — mirror oauth2'sruntimeSignaturepattern (opencode.ts:458).
- Resolve provider id:
input.model?.providerID ?? input.provider?.info?.id(same fallback as oauth2,opencode.ts:705). - Managed-provider guard: not a
repoAuthprovider → return untouched. ensure(human)(interactive allowed here — first request may trigger the device-code/browser flow).- Read the cached project token for
projectId(human-<hash(human:projectId)>.json):- valid → inject;
- expired / absent → re-exchange from the offline root ("model b") → cache → inject.
- Fail closed: an exchange failure (non-member, resolver error, network) produces no header — the gateway 401s, which is the correct behavior (matches the SPI's fail-closed semantics). Emits
repo_auth_exchange_failedat error. Host-level caveat: if the provider also has a staticapiKeyconfigured, OpenCode itself sends that asAuthorization: Bearer <apiKey>when our hook injects nothing — a bogus credential the gateway still refuses, so the posture stays fail-closed; on success the project bearer overwrites it. Verified in the e2e recipe (local-development.md).
The project token is never refreshed — exchanged tokens carry no refresh token and re-exchange is the canonical renewal (epic risk mitigation: "Exchange aud not preserved across refresh → default to re-exchange from the offline root"). The human root token is refreshed via its offline_access refresh token.
| Condition | Behavior |
|---|---|
No origin remote / not a git repo |
repo_auth_remote_missing; plugin still works if projectId is declared (remote is log-only) |
Provider without options.meta.repoAuth |
skipped, untouched |
options.meta.repoAuth without projectId |
repo_auth_skipped_no_project_id (warn); no crash |
| Provider also managed by oauth2 | repo_auth_skipped_oauth2_provider (warn); skipped |
| Human token needs fresh interactive login at config time | stamp skipped (..._skipped_no_token); first chat.headers does it |
| Exchange fails (non-member / resolver error) | no header; repo_auth_exchange_failed; gateway 401s (fail closed) |
The flow is two-phase — boot (config hook) and per request (chat.headers) — and involves two tokens. This section walks one concrete example end to end.
| Thing | Value | Notes |
|---|---|---|
| Developer | Alice | logs in once as herself |
| Repo | acme/webapp (checked out at /home/alice/work/acme-webapp, a linked worktree) |
|
| Project | proj-123 |
declared in opencode.json, never derived |
| IdP | https://idp.acme.com/realms/camer-digital (Keycloak) |
|
| Gateway | https://gateway.acme.com/v1 |
the only provider repo-auth manages |
| Human token | eyJ… (JWT) + refresh token |
"who Alice is" — cached at ~/.cache/opencode-repo-auth/human.json |
| Project token | eyJ… (JWT with account_id + project_id claims) |
"Alice working on proj-123" — cached at ~/.cache/opencode-repo-auth/human:<hash>.json |
opencode.json (the enrolled-repo config):
-
opencode starts in the repo and loads plugins. repo-auth's
confighook fires. -
Git identity (log-only): the plugin reads
.git/config(worktree-aware —.gitis a file here pointing at the common dir) and findsorigin = git@github.com:acme/webapp.git, normalizes it togithub.com/acme/webapp(userinfo stripped, scp form normalized), logsrepo_auth_remote_resolved. Nothing is derived from it in v1. -
Provider walk: the plugin finds
gatewaycarriesoptions.meta.repoAuth→ parses it →projectId = "proj-123"+ the auth config. This is the only provider it will ever touch. -
Runtime: one
TokenRuntime(auth-core) keyed by the human identity, cache dir~/.cache/opencode-repo-auth/. -
Warmup — human token, refresh-only (
ensure(human, { interactive: false })):- First run: no cached token → the runtime refuses to start a device-code prompt at boot → the plugin skips the stamp and logs
repo_auth_bearer_propagation_skipped_no_token. Boot continues; the login happens on the first request instead. - Cached + valid: reused.
- Cached + expired with refresh token: one
POST grant_type=refresh_tokento the token endpoint → new human token → saved.
- First run: no cached token → the runtime refuses to start a device-code prompt at boot → the plugin skips the stamp and logs
-
Exchange — project token (only if step 5 produced a human token). One POST to the token endpoint:
POST https://idp.acme.com/realms/camer-digital/protocol/openid-connect/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange client_id=opencode-cli subject_token=eyJ… ← Alice's human token subject_token_type=urn:ietf:params:oauth:token-type:jwt project_id=proj-123Keycloak's SPI checks Alice's membership of
proj-123via the authz resolver; on success it returns a JWT sealed with{account_id: "acc-42", project_id: "proj-123"}. The plugin saves it underhuman:<hash(proj-123)>.json. -
Config-object handshake: the plugin stamps
provider.gateway.options.headers.Authorization = "Bearer <project token>"so@vymalo/opencode-models-info(which runs after) can fetch an OAuth2-protectedmeta.modelsInfoUrl. A user-setAuthorizationstill wins. -
Boot is done. Nothing was sent to the gateway.
- Alice types a message; opencode resolves the model to provider
gatewayand calls repo-auth'schat.headershook. - Guard:
gatewayis a managed provider (hasoptions.meta.repoAuth) → proceed. Any other provider → return untouched. - Human token (
ensure(human), interactive allowed): if Alice never logged in, this is where the device-code flow runs — the plugin prints a[opencode-repo-auth] device-code login for …block to the terminal with the verification URL and code (the one deliberate exception to the plugin's otherwise-silent terminal, ADR-0014 — every other diagnostic goes only to OpenCode's own log), Alice completes it in her browser, the plugin polls and stores the human token + refresh token. If she has one, it's reused/refreshed silently. - Project token: read
<cacheDir>/human-<hash(human:proj-123)>.json:- still valid → use it;
- expired or absent → re-run the exchange POST from step 6 (no user interaction — the human refresh token makes it automatic) → save → use it.
- Inject:
output.headers.Authorization = "Bearer <project token>". - opencode sends
POST https://gateway.acme.com/v1/chat/completionswith that header. Authorino validates the JWT, readsaccount_id/project_id, and bills the request to the project.
- The human token only says "Alice". It is never sent to the gateway.
- The project token says "Alice working on proj-123" — that is what the gateway/Authorino accepts and bills. It is short-lived (Keycloak exchange tokens are typically minutes) and has no refresh token.
- "Model b" is the renewal rule: never try to refresh the project token (a refresh would lose the project claims — the epic's "exchange
audnot preserved across refresh" risk). Instead, when it expires, re-do the exchange from the offline root — the human token, which does have a refresh token and can renew itself without any user interaction. So after the one-time login, every subsequent renewal is automatic: human refresh (1 POST) → re-exchange (1 POST) → inject.
login (once, interactive) → human token + refresh token
│
▼
exchange (per project, on demand) → project token {account_id, project_id}
│
▼
chat request → inject project token on the gateway provider only
│
└─ project token expired? → re-exchange from the human token (automatic)
| Human token | Project token | |
|---|---|---|
| What it says | "Alice is logged in" | "Alice is logged in and is working on project proj-123" |
| Claims it carries | sub (Alice's user id), iss, aud = opencode-cli, scopes |
sub + account_id + project_id (sealed by the SPI) |
| Who issues it | Keycloak directly, when Alice completes the device-code login | Keycloak's SPI, during the token exchange (human token + project_id → membership check → sealed JWT) |
| How it's obtained | One-time interactive login (device code in the terminal) | On demand, automatically — one POST, no user interaction |
| Renewal | Has a refresh token (offline_access scope) → renews itself silently |
No refresh token → when it expires, re-do the exchange ("model b") |
| Lifetime | Longer (Keycloak access token, refreshable) | Short (typically minutes) |
| Who consumes it | Only the plugin itself — as the subject_token in the exchange |
The gateway — as Authorization: Bearer on every chat request |
| Where it's cached | ~/.cache/opencode-repo-auth/human.json |
~/.cache/opencode-repo-auth/human-<hash>("human:<proj-123>").json (identity-prefixed -, hashed over the identity:key pair — no : in filenames, so the layout is NTFS-safe; separate human identities can never collide on the same project key) |
| If it leaks | Attacker can impersonate Alice — exchange it for project tokens of any project she belongs to. This is the master credential. | Attacker can make gateway requests billed to one project, for a few minutes, until it expires. Bounded blast radius. |
If a project token was revoked but its cached copy hasn't expired yet, the gateway will keep returning
401until the TTL elapses — the exchange only re-runs on expiry, and the plugin cannot observe 401s (it has no post-response hook). The project token is bound to a short TTL by design, so this self-heals within minutes. To force it now, delete the cached file (<cacheDir>/human-<hash>.json) — the next request re-exchanges from the human root.repo-auth reset(see Caching and reset) clears the human root and every exchanged token at once.
The human token is the offline root (the thing that can renew itself); the project token is the leaf credential (what actually touches the gateway). The gateway never sees the human token; the human token never carries project context.
- Alice is not a member of
proj-123(or the project doesn't exist): the SPI returns 404, the exchange fails, the plugin injects no header, and the gateway 401s. Fail closed — the request never runs under a wrong identity. - The gateway provider is also managed by
@vymalo/opencode-oauth2: the plugin skips it withrepo_auth_skipped_oauth2_provider— the two plugins never fight over one provider. - No
projectIdin the meta block: the provider is skipped, nothing crashes.
Mirrors the per-package convention (see AGENTS.md), minus cache.ts (auth-core's FileCacheStore via TokenRuntime owns persistence) and plus git.ts:
packages/opencode-repo-auth/
├── src/
│ ├── index.ts # slim entry — single `export { default } from "./opencode.js"`
│ ├── opencode.ts # plugin factory: createOpencodeRepoAuthPlugin(opts) → Plugin; hooks
│ ├── plugin.ts # RepoAuthPlugin runtime — testable core (ensure/exchange/inject)
│ ├── config.ts # options.meta.repoAuth parsing + validation → AuthServerConfigInput + projectId
│ ├── git.ts # worktree-aware repo-root + origin-remote resolution, remote normalization
│ └── lib.ts # public API (embedders): createOpencodeRepoAuthPlugin, RepoAuthPlugin, git utils
└── test/ # vitest, *.test.ts
- No
cache.ts— token persistence is auth-core'sFileCacheStore(atomic rename,0o600, ADR-0005), reached throughTokenRuntime. The plugin keeps only in-memory runtime state (managed provider ids, runtime signature). - Logging comes from
auth-core'slogging.ts(field-name redaction,scrubSecrets,redactUrl) — same primitives oauth2 uses. git.tsreads off disk (.git/config, worktree-aware) rather than shelling out togit— the house precedent set by otel's VCS collector (CHANGELOG 0.14.0: read.git/config/HEAD/refsdirectly; no subprocess, nogitonPATH). For a linked worktree.gitis a file containinggitdir:— resolve the common dir, then read[remote "origin"] urlfrom the common dir's config.normalizeRemotestripsuser:pass@userinfo, normalizes scp-style (git@host:org/repo.git) and https forms, drops query/fragment. Trade-off accepted: raw config missesurl.<base>.insteadOfrewrites thatgit remote get-urlwould apply — irrelevant in v1 because the remote is log-only; revisit if resolve-by-remote lands.
Per-provider opt-in via options.meta.repoAuth, mirroring models-info's options.meta.modelsInfoUrl pattern. The auth-server fields ride in the same block (like oauth2's per-provider options.oauth2 shape) so each managed provider is self-contained:
{
"provider": {
"gateway": {
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://gateway.example.com/v1",
"meta": {
"repoAuth": {
"projectId": "proj-123",
"issuer": "https://idp.example.com/realms/camer-digital",
"clientId": "opencode-cli",
"scopes": ["openid", "offline_access"],
"authFlow": "device_code",
"tokenEndpoint": "https://idp.example.com/realms/camer-digital/protocol/openid-connect/token",
"deviceAuthorizationEndpoint": "https://idp.example.com/realms/camer-digital/protocol/openid-connect/auth/device"
}
}
}
}
}
}projectIdis required; everything else maps ontoAuthServerConfigInput(auth-core) — explicit endpoints optional, OIDC discovery fills the gaps (auth-core'sresolveEndpoints).authFlowdefaults toauthorization_code(auth-core'sDEFAULT_AUTH_FLOW);device_codeis the recommended interactive flow for a CLI.scopesmust includeoffline_accessso the human root gets a refresh token (the "offline root" that model-b re-exchange depends on).- Alternative considered: a shared top-level
pluginConfig.repoAuthfor the IdP config with onlyprojectIdper provider. Rejected for v1 — the ticket's stated shape is per-provider meta, the suite's opt-in convention is per-provider meta, and the "one gateway provider" case is the norm. Revisit only if multi-provider duplication becomes real.
-
One
TokenRuntimeper distinctAuthServerConfig(v1: one — a single Keycloak realm). The runtime is identity-keyed; the identity is the human ("human"constant — the plugin is single-IdP in v1; if multi-IdP ever lands, deriveissuer|clientId). -
Human root token:
ensure()/refresh()— cached at<cacheDir>/human.json. -
Project token:
exchangeTo(projectId, subjectToken, { project_id })— cached at<cacheDir>/human-<hash("human:"+projectId)>.json. -
Key derivation is
auth-core'sexchangeCacheKey—${identity}-${hashCacheKey(${identity}:${key})}: a deterministic truncatedsha256hex of theidentity:keypair (the/-separator was replaced by-and the full pair is hashed, so the filename is NTFS-safe — no:— and two identities exchanging for the same project can never collide). It is a cache-key derivation from an upstream id, not a minted record id — the CUID2 rule does not apply.FileCacheStoreuses keys verbatim as filenames (cache.ts:36). -
Reset / log-out (
repo_auth_reset): removeshuman.jsonand everyhuman-*exchanged token —TokenRuntime.resetnow walks the cache dir vialistKeys(${identity}-)and deletes each (token-runtime.ts). A bad day can be undone withrm -rf <cacheDir>, but the API no longer leaves orphaned project tokens behind a cleared human root. -
cacheDir:join(resolveCacheRoot(), "opencode-repo-auth")— the ticket's namespace, and the patternresolveCacheRootexists for (oauth2 keeps<root>/opencode-oauth2; auth-core's defaultopencode-auth-core/is not used here).
auth-core's only exchange primitive is exchangeToAudience(audience, subjectToken) (token-runtime.ts:87), which POSTs an audience form param — exactly what the SPI rejects. The plugin needs a project_id form param. Options:
| Option | Description | Verdict |
|---|---|---|
| A — extend auth-core (recommended) | Generalize the federated-grant machinery: OAuthClient.exchange({ subjectToken, extraParams }) + TokenRuntime.exchangeTo(key, subjectToken, extraParams), cached under an identity-prefixed hash of the identity:key pair (identity-<hash(identity:key)>, NTFS-safe). exchangeToAudience stays a compatibility wrapper (exchangeTo(audience, subjectToken, { audience })). repo-auth calls exchangeTo(projectId, humanToken, { project_id: projectId }). |
Adopt. Keeps the security-critical code (timeout, scrubbing, redaction, PKCE-free machine grant) in the one place #66 exists to create. Generic, not SPI-shaped — auth-core stays model-free. |
| B — plugin does its own HTTP POST | Duplicate the token-endpoint call in repo-auth. | Reject. Re-creates the exact drift #66 was filed to eliminate (timeout/scrub/redact bugs land in two places). |
C — pass projectId as audience |
No auth-core change. | Reject. The SPI explicitly rejects a self-audience; the exchange would fail closed every time. |
The extension is small and backward-compatible; it can ride in the repo-auth PR (auth-core is still under review in #97) or land as a tiny auth-core PR first — one PR per concern suggests the latter if #97 merges first.
Review note for #97 (resolved in the review round):
TokenRuntime.exchangeToAudiencecaches under a derived key andFileCacheStoreuses keys verbatim as filenames — a URL-shaped key (https://…/sources/src-1) would be path-unsafe on Windows (:) and would create nested dirs on POSIX (/). The genericexchangeTonow derives its key asidentity-<hash(identity:key)>(token-runtime.tsexchangeCacheKey), which eliminates both classes of problem for every caller, repo-auth included.
project token valid? ──yes──▶ inject cached
│ no (expired / absent / no expires_in)
▼
ensure(human) ← refreshes the offline root via its refresh_token if stale
▼
exchangeTo(projectId, humanToken, { project_id })
▼
cache + inject
- The project token's
expiresAtcomes from the exchange response'sexpires_in. Undefined expiry → treat as expired (re-exchange each time) — matching auth-core's machine-flow policy (client.ts:76) and the ticket's "short TTL" intent. - Validity check applies the standard
tokenExpirySkewMs(default 30s) headroom. - No scheduler: the project token is only consumed when a request runs; proactive re-exchange gains nothing and costs a round trip. Lazy re-exchange on
chat.headersis the whole lifecycle.
| Event | When | Level |
|---|---|---|
repo_auth_config_hook_start / _finished |
config hook entry/exit | trace |
repo_auth_remote_resolved / repo_auth_remote_missing |
git identity resolution | debug / trace |
repo_auth_provider_opted_in / repo_auth_provider_skipped |
provider walk | trace |
repo_auth_skipped_no_project_id |
meta block without projectId |
warn |
repo_auth_skipped_malformed |
unparseable meta.repoAuth (other providers still managed) |
warn |
repo_auth_skipped_oauth2_provider |
oauth2-conflict guard | warn |
repo_auth_skipped_invalid_auth |
RepoAuthPlugin construction failed (invalid auth config) |
warn |
repo_auth_human_token_ensured |
ensure(human) result |
debug |
repo_auth_exchange_started / _success / _failed |
token exchange | info / info / error |
repo_auth_exchange_cache_hit / _miss |
project-token cache read | trace |
repo_auth_bearer_propagated_to_provider_headers / ..._skipped_user_set / ..._skipped_no_token |
config-time stamp | debug |
repo_auth_chat_headers_bearer_injected / repo_auth_chat_headers_skipped / repo_auth_chat_headers_no_bearer |
per-request injection / unmanaged provider / exchange failure degraded to no header | trace |
Happy-path lifecycle events sit at debug/trace (silent at default info), failures at warn/error — same convention as oauth2.
sequenceDiagram
autonumber
actor Dev
participant OC as opencode
participant RA as opencode-repo-auth
participant Git as .git/config
participant Cache as ~/.cache/opencode-repo-auth
participant KC as Keycloak token endpoint
participant SPI as LightbridgeTokenExchangeProvider
participant AZ as authz resolver (OPA)
participant GW as Gateway provider
Note over OC,RA: config hook (plugin load)
OC->>RA: config hook
RA->>Git: resolve origin (worktree-aware, off disk)
Git-->>RA: normalized remote (log-only)
RA->>Cache: load human token
alt human token valid
RA-->>RA: reuse
else stale with refresh_token
RA->>KC: POST grant_type=refresh_token
KC-->>RA: new human token
RA->>Cache: save
else none / refresh failed
RA-->>RA: skip stamp (interactive: false)
end
RA->>KC: POST grant_type=token-exchange (subject_token, project_id)
KC->>SPI: supports() → resolve-context
SPI->>AZ: POST /idp/v1/resolve-context {subject, project_id}
AZ-->>SPI: 200 {account_id, project_id}
SPI-->>KC: sealed JWT {account_id, project_id}
KC-->>RA: project token
RA->>Cache: save under human-<hash(human:projectId)>
RA->>OC: stamp provider.options.headers.Authorization
Note over OC,RA: chat.headers (per request)
OC->>RA: chat.headers(model=gateway/...)
RA->>Cache: load project token
alt valid
RA-->>RA: reuse
else expired / absent
RA->>KC: ensure(human) → re-exchange
KC-->>RA: fresh project token
RA->>Cache: save
end
RA->>OC: output.headers.Authorization = Bearer <project-token>
OC->>GW: POST /v1/chat/completions (project bearer)
sequenceDiagram
autonumber
actor Dev
participant OC as OpenCode
participant RA as Repo Auth Plugin
participant Cache as Token Cache
participant KC as Keycloak
participant AZ as Access Check
participant GW as Gateway
Note over OC,RA: OpenCode starts
OC->>RA: Start repo authentication
RA->>RA: Identify the current repository
RA->>Cache: Check saved user login
alt User login is valid
RA-->>RA: Reuse login
else Login needs refreshing
RA->>KC: Refresh login
KC-->>RA: New login
RA->>Cache: Save login
end
RA->>KC: Request access for this project
KC->>AZ: Check user and project
AZ-->>KC: Access approved
KC-->>RA: Project access token
RA->>Cache: Save project token
RA->>OC: Add project authentication
Note over OC,RA: For each chat request
OC->>RA: Request authentication
RA->>Cache: Check project token
alt Token is valid
RA-->>RA: Reuse token
else Token expired
RA->>KC: Get new project token
KC-->>RA: New token
RA->>Cache: Save token
end
RA->>OC: Add project authentication
OC->>GW: Send request
- Managed-provider guard — the project bearer is only ever stamped/injected on providers carrying
options.meta.repoAuth. No repo/project identity leaks to other providers (the ticket's "leaking repo URL to non-gateway providers" risk). - oauth2-conflict guard — never both plugins on one provider; skip + warn, never crash.
- Redaction — auth-core's logger redacts
token|secret|passwordfields;scrubSecretsmasks token-shaped substrings in error bodies;redactUrlstrips userinfo/query from logged endpoints. The normalized remote has userinfo stripped before it is ever logged. - Disk —
0o600files, atomic rename,0o700dirs (auth-coreFileCacheStore, ADR-0005). - Fail closed — exchange failure ⇒ no header ⇒ gateway 401. The plugin never invents a token.
- No content capture — the plugin only reads/writes headers and the token cache; it never sees prompts or responses.
- Ordering in
plugin:repo-auth→models-info→ratelimit. repo-auth stampsprovider.options.headers.Authorizationat config time so models-info'smeta.modelsInfoUrlfetch inherits the project bearer; ratelimit's fetch wrapping is auth-independent so its position is cosmetic (same contract as oauth2 → models-info → ratelimit today). - Never on the same provider as
@vymalo/opencode-oauth2— documented + guarded (see No-op matrix). A dev may run both plugins on different providers (oauth2 for one IdP, repo-auth for the gateway); they share nothing — separate cache namespaces, separate runtimes. chat.headers"after any user-bearer" (ticket wording) is interpreted as: on a managed provider, the project bearer wins per request (it is the more specific credential and the gateway requires it); at config time a user-setAuthorizationstill wins (mirror oauth2). Flag for confirmation with the owner.
- Unit (
test/*.test.ts, vitest):git.ts: scp vs https normalization,user:pass@stripping, query/fragment drop, linked-worktree.gitfile resolution, missing remote.config.ts: opt-in parsing, missingprojectId, oauth2-conflict detection,AuthServerConfigInputmapping.plugin.ts: cache keying (identity-<hash(identity:key)>deterministic, NTFS-safe), exchange request shape (assertsproject_idform param present, noaudience), model-b re-exchange on expiry, in-flight exchange dedup, no-op matrix, reset clears human + project tokens, fail-closed on exchange error.
- Integration (
test/integration/exchange.integration.test.ts, WireMock via thetest-envcompose stack, self-skips unlessINTEGRATION_REPO_AUTH_TOKEN_URLis set): real-HTTP exchange of a seeded human root (asserts via WireMock's request journal that the POST carriesgrant_type=token-exchange+subject_token+project_idand noaudience), config-time stamping +chat.headersinjection of the sealed bearer, cache-hit without a second exchange, stale-bearer re-exchange, and non-member → 403 → fail-closed no header. - Manual e2e against the real gateway: enrolled repo + logged-in dev → gateway sees the project bearer; unenrolled repo → no header. A scripted variant against the test-env stack (real
opencodebinary, WireMock as IdP + gateway) is indocs/local-development.md.
- resolve-by-remote (derive
projectIdfrom the remote) — deferred; the git module is built to support it later. - Monorepo sub-project Sources — one project per repo in v1.
- Self-serve Source claiming — stays operator-controlled (ADR-0049).
auth-coreextraction — done in #66 (PR #97).opencode-corekit unifying logging/cache/config across all plugins — separate refactor.
project_idform-param exchange vs the ticket body'saud=Source— the design follows the superseding comments + the live SPI contract. Worth an ADR (the ticket referencesdocs/adr/0005-…; this decision closes off the audience-scoped design someone would reasonably reach for).- auth-core generic exchange extension (Option A) — small, backward-compatible; confirm whether it rides in the repo-auth PR or lands as a separate auth-core PR.
- Read-off-disk git vs
gitCLI — off-disk matches otel's precedent; CLI would honorinsteadOfrewrites. Log-only in v1, so off-disk wins. chat.headers"after any user-bearer" interpretation — project bearer wins per request on managed providers.- Config shape — per-provider
options.meta.repoAuth(recommended) vs shared top-levelpluginConfig.repoAuth.
{ "provider": { "gateway": { "options": { "baseURL": "https://gateway.acme.com/v1", "meta": { "repoAuth": { "projectId": "proj-123", "issuer": "https://idp.acme.com/realms/camer-digital", "clientId": "opencode-cli", "scopes": ["openid", "offline_access"], "authFlow": "device_code", "tokenEndpoint": "https://idp.acme.com/realms/camer-digital/protocol/openid-connect/token", "deviceAuthorizationEndpoint": "https://idp.acme.com/realms/camer-digital/protocol/openid-connect/auth/device" } } } } } }