@@ -39,6 +39,12 @@ class KeyCredentialPolicy(Policy):
3939 SansIO-shaped (no chain wrapping needed) but implemented as a ``Policy``
4040 so it integrates uniformly with the rest of the pipeline.
4141
42+ The credential header is stamped only while the request stays on the
43+ origin recorded on the first pass through the policy. When a downstream
44+ redirect reissues the request against a different origin (scheme, host,
45+ or effective port), the credential is withheld so it never reaches a
46+ foreign host.
47+
4248 Attributes:
4349 header_name: Header to write.
4450 prefix: Optional prefix (with trailing space) for the header value.
@@ -63,12 +69,21 @@ def __init__(
6369 self .prefix = f"{ prefix } " if prefix else ""
6470
6571 def send (self , request : Request , ctx : PipelineContext ) -> Response :
72+ if _crosses_recorded_origin (request , ctx ):
73+ return self .next .send (request , ctx )
6674 value = f"{ self .prefix } { self ._credential .key } "
6775 return self .next .send (request .with_header (self .header_name , value ), ctx )
6876
6977
7078class BasicAuthPolicy (Policy ):
71- """Stamp ``Authorization: Basic <base64>`` from a ``BasicAuthCredential``."""
79+ """Stamp ``Authorization: Basic <base64>`` from a ``BasicAuthCredential``.
80+
81+ The credential is stamped only while the request stays on the origin
82+ recorded on the first pass through the policy. When a downstream redirect
83+ reissues the request against a different origin (scheme, host, or
84+ effective port), the credential is withheld so it never reaches a foreign
85+ host.
86+ """
7287
7388 STAGE = Stage .AUTH
7489 __slots__ = ("_credential" ,)
@@ -79,6 +94,8 @@ def __init__(self, credential: BasicAuthCredential) -> None:
7994 self ._credential = credential
8095
8196 def send (self , request : Request , ctx : PipelineContext ) -> Response :
97+ if _crosses_recorded_origin (request , ctx ):
98+ return self .next .send (request , ctx )
8299 value = f"Basic { self ._credential .encoded } "
83100 return self .next .send (request .with_header ("Authorization" , value ), ctx )
84101
@@ -91,6 +108,13 @@ class BearerTokenPolicy(Policy):
91108 returns True, or after a 401 response with ``WWW-Authenticate``. Enforces
92109 HTTPS unless ``enforce_https=False`` is passed in ``ctx.options``.
93110
111+ The token is acquired and stamped only while the request stays on the
112+ origin recorded on the first pass. When a downstream redirect reissues the
113+ request against a different origin (scheme, host, or effective port), the
114+ policy forwards the request unchanged — it does not acquire, refresh, or
115+ stamp a token — so the bearer token never reaches a foreign host. The
116+ HTTPS-enforcement check applies only on that same-origin stamping path.
117+
94118 Concurrent refreshes are serialized via a ``threading.Lock`` using a
95119 double-checked pattern so the credential's ``get_token_info`` is invoked
96120 at most once per refresh window even under heavy concurrent send pressure.
@@ -226,6 +250,11 @@ def _authorize(
226250 * ,
227251 force_refresh : bool = False ,
228252 ) -> Request :
253+ # A redirect that crossed origin must not receive the bearer token:
254+ # forward the request unchanged without acquiring or refreshing one,
255+ # and skip the HTTPS enforcement that only governs the stamping path.
256+ if _crosses_recorded_origin (request , ctx ):
257+ return request
229258 if ctx .options .get ("enforce_https" , True ) and not _is_https (request .url ):
230259 raise ServiceRequestError (
231260 "Bearer token authentication is not permitted for non-HTTPS URLs."
@@ -257,6 +286,13 @@ class AsyncBearerTokenPolicy(AsyncPolicy):
257286 the returned ``(name, value)`` pair is stamped on the retried request.
258287 A 401 invalidates the cached origin token; a 407 leaves it alone because
259288 the proxy, not the origin, rejected the request.
289+
290+ The token is acquired and stamped only while the request stays on the
291+ origin recorded on the first pass. When a downstream redirect reissues the
292+ request against a different origin (scheme, host, or effective port), the
293+ policy forwards the request unchanged — it does not acquire, refresh, or
294+ stamp a token — so the bearer token never reaches a foreign host. The
295+ HTTPS-enforcement check applies only on that same-origin stamping path.
260296 """
261297
262298 STAGE = Stage .AUTH
@@ -381,6 +417,11 @@ async def _authorize(
381417 * ,
382418 force_refresh : bool = False ,
383419 ) -> Request :
420+ # A redirect that crossed origin must not receive the bearer token:
421+ # forward the request unchanged without acquiring or refreshing one,
422+ # and skip the HTTPS enforcement that only governs the stamping path.
423+ if _crosses_recorded_origin (request , ctx ):
424+ return request
384425 if ctx .options .get ("enforce_https" , True ) and not _is_https (request .url ):
385426 raise ServiceRequestError (
386427 "Bearer token authentication is not permitted for non-HTTPS URLs."
@@ -398,6 +439,51 @@ async def _authorize(
398439 return request .with_header ("Authorization" , f"{ token .token_type } { token .token } " )
399440
400441
442+ _DEFAULT_PORTS : dict [str , int ] = {"https" : 443 , "http" : 80 }
443+ _AUTH_ORIGIN_KEY : str = "_auth_origin"
444+
445+
446+ def _origin (url : Url ) -> tuple [str , str , int | None ]:
447+ """Return the ``(scheme, host, port)`` origin tuple for ``url``.
448+
449+ The scheme and host are lower-cased and the port is resolved to its
450+ scheme default (443 for https, 80 for http) when not explicit, so two
451+ URLs that differ only in an implied/explicit default port compare equal.
452+
453+ Args:
454+ url: The URL to derive an origin from.
455+
456+ Returns:
457+ A ``(scheme, host, effective_port)`` tuple suitable for equality
458+ comparison.
459+ """
460+ scheme = url .scheme .lower ()
461+ port = url .port if url .port is not None else _DEFAULT_PORTS .get (scheme )
462+ return scheme , url .host .lower (), port
463+
464+
465+ def _crosses_recorded_origin (request : Request , ctx : PipelineContext ) -> bool :
466+ """Report whether ``request`` left the origin recorded for this operation.
467+
468+ On the first pass through an auth policy the request's origin is stored in
469+ ``ctx.data`` (which is per-operation), so a later redirect reissue can be
470+ compared against it. When the current origin differs, the credential must
471+ not be stamped — this is what stops a redirect to a foreign host from
472+ receiving the caller's credentials.
473+
474+ Args:
475+ request: The request the auth policy is about to forward.
476+ ctx: The per-operation pipeline context.
477+
478+ Returns:
479+ ``True`` when the request's origin differs from the one recorded on
480+ the first pass; ``False`` on the first pass or a same-origin reissue.
481+ """
482+ current = _origin (request .url )
483+ recorded : tuple [str , str , int | None ] = ctx .data .setdefault (_AUTH_ORIGIN_KEY , current )
484+ return recorded != current
485+
486+
401487def _is_https (url : Url ) -> bool :
402488 """Return True if ``url``'s scheme is ``https`` (case-insensitive)."""
403489 return url .scheme .lower () == "https"
0 commit comments