feat(keycloak): add forte-cli public device-code client #26

Closed
jorgen.stensrud wants to merge 1 commits from fm/launchpad-forte-cli into main
Member

What

Adds a shared public OIDC client forte-cli to the forte realm with the RFC 8628 device-authorization grant enabled, so downloaded skills / CLI tools (forte-drop's drop.sh login first) can log in through the Auth Sidecar. Today no client in the realm has oauth2.device.authorization.grant.enabled, so the device-code flow cannot even start (k8s rollout plan §2.2a, step 0a). All skills reuse it via <PREFIX>_CLIENT_ID=forte-cli.

Client config (inline in forte-realm.json, infra/values/base/keycloak-values.yaml)

{
  "clientId": "forte-cli",
  "name": "Forte CLI",
  "description": "Shared public client for RFC 8628 device-code login from downloaded skills/CLI tools (forte-drop first) against services behind Auth Sidecar. No client secret.",
  "enabled": true,
  "protocol": "openid-connect",
  "standardFlowEnabled": false,
  "directAccessGrantsEnabled": false,
  "publicClient": true,
  "redirectUris": [],
  "webOrigins": [],
  "attributes": {
    "oauth2.device.authorization.grant.enabled": "true"
  }
}
  • Public client: no secret, nothing to sync (no k8s.secret.sync, so the registrar's legacy sync skips it).
  • standardFlowEnabled: false, directAccessGrantsEnabled: false, empty redirectUris/webOrigins: the only usable grant is device-code.

Why the realm JSON and not a client-config Secret

The self-service registrar (jq block around line 520-532) hardcodes publicClient: false / standardFlowEnabled: true and drops attributes, so it cannot produce this client. The inline clients list bypasses the registrar entirely: the Bitnami chart renders keycloakConfigCli.configuration verbatim into the keycloak-keycloak-config-cli-configmap ConfigMap (IMPORT_FILES_LOCATIONS=/config/*), and keycloak-config-cli 6.4.0 imports the client representation as-is. The registrar's legacy path only reads clients with k8s.secret.sync=true to sync secrets; it never rewrites clients.

Environments

Keycloak is deployed only by the upc-dev overlay (infra/overlays/upc-dev/kustomization.yaml -> infra/base/keycloak), whose Application uses base + upc-dev values, and upc-dev only overrides ingress.hostname. The upc-prod overlay has no Keycloak Application. So base is the right (and only) place, and this lands on id.forteapps.net, the realm forte-drop-mcp uses.

Scope / safety

  • Additive only. gitea, grafana, argocd and all other realm settings are byte-identical to main (checked by parsing the realm JSON before/after).
  • No secrets involved.
  • forte-cli is also added to the cleanup CronJob's PROTECTED_JSON list, next to vaultwarden. Belt-and-braces: the cleanup only targets UUID-shaped clientIds anyway. Happy to drop that one line if you want the diff to be the client only.
  • Short docs note under Legacy Method: Realm JSON in docs/DEVELOPER-GUIDE.md.

Verification done

  • helm template with chart 25.2.0 + both value files (as in the Argo Application) renders cleanly (23 resources).
  • Rendered forte-realm.json parses; forte-cli is present with publicClient: true, standardFlowEnabled: false, directAccessGrantsEnabled: false, device-grant attribute "true"; no secret, no k8s.secret.sync. It is the only device-grant client in the realm.
  • Registrar CronJob script passes sh -n; cleanup script differs from main only in PROTECTED_JSON.
  • Not done: a live keycloak-config-cli import (no docker daemon available here). After sync, verify with:
curl -s https://id.forteapps.net/realms/forte/.well-known/openid-configuration | jq .device_authorization_endpoint
curl -s -X POST https://id.forteapps.net/realms/forte/protocol/openid-connect/auth/device \
  -d client_id=forte-cli -d scope=openid | jq

The second call should return device_code / user_code / verification_uri instead of unauthorized_client.

Follow-ups (not in this MR)

  • Plan §2.2b: the kit must send RFC 8707 resource= so the token aud matches the sidecar's AUTH_MCP_RESOURCE. If Keycloak does not honour it for device-code tokens, add one oidc-audience-mapper per resource on forte-cli.
  • Pre-existing, untouched: the cleanup CronJob uses done < <(...) (bash process substitution) under /bin/sh on alpine; sh -n rejects it on main too. Worth a separate look.

Auth infra: please do not merge without captain sign-off.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QciXev3MtCxo3eomcfrDRW

## What Adds a shared **public** OIDC client `forte-cli` to the `forte` realm with the RFC 8628 device-authorization grant enabled, so downloaded skills / CLI tools (forte-drop's `drop.sh login` first) can log in through the Auth Sidecar. Today **no** client in the realm has `oauth2.device.authorization.grant.enabled`, so the device-code flow cannot even start (k8s rollout plan §2.2a, step 0a). All skills reuse it via `<PREFIX>_CLIENT_ID=forte-cli`. ## Client config (inline in `forte-realm.json`, `infra/values/base/keycloak-values.yaml`) ```json { "clientId": "forte-cli", "name": "Forte CLI", "description": "Shared public client for RFC 8628 device-code login from downloaded skills/CLI tools (forte-drop first) against services behind Auth Sidecar. No client secret.", "enabled": true, "protocol": "openid-connect", "standardFlowEnabled": false, "directAccessGrantsEnabled": false, "publicClient": true, "redirectUris": [], "webOrigins": [], "attributes": { "oauth2.device.authorization.grant.enabled": "true" } } ``` - Public client: no secret, nothing to sync (no `k8s.secret.sync`, so the registrar's legacy sync skips it). - `standardFlowEnabled: false`, `directAccessGrantsEnabled: false`, empty `redirectUris`/`webOrigins`: the only usable grant is device-code. ## Why the realm JSON and not a `client-config` Secret The self-service registrar (jq block around line 520-532) hardcodes `publicClient: false` / `standardFlowEnabled: true` and drops `attributes`, so it cannot produce this client. The inline `clients` list bypasses the registrar entirely: the Bitnami chart renders `keycloakConfigCli.configuration` verbatim into the `keycloak-keycloak-config-cli-configmap` ConfigMap (`IMPORT_FILES_LOCATIONS=/config/*`), and keycloak-config-cli 6.4.0 imports the client representation as-is. The registrar's legacy path only *reads* clients with `k8s.secret.sync=true` to sync secrets; it never rewrites clients. ## Environments Keycloak is deployed only by the `upc-dev` overlay (`infra/overlays/upc-dev/kustomization.yaml` -> `infra/base/keycloak`), whose Application uses `base` + `upc-dev` values, and `upc-dev` only overrides `ingress.hostname`. The `upc-prod` overlay has no Keycloak Application. So `base` is the right (and only) place, and this lands on `id.forteapps.net`, the realm forte-drop-mcp uses. ## Scope / safety - **Additive only.** `gitea`, `grafana`, `argocd` and all other realm settings are byte-identical to `main` (checked by parsing the realm JSON before/after). - No secrets involved. - `forte-cli` is also added to the cleanup CronJob's `PROTECTED_JSON` list, next to `vaultwarden`. Belt-and-braces: the cleanup only targets UUID-shaped clientIds anyway. Happy to drop that one line if you want the diff to be the client only. - Short docs note under *Legacy Method: Realm JSON* in `docs/DEVELOPER-GUIDE.md`. ## Verification done - `helm template` with chart 25.2.0 + both value files (as in the Argo Application) renders cleanly (23 resources). - Rendered `forte-realm.json` parses; `forte-cli` is present with `publicClient: true`, `standardFlowEnabled: false`, `directAccessGrantsEnabled: false`, device-grant attribute `"true"`; no `secret`, no `k8s.secret.sync`. It is the only device-grant client in the realm. - Registrar CronJob script passes `sh -n`; cleanup script differs from `main` only in `PROTECTED_JSON`. - **Not done:** a live keycloak-config-cli import (no docker daemon available here). After sync, verify with: ```bash curl -s https://id.forteapps.net/realms/forte/.well-known/openid-configuration | jq .device_authorization_endpoint curl -s -X POST https://id.forteapps.net/realms/forte/protocol/openid-connect/auth/device \ -d client_id=forte-cli -d scope=openid | jq ``` The second call should return `device_code` / `user_code` / `verification_uri` instead of `unauthorized_client`. ## Follow-ups (not in this MR) - Plan §2.2b: the kit must send RFC 8707 `resource=` so the token `aud` matches the sidecar's `AUTH_MCP_RESOURCE`. If Keycloak does not honour it for device-code tokens, add one `oidc-audience-mapper` per resource on `forte-cli`. - Pre-existing, untouched: the cleanup CronJob uses `done < <(...)` (bash process substitution) under `/bin/sh` on alpine; `sh -n` rejects it on `main` too. Worth a separate look. Auth infra: please do not merge without captain sign-off. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QciXev3MtCxo3eomcfrDRW
danijel.simeunovic added 1 commit 2026-10-01 10:37:52 +00:00
Add forte-cli public device-code Keycloak client
AI Code Review / ai-review (pull_request) Skipped
scan.yaml / test (pull_request) Successful in 27s
d087472e63
Add a shared public client `forte-cli` to the `forte` realm so downloaded
skills (forte-drop first) can do RFC 8628 device-code login through the
Auth Sidecar. Today no client in the realm has the device grant enabled,
so the flow cannot start.

Client (inline in forte-realm.json, imported verbatim by keycloak-config-cli):
- publicClient: true, standardFlowEnabled: false,
  directAccessGrantsEnabled: false
- attributes: oauth2.device.authorization.grant.enabled=true
- no secret, no redirectUris/webOrigins, no k8s.secret.sync

It has to go in the realm JSON because the self-service registrar
hardcodes publicClient:false/standardFlowEnabled:true and drops
attributes. Also add forte-cli to the cleanup CronJob's protected list
(belt-and-braces; it does not match the UUID pattern anyway).

Additive only: gitea/grafana/argocd and all other realm settings are
unchanged. Keycloak is deployed only via the upc-dev overlay, which
inherits base values, so this lands on id.forteapps.net.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QciXev3MtCxo3eomcfrDRW
danijel.simeunovic force-pushed fm/launchpad-forte-cli from 46eab199ee to d087472e63 2026-10-01 10:37:52 +00:00 Compare
Author
Member

Superseded by #44, which contains this commit unchanged (same forte-cli client) plus the audience mapper the forte-drop-mcp sidecar needs. Closing in favour of merging #44 alone.

Superseded by #44, which contains this commit unchanged (same `forte-cli` client) plus the audience mapper the forte-drop-mcp sidecar needs. Closing in favour of merging #44 alone.
jorgen.stensrud closed this pull request 2026-10-01 11:03:17 +00:00

Pull request closed

Please reopen this pull request to perform a merge.
Sign in to join this conversation.