|
41 | 41 | PasskeySignupChallengeResponse, |
42 | 42 | PasskeyTokenResponse, |
43 | 43 | PasskeyUserProfile, |
| 44 | + SessionTransferTokenResult, |
44 | 45 | StartInteractiveLoginOptions, |
45 | 46 | StateData, |
46 | 47 | TokenExchangeResponse, |
|
86 | 87 | INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", |
87 | 88 | "code_challenge", "code_challenge_method", "state", "nonce", "scope"] |
88 | 89 |
|
| 90 | +# issued_token_type URN for a Session Transfer Token (STT). |
| 91 | +SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token" |
| 92 | + |
| 93 | +# actor_token_type URN when the actor is sourced from the agent session's ID token. |
| 94 | +ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token" |
| 95 | + |
89 | 96 |
|
90 | 97 | class ServerClient(Generic[TStoreOptions]): |
91 | 98 | """ |
@@ -2635,6 +2642,213 @@ async def login_with_custom_token_exchange( |
2635 | 2642 | e |
2636 | 2643 | ) |
2637 | 2644 |
|
| 2645 | + # ============================================================================ |
| 2646 | + # SESSION TRANSFER TOKEN (STT) |
| 2647 | + # Impersonation via Session Transfer, built on Custom Token Exchange. |
| 2648 | + # ============================================================================ |
| 2649 | + |
| 2650 | + async def _is_id_token_usable(self, token: str, store_options: Optional[dict[str, Any]]) -> bool: |
| 2651 | + """ |
| 2652 | + Verifies the agent session's ID token (signature + expiry) before using it as an actor. |
| 2653 | +
|
| 2654 | + Full verification against JWKS - the same path the login callback uses - so an expired |
| 2655 | + or tampered token is rejected client-side rather than sent to the server as a dud. |
| 2656 | + """ |
| 2657 | + if not token: |
| 2658 | + return False |
| 2659 | + domain = await self._resolve_current_domain(store_options) |
| 2660 | + metadata = await self._get_oidc_metadata_cached(domain) |
| 2661 | + jwks = await self._get_jwks_cached(domain, metadata) |
| 2662 | + try: |
| 2663 | + # aud is the client_id for a standard Auth0 ID token. |
| 2664 | + await self._verify_and_decode_jwt(token, jwks, audience=self._client_id) |
| 2665 | + return True |
| 2666 | + except (jwt.PyJWTError, ValueError): |
| 2667 | + # Only token-level failures mean "unusable"; infra errors (JWKS fetch, domain |
| 2668 | + # resolution) propagate above rather than being masked as ACTOR_UNAVAILABLE. |
| 2669 | + return False |
| 2670 | + |
| 2671 | + async def _resolve_actor_token( |
| 2672 | + self, |
| 2673 | + actor_token: Optional[str], |
| 2674 | + actor_token_type: Optional[str], |
| 2675 | + store_options: Optional[dict[str, Any]] |
| 2676 | + ) -> tuple[str, str]: |
| 2677 | + """ |
| 2678 | + Resolves the (actor_token, actor_token_type) pair for a session transfer request. |
| 2679 | +
|
| 2680 | + Raises: |
| 2681 | + CustomTokenExchangeError(ACTOR_UNAVAILABLE): if no usable actor can be resolved. |
| 2682 | + """ |
| 2683 | + # Explicit actor wins; a passed-but-blank value is a bug, not a fallback signal. |
| 2684 | + if actor_token is not None: |
| 2685 | + if not actor_token.strip(): |
| 2686 | + raise CustomTokenExchangeError( |
| 2687 | + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, |
| 2688 | + "actor_token cannot be empty or whitespace-only" |
| 2689 | + ) |
| 2690 | + return actor_token, (actor_token_type or ID_TOKEN_TYPE) |
| 2691 | + |
| 2692 | + # Otherwise source it from the agent's session ID token. |
| 2693 | + state_data = await self._state_store.get(self._state_identifier, store_options) |
| 2694 | + if state_data and hasattr(state_data, "dict") and callable(state_data.dict): |
| 2695 | + state_data = state_data.dict() |
| 2696 | + state_data = state_data or {} |
| 2697 | + |
| 2698 | + # In resolver mode, don't source the actor from a session on a different domain. |
| 2699 | + if self._domain_resolver: |
| 2700 | + session_domain = self._get_session_domain(state_data) |
| 2701 | + current_domain = await self._resolve_current_domain(store_options) |
| 2702 | + if not session_domain or self._normalize_url(session_domain) != self._normalize_url(current_domain): |
| 2703 | + raise CustomTokenExchangeError( |
| 2704 | + CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE, |
| 2705 | + "No usable actor token: the agent session is on a different domain." |
| 2706 | + ) |
| 2707 | + |
| 2708 | + session_id_token = state_data.get("id_token") |
| 2709 | + |
| 2710 | + # Refresh a stale (or missing) ID token when the agent session has a refresh token. |
| 2711 | + if not await self._is_id_token_usable(session_id_token, store_options) and state_data.get("refresh_token"): |
| 2712 | + refresh_domain = self._get_session_domain(state_data) or await self._resolve_current_domain(store_options) |
| 2713 | + try: |
| 2714 | + refreshed = await self.get_token_by_refresh_token({ |
| 2715 | + "refresh_token": state_data["refresh_token"], |
| 2716 | + "domain": refresh_domain, |
| 2717 | + }) |
| 2718 | + except (ApiError, AccessTokenError): |
| 2719 | + # A genuine refresh failure means no usable actor; unexpected errors propagate. |
| 2720 | + refreshed = None |
| 2721 | + if refreshed: |
| 2722 | + updated_state_data = State.update_state_data( |
| 2723 | + self.DEFAULT_AUDIENCE_STATE_KEY, state_data, refreshed) |
| 2724 | + await self._state_store.set(self._state_identifier, updated_state_data, options=store_options) |
| 2725 | + session_id_token = refreshed.get("id_token") or session_id_token |
| 2726 | + |
| 2727 | + if await self._is_id_token_usable(session_id_token, store_options): |
| 2728 | + return session_id_token, ID_TOKEN_TYPE |
| 2729 | + |
| 2730 | + raise CustomTokenExchangeError( |
| 2731 | + CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE, |
| 2732 | + "No usable actor token: pass actor_token or ensure the agent has a valid session." |
| 2733 | + ) |
| 2734 | + |
| 2735 | + async def request_session_transfer_token( |
| 2736 | + self, |
| 2737 | + subject_token: str, |
| 2738 | + subject_token_type: str, |
| 2739 | + actor_token: Optional[str] = None, |
| 2740 | + actor_token_type: Optional[str] = None, |
| 2741 | + scope: Optional[str] = None, |
| 2742 | + organization: Optional[str] = None, |
| 2743 | + store_options: Optional[dict[str, Any]] = None |
| 2744 | + ) -> SessionTransferTokenResult: |
| 2745 | + """ |
| 2746 | + Requests a Session Transfer Token (STT) for impersonation via session transfer. |
| 2747 | +
|
| 2748 | + Performs a custom token exchange against the session_transfer audience. The returned |
| 2749 | + STT is opaque and single-use; hand it to build_session_transfer_redirect and do not |
| 2750 | + decode or store it. The act claim is not on this result. |
| 2751 | +
|
| 2752 | + Args: |
| 2753 | + subject_token: Your proof of which customer to impersonate (validated by your Action) |
| 2754 | + subject_token_type: The subject token type URI routing to your CTE Profile |
| 2755 | + actor_token: The acting party's token; optional. Defaults to the agent session's ID token |
| 2756 | + actor_token_type: Type URI of the actor token; defaults to the ID token URN |
| 2757 | + scope: Space-delimited list of scopes (optional) |
| 2758 | + organization: Organization identifier (optional) |
| 2759 | + store_options: Optional options used to read the agent session and resolve the domain |
| 2760 | +
|
| 2761 | + Returns: |
| 2762 | + SessionTransferTokenResult containing the STT and its metadata |
| 2763 | +
|
| 2764 | + Raises: |
| 2765 | + CustomTokenExchangeError: If no actor can be resolved or the exchange fails |
| 2766 | + """ |
| 2767 | + try: |
| 2768 | + # Validate the subject up front - before any session read/refresh/network. |
| 2769 | + if not subject_token or not subject_token.strip(): |
| 2770 | + raise CustomTokenExchangeError( |
| 2771 | + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, |
| 2772 | + "subject_token cannot be empty or whitespace-only" |
| 2773 | + ) |
| 2774 | + if not subject_token_type or not subject_token_type.strip(): |
| 2775 | + raise CustomTokenExchangeError( |
| 2776 | + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, |
| 2777 | + "subject_token_type cannot be empty or whitespace-only" |
| 2778 | + ) |
| 2779 | + |
| 2780 | + actor_token, actor_token_type = await self._resolve_actor_token( |
| 2781 | + actor_token, actor_token_type, store_options) |
| 2782 | + |
| 2783 | + # Build the session_transfer audience from the resolved request domain. |
| 2784 | + domain = await self._resolve_current_domain(store_options) |
| 2785 | + audience = f"urn:{domain}:session_transfer" |
| 2786 | + |
| 2787 | + options = CustomTokenExchangeOptions( |
| 2788 | + subject_token=subject_token, |
| 2789 | + subject_token_type=subject_token_type, |
| 2790 | + audience=audience, |
| 2791 | + scope=scope, |
| 2792 | + actor_token=actor_token, |
| 2793 | + actor_token_type=actor_token_type, |
| 2794 | + organization=organization, |
| 2795 | + ) |
| 2796 | + |
| 2797 | + response = await self.custom_token_exchange(options, store_options) |
| 2798 | + |
| 2799 | + return SessionTransferTokenResult( |
| 2800 | + session_transfer_token=response.access_token, |
| 2801 | + # Return the server's value as-is; don't default to the STT URN, or a non-STT |
| 2802 | + # response would be mislabelled as an STT. |
| 2803 | + issued_token_type=response.issued_token_type or "", |
| 2804 | + expires_in=response.expires_in, |
| 2805 | + token_type=response.token_type, |
| 2806 | + scope=response.scope, |
| 2807 | + ) |
| 2808 | + except (CustomTokenExchangeError, ApiError): |
| 2809 | + raise |
| 2810 | + except Exception as e: |
| 2811 | + raise CustomTokenExchangeError( |
| 2812 | + CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED, |
| 2813 | + f"Session transfer token request failed: {str(e)}", |
| 2814 | + e |
| 2815 | + ) |
| 2816 | + |
| 2817 | + def build_session_transfer_redirect( |
| 2818 | + self, |
| 2819 | + target_login_url: str, |
| 2820 | + result: SessionTransferTokenResult, |
| 2821 | + organization: Optional[str] = None |
| 2822 | + ) -> str: |
| 2823 | + """ |
| 2824 | + Builds the redirect URL that hands the STT to the target app's login URL. |
| 2825 | +
|
| 2826 | + target_login_url must be a trusted, app-controlled absolute https URL (http is allowed |
| 2827 | + only for localhost/loopback) - the STT is a single-use credential and must not leak to an |
| 2828 | + untrusted host. |
| 2829 | +
|
| 2830 | + Args: |
| 2831 | + target_login_url: The target app's login URL (absolute, https) |
| 2832 | + result: The SessionTransferTokenResult from request_session_transfer_token |
| 2833 | + organization: Organization identifier to forward (optional) |
| 2834 | +
|
| 2835 | + Returns: |
| 2836 | + A URL string with session_transfer_token (and organization) as query parameters |
| 2837 | +
|
| 2838 | + Raises: |
| 2839 | + MissingRequiredArgumentError: If target_login_url is missing or blank |
| 2840 | + InvalidArgumentError: If target_login_url is not an absolute https URL, or organization is blank |
| 2841 | + """ |
| 2842 | + URL.validate_https_redirect_target(target_login_url, "target_login_url") |
| 2843 | + |
| 2844 | + params = {"session_transfer_token": result.session_transfer_token} |
| 2845 | + if organization is not None: |
| 2846 | + if not organization.strip(): |
| 2847 | + raise InvalidArgumentError("organization", "organization must not be blank") |
| 2848 | + params["organization"] = organization |
| 2849 | + |
| 2850 | + return URL.build_url(target_login_url, params) |
| 2851 | + |
2638 | 2852 | # ============================================================================ |
2639 | 2853 | # MFA (Multi-Factor Authentication) |
2640 | 2854 | # ============================================================================ |
|
0 commit comments