Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions a2a/iag-token-exchange/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,16 @@ DRIVE_MCP_PORT=8000
DRIVE_MCP_HOST=drive-mcp

# [Token Service] Self-hosted issuer of exchanged (delegation) tokens - see
# token-service/README.md. The gateways exchange here instead of at the IdP;
# the delegated token travels in X-IK-Token (policies read it as $ik_token).
# token-service/README.md. The A2A gateways exchange here instead of at the
# IdP (gateway >= 2.47.0 reads the incoming X-IK-Token, so the chain nests
# hop by hop); the delegated token travels in X-IK-Token (policies read it
# as $ik_token). The MCP gateways stay on the IdP exchange until the
# platform mcp-server 2.49.0 rollout (see docker-compose.yaml).
# Client credentials must match token-service.yaml idp.client_auth.
TOKEN_SERVICE_CLIENT_ID=agent-gateway
TOKEN_SERVICE_CLIENT_SECRET=
TOKEN_SERVICE_PORT=8102
# Base URL as the gateways reach it; empty reverts to IdP-side exchange.
# Base URL as the gateways reach it (defaults to http://token-service:8102
# in docker-compose.yaml; set only to override the address - to disable
# token-service mode, comment the JARVIS_TOKEN_SERVICE_* block out there).
# TOKEN_SERVICE_BASE_URL=http://token-service:8102
29 changes: 18 additions & 11 deletions a2a/iag-token-exchange/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,23 +243,26 @@ make new-analyst
```yaml
services:
iag-base:
image: indykite/agent-gateway:2.43.6 # or any newer tag from Docker Hub
image: indykite/agent-gateway:2.48.0 # or any newer tag from Docker Hub
```

All gateways inherit this tag. `2.42.x` adds the `token_service` exchange
block (delegation minted by the IndyKite Token Service, travelling in
`X-IK-Token`); `2.21.1` was the first tag with MCP proxying
(`JARVIS_PROTECTED_AGENT_PROTOCOL: mcp`), which the `mcp-iag` and
`drive-mcp-iag` services need - the published `2.0.x` tags ignore the protocol
and 404 every MCP method after the auth pipeline passes. Avoid floating tags
like `latest` so the demo behaviour is reproducible.
All gateways inherit this tag. `2.47.0` makes a gateway in token-service mode
read the incoming `X-IK-Token` (introspected at the Token Service, used as
the subject of this hop's exchange), so multi-hop A2A delegation chains grow
hop by hop - the minimum for the A2A gateways' token-service mode. `2.42.x`
added the `token_service` exchange block itself (delegation minted by the
IndyKite Token Service, travelling in `X-IK-Token`); `2.21.1` was the first
tag with MCP proxying (`JARVIS_PROTECTED_AGENT_PROTOCOL: mcp`), which the
`mcp-iag` and `drive-mcp-iag` services need - the published `2.0.x` tags
ignore the protocol and 404 every MCP method after the auth pipeline passes.
Avoid floating tags like `latest` so the demo behaviour is reproducible.

If you are on Apple Silicon, add a `platform` attribute:

```yaml
services:
iag-base:
image: indykite/agent-gateway:2.43.6
image: indykite/agent-gateway:2.48.0
platform: linux/amd64
```

Expand All @@ -276,8 +279,12 @@ for different releases.

### Token Service setup

The Token Service issues the exchanged (delegation) tokens for the MCP
gateways. Full build/config/run details live in
The Token Service issues the exchanged (delegation) tokens for the A2A
gateways (the MCP gateways join once the platform's mcp-server `2.49.0` -
which validates the `X-IK-Token` delegated token and accepts any Token
Introspect config of the app space - is rolled out to the target
environment; see the notes in `docker-compose.yaml`). Full
build/config/run details live in
[`token-service/README.md`](token-service/README.md); in short:

1. **Build the image** from the jarvis repository
Expand Down
12 changes: 8 additions & 4 deletions a2a/iag-token-exchange/analyst_agent/analyst_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,16 +1051,20 @@ def _get_ik_token_from_context(context: RequestContext | None) -> str:
_background_tasks: set = set()


def _report_exchanged_token(token: str) -> None:
def _report_exchanged_token(token: str, delegation: str = "") -> None:
"""Post the exchanged bearer token to the console's audit terminal (fire-and-forget).

The gateways' audit events carry the decision and actors chain but not the
minted delegation token itself, so each agent reports the token it
received; the console renders it as a TOKEN card. Failures never affect
the request.
received; the console renders it as a TOKEN card. In token-service mode
the delegation travels in X-IK-Token beside the user's own bearer, so the
caller passes it too and the card shows the chained token; without one
(classic mode) the Authorization bearer already carries the chain.
Failures never affect the request.
"""
if not CHATBOT_UPDATES_URL:
return
token = delegation or token
subject = actor = "?"
try:
payload = token.split(".")[1]
Expand Down Expand Up @@ -1672,7 +1676,7 @@ async def execute( # noqa: D102
# surfaced in the console audit terminal to show the exchange chain;
# logs carry only a redacted fingerprint to avoid credential leaks.
_logger.info("Exchanged bearer token (redacted): %s...%s", access_token[:6], access_token[-6:])
_report_exchanged_token(access_token)
_report_exchanged_token(access_token, _current_ik_token.get())

await _process_analyst_request(context, event_queue, access_token)

Expand Down
7 changes: 6 additions & 1 deletion a2a/iag-token-exchange/chatbot/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -361,9 +361,14 @@
actor.textContent = event.actor || '?';
const badge = document.createElement('span');
badge.className = 'audit-badge';
// Token-service audits carry their own decision vocabulary (RFC 7662
// introspections, exchange refusals) - label each precisely instead
// of defaulting every unknown decision to NOT AUTHORIZED.
badge.textContent = decision === 'authorized' ? 'AUTHORIZED'
: decision === 'token_exchanged' ? 'TOKEN'
: 'NOT AUTHORIZED';
: decision === 'introspected' ? 'INTROSPECTED'
: decision === 'not_authorized' ? 'NOT AUTHORIZED'
: decision.toUpperCase().replaceAll('_', ' ');
Comment thread
cowan-macady marked this conversation as resolved.
flow.append(subject, arrow, actor, badge);

card.append(head, flow);
Expand Down
18 changes: 18 additions & 0 deletions a2a/iag-token-exchange/chatbot/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ body {
.audit-card.authorized { border-left-color: #4ade80; }
.audit-card.not_authorized { border-left-color: #f87171; }
.audit-card.token_exchanged { border-left-color: var(--accent); }
/* Informational token-service decisions (successful introspections). */
.audit-card.introspected { border-left-color: #60a5fa; }
/* Token-service refusals/errors keep the failure red. */
.audit-card.introspected_inactive,
.audit-card.introspection_refused,
.audit-card.exchange_refused,
.audit-card.error { border-left-color: #f87171; }

.audit-card-head {
display: flex;
Expand Down Expand Up @@ -170,6 +177,17 @@ body {
color: var(--accent);
background: rgba(255, 107, 53, 0.12);
}
.audit-card.introspected .audit-badge {
color: #60a5fa;
background: rgba(96, 165, 250, 0.12);
}
.audit-card.introspected_inactive .audit-badge,
.audit-card.introspection_refused .audit-badge,
.audit-card.exchange_refused .audit-badge,
.audit-card.error .audit-badge {
color: #f87171;
background: rgba(248, 113, 113, 0.12);
}

.audit-reason { margin-top: 6px; }
.audit-reason summary {
Expand Down
72 changes: 60 additions & 12 deletions a2a/iag-token-exchange/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ services:
# Authorization header (policies read it as $ik_token). See token-service/.
token-service:
image: token-service:local
# The jarvis base image bakes a HEALTHCHECK on :9080/healthz, which only
# the gateway binary serves - the token-service binary does not register
# the healthcheck module, so probe the real endpoint instead.
healthcheck:
test: ["CMD", "/bin/http_health_probe", "http://localhost:8102/.well-known/openid-configuration"]
interval: 10s
timeout: 1s
start_period: 10s
ports:
- "${TOKEN_SERVICE_PORT:-8102}:8102"
networks:
Expand Down Expand Up @@ -82,6 +90,16 @@ services:
JARVIS_PROTECTED_AGENT_BASE_URL: http://${ORCHESTRATOR_HOST}:${ORCHESTRATOR_PORT}
JARVIS_PROTECTED_AGENT_AUTHENTICATION_CLIENT_ID: ${ORCHESTRATOR_IDP_CLIENT_ID}
JARVIS_PROTECTED_AGENT_AUTHENTICATION_CLIENT_SECRET: ${ORCHESTRATOR_IDP_CLIENT_SECRET}
# Token Service mode (gateway >= 2.47.0): delegation minted at the
# self-hosted Token Service; the user's token stays in Authorization,
# the chain travels in X-IK-Token and nests hop by hop. Comment the
# block out to fall back to the classic IdP exchange.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
volumes:
- ./audit-config.yaml:/app/.configs/audit-config.yaml
command: ["--config=/app/.configs/audit-config.yaml"]
Expand Down Expand Up @@ -138,6 +156,13 @@ services:
JARVIS_PROTECTED_AGENT_BASE_URL: http://${RETRIEVER_HOST}:${RETRIEVER_PORT}
JARVIS_PROTECTED_AGENT_AUTHENTICATION_CLIENT_ID: ${RETRIEVER_IDP_CLIENT_ID}
JARVIS_PROTECTED_AGENT_AUTHENTICATION_CLIENT_SECRET: ${RETRIEVER_IDP_CLIENT_SECRET}
# Token Service mode (gateway >= 2.47.0) - see orchestrator-iag.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
volumes:
- ./audit-config.yaml:/app/.configs/audit-config.yaml
command: ["--config=/app/.configs/audit-config.yaml"]
Expand Down Expand Up @@ -194,6 +219,13 @@ services:
# The weather agent runs wf2 (weather -> mcp), not the base ${WORKFLOW_ID}
# (wf1). allowed_workflow_id is a single value per gateway.
JARVIS_CONTX_IQ_ALLOWED_WORKFLOW_ID: ${WEATHER_WORKFLOW_ID}
# Token Service mode (gateway >= 2.47.0) - see orchestrator-iag.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
volumes:
- ./audit-config.yaml:/app/.configs/audit-config.yaml
command: ["--config=/app/.configs/audit-config.yaml"]
Expand All @@ -217,6 +249,13 @@ services:
# The analyst runs its own workflow (wf3: millicent -> analyst -> mcp), not the
# base ${WORKFLOW_ID} (wf1). allowed_workflow_id is a single value per gateway.
JARVIS_CONTX_IQ_ALLOWED_WORKFLOW_ID: ${ANALYST_WORKFLOW_ID}
# Token Service mode (gateway >= 2.47.0) - see orchestrator-iag.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
volumes:
- ./audit-config.yaml:/app/.configs/audit-config.yaml
command: ["--config=/app/.configs/audit-config.yaml"]
Expand Down Expand Up @@ -276,12 +315,20 @@ services:
- mcp-iag-network
environment:
K_SERVICE: mcp-iag
# Token Service mode is OFF for now: the platform's MCP endpoint gates
# Authorization on a single bound issuer+audience shape, which requires
# the classic final IdP exchange (aud = this gateway's client). Set the
# JARVIS_TOKEN_SERVICE_* block here once the platform supports the
# per-path audiences (see token-service/README.md and the findings
# report).
# Token Service mode is ON (verified 2026-08-28: RC runs mcp-server
# 2.49.0 - a garbage X-IK-Token gets 400 invalid_request naming the
# header). The platform no longer gates Authorization on the single
# bound issuer+audience shape (any Token Introspect config of the app
# space authenticates) and validates the X-IK-Token delegation itself.
# This block is REQUIRED while the A2A gateways run token-service
# mode: their chain travels in X-IK-Token, which a classic-mode
# gateway ignores - mixed modes on one path break the chain.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
JARVIS_SERVICE_NAME: mcp-iag
JARVIS_SERVICE_PORT: ${IAG_MCP_PORT}
# Switch the gateway out of the default "a2a" proxy mode into MCP proxy mode.
Expand Down Expand Up @@ -349,12 +396,13 @@ services:
- drive-mcp-iag-network
environment:
K_SERVICE: drive-mcp-iag
# Token Service mode is OFF for now: the platform's MCP endpoint gates
# Authorization on a single bound issuer+audience shape, which requires
# the classic final IdP exchange (aud = this gateway's client). Set the
# JARVIS_TOKEN_SERVICE_* block here once the platform supports the
# per-path audiences (see token-service/README.md and the findings
# report).
# Token Service mode is ON - same block and reasoning as mcp-iag.
JARVIS_TOKEN_SERVICE_BASE_URL: ${TOKEN_SERVICE_BASE_URL:-http://token-service:8102}
JARVIS_TOKEN_SERVICE_EXCHANGE_ENDPOINT: "oauth2/token"
JARVIS_TOKEN_SERVICE_INTROSPECT_ENDPOINT: "oauth2/introspect"
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_TYPE: client_secret_basic
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_ID: ${TOKEN_SERVICE_CLIENT_ID:-agent-gateway}
JARVIS_TOKEN_SERVICE_CLIENT_AUTH_CLIENT_SECRET: ${TOKEN_SERVICE_CLIENT_SECRET}
JARVIS_SERVICE_NAME: drive-mcp-iag
JARVIS_SERVICE_PORT: ${IAG_DRIVE_MCP_PORT:-8887}
# MCP proxy mode, same as mcp-iag.
Expand Down
34 changes: 22 additions & 12 deletions a2a/iag-token-exchange/iag-base-docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ version: '3.9'

services:
iag-base:
# Pinned: 2.42.x adds the token_service block (delegation minted by the
# IndyKite Token Service; delegated token travels in X-IK-Token, policies
# read it as $ik_token). 2.21.1 was the previous pin (first with MCP
# proxying). All gateways share this tag.
# Pinned: 2.47.0+ reads the incoming X-IK-Token in token-service mode
# (introspected at the Token Service, subject of this hop's exchange), so
# multi-hop A2A delegation chains grow hop by hop. 2.42.x added the
# token_service block (delegation minted by the IndyKite Token Service;
# travels in X-IK-Token, policies read it as $ik_token); 2.21.1 was the
# first tag with MCP proxying. All gateways share this tag.
# See https://hub.docker.com/r/indykite/agent-gateway/tags.
image: indykite/agent-gateway:2.43.6
image: indykite/agent-gateway:2.48.0
# The image bakes a HEALTHCHECK on :9080/healthz, but the 2.48.0 gateway
# binary registers no healthcheck server and every service-port route
# needs auth (the probe insists on a 200), so every gateway would sit
# "unhealthy" forever. Disable it - a plain "Up" is the honest status.
healthcheck:
disable: true
environment:
JARVIS_SERVICE_LOG_LEVEL: debug
JARVIS_SERVICE_ENVIRONMENT: demo
Expand All @@ -17,13 +25,15 @@ services:
JARVIS_IDENTITY_PROVIDER_CLIENT_CREDENTIAL_ENDPOINT: "oauth-token"
JARVIS_IDENTITY_PROVIDER_EXCHANGE_ENDPOINT: "oauth-token"
# NOTE on the Token Service (JARVIS_TOKEN_SERVICE_*): it is enabled per
# gateway in docker-compose.yaml, NOT here. On 2.42.0 a gateway in
# token-service mode keeps the user's raw token in Authorization and
# ignores incoming X-IK-Token when building the actors chain, so
# multi-hop A2A chains cannot grow. The A2A gateways therefore keep the
# classic IdP exchange (chain travels in Authorization), and only the
# MCP gateways - the last hop before the platform - mint their
# delegation at the Token Service.
# gateway in docker-compose.yaml, NOT here. Since 2.47.0 a gateway in
# token-service mode reads the incoming X-IK-Token (introspects it at
# the Token Service, requires its sub to match the access token) and
# exchanges IT, so the act chain nests hop by hop - the A2A gateways
# now mint their delegation at the Token Service (user token stays in
# Authorization, chain travels in X-IK-Token). The MCP gateways keep
# the classic IdP exchange until the platform MCP server that validates
# delegated tokens (mcp-server 2.49.0) is rolled out to the target
# environment - see the notes on mcp-iag / drive-mcp-iag.
JARVIS_CONTX_IQ_BASE_URL: ${INDYKITE_BASE_URL}/contx-iq/v1
JARVIS_CONTX_IQ_QUERY_ID: ${CIQ_QUERY_ID}
JARVIS_CONTX_IQ_APP_AGENT_CREDENTIALS_TOKEN: ${APP_AGENT_CREDENTIALS_TOKEN}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,16 +184,20 @@ def _get_ik_token_from_context(context: "RequestContext | None") -> str:
_background_tasks: set = set()


def _report_exchanged_token(token: str) -> None:
def _report_exchanged_token(token: str, delegation: str = "") -> None:
"""Post the exchanged bearer token to the console's audit terminal (fire-and-forget).

The gateways' audit events carry the decision and actors chain but not the
minted delegation token itself, so each agent reports the token it
received; the console renders it as a TOKEN card. Failures never affect
the request.
received; the console renders it as a TOKEN card. In token-service mode
the delegation travels in X-IK-Token beside the user's own bearer, so the
caller passes it too and the card shows the chained token; without one
(classic mode) the Authorization bearer already carries the chain.
Failures never affect the request.
"""
if not CHATBOT_UPDATES_URL:
return
token = delegation or token
subject = actor = "?"
try:
payload = token.split(".")[1]
Expand Down Expand Up @@ -824,7 +828,7 @@ async def execute( # noqa: C901,D102,PLR0912 # skipcq: PY-R1000,PYL-R0201
# surfaced in the console audit terminal to show the exchange chain;
# logs carry only a redacted fingerprint to avoid credential leaks.
_logger.info("Exchanged bearer token (redacted): %s...%s", access_token[:6], access_token[-6:])
_report_exchanged_token(access_token)
_report_exchanged_token(access_token, _current_ik_token.get())

# SDK 1.0: context.message.parts is list[Part]; Part.text is the text field directly.
raw_text = ""
Expand Down
Loading