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