ADORSYS-GIS / ADORSYS-GIS/lightbridge-authz

[Epic]: lightbridge-authz issues its own tokens to CLI clients via the device authorization grant

Open
#337 1 comment 0 reactions 0 assignees View on GitHub
epic
Dominant language
Rust
Stars
0
Forks
1
Avg merge
6h 42m
Merged PRs (30d)
246

Description

> **Re-scoped 2026-08-22.** Phase 0 (ADR-0012) and Phase 1 (the new microservice) below are
> **done** — `authz-idp` is live at `auth.ai.camer.digital`, serving discovery/JWKS/`/oauth2/token`/
> `/oauth2/revoke` today. The repo owner has since directed a broader goal than this epic originally
> scoped: *"we need an auth endpoint to start standard flow or device-code flow, then redirect to
> SSO (Keycloak) so that we shall remove token-exchange from most of our clients"* — adding the
> `/authorize` browser flow this epic's own "Out of Scope" (below) originally excluded. That
> exclusion is superseded by
> [ADR-0019](https://github.com/ADORSYS-GIS/lightbridge-authz/blob/main/docs/adr/0019-authz-idp-brokers-authorization-code-alongside-device-grant.md)
> (PR https://github.com/ADORSYS-GIS/lightbridge-authz/pull/422), which also supersedes ADR-0012
> Decision 4 (`redirect_uris` deliberately empty). **The "Child User Stories" checklist and the
> "Phase 2 must stay unmodified" note in Phase 2's own bullet below are stale** — re-derived,
> code-verified tickets are tracked in the new "Re-scoped child tickets" section near the bottom of
> this issue. The Executive Summary/Problem Statement/Risks below remain accurate context for why
> this epic exists and are left as originally written.

## Executive Summary

We want to make lightbridge-authz the token issuer for AI CLI tools (opencode, and future CLIs) via the OAuth 2.0 Device Authorization Grant (RFC 8628), brokering authentication to Keycloak behind a verification page we host, because today every CLI authenticates against Keycloak directly, which requires manual realm surgery per client, splits the user-facing auth domain (`auth.verif.fyi`) from the product domain (`ai.camer.digital`), and leaves tokens without tenant context unless routed through token-exchange. This epic exists to solve the problem of CLI onboarding and token issuance being coupled to an external, non-GitOps'd Keycloak realm instead of our own GitOps'd config.

## Strategic Intent

The intent of this epic is to make lightbridge-authz the single token issuer for CLI clients, so that registering a new CLI is a GitOps YAML change instead of Keycloak realm surgery, and so that tenant context (`account_id`/`project_id`) is sealed into the token at issuance without needing token-exchange as a universal path.

## Problem Statement

**Current pains:**
- AI CLI tools on customer laptops authenticate against Keycloak directly today: opencode uses `authFlow: device_code`, `clientId: opencode-cli`, `issuer: https://auth.verif.fyi/realms/camer-digital` (`ai-helm/charts/librechat-opencode-wellknown/values.yaml:160-169`), and `governance-auth` already implements RFC 8628 device flow with PKCE independently (`lightbridge-governance/app/governance-auth/tests/device_flow.rs`).
- Codex CLI and GitHub Copilot support only static/non-refreshing credentials on at least part of their auth surface (Codex's OTLP telemetry auth is static-only; Copilot has no refreshing credential path at all) — confirmed in `lightbridge-governance/docs/integrations/ai-client-support-matrix.md` ("Telemetry auth, refreshing": Codex "static only", Copilot "static only"; "Inference auth": Copilot unsupported). A device-grant-issued, refreshable token from an issuer we control is the fix for clients that can accept it.
- Every new CLI needs its own Keycloak client, and the realm is an explicitly-flagged external dependency, not managed in GitOps: `ai-helm-values/environments/prod/values/lightbridge-app.yaml:109` reads "⚠️ EXTERNAL DEPENDENCY: the camer-digital realm (auth.verif.fyi, the jwks_url above) ... wire it in the realm." Onboarding a client today means manual realm edits outside our deployment pipeline.
- Users authenticate at `auth.verif.fyi` while using the product at `ai.camer.digital` — a confusing, split-domain login experience.
- Tokens arrive without tenant context. Getting `account_id`/`project_id` onto a token today requires either the Keycloak SPI (token-exchange interception) or our RFC 8693 token-exchange endpoint. Extending that pattern to every CLI (claude, opencode, goose, ...) would make token-exchange the universal path, which is not the intended design.

**Impact:**
- Operations: every new CLI integration is manual, out-of-band realm work, not a reviewable GitOps diff.
- Users: a confusing two-domain auth experience (`auth.verif.fyi` vs `ai.camer.digital`).
- Security/architecture: tenant-context sealing is bolted on via token-exchange rather than being native to issuance, and this pattern would otherwise need to be repeated per CLI.

## Desired Outcome

lightbridge-authz issues its own tokens to CLI clients via the device authorization grant, brokering authentication to Keycloak behind a verification page we host and control. A CLI's config changes from `issuer: auth.verif.fyi/realms/camer-digital` to `issuer: auth.ai.camer.digital`; everything else on the client side (`authFlow: device_code`, `clientId`, `scopes`) stays as-is. Client registration becomes a GitOps'd YAML change instead of a Keycloak realm edit. Tenant context (`account_id`/`project_id`) is sealed into the token at issuance, removing the need for token-exchange on this path.

## Scope (In / Out)

### In Scope
- Phase 0 — ADR-0012: a superseding ADR recording the shift from "delegates identity entirely" (ADR-0011) to "brokers identity" for the device-grant path only. Blocking gate for all subsequent phases; closes #336.
- Phase 1 — new microservice: move the OIDC surface (`/oauth2/token`, `/oauth2/revoke`, `/.well-known/openid-configuration`, `/.well-known/jwks.json`) off `authz-api` into a new binary that shares the authz Postgres/migrations; resolve signing-key bootstrap ownership; cut over `auth.ai.camer.digital` routing without an outage.
- Phase 2 — the device grant itself: a real `DeviceCodeStore` backed by a new table (replacing `NoDeviceCodeStore`), the verification page (the relying-party leg to Keycloak), tenant context sealed at issuance via `resolve_context`, a config-driven client registry entry for each CLI, and discovery document updates (`device_authorization_endpoint`, device grant type) while keeping the authorization-endpoint gates empty.
- Phase 3 — deployment: container-build CI matrix entry, `image-list`/`image-name`/`image-tag` annotations in `ai-helm`, prod config in both `lightbridge-app.yaml` blocks, chart/ingress/TLS/probes.
- Phase 4 — cutover: repoint opencode's `issuer` to `https://auth.ai.camer.digital`, then `governance-auth`.

### Out of Scope
- ~~The `/authorize` browser authorization-code flow.~~ **Superseded 2026-08-22 by ADR-0019** — see
the re-scope note at the top of this issue. `/authorize` is now in scope, for browser clients
specifically (e.g. `lightbridge-ss`), with the redirect-URI/PKCE surface this bullet originally
avoided now deliberately accepted and mitigated (ADR-0019 Decision 2). The original reasoning
below remains correct for CLIs, which is exactly why they stay on the device grant: `ai-helm/charts/librechat-opencode-wellknown/README.md:73` documents from measurement that the plugin's default `authorization_code` flow "binds a localhost callback port and breaks headless use" — the device grant is the correct flow for these CLIs regardless.
- Replacing the Keycloak SPI (`lightbridge-keycloak-spi`). It intercepts Keycloak's own token-exchange grant and is unaffected by this epic; it only becomes redundant for clients that move to lightbridge-issued tokens.
- Changing the gateway's fail-open introspection metadata behavior (`ai-helm-values/environments/prod/values/security-policies.yaml`) — left as a deliberate, separately-documented availability tradeoff, not touched here.

## Source of truth (links)

#336 (blocking ADR gate — "Authorization-code flow needs a superseding ADR: ADR-0011 states this service runs no login flow"). Phase 0 (ADR-0012) supersedes ADR-0011 for the device-grant path only and must close #336.

## Stakeholders

- Human accountable owner: @stephane-segning
- Technical Lead: @stephane-segning
- Coordination: `lightbridge-governance` (owns `governance-auth`'s existing device-flow implementation) and `ai-helm`/`ai-helm-values` maintainers (deployment/GitOps cutover)

## Key Assumptions

- authkestra-op 0.5.0 (already a workspace dependency) provides the device-grant handlers and store trait (`handlers/device_authorization.rs`, `handlers/device_verify.rs`, `DeviceCodeStore`) needed for Phase 2, so this is wiring plus one verification page, not a protocol implementation from scratch — needs confirming against the exact pinned version before work starts.
- `auth.ai.camer.digital` can be repointed to the new service without downtime by serving discovery/JWKS identically from both services before the DNS/ingress cutover.
- No repo in the workspace currently takes an outbound HTTP-client dependency for an OIDC relying-party leg; Phase 2 must decide whether to add `authkestra-oidc` (pinned in lockstep with the other `authkestra-*` crates at 0.5.0) or hand-write the RP leg.

## Constraints

- Phase 0 (ADR-0012) is a hard blocker: no code before it merges and closes #336.
- `auth.ai.camer.digital` is a live trusted issuer in `ai-helm-values/environments/prod/values/security-policies.yaml` (identity source `lightbridge-apikey`), and every in-circulation API-key JWT carries it as `iss`. Discovery and JWKS must never stop resolving there during the Phase 1 cutover — this is the epic's primary outage risk.
- ADR-0038/0039 boundaries apply to the new `DeviceCodeStore` table: CAS-consume pattern following `exchange_refresh_tokens`' precedent, cuid2 ids via `lightbridge_authz_core::cuid::cuid2()`, never shape-validated or sorted by id.
- Discovery's three-gate structure (`crates/lightbridge-authz-rest/src/signing.rs`) must stay intact — conflating gates caused a real prior bug (#300, #304); `authorization_endpoint` and `response_types_supported` must stay empty/absent even after the device grant ships.
- Workspace pins all `authkestra-*` crates at 0.5.0 in lockstep; adding `authkestra-oidc` (if chosen for the RP leg) must follow that constraint.

## Risks

| Risk | Probability | Impact | Mitigation |
| --- | --- | --- | --- |
| Routing cutover to `auth.ai.camer.digital` breaks live JWKS/discovery resolution for every in-circulation API-key JWT | Medium | High (auth outage across the platform) | Serve identical `/.well-known/openid-configuration` and `/.well-known/jwks.json` bodies from both old and new services before repointing; verify parity as an explicit pre-cutover gate (Phase 1). |
| Signing-key bootstrap race between the new service and existing `authz-api`/`lightbridge-mcp` on `signing_keys` | Low-Medium | Medium (duplicate/conflicting key rotation) | `ensure_active_signing_key` is already advisory-lock-safe; explicitly document and test whether the new service bootstraps or only reads, and confirm all services share the same `max_key_age_days`. |
| Scope creep into the `/authorize` authorization-code flow during implementation | Low | Medium (reopens the exact risk surface #336 flagged: redirect-URI registry, open-redirect, code substitution) | Keep the existing regression test asserting discovery never advertises `response_types`/`authorization_endpoint` unmodified as a scope-creep signal; `AuthorizationCodeStore` stays a no-op stub. |
| An unsigned/unpromoted image ships silently if `image-list` annotations are incomplete | Medium | Medium (new service never gets deployed despite green CI) | Mirror `ai-helm` PR #1015's pattern exactly: both `helm.image-name` and `helm.image-tag` required per image; verify cosign `.sig` and argocd-image-updater promotion post-merge. |

## Success metrics

| Metric | Current | Target | Source |
| ------ | ------- | ------ | ------ |
| CLI clients issued tokens directly by lightbridge-authz via device grant | 0 | opencode + governance-auth (2) | `ai-helm/charts/librechat-opencode-wellknown/values.yaml`, `lightbridge-governance` config |
| New-CLI onboarding requiring manual Keycloak realm edits | Every new client | 0 (GitOps YAML change only) | `crates/lightbridge-authz-core/src/config/mod.rs` client registry diff |
| Discovery/JWKS availability during the `auth.ai.camer.digital` cutover | N/A (not yet migrated) | Zero downtime, verified by parity check before DNS/ingress switch | Phase 1 verification step |
| CLI tokens carrying sealed tenant context (`account_id`/`project_id`) without token-exchange | 0 | 100% of device-grant-issued tokens | Phase 2 issuance code path |

## Child User Stories

- [ ] **Phase 0 — ADR-0012:** author `docs/adr/0012-authz-brokers-identity-for-cli-clients.md`, superseding ADR-0011's Context and Decision 3 (`DeviceCodeStore` only — `AuthorizationCodeStore` stays a no-op stub), leaving Decision 5 (`redirect_uris` empty) standing, answering #336's `sub`-ownership question, and updating Decision 7's `nonce`/`auth_time` framing now that a real authorization request exists. Must close #336 on merge.
- [ ] **Phase 1 — new microservice:** stand up a new binary under `app/` (mirroring `lightbridge-authz-usage`'s structure, sharing the authz Postgres/migrations the way `authz-opa` does); move `/oauth2/token`, `/oauth2/revoke`, and the `.well-known` OIDC surface off `authz-api`; decide and document signing-key bootstrap ownership against the shared `signing_keys` table; verify discovery/JWKS parity between old and new services before the `auth.ai.camer.digital` routing cutover.
- [ ] **Phase 2 — the device grant:** replace `NoDeviceCodeStore` (`crates/lightbridge-authz-rest/src/oauth2_op/noop_stores.rs`) with a real, CAS-consuming, TTL'd `DeviceCodeStore` table; build the verification page (the RP leg to Keycloak, deciding on `authkestra-oidc` vs. a hand-written leg); seal tenant context at issuance via `resolve_context`; extend the client registry (`crates/lightbridge-authz-core/src/config/mod.rs`) with the device grant type and register `opencode-cli` and other CLIs; add `device_authorization_endpoint` and the device grant to discovery while keeping `authorization_endpoint`/`response_types_supported` empty. `discovery_never_advertises_response_types_or_modes` (`crates/lightbridge-authz-rest/tests/signing_tests.rs:466`) must stay green and unmodified through this phase — a deliberate scope guard: the device grant adds `device_authorization_endpoint` and its own grant type, but `authorization_endpoint` and `response_types_supported` stay empty. If that test ever needs changing, `/authorize` has crept into scope and the change needs its own decision.
- [ ] **Phase 3 — deployment:** add a `container-build` CI matrix entry (main-only); add the new image to `image-list` in `ai-helm/charts/lightbridge/templates/applications.yaml` with both `helm.image-name` and `helm.image-tag` (mirroring `ai-helm` PR #1015); add prod config to both blocks of `ai-helm-values/environments/prod/values/lightbridge-app.yaml`; wire chart/subchart, ingress, TLS, probes.
- [ ] **Phase 4 — cut clients over:** change `issuer` to `https://auth.ai.camer.digital` in `ai-helm/charts/librechat-opencode-wellknown/values.yaml` (leaving `authFlow`, `clientId`, `scopes` unchanged); then cut `governance-auth` over.

Implementation on every phase after Phase 0 is gated on ADR-0012 merging.

## Re-scoped child tickets (2026-08-22, per ADR-0019)

Phase 0 and Phase 1 above are done (see the re-scope note at the top of this issue). The list
above is superseded by this checklist, re-derived from the current state of the code rather than
copied from the original plan:

- [ ] #423 — real, CAS-consuming `DeviceCodeStore` replacing `NoDeviceCodeStore` (ADR-0012 Decision
7). No dependency beyond ADR-0019/ADR-0012.
- [ ] #424 — RP leg to Keycloak + device-grant verification page. Depends on #423.
- [ ] #425 — `/authorize` + `AuthorizationCodeStore` + `redirect_uris` registry + mandatory PKCE
(ADR-0019). Depends on #424 (reuses its RP leg).
- [ ] #426 — discovery document advertises the device and authorization-code endpoints; the
deliberate, reasoned rewrite of `discovery_never_advertises_response_types_or_modes`. Depends
on #423, #424, and #425 all merging first.
- [ ] #427 — cut opencode over to the device grant and `lightbridge-ss` over to `/authorize`.
Depends on #426 (discovery must advertise the real endpoints before any client is repointed).

Source of truth for the re-scope: ADR-0019
(https://github.com/ADORSYS-GIS/lightbridge-authz/pull/422) plus the repo owner's 2026-08-22
in-session instruction quoted in the re-scope note above.

## Human accountable owner

@stephane-segning

## AI Usage Declaration

Research, Ticket decomposition

## Human verification completed

- [ ] Intent checked against source of truth
- [ ] Scope reviewed by Product Owner
- [ ] Technical feasibility reviewed by Technical Lead
- [ ] Risks reviewed
- [ ] Acceptance criteria reviewed
- [ ] No unverified AI claim remains
- [ ] I am the accountable owner and accept responsibility for this epic.

_This epic was drafted by an AI agent from an approved implementation plan and cross-checked against the current codebase/sibling repos (see notes below); the boxes above are left unchecked for @stephane-segning to tick after personal review, per this repo's AI governance doctrine._

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.