Skip to the content.

Agent authorization (with User Consent)

In this demo, we’ll explore how Agent Identity Authorization works when user consent is required. This builds on the autonomous authorization flow but adds a critical dimension: user delegation. When an agent needs to act on behalf of a user, the authorization server (Keycloak) ensures the user explicitly grants permission.

When user consent is needed, the auth server does not return an auth_token on the first signed POST to the token endpoint. Instead it responds with 202 Accepted, a Location header pointing at a pending URL, and an AAuth: require=interaction; code="..." header so the user can complete consent out-of-band. The agent (here, the backend) polls that pending URL with signed GET requests (often with Prefer: wait) until the server responds with 200 and an auth_token. There is no separate opaque “request token” the browser exchanges for the auth token, the pending URL and polling carry that role. The same pattern is walked through in Agent authorization with user consent.

← Back to index

Watch the demo

First, we need to tell Keycloak which scopes require user consent. With Keycloak running, execute the consent configuration script:

./keycloak/set_aauth_consent_attributes.sh 

==============================================
  AAuth Consent Attributes Configuration
==============================================

Keycloak: http://localhost:8080
Realm:    aauth-test

Connecting to Keycloak...

What would you like to do?
  1) Scopes only   - configure exact scope names (e.g. openid, profile, email)
  2) Prefixes only - configure scope prefixes (e.g. user., profile., email.)
  3) Both scopes and prefixes
  4) Use defaults for both
  5) View current scopes/prefixes
  6) Clear scopes and prefixes (set to empty)
  7) Quit (no changes)

Choice [1-7]: 

Choose 1 for scopes only, then configure supply-chain:optimize as a scope requiring user consent:

Choice [1-7]: 1

--- Configure scopes ---
  Examples: openid, profile, email
  Default:  ["openid","profile","email"]

Enter scopes (comma-separated, or press Enter for default): supply-chain:optimize

Summary:
  Scopes:   [  "supply-chain:optimize"]
  Prefixes: ["user.","profile.","email."]

Apply these to Keycloak? [Y/n]: 

Hit ENTER to continue and you should see keycloak updated:

Applying to Keycloak...
✅ Successfully set AAuth consent attributes for realm 'aauth-test'!

Configured values:
  aauth.consent.required.scopes:         ["supply-chain:optimize"]
  aauth.consent.required.scope.prefixes: ["user.","profile.","email."]

Non-interactive usage:
  export AAUTH_CONSENT_SCOPES='["openid","profile","email"]'
  export AAUTH_CONSENT_PREFIXES='["user.","profile.","email."]'
  ./keycloak/set_aauth_consent_attributes.sh http://localhost:8080 aauth-test admin admin

Start the infrastructure with the user-consent config:

./scripts/start-infra.sh user-consent

This uses aauth-config-user-consent.yaml for the aauth-service, which configures Keycloak scopes so that supply-chain:optimize requires user consent before an auth token is issued. All agents bootstrap their aa-agent+jwt from the Person Server automatically on startup.

Navigate to the UI and click “Optimize Laptop Supply Chain”. The flow now includes the deferred consent path: the token endpoint may return 202 instead of issuing an auth_token immediately.

sequenceDiagram
  participant UI as UI
  participant BE as Backend
  participant KC as Keycloak
  participant SCA as "Supply-Chain Agent"

  UI->>BE: 1. User clicks Optimize Laptop Supply Chain
  BE->>SCA: 2. POST signed, no auth_token yet
  SCA-->>BE: 3. 401 + AAuth require=auth-token + resource-token
  BE->>KC: 4. POST token + resource_token, Prefer wait
  KC-->>BE: 5. 202 + Location pending URL + interaction code
  BE-->>UI: 6. interaction_endpoint, code, request_id, callback_url
  Note over BE: Backend polls pending URL with signed GET
  UI->>KC: 7. User opens interaction URL and completes consent
  BE->>KC: 8. Poll returns 200 + auth_token after consent
  BE->>SCA: 9. Retry with auth_token, scheme=jwt
  SCA-->>BE: 10. Success

The UI response is a convenience for this demo so the browser can open Keycloak’s consent page. The authoritative protocol step is polling the pending URL from the 202 response; the backend starts that polling when it returns interaction_required to the frontend (and the optional callback URL is only a UX hint to return to the app).

Step By Step

1. Initial Request Fails (401)

When the backend calls the supply-chain-agent, it receives a 401 with a resource token (same as autonomous flow):

INFO:aauth_interceptor:🔐 AAuth: Signing with agent token (aa-agent+jwt in Signature-Key)
INFO:aauth.tokens:🔐 401 from supply-chain-agent (url=http://supply-chain-agent.localhost:3000): headers={'date': 'Sat, 07 Feb 2026 16:56:35 GMT', 'server': 'uvicorn', 'aauth': 'require=auth-token; resource-token="eyJhbGciOiJFZERTQSIsImtpZCI6InN1cHBseS1jaGFpbi1hZ2VudC1lcGhlbWVyYWwtMSIsInR5cCI6InJlc291cmNlK2p3dCJ9.eyJpc3MiOiJodHRwOi8vc3VwcGx5LWNoYWluLWFnZW50LmxvY2FsaG9zdDozMDAwIiwiYXVkIjoiaHR0cDovLzEyNy4wLjAuMTo4NzY1IiwiYWdlbnQiOiJodHRwOi8vYmFja2VuZC5sb2NhbGhvc3Q6ODAwMCIsImFnZW50X2prdCI6IlVDaWE5dEpNV3lEMWZPMGlhV1YxV2NzQmRaQzIwb0E5MVZYLS1VY2NXM0UiLCJleHAiOjE3NzA0ODM2OTYsInNjb3BlIjoic3VwcGx5LWNoYWluOm9wdGltaXplIG1hcmtldC1hbmFseXNpczphbmFseXplIn0.jHesCOn3qIXke_aAe3VrIzS7RbLhW9_rMRfLNqVeMDC9YZl16a1RvOEELHiy0wXA-Cy7y3CUzW7t5N_FbxgiCA"; auth-server="http://127.0.0.1:8765"', 'content-length': '22', 'content-type': 'text/plain; charset=utf-8'}

2. Keycloak returns 202 (interaction required)

The backend exchanges the resource token at Keycloak’s AAuth token endpoint (signed POST with resource_token, typically with Prefer: wait=…). When policy requires user consent, the auth server does not return auth_token in the first response. It returns a deferred response (see also flow-04-user):

The backend maps that to an API payload for the UI (interaction_required with interaction_endpoint, interaction_code, request_id, callback_url) and begins polling the pending URL with signed GET requests until Keycloak responds with 200 and an auth_token.

Illustrative HTTP shape (same idea as the auth-server response in flow-04-user; the exact Location path depends on your auth server):

HTTP/1.1 202 Accepted
Location: http://localhost:8080/realms/aauth-test/pending/2e44214dc421
AAuth: require=interaction; code="7R6XKPRP"
Content-Type: application/json

{"status":"pending","location":"http://localhost:8080/realms/aauth-test/pending/2e44214dc421","require":"interaction","code":"7R6XKPRP"}

You should see aauth.tokens poll lines in the backend logs (exact wording varies by attempt), for example:

INFO:aauth.tokens:AAuth poll started: pending_url=http://localhost:8080/realms/aauth-test/... max_attempts=120 Prefer_wait=45 min_interval=3.0
INFO:aauth.tokens:AAuth poll GET attempt 1/120 url=http://localhost:8080/realms/aauth-test/...
INFO:aauth.tokens:AAuth poll response: status=202 attempt=1/120
INFO:aauth.tokens:AAuth poll 202: body_status=pending require=interaction retry_after=0 clarification=False is_awaiting=False

After the user completes consent, a later poll returns 200 and AAuth poll complete: auth_token received.

The UI presents the consent screen to the user:

The user sees:

The browser reaches this screen via the interaction endpoint and interaction code returned to the UI (step 2 above). AAuth expects the user to be sent to the auth server’s interaction URL with that code while the agent keeps polling the pending URL.

4. Authorization token with user identity

After the pending poll completes, the backend holds a valid auth_token and retries the call to the supply-chain-agent using scheme=jwt in the Signature-Key header. The supply-chain-agent receives:

INFO:agent_executor:✅ AAuth signature verification successful
INFO:aauth.tokens:🔐 Received auth_token in request (HTTPSig scheme=jwt): eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiYXV0aCtqd3QiLCJraWQiIDogIjF2SGZlTWk5U0E4VTdWZlNKRTN3SnVTQklOZUhVeWpOY0pzZ2tYWWNHQlkifQ.eyJleHAiOjE3NzA0ODM3NjUsImlhdCI6MTc3MDQ4MzQ2NSwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9hYXV0aC10ZXN0IiwiYXVkIjoiaHR0cDovL3N1cHBseS1jaGFpbi1hZ2VudC5sb2NhbGhvc3Q6MzAwMCIsInN1YiI6IjAwYjUxOWU4LWY0MDktNDIwMS04OTExLTFjYjQwOGU4YTA4MiIsImFnZW50IjoiaHR0cDovL2JhY2tlbmQubG9jYWxob3N0OjgwMDAiLCJjbmYiOnsiandrIjp7ImtpZCI6IkdmWHZZS3ZscktGb3V2S1JQdUFnbFJZX3RiTmJJTFpMRzJBaTJNd2l6bVUiLCJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJjcnYiOiJFZDI1NTE5IiwieCI6IklDSEVXYUwyRTBFaGJkU3F4eGZ5ZUY4RjF5WndiNEViQzJKQXZ0dl9ZR3cifX0sInNjb3BlIjoic3VwcGx5LWNoYWluOm9wdGltaXplIG1hcmtldC1hbmFseXNpczphbmFseXplIn0.NAsgPE4DHrS36m98J76QbsZHWj9EIYJzVg1mqa5IJv6xp6lRbsqYO7jnqodh5QV86yhrLpBWpVzyrSYm1LU9DJnh8VegIR9JWUwO-c9j4xdFIQLdDIAzCMalCUTWnC2E2dwZA1raaw3kxxJ1eKkIOWQqWRp-oeubHdtoqI2yJHbVZNs1VQ7YeajGygyEHFG3W7F1eWpt8TChF8sy5gvqvk5DPiHXRykyxpghK-klq4hzQACIAXoFhtBUo8zqYFtF_gtSkQPcs_CdNhjp5ksr-ZkyqpXQjhBmajaARNGoVxUtdAtVOyoz4wFSFwBTTYpFg-f4IrkjA-kwCE-_71UZ5A
INFO:agent_executor:🔐 Auth token detected in request (scheme=jwt)
INFO:httpx:HTTP Request: GET http://localhost:8080/realms/aauth-test/protocol/openid-connect/certs "HTTP/1.1 200 OK"
INFO:agent_executor:✅ Auth token verified successfully
INFO:agent_executor:✅ Authorization successful: auth_token verified for agent: http://backend.localhost:8000

If we decode the token (aa-auth+jwt, AAuth spec §9.4.1):

{
  "exp": 1770483765,
  "iat": 1770483465,
  "iss": "http://127.0.0.1:8765",
  "aud": "http://supply-chain-agent.localhost:3000",
  "sub": "00b519e8-f409-4201-8911-1cb408e8a082",
  "agent": "http://backend.localhost:8000",
  "cnf": {
    "jwk": {
      "kid": "GfXvYKvlrKFouvKRPuAglRY_tbNbILZLG2Ai2MwizmU",
      "kty": "OKP",
      "use": "sig",
      "crv": "Ed25519",
      "x": "ICHEWaL2E0EhbdSqxxfyeF8F1yZwb4EbC2JAvtv_YGw"
    }
  },
  "scope": "supply-chain:optimize market-analysis:analyze"
}

Note: The iss is the Person Server (http://127.0.0.1:8765), not Keycloak. The Person Server is the entity that issued the agents’ aa-agent+jwt tokens AND issues aa-auth+jwt auth tokens after consent is approved. Keycloak is still used for OIDC authentication of the human user (mcp-user), but the AAuth token chain flows through the Person Server.

Compare to Autonomous Scheme

Compare this to the autonomous auth flow token:

Claim Autonomous User-Delegated Meaning
sub ❌ Not present "00b519e8..." User identity - who authorized this action
agent "http://backend..." "http://backend..." Agent identity - which agent is acting
cnf ✅ Bound to agent’s key ✅ Bound to agent’s key Proof-of-possession - only this agent can use the token

How This Relates to OIDC

If you’re familiar with OAuth 2.0 and OpenID Connect, this pattern should feel familiar:

The key innovation of AAuth is dual identity:

This enables fine-grained audit trails: “User Alice authorized Backend Agent to optimize the supply chain at 4:56 PM on February 7th, 2026.”

Summary

Use autonomous mode when: Agents are acting on their own authority (background jobs, system tasks, agent-to-agent coordination).

Use user-delegated mode when: Agents must act on behalf of a specific user (accessing user data, making decisions with user accountability, compliance requirements).


The user consent flow is exercised by the user-consent test suite. Because consent traditionally requires a human to click a browser page, the test harness automates this step via the Person Server REST API — the same API the browser UI calls under the hood.

# Requires Keycloak and Person Server already running
./scripts/start-infra.sh user-consent
./scripts/run-tests.sh user-consent
./scripts/stop-infra.sh

When the backend returns status: interaction_required, the test extracts the interaction_code and approves it directly through the Person Server:

# 1. Start optimization
request_id = requests.post(f"{backend_url}/optimization/start", ...).json()["request_id"]

# 2. Poll until interaction_required
while True:
    progress = requests.get(f"{backend_url}/optimization/progress/{request_id}", ...).json()
    if progress["status"] == "interaction_required":
        consent_code = progress["interaction_code"]
        break

# 3. Look up the pending consent context
pending_id = requests.get(f"{person_server_url}/consent?code={consent_code}").json()["pending_id"]

# 4. Approve via REST API (replaces clicking "Approve" in the browser)
requests.post(f"{person_server_url}/consent/{pending_id}/decision", json={"approved": True})

# 5. Poll until completion
while True:
    progress = requests.get(f"{backend_url}/optimization/progress/{request_id}", ...).json()
    if progress["status"] == "completed":
        break

This is exactly what the browser UI does when the user clicks “Approve” on the Keycloak consent screen — the Person Server acts as the intermediary that bridges the browser’s redirect back to the polling backend.

What the Tests Verify

tests/integration/test_user_consent_flow.py contains four tests:

Test What it checks
test_user_consent_full_flow Full flow: detect interaction_required → approve → completed
test_user_consent_denial Deny consent → request enters failed/pending state
test_market_analysis_with_consent Market analysis that triggers SCA→MAA + consent; approves inline during poll loop
test_consent_timeout Verifies interaction_code and interaction_url are both present when consent is pending

test_market_analysis_with_consent demonstrates the full automated loop — whenever the poll returns interaction_required, the test approves immediately and continues polling:

while time.time() - start_time < timeout:
    progress = requests.get(f"{backend_url}/optimization/progress/{request_id}", ...).json()
    
    if progress["status"] == "interaction_required":
        consent_code = progress["interaction_code"]
        pending_id = requests.get(f"{person_server_url}/consent?code={consent_code}").json()["pending_id"]
        requests.post(f"{person_server_url}/consent/{pending_id}/decision", json={"approved": True})
        continue   # resume polling

    if progress["status"] == "completed":
        break

The sub Claim — Proof That User Identity Propagates

After consent is approved, the backend polls the Keycloak pending URL and receives an auth_token that now contains a sub claim. The tests confirm the full flow completes — but you can verify the user identity claim is present by decoding the token logged by the supply-chain-agent:

{
  "iss": "http://localhost:8080/realms/aauth-test",
  "aud": "http://supply-chain-agent.localhost:3000",
  "sub": "00b519e8-f409-4201-8911-1cb408e8a082",    user identity
  "agent": "http://backend.localhost:8000",
  "cnf": { "jwk": { ... } },
  "scope": "supply-chain:optimize"
}

The sub is the Keycloak user ID for mcp-user. This claim does not appear in Mode 3 autonomous tokens — its presence is what distinguishes user-delegated authorization from autonomous authorization, and is exactly what the Compare to Autonomous Scheme table above illustrates.

In the next post, we’ll explore token exchange and delegation chains—how agents can delegate to other agents while preserving the user’s identity and consent. Read more: Agent Token Exchange →

← Back to index