|
1 | 1 | import time |
2 | | -from typing import Any, Optional |
| 2 | +from typing import Any, Optional, Union |
3 | 3 |
|
4 | 4 | import httpx |
5 | 5 | from authlib.jose import JsonWebKey, JsonWebToken |
|
9 | 9 | ApiError, |
10 | 10 | BaseAuthError, |
11 | 11 | GetAccessTokenForConnectionError, |
| 12 | + GetTokenByExchangeProfileError, |
12 | 13 | InvalidAuthSchemeError, |
13 | 14 | InvalidDpopProofError, |
14 | 15 | MissingAuthorizationError, |
@@ -501,6 +502,211 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict |
501 | 502 | exc |
502 | 503 | ) |
503 | 504 |
|
| 505 | + async def get_token_by_exchange_profile( |
| 506 | + self, |
| 507 | + subject_token: str, |
| 508 | + subject_token_type: str, |
| 509 | + audience: Optional[str] = None, |
| 510 | + scope: Optional[str] = None, |
| 511 | + requested_token_type: Optional[str] = None, |
| 512 | + extra: Optional[dict[str, Union[str, list[str]]]] = None |
| 513 | + ) -> dict[str, Any]: |
| 514 | + """ |
| 515 | + Exchange a subject token for an Auth0 token using RFC 8693. |
| 516 | +
|
| 517 | + The matching Token Exchange Profile is selected by subject_token_type. |
| 518 | + This method requires a confidential client (client_id and client_secret must be configured). |
| 519 | +
|
| 520 | + Args: |
| 521 | + subject_token: The token to be exchanged |
| 522 | + subject_token_type: URI identifying the token type (must match a Token Exchange Profile) |
| 523 | + audience: Optional target API identifier for the exchanged tokens |
| 524 | + scope: Optional space-separated OAuth 2.0 scopes to request |
| 525 | + requested_token_type: Optional type of token to issue (defaults to access token) |
| 526 | + extra: Optional additional parameters sent as form fields to Auth0. |
| 527 | + Cannot override reserved OAuth parameters. |
| 528 | +
|
| 529 | + Returns: |
| 530 | + Dictionary containing: |
| 531 | + - access_token (str): The Auth0 access token |
| 532 | + - expires_in (int): Token lifetime in seconds |
| 533 | + - expires_at (int): Unix timestamp when token expires |
| 534 | + - id_token (str, optional): OpenID Connect ID token |
| 535 | + - refresh_token (str, optional): Refresh token |
| 536 | + - scope (str, optional): Granted scopes |
| 537 | + - token_type (str, optional): Token type (typically "Bearer") |
| 538 | + - issued_token_type (str, optional): RFC 8693 issued token type identifier |
| 539 | +
|
| 540 | + Raises: |
| 541 | + MissingRequiredArgumentError: If required parameters are missing |
| 542 | + GetTokenByExchangeProfileError: If client credentials not configured, validation fails, |
| 543 | + or reserved parameters are supplied in extra |
| 544 | + ApiError: If the token endpoint returns an error |
| 545 | +
|
| 546 | + Example: |
| 547 | + >>> result = await api_client.get_token_by_exchange_profile( |
| 548 | + ... subject_token=token, |
| 549 | + ... subject_token_type="urn:example:subject-token", |
| 550 | + ... audience="https://api.backend.com" |
| 551 | + ... ) |
| 552 | +
|
| 553 | + References: |
| 554 | + - Custom Token Exchange: https://auth0.com/docs/authenticate/custom-token-exchange |
| 555 | + - RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693 |
| 556 | + - Related SDK: https://github.com/auth0/auth0-auth-js |
| 557 | + """ |
| 558 | + # Constants |
| 559 | + TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105 |
| 560 | + |
| 561 | + # OAuth parameter denylist - fail fast if these are supplied in extra |
| 562 | + RESERVED_PARAMS = frozenset([ |
| 563 | + "grant_type", "client_id", "client_secret", "client_assertion", |
| 564 | + "client_assertion_type", "subject_token", "subject_token_type", |
| 565 | + "requested_token_type", "actor_token", "actor_token_type", |
| 566 | + "audience", "aud", "resource", "resources", "resource_indicator", |
| 567 | + "scope", "connection", "login_hint", "organization", "assertion", |
| 568 | + ]) |
| 569 | + |
| 570 | + # Validate required parameters |
| 571 | + if not subject_token: |
| 572 | + raise MissingRequiredArgumentError("subject_token") |
| 573 | + if not subject_token_type: |
| 574 | + raise MissingRequiredArgumentError("subject_token_type") |
| 575 | + |
| 576 | + # Validate and normalize subject token |
| 577 | + if not isinstance(subject_token, str): |
| 578 | + raise GetTokenByExchangeProfileError("subject_token must be a string") |
| 579 | + |
| 580 | + subject_token = subject_token.strip() |
| 581 | + |
| 582 | + if not subject_token: |
| 583 | + raise GetTokenByExchangeProfileError("subject_token cannot be blank or whitespace") |
| 584 | + if subject_token.lower().startswith("bearer "): |
| 585 | + raise GetTokenByExchangeProfileError( |
| 586 | + "subject_token must not include the 'Bearer ' prefix" |
| 587 | + ) |
| 588 | + |
| 589 | + # Require client credentials |
| 590 | + client_id = self.options.client_id |
| 591 | + client_secret = self.options.client_secret |
| 592 | + if not client_id or not client_secret: |
| 593 | + raise GetTokenByExchangeProfileError( |
| 594 | + "Client credentials are required to use get_token_by_exchange_profile" |
| 595 | + ) |
| 596 | + |
| 597 | + # Discover token endpoint |
| 598 | + metadata = await self._discover() |
| 599 | + token_endpoint = metadata.get("token_endpoint") |
| 600 | + if not token_endpoint: |
| 601 | + raise GetTokenByExchangeProfileError("Token endpoint missing in OIDC metadata") |
| 602 | + |
| 603 | + # Build request parameters (client_id sent via HTTP Basic auth only) |
| 604 | + params = { |
| 605 | + "grant_type": TOKEN_EXCHANGE_GRANT_TYPE, |
| 606 | + "subject_token": subject_token, |
| 607 | + "subject_token_type": subject_token_type, |
| 608 | + } |
| 609 | + |
| 610 | + # Add optional parameters |
| 611 | + if audience: |
| 612 | + params["audience"] = audience |
| 613 | + if scope: |
| 614 | + params["scope"] = scope |
| 615 | + if requested_token_type: |
| 616 | + params["requested_token_type"] = requested_token_type |
| 617 | + |
| 618 | + # Append extra parameters with validation |
| 619 | + if extra: |
| 620 | + for parameter_key, parameter_value in extra.items(): |
| 621 | + # Fail fast if reserved parameter is supplied |
| 622 | + if parameter_key in RESERVED_PARAMS: |
| 623 | + raise GetTokenByExchangeProfileError( |
| 624 | + f"Parameter '{parameter_key}' is reserved and cannot be overridden" |
| 625 | + ) |
| 626 | + |
| 627 | + # Store value (httpx handles list encoding as multiple key=value pairs) |
| 628 | + if isinstance(parameter_value, list): |
| 629 | + params[parameter_key] = parameter_value |
| 630 | + else: |
| 631 | + params[parameter_key] = str(parameter_value) |
| 632 | + |
| 633 | + # Make token exchange request |
| 634 | + try: |
| 635 | + async with httpx.AsyncClient() as client: |
| 636 | + response = await client.post( |
| 637 | + token_endpoint, |
| 638 | + data=params, |
| 639 | + auth=(client_id, client_secret) |
| 640 | + ) |
| 641 | + |
| 642 | + if response.status_code != 200: |
| 643 | + error_data = {} |
| 644 | + try: |
| 645 | + if "json" in response.headers.get("content-type", "").lower(): |
| 646 | + error_data = response.json() |
| 647 | + except Exception: # noqa: S110 |
| 648 | + pass # Ignore JSON parse errors, use generic error message below |
| 649 | + |
| 650 | + raise ApiError( |
| 651 | + error_data.get("error", "token_exchange_error"), |
| 652 | + error_data.get( |
| 653 | + "error_description", |
| 654 | + f"Failed to exchange token of type '{subject_token_type}'" |
| 655 | + + (f" for audience '{audience}'" if audience else "") |
| 656 | + ), |
| 657 | + response.status_code |
| 658 | + ) |
| 659 | + |
| 660 | + try: |
| 661 | + token_response = response.json() |
| 662 | + except Exception: |
| 663 | + raise ApiError("invalid_json", "Token endpoint returned invalid JSON.", 502) |
| 664 | + |
| 665 | + # Validate required fields |
| 666 | + access_token = token_response.get("access_token") |
| 667 | + if not isinstance(access_token, str) or not access_token: |
| 668 | + raise ApiError( |
| 669 | + "invalid_response", |
| 670 | + "Missing or invalid access_token in response.", |
| 671 | + 502 |
| 672 | + ) |
| 673 | + |
| 674 | + expires_in_raw = token_response.get("expires_in", 3600) |
| 675 | + try: |
| 676 | + expires_in = int(expires_in_raw) |
| 677 | + except (TypeError, ValueError): |
| 678 | + raise ApiError("invalid_response", "expires_in is not an integer.", 502) |
| 679 | + |
| 680 | + # Build response with required fields |
| 681 | + result = { |
| 682 | + "access_token": access_token, |
| 683 | + "expires_in": expires_in, |
| 684 | + "expires_at": int(time.time()) + expires_in, |
| 685 | + } |
| 686 | + |
| 687 | + # Add optional fields if present |
| 688 | + optional_fields = ["scope", "id_token", "refresh_token", "token_type", "issued_token_type"] |
| 689 | + for field in optional_fields: |
| 690 | + if value := token_response.get(field): |
| 691 | + result[field] = value |
| 692 | + |
| 693 | + return result |
| 694 | + |
| 695 | + except httpx.TimeoutException as exc: |
| 696 | + raise ApiError( |
| 697 | + "timeout_error", |
| 698 | + f"Request to token endpoint timed out: {str(exc)}", |
| 699 | + 504, |
| 700 | + exc |
| 701 | + ) |
| 702 | + except httpx.HTTPError as exc: |
| 703 | + raise ApiError( |
| 704 | + "network_error", |
| 705 | + f"Network error occurred: {str(exc)}", |
| 706 | + 502, |
| 707 | + exc |
| 708 | + ) |
| 709 | + |
504 | 710 | # ===== Private Methods ===== |
505 | 711 |
|
506 | 712 | async def _discover(self) -> dict[str, Any]: |
|
0 commit comments