ADORSYS-GIS / ADORSYS-GIS/lightbridge-opencode-toolbeit

[Proposal]: Exchange proxy via lightbridge-authz — server-side token exchange

Offen
#103 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen
Vorherrschende Sprache
TypeScript
Sterne
4
Forks
0
Ø Merge
6 Std. 14 Min.
Gemergte PRs (30 T.)
26

Beschreibung

**Type:** Proposal / Architecture Decision

## Context

Bug [#102](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues/102) identified that the `@vymalo/opencode-repo-auth` plugin sends `client_id=opencode-cli` (public) in the RFC 8693 token exchange. Keycloak STE V2 requires a confidential client (`opencode-exchange`). The confidential client secret cannot be held by the plugin (runs on developer laptops).

**Recommended fix:** Proxy the exchange through `lightbridge-authz` — the confidential credentials stay server-side.

## Current (broken) flow

```
Plugin → Keycloak token endpoint
client_id=opencode-cli (public)
client_secret=(none)
grant_type=token-exchange
subject_token=
project_id=proj-123
→ Keycloak STE V2: "not enabled for requested client" ❌
```

The `OAuthClient.exchange()` hardcodes `this.server.clientId` ([source](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues/102)). No way to specify a different client for the exchange step.

## Proposed flow — Authz proxy

```
Plugin → authz-api POST /oauth2/token
Authorization: Bearer
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=
subject_token_type=urn:ietf:params:oauth:token-type:jwt
project_id=proj-123
→ authz-api validates human token
→ authz-api calls Keycloak token endpoint (confidential client)
client_id=opencode-exchange
client_secret=
grant_type=token-exchange
subject_token=
project_id=proj-123
→ Keycloak SPI resolves context
→ returns sealed JWT {account_id, project_id}
→ authz-api returns sealed JWT to plugin
```

**No confidential credentials reach the plugin.** The `opencode-exchange` secret stays in the cluster as a K8s secret injected into `authz-api`.

## Why this works

| Component | Status |
|-----------|--------|
| `POST /oauth2/token` on `authz-api` | ✅ Already supports RFC 8693 token exchange with `project_id` ([architecture.md](https://github.com/ADORSYS-GIS/lightbridge-authz/blob/main/docs/architecture.md)) |
| `POST /idp/v1/resolve-context` on `authz-opa` | ✅ SPI calls this; resolves `{account_id, project_id}` from membership |
| Server-side Keycloak exchange credentials | ✅ `authz-api` is Rust, server-side — exchange secrets live in K8s secrets |
| Keycloak SPI (`lightbridge-keycloak-spi`) | ✅ Already handles `project_id` exchanges — no SPI changes needed |

## Changes required

### 1. `lightbridge-authz` — Add Keycloak exchange config

`authz-api` needs configuration for the Keycloak token endpoint and exchange client:

```yaml
# config.yaml for authz-api
keycloak:
issuer: https://auth.verif.fyi/realms/camer-digital
token_endpoint: https://auth.verif.fyi/realms/camer-digital/protocol/openid-connect/token
exchange_client_id: opencode-exchange
exchange_client_secret: ${OPENCODE_EXCHANGE_SECRET} # from K8s secret via ESO
```

The `opencode-exchange` client secret is already managed via ESO: `camer-digital/keycloak/opencode-exchange`.

### 2. `@vymalo/opencode-repo-auth` — Target authz-api endpoint

Update `performExchange()` to call `authz-api`'s `POST /oauth2/token` instead of Keycloak's token endpoint:

```js
// Current (broken):
const exchanged = await this.runtime.exchangeTo(
this.projectId,
human.accessToken,
{ project_id: this.projectId }
);
// ↑ Calls Keycloak directly with client_id=opencode-cli (public)

// Proposed (Option A):
const exchanged = await this.runtime.exchangeTo(
this.projectId,
human.accessToken,
{
project_id: this.projectId,
// Override token endpoint to point at authz-api
}
);
```

The simplest approach: add an `exchangeTokenEndpoint` config option to the plugin. When set, `exchange()` targets that endpoint instead of Keycloak's. The authz-api endpoint accepts the human bearer token and performs the exchange server-side.

### 3. Config shape (plugin side)

```jsonc
"repoAuth": {
"projectId": "proj-123",
"issuer": "https://auth.verif.fyi/realms/camer-digital",
"clientId": "opencode-cli", // human login (public)
"scopes": ["openid", "offline_access"],
"authFlow": "device_code",
"exchangeTokenEndpoint": "https://api.ai.camer.digital/oauth2/token" // authz-api proxy
}
```

- `clientId` — used for device-code login (public, unchanged)
- `exchangeTokenEndpoint` — authz-api endpoint for the exchange (confidential, server-side)

No `exchangeClientId`/`exchangeClientSecret` in the plugin config — the confidential credentials stay in `authz-api`.

## Acceptance Criteria

- [ ] `authz-api` `POST /oauth2/token` accepts human bearer token + `project_id` for exchange
- [ ] `authz-api` calls Keycloak with `opencode-exchange` (confidential) credentials
- [ ] Plugin targets `exchangeTokenEndpoint` when configured (falls back to Keycloak for backward-compat)
- [ ] Sealed JWT returned with `{account_id, project_id}` claims
- [ ] No confidential credentials in plugin config or on developer laptops
- [ ] E2E test passes against live `camer-digital` Keycloak realm
- [ ] All existing tests pass; new tests cover the proxy flow

## Security properties

- **Confidential credentials stay server-side** — `opencode-exchange` secret in K8s, never in plugin config
- **Human token is the authorization** — authz-api validates the human token before performing the exchange
- **SPI unchanged** — `lightbridge-keycloak-spi` already handles `project_id` exchanges correctly
- **Fail-closed** — exchange failure → no token → no header → gateway 401s (matches SPI semantics)

## Alternatives considered

| Option | Approach | Why not chosen |
|--------|----------|----------------|
| Plugin fix (exchangeClientId) | Add `exchangeClientId`/`exchangeClientSecret` to plugin config | Confidential secret still reaches developer laptops — violates security constraint |
| SPI relaxation | Accept public client exchanges | Weakens SPI client-boundary guarantee; Keycloak STE V2 may reject at protocol level |
| Gateway-level exchange | New gateway endpoint | Wrong layer for business logic; gateway is Envoy/Authorino |
| No exchange (gateway stamping) | Plugin sends human token + x-project-id header, Authorino stamps x-account-id | Viable alternative — see discussion below |

### Alternative: No exchange (gateway stamping)

If the gateway can stamp `x-account-id` from project membership (PR [#299](https://github.com/ADORSYS-GIS/ai-helm-values/pull/299) implements this), token exchange may be unnecessary entirely:

```
Plugin → Gateway
Authorization: Bearer
x-project-id: proj-123
→ Authorino validates membership
→ Authorino stamps x-account-id from project context
```

This eliminates the exchange entirely. Worth evaluating as a simpler alternative if the gateway CEL stamping is sufficient for attribution.

## Impact

Without this fix (or the no-exchange alternative), **no E2E test of the repo-auth epic can succeed** against the live Keycloak `camer-digital` realm. This blocks epic [#64](https://github.com/ADORSYS-GIS/lightbridge-opencode-toolbeit/issues/64) user story verification.

## Human accountable owner

@stephane-segning

## AI Usage Declaration

Feasibility analysis, architecture design, alternatives evaluation. Verified against live codebase and Keycloak configuration.

## Human verification completed

- [ ] I understood the intent
- [ ] I checked the source of truth
- [ ] I verified the implementation manually
- [ ] I verified the tests
- [ ] I am the accountable owner and accept responsibility for this ticket.

Beitragsleitfaden

Beitragsleitfaden öffnen

Bewertung

Dieses Issue wurde noch nicht bewertet.

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.