Skip to content

Conversation

@kishore7snehil
Copy link
Contributor

⚠️ TEMPORARY PR FOR CLAUDE CODE REVIEW ONLY

This is a temporary PR to run automated Claude Code review on PR #51.

Original PR: #51 by @btiernay

This PR will be closed after review is complete.


Reference:

Implements token exchange via Exchange Profiles for Auth0 API client.

Features:
- get_token_by_exchange_profile() method with HTTP Basic auth
- Reserved parameter protection (case-insensitive)
- DoS protection (array size limits)
- Strict token validation (whitespace, Bearer prefix)
- Type safety for extra params (str, sequences; rejects dict/set/bytes)
- Comprehensive error handling (GetTokenByExchangeProfileError, ApiError)
- Lenient expires_in parsing (coerces numeric strings)
- Negative expires_in validation

Testing:
- 129 tests, 86% coverage
- Shared test utilities in conftest.py
- Table-driven validation tests
- Cross-validated against auth0-auth-js

Docs:
- README with usage examples and Early Access warning
- Related SDKs section
- Error handling patterns

Note: verify_request() now uses split(None, 1) for header parsing.
Malformed headers with multiple spaces now raise VerifyAccessTokenError
during JWT parsing instead of InvalidAuthSchemeError. Standard inputs
unaffected.
@kishore7snehil kishore7snehil requested a review from a team as a code owner October 28, 2025 13:46
@kishore7snehil kishore7snehil changed the title feat: add Custom Token Exchange support (RFC 8693) temp : review Custom Token Exchange support Oct 28, 2025
@kishore7snehil
Copy link
Contributor Author

@claude

@github-actions
Copy link

github-actions bot commented Oct 28, 2025

Claude finished @kishore7snehil's task —— View job


Review Complete

  • Read PR diff and understand changes
  • Read project architecture documentation
  • Review security aspects
  • Review code quality
  • Review performance
  • Provide overall assessment

Overall Assessment

This pull request is ready to be merged after addressing the inline comments. The Custom Token Exchange (RFC 8693) implementation is well-designed with strong security practices, comprehensive input validation, and excellent test coverage. The code follows RFC 8693 specifications correctly and includes appropriate safeguards against common vulnerabilities.

Key Strengths:

  • Robust input validation (whitespace checks, Bearer prefix detection, reserved parameter blocking)
  • Proper HTTP Basic Auth usage for client credentials (not in request body)
  • DoS protection via array size limits on extra parameters
  • Comprehensive error handling with appropriate exception types
  • Extensive test coverage including edge cases and security scenarios
  • Clear documentation with security warnings about extra parameters

Issues Identified (see inline comments for details):

  • Minor inconsistencies in timeout configuration between methods
  • Content-Type parsing could be more robust
  • Missing error handling for malformed JSON in success responses
  • Some validation edge cases (e.g., unreasonably large expires_in values)
  • Documentation could be enhanced with additional security considerations

No blocking security issues were found. The implementation demonstrates strong security awareness with proper credential handling, input sanitization, and protection against injection attacks. All inline comments are recommendations for improvements rather than critical fixes.


Job RunBranch

# Require client credentials
client_id = self.options.client_id
client_secret = self.options.client_secret
if not client_id or not client_secret:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The client credentials check occurs after input validation. While this ordering is correct for fail-fast validation, consider if credential presence should be verified earlier to avoid processing potentially sensitive tokens when the request will ultimately fail due to missing credentials. Current implementation is acceptable as token validation is lightweight.

if "json" in response.headers.get("content-type", "").lower():
error_data = response.json()
except ValueError:
pass # Ignore JSON parse errors, use generic error message below

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON parsing error handling silently swallows exceptions with pass. While this is acceptable for gracefully handling malformed error responses, consider logging these parse failures for debugging purposes, especially since they indicate the Auth0 endpoint returned invalid JSON in an error scenario.

except (TypeError, ValueError):
raise ApiError("invalid_response", "expires_in is not an integer.", 502)

if expires_in < 0:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Negative expires_in validation is good for preventing immediately-expired tokens. However, consider also validating for unreasonably large values (e.g., > 1 year) which could indicate a malformed response or potential integer overflow issues in downstream systems that calculate expiry timestamps.

f"Parameter '{k}' exceeds maximum array size of {MAX_ARRAY_VALUES_PER_KEY}"
)
# Convert sequence items to strings
params[key] = [str(x) for x in v]

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Converting all sequence items to strings with [str(x) for x in v] could mask issues if the sequence contains objects without proper __str__ methods. Consider validating that items are primitive types (str, int, float, bool) before conversion to prevent unexpected serialization behavior.

MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays

# OAuth parameter denylist - parameters that cannot be overridden via extras
RESERVED_PARAMS = frozenset([

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reserved parameters list is comprehensive, but consider if resource vs resources distinction is necessary. RFC 8693 uses resource (singular), while the list includes both. Verify this matches Auth0's actual parameter handling to avoid blocking legitimate use cases.


# Make token exchange request
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout is configured here but not in the get_access_token_for_connection method at line 474. This inconsistency means connection token exchange has no configurable timeout and uses httpx defaults. Consider applying the same timeout configuration to both methods for consistency.


**Important:**
- Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body.
- Do not prefix `subject_token` with "Bearer " - send the raw token value only.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning states "Extra parameters are sent as form fields and may appear in logs. Do not include secrets or sensitive data." Consider strengthening this warning to explicitly mention that these parameters may also appear in Auth0 logs, monitoring systems, and potentially in error messages returned to clients.

tok = subject_token
if not isinstance(tok, str) or not tok.strip():
raise GetTokenByExchangeProfileError("subject_token cannot be blank or whitespace")
if tok != tok.strip():

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whitespace stripping check uses .strip() to detect leading/trailing whitespace. This rejects tokens with legitimate trailing/leading whitespace that might be part of the token format. However, this is likely intentional to prevent common copy-paste errors. Consider documenting this behavior in the docstring.

for k, v in extra.items():
key = str(k)
# Case-insensitive check against reserved params
if key.lower() in reserved_lower:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The case-insensitive check for reserved parameters uses key.lower() which is correct. However, the error message uses the original key k instead of the normalized key. This is actually good UX as it shows the user's exact input, but ensure this doesn't cause confusion if mixed-case variations exist in RESERVED_PARAMS (currently all lowercase).

if response.status_code != 200:
error_data = {}
try:
if "json" in response.headers.get("content-type", "").lower():

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Content-Type parsing issue as in get_access_token_for_connection. The check if "json" in response.headers.get("content-type", "").lower() is fragile. Consider using response.headers.get("content-type", "").startswith("application/json") or leveraging httpx's response type detection.

result = {
"access_token": access_token,
"expires_in": expires_in,
"expires_at": int(time.time()) + expires_in,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The calculation int(time.time()) + expires_in could theoretically overflow for extremely large expires_in values (though validated as int). In Python 3, integers have arbitrary precision, so this is safe. However, downstream systems converting to 32-bit timestamps could overflow. The earlier suggested validation for maximum expires_in would prevent this.


# Token Exchange constants
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105
MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constant MAX_ARRAY_VALUES_PER_KEY = 20 provides DoS protection, which is good. However, this limit is somewhat arbitrary and not documented in the method's docstring. Consider documenting this limit in the get_token_by_exchange_profile method documentation so users are aware of this constraint.

audience="<AUTH0_AUDIENCE>",
client_id="<AUTH0_CLIENT_ID>",
client_secret="<AUTH0_CLIENT_SECRET>",
timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The important note states "Do not prefix subject_token with 'Bearer '" which is validated in code (line 597-600 in api_client.py). However, the validation is case-insensitive (token.lower().startswith("bearer ")), which is good. Consider clarifying in docs that this check is case-insensitive.

client_secret: Optional required if you want to use get_access_token_for_connection.
client_id: Required for get_access_token_for_connection and get_token_by_exchange_profile.
client_secret: Required for get_access_token_for_connection and get_token_by_exchange_profile.
timeout: HTTP timeout in seconds for token endpoint requests (default: 10.0).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring mentions timeout defaults to 10.0 seconds, and the parameter has timeout: float = 10.0. However, consider if this timeout should also apply to OIDC discovery and JWKS fetching operations, or only to token endpoint calls. Currently it only applies to token endpoint operations.

)

# Handle sequences (list, tuple, etc.) but reject mappings/sets/bytes
if isinstance(v, (dict, set, bytes)):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type check isinstance(v, (dict, set, bytes)) rejects these types, which is good. However, the error message mentions the type name but doesn't explain why these types are unsupported. Consider enhancing the error message to say something like "unsupported type {type} - only strings, numbers, and sequences are allowed".

raise GetTokenByExchangeProfileError(
"subject_token must not include leading or trailing whitespace"
)
if tok.lower().startswith("bearer "):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Bearer prefix validation is case-insensitive which is good. However, the error message says "must not include the 'Bearer ' prefix" (with capital B). Consider matching the case in the error message to what was actually detected, or clarifying that the check is case-insensitive to avoid confusion.

client_id="<AUTH0_CLIENT_ID>",
client_secret="<AUTH0_CLIENT_SECRET>",
timeout=10.0 # Optional: HTTP timeout in seconds (default: 10.0)
))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states "This URI identifies which profile will process the exchange and must not use reserved OAuth namespaces (IETF or vendor-controlled)". While this is excellent guidance, consider adding validation in the code to reject common reserved namespaces like urn:ietf:params:oauth:* to fail fast and provide better error messages to developers.

"expires_at": int(time.time()) + expires_in,
}

# Add optional fields if present (preserves falsy values like empty scope)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code preserves optional fields including falsy values with the comment "preserves falsy values like empty scope". This is good design for RFC 8693 compliance where an empty scope string has different meaning than no scope field. Consider adding a test case to verify this behavior is maintained.

return "get_access_token_for_connection_error"


class GetTokenByExchangeProfileError(BaseAuthError):

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetTokenByExchangeProfileError class returns a 400 status code. However, some scenarios (like missing credentials) might be more appropriately classified as 401 (Unauthorized) or 500 (Internal Server Error - configuration issue). Consider if different error scenarios should return different status codes.

)
elif isinstance(v, (list, tuple)):
if len(v) > MAX_ARRAY_VALUES_PER_KEY:
raise GetTokenByExchangeProfileError(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array size limit check happens before string conversion. If an attacker provides 21+ complex objects with expensive __str__ methods, those conversions would still execute before the limit check fails. Consider checking array length before iterating for string conversion to prevent potential DoS via expensive string conversions.

)

# Result contains access_token, expires_in, expires_at
# id_token, refresh_token, and scope are profile/Action dependent (not guaranteed; scope may be empty)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code example shows extra usage with device_id and session_id. These look like potentially sensitive identifiers. Consider adding a note that even non-secret identifiers may have privacy implications if logged, especially in jurisdictions with strict data protection laws (GDPR, CCPA, etc.).

from .errors import ApiError, GetTokenByExchangeProfileError

__all__ = [
"ApiClient",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new GetTokenByExchangeProfileError is exported in the public API. Consider if this should be documented in a CHANGELOG or migration guide, especially if this is part of a major version release, so users know about the new exception type they should handle.

response = await client.post(
token_endpoint,
data=params,
auth=(client_id, client_secret)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Client credentials are sent via HTTP Basic Auth (line 646), which is RFC 8693 compliant. However, ensure the underlying httpx library properly encodes the credentials. Special characters in client_id or client_secret (like colons) could cause parsing issues. The httpx library should handle this, but consider adding a note in documentation about special character handling.


try:
token_response = response.json()
except ValueError:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON parsing of the successful response doesn't wrap in try-except like the error response parsing does (line 651-655). If Auth0 returns a 200 status with malformed JSON, this will raise an unhandled ValueError. Consider wrapping this in try-except and raising an ApiError for consistency.


# Validate required fields
access_token = token_response.get("access_token")
if not isinstance(access_token, str) or not access_token:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation if not isinstance(access_token, str) or not access_token correctly checks type and emptiness. However, consider also validating that the token doesn't contain only whitespace (similar to the subject_token validation) to catch malformed responses early.

- Migrating to Auth0

```python
import asyncio

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example shows accessing result["access_token"] directly without error handling. For production code examples, consider adding a brief note about handling ApiError and GetTokenByExchangeProfileError exceptions, or showing the pattern demonstrated later (lines 175-186).

# Reject negative values (prevent accidental "already expired" tokens)
expires_in_raw = token_response.get("expires_in", 3600)
try:
expires_in = int(expires_in_raw)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coercion of expires_in to int with int(expires_in_raw) will truncate float values like 3600.5 to 3600. While this is acceptable and documented with "Lenient policy: coerce numeric strings", consider if rounding (rather than truncating) would be more appropriate for float values to avoid premature expiration.

Comment on lines +527 to +530
async def get_token_by_exchange_profile(
self,
subject_token: str,
subject_token_type: str,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subject_token parameter accepts a raw string token. While the validation prevents Bearer prefix, there's no validation of token format (JWT structure, base64 encoding, etc.). This is intentional since custom tokens may have arbitrary formats, but consider documenting that malformed tokens will only be caught by Auth0's endpoint, not locally.

@btiernay btiernay closed this pull request by merging all changes into main in 87f13ad Oct 29, 2025
@btiernay btiernay deleted the temp/claude-review-token-exchange branch October 29, 2025 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants