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: 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..877e187e 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,134 @@ 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() + # 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 clients_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) if data else None + ), + ) + + 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/_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 afbb75e0..06ba5e93 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,134 @@ 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() + # 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 clients_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) if data else None + ), + ) + + 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/_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/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..5fb30d87 100644 --- a/src/auth/tests/_async/test_gotrue_admin_api.py +++ b/src/auth/tests/_async/test_gotrue_admin_api.py @@ -639,3 +639,73 @@ 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.""" + 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.""" + 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.""" + # 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.""" + # 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.""" + # 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