heidloff.net - Building is my Passion
Post
Cancel

Delegated Trust for Enterprise Agents via OAuth On-Behalf-Of

In enterprise applications, AI agents frequently access downstream systems on behalf of users. IBM watsonx Orchestrate supports OAuth On-Behalf-Of to propagate the user’s identity from initial login through to downstream enterprise systems.

OAuth On-Behalf-Of is defined in RFC 7523 and Microsoft OBO which is supported by Microsoft Entra ID and IBM App ID. The example below demonstrates implementation using IBM App ID.

Related resources:

Python

In watsonx Orchestrate tools can be implemented in Python running in the Orchestrate runtime or as MCP servers and tools running remotely.

Tool Context:

The following context is available in Python tools. The ‘context_sub’ variable holds the user ID, ‘obo_token’ is the token that had been exchanged by the Orchestrate runtime which can be used to authenticate requests against third-party systems.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "client_id": "trip-booking-app",
  "context_email": "user@email.com",
  "context_keys": "[\"clientID\", \"email\", \"roles\", \"sso_token\", \"sub\", \"user_id\", \"wxo_run_id\", \"wxo_tenant_id\", \"wxo_thread_id\", \"wxo_user_name\"]",
  "context_roles": "[\"trip_booker\"]",
  "context_sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "context_user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "has_obo_token": true,
  "has_sso_token": true,
  "obo_jwt": "{\"iss\": \"https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud\", \"sub\": \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\", \"email\": \"mock_user@example.com\", \"roles\": [\"trip_booker\"], \"aud\": \"https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud\", \"iat\": 1790779080, \"exp\": 1790782680, \"jti\": \"...\", \"step\": \"delegated\", \"scope\": \"enterprise:read\", \"enterprise_app\": \"mock\"}",
  "obo_token_redacted": "...",
  "sso_token_redacted": "...",
  "wxo_run_id": "...",
  "wxo_tenant_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaa",
  "wxo_thread_id": "...",
  "wxo_user_name": "wxochatxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@example.com"
}

Code:

The following section contains the code of a debug Python tool. Here is the TL;DR:

1
2
3
from ibm_watsonx_orchestrate.run import connections
obo_creds = connections.oauth2_on_behalf_of(APP_ID)
obo_token = obo_creds.access_token
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@tool(
    expected_credentials=[
        {"app_id": APP_ID, "type": ConnectionType.OAUTH_ON_BEHALF_OF_FLOW}
    ]
)
def get_user_profile_on_behalf_of(context: AgentRun) -> DebugResult:
    """
    DEBUG ONLY: dump every context variable the wxO runtime injects into a tool.

    Args:
        context (AgentRun): Injected agent-run context.

    Returns:
        DebugResult: Full context dump
    """
    
    rc = context.request_context
    sso_token = rc.get("sso_token", "")
    roles     = rc.get("roles", [])
    token_redacted = (sso_token[:8] + "..." + sso_token[-4:]) if sso_token else "(empty)"

    obo_creds = connections.oauth2_on_behalf_of(APP_ID)
    obo_token = obo_creds.access_token or "" if obo_creds else ""
    obo_token_red = (obo_token[:8] + "..." + obo_token[-4:]) if obo_token else "(empty)"
    obo_jwt_payload = json.dumps(jwt.decode(obo_token, options={"verify_signature": False})) if obo_token else "(empty)"

    return DebugResult(
        wxo_run_id      = rc.get("wxo_run_id",    "(missing)"),
        wxo_tenant_id   = rc.get("wxo_tenant_id", "(missing)"),
        wxo_thread_id   = rc.get("wxo_thread_id", "(missing)"),
        wxo_user_name   = rc.get("wxo_user_name", "(missing)"),
        context_email   = rc.get("email",         "(missing)"),
        context_roles   = json.dumps(roles),
        context_sub     = rc.get("sub",           "(missing)"),
        context_user_id = rc.get("user_id",       "(missing)"),
        has_sso_token      = bool(sso_token),
        sso_token_redacted = token_redacted,
        has_obo_token      = bool(obo_token),
        obo_token_redacted = obo_token_red,
        obo_jwt            = obo_jwt_payload,
        client_id       = rc.get("clientID",      "(missing)"),
        context_keys    = json.dumps(sorted(rc.keys())),
    )

MCP

In MCP tools, the token is retrieved differently.

Tool Context:

The following context is returned by a debug MCP tool, containing the ‘obo_token’ and the user’s ‘sub’ for the target system.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
  "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "email": "mock_user@example.com",
  "roles": ["trip_booker"],
  "has_obo_token": true,
  "obo_token_redacted": "...",
  "obo_token_jwt": {
    "iss": "https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud",
    "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "email": "mock_user@example.com",
    "roles": ["trip_booker"],
    "aud": "https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud",
    "iat": 1790779402,
    "exp": 1790783002,
    "jti": "jjjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj",
    "step": "delegated",
    "scope": "enterprise:read",
    "enterprise_app": "mock"
  },
  "token_claim_keys": [
    "aud", "email", "enterprise_app", "exp",
    "iat", "iss", "jti", "roles", "scope", "step", "sub"
  ],
  "raw_claims": {
    "iss": "https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud",
    "sub": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "email": "mock_user@example.com",
    "roles": ["trip_booker"],
    "aud": "https://mock-enterprise-step2.dddddddddddd.eu-de.codeengine.appdomain.cloud",
    "jti": "jjjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj",
    "step": "delegated",
    "scope": "enterprise:read",
    "enterprise_app": "mock"
  },
  "wxo_run_id": "(not available in MCP)",
  "wxo_tenant_id": "(not available in MCP)",
  "wxo_thread_id": "(not available in MCP)",
  "wxo_user_name": "(not available in MCP)",
  "context_user_id": "(not available in MCP)",
  "client_id": "(not available in MCP)"
}

Code:

Below is the Python implementation of the MCP tool. In summary, the on-behalf-of token is read directly from the bearer token in the request header.

1
2
auth = _get_auth_header(ctx)
obo_token = auth[7:]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def _verify_bearer(auth: str) -> dict:
    if not auth.startswith("Bearer "):
        [...]
    raw_token = auth[7:]
    jwks_uri = config.jwks_uri or os.environ.get("JWKS_URI")
    if not jwks_uri:
        [...]

    try:
        client = _fetch_jwks()
        options = {"verify_exp": True, "verify_aud": bool(config.jwt_audience)}
        decode_kwargs = dict(
            algorithms=["RS256"],
            issuer=config.jwt_issuer or None,
            audience=config.jwt_audience or None,
            options=options,
        )

        try:
            signing_key = client.get_signing_key_from_jwt(raw_token)
            signing_keys = [signing_key]
        except Exception:
            signing_keys = client.get_jwk_set().keys
        last_exc: Exception = Exception("No JWKS keys available")
        for sk in signing_keys:
            try:
                claims = pyjwt.decode(raw_token, sk.key, **decode_kwargs)
                return claims
            except (pyjwt.InvalidSignatureError, pyjwt.DecodeError) as exc:
                last_exc = exc
                continue
        [...]

@mcp.tool(structured_output=False)
async def get_user_profile_mcp_7523_obo(ctx: Context) -> str:
    """Return the authenticated user's full identity from their App ID OBO JWT.
    Uses App ID OBO tokens (oauth_auth_on_behalf_of_flow).
    Returns identity fields extracted from the decoded token."""
    
    NOT_AVAILABLE = "(not available in MCP)"
    auth = _get_auth_header(ctx)
    try:
        claims = _verify_bearer_obo(auth)
    except PermissionError as exc:
        return str(exc)

    sub   = claims.get("sub", "(missing)")
    email = claims.get("email", claims.get("preferred_username", "(missing)"))
    roles: list = [r for r in claims.get("roles", []) if isinstance(r, str)]
    group_ids: list = claims.get("groupIds", [])
    roles += [g.get("displayName", "") for g in group_ids if isinstance(g, dict)]
    roles += [g for g in claims.get("groups", []) if isinstance(g, str)]

    obo_token = auth[7:] if auth.startswith("Bearer ") else ""
    if obo_token:
        obo_token_redacted = obo_token[:8] + "..." + obo_token[-4:]
        try:
            obo_token_jwt = json.dumps(
                pyjwt.decode(obo_token, options={"verify_signature": False})
            )
        except Exception as exc:
            obo_token_jwt = f"(decode error: {exc})"
    else:
        obo_token_redacted = "(empty)"
        obo_token_jwt = "(not fetched)"

    raw_claims = {k: v for k, v in claims.items() if k not in ("exp", "iat")}
    token_claim_keys = sorted(claims.keys())

    return json.dumps({
        "sub":   sub,
        "email": email,
        "roles": roles,
        "has_obo_token":      bool(obo_token),
        "obo_token_redacted": obo_token_redacted,
        "obo_token_jwt":      obo_token_jwt,
        "token_claim_keys":   token_claim_keys,
        "raw_claims":         raw_claims,
        "wxo_run_id":      NOT_AVAILABLE,
        "wxo_tenant_id":   NOT_AVAILABLE,
        "wxo_thread_id":   NOT_AVAILABLE,
        "wxo_user_name":   NOT_AVAILABLE,
        "context_user_id": NOT_AVAILABLE,
        "client_id":       NOT_AVAILABLE,
    })

Setup

Refer to the tutorial for setup instructions.

Connection:

Below is a sample connection configuration post-creation.

Note: You cannot import this connection directly during initial setup because no authenticated user exists at the time of import. Refer to the official documentation on how to import team connections first and subsequently convert them to member connections.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
spec_version: v1
kind: connection
app_id: appid_appid
environments:
  draft:
    # Final state — overrides the key_value bootstrap from first step
    kind: oauth_auth_on_behalf_of_flow
    type: member
    sso: true
    server_url: "__OIDC_ISSUER_URL__"
    idp_config:
      header:
        content-type: application/x-www-form-urlencoded
      body:
        requested_token_use: on_behalf_of
        requested_token_type: urn:ietf:params:oauth:token-type:access_token
    app_config:
      header:
        content-type: application/x-www-form-urlencoded
  live:
    kind: oauth_auth_on_behalf_of_flow
    type: member
    sso: true
    server_url: "__OIDC_ISSUER_URL__"
    idp_config:
      header:
        content-type: application/x-www-form-urlencoded
      body:
        requested_token_use: on_behalf_of
        requested_token_type: urn:ietf:params:oauth:token-type:access_token
    app_config:
      header:
        content-type: application/x-www-form-urlencoded

Next Steps

To find out more, check out the following resources:

Featured Blog Posts
Disclaimer
The postings on this site are my own and don’t necessarily represent IBM’s positions, strategies or opinions.
Contents
Trending Tags