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.
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 approach | Problem |
|---|---|
| Give the agent a powerful service account | Over-broad access; audit says "the bot did it" |
| Hand the user's own token to the agent | The 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:
browser_login()— user logs in and consents → returns anid_token- Token-exchange — SDK sends
id_tokento AuthSec → receives an ID-JAG - jwt-bearer — SDK redeems the ID-JAG → receives a scoped access token (
sub= user,act= agent) 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
| Variable | What it does | Where it comes from |
|---|---|---|
AUTHSEC_ISSUER | The 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_ID | The 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_SECRET | The 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_URL | The 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())
Check the PRM document
(/.well-known/oauth-protected-resource/mcp → scopes_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 theid_tokenfrom your own OIDC login directly toaccess_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
| Exception | Meaning | What to do |
|---|---|---|
PendingApprovalError | First contact — admin approval pending | poll_until_approved(...) |
ApprovalDeniedError | Admin declined the request | Inform the user; don't retry |
ConnectionRevokedError | Previously approved connection revoked | Re-request access or inform |
TrustedIssuerMissingError | IdP not trusted by AuthSec | Configure the identity provider in the dashboard |
CredentialInvalidError | Agent client_id/secret wrong | Re-copy from registration |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Stuck "waiting for approval" forever | Requested scopes don't exist on the target server | Use scopes from the PRM scopes_supported |
| Browser doesn't open | Headless/remote session | The SDK prints the URL — open it manually |
redirect_uri mismatch | Agent registered with a different redirect URI | Match the registration (http://localhost:8126/callback) |
| Token works, then suddenly 401s | Connection revoked or token expired | agent.clear_cache(MCP_URL) and retry; if ConnectionRevokedError, re-request |
| Agent missing from the Agents page | It has never connected | Normal — it appears after its first server connection |