feat(keycloak): audience mapper for forte-drop-mcp on forte-cli (stacks on #26) #44

Merged
jorgen.stensrud merged 2 commits from fm/launchpad-forte-cli-audience into main 2026-10-01 11:25:35 +00:00
Member

Needed together with #26. Stacks on #26; until #26 merges the diff also shows #26's commit. This PR's own change is the top commit. Verified end to end locally (see the comment below): without this mapper the device-code token gets aud: "account" and the forte-drop-mcp sidecar answers 401; with it, the full chain works.

What

Adds one protocol mapper to the forte-cli client from #26:

{
  "name": "audience-forte-drop-mcp",
  "protocol": "openid-connect",
  "protocolMapper": "oidc-audience-mapper",
  "consentRequired": false,
  "config": {
    "included.custom.audience": "https://mcp.drop.forteapps.net/mcp",
    "access.token.claim": "true",
    "id.token.claim": "false",
    "introspection.token.claim": "true"
  }
}

Why it is needed

The auth sidecar in mcp mode verifies bearer JWTs with audience = AUTH_MCP_AUDIENCE, defaulting to AUTH_MCP_RESOURCE (internal/config/config.go, internal/auth/mcp.go in Forte/auth-sidecar). For forte-drop-mcp that is https://mcp.drop.forteapps.net/mcp.

forte-drop #67 makes the skill send the RFC 8707 resource= parameter on the device-authorization, token and refresh requests. Keycloak 26.3.3 (the version Bitnami chart 25.2.0 deploys) ignores it: tested locally, the device-code token comes back with aud: "account" only, and the sidecar rejects it with oidc: expected audience "…/mcp" got ["account"]. This mapper is what puts the MCP resource into aud.

Trade-off

The mapper is unconditional: every forte-cli access token carries the forte-drop-mcp audience, whichever service the user logged in for. That is fine while forte-drop is the only consumer. When more sidecar-fronted services adopt forte-cli (mcp10x, ts-mcp …), a per-service optional client scope that carries the audience mapper, requested explicitly via <PREFIX>_SCOPE, keeps tokens least-privilege and should replace this.

Verification done

  • helm template with the Bitnami keycloak chart 25.2.0 and both value files, as in the Argo Application, renders cleanly.
  • Rendered forte-realm.json parses; compared with #26's render, the only difference is the protocolMappers array on forte-cli. All other clients and realm settings are byte-identical.
  • keycloak-config-cli runs with IMPORT_MANAGED_PROTOCOL_MAPPER=no-delete, the same path the existing inline clients (gitea, grafana, argocd) use for their mappers.

🤖 Generated with Claude Code

> **Needed together with [#26](https://git.forteapps.net/Forte/launchpad/pulls/26).** Stacks on #26; until #26 merges the diff also shows #26's commit. This PR's own change is the top commit. Verified end to end locally (see the comment below): without this mapper the device-code token gets `aud: "account"` and the forte-drop-mcp sidecar answers 401; with it, the full chain works. ## What Adds one protocol mapper to the `forte-cli` client from #26: ```json { "name": "audience-forte-drop-mcp", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", "consentRequired": false, "config": { "included.custom.audience": "https://mcp.drop.forteapps.net/mcp", "access.token.claim": "true", "id.token.claim": "false", "introspection.token.claim": "true" } } ``` ## Why it is needed The auth sidecar in `mcp` mode verifies bearer JWTs with `audience = AUTH_MCP_AUDIENCE`, defaulting to `AUTH_MCP_RESOURCE` (`internal/config/config.go`, `internal/auth/mcp.go` in `Forte/auth-sidecar`). For forte-drop-mcp that is `https://mcp.drop.forteapps.net/mcp`. forte-drop [#67](https://git.forteapps.net/Forte/forte-drop/pulls/67) makes the skill send the RFC 8707 `resource=` parameter on the device-authorization, token and refresh requests. **Keycloak 26.3.3 (the version Bitnami chart 25.2.0 deploys) ignores it**: tested locally, the device-code token comes back with `aud: "account"` only, and the sidecar rejects it with `oidc: expected audience "…/mcp" got ["account"]`. This mapper is what puts the MCP resource into `aud`. ## Trade-off The mapper is unconditional: every `forte-cli` access token carries the forte-drop-mcp audience, whichever service the user logged in for. That is fine while forte-drop is the only consumer. When more sidecar-fronted services adopt `forte-cli` (mcp10x, ts-mcp …), a per-service **optional client scope** that carries the audience mapper, requested explicitly via `<PREFIX>_SCOPE`, keeps tokens least-privilege and should replace this. ## Verification done - `helm template` with the Bitnami keycloak chart 25.2.0 and both value files, as in the Argo Application, renders cleanly. - Rendered `forte-realm.json` parses; compared with #26's render, the **only** difference is the `protocolMappers` array on `forte-cli`. All other clients and realm settings are byte-identical. - keycloak-config-cli runs with `IMPORT_MANAGED_PROTOCOL_MAPPER=no-delete`, the same path the existing inline clients (`gitea`, `grafana`, `argocd`) use for their mappers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jorgen.stensrud changed title from WIP: feat(keycloak): audience mapper for forte-drop-mcp on forte-cli (fallback, stacks on #26) to feat(keycloak): audience mapper for forte-drop-mcp on forte-cli (stacks on #26) 2026-10-01 10:30:39 +00:00
Author
Member

End-to-end test: this PR is needed

Ran the whole login chain locally against the same components as prod:

  • Keycloak 26.3.3 (quay.io/keycloak/keycloak:26.3.3, the version Bitnami chart 25.2.0 deploys), realm imported from the helm template render of this branch (03a1ceb): forte-cli exactly as in #26 / this PR, plus a local test user.
  • auth-sidecar built from Forte/auth-sidecar b89e6a8, AUTH_MODE=mcp, AUTH_MCP_AUDIENCE=https://mcp.drop.forteapps.net/mcp (the value prod checks).
  • forte-drop from forte-drop #67 (top of the #65 ← #66 ← #67 stack) in docker-compose.local, driven by the skill's drop.sh login (device code, approved in a browser) and drop.sh list / create.
Keycloak config Token aud Through the sidecar
#26 only "account" 401 — oidc: expected audience "…/mcp" got ["account"]
#26 + this PR ["https://mcp.drop.forteapps.net/mcp", "account"] list and create OK, drop served, no JWT errors

Keycloak 26.3.3 ignores the RFC 8707 resource= parameter the skill sends, so the audience has to come from this mapper. Without it, every user gets a 401 right after a successful login. Merge order: #26, then this PR (or both together).

## End-to-end test: this PR is needed Ran the whole login chain locally against the same components as prod: - **Keycloak 26.3.3** (`quay.io/keycloak/keycloak:26.3.3`, the version Bitnami chart 25.2.0 deploys), realm imported from the `helm template` render of this branch (`03a1ceb`): `forte-cli` exactly as in #26 / this PR, plus a local test user. - **auth-sidecar** built from `Forte/auth-sidecar` `b89e6a8`, `AUTH_MODE=mcp`, `AUTH_MCP_AUDIENCE=https://mcp.drop.forteapps.net/mcp` (the value prod checks). - **forte-drop** from forte-drop #67 (top of the #65 ← #66 ← #67 stack) in `docker-compose.local`, driven by the skill's `drop.sh login` (device code, approved in a browser) and `drop.sh list` / `create`. | Keycloak config | Token `aud` | Through the sidecar | |---|---|---| | #26 only | `"account"` | **401** — `oidc: expected audience "…/mcp" got ["account"]` | | #26 + this PR | `["https://mcp.drop.forteapps.net/mcp", "account"]` | `list` and `create` OK, drop served, no JWT errors | Keycloak 26.3.3 ignores the RFC 8707 `resource=` parameter the skill sends, so the audience has to come from this mapper. Without it, every user gets a 401 right after a successful login. Merge order: #26, then this PR (or both together).
danijel.simeunovic approved these changes 2026-10-01 10:37:03 +00:00
Dismissed
danijel.simeunovic added 2 commits 2026-10-01 10:37:15 +00:00
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
feat(keycloak): audience mapper for forte-drop-mcp on forte-cli (fallback)
AI Code Review / ai-review (pull_request) Skipped
scan.yaml / test (pull_request) Successful in 6s
95db80fb53
Adds an oidc-audience-mapper to the forte-cli client so its access
tokens carry aud=https://mcp.drop.forteapps.net/mcp, the audience the
forte-drop-mcp auth sidecar verifies. Fallback for the case where
Keycloak ignores the RFC 8707 resource= parameter the skill sends.
Stacks on #26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
danijel.simeunovic force-pushed fm/launchpad-forte-cli-audience from 03a1cebf17 to 95db80fb53 2026-10-01 10:37:15 +00:00 Compare
danijel.simeunovic dismissed danijel.simeunovic's review 2026-10-01 10:37:15 +00:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

danijel.simeunovic approved these changes 2026-10-01 11:09:50 +00:00
jorgen.stensrud merged commit 4a4b8e3540 into main 2026-10-01 11:25:35 +00:00
jorgen.stensrud deleted branch fm/launchpad-forte-cli-audience 2026-10-01 11:25:35 +00:00
Sign in to join this conversation.