diff --git a/src/auth/infra/docker-compose.yml b/src/auth/infra/docker-compose.yml index 7ad97278..7ba29cbc 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: @@ -43,7 +43,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: @@ -70,11 +70,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: @@ -105,7 +107,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 8a5dcf04..84e2deb8 100644 --- a/src/auth/src/supabase_auth/_async/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_async/gotrue_admin_api.py @@ -6,7 +6,7 @@ from pydantic import TypeAdapter from ..helpers import ( - is_valid_uuid, + validate_uuid, model_validate, parse_link_response, parse_user_response, @@ -18,15 +18,22 @@ AuthMFAAdminDeleteFactorResponse, AuthMFAAdminListFactorsParams, AuthMFAAdminListFactorsResponse, + CreateOAuthClientParams, GenerateLinkParams, GenerateLinkResponse, InviteUserByEmailOptions, + OAuthClient, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, SignOutScope, + UpdateOAuthClientParams, User, UserList, UserResponse, ) from .gotrue_admin_mfa_api import AsyncGoTrueAdminMFAAPI +from .gotrue_admin_oauth_api import AsyncGoTrueAdminOAuthAPI from .gotrue_base_api import AsyncGoTrueBaseAPI @@ -50,8 +57,15 @@ def __init__( ) # TODO(@o-santi): why is is this done this way? self.mfa = AsyncGoTrueAdminMFAAPI() - self.mfa.list_factors = self._list_factors # type: ignore - self.mfa.delete_factor = self._delete_factor # type: ignore + self.mfa.list_factors = self._list_factors # type: ignore + self.mfa.delete_factor = self._delete_factor # type: ignore + self.oauth = AsyncGoTrueAdminOAuthAPI() + self.oauth.list_clients = self._list_oauth_clients # type: ignore + self.oauth.create_client = self._create_oauth_client # type: ignore + self.oauth.get_client = self._get_oauth_client # type: ignore + self.oauth.update_client = self._update_oauth_client # type: ignore + self.oauth.delete_client = self._delete_oauth_client # type: ignore + self.oauth.regenerate_client_secret = self._regenerate_oauth_client_secret # type: ignore async def sign_out(self, jwt: str, scope: SignOutScope = "global") -> None: """ @@ -139,7 +153,7 @@ async def get_user_by_id(self, uid: str) -> UserResponse: This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(uid) + validate_uuid(uid) response = await self._request( "GET", @@ -158,7 +172,7 @@ async def update_user_by_id( This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(uid) + validate_uuid(uid) response = await self._request( "PUT", f"admin/users/{uid}", @@ -173,7 +187,7 @@ async def delete_user(self, id: str, should_soft_delete: bool = False) -> None: This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(id) + validate_uuid(id) body = {"should_soft_delete": should_soft_delete} await self._request("DELETE", f"admin/users/{id}", body=body) @@ -181,7 +195,7 @@ async def _list_factors( self, params: AuthMFAAdminListFactorsParams, ) -> AuthMFAAdminListFactorsResponse: - self._validate_uuid(params.get("user_id")) + validate_uuid(params.get("user_id")) response = await self._request( "GET", f"admin/users/{params.get('user_id')}/factors", @@ -192,16 +206,154 @@ async def _delete_factor( self, params: AuthMFAAdminDeleteFactorParams, ) -> AuthMFAAdminDeleteFactorResponse: - self._validate_uuid(params.get("user_id")) - self._validate_uuid(params.get("id")) + validate_uuid(params.get("user_id")) + validate_uuid(params.get("id")) response = await self._request( "DELETE", f"admin/users/{params.get('user_id')}/factors/{params.get('id')}", ) return model_validate(AuthMFAAdminDeleteFactorResponse, response.content) - def _validate_uuid(self, id: str | None) -> None: - if id is None: - raise ValueError("Invalid id, id cannot be 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 = 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. + """ + if params: + query = QueryParams(page=params.page, per_page=params.per_page) + else: + query = None + response = await self._request( + "GET", + "admin/oauth/clients", + query=query, + no_resolve_json=True, + ) + + result = model_validate(OAuthClientListResponse, response.content) + + # 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. + """ + response = await self._request( + "POST", + "admin/oauth/clients", + body=params, + ) + + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + 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. + """ + validate_uuid(client_id) + response = await self._request( + "GET", + f"admin/oauth/clients/{client_id}", + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + + async def _update_oauth_client( + self, + client_id: str, + params: UpdateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Updates 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. + """ + validate_uuid(client_id) + response = await self._request( + "PUT", + f"admin/oauth/clients/{client_id}", + body=params, + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + + async def _delete_oauth_client( + self, + client_id: str, + ) -> None: + """ + 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. + """ + validate_uuid(client_id) + await self._request( + "DELETE", + f"admin/oauth/clients/{client_id}", + ) + + 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. + """ + validate_uuid(client_id) + response = await self._request( + "POST", + f"admin/oauth/clients/{client_id}/regenerate_secret", + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) 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..3b4e1c8e --- /dev/null +++ b/src/auth/src/supabase_auth/_async/gotrue_admin_oauth_api.py @@ -0,0 +1,94 @@ +from ..types import ( + CreateOAuthClientParams, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, + UpdateOAuthClientParams, +) +from typing import Optional + + +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: Optional[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 update_client( + self, + client_id: str, + params: UpdateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Updates 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 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 ea6e9f4a..0007b0c5 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_admin_api.py @@ -6,7 +6,7 @@ from pydantic import TypeAdapter from ..helpers import ( - is_valid_uuid, + validate_uuid, model_validate, parse_link_response, parse_user_response, @@ -18,15 +18,22 @@ AuthMFAAdminDeleteFactorResponse, AuthMFAAdminListFactorsParams, AuthMFAAdminListFactorsResponse, + CreateOAuthClientParams, GenerateLinkParams, GenerateLinkResponse, InviteUserByEmailOptions, + OAuthClient, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, SignOutScope, + UpdateOAuthClientParams, User, UserList, UserResponse, ) from .gotrue_admin_mfa_api import SyncGoTrueAdminMFAAPI +from .gotrue_admin_oauth_api import SyncGoTrueAdminOAuthAPI from .gotrue_base_api import SyncGoTrueBaseAPI @@ -50,8 +57,15 @@ def __init__( ) # TODO(@o-santi): why is is this done this way? self.mfa = SyncGoTrueAdminMFAAPI() - self.mfa.list_factors = self._list_factors # type: ignore - self.mfa.delete_factor = self._delete_factor # type: ignore + self.mfa.list_factors = self._list_factors # type: ignore + self.mfa.delete_factor = self._delete_factor # type: ignore + self.oauth = SyncGoTrueAdminOAuthAPI() + self.oauth.list_clients = self._list_oauth_clients # type: ignore + self.oauth.create_client = self._create_oauth_client # type: ignore + self.oauth.get_client = self._get_oauth_client # type: ignore + self.oauth.update_client = self._update_oauth_client # type: ignore + self.oauth.delete_client = self._delete_oauth_client # type: ignore + self.oauth.regenerate_client_secret = self._regenerate_oauth_client_secret # type: ignore def sign_out(self, jwt: str, scope: SignOutScope = "global") -> None: """ @@ -139,7 +153,7 @@ def get_user_by_id(self, uid: str) -> UserResponse: This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(uid) + validate_uuid(uid) response = self._request( "GET", @@ -158,7 +172,7 @@ def update_user_by_id( This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(uid) + validate_uuid(uid) response = self._request( "PUT", f"admin/users/{uid}", @@ -173,7 +187,7 @@ def delete_user(self, id: str, should_soft_delete: bool = False) -> None: This function should only be called on a server. Never expose your `service_role` key in the browser. """ - self._validate_uuid(id) + validate_uuid(id) body = {"should_soft_delete": should_soft_delete} self._request("DELETE", f"admin/users/{id}", body=body) @@ -181,7 +195,7 @@ def _list_factors( self, params: AuthMFAAdminListFactorsParams, ) -> AuthMFAAdminListFactorsResponse: - self._validate_uuid(params.get("user_id")) + validate_uuid(params.get("user_id")) response = self._request( "GET", f"admin/users/{params.get('user_id')}/factors", @@ -192,16 +206,154 @@ def _delete_factor( self, params: AuthMFAAdminDeleteFactorParams, ) -> AuthMFAAdminDeleteFactorResponse: - self._validate_uuid(params.get("user_id")) - self._validate_uuid(params.get("id")) + validate_uuid(params.get("user_id")) + validate_uuid(params.get("id")) response = self._request( "DELETE", f"admin/users/{params.get('user_id')}/factors/{params.get('id')}", ) return model_validate(AuthMFAAdminDeleteFactorResponse, response.content) - def _validate_uuid(self, id: str | None) -> None: - if id is None: - raise ValueError("Invalid id, id cannot be 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 = 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. + """ + if params: + query = QueryParams(page=params.page, per_page=params.per_page) + else: + query = None + response = self._request( + "GET", + "admin/oauth/clients", + query=query, + no_resolve_json=True, + ) + + result = model_validate(OAuthClientListResponse, response.content) + + # 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. + """ + response = self._request( + "POST", + "admin/oauth/clients", + body=params, + ) + + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + 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. + """ + validate_uuid(client_id) + response = self._request( + "GET", + f"admin/oauth/clients/{client_id}", + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + + def _update_oauth_client( + self, + client_id: str, + params: UpdateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Updates 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. + """ + validate_uuid(client_id) + response = self._request( + "PUT", + f"admin/oauth/clients/{client_id}", + body=params, + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) + + def _delete_oauth_client( + self, + client_id: str, + ) -> None: + """ + 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. + """ + validate_uuid(client_id) + self._request( + "DELETE", + f"admin/oauth/clients/{client_id}", + ) + + 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. + """ + validate_uuid(client_id) + response = self._request( + "POST", + f"admin/oauth/clients/{client_id}/regenerate_secret", + ) + return OAuthClientResponse( + client=model_validate(OAuthClient, response.content) + ) 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..56f1b5c1 --- /dev/null +++ b/src/auth/src/supabase_auth/_sync/gotrue_admin_oauth_api.py @@ -0,0 +1,94 @@ +from ..types import ( + CreateOAuthClientParams, + OAuthClientListResponse, + OAuthClientResponse, + PageParams, + UpdateOAuthClientParams, +) +from typing import Optional + + +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: Optional[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 update_client( + self, + client_id: str, + params: UpdateOAuthClientParams, + ) -> OAuthClientResponse: + """ + Updates 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 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_client.py b/src/auth/src/supabase_auth/_sync/gotrue_client.py index 0716260f..9e5ea677 100644 --- a/src/auth/src/supabase_auth/_sync/gotrue_client.py +++ b/src/auth/src/supabase_auth/_sync/gotrue_client.py @@ -441,7 +441,9 @@ def sign_in_with_oauth( ) return OAuthResponse(provider=provider, url=url_with_qs) - def link_identity(self, credentials: SignInWithOAuthCredentials) -> OAuthResponse: + def link_identity( + self, credentials: SignInWithOAuthCredentials + ) -> OAuthResponse: provider = credentials["provider"] options = credentials.get("options", {}) redirect_to = options.get("redirect_to") @@ -741,7 +743,9 @@ def set_session(self, access_token: str, refresh_token: str) -> AuthResponse: self._notify_all_subscribers("TOKEN_REFRESHED", session) return AuthResponse(session=session, user=session.user) - def refresh_session(self, refresh_token: Optional[str] = None) -> AuthResponse: + def refresh_session( + self, refresh_token: Optional[str] = None + ) -> AuthResponse: """ Returns a new session, regardless of expiry status. @@ -1149,7 +1153,9 @@ def _get_url_for_provider( if self._flow_type == "pkce": code_verifier = generate_pkce_verifier() code_challenge = generate_pkce_challenge(code_verifier) - self._storage.set_item(f"{self._storage_key}-code-verifier", code_verifier) + self._storage.set_item( + f"{self._storage_key}-code-verifier", code_verifier + ) code_challenge_method = ( "plain" if code_verifier == code_challenge else "s256" ) diff --git a/src/auth/src/supabase_auth/helpers.py b/src/auth/src/supabase_auth/helpers.py index a0ee444e..c3ad4e42 100644 --- a/src/auth/src/supabase_auth/helpers.py +++ b/src/auth/src/supabase_auth/helpers.py @@ -298,3 +298,9 @@ def is_valid_uuid(value: str) -> bool: return True except ValueError: return False + +def validate_uuid(id: str | None) -> None: + if id is None: + raise ValueError("Invalid id, id is None") + if not is_valid_uuid(id): + raise ValueError(f"Invalid id, '{id}' is not a valid uuid") \ No newline at end of file diff --git a/src/auth/src/supabase_auth/types.py b/src/auth/src/supabase_auth/types.py index 3ec9d812..5306c359 100644 --- a/src/auth/src/supabase_auth/types.py +++ b/src/auth/src/supabase_auth/types.py @@ -869,6 +869,154 @@ 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. +""" + +OAuthClientTokenEndpointAuthMethod = Literal["none", "client_secret_basic", "client_secret_post"] +""" +OAuth client token endpoint authentication method. +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 client identifier""" + client_name: str + """Human-readable name of the client application""" + client_secret: Optional[str] = None + """Client secret for confidential clients (only returned on registration/regeneration)""" + client_type: OAuthClientType + """Type of the client""" + token_endpoint_auth_method: OAuthClientTokenEndpointAuthMethod + """Authentication method for the token endpoint""" + registration_type: OAuthClientRegistrationType + """Registration type of the client""" + client_uri: Optional[str] = None + """URL of the client application's homepage""" + logo_uri: Optional[str] = None + """URL of the client application's logo""" + redirect_uris: List[str] + """Array of redirect URIs used by the client""" + grant_types: List[OAuthClientGrantType] + """OAuth grant types the client is authorized to use""" + response_types: List[OAuthClientResponseType] + """OAuth response types the client can use""" + scope: Optional[str] = None + """Space-separated list of scope values""" + created_at: str + """Timestamp when the client was created""" + updated_at: str + """Timestamp when the client was last updated""" + + +class CreateOAuthClientParams(BaseModel): + """ + 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: Optional[str] = None + """URL of the client application's homepage""" + logo_uri: Optional[str] = None + """URL of the client application's logo""" + redirect_uris: List[str] + """Array of redirect URIs used by the client""" + grant_types: Optional[List[OAuthClientGrantType]] = None + """OAuth grant types the client is authorized to use (optional, defaults to authorization_code and refresh_token)""" + response_types: Optional[List[OAuthClientResponseType]] = None + """OAuth response types the client can use (optional, defaults to code)""" + scope: Optional[str] = None + """Space-separated list of scope values""" + +class UpdateOAuthClientParams(BaseModel): + """ + Parameters for updating an existing OAuth client. + Only relevant when the OAuth 2.1 server is enabled in Supabase Auth. + """ + + client_name: Optional[str] = None + """Human-readable name of the OAuth client""" + client_uri: Optional[str] = None + """URI of the OAuth client""" + logo_uri: Optional[str] = None + """URI of the OAuth client's logo""" + redirect_uris: Optional[List[str]] = None + """Array of allowed redirect URIs""" + grant_types: Optional[List[OAuthClientGrantType]] = None + """Array of allowed grant types""" + +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(BaseModel): + """ + Pagination parameters. + """ + + page: Optional[int] = None + """Page number""" + per_page: Optional[int] = None + """Number of items per page""" + + for model in [ AMREntry, AuthResponse, @@ -889,6 +1037,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 94c1c573..ab13de1f 100644 --- a/src/auth/tests/_async/test_gotrue_admin_api.py +++ b/src/auth/tests/_async/test_gotrue_admin_api.py @@ -9,7 +9,7 @@ AuthSessionMissingError, AuthWeakPasswordError, ) - +from supabase_auth.types import CreateOAuthClientParams, UpdateOAuthClientParams from .clients import ( auth_client, auth_client_with_session, @@ -604,3 +604,101 @@ 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( + CreateOAuthClientParams( + 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.""" + client = service_role_api_client() + await client.oauth.create_client( + CreateOAuthClientParams( + client_name="Test OAuth Client", + redirect_uris=["https://example.com/callback"], + ) + ) + response = await client.oauth.list_clients() + assert len(response.clients) > 0 + assert any(client.client_name == "Test OAuth Client" for client in response.clients) + assert any(client.client_id is not None for client in response.clients) + + +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( + CreateOAuthClientParams( + 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 + +# Server is not yet released, so this test is not yet relevant. +# async def test_update_oauth_client(): +# """Test updating an OAuth client.""" +# # First create a client +# client = service_role_api_client() +# create_response = await client.oauth.create_client( +# CreateOAuthClientParams( +# client_name="Test OAuth Client for Update", +# redirect_uris=["https://example.com/callback"], +# ) +# ) +# assert create_response.client is not None +# client_id = create_response.client.client_id +# response = await client.oauth.update_client( +# client_id, +# UpdateOAuthClientParams( +# client_name="Updated Test OAuth Client", +# ) +# ) +# assert response.client is not None +# assert response.client.client_name == "Updated Test OAuth Client" + +async def test_delete_oauth_client(): + """Test deleting an OAuth client.""" + # First create a client + client = service_role_api_client() + create_response = await client.oauth.create_client( + CreateOAuthClientParams( + client_name="Test OAuth Client for Delete", + redirect_uris=["https://example.com/callback"], + ) + ) + assert create_response.client is not None + client_id = create_response.client.client_id + await client.oauth.delete_client(client_id) + + +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( + CreateOAuthClientParams( + 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 diff --git a/src/auth/tests/_sync/test_gotrue.py b/src/auth/tests/_sync/test_gotrue.py index f15dcbd8..e8318fa0 100644 --- a/src/auth/tests/_sync/test_gotrue.py +++ b/src/auth/tests/_sync/test_gotrue.py @@ -331,7 +331,9 @@ def test_exchange_code_for_session(): client._flow_type = "pkce" # Test the PKCE URL generation which is needed for exchange_code_for_session - url, params = client._get_url_for_provider(f"{client._url}/authorize", "github", {}) + url, params = client._get_url_for_provider( + f"{client._url}/authorize", "github", {} + ) # Verify PKCE parameters were added assert "code_challenge" in params diff --git a/src/auth/tests/_sync/test_gotrue_admin_api.py b/src/auth/tests/_sync/test_gotrue_admin_api.py index 75be02f7..fc19c377 100644 --- a/src/auth/tests/_sync/test_gotrue_admin_api.py +++ b/src/auth/tests/_sync/test_gotrue_admin_api.py @@ -9,7 +9,7 @@ AuthSessionMissingError, AuthWeakPasswordError, ) - +from supabase_auth.types import CreateOAuthClientParams, UpdateOAuthClientParams from .clients import ( auth_client, auth_client_with_session, @@ -142,13 +142,11 @@ def test_modify_confirm_email_using_update_user_by_id(): def test_invalid_credential_sign_in_with_phone(): try: - response = ( - client_api_auto_confirm_off_signups_enabled_client().sign_in_with_password( - { - "phone": "+123456789", - "password": "strong_pwd", - } - ) + response = client_api_auto_confirm_off_signups_enabled_client().sign_in_with_password( + { + "phone": "+123456789", + "password": "strong_pwd", + } ) except AuthApiError as e: assert e.to_dict() @@ -156,13 +154,11 @@ def test_invalid_credential_sign_in_with_phone(): def test_invalid_credential_sign_in_with_email(): try: - response = ( - client_api_auto_confirm_off_signups_enabled_client().sign_in_with_password( - { - "email": "unknown_user@unknowndomain.com", - "password": "strong_pwd", - } - ) + response = client_api_auto_confirm_off_signups_enabled_client().sign_in_with_password( + { + "email": "unknown_user@unknowndomain.com", + "password": "strong_pwd", + } ) except AuthApiError as e: assert e.to_dict() @@ -363,10 +359,12 @@ def test_sign_in_with_sso(): def test_sign_in_with_oauth(): - assert client_api_auto_confirm_off_signups_enabled_client().sign_in_with_oauth( - { - "provider": "google", - } + assert ( + client_api_auto_confirm_off_signups_enabled_client().sign_in_with_oauth( + { + "provider": "google", + } + ) ) @@ -606,3 +604,101 @@ def test_delete_factor_invalid_id_raises_error(): service_role_api_client()._delete_factor( {"user_id": str(uuid.uuid4()), "id": "invalid_id"} ) + + +def test_create_oauth_client(): + """Test creating an OAuth client.""" + response = service_role_api_client().oauth.create_client( + CreateOAuthClientParams( + 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 + + +def test_list_oauth_clients(): + """Test listing OAuth clients.""" + client = service_role_api_client() + client.oauth.create_client( + CreateOAuthClientParams( + client_name="Test OAuth Client", + redirect_uris=["https://example.com/callback"], + ) + ) + response = client.oauth.list_clients() + assert len(response.clients) > 0 + assert any(client.client_name == "Test OAuth Client" for client in response.clients) + assert any(client.client_id is not None for client in response.clients) + + +def test_get_oauth_client(): + """Test getting an OAuth client by ID.""" + # First create a client + create_response = service_role_api_client().oauth.create_client( + CreateOAuthClientParams( + 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 = service_role_api_client().oauth.get_client(client_id) + assert response.client is not None + assert response.client.client_id == client_id + +# Server is not yet released, so this test is not yet relevant. +# async def test_update_oauth_client(): +# """Test updating an OAuth client.""" +# # First create a client +# client = service_role_api_client() +# create_response = await client.oauth.create_client( +# CreateOAuthClientParams( +# client_name="Test OAuth Client for Update", +# redirect_uris=["https://example.com/callback"], +# ) +# ) +# assert create_response.client is not None +# client_id = create_response.client.client_id +# response = await client.oauth.update_client( +# client_id, +# UpdateOAuthClientParams( +# client_name="Updated Test OAuth Client", +# ) +# ) +# assert response.client is not None +# assert response.client.client_name == "Updated Test OAuth Client" + +def test_delete_oauth_client(): + """Test deleting an OAuth client.""" + # First create a client + client = service_role_api_client() + create_response = client.oauth.create_client( + CreateOAuthClientParams( + client_name="Test OAuth Client for Delete", + redirect_uris=["https://example.com/callback"], + ) + ) + assert create_response.client is not None + client_id = create_response.client.client_id + client.oauth.delete_client(client_id) + + +def test_regenerate_oauth_client_secret(): + """Test regenerating an OAuth client secret.""" + # First create a client + create_response = service_role_api_client().oauth.create_client( + CreateOAuthClientParams( + 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 = service_role_api_client().oauth.regenerate_client_secret( + client_id + ) + assert response.client is not None + assert response.client.client_secret is not None diff --git a/uv.lock b/uv.lock index 7813ed72..a83ccaeb 100644 --- a/uv.lock +++ b/uv.lock @@ -1774,7 +1774,7 @@ wheels = [ [[package]] name = "postgrest" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/postgrest" } dependencies = [ { name = "deprecation" }, @@ -2312,7 +2312,7 @@ wheels = [ [[package]] name = "realtime" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/realtime" } dependencies = [ { name = "pydantic" }, @@ -2898,7 +2898,7 @@ wheels = [ [[package]] name = "storage3" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/storage" } dependencies = [ { name = "deprecation" }, @@ -2998,7 +2998,7 @@ wheels = [ [[package]] name = "supabase" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/supabase" } dependencies = [ { name = "httpx" }, @@ -3061,7 +3061,7 @@ tests = [ [[package]] name = "supabase-auth" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/auth" } dependencies = [ { name = "httpx", extra = ["http2"] }, @@ -3072,16 +3072,22 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "faker" }, + { name = "pylsp-mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-depends" }, { name = "pytest-mock" }, + { name = "python-lsp-ruff" }, + { name = "python-lsp-server" }, { name = "respx" }, { name = "ruff" }, { name = "unasync" }, ] lints = [ + { name = "pylsp-mypy" }, + { name = "python-lsp-ruff" }, + { name = "python-lsp-server" }, { name = "ruff" }, { name = "unasync" }, ] @@ -3105,16 +3111,22 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "faker", specifier = ">=37.4.0" }, + { name = "pylsp-mypy", specifier = ">=0.7.0,<0.8.0" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "pytest-depends", specifier = ">=1.0.1" }, { name = "pytest-mock", specifier = ">=3.14.0" }, + { name = "python-lsp-ruff", specifier = ">=2.2.2,<3.0.0" }, + { name = "python-lsp-server", specifier = ">=1.12.2,<2.0.0" }, { name = "respx", specifier = ">=0.20.2,<0.23.0" }, { name = "ruff", specifier = ">=0.12.1" }, { name = "unasync", specifier = ">=0.6.0" }, ] lints = [ + { name = "pylsp-mypy", specifier = ">=0.7.0,<0.8.0" }, + { name = "python-lsp-ruff", specifier = ">=2.2.2,<3.0.0" }, + { name = "python-lsp-server", specifier = ">=1.12.2,<2.0.0" }, { name = "ruff", specifier = ">=0.12.1" }, { name = "unasync", specifier = ">=0.6.0" }, ] @@ -3130,7 +3142,7 @@ tests = [ [[package]] name = "supabase-functions" -version = "2.22.2" +version = "2.22.3" source = { editable = "src/functions" } dependencies = [ { name = "httpx", extra = ["http2"] },