From dd0c9a6c7fa6ec8dad0e455033714ddb172f719d Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 17 Sep 2026 09:55:30 -0300 Subject: [PATCH] fix(auth): stop the admin API from sharing the client's header dict `GoTrueBaseAPI.__init__` stored the caller's `headers` dict by reference, and `GoTrueClient.__init__` hands the admin API its own `_headers`. That made `client._headers is client.admin._headers`. Anything that then rewrote the client's `Authorization` header in place also rewrote the admin one. In the `supabase` wrapper, `_listen_to_auth_events` does exactly that, so after `sign_in_with_password()` every `auth.admin.*` call ran with the signed-in user's token instead of the service key and failed with "User not allowed". Copying the dict on the way in fixes it for every consumer, including code that builds `GoTrueClient` or `GoTrueAdminAPI` directly, and makes in-place header mutation safe by construction. Fixes #1404 --- .../supabase_auth/_async/gotrue_base_api.py | 6 ++- .../supabase_auth/_sync/gotrue_base_api.py | 6 ++- src/auth/tests/_async/test_admin_headers.py | 38 +++++++++++++++++++ src/auth/tests/_sync/test_admin_headers.py | 38 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 src/auth/tests/_async/test_admin_headers.py create mode 100644 src/auth/tests/_sync/test_admin_headers.py 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"