Documentation
Cookbook: multi-hop delegation chain
User → orchestrator → worker → tool. Each hop narrows scope. The resource server walks the act chain to enforce policy without trusting any intermediate party.
Scenario
Alice (usr_alice) authorizes an "Inbox Triage" workflow. The orchestrator agent dispatches a worker that calls a search tool. Three agents in the chain:
usr_alice
│
└─ orchestrator (scope: inbox:read inbox:write search:query)
│
└─ worker (scope: inbox:read search:query)
│
└─ search_tool (scope: search:query, audience=https://search.api)
Step 1 — register agents (one-time)
from shark_auth import Client
c = Client("https://auth.example.com", "sk_live_admin")
orch = c.agents.register_agent(
app_id="app_triage", name="orchestrator",
scopes=["inbox:read", "inbox:write", "search:query"],
)
worker = c.agents.register_agent(
app_id="app_triage", name="worker",
scopes=["inbox:read", "search:query"],
)
search = c.agents.register_agent(
app_id="app_triage", name="search_tool",
scopes=["search:query"],
)
Step 2 — Alice authorizes the orchestrator
Authorization-code grant + PKCE. Alice clicks consent on the Shark dashboard.
from shark_auth import OAuthClient, pkce_pair
verifier, challenge, _ = pkce_pair()
authorize_url = OAuthClient.build_authorize_url(
client_id=orch["client_id"],
redirect_uri="https://triage.example/cb",
scope="inbox:read inbox:write search:query",
state="csrf-xyz",
code_challenge=challenge,
base_url="https://auth.example.com",
)
# Redirect Alice to authorize_url. Receive ?code=...&state=... at /cb.
Step 3 — orchestrator obtains user token
from shark_auth import DPoPProver
orch_prover = DPoPProver.generate()
oauth = OAuthClient("https://auth.example.com")
# In real life this is /oauth/token with DPoP header — for token-exchange flows
# the orchestrator typically receives the user's token through a different
# channel (Authorization Code grant, or session bridge). For brevity we assume
# `user_token` exists and is DPoP-bound to orch_prover.
user_token = oauth.get_token_authorization_code(
code="auth_xyz",
redirect_uri="https://triage.example/cb",
code_verifier=verifier,
client_id=orch["client_id"],
client_secret=orch["client_secret"],
)
Step 4 — orchestrator delegates to worker
The orchestrator narrows the scope and adds itself as the actor.
worker_prover = DPoPProver.generate()
worker_token = oauth.token_exchange(
subject_token=user_token.access_token, # original user
actor_token=user_token.access_token, # delegating agent
dpop_prover=worker_prover,
scope="inbox:read search:query", # narrower
)
Resulting payload:
{
"sub": "usr_alice",
"scope": "inbox:read search:query",
"cnf": { "jkt": "<worker_prover.jkt>" },
"act": {
"sub": "shark_agent_orchestrator",
"iat": 1714200000,
"scope": "inbox:read inbox:write search:query",
"cnf": { "jkt": "<orch_prover.jkt>" }
}
}
search_prover = DPoPProver.generate()
search_token = oauth.token_exchange(
subject_token=worker_token.access_token,
actor_token=worker_token.access_token,
dpop_prover=search_prover,
scope="search:query",
audience="https://search.api",
)
Now the token has act -> act — two delegation hops.
from shark_auth import DPoPHTTPClient
http = DPoPHTTPClient(base_url="https://search.api")
resp = http.get_with_dpop("/v1/search", token=search_token.access_token, prover=search_prover, params={"q": "from:vendor"})
Step 7 — resource server walks the chain
from shark_auth import AgentTokenClaims
claims = AgentTokenClaims.parse(search_token.access_token)
print(claims.sub) # usr_alice
print(claims.scope) # search:query
print(claims.is_delegated()) # True
for i, hop in enumerate(claims.delegation_chain()):
print(f"hop {i}: {hop.sub} (scope={hop.scope}, jkt={hop.jkt[:8]}…)")
// decodeAgentToken is not yet exported from @sharkauth/sdk.
// Decode the JWT payload manually until P1 lands.
const claims = JSON.parse(Buffer.from(searchToken.accessToken.split(".")[1], "base64url").toString());
console.log(claims.sub);
for (const hop of claims.act ?? []) {
console.log(hop.sub, hop.scope, hop.cnf?.jkt);
}
The resource server policy can now express things like:
"Allow search:query only when the chain includes shark_agent_worker AND the original sub is a Pro-tier user"
without trusting the worker — every hop's signature was minted by the SharkAuth issuer.
Constraints the server enforces
- No scope expansion. Each
token_exchange must request a subset of the parent's scope. invalid_scope otherwise.
- DPoP binding preserved. Each child token's
cnf.jkt matches the prover used at exchange time. The chain hops carry the per-hop cnf.jkt so the resource server can verify each hop's DPoP proof.
- Revoke any link, kill the chain. Revoke the orchestrator's token and the worker / search tokens are usable until expiry but no longer refreshable. Use
bulk_revoke_by_pattern for emergency stops.
See also