Skip to content

Commit a5c5153

Browse files
committed
Add user_identifier parameter to token exchange
1 parent 0d011da commit a5c5153

9 files changed

Lines changed: 351 additions & 12 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Example: User Identifier Token Exchange
2+
3+
This example demonstrates how to use the user_identifier parameter to retrieve
4+
grants on behalf of users without requiring the user to be present, after
5+
a one-time consent.
6+
"""
7+
8+
9+
from keycardai.oauth import AsyncClient, TokenType
10+
11+
12+
async def main():
13+
"""Exchange using user identifier for offline delegated grant access."""
14+
15+
# Example 1: Using AsyncClient with user_identifier parameter
16+
async with AsyncClient(
17+
base_url="https://zone1234.keycard.cloud",
18+
client_id="agent-app-id",
19+
client_secret="agent-app-secret"
20+
) as client:
21+
22+
# Exchange token using user identifier
23+
# The SDK automatically builds the unsigned JWT and sets the correct token type
24+
response = await client.exchange_token(
25+
user_identifier="user@example.com",
26+
resource="https://graph.microsoft.com"
27+
)
28+
29+
print(f"Access token: {response.access_token[:20]}...")
30+
print(f"Token type: {response.token_type}")
31+
print(f"Expires in: {response.expires_in}s")
32+
33+
# Example 2: Building the unsigned JWT manually (advanced usage)
34+
from keycardai.oauth.utils.jwt import build_user_identifier_token
35+
36+
user_identifier_jwt = build_user_identifier_token("user@example.com")
37+
print(f"\nManually built JWT: {user_identifier_jwt[:50]}...")
38+
39+
# This JWT can be used with TokenExchangeRequest
40+
from keycardai.oauth import TokenExchangeRequest
41+
42+
request = TokenExchangeRequest(
43+
subject_token=user_identifier_jwt,
44+
subject_token_type=TokenType.USER_IDENTIFIER,
45+
resource="https://graph.microsoft.com"
46+
)
47+
print(f"Subject token type: {request.subject_token_type}")
48+
49+
50+
if __name__ == "__main__":
51+
# Note: This example won't run without a real Keycard zone
52+
# It demonstrates the API usage pattern
53+
print("Example usage pattern for user identifier token exchange\n")
54+
print("To run this example, configure a real Keycard zone and credentials.\n")
55+
56+
# Uncomment to run:
57+
# asyncio.run(main())

packages/oauth/src/keycardai/oauth/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ async def exchange_token(
580580
client_id: str | None = None,
581581
client_assertion_type: str | None = None,
582582
client_assertion: str | None = None,
583+
user_identifier: str | None = None,
583584
) -> TokenResponse: ...
584585

585586
async def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token_exchange_args) -> TokenResponse:
@@ -1027,6 +1028,7 @@ def exchange_token(
10271028
actor_token_type: str | None = None,
10281029
timeout: float | None = None,
10291030
client_id: str | None = None,
1031+
user_identifier: str | None = None,
10301032
) -> TokenResponse: ...
10311033

10321034
def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token_exchange_args) -> TokenResponse:

packages/oauth/src/keycardai/oauth/operations/_token_exchange.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from ..http._context import HTTPContext
1212
from ..http._wire import HttpRequest, HttpResponse
1313
from ..types.models import TokenExchangeRequest, TokenResponse
14+
from ..types.oauth import TokenType
15+
from ..utils.jwt import build_user_identifier_token
1416

1517

1618
def build_token_exchange_http_request(
@@ -29,7 +31,7 @@ def build_token_exchange_http_request(
2931
payload = request.model_dump(
3032
mode="json",
3133
exclude_none=True,
32-
exclude={"timeout"}
34+
exclude={"timeout", "user_identifier"}
3335
)
3436

3537
headers = {
@@ -144,13 +146,22 @@ def exchange_token(
144146
TokenResponse with the exchanged token and metadata
145147
146148
Raises:
147-
ValueError: If required parameters are missing
149+
ValueError: If required parameters are missing or both user_identifier and subject_token are provided
148150
OAuthHttpError: If token endpoint is unreachable or returns non-200
149151
OAuthProtocolError: If response format is invalid or contains OAuth errors
150152
NetworkError: If network request fails
151153
152154
Reference: https://datatracker.ietf.org/doc/html/rfc8693#section-2.1
153155
"""
156+
# Handle user_identifier parameter
157+
if request.user_identifier is not None:
158+
if request.subject_token is not None:
159+
raise ValueError("Cannot provide both user_identifier and subject_token")
160+
161+
# Build unsigned JWT from user identifier
162+
request.subject_token = build_user_identifier_token(request.user_identifier)
163+
request.subject_token_type = TokenType.USER_IDENTIFIER
164+
154165
http_req = build_token_exchange_http_request(request, context)
155166

156167
# Execute HTTP request using transport
@@ -177,14 +188,21 @@ async def exchange_token_async(
177188
TokenResponse with the exchanged token and metadata
178189
179190
Raises:
180-
ValueError: If required parameters are missing
191+
ValueError: If required parameters are missing or both user_identifier and subject_token are provided
181192
OAuthHttpError: If token endpoint is unreachable or returns non-200
182193
OAuthProtocolError: If response format is invalid or contains OAuth errors
183194
NetworkError: If network request fails
184195
185196
Reference: https://datatracker.ietf.org/doc/html/rfc8693#section-2.1
186197
"""
187-
# Build HTTP request
198+
# Handle user_identifier parameter
199+
if request.user_identifier is not None:
200+
if request.subject_token is not None:
201+
raise ValueError("Cannot provide both user_identifier and subject_token")
202+
203+
# Build unsigned JWT from user identifier
204+
request.subject_token = build_user_identifier_token(request.user_identifier)
205+
request.subject_token_type = TokenType.USER_IDENTIFIER
188206

189207
http_req = build_token_exchange_http_request(request, context)
190208

packages/oauth/src/keycardai/oauth/types/models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ class TokenExchangeRequest(BaseModel):
4848
client_assertion_type: str | None = None
4949
client_assertion: str | None = None
5050

51+
user_identifier: str | None = Field(default=None, description="User identifier for user identifier delegated grant access. Mutually exclusive with subject_token.")
52+
5153

5254
@dataclass
5355
class TokenResponse:

packages/oauth/src/keycardai/oauth/types/oauth.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@ class TokenType(str, Enum):
135135
# RFC 9068 - JWT Profile for Access Tokens
136136
JWT = "urn:ietf:params:oauth:token-type:jwt"
137137

138+
# Keycard extension - User identifier for user identifier delegated grant access
139+
USER_IDENTIFIER = "urn:keycard:params:oauth:token-type:user-identifier"
140+
138141

139142
class TokenTypeHint(str, Enum):
140143
"""Token type hints for introspection and revocation as defined in RFCs.

packages/oauth/src/keycardai/oauth/utils/jwt.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,50 @@
4242
from ..types.models import ClientConfig
4343

4444

45+
def build_user_identifier_token(identifier: str) -> str:
46+
"""Build an unsigned JWT for user identifier token exchange.
47+
48+
Creates a JWT with header {"typ": "vnd.kc.user+jwt", "alg": "none"}
49+
and payload {"sub": identifier}, with no signature.
50+
51+
The token is intentionally unsigned. It carries no security guarantees on
52+
its own. Security relies on two other layers:
53+
54+
1. Client authentication: the calling application must authenticate via
55+
client credentials (e.g. client_secret_basic), proving it is authorized
56+
to request grants on behalf of users.
57+
2. Prior user consent: the user must have previously authenticated
58+
interactively and established a delegated grant for the requested
59+
resource.
60+
61+
The unsigned JWT is a structured container for the user identifier, keeping
62+
a consistent format across custom subject token types (see also
63+
vnd.kc.process+jwt).
64+
65+
Args:
66+
identifier: User identifier string (e.g. email, sub, oid value)
67+
68+
Returns:
69+
Base64url-encoded JWT string in format: header.payload.
70+
71+
Example:
72+
>>> token = build_user_identifier_token("user@example.com")
73+
>>> print(token) # eyJ0eXAiOiJ2bmQua2MudXNlcitqd3QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIn0.
74+
"""
75+
header = {"typ": "vnd.kc.user+jwt", "alg": "none"}
76+
payload = {"sub": identifier}
77+
78+
# Encode header and payload as base64url (no padding)
79+
header_json = json.dumps(header, separators=(",", ":"))
80+
payload_json = json.dumps(payload, separators=(",", ":"))
81+
82+
header_b64 = base64.urlsafe_b64encode(header_json.encode()).decode().rstrip("=")
83+
payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=")
84+
85+
# Return header.payload. (trailing dot, empty signature)
86+
return f"{header_b64}.{payload_b64}."
87+
88+
4589
def _split_jwt_token(jwt_token: str) -> tuple[str, str, str]:
4690
"""Split JWT token into its three parts.
4791

packages/oauth/src/keycardai/oauth/utils/pkce.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
- Enables secure OAuth flows for mobile and SPA applications
1818
"""
1919

20+
import base64
21+
import hashlib
22+
import secrets
23+
2024
from pydantic import BaseModel
2125

2226

@@ -86,8 +90,9 @@ def generate_code_verifier(length: int = 128) -> str:
8690
8791
Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
8892
"""
89-
# Implementation placeholder
90-
raise NotImplementedError("PKCE code verifier generation not yet implemented")
93+
if length < 43 or length > 128:
94+
raise ValueError("Code verifier length must be between 43 and 128 characters")
95+
return secrets.token_urlsafe(96)[:length]
9196

9297
@staticmethod
9398
def generate_code_challenge(verifier: str, method: str = "S256") -> str:
@@ -107,8 +112,13 @@ def generate_code_challenge(verifier: str, method: str = "S256") -> str:
107112
108113
Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2
109114
"""
110-
# Implementation placeholder
111-
raise NotImplementedError("PKCE code challenge generation not yet implemented")
115+
if method == PKCEMethods.S256:
116+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
117+
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
118+
elif method == PKCEMethods.PLAIN:
119+
return verifier
120+
else:
121+
raise ValueError(f"Unsupported PKCE method: {method}")
112122

113123
def generate_pkce_pair(
114124
self, method: str = "S256", verifier_length: int = 128
@@ -127,8 +137,13 @@ def generate_pkce_pair(
127137
128138
Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4
129139
"""
130-
# Implementation placeholder
131-
raise NotImplementedError("PKCE pair generation not yet implemented")
140+
verifier = self.generate_code_verifier(verifier_length)
141+
challenge = self.generate_code_challenge(verifier, method)
142+
return PKCEChallenge(
143+
code_verifier=verifier,
144+
code_challenge=challenge,
145+
code_challenge_method=method,
146+
)
132147

133148
@staticmethod
134149
def validate_pkce_pair(
@@ -148,5 +163,5 @@ def validate_pkce_pair(
148163
149164
Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.6
150165
"""
151-
# Implementation placeholder
152-
raise NotImplementedError("PKCE validation not yet implemented")
166+
expected = PKCEGenerator.generate_code_challenge(code_verifier, method)
167+
return secrets.compare_digest(expected, code_challenge)

0 commit comments

Comments
 (0)