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

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

Ouverte
#103 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
Langage dominant
TypeScript
Étoiles
4
Forks
0
Merge moyen
6 h 14 min
PR mergées (30 j)
26

Description

**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.

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.