Skip to main content

ID-JAG delegation (Python SDK)

An AI agent that acts for a user — the user's permissions, the agent's identity, both in the token, both revocable independently.

Dashboard walkthrough

For registering the agent and approving connections with screenshots, see ID-JAG agents (dashboard guide).

Why ID-JAG exists

An M2M service acts as itself. But a copilot acts for a user: the MCP server should apply Alice's permissions and the audit log should say "Alice, via agent X".

Naive approachProblem
Give the agent a powerful service accountOver-broad access; audit says "the bot did it"
Hand the user's own token to the agentThe agent can do everything, everywhere, invisibly

ID-JAG (Identity Assertion JWT Authorization Grant / XAA) solves it: the user logs in once, the agent exchanges proof of that login for a scoped, short-lived token that carries both identities:

{
"sub": "alice@example.com",
"act": { "client_id": "agent-x" },
"scope": "my_mcp:read ...",
"aud": "https://your-mcp/mcp"
}

The user consents once, an admin approves once, either can revoke at any time.

The four-step flow

The SDK does all of this in one access_for() call:

  1. browser_login() — user logs in and consents → returns an id_token
  2. Token-exchange — SDK sends id_token to AuthSec → receives an ID-JAG
  3. jwt-bearer — SDK redeems the ID-JAG → receives a scoped access token (sub = user, act = agent)
  4. tools/call — agent calls the MCP server with the token → tool runs with user's permissions

Step 1 happens once per session; steps 2-3 are invisible; the token is cached until near expiry.

Environment

AUTHSEC_ISSUER=https://app.authsec.ai
AGENT_CLIENT_ID=<from the registration dialog>
AGENT_CLIENT_SECRET=<the one-time secret>
MCP_URL=https://your-mcp-server.example.com/mcp
VariableWhat it doesWhere it comes from
AUTHSEC_ISSUERThe OAuth issuer. The SDK uses it for token exchange, jwt-bearer grants, and as the idp_issuer for user identity verification.Always https://app.authsec.ai
AGENT_CLIENT_IDThe agent's unique ID. Identifies this agent in token requests and appears as act.client_id in the delegated token.Shown in the register-agent dialog (dashboard guide)
AGENT_CLIENT_SECRETThe agent's confidential secret. Used to authenticate the agent itself (separate from the user's identity). Shown once at registration; re-register the agent if lost.Shown once in the credentials dialog
MCP_URLThe target MCP server's Resource URI. The SDK requests a delegated token scoped to this audience. Must match the server's registered AUTHSEC_RESOURCE_URI exactly.The Resource URI from registration

Full program

import asyncio, os
from dotenv import load_dotenv
from authsec_sdk import (
AgentIdentity, browser_login,
PendingApprovalError, ApprovalDeniedError, poll_until_approved,
)

load_dotenv()
ISSUER = os.environ["AUTHSEC_ISSUER"]
CLIENT = os.environ["AGENT_CLIENT_ID"]
SECRET = os.environ["AGENT_CLIENT_SECRET"]
MCP_URL = os.environ["MCP_URL"]

async def main():
# 1. Log the user in (opens the browser; prints the URL as fallback).
# Web apps: skip this and pass the id_token from your own OIDC login.
id_token = await browser_login(
issuer=ISSUER, client_id=CLIENT, resource=MCP_URL,
)

# 2. The agent's own identity. One instance per process — reuse it.
agent = AgentIdentity(
issuer=ISSUER, client_id=CLIENT, client_secret=SECRET,
idp_issuer=ISSUER,
)

# 3. Scoped token, delegated from the user.
async with agent:
try:
token = await agent.access_for(
MCP_URL,
user_session={"subject_token": id_token},
requested_scopes=["my_mcp:read", "my_mcp:tools:read"],
)
except PendingApprovalError as e:
print("Waiting for admin approval in the dashboard…")
token = await poll_until_approved(
agent, MCP_URL, e.status_url,
user_session={"subject_token": id_token},
requested_scopes=["my_mcp:read", "my_mcp:tools:read"],
)
except ApprovalDeniedError:
raise SystemExit("Admin declined access.")

print("token:", token[:25], "…")
# → use as Authorization: Bearer {token} on MCP requests

asyncio.run(main())
Scopes must exist on the target server

Check the PRM document (/.well-known/oauth-protected-resource/mcpscopes_supported). Requesting a scope the server doesn't define looks exactly like "waiting for approval forever" — this is the #1 gotcha.

How browser_login() works

Opens the system browser to AuthSec's login page. The user authenticates, sees a consent screen listing the requested scopes, and clicks Allow. The SDK runs a local HTTP server on http://localhost:8126/callback to receive the redirect.

  • Web apps: skip browser_login() and pass the id_token from your own OIDC login directly to access_for().
  • Headless environments: the SDK prints the auth URL to stdout as a fallback — open it manually in any browser.

How PendingApprovalError works

On the agent's first connection to a server, the admin must approve it from the application's Connections tab. The SDK raises PendingApprovalError with a status_url you can poll:

token = await poll_until_approved(
agent, MCP_URL, e.status_url,
user_session={"subject_token": id_token},
requested_scopes=["my_mcp:read", "my_mcp:tools:read"],
)

Once approved, the approval is permanent — subsequent runs go straight through with no consent screen and no admin involvement.

Error handling reference

ExceptionMeaningWhat to do
PendingApprovalErrorFirst contact — admin approval pendingpoll_until_approved(...)
ApprovalDeniedErrorAdmin declined the requestInform the user; don't retry
ConnectionRevokedErrorPreviously approved connection revokedRe-request access or inform
TrustedIssuerMissingErrorIdP not trusted by AuthSecConfigure the identity provider in the dashboard
CredentialInvalidErrorAgent client_id/secret wrongRe-copy from registration

Troubleshooting

SymptomCauseFix
Stuck "waiting for approval" foreverRequested scopes don't exist on the target serverUse scopes from the PRM scopes_supported
Browser doesn't openHeadless/remote sessionThe SDK prints the URL — open it manually
redirect_uri mismatchAgent registered with a different redirect URIMatch the registration (http://localhost:8126/callback)
Token works, then suddenly 401sConnection revoked or token expiredagent.clear_cache(MCP_URL) and retry; if ConnectionRevokedError, re-request
Agent missing from the Agents pageIt has never connectedNormal — it appears after its first server connection