From f42ebc03e778fa0e386301a71e2a4282fd386e71 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 2 Oct 2025 11:51:38 -0300 Subject: [PATCH 1/3] feat(auth): add OAuth 2.1 client admin endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OAuth 2.1 client administration endpoints to supabase-auth based on the implementation from supabase-js PR #1582. This adds a new `admin.oauth` namespace with full CRUD operations for managing OAuth clients when the OAuth 2.1 server is enabled. New admin.oauth methods: - list_clients() - List OAuth clients with pagination - create_client() - Register new OAuth client - get_client() - Get client details by ID - delete_client() - Remove OAuth client - regenerate_client_secret() - Regenerate client secret All methods include proper error handling and follow existing patterns (similar to admin.mfa). These methods are only relevant when the OAuth 2.1 server is enabled in Supabase Auth. References: https://github.com/supabase/supabase-js/pull/1582 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../supabase_auth/_async/gotrue_admin_api.py | 141 ++++++++++++++++++ .../_async/gotrue_admin_oauth_api.py | 78 ++++++++++ .../supabase_auth/_sync/gotrue_admin_api.py | 141 ++++++++++++++++++ .../_sync/gotrue_admin_oauth_api.py | 78 ++++++++++ src/auth/src/supabase_auth/types.py | 126 ++++++++++++++++ .../tests/_async/test_gotrue_admin_api.py | 89 +++++++++++ 6 files changed, 653 insertions(+) create mode 100644 src/auth/src/supabase_auth/_async/gotrue_admin_oauth_api.py create mode 100644 src/auth/src/supabase_auth/_sync/gotrue_admin_oauth_api.py diff --git a/src/auth/src/supabase_auth/_async/gotrue_admin_api.py b/src/auth/src/supabase_auth/_async/gotrue_admin_api.py index 408f3da0..744f2ffa 100644 --- a/src/auth/src/supabase_auth/_async/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_async/gotrue_admin_api.py @@ -16,14 +16,20 @@ AuthMFAAdminDeleteFactorResponse, AuthMFAAdminListFactorsParams, AuthMFAAdminListFactorsResponse, + CreateOAuthClientParams, GenerateLinkParams, GenerateLinkResponse, InviteUserByEmailOptions, + OAuthClient, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, SignOutScope, User, UserResponse, ) from .gotrue_admin_mfa_api import AsyncGoTrueAdminMFAAPI +from .gotrue_admin_oauth_api import AsyncGoTrueAdminOAuthAPI from .gotrue_base_api import AsyncGoTrueBaseAPI @@ -48,6 +54,12 @@ def __init__( self.mfa = AsyncGoTrueAdminMFAAPI() self.mfa.list_factors = self._list_factors self.mfa.delete_factor = self._delete_factor + self.oauth = AsyncGoTrueAdminOAuthAPI() + self.oauth.list_clients = self._list_oauth_clients + self.oauth.create_client = self._create_oauth_client + self.oauth.get_client = self._get_oauth_client + self.oauth.delete_client = self._delete_oauth_client + self.oauth.regenerate_client_secret = self._regenerate_oauth_client_secret async def sign_out(self, jwt: str, scope: SignOutScope = "global") -> None: """ @@ -200,3 +212,132 @@ async def _delete_factor( def _validate_uuid(self, id: str) -> None: if not is_valid_uuid(id): raise ValueError(f"Invalid id, '{id}' is not a valid uuid") + + async def _list_oauth_clients( + self, + params: PageParams = None, + ) -> OAuthClientListResponse: + """ + Lists all OAuth clients with optional pagination. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + query = {} + if params: + if params.get("page") is not None: + query["page"] = str(params["page"]) + if params.get("per_page") is not None: + query["per_page"] = str(params["per_page"]) + + response = await self._request( + "GET", + "admin/oauth/clients", + query=query, + no_resolve_json=True, + ) + + data = response.json() + result = OAuthClientListResponse( + clients=[model_validate(OAuthClient, client) for client in data], + aud=data.get("aud") if isinstance(data, dict) else None, + ) + + # Parse pagination headers + total = response.headers.get("x-total-count") + if total: + result.total = int(total) + + links = response.headers.get("link") + if links: + for link in links.split(","): + parts = link.split(";") + if len(parts) >= 2: + page_match = parts[0].split("page=") + if len(page_match) >= 2: + page_num = int(page_match[1].split("&")[0].rstrip(">")) + rel = parts[1].split("=")[1].strip('"') + if rel == "next": + result.next_page = page_num + elif rel == "last": + result.last_page = page_num + + return result + + async def _create_oauth_client( + self, + params: CreateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Creates a new OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "POST", + "admin/oauth/clients", + body=params, + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + async def _get_oauth_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Gets details of a specific OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "GET", + f"admin/oauth/clients/{client_id}", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + async def _delete_oauth_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Deletes an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "DELETE", + f"admin/oauth/clients/{client_id}", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + async def _regenerate_oauth_client_secret( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Regenerates the secret for an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "POST", + f"admin/oauth/clients/{client_id}/regenerate_secret", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) diff --git a/src/auth/src/supabase_auth/_async/gotrue_admin_oauth_api.py b/src/auth/src/supabase_auth/_async/gotrue_admin_oauth_api.py new file mode 100644 index 00000000..9f35f0b0 --- /dev/null +++ b/src/auth/src/supabase_auth/_async/gotrue_admin_oauth_api.py @@ -0,0 +1,78 @@ +from ..types import ( + CreateOAuthClientParams, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, +) + + +class AsyncGoTrueAdminOAuthAPI: + """ + Contains all OAuth client administration methods. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + async def list_clients( + self, + params: PageParams = None, + ) -> OAuthClientListResponse: + """ + Lists all OAuth clients with optional pagination. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + async def create_client( + self, + params: CreateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Creates a new OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + async def get_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Gets details of a specific OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + async def delete_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Deletes an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + async def regenerate_client_secret( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Regenerates the secret for an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover diff --git a/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py b/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py index afbb75e0..a51053fb 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py @@ -16,14 +16,20 @@ AuthMFAAdminDeleteFactorResponse, AuthMFAAdminListFactorsParams, AuthMFAAdminListFactorsResponse, + CreateOAuthClientParams, GenerateLinkParams, GenerateLinkResponse, InviteUserByEmailOptions, + OAuthClient, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, SignOutScope, User, UserResponse, ) from .gotrue_admin_mfa_api import SyncGoTrueAdminMFAAPI +from .gotrue_admin_oauth_api import SyncGoTrueAdminOAuthAPI from .gotrue_base_api import SyncGoTrueBaseAPI @@ -48,6 +54,12 @@ def __init__( self.mfa = SyncGoTrueAdminMFAAPI() self.mfa.list_factors = self._list_factors self.mfa.delete_factor = self._delete_factor + self.oauth = SyncGoTrueAdminOAuthAPI() + self.oauth.list_clients = self._list_oauth_clients + self.oauth.create_client = self._create_oauth_client + self.oauth.get_client = self._get_oauth_client + self.oauth.delete_client = self._delete_oauth_client + self.oauth.regenerate_client_secret = self._regenerate_oauth_client_secret def sign_out(self, jwt: str, scope: SignOutScope = "global") -> None: """ @@ -200,3 +212,132 @@ def _delete_factor( def _validate_uuid(self, id: str) -> None: if not is_valid_uuid(id): raise ValueError(f"Invalid id, '{id}' is not a valid uuid") + + def _list_oauth_clients( + self, + params: PageParams = None, + ) -> OAuthClientListResponse: + """ + Lists all OAuth clients with optional pagination. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + query = {} + if params: + if params.get("page") is not None: + query["page"] = str(params["page"]) + if params.get("per_page") is not None: + query["per_page"] = str(params["per_page"]) + + response = self._request( + "GET", + "admin/oauth/clients", + query=query, + no_resolve_json=True, + ) + + data = response.json() + result = OAuthClientListResponse( + clients=[model_validate(OAuthClient, client) for client in data], + aud=data.get("aud") if isinstance(data, dict) else None, + ) + + # Parse pagination headers + total = response.headers.get("x-total-count") + if total: + result.total = int(total) + + links = response.headers.get("link") + if links: + for link in links.split(","): + parts = link.split(";") + if len(parts) >= 2: + page_match = parts[0].split("page=") + if len(page_match) >= 2: + page_num = int(page_match[1].split("&")[0].rstrip(">")) + rel = parts[1].split("=")[1].strip('"') + if rel == "next": + result.next_page = page_num + elif rel == "last": + result.last_page = page_num + + return result + + def _create_oauth_client( + self, + params: CreateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Creates a new OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "POST", + "admin/oauth/clients", + body=params, + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + def _get_oauth_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Gets details of a specific OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "GET", + f"admin/oauth/clients/{client_id}", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + def _delete_oauth_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Deletes an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "DELETE", + f"admin/oauth/clients/{client_id}", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) + + def _regenerate_oauth_client_secret( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Regenerates the secret for an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "POST", + f"admin/oauth/clients/{client_id}/regenerate_secret", + xform=lambda data: OAuthClientResponse( + client=model_validate(OAuthClient, data) + ), + ) diff --git a/src/auth/src/supabase_auth/_sync/gotrue_admin_oauth_api.py b/src/auth/src/supabase_auth/_sync/gotrue_admin_oauth_api.py new file mode 100644 index 00000000..c80a6e82 --- /dev/null +++ b/src/auth/src/supabase_auth/_sync/gotrue_admin_oauth_api.py @@ -0,0 +1,78 @@ +from ..types import ( + CreateOAuthClientParams, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, +) + + +class SyncGoTrueAdminOAuthAPI: + """ + Contains all OAuth client administration methods. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + def list_clients( + self, + params: PageParams = None, + ) -> OAuthClientListResponse: + """ + Lists all OAuth clients with optional pagination. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + def create_client( + self, + params: CreateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Creates a new OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + def get_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Gets details of a specific OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + def delete_client( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Deletes an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover + + def regenerate_client_secret( + self, + client_id: str, + ) -> OAuthClientResponse: + """ + Regenerates the secret for an OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + raise NotImplementedError() # pragma: no cover diff --git a/src/auth/src/supabase_auth/types.py b/src/auth/src/supabase_auth/types.py index c41319c7..caa34196 100644 --- a/src/auth/src/supabase_auth/types.py +++ b/src/auth/src/supabase_auth/types.py @@ -848,6 +848,128 @@ class JWKSet(TypedDict): keys: List[JWK] +OAuthClientGrantType = Literal["authorization_code", "refresh_token"] +""" +OAuth client grant types supported by the OAuth 2.1 server. +Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +""" + +OAuthClientResponseType = Literal["code"] +""" +OAuth client response types supported by the OAuth 2.1 server. +Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +""" + +OAuthClientType = Literal["public", "confidential"] +""" +OAuth client type indicating whether the client can keep credentials confidential. +Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +""" + +OAuthClientRegistrationType = Literal["dynamic", "manual"] +""" +OAuth client registration type. +Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. +""" + + +class OAuthClient(BaseModel): + """ + OAuth client object returned from the OAuth 2.1 server. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + client_id: str + """Unique identifier for the OAuth client""" + client_name: str + """Human-readable name of the OAuth client""" + client_secret: Optional[str] = None + """Client secret (only returned on registration and regeneration)""" + client_type: OAuthClientType + """Type of OAuth client""" + token_endpoint_auth_method: str + """Token endpoint authentication method""" + registration_type: OAuthClientRegistrationType + """Registration type of the client""" + client_uri: Optional[str] = None + """URI of the OAuth client""" + redirect_uris: List[str] + """Array of allowed redirect URIs""" + grant_types: List[OAuthClientGrantType] + """Array of allowed grant types""" + response_types: List[OAuthClientResponseType] + """Array of allowed response types""" + scope: Optional[str] = None + """Scope of the OAuth client""" + created_at: str + """Timestamp when the client was created""" + updated_at: str + """Timestamp when the client was last updated""" + + +class CreateOAuthClientParams(TypedDict): + """ + Parameters for creating a new OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + client_name: str + """Human-readable name of the OAuth client""" + client_uri: NotRequired[str] + """URI of the OAuth client""" + redirect_uris: List[str] + """Array of allowed redirect URIs""" + grant_types: NotRequired[List[OAuthClientGrantType]] + """Array of allowed grant types (optional, defaults to authorization_code and refresh_token)""" + response_types: NotRequired[List[OAuthClientResponseType]] + """Array of allowed response types (optional, defaults to code)""" + scope: NotRequired[str] + """Scope of the OAuth client""" + + +class OAuthClientResponse(BaseModel): + """ + Response type for OAuth client operations. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + client: Optional[OAuthClient] = None + + +class Pagination(BaseModel): + """ + Pagination information for list responses. + """ + + next_page: Optional[int] = None + last_page: int = 0 + total: int = 0 + + +class OAuthClientListResponse(BaseModel): + """ + Response type for listing OAuth clients. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + clients: List[OAuthClient] + aud: Optional[str] = None + next_page: Optional[int] = None + last_page: int = 0 + total: int = 0 + + +class PageParams(TypedDict): + """ + Pagination parameters. + """ + + page: NotRequired[int] + """Page number""" + per_page: NotRequired[int] + """Number of items per page""" + + for model in [ AMREntry, AuthResponse, @@ -868,6 +990,10 @@ class JWKSet(TypedDict): AuthMFAAdminDeleteFactorResponse, AuthMFAAdminListFactorsResponse, GenerateLinkProperties, + OAuthClient, + OAuthClientResponse, + OAuthClientListResponse, + Pagination, ]: try: # pydantic > 2 diff --git a/src/auth/tests/_async/test_gotrue_admin_api.py b/src/auth/tests/_async/test_gotrue_admin_api.py index 69253c26..02096a40 100644 --- a/src/auth/tests/_async/test_gotrue_admin_api.py +++ b/src/auth/tests/_async/test_gotrue_admin_api.py @@ -639,3 +639,92 @@ async def test_delete_factor_invalid_id_raises_error(): await service_role_api_client()._delete_factor( {"user_id": str(uuid.uuid4()), "id": "invalid_id"} ) + + +async def test_create_oauth_client(): + """Test creating an OAuth client.""" + try: + response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client", + "redirect_uris": ["https://example.com/callback"], + } + ) + assert response.client is not None + assert response.client.client_name == "Test OAuth Client" + assert response.client.client_id is not None + except AuthApiError: + # OAuth 2.1 server might not be enabled in the test environment + pass + + +async def test_list_oauth_clients(): + """Test listing OAuth clients.""" + try: + response = await service_role_api_client().oauth.list_clients() + assert response.clients is not None + assert isinstance(response.clients, list) + except AuthApiError: + # OAuth 2.1 server might not be enabled in the test environment + pass + + +async def test_get_oauth_client(): + """Test getting an OAuth client by ID.""" + try: + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Get", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.get_client(client_id) + assert response.client is not None + assert response.client.client_id == client_id + except AuthApiError: + # OAuth 2.1 server might not be enabled in the test environment + pass + + +async def test_delete_oauth_client(): + """Test deleting an OAuth client.""" + try: + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Delete", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.delete_client(client_id) + assert response.client is not None + except AuthApiError: + # OAuth 2.1 server might not be enabled in the test environment + pass + + +async def test_regenerate_oauth_client_secret(): + """Test regenerating an OAuth client secret.""" + try: + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Regenerate", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.regenerate_client_secret( + client_id + ) + assert response.client is not None + assert response.client.client_secret is not None + except AuthApiError: + # OAuth 2.1 server might not be enabled in the test environment + pass From b48c6ff53d58afde6c953d81b8096f6bfa875b0c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 6 Oct 2025 12:19:26 -0300 Subject: [PATCH 2/3] fix(auth): handle empty responses and OAuth client list parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix multiple issues with OAuth 2.1 client admin endpoints: - Add missing return statement in _request method when no xform - Handle empty responses (204 No Content) from DELETE operations - Fix list_clients to handle both list and dict response formats - Handle empty data in delete_client response - Update test expectations for DELETE operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../supabase_auth/_async/gotrue_admin_api.py | 6 +- .../supabase_auth/_async/gotrue_base_api.py | 10 +- .../supabase_auth/_sync/gotrue_admin_api.py | 6 +- .../supabase_auth/_sync/gotrue_base_api.py | 10 +- .../tests/_async/test_gotrue_admin_api.py | 117 ++++++++---------- 5 files changed, 75 insertions(+), 74 deletions(-) diff --git a/src/auth/src/supabase_auth/_async/gotrue_admin_api.py b/src/auth/src/supabase_auth/_async/gotrue_admin_api.py index 744f2ffa..877e187e 100644 --- a/src/auth/src/supabase_auth/_async/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_async/gotrue_admin_api.py @@ -239,8 +239,10 @@ async def _list_oauth_clients( ) data = response.json() + # API may return either a list directly or a dict with "clients" key + clients_data = data.get("clients", data) if isinstance(data, dict) else data result = OAuthClientListResponse( - clients=[model_validate(OAuthClient, client) for client in data], + clients=[model_validate(OAuthClient, client) for client in clients_data], aud=data.get("aud") if isinstance(data, dict) else None, ) @@ -319,7 +321,7 @@ async def _delete_oauth_client( "DELETE", f"admin/oauth/clients/{client_id}", xform=lambda data: OAuthClientResponse( - client=model_validate(OAuthClient, data) + client=model_validate(OAuthClient, data) if data else None ), ) 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 21d0b444..8ecbe2cb 100644 --- a/src/auth/src/supabase_auth/_async/gotrue_base_api.py +++ b/src/auth/src/supabase_auth/_async/gotrue_base_api.py @@ -118,8 +118,16 @@ async def _request( json=model_dump(body) if isinstance(body, BaseModel) else body, ) response.raise_for_status() - result = response if no_resolve_json else response.json() + if no_resolve_json: + result = response + else: + # Handle empty responses (e.g., 204 No Content from DELETE) + if response.content: + result = response.json() + else: + result = {} if xform: return xform(result) + return result except Exception as e: raise handle_exception(e) diff --git a/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py b/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py index a51053fb..06ba5e93 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py @@ -239,8 +239,10 @@ def _list_oauth_clients( ) data = response.json() + # API may return either a list directly or a dict with "clients" key + clients_data = data.get("clients", data) if isinstance(data, dict) else data result = OAuthClientListResponse( - clients=[model_validate(OAuthClient, client) for client in data], + clients=[model_validate(OAuthClient, client) for client in clients_data], aud=data.get("aud") if isinstance(data, dict) else None, ) @@ -319,7 +321,7 @@ def _delete_oauth_client( "DELETE", f"admin/oauth/clients/{client_id}", xform=lambda data: OAuthClientResponse( - client=model_validate(OAuthClient, data) + client=model_validate(OAuthClient, data) if data else None ), ) 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 c6c2b7b0..e34df951 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_base_api.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_base_api.py @@ -118,8 +118,16 @@ def _request( json=model_dump(body) if isinstance(body, BaseModel) else body, ) response.raise_for_status() - result = response if no_resolve_json else response.json() + if no_resolve_json: + result = response + else: + # Handle empty responses (e.g., 204 No Content from DELETE) + if response.content: + result = response.json() + else: + result = {} if xform: return xform(result) + return result except Exception as e: raise handle_exception(e) diff --git a/src/auth/tests/_async/test_gotrue_admin_api.py b/src/auth/tests/_async/test_gotrue_admin_api.py index 02096a40..5fb30d87 100644 --- a/src/auth/tests/_async/test_gotrue_admin_api.py +++ b/src/auth/tests/_async/test_gotrue_admin_api.py @@ -643,88 +643,69 @@ async def test_delete_factor_invalid_id_raises_error(): async def test_create_oauth_client(): """Test creating an OAuth client.""" - try: - response = await service_role_api_client().oauth.create_client( - { - "client_name": "Test OAuth Client", - "redirect_uris": ["https://example.com/callback"], - } - ) - assert response.client is not None - assert response.client.client_name == "Test OAuth Client" - assert response.client.client_id is not None - except AuthApiError: - # OAuth 2.1 server might not be enabled in the test environment - pass + response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client", + "redirect_uris": ["https://example.com/callback"], + } + ) + assert response.client is not None + assert response.client.client_name == "Test OAuth Client" + assert response.client.client_id is not None async def test_list_oauth_clients(): """Test listing OAuth clients.""" - try: - response = await service_role_api_client().oauth.list_clients() - assert response.clients is not None - assert isinstance(response.clients, list) - except AuthApiError: - # OAuth 2.1 server might not be enabled in the test environment - pass + response = await service_role_api_client().oauth.list_clients() + assert response.clients is not None + assert isinstance(response.clients, list) async def test_get_oauth_client(): """Test getting an OAuth client by ID.""" - try: - # First create a client - create_response = await service_role_api_client().oauth.create_client( - { - "client_name": "Test OAuth Client for Get", - "redirect_uris": ["https://example.com/callback"], - } - ) - if create_response.client: - client_id = create_response.client.client_id - response = await service_role_api_client().oauth.get_client(client_id) - assert response.client is not None - assert response.client.client_id == client_id - except AuthApiError: - # OAuth 2.1 server might not be enabled in the test environment - pass + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Get", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.get_client(client_id) + assert response.client is not None + assert response.client.client_id == client_id async def test_delete_oauth_client(): """Test deleting an OAuth client.""" - try: - # First create a client - create_response = await service_role_api_client().oauth.create_client( - { - "client_name": "Test OAuth Client for Delete", - "redirect_uris": ["https://example.com/callback"], - } - ) - if create_response.client: - client_id = create_response.client.client_id - response = await service_role_api_client().oauth.delete_client(client_id) - assert response.client is not None - except AuthApiError: - # OAuth 2.1 server might not be enabled in the test environment - pass + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Delete", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.delete_client(client_id) + # DELETE operations return an empty response + assert response is not None async def test_regenerate_oauth_client_secret(): """Test regenerating an OAuth client secret.""" - try: - # First create a client - create_response = await service_role_api_client().oauth.create_client( - { - "client_name": "Test OAuth Client for Regenerate", - "redirect_uris": ["https://example.com/callback"], - } + # First create a client + create_response = await service_role_api_client().oauth.create_client( + { + "client_name": "Test OAuth Client for Regenerate", + "redirect_uris": ["https://example.com/callback"], + } + ) + if create_response.client: + client_id = create_response.client.client_id + response = await service_role_api_client().oauth.regenerate_client_secret( + client_id ) - if create_response.client: - client_id = create_response.client.client_id - response = await service_role_api_client().oauth.regenerate_client_secret( - client_id - ) - assert response.client is not None - assert response.client.client_secret is not None - except AuthApiError: - # OAuth 2.1 server might not be enabled in the test environment - pass + assert response.client is not None + assert response.client.client_secret is not None From d81c379ea373708c4b6553c95637c6b54f212ce2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 6 Oct 2025 12:19:34 -0300 Subject: [PATCH 3/3] chore(auth): upgrade GoTrue to v2.180.0 and enable OAuth server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade all GoTrue containers from v2.178.0/v2.169.0 to v2.180.0 - Enable OAuth 2.1 server on autoconfirm container for testing - Add GOTRUE_OAUTH_SERVER_ENABLED and GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/auth/infra/docker-compose.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/auth/infra/docker-compose.yml b/src/auth/infra/docker-compose.yml index 6506cea4..4766cc0b 100644 --- a/src/auth/infra/docker-compose.yml +++ b/src/auth/infra/docker-compose.yml @@ -2,7 +2,7 @@ name: auth-tests services: gotrue: # Signup enabled, autoconfirm off - image: supabase/auth:v2.178.0 + image: supabase/auth:v2.180.0 ports: - '9999:9999' environment: @@ -42,7 +42,7 @@ services: - db restart: on-failure autoconfirm: # Signup enabled, autoconfirm on - image: supabase/auth:v2.178.0 + image: supabase/auth:v2.180.0 ports: - '9998:9998' environment: @@ -68,11 +68,13 @@ services: GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com GOTRUE_COOKIE_KEY: 'sb' + GOTRUE_OAUTH_SERVER_ENABLED: 'true' + GOTRUE_OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: 'true' depends_on: - db restart: on-failure autoconfirm_with_asymmetric_keys: # Signup enabled, autoconfirm on - image: supabase/auth:v2.169.0 + image: supabase/auth:v2.180.0 ports: - '9996:9996' environment: @@ -102,7 +104,7 @@ services: - db restart: on-failure disabled: # Signup disabled - image: supabase/auth:v2.178.0 + image: supabase/auth:v2.180.0 ports: - '9997:9997' environment: