opencode-portal-auth
v0.1.0
Published
OpenCode plugin: SSO-connect to a company AI portal (e.g. a Backstage instance) via browser, with per-team virtual keys and dynamic model discovery from an OpenAI-compatible gateway.
Maintainers
Readme
opencode-portal-auth
OpenCode plugin that connects OpenCode to a company AI portal (e.g. a Backstage instance) through the browser — no terminal, no JSON editing for end users.
What it does:
- The user runs
/connectin OpenCode (TUI or Desktop) and picks "Sign in with company SSO" - (optional) A team picker rendered in the OpenCode UI — the selected team determines which budget the usage is billed to
- The browser opens on the portal's connect page (the user is already signed in via SSO) and confirms
- The portal generates a virtual key and redirects back to a local callback
- The plugin stores the credential and discovers the models the key is allowed to use, straight from the gateway (
GET /v1/models)
The portal is responsible for key generation/budget/teams (governance). This plugin only implements the client side of the handshake. It is gateway-agnostic — LiteLLM, or any OpenAI-compatible endpoint, works.
Install
// ~/.config/opencode/opencode.json (or managed/MDM config for fleet rollout)
{
"plugin": [
["opencode-portal-auth", {
"connectUrl": "https://backstage.yourcompany.com/api/litellm/opencode/connect",
"providerId": "govai",
"providerName": "GOVAI AI Gateway",
"defaultBaseURL": "https://litellm.yourcompany.com/v1",
"teams": [
{ "label": "Finance", "value": "finance", "hint": "budget $500/mo" },
{ "label": "Marketing", "value": "marketing" }
]
}]
]
}
connectUrlalso accepts{env:MY_PORTAL_URL}. Plugin options can also be provided by an MDM-managed config; users need no local setup at all.
Options
| Option | Default | Description |
|---|---|---|
| connectUrl | (required) | Portal connect endpoint, see the contract. Accepts {env:VAR}. |
| providerId | portal | Provider id inside OpenCode (/models shows <providerId>/<model>). |
| providerName | Company AI Portal | Display name in the /connect and /models UI. |
| defaultBaseURL | — | OpenAI-compatible inference base URL. Used for model discovery; the portal can override per key via base_url. |
| callbackPort | 1456 | Local port for the OAuth callback listener. |
| teamSource | options | options = static list below; portal = fetch from GET {connectUrl}. |
| teams | [] | Static team list for the picker: {label, value, hint?}. |
| teamPrompt | "Which team should this key be billed to?" | Message shown above the team list. |
| methodLabel | "Sign in with company SSO" | Method label in /connect. |
| connectTimeoutMs | 10000 | Timeout for the whole browser handshake. |
| modelsTimeoutMs | 5000 | Timeout for GET /v1/models. |
With teamSource: "portal", GET {connectUrl} (no query params) must return:
[{ "label": "Finance", "value": "finance", "hint": "budget $500/mo" }]or { "teams": [ ... ] }.
Portal contract
The portal (Backstage or anything with SSO) implements one endpoint:
GET {connectUrl}?team=<team-id>&redirect_uri=http://localhost:<callbackPort>/callbackThe endpoint must:
- Authenticate the user (its own SSO/session — the user is already signed in)
- Generate (or reuse) a virtual key bound to the user and, if given, the
team— this is what makes per-team accounting work - Redirect (302) back with the key:
{redirect_uri}?key=<api-key>&base_url=<optional-overriding-base-url>&provider=<optional-provider-id>&<extra metadata...>Only key is required. base_url overrides defaultBaseURL for that connection (e.g. a team-specific gateway URL). All other query parameters are stored as credential metadata for downstream hooks.
Example: Backstage
In your Backstage backend plugin (see backstage-plugin-litellm-govai for the full governance plugin):
router.get('/opencode/connect', async (req, res) => {
const userId = await resolveUserId(req, auth);
if (!userId) return res.status(401).json({ error: 'Authentication required' });
const { team, redirect_uri } = req.query as Record<string, string>;
if (!redirect_uri?.startsWith('http://localhost:')) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
// generate a virtual key bound to user + team via LiteLLM /key/generate
const key = await client.generateKey({ alias: `opencode-${userId}`, teams: team ? [team] : undefined, ... });
const url = new URL(redirect_uri);
url.searchParams.set('key', key.key);
return res.redirect(url.href);
});How the team accounting works
The portal generates a virtual key per team (LiteLLM virtual keys carry team_id + user_id). Every OpenCode request bears that key, so the gateway attributes usage and enforces budget per team automatically. No config switching on the client side — the user just picks the team once at connect time. To switch, run /connect again.
Development
bun install
bun run typecheck
bun test
bun run build # emits dist/License
MIT
