diff --git a/src/auth/src/supabase_auth/_async/gotrue_base_api.py b/src/auth/src/supabase_auth/_async/gotrue_base_api.py index ae0bcaa9..76e73b3b 100644 --- a/src/auth/src/supabase_auth/_async/gotrue_base_api.py +++ b/src/auth/src/supabase_auth/_async/gotrue_base_api.py @@ -21,7 +21,11 @@ def __init__( proxy: Optional[str] = None, ) -> None: self._url = url - self._headers = headers + # Copy: callers pass a dict they keep mutating, and GoTrueClient hands + # its own `_headers` to the admin API. Sharing the object means a later + # `_headers["Authorization"] = ` silently downgrades every + # other holder of that dict. + self._headers = dict(headers) self._http_client = http_client or AsyncClient( verify=bool(verify), proxy=proxy, diff --git a/src/auth/src/supabase_auth/_sync/gotrue_base_api.py b/src/auth/src/supabase_auth/_sync/gotrue_base_api.py index 727478eb..627d13f0 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_base_api.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_base_api.py @@ -21,7 +21,11 @@ def __init__( proxy: Optional[str] = None, ) -> None: self._url = url - self._headers = headers + # Copy: callers pass a dict they keep mutating, and GoTrueClient hands + # its own `_headers` to the admin API. Sharing the object means a later + # `_headers["Authorization"] = ` silently downgrades every + # other holder of that dict. + self._headers = dict(headers) self._http_client = http_client or Client( verify=bool(verify), proxy=proxy, diff --git a/src/auth/tests/_async/test_admin_headers.py b/src/auth/tests/_async/test_admin_headers.py new file mode 100644 index 00000000..47192f22 --- /dev/null +++ b/src/auth/tests/_async/test_admin_headers.py @@ -0,0 +1,38 @@ +from supabase_auth import AsyncGoTrueAdminAPI, AsyncGoTrueClient + + +async def test_admin_api_does_not_share_the_client_header_dict() -> None: + """The admin API must hold its own headers. + + `GoTrueClient` passes its own `_headers` to the admin API. If that dict is + shared, anything that later rewrites the client's `Authorization` header + also rewrites the admin one, and admin calls silently run with the user's + token instead of the service key. + """ + client = AsyncGoTrueClient( + url="http://localhost:9998", + headers={"Authorization": "Bearer service-key"}, + auto_refresh_token=False, + persist_session=False, + ) + + assert client.admin._headers is not client._headers + + client._headers["Authorization"] = "Bearer user-token" + + assert client.admin._headers["Authorization"] == "Bearer service-key" + + +async def test_caller_headers_are_copied() -> None: + """A caller that keeps mutating the dict it passed in must not reach us.""" + caller_headers = {"Authorization": "Bearer service-key"} + + admin = AsyncGoTrueAdminAPI( + url="http://localhost:9998", + headers=caller_headers, + http_client=None, + ) + + caller_headers["Authorization"] = "Bearer user-token" + + assert admin._headers["Authorization"] == "Bearer service-key" diff --git a/src/auth/tests/_sync/test_admin_headers.py b/src/auth/tests/_sync/test_admin_headers.py new file mode 100644 index 00000000..05997b83 --- /dev/null +++ b/src/auth/tests/_sync/test_admin_headers.py @@ -0,0 +1,38 @@ +from supabase_auth import SyncGoTrueAdminAPI, SyncGoTrueClient + + +def test_admin_api_does_not_share_the_client_header_dict() -> None: + """The admin API must hold its own headers. + + `GoTrueClient` passes its own `_headers` to the admin API. If that dict is + shared, anything that later rewrites the client's `Authorization` header + also rewrites the admin one, and admin calls silently run with the user's + token instead of the service key. + """ + client = SyncGoTrueClient( + url="http://localhost:9998", + headers={"Authorization": "Bearer service-key"}, + auto_refresh_token=False, + persist_session=False, + ) + + assert client.admin._headers is not client._headers + + client._headers["Authorization"] = "Bearer user-token" + + assert client.admin._headers["Authorization"] == "Bearer service-key" + + +def test_caller_headers_are_copied() -> None: + """A caller that keeps mutating the dict it passed in must not reach us.""" + caller_headers = {"Authorization": "Bearer service-key"} + + admin = SyncGoTrueAdminAPI( + url="http://localhost:9998", + headers=caller_headers, + http_client=None, + ) + + caller_headers["Authorization"] = "Bearer user-token" + + assert admin._headers["Authorization"] == "Bearer service-key"