Skip to content

Commit 2feff31

Browse files
committed
feat: support for impersonation token exchange
1 parent 0d011da commit 2feff31

File tree

21 files changed

+2069
-15
lines changed

21 files changed

+2069
-15
lines changed

packages/mcp/src/keycardai/mcp/server/auth/provider.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ def get_token_verifier(
429429
client_factory=self.client_factory,
430430
)
431431

432-
def grant(self, resources: str | list[str]):
432+
def grant(self, resources: str | list[str], user_identifier: Callable[..., str] | None = None):
433433
"""Decorator for automatic delegated token exchange.
434434
435435
This decorator automates the OAuth token exchange process for accessing
@@ -478,6 +478,18 @@ async def my_async_tool(access_ctx: AccessContext, ctx: Context, user_id: str):
478478
- Have a parameter annotated with `Context` type from MCP (e.g., `ctx: Context`)
479479
- Can be either async or sync (the decorator handles both cases automatically)
480480
481+
When ``user_identifier`` is provided, the decorator uses impersonation
482+
(substitute-user token exchange) instead of subject-token-based exchange.
483+
The callable receives only the tool's **keyword** arguments (not positional
484+
arguments) and should return the user identifier string::
485+
486+
@provider.grant(
487+
"https://graph.microsoft.com",
488+
user_identifier=lambda **kw: kw["user_email"],
489+
)
490+
async def get_calendar(access_ctx: AccessContext, ctx: Context, user_email: str):
491+
token = access_ctx.access("https://graph.microsoft.com").access_token
492+
481493
Error handling:
482494
- Sets error state in AccessContext if token exchange fails
483495
- Preserves original function signature and behavior
@@ -643,27 +655,46 @@ async def wrapper(*args, **kwargs) -> Any:
643655
_resource_list = (
644656
[resources] if isinstance(resources, str) else resources
645657
)
658+
659+
# Resolve user identifier for impersonation if callback provided
660+
_resolved_user_id: str | None = None
661+
if user_identifier is not None:
662+
try:
663+
_resolved_user_id = user_identifier(**kwargs)
664+
except Exception as e:
665+
_set_error({
666+
"message": "Failed to resolve user_identifier from tool arguments.",
667+
"raw_error": str(e),
668+
}, None, _access_ctx)
669+
return await _call_func(_is_async_func, func, *args, **kwargs)
670+
646671
_access_tokens = {}
647672
for resource in _resource_list:
648673
try:
649-
# Prepare token exchange request using application identity provider
650-
if self.application_credential:
674+
if _resolved_user_id is not None:
675+
# Impersonation path: use substitute-user token exchange
676+
_token_response = await _client.impersonate(
677+
user_identifier=_resolved_user_id,
678+
resource=resource,
679+
)
680+
elif self.application_credential:
681+
# Prepare token exchange request using application identity provider
651682
_token_exchange_request = await self.application_credential.prepare_token_exchange_request(
652683
client=_client,
653684
subject_token=_keycardai_auth_info["access_token"],
654685
resource=resource,
655686
auth_info=_keycardai_auth_info,
656687
)
688+
_token_response = await _client.exchange_token(_token_exchange_request)
657689
else:
658690
# Basic token exchange without client authentication
659691
_token_exchange_request = TokenExchangeRequest(
660692
subject_token=_keycardai_auth_info["access_token"],
661693
resource=resource,
662694
subject_token_type="urn:ietf:params:oauth:token-type:access_token",
663695
)
696+
_token_response = await _client.exchange_token(_token_exchange_request)
664697

665-
# Execute token exchange
666-
_token_response = await _client.exchange_token(_token_exchange_request)
667698
_access_tokens[resource] = _token_response
668699
except Exception as e:
669700
_error_dict: dict[str, str] = {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Keycard zone URL
2+
ZONE_URL=https://your-zone-id.keycard.cloud
3+
4+
# Landing Page app: public client (no secret), used for browser-based authorization
5+
LANDING_PAGE_CLIENT_ID=your-landing-page-client-id
6+
7+
# Background Agent app: confidential client, used for offline impersonation
8+
AGENT_CLIENT_ID=your-agent-client-id
9+
AGENT_CLIENT_SECRET=your-agent-client-secret
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.12
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Impersonation Token Exchange Example
2+
3+
Demonstrates user impersonation via OAuth 2.0 token exchange (RFC 8693) using the Keycard OAuth SDK. A background agent obtains resource-specific access tokens on behalf of a user, without the user being present.
4+
5+
## How It Works
6+
7+
A landing page collects user consent. A background agent uses that consent to get resource tokens later.
8+
9+
### Phase 1: Landing page (one-time, interactive)
10+
11+
The landing page application is a public client (PKCE, no secret).
12+
13+
1. Serves a local web page
14+
2. The user clicks "Continue with Keycard" and authenticates with their identity provider
15+
3. The user authorizes the requested resource dependencies
16+
4. Keycard stores a delegated grant for each resource
17+
18+
The same landing page can be used again when new resources need to be authorized.
19+
20+
### Phase 2: Impersonate (repeatable, offline)
21+
22+
The background agent is a confidential client (e.g. `client_secret_basic` or workload identity).
23+
24+
1. The agent authenticates and requests a token for a specific user and resource via `client.impersonate()`
25+
2. Keycard validates the delegated grant and issues a scoped, short-lived resource token
26+
27+
No browser, no user interaction. Impersonation is forbidden by default. An administrator must explicitly grant permission to specific applications.
28+
29+
## Prerequisites
30+
31+
- Python 3.10+
32+
- A Keycard zone, set up in Console:
33+
1. **Create a provider.** The user identifier is assigned at login and can be mapped to a provider claim in Console.
34+
2. **Create a resource** (e.g. `https://api.github.com`) and link it to the provider.
35+
3. **Create a "Landing Page" application**
36+
- Public credential (client_id, no secret)
37+
- Redirect URI: `http://localhost/callback`
38+
- Add the resource as a dependency
39+
4. **Create a "Background Agent" application**
40+
- Password credential (client_id + client_secret)
41+
- Add the resource as a dependency
42+
5. **Enable impersonation** by adding a policy in Console. For example, to allow the Background Agent app to impersonate a specific user:
43+
```
44+
permit (
45+
principal is Keycard::Application,
46+
action,
47+
resource
48+
)
49+
when {
50+
principal.identifier == "background-agent" &&
51+
context.impersonate == true &&
52+
context has subject &&
53+
context.subject.identifier == "user@example.com"
54+
};
55+
```
56+
57+
## Install
58+
59+
```bash
60+
uv sync
61+
```
62+
63+
## Configuration
64+
65+
Copy `.env.example` to `.env` and fill in your values:
66+
67+
```bash
68+
cp .env.example .env
69+
```
70+
71+
All parameters can also be passed as CLI flags (run `--help` on each subcommand for details).
72+
73+
74+
| Variable | Description |
75+
|---|---|
76+
| `ZONE_URL` | Keycard zone URL |
77+
| `LANDING_PAGE_CLIENT_ID` | Public client ID for the Landing Page app |
78+
| `AGENT_CLIENT_ID` | Confidential client ID for the Background Agent app |
79+
| `AGENT_CLIENT_SECRET` | Confidential client secret |
80+
81+
82+
## Usage
83+
84+
### Step 1: Landing page (interactive, one-time)
85+
86+
Starts a local web server where the user can log in and establish delegated grants. Resources are determined by the application's dependencies configured in Keycard Console.
87+
88+
```bash
89+
uv run python main.py landing-page --port 3000
90+
```
91+
92+
Open `http://localhost:3000` in a browser and click "Continue with Keycard".
93+
94+
### Step 2: Impersonate (offline, repeatable)
95+
96+
The background agent obtains a resource token for the user without any browser interaction.
97+
98+
```bash
99+
uv run python main.py impersonate \
100+
--user-identifier user@example.com \
101+
--resource https://api.github.com
102+
```
103+
104+
## Example Output
105+
106+
```
107+
$ uv run python main.py impersonate \
108+
--user-identifier user@example.com \
109+
--resource https://api.github.com
110+
Access Token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
111+
Token Type: Bearer
112+
Expires In: 3600s
113+
```
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Background agent impersonation via substitute-user token exchange.
2+
3+
Authenticates as a confidential client (client_secret_basic) and obtains a
4+
resource-specific access token on behalf of a user, without the user being
5+
present. The user must have previously established a delegated grant for the
6+
resource through the landing page.
7+
"""
8+
9+
from keycardai.oauth import Client
10+
from keycardai.oauth.exceptions import OAuthHttpError, OAuthProtocolError
11+
from keycardai.oauth.http.auth import BasicAuth
12+
from keycardai.oauth.types.models import ClientConfig
13+
14+
15+
def run_impersonate(
16+
zone_url: str,
17+
client_id: str,
18+
client_secret: str,
19+
user_identifier: str,
20+
resource: str,
21+
) -> None:
22+
"""Impersonate a user and print the resulting resource token.
23+
24+
Args:
25+
zone_url: Keycard zone URL for metadata discovery.
26+
client_id: Confidential client ID.
27+
client_secret: Confidential client secret.
28+
user_identifier: User identifier (e.g. email, oid).
29+
resource: Target resource URI.
30+
"""
31+
try:
32+
with Client(
33+
base_url=zone_url,
34+
auth=BasicAuth(client_id, client_secret),
35+
config=ClientConfig(
36+
enable_metadata_discovery=True,
37+
auto_register_client=False,
38+
),
39+
) as client:
40+
response = client.impersonate(
41+
user_identifier=user_identifier,
42+
resource=resource,
43+
)
44+
45+
print(f"Access Token: {response.access_token}")
46+
print(f"Token Type: {response.token_type}")
47+
if response.expires_in:
48+
print(f"Expires In: {response.expires_in}s")
49+
if response.scope:
50+
print(f"Scope: {' '.join(response.scope)}")
51+
52+
except OAuthProtocolError as e:
53+
desc = f" - {e.error_description}" if e.error_description else ""
54+
raise SystemExit(f"Error: OAuth error: {e.error}{desc}")
55+
except OAuthHttpError as e:
56+
raise SystemExit(f"Error: HTTP {e.status_code}: {e.response_body}")

0 commit comments

Comments
 (0)