From a5c51539c6cbd06b7c695a49c26d4824eb299f87 Mon Sep 17 00:00:00 2001 From: Benjamin Boudreau Date: Tue, 24 Mar 2026 17:43:31 -0400 Subject: [PATCH] Add user_identifier parameter to token exchange --- .../user_identifier_token_exchange.py | 57 ++++++++ packages/oauth/src/keycardai/oauth/client.py | 2 + .../oauth/operations/_token_exchange.py | 26 +++- .../oauth/src/keycardai/oauth/types/models.py | 2 + .../oauth/src/keycardai/oauth/types/oauth.py | 3 + .../oauth/src/keycardai/oauth/utils/jwt.py | 44 ++++++ .../oauth/src/keycardai/oauth/utils/pkce.py | 31 ++-- .../oauth/operations/test_token_exchange.py | 135 ++++++++++++++++++ .../tests/keycardai/oauth/utils/test_jwt.py | 63 ++++++++ 9 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 packages/oauth/examples/user_identifier_token_exchange.py diff --git a/packages/oauth/examples/user_identifier_token_exchange.py b/packages/oauth/examples/user_identifier_token_exchange.py new file mode 100644 index 0000000..4eda77b --- /dev/null +++ b/packages/oauth/examples/user_identifier_token_exchange.py @@ -0,0 +1,57 @@ +"""Example: User Identifier Token Exchange + +This example demonstrates how to use the user_identifier parameter to retrieve +grants on behalf of users without requiring the user to be present, after +a one-time consent. +""" + + +from keycardai.oauth import AsyncClient, TokenType + + +async def main(): + """Exchange using user identifier for offline delegated grant access.""" + + # Example 1: Using AsyncClient with user_identifier parameter + async with AsyncClient( + base_url="https://zone1234.keycard.cloud", + client_id="agent-app-id", + client_secret="agent-app-secret" + ) as client: + + # Exchange token using user identifier + # The SDK automatically builds the unsigned JWT and sets the correct token type + response = await client.exchange_token( + user_identifier="user@example.com", + resource="https://graph.microsoft.com" + ) + + print(f"Access token: {response.access_token[:20]}...") + print(f"Token type: {response.token_type}") + print(f"Expires in: {response.expires_in}s") + + # Example 2: Building the unsigned JWT manually (advanced usage) + from keycardai.oauth.utils.jwt import build_user_identifier_token + + user_identifier_jwt = build_user_identifier_token("user@example.com") + print(f"\nManually built JWT: {user_identifier_jwt[:50]}...") + + # This JWT can be used with TokenExchangeRequest + from keycardai.oauth import TokenExchangeRequest + + request = TokenExchangeRequest( + subject_token=user_identifier_jwt, + subject_token_type=TokenType.USER_IDENTIFIER, + resource="https://graph.microsoft.com" + ) + print(f"Subject token type: {request.subject_token_type}") + + +if __name__ == "__main__": + # Note: This example won't run without a real Keycard zone + # It demonstrates the API usage pattern + print("Example usage pattern for user identifier token exchange\n") + print("To run this example, configure a real Keycard zone and credentials.\n") + + # Uncomment to run: + # asyncio.run(main()) diff --git a/packages/oauth/src/keycardai/oauth/client.py b/packages/oauth/src/keycardai/oauth/client.py index a77bd40..1ab6c02 100644 --- a/packages/oauth/src/keycardai/oauth/client.py +++ b/packages/oauth/src/keycardai/oauth/client.py @@ -580,6 +580,7 @@ async def exchange_token( client_id: str | None = None, client_assertion_type: str | None = None, client_assertion: str | None = None, + user_identifier: str | None = None, ) -> TokenResponse: ... async def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token_exchange_args) -> TokenResponse: @@ -1027,6 +1028,7 @@ def exchange_token( actor_token_type: str | None = None, timeout: float | None = None, client_id: str | None = None, + user_identifier: str | None = None, ) -> TokenResponse: ... def exchange_token(self, request: TokenExchangeRequest | None = None, /, **token_exchange_args) -> TokenResponse: diff --git a/packages/oauth/src/keycardai/oauth/operations/_token_exchange.py b/packages/oauth/src/keycardai/oauth/operations/_token_exchange.py index 42e6e21..ba91815 100644 --- a/packages/oauth/src/keycardai/oauth/operations/_token_exchange.py +++ b/packages/oauth/src/keycardai/oauth/operations/_token_exchange.py @@ -11,6 +11,8 @@ from ..http._context import HTTPContext from ..http._wire import HttpRequest, HttpResponse from ..types.models import TokenExchangeRequest, TokenResponse +from ..types.oauth import TokenType +from ..utils.jwt import build_user_identifier_token def build_token_exchange_http_request( @@ -29,7 +31,7 @@ def build_token_exchange_http_request( payload = request.model_dump( mode="json", exclude_none=True, - exclude={"timeout"} + exclude={"timeout", "user_identifier"} ) headers = { @@ -144,13 +146,22 @@ def exchange_token( TokenResponse with the exchanged token and metadata Raises: - ValueError: If required parameters are missing + ValueError: If required parameters are missing or both user_identifier and subject_token are provided OAuthHttpError: If token endpoint is unreachable or returns non-200 OAuthProtocolError: If response format is invalid or contains OAuth errors NetworkError: If network request fails Reference: https://datatracker.ietf.org/doc/html/rfc8693#section-2.1 """ + # Handle user_identifier parameter + if request.user_identifier is not None: + if request.subject_token is not None: + raise ValueError("Cannot provide both user_identifier and subject_token") + + # Build unsigned JWT from user identifier + request.subject_token = build_user_identifier_token(request.user_identifier) + request.subject_token_type = TokenType.USER_IDENTIFIER + http_req = build_token_exchange_http_request(request, context) # Execute HTTP request using transport @@ -177,14 +188,21 @@ async def exchange_token_async( TokenResponse with the exchanged token and metadata Raises: - ValueError: If required parameters are missing + ValueError: If required parameters are missing or both user_identifier and subject_token are provided OAuthHttpError: If token endpoint is unreachable or returns non-200 OAuthProtocolError: If response format is invalid or contains OAuth errors NetworkError: If network request fails Reference: https://datatracker.ietf.org/doc/html/rfc8693#section-2.1 """ - # Build HTTP request + # Handle user_identifier parameter + if request.user_identifier is not None: + if request.subject_token is not None: + raise ValueError("Cannot provide both user_identifier and subject_token") + + # Build unsigned JWT from user identifier + request.subject_token = build_user_identifier_token(request.user_identifier) + request.subject_token_type = TokenType.USER_IDENTIFIER http_req = build_token_exchange_http_request(request, context) diff --git a/packages/oauth/src/keycardai/oauth/types/models.py b/packages/oauth/src/keycardai/oauth/types/models.py index 0a88383..4ed507f 100644 --- a/packages/oauth/src/keycardai/oauth/types/models.py +++ b/packages/oauth/src/keycardai/oauth/types/models.py @@ -48,6 +48,8 @@ class TokenExchangeRequest(BaseModel): client_assertion_type: str | None = None client_assertion: str | None = None + user_identifier: str | None = Field(default=None, description="User identifier for user identifier delegated grant access. Mutually exclusive with subject_token.") + @dataclass class TokenResponse: diff --git a/packages/oauth/src/keycardai/oauth/types/oauth.py b/packages/oauth/src/keycardai/oauth/types/oauth.py index 505bbe1..6f69df7 100644 --- a/packages/oauth/src/keycardai/oauth/types/oauth.py +++ b/packages/oauth/src/keycardai/oauth/types/oauth.py @@ -135,6 +135,9 @@ class TokenType(str, Enum): # RFC 9068 - JWT Profile for Access Tokens JWT = "urn:ietf:params:oauth:token-type:jwt" + # Keycard extension - User identifier for user identifier delegated grant access + USER_IDENTIFIER = "urn:keycard:params:oauth:token-type:user-identifier" + class TokenTypeHint(str, Enum): """Token type hints for introspection and revocation as defined in RFCs. diff --git a/packages/oauth/src/keycardai/oauth/utils/jwt.py b/packages/oauth/src/keycardai/oauth/utils/jwt.py index 395e7a1..b99bcfe 100644 --- a/packages/oauth/src/keycardai/oauth/utils/jwt.py +++ b/packages/oauth/src/keycardai/oauth/utils/jwt.py @@ -42,6 +42,50 @@ from ..types.models import ClientConfig +def build_user_identifier_token(identifier: str) -> str: + """Build an unsigned JWT for user identifier token exchange. + + Creates a JWT with header {"typ": "vnd.kc.user+jwt", "alg": "none"} + and payload {"sub": identifier}, with no signature. + + The token is intentionally unsigned. It carries no security guarantees on + its own. Security relies on two other layers: + + 1. Client authentication: the calling application must authenticate via + client credentials (e.g. client_secret_basic), proving it is authorized + to request grants on behalf of users. + 2. Prior user consent: the user must have previously authenticated + interactively and established a delegated grant for the requested + resource. + + The unsigned JWT is a structured container for the user identifier, keeping + a consistent format across custom subject token types (see also + vnd.kc.process+jwt). + + Args: + identifier: User identifier string (e.g. email, sub, oid value) + + Returns: + Base64url-encoded JWT string in format: header.payload. + + Example: + >>> token = build_user_identifier_token("user@example.com") + >>> print(token) # eyJ0eXAiOiJ2bmQua2MudXNlcitqd3QiLCJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyQGV4YW1wbGUuY29tIn0. + """ + header = {"typ": "vnd.kc.user+jwt", "alg": "none"} + payload = {"sub": identifier} + + # Encode header and payload as base64url (no padding) + header_json = json.dumps(header, separators=(",", ":")) + payload_json = json.dumps(payload, separators=(",", ":")) + + header_b64 = base64.urlsafe_b64encode(header_json.encode()).decode().rstrip("=") + payload_b64 = base64.urlsafe_b64encode(payload_json.encode()).decode().rstrip("=") + + # Return header.payload. (trailing dot, empty signature) + return f"{header_b64}.{payload_b64}." + + def _split_jwt_token(jwt_token: str) -> tuple[str, str, str]: """Split JWT token into its three parts. diff --git a/packages/oauth/src/keycardai/oauth/utils/pkce.py b/packages/oauth/src/keycardai/oauth/utils/pkce.py index 7e009fb..d86bf83 100644 --- a/packages/oauth/src/keycardai/oauth/utils/pkce.py +++ b/packages/oauth/src/keycardai/oauth/utils/pkce.py @@ -17,6 +17,10 @@ - Enables secure OAuth flows for mobile and SPA applications """ +import base64 +import hashlib +import secrets + from pydantic import BaseModel @@ -86,8 +90,9 @@ def generate_code_verifier(length: int = 128) -> str: Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 """ - # Implementation placeholder - raise NotImplementedError("PKCE code verifier generation not yet implemented") + if length < 43 or length > 128: + raise ValueError("Code verifier length must be between 43 and 128 characters") + return secrets.token_urlsafe(96)[:length] @staticmethod def generate_code_challenge(verifier: str, method: str = "S256") -> str: @@ -107,8 +112,13 @@ def generate_code_challenge(verifier: str, method: str = "S256") -> str: Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 """ - # Implementation placeholder - raise NotImplementedError("PKCE code challenge generation not yet implemented") + if method == PKCEMethods.S256: + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + elif method == PKCEMethods.PLAIN: + return verifier + else: + raise ValueError(f"Unsupported PKCE method: {method}") def generate_pkce_pair( self, method: str = "S256", verifier_length: int = 128 @@ -127,8 +137,13 @@ def generate_pkce_pair( Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4 """ - # Implementation placeholder - raise NotImplementedError("PKCE pair generation not yet implemented") + verifier = self.generate_code_verifier(verifier_length) + challenge = self.generate_code_challenge(verifier, method) + return PKCEChallenge( + code_verifier=verifier, + code_challenge=challenge, + code_challenge_method=method, + ) @staticmethod def validate_pkce_pair( @@ -148,5 +163,5 @@ def validate_pkce_pair( Reference: https://datatracker.ietf.org/doc/html/rfc7636#section-4.6 """ - # Implementation placeholder - raise NotImplementedError("PKCE validation not yet implemented") + expected = PKCEGenerator.generate_code_challenge(code_verifier, method) + return secrets.compare_digest(expected, code_challenge) diff --git a/packages/oauth/tests/keycardai/oauth/operations/test_token_exchange.py b/packages/oauth/tests/keycardai/oauth/operations/test_token_exchange.py index cdf97dd..b0bd030 100644 --- a/packages/oauth/tests/keycardai/oauth/operations/test_token_exchange.py +++ b/packages/oauth/tests/keycardai/oauth/operations/test_token_exchange.py @@ -1,5 +1,7 @@ """Unit tests for OAuth 2.0 Token Exchange operations (RFC 8693).""" +import base64 +import json from unittest.mock import AsyncMock, Mock import pytest @@ -190,3 +192,136 @@ async def test_token_exchange_async(self): assert result.access_token == "async_exchanged_token" assert result.token_type == "Bearer" assert result.expires_in == 7200 + + def test_token_exchange_with_user_identifier(self): + """Test token exchange with user_identifier parameter.""" + mock_transport = Mock() + mock_transport.request_raw.return_value = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'{"access_token": "user_identifier_token", "token_type": "Bearer", "expires_in": 3600}' + ) + + mock_auth = Mock() + mock_auth.apply_headers.return_value = {"Authorization": "Basic Y2xpZW50OnNlY3JldA=="} + + context = HTTPContext( + endpoint="https://auth.example.com/token", + transport=mock_transport, + auth=mock_auth, + timeout=30.0 + ) + + req = TokenExchangeRequest( + user_identifier="user@example.com", + resource="https://graph.microsoft.com", + grant_type=GrantType.TOKEN_EXCHANGE + ) + + result = exchange_token(req, context) + + assert isinstance(result, TokenResponse) + assert result.access_token == "user_identifier_token" + + # Verify that subject_token was set to an unsigned JWT + assert req.subject_token is not None + assert req.subject_token_type == TokenType.USER_IDENTIFIER + + # Verify the JWT structure + parts = req.subject_token.split(".") + assert len(parts) == 3 + assert parts[2] == "" # Empty signature + + # Decode and verify payload contains the user identifier + payload_json = base64.urlsafe_b64decode(parts[1] + "==").decode() + payload = json.loads(payload_json) + assert payload["sub"] == "user@example.com" + + def test_token_exchange_user_identifier_and_subject_token_raises_error(self): + """Test that providing both user_identifier and subject_token raises ValueError.""" + mock_transport = Mock() + mock_auth = Mock() + mock_auth.apply_headers.return_value = {} + + context = HTTPContext( + endpoint="https://auth.example.com/token", + transport=mock_transport, + auth=mock_auth, + timeout=30.0 + ) + + req = TokenExchangeRequest( + user_identifier="user@example.com", + subject_token="some_token", + subject_token_type=TokenType.ACCESS_TOKEN, + grant_type=GrantType.TOKEN_EXCHANGE + ) + + with pytest.raises(ValueError, match="Cannot provide both user_identifier and subject_token"): + exchange_token(req, context) + + @pytest.mark.asyncio + async def test_token_exchange_async_with_user_identifier(self): + """Test async token exchange with user_identifier parameter.""" + mock_transport = AsyncMock() + mock_transport.request_raw.return_value = HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=b'{"access_token": "async_user_identifier_token", "token_type": "Bearer", "expires_in": 7200}' + ) + + mock_auth = Mock() + mock_auth.apply_headers.return_value = {"Authorization": "Basic Y2xpZW50OnNlY3JldA=="} + + context = HTTPContext( + endpoint="https://auth.example.com/token", + transport=mock_transport, + auth=mock_auth, + timeout=30.0 + ) + + req = TokenExchangeRequest( + user_identifier="john.doe@example.com", + resource="https://api.example.com", + grant_type=GrantType.TOKEN_EXCHANGE + ) + + result = await exchange_token_async(req, context) + + assert isinstance(result, TokenResponse) + assert result.access_token == "async_user_identifier_token" + assert result.token_type == "Bearer" + assert result.expires_in == 7200 + + # Verify subject_token was set correctly + assert req.subject_token is not None + assert req.subject_token_type == TokenType.USER_IDENTIFIER + + def test_build_http_request_excludes_user_identifier(self): + """Test that user_identifier is excluded from form data.""" + req = TokenExchangeRequest( + user_identifier="user@example.com", + resource="https://api.example.com", + grant_type=GrantType.TOKEN_EXCHANGE + ) + + # Manually set subject_token as the exchange_token function would + from keycardai.oauth.utils.jwt import build_user_identifier_token + req.subject_token = build_user_identifier_token(req.user_identifier) + req.subject_token_type = TokenType.USER_IDENTIFIER + + auth = NoneAuth() + http_req = build_token_exchange_http_request( + req, + HTTPContext(endpoint="https://auth.example.com/token", transport=Mock(), auth=auth) + ) + + body_str = http_req.body.decode('utf-8') + + # user_identifier should not be in the form data + assert "user_identifier" not in body_str + + # subject_token and subject_token_type should be in the form data + assert "subject_token=" in body_str + assert "subject_token_type=" in body_str + assert "urn%3Akeycard%3Aparams%3Aoauth%3Atoken-type%3Auser-identifier" in body_str diff --git a/packages/oauth/tests/keycardai/oauth/utils/test_jwt.py b/packages/oauth/tests/keycardai/oauth/utils/test_jwt.py index 4d94722..fb73ba3 100644 --- a/packages/oauth/tests/keycardai/oauth/utils/test_jwt.py +++ b/packages/oauth/tests/keycardai/oauth/utils/test_jwt.py @@ -14,6 +14,7 @@ JWTAccessToken, _decode_jwt_part, _split_jwt_token, + build_user_identifier_token, decode_and_verify_jwt, extract_scopes, get_claims, @@ -53,6 +54,68 @@ def test_split_jwt_token_invalid_format(self): +class TestBuildUserIdentifierToken: + """Test building unsigned JWT for user identifier token exchange.""" + + def test_build_user_identifier_token_basic(self): + """Test building a basic user identifier token.""" + identifier = "user@example.com" + token = build_user_identifier_token(identifier) + + # Token should have three parts (header.payload.signature) + parts = token.split(".") + assert len(parts) == 3 + assert parts[2] == "" # Empty signature + + # Decode and verify header + header_json = base64.urlsafe_b64decode(parts[0] + "==").decode() + header = json.loads(header_json) + assert header["typ"] == "vnd.kc.user+jwt" + assert header["alg"] == "none" + + # Decode and verify payload + payload_json = base64.urlsafe_b64decode(parts[1] + "==").decode() + payload = json.loads(payload_json) + assert payload["sub"] == identifier + + def test_build_user_identifier_token_various_identifiers(self): + """Test building user identifier tokens with various identifier formats.""" + identifiers = [ + "user@example.com", + "user123", + "john.doe@company.com", + "user+tag@example.com", + "01234567-89ab-cdef-0123-456789abcdef", + ] + + for identifier in identifiers: + token = build_user_identifier_token(identifier) + + # Verify token structure + parts = token.split(".") + assert len(parts) == 3 + assert parts[2] == "" + + # Verify payload contains correct identifier + payload_json = base64.urlsafe_b64decode(parts[1] + "==").decode() + payload = json.loads(payload_json) + assert payload["sub"] == identifier + + def test_build_user_identifier_token_format(self): + """Test that the token format is correct (header.payload.).""" + token = build_user_identifier_token("test@example.com") + + # Should end with a trailing dot (empty signature) + assert token.endswith(".") + + # Should not have padding characters + assert "=" not in token[:-1] # Check all parts except the final dot + + # Should be base64url encoded (no +/ characters, only - and _) + assert "+" not in token + assert "/" not in token + + class TestJWTPartDecoding: """Test JWT part decoding functionality."""