diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ed573e7..ccfa2a41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - python-version: [3.7, 3.8, 3.9, "3.10", "3.11"] + python-version: [3.8, 3.9, "3.10", "3.11"] runs-on: ${{ matrix.os }} steps: - name: Clone Repository diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f33d006..29056451 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: args: ["--fix=lf"] - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.12.0 hooks: - id: isort args: @@ -40,13 +40,13 @@ repos: - id: black - repo: https://github.com/asottile/pyupgrade - rev: v2.34.0 + rev: v2.37.3 hooks: - id: pyupgrade args: ["--py37-plus", "--keep-runtime-typing"] - repo: https://github.com/commitizen-tools/commitizen - rev: v2.28.0 + rev: v2.32.1 hooks: - id: commitizen stages: [commit-msg] diff --git a/.sourcery.yaml b/.sourcery.yaml new file mode 100644 index 00000000..f300aba4 --- /dev/null +++ b/.sourcery.yaml @@ -0,0 +1,2 @@ +refactor: + python_version: '3.7' diff --git a/Makefile b/Makefile index bdec57bc..ab21eb30 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,9 @@ clean_infra: docker-compose down --remove-orphans &&\ docker system prune -a --volumes -f +sync_infra: + python scripts/gh-download.py --repo=supabase/gotrue-js --branch=master --folder=infra + run_tests: run_infra sleep tests build_sync: diff --git a/gotrue/__init__.py b/gotrue/__init__.py index 6db087d8..f478859f 100644 --- a/gotrue/__init__.py +++ b/gotrue/__init__.py @@ -2,13 +2,12 @@ __version__ = "0.5.4" -from ._async.api import AsyncGoTrueAPI -from ._async.client import AsyncGoTrueClient -from ._async.storage import AsyncMemoryStorage, AsyncSupportedStorage -from ._sync.api import SyncGoTrueAPI -from ._sync.client import SyncGoTrueClient -from ._sync.storage import SyncMemoryStorage, SyncSupportedStorage -from .types import * - -Client = SyncGoTrueClient -GoTrueAPI = SyncGoTrueAPI +from ._async.gotrue_admin_api import AsyncGoTrueAdminAPI # type: ignore # noqa: F401 +from ._async.gotrue_client import AsyncGoTrueClient # type: ignore # noqa: F401 +from ._async.storage import AsyncMemoryStorage # type: ignore # noqa: F401 +from ._async.storage import AsyncSupportedStorage # type: ignore # noqa: F401 +from ._sync.gotrue_admin_api import SyncGoTrueAdminAPI # type: ignore # noqa: F401 +from ._sync.gotrue_client import SyncGoTrueClient # type: ignore # noqa: F401 +from ._sync.storage import SyncMemoryStorage # type: ignore # noqa: F401 +from ._sync.storage import SyncSupportedStorage # type: ignore # noqa: F401 +from .types import * # type: ignore # noqa: F401, F403 diff --git a/gotrue/_async/gotrue_admin_api.py b/gotrue/_async/gotrue_admin_api.py new file mode 100644 index 00000000..66ea481a --- /dev/null +++ b/gotrue/_async/gotrue_admin_api.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Dict, List, Union + +from ..helpers import parse_link_response, parse_user_response +from ..http_clients import AsyncClient +from ..types import ( + AdminUserAttributes, + AuthMFAAdminDeleteFactorParams, + AuthMFAAdminDeleteFactorResponse, + AuthMFAAdminListFactorsParams, + AuthMFAAdminListFactorsResponse, + GenerateLinkParams, + GenerateLinkResponse, + Options, + User, + UserResponse, +) +from .gotrue_admin_mfa_api import AsyncGoTrueAdminMFAAPI +from .gotrue_base_api import AsyncGoTrueBaseAPI + + +class AsyncGoTrueAdminAPI(AsyncGoTrueBaseAPI): + def __init__( + self, + *, + url: str = "", + headers: Dict[str, str] = {}, + http_client: Union[AsyncClient, None] = None, + ) -> None: + AsyncGoTrueBaseAPI.__init__( + self, + url=url, + headers=headers, + http_client=http_client, + ) + self.mfa = AsyncGoTrueAdminMFAAPI() + self.mfa.list_factors = self._list_factors + self.mfa.delete_factor = self._delete_factor + + async def sign_out(self, jwt: str) -> None: + """ + Removes a logged-in session. + """ + return await self._request( + "POST", + "logout", + jwt=jwt, + no_resolve_json=True, + ) + + async def invite_user_by_email( + self, + email: str, + options: Options = {}, + ) -> UserResponse: + """ + Sends an invite link to an email address. + """ + return await self._request( + "POST", + "invite", + body={"email": email, "data": options.get("data")}, + redirect_to=options.get("redirect_to"), + xform=parse_user_response, + ) + + async def generate_link(self, params: GenerateLinkParams) -> GenerateLinkResponse: + """ + Generates email links and OTPs to be sent via a custom email provider. + """ + return await self._request( + "POST", + "admin/generate_link", + body={ + "type": params.get("type"), + "email": params.get("email"), + "password": params.get("password"), + "new_email": params.get("new_email"), + "data": params.get("options", {}).get("data"), + }, + redirect_to=params.get("options", {}).get("redirect_to"), + xform=parse_link_response, + ) + + # User Admin API + + async def create_user(self, attributes: AdminUserAttributes) -> UserResponse: + """ + Creates a new user. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "POST", + "admin/users", + body=attributes, + xform=parse_user_response, + ) + + async def list_users(self) -> List[User]: + """ + Get a list of users. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "GET", + "admin/users", + xform=lambda data: [User.parse_obj(user) for user in data["users"]] + if "users" in data + else [], + ) + + async def get_user_by_id(self, uid: str) -> UserResponse: + """ + Get user by id. + + 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/users/{uid}", + xform=parse_user_response, + ) + + async def update_user_by_id( + self, + uid: str, + attributes: AdminUserAttributes, + ) -> UserResponse: + """ + Updates the user data. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return await self._request( + "PUT", + f"admin/users/{uid}", + body=attributes, + xform=parse_user_response, + ) + + async def delete_user(self, id: str) -> None: + """ + Delete a user. Requires a `service_role` key. + + 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/users/{id}") + + async def _list_factors( + self, + params: AuthMFAAdminListFactorsParams, + ) -> AuthMFAAdminListFactorsResponse: + return await self._request( + "GET", + f"admin/users/{params.get('user_id')}/factors", + xform=AuthMFAAdminListFactorsResponse.parse_obj, + ) + + async def _delete_factor( + self, + params: AuthMFAAdminDeleteFactorParams, + ) -> AuthMFAAdminDeleteFactorResponse: + return await self._request( + "DELETE", + f"admin/users/{params.get('user_id')}/factors/{params.get('factor_id')}", + xform=AuthMFAAdminDeleteFactorResponse.parse_obj, + ) diff --git a/gotrue/_async/gotrue_admin_mfa_api.py b/gotrue/_async/gotrue_admin_mfa_api.py new file mode 100644 index 00000000..ca812fcd --- /dev/null +++ b/gotrue/_async/gotrue_admin_mfa_api.py @@ -0,0 +1,32 @@ +from ..types import ( + AuthMFAAdminDeleteFactorParams, + AuthMFAAdminDeleteFactorResponse, + AuthMFAAdminListFactorsParams, + AuthMFAAdminListFactorsResponse, +) + + +class AsyncGoTrueAdminMFAAPI: + """ + Contains the full multi-factor authentication administration API. + """ + + async def list_factors( + self, + params: AuthMFAAdminListFactorsParams, + ) -> AuthMFAAdminListFactorsResponse: + """ + Lists all factors attached to a user. + """ + raise NotImplementedError() # pragma: no cover + + async def delete_factor( + self, + params: AuthMFAAdminDeleteFactorParams, + ) -> AuthMFAAdminDeleteFactorResponse: + """ + Deletes a factor on a user. This will log the user out of all active + sessions (if the deleted factor was verified). There's no need to delete + unverified factors. + """ + raise NotImplementedError() # pragma: no cover diff --git a/gotrue/_async/gotrue_base_api.py b/gotrue/_async/gotrue_base_api.py new file mode 100644 index 00000000..6431b60b --- /dev/null +++ b/gotrue/_async/gotrue_base_api.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, TypeVar, Union, overload + +from httpx import Response +from pydantic import BaseModel +from typing_extensions import Literal, Self + +from ..helpers import handle_exception +from ..http_clients import AsyncClient + +T = TypeVar("T") + + +class AsyncGoTrueBaseAPI: + def __init__( + self, + *, + url: str, + headers: Dict[str, str], + http_client: Union[AsyncClient, None], + ): + self._url = url + self._headers = headers + self._http_client = http_client or AsyncClient() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb) -> None: + await self.close() + + async def close(self) -> None: + await self._http_client.aclose() + + @overload + async def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: Literal[False] = False, + xform: Callable[[Any], T], + ) -> T: + ... # pragma: no cover + + @overload + async def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: Literal[True], + xform: Callable[[Response], T], + ) -> T: + ... # pragma: no cover + + @overload + async def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: bool = False, + ) -> None: + ... # pragma: no cover + + async def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: bool = False, + xform: Union[Callable[[Any], T], None] = None, + ) -> Union[T, None]: + url = f"{self._url}/{path}" + headers = {**self._headers, **(headers or {})} + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json;charset=UTF-8" + if jwt: + headers["Authorization"] = f"Bearer {jwt}" + query = query or {} + if redirect_to: + query["redirect_to"] = redirect_to + try: + response = await self._http_client.request( + method, + url, + headers=headers, + params=query, + json=body.dict() if isinstance(body, BaseModel) else body, + ) + response.raise_for_status() + result = response if no_resolve_json else response.json() + if xform: + return xform(result) + except Exception as e: + raise handle_exception(e) diff --git a/gotrue/_async/gotrue_client.py b/gotrue/_async/gotrue_client.py new file mode 100644 index 00000000..97f5f14c --- /dev/null +++ b/gotrue/_async/gotrue_client.py @@ -0,0 +1,841 @@ +from __future__ import annotations + +from json import loads +from time import time +from typing import Callable, Dict, List, Tuple, Union +from urllib.parse import parse_qs, quote, urlencode, urlparse +from uuid import uuid4 + +from ..constants import ( + DEFAULT_HEADERS, + EXPIRY_MARGIN, + GOTRUE_URL, + MAX_RETRIES, + RETRY_INTERVAL, + STORAGE_KEY, +) +from ..errors import ( + AuthImplicitGrantRedirectError, + AuthInvalidCredentialsError, + AuthRetryableError, + AuthSessionMissingError, +) +from ..helpers import decode_jwt_payload, parse_auth_response, parse_user_response +from ..http_clients import AsyncClient +from ..timer import Timer +from ..types import ( + AuthChangeEvent, + AuthenticatorAssuranceLevels, + AuthMFAChallengeResponse, + AuthMFAEnrollResponse, + AuthMFAGetAuthenticatorAssuranceLevelResponse, + AuthMFAListFactorsResponse, + AuthMFAUnenrollResponse, + AuthMFAVerifyResponse, + AuthResponse, + DecodedJWTDict, + MFAChallengeAndVerifyParams, + MFAChallengeParams, + MFAEnrollParams, + MFAUnenrollParams, + MFAVerifyParams, + OAuthResponse, + Options, + Provider, + Session, + SignInWithOAuthCredentials, + SignInWithPasswordCredentials, + SignInWithPasswordlessCredentials, + SignUpWithPasswordCredentials, + Subscription, + UserAttributes, + UserResponse, + VerifyOtpParams, +) +from .gotrue_admin_api import AsyncGoTrueAdminAPI +from .gotrue_base_api import AsyncGoTrueBaseAPI +from .gotrue_mfa_api import AsyncGoTrueMFAAPI +from .storage import AsyncMemoryStorage, AsyncSupportedStorage + + +class AsyncGoTrueClient(AsyncGoTrueBaseAPI): + def __init__( + self, + *, + url: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + storage_key: Union[str, None] = None, + auto_refresh_token: bool = True, + persist_session: bool = True, + storage: Union[AsyncSupportedStorage, None] = None, + http_client: Union[AsyncClient, None] = None, + ) -> None: + AsyncGoTrueBaseAPI.__init__( + self, + url=url or GOTRUE_URL, + headers=headers or DEFAULT_HEADERS, + http_client=http_client, + ) + self._storage_key = storage_key or STORAGE_KEY + self._auto_refresh_token = auto_refresh_token + self._persist_session = persist_session + self._storage = storage or AsyncMemoryStorage() + self._in_memory_session: Union[Session, None] = None + self._refresh_token_timer: Union[Timer, None] = None + self._network_retries = 0 + self._state_change_emitters: Dict[str, Subscription] = {} + + self.admin = AsyncGoTrueAdminAPI( + url=self._url, + headers=self._headers, + http_client=self._http_client, + ) + self.mfa = AsyncGoTrueMFAAPI() + self.mfa.challenge = self._challenge + self.mfa.challenge_and_verify = self._challenge_and_verify + self.mfa.enroll = self._enroll + self.mfa.get_authenticator_assurance_level = ( + self._get_authenticator_assurance_level + ) + self.mfa.list_factors = self._list_factors + self.mfa.unenroll = self._unenroll + self.mfa.verify = self._verify + + # Initializations + + async def initialize(self, *, url: Union[str, None] = None) -> None: + if url and self._is_implicit_grant_flow(url): + await self.initialize_from_url(url) + else: + await self.initialize_from_storage() + + async def initialize_from_storage(self) -> None: + return await self._recover_and_refresh() + + async def initialize_from_url(self, url: str) -> None: + try: + if self._is_implicit_grant_flow(url): + session, redirect_type = await self._get_session_from_url(url) + await self._save_session(session) + self._notify_all_subscribers("SIGNED_IN", session) + if redirect_type == "recovery": + self._notify_all_subscribers("PASSWORD_RECOVERY", session) + except Exception as e: + await self._remove_session() + raise e + + # Public methods + + async def sign_up( + self, + credentials: SignUpWithPasswordCredentials, + ) -> AuthResponse: + """ + Creates a new user. + """ + await self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + password = credentials.get("password") + options = credentials.get("options", {}) + redirect_to = options.get("redirect_to") + data = options.get("data") or {} + captcha_token = options.get("captcha_token") + if email: + response = await self._request( + "POST", + "signup", + body={ + "email": email, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + redirect_to=redirect_to, + xform=parse_auth_response, + ) + elif phone: + response = await self._request( + "POST", + "signup", + body={ + "phone": phone, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + xform=parse_auth_response, + ) + else: + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number and a password" + ) + if response.session: + await self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + async def sign_in_with_password( + self, + credentials: SignInWithPasswordCredentials, + ) -> AuthResponse: + """ + Log in an existing user with an email or phone and password. + """ + await self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + password = credentials.get("password") + options = credentials.get("options", {}) + data = options.get("data") or {} + captcha_token = options.get("captcha_token") + if email: + response = await self._request( + "POST", + "token", + body={ + "email": email, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + query={ + "grant_type": "password", + }, + xform=parse_auth_response, + ) + elif phone: + response = await self._request( + "POST", + "token", + body={ + "phone": phone, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + query={ + "grant_type": "password", + }, + xform=parse_auth_response, + ) + else: + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number and a password" + ) + if response.session: + await self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + async def sign_in_with_oauth( + self, + credentials: SignInWithOAuthCredentials, + ) -> OAuthResponse: + """ + Log in an existing user via a third-party provider. + """ + await self._remove_session() + provider = credentials.get("provider") + options = credentials.get("options", {}) + redirect_to = options.get("redirect_to") + scopes = options.get("scopes") + params = options.get("query_params", {}) + if redirect_to: + params["redirect_to"] = redirect_to + if scopes: + params["scopes"] = scopes + url = self._get_url_for_provider(provider, params) + return OAuthResponse(provider=provider, url=url) + + async def sign_in_with_otp( + self, + credentials: SignInWithPasswordlessCredentials, + ) -> AuthResponse: + """ + Log in a user using magiclink or a one-time password (OTP). + + If the `{{ .ConfirmationURL }}` variable is specified in + the email template, a magiclink will be sent. + + If the `{{ .Token }}` variable is specified in the email + template, an OTP will be sent. + + If you're using phone sign-ins, only an OTP will be sent. + You won't be able to send a magiclink for phone sign-ins. + """ + await self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + options = credentials.get("options", {}) + email_redirect_to = options.get("email_redirect_to") + should_create_user = options.get("create_user", True) + data = options.get("data") + captcha_token = options.get("captcha_token") + if email: + return await self._request( + "POST", + "otp", + body={ + "email": email, + "data": data, + "create_user": should_create_user, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + redirect_to=email_redirect_to, + xform=parse_auth_response, + ) + if phone: + return await self._request( + "POST", + "otp", + body={ + "phone": phone, + "data": data, + "create_user": should_create_user, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + xform=parse_auth_response, + ) + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number" + ) + + async def verify_otp(self, params: VerifyOtpParams) -> AuthResponse: + """ + Log in a user given a User supplied OTP received via mobile. + """ + await self._remove_session() + response = await self._request( + "POST", + "verify", + body={ + "gotrue_meta_security": { + "captcha_token": params.get("options", {}).get("captcha_token"), + }, + **params, + }, + redirect_to=params.get("options", {}).get("redirect_to"), + xform=parse_auth_response, + ) + if response.session: + await self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + async def get_session(self) -> Union[Session, None]: + """ + Returns the session, refreshing it if necessary. + + The session returned can be null if the session is not detected which + can happen in the event a user is not signed-in or has logged out. + """ + current_session: Union[Session, None] = None + if self._persist_session: + maybe_session = await self._storage.get_item(self._storage_key) + current_session = self._get_valid_session(maybe_session) + if not current_session: + await self._remove_session() + else: + current_session = self._in_memory_session + if not current_session: + return None + time_now = round(time()) + has_expired = ( + current_session.expires_at <= time_now + EXPIRY_MARGIN + if current_session.expires_at + else False + ) + return ( + await self._call_refresh_token(current_session.refresh_token) + if has_expired + else current_session + ) + + async def get_user(self, jwt: Union[str, None] = None) -> UserResponse: + """ + Gets the current user details if there is an existing session. + + Takes in an optional access token `jwt`. If no `jwt` is provided, + `get_user()` will attempt to get the `jwt` from the current session. + """ + if not jwt: + session = await self.get_session() + if session: + jwt = session.access_token + return await self._request("GET", "user", jwt=jwt, xform=parse_user_response) + + async def update_user(self, attributes: UserAttributes) -> UserResponse: + """ + Updates user data, if there is a logged in user. + """ + session = await self.get_session() + if not session: + raise AuthSessionMissingError() + response = await self._request( + "PUT", + "user", + body=attributes, + jwt=session.access_token, + xform=parse_user_response, + ) + session.user = response.user + await self._save_session(session) + self._notify_all_subscribers("USER_UPDATED", session) + return response + + async def set_session(self, access_token: str, refresh_token: str) -> AuthResponse: + """ + Sets the session data from the current session. If the current session + is expired, `set_session` will take care of refreshing it to obtain a + new session. + + If the refresh token in the current session is invalid and the current + session has expired, an error will be thrown. + + If the current session does not contain at `expires_at` field, + `set_session` will use the exp claim defined in the access token. + + The current session that minimally contains an access token, + refresh token and a user. + """ + time_now = round(time()) + expires_at = time_now + has_expired = True + session: Union[Session, None] = None + if access_token and access_token.split(".")[1]: + payload = self._decode_jwt(access_token) + exp = payload.get("exp") + if exp: + expires_at = int(exp) + has_expired = expires_at <= time_now + if has_expired: + if not refresh_token: + raise AuthSessionMissingError() + response = await self._refresh_access_token(refresh_token) + if not response.session: + return AuthResponse() + session = response.session + else: + response = await self.get_user(access_token) + session = Session( + access_token=access_token, + refresh_token=refresh_token, + user=response.user, + token_type="bearer", + expires_in=expires_at - time_now, + expires_at=expires_at, + ) + await self._save_session(session) + self._notify_all_subscribers("TOKEN_REFRESHED", session) + return AuthResponse(session=session, user=response.user) + + async def refresh_session( + self, refresh_token: Union[str, None] = None + ) -> AuthResponse: + """ + Returns a new session, regardless of expiry status. + + Takes in an optional current session. If not passed in, then refreshSession() + will attempt to retrieve it from getSession(). If the current session's + refresh token is invalid, an error will be thrown. + """ + if not refresh_token: + session = await self.get_session() + if session: + refresh_token = session.refresh_token + if not refresh_token: + raise AuthSessionMissingError() + session = await self._call_refresh_token(refresh_token) + return AuthResponse(session=session, user=session.user) + + async def sign_out(self) -> None: + """ + Inside a browser context, `sign_out` will remove the logged in user from the + browser session and log them out - removing all items from localstorage and + then trigger a `"SIGNED_OUT"` event. + + For server-side management, you can revoke all refresh tokens for a user by + passing a user's JWT through to `api.sign_out`. + + There is no way to revoke a user's access token jwt until it expires. + It is recommended to set a shorter expiry on the jwt for this reason. + """ + session = await self.get_session() + access_token = session.access_token if session else None + if access_token: + await self.admin.sign_out(access_token) + await self._remove_session() + self._notify_all_subscribers("SIGNED_OUT", None) + + async def on_auth_state_change( + self, + callback: Callable[[AuthChangeEvent, Union[Session, None]], None], + ) -> Subscription: + """ + Receive a notification every time an auth event happens. + """ + unique_id = str(uuid4()) + + def _unsubscribe() -> None: + self._state_change_emitters.pop(unique_id) + + subscription = Subscription( + id=unique_id, + callback=callback, + unsubscribe=_unsubscribe, + ) + self._state_change_emitters[unique_id] = subscription + return subscription + + async def reset_password_email( + self, + email: str, + options: Options = {}, + ) -> None: + """ + Sends a password reset request to an email address. + """ + await self._request( + "POST", + "recover", + body={ + "email": email, + "gotrue_meta_security": { + "captcha_token": options.get("captcha_token"), + }, + }, + redirect_to=options.get("redirect_to"), + ) + + # MFA methods + + async def _enroll(self, params: MFAEnrollParams) -> AuthMFAEnrollResponse: + session = await self.get_session() + if not session: + raise AuthSessionMissingError() + response = await self._request( + "POST", + "factors", + body=params, + jwt=session.access_token, + xform=AuthMFAEnrollResponse.parse_obj, + ) + if response.totp.qr_code: + response.totp.qr_code = f"data:image/svg+xml;utf-8,{response.totp.qr_code}" + return response + + async def _challenge(self, params: MFAChallengeParams) -> AuthMFAChallengeResponse: + session = await self.get_session() + if not session: + raise AuthSessionMissingError() + return await self._request( + "POST", + f"factors/{params.get('factor_id')}/challenge", + jwt=session.access_token, + xform=AuthMFAChallengeResponse.parse_obj, + ) + + async def _challenge_and_verify( + self, + params: MFAChallengeAndVerifyParams, + ) -> AuthMFAVerifyResponse: + response = await self._challenge( + { + "factor_id": params.get("factor_id"), + } + ) + return await self._verify( + { + "factor_id": params.get("factor_id"), + "challenge_id": response.id, + "code": params.get("code"), + } + ) + + async def _verify(self, params: MFAVerifyParams) -> AuthMFAVerifyResponse: + session = await self.get_session() + if not session: + raise AuthSessionMissingError() + response = await self._request( + "POST", + f"factors/{params.get('factor_id')}/verify", + body=params, + jwt=session.access_token, + xform=AuthMFAVerifyResponse.parse_obj, + ) + session = Session.parse_obj(response.dict()) + await self._save_session(session) + self._notify_all_subscribers("MFA_CHALLENGE_VERIFIED", session) + return response + + async def _unenroll(self, params: MFAUnenrollParams) -> AuthMFAUnenrollResponse: + session = await self.get_session() + if not session: + raise AuthSessionMissingError() + return await self._request( + "DELETE", + f"factors/{params.get('factor_id')}", + jwt=session.access_token, + xform=AuthMFAUnenrollResponse.parse_obj, + ) + + async def _list_factors(self) -> AuthMFAListFactorsResponse: + response = await self.get_user() + all = response.user.factors or [] + totp = [f for f in all if f.factor_type == "totp" and f.status == "verified"] + return AuthMFAListFactorsResponse(all=all, totp=totp) + + async def _get_authenticator_assurance_level( + self, + ) -> AuthMFAGetAuthenticatorAssuranceLevelResponse: + session = await self.get_session() + if not session: + return AuthMFAGetAuthenticatorAssuranceLevelResponse( + current_level=None, + next_level=None, + current_authentication_methods=[], + ) + payload = self._decode_jwt(session.access_token) + current_level: Union[AuthenticatorAssuranceLevels, None] = None + if payload.get("aal"): + current_level = payload.get("aal") + verified_factors = [ + f for f in session.user.factors or [] if f.status == "verified" + ] + next_level = "aal2" if verified_factors else current_level + current_authentication_methods = payload.get("amr") or [] + return AuthMFAGetAuthenticatorAssuranceLevelResponse( + current_level=current_level, + next_level=next_level, + current_authentication_methods=current_authentication_methods, + ) + + # Private methods + + async def _remove_session(self) -> None: + if self._persist_session: + await self._storage.remove_item(self._storage_key) + else: + self._in_memory_session = None + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + + async def _get_session_from_url( + self, + url: str, + ) -> Tuple[Session, Union[str, None]]: + if not self._is_implicit_grant_flow(url): + raise AuthImplicitGrantRedirectError("Not a valid implicit grant flow url.") + result = urlparse(url) + params = parse_qs(result.query) + error_description = self._get_param(params, "error_description") + if error_description: + error_code = self._get_param(params, "error_code") + error = self._get_param(params, "error") + if not error_code: + raise AuthImplicitGrantRedirectError("No error_code detected.") + if not error: + raise AuthImplicitGrantRedirectError("No error detected.") + raise AuthImplicitGrantRedirectError( + error_description, + {"code": error_code, "error": error}, + ) + provider_token = self._get_param(params, "provider_token") + provider_refresh_token = self._get_param(params, "provider_refresh_token") + access_token = self._get_param(params, "access_token") + if not access_token: + raise AuthImplicitGrantRedirectError("No access_token detected.") + expires_in = self._get_param(params, "expires_in") + if not expires_in: + raise AuthImplicitGrantRedirectError("No expires_in detected.") + refresh_token = self._get_param(params, "refresh_token") + if not refresh_token: + raise AuthImplicitGrantRedirectError("No refresh_token detected.") + token_type = self._get_param(params, "token_type") + if not token_type: + raise AuthImplicitGrantRedirectError("No token_type detected.") + time_now = round(time()) + expires_at = time_now + int(expires_in) + user = await self.get_user(access_token) + session = Session( + provider_token=provider_token, + provider_refresh_token=provider_refresh_token, + access_token=access_token, + expires_in=int(expires_in), + expires_at=expires_at, + refresh_token=refresh_token, + token_type=token_type, + user=user.user, + ) + redirect_type = self._get_param(params, "type") + return session, redirect_type + + async def _recover_and_refresh(self) -> None: + raw_session = await self._storage.get_item(self._storage_key) + current_session = self._get_valid_session(raw_session) + if not current_session: + if raw_session: + await self._remove_session() + return + time_now = round(time()) + expires_at = current_session.expires_at + if expires_at and expires_at < time_now + EXPIRY_MARGIN: + refresh_token = current_session.refresh_token + if self._auto_refresh_token and refresh_token: + self._network_retries += 1 + try: + await self._call_refresh_token(refresh_token) + self._network_retries = 0 + except Exception as e: + if ( + isinstance(e, AuthRetryableError) + and self._network_retries < MAX_RETRIES + ): + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = Timer( + (RETRY_INTERVAL ** (self._network_retries * 100)), + self._recover_and_refresh, + ) + self._refresh_token_timer.start() + return + await self._remove_session() + return + if self._persist_session: + await self._save_session(current_session) + self._notify_all_subscribers("SIGNED_IN", current_session) + + async def _call_refresh_token(self, refresh_token: str) -> Session: + if not refresh_token: + raise AuthSessionMissingError() + response = await self._refresh_access_token(refresh_token) + if not response.session: + raise AuthSessionMissingError() + await self._save_session(response.session) + self._notify_all_subscribers("TOKEN_REFRESHED", response.session) + return response.session + + async def _refresh_access_token(self, refresh_token: str) -> AuthResponse: + return await self._request( + "POST", + "token", + query={"grant_type": "refresh_token"}, + body={"refresh_token": refresh_token}, + xform=parse_auth_response, + ) + + async def _save_session(self, session: Session) -> None: + if not self._persist_session: + self._in_memory_session = session + expire_at = session.expires_at + if expire_at: + time_now = round(time()) + expire_in = expire_at - time_now + refresh_duration_before_expires = ( + EXPIRY_MARGIN if expire_in > EXPIRY_MARGIN else 0.5 + ) + value = (expire_in - refresh_duration_before_expires) * 1000 + await self._start_auto_refresh_token(value) + if self._persist_session and session.expires_at: + await self._storage.set_item(self._storage_key, session.json()) + + async def _start_auto_refresh_token(self, value: float) -> None: + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + if value <= 0 or not self._auto_refresh_token: + return + + async def refresh_token_function(): + self._network_retries += 1 + try: + session = await self.get_session() + if session: + await self._call_refresh_token(session.refresh_token) + self._network_retries = 0 + except Exception as e: + if ( + isinstance(e, AuthRetryableError) + and self._network_retries < MAX_RETRIES + ): + await self._start_auto_refresh_token( + RETRY_INTERVAL ** (self._network_retries * 100) + ) + + self._refresh_token_timer = Timer(value, refresh_token_function) + self._refresh_token_timer.start() + + def _notify_all_subscribers( + self, + event: AuthChangeEvent, + session: Union[Session, None], + ) -> None: + for subscription in self._state_change_emitters.values(): + subscription.callback(event, session) + + def _get_valid_session( + self, + raw_session: Union[str, None], + ) -> Union[Session, None]: + if not raw_session: + return None + data = loads(raw_session) + if not data: + return None + if not data.get("access_token"): + return None + if not data.get("refresh_token"): + return None + if not data.get("expires_at"): + return None + try: + expires_at = int(data["expires_at"]) + data["expires_at"] = expires_at + except ValueError: + return None + try: + return Session.parse_obj(data) + except Exception: + return None + + def _get_param( + self, + query_params: Dict[str, List[str]], + name: str, + ) -> Union[str, None]: + return query_params[name][0] if name in query_params else None + + def _is_implicit_grant_flow(self, url: str) -> bool: + result = urlparse(url) + params = parse_qs(result.query) + return "access_token" in params or "error_description" in params + + def _get_url_for_provider( + self, + provider: Provider, + params: Dict[str, str], + ) -> str: + params = {k: quote(v) for k, v in params.items()} + params["provider"] = quote(provider) + query = urlencode(params) + return f"{self._url}/authorize?{query}" + + def _decode_jwt(self, jwt: str) -> DecodedJWTDict: + """ + Decodes a JWT (without performing any validation). + """ + return decode_jwt_payload(jwt) diff --git a/gotrue/_async/gotrue_mfa_api.py b/gotrue/_async/gotrue_mfa_api.py new file mode 100644 index 00000000..a30c4c73 --- /dev/null +++ b/gotrue/_async/gotrue_mfa_api.py @@ -0,0 +1,94 @@ +from ..types import ( + AuthMFAChallengeResponse, + AuthMFAEnrollResponse, + AuthMFAGetAuthenticatorAssuranceLevelResponse, + AuthMFAListFactorsResponse, + AuthMFAUnenrollResponse, + AuthMFAVerifyResponse, + MFAChallengeAndVerifyParams, + MFAChallengeParams, + MFAEnrollParams, + MFAUnenrollParams, + MFAVerifyParams, +) + + +class AsyncGoTrueMFAAPI: + """ + Contains the full multi-factor authentication API. + """ + + async def enroll(self, params: MFAEnrollParams) -> AuthMFAEnrollResponse: + """ + Starts the enrollment process for a new Multi-Factor Authentication + factor. This method creates a new factor in the 'unverified' state. + Present the QR code or secret to the user and ask them to add it to their + authenticator app. Ask the user to provide you with an authenticator code + from their app and verify it by calling challenge and then verify. + + The first successful verification of an unverified factor activates the + factor. All other sessions are logged out and the current one gets an + `aal2` authenticator level. + """ + raise NotImplementedError() # pragma: no cover + + async def challenge(self, params: MFAChallengeParams) -> AuthMFAChallengeResponse: + """ + Prepares a challenge used to verify that a user has access to a MFA + factor. Provide the challenge ID and verification code by calling `verify`. + """ + raise NotImplementedError() # pragma: no cover + + async def challenge_and_verify( + self, + params: MFAChallengeAndVerifyParams, + ) -> AuthMFAVerifyResponse: + """ + Helper method which creates a challenge and immediately uses the given code + to verify against it thereafter. The verification code is provided by the + user by entering a code seen in their authenticator app. + """ + raise NotImplementedError() # pragma: no cover + + async def verify(self, params: MFAVerifyParams) -> AuthMFAVerifyResponse: + """ + Verifies a verification code against a challenge. The verification code is + provided by the user by entering a code seen in their authenticator app. + """ + raise NotImplementedError() # pragma: no cover + + async def unenroll(self, params: MFAUnenrollParams) -> AuthMFAUnenrollResponse: + """ + Unenroll removes a MFA factor. Unverified factors can safely be ignored + and it's not necessary to unenroll them. Unenrolling a verified MFA factor + cannot be done from a session with an `aal1` authenticator level. + """ + raise NotImplementedError() # pragma: no cover + + async def list_factors(self) -> AuthMFAListFactorsResponse: + """ + Returns the list of MFA factors enabled for this user. For most use cases + you should consider using `get_authenticator_assurance_level`. + + This uses a cached version of the factors and avoids incurring a network call. + If you need to update this list, call `get_user` first. + """ + raise NotImplementedError() # pragma: no cover + + async def get_authenticator_assurance_level( + self, + ) -> AuthMFAGetAuthenticatorAssuranceLevelResponse: + """ + Returns the Authenticator Assurance Level (AAL) for the active session. + + - `aal1` (or `null`) means that the user's identity has been verified only + with a conventional login (email+password, OTP, magic link, social login, + etc.). + - `aal2` means that the user's identity has been verified both with a + conventional login and at least one MFA factor. + + Although this method returns a promise, it's fairly quick (microseconds) + and rarely uses the network. You can use this to check whether the current + user needs to be shown a screen to verify their MFA factors. + """ + raise NotImplementedError() # pragma: no cover diff --git a/gotrue/_sync/api.py b/gotrue/_sync/api.py index 6ab024f9..abbdc480 100644 --- a/gotrue/_sync/api.py +++ b/gotrue/_sync/api.py @@ -60,8 +60,8 @@ def create_user(self, *, attributes: UserAttributes) -> User: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers data = attributes.dict() @@ -82,8 +82,8 @@ def list_users(self) -> List[User]: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers url = f"{self.url}/admin/users" @@ -125,8 +125,8 @@ def sign_up_with_email( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers query_string = "" @@ -164,9 +164,10 @@ def sign_in_with_email( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ + headers = self.headers query_string = "?grant_type=password" if redirect_to: @@ -203,8 +204,8 @@ def sign_up_with_phone( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers data = {"phone": phone, "password": password, "data": data} @@ -235,8 +236,8 @@ def sign_in_with_phone( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ data = {"phone": phone, "password": password} url = f"{self.url}/token?grant_type=password" @@ -262,8 +263,8 @@ def send_magic_link_email( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers query_string = "" @@ -285,8 +286,8 @@ def send_mobile_otp(self, *, phone: str, create_user: bool) -> None: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers data = {"phone": phone, "create_user": create_user} @@ -320,8 +321,8 @@ def verify_mobile_otp( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers data = { @@ -362,8 +363,8 @@ def invite_user_by_email( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers query_string = "" @@ -392,8 +393,8 @@ def reset_password_for_email( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers query_string = "" @@ -422,8 +423,7 @@ def _create_request_headers(self, *, jwt: str) -> Dict[str, str]: The headers required for a successful request statement with the supabase backend. """ - headers = {**self.headers} - headers["Authorization"] = f"Bearer {jwt}" + headers = {**self.headers, "Authorization": f"Bearer {jwt}"} return headers def sign_out(self, *, jwt: str) -> None: @@ -463,8 +463,8 @@ def get_url_for_provider( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ url_params = [f"provider={encode_uri_component(provider)}"] if redirect_to: @@ -489,8 +489,8 @@ def get_user(self, *, jwt: str) -> User: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self._create_request_headers(jwt=jwt) url = f"{self.url}/user" @@ -520,8 +520,8 @@ def update_user( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self._create_request_headers(jwt=jwt) data = attributes.dict() @@ -549,8 +549,8 @@ def delete_user(self, *, uid: str, jwt: str) -> None: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self._create_request_headers(jwt=jwt) url = f"{self.url}/admin/users/{uid}" @@ -572,8 +572,8 @@ def refresh_access_token(self, *, refresh_token: str) -> Session: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ data = {"refresh_token": refresh_token} url = f"{self.url}/token?grant_type=refresh_token" @@ -614,8 +614,8 @@ def generate_link( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ headers = self.headers data = { diff --git a/gotrue/_sync/client.py b/gotrue/_sync/client.py index f7f20455..3ce6b2de 100644 --- a/gotrue/_sync/client.py +++ b/gotrue/_sync/client.py @@ -121,8 +121,8 @@ def sign_up( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ self._remove_session() @@ -202,8 +202,8 @@ def sign_in( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ self._remove_session() if email: @@ -268,8 +268,8 @@ def verify_otp( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ self._remove_session() response = self.api.verify_mobile_otp( @@ -315,8 +315,8 @@ def update(self, *, attributes: Union[UserAttributesDict, UserAttributes]) -> Us Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ if not self.current_session: raise ValueError("Not logged in.") @@ -350,8 +350,8 @@ def set_session(self, *, refresh_token: str) -> Session: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ response = self.api.refresh_access_token(refresh_token=refresh_token) self._save_session(session=response) @@ -374,8 +374,8 @@ def set_auth(self, *, access_token: str) -> Session: Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ session = Session( access_token=access_token, @@ -416,8 +416,8 @@ def get_session_from_url( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ data = urlparse(url) query = parse_qs(data.query) @@ -492,8 +492,8 @@ def on_auth_state_change( Raises ------ - error : APIError - If an error occurs + APIError + If an error occurs. """ unique_id = uuid4() subscription = Subscription( diff --git a/gotrue/_sync/gotrue_admin_api.py b/gotrue/_sync/gotrue_admin_api.py new file mode 100644 index 00000000..1d367fce --- /dev/null +++ b/gotrue/_sync/gotrue_admin_api.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Dict, List, Union + +from ..helpers import parse_link_response, parse_user_response +from ..http_clients import SyncClient +from ..types import ( + AdminUserAttributes, + AuthMFAAdminDeleteFactorParams, + AuthMFAAdminDeleteFactorResponse, + AuthMFAAdminListFactorsParams, + AuthMFAAdminListFactorsResponse, + GenerateLinkParams, + GenerateLinkResponse, + Options, + User, + UserResponse, +) +from .gotrue_admin_mfa_api import SyncGoTrueAdminMFAAPI +from .gotrue_base_api import SyncGoTrueBaseAPI + + +class SyncGoTrueAdminAPI(SyncGoTrueBaseAPI): + def __init__( + self, + *, + url: str = "", + headers: Dict[str, str] = {}, + http_client: Union[SyncClient, None] = None, + ) -> None: + SyncGoTrueBaseAPI.__init__( + self, + url=url, + headers=headers, + http_client=http_client, + ) + self.mfa = SyncGoTrueAdminMFAAPI() + self.mfa.list_factors = self._list_factors + self.mfa.delete_factor = self._delete_factor + + def sign_out(self, jwt: str) -> None: + """ + Removes a logged-in session. + """ + return self._request( + "POST", + "logout", + jwt=jwt, + no_resolve_json=True, + ) + + def invite_user_by_email( + self, + email: str, + options: Options = {}, + ) -> UserResponse: + """ + Sends an invite link to an email address. + """ + return self._request( + "POST", + "invite", + body={"email": email, "data": options.get("data")}, + redirect_to=options.get("redirect_to"), + xform=parse_user_response, + ) + + def generate_link(self, params: GenerateLinkParams) -> GenerateLinkResponse: + """ + Generates email links and OTPs to be sent via a custom email provider. + """ + return self._request( + "POST", + "admin/generate_link", + body={ + "type": params.get("type"), + "email": params.get("email"), + "password": params.get("password"), + "new_email": params.get("new_email"), + "data": params.get("options", {}).get("data"), + }, + redirect_to=params.get("options", {}).get("redirect_to"), + xform=parse_link_response, + ) + + # User Admin API + + def create_user(self, attributes: AdminUserAttributes) -> UserResponse: + """ + Creates a new user. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "POST", + "admin/users", + body=attributes, + xform=parse_user_response, + ) + + def list_users(self) -> List[User]: + """ + Get a list of users. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "GET", + "admin/users", + xform=lambda data: [User.parse_obj(user) for user in data["users"]] + if "users" in data + else [], + ) + + def get_user_by_id(self, uid: str) -> UserResponse: + """ + Get user by id. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "GET", + f"admin/users/{uid}", + xform=parse_user_response, + ) + + def update_user_by_id( + self, + uid: str, + attributes: AdminUserAttributes, + ) -> UserResponse: + """ + Updates the user data. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request( + "PUT", + f"admin/users/{uid}", + body=attributes, + xform=parse_user_response, + ) + + def delete_user(self, id: str) -> None: + """ + Delete a user. Requires a `service_role` key. + + This function should only be called on a server. + Never expose your `service_role` key in the browser. + """ + return self._request("DELETE", f"admin/users/{id}") + + def _list_factors( + self, + params: AuthMFAAdminListFactorsParams, + ) -> AuthMFAAdminListFactorsResponse: + return self._request( + "GET", + f"admin/users/{params.get('user_id')}/factors", + xform=AuthMFAAdminListFactorsResponse.parse_obj, + ) + + def _delete_factor( + self, + params: AuthMFAAdminDeleteFactorParams, + ) -> AuthMFAAdminDeleteFactorResponse: + return self._request( + "DELETE", + f"admin/users/{params.get('user_id')}/factors/{params.get('factor_id')}", + xform=AuthMFAAdminDeleteFactorResponse.parse_obj, + ) diff --git a/gotrue/_sync/gotrue_admin_mfa_api.py b/gotrue/_sync/gotrue_admin_mfa_api.py new file mode 100644 index 00000000..c3fcfc8e --- /dev/null +++ b/gotrue/_sync/gotrue_admin_mfa_api.py @@ -0,0 +1,32 @@ +from ..types import ( + AuthMFAAdminDeleteFactorParams, + AuthMFAAdminDeleteFactorResponse, + AuthMFAAdminListFactorsParams, + AuthMFAAdminListFactorsResponse, +) + + +class SyncGoTrueAdminMFAAPI: + """ + Contains the full multi-factor authentication administration API. + """ + + def list_factors( + self, + params: AuthMFAAdminListFactorsParams, + ) -> AuthMFAAdminListFactorsResponse: + """ + Lists all factors attached to a user. + """ + raise NotImplementedError() # pragma: no cover + + def delete_factor( + self, + params: AuthMFAAdminDeleteFactorParams, + ) -> AuthMFAAdminDeleteFactorResponse: + """ + Deletes a factor on a user. This will log the user out of all active + sessions (if the deleted factor was verified). There's no need to delete + unverified factors. + """ + raise NotImplementedError() # pragma: no cover diff --git a/gotrue/_sync/gotrue_base_api.py b/gotrue/_sync/gotrue_base_api.py new file mode 100644 index 00000000..b180e341 --- /dev/null +++ b/gotrue/_sync/gotrue_base_api.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import Any, Callable, Dict, TypeVar, Union, overload + +from httpx import Response +from pydantic import BaseModel +from typing_extensions import Literal, Self + +from ..helpers import handle_exception +from ..http_clients import SyncClient + +T = TypeVar("T") + + +class SyncGoTrueBaseAPI: + def __init__( + self, + *, + url: str, + headers: Dict[str, str], + http_client: Union[SyncClient, None], + ): + self._url = url + self._headers = headers + self._http_client = http_client or SyncClient() + + def __enter__(self) -> Self: + return self + + def __exit__(self, exc_t, exc_v, exc_tb) -> None: + self.close() + + def close(self) -> None: + self._http_client.aclose() + + @overload + def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: Literal[False] = False, + xform: Callable[[Any], T], + ) -> T: + ... # pragma: no cover + + @overload + def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: Literal[True], + xform: Callable[[Response], T], + ) -> T: + ... # pragma: no cover + + @overload + def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: bool = False, + ) -> None: + ... # pragma: no cover + + def _request( + self, + method: Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"], + path: str, + *, + jwt: Union[str, None] = None, + redirect_to: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + query: Union[Dict[str, str], None] = None, + body: Union[Any, None] = None, + no_resolve_json: bool = False, + xform: Union[Callable[[Any], T], None] = None, + ) -> Union[T, None]: + url = f"{self._url}/{path}" + headers = {**self._headers, **(headers or {})} + if "Content-Type" not in headers: + headers["Content-Type"] = "application/json;charset=UTF-8" + if jwt: + headers["Authorization"] = f"Bearer {jwt}" + query = query or {} + if redirect_to: + query["redirect_to"] = redirect_to + try: + response = self._http_client.request( + method, + url, + headers=headers, + params=query, + json=body.dict() if isinstance(body, BaseModel) else body, + ) + response.raise_for_status() + result = response if no_resolve_json else response.json() + if xform: + return xform(result) + except Exception as e: + raise handle_exception(e) diff --git a/gotrue/_sync/gotrue_client.py b/gotrue/_sync/gotrue_client.py new file mode 100644 index 00000000..be893d90 --- /dev/null +++ b/gotrue/_sync/gotrue_client.py @@ -0,0 +1,839 @@ +from __future__ import annotations + +from json import loads +from time import time +from typing import Callable, Dict, List, Tuple, Union +from urllib.parse import parse_qs, quote, urlencode, urlparse +from uuid import uuid4 + +from ..constants import ( + DEFAULT_HEADERS, + EXPIRY_MARGIN, + GOTRUE_URL, + MAX_RETRIES, + RETRY_INTERVAL, + STORAGE_KEY, +) +from ..errors import ( + AuthImplicitGrantRedirectError, + AuthInvalidCredentialsError, + AuthRetryableError, + AuthSessionMissingError, +) +from ..helpers import decode_jwt_payload, parse_auth_response, parse_user_response +from ..http_clients import SyncClient +from ..timer import Timer +from ..types import ( + AuthChangeEvent, + AuthenticatorAssuranceLevels, + AuthMFAChallengeResponse, + AuthMFAEnrollResponse, + AuthMFAGetAuthenticatorAssuranceLevelResponse, + AuthMFAListFactorsResponse, + AuthMFAUnenrollResponse, + AuthMFAVerifyResponse, + AuthResponse, + DecodedJWTDict, + MFAChallengeAndVerifyParams, + MFAChallengeParams, + MFAEnrollParams, + MFAUnenrollParams, + MFAVerifyParams, + OAuthResponse, + Options, + Provider, + Session, + SignInWithOAuthCredentials, + SignInWithPasswordCredentials, + SignInWithPasswordlessCredentials, + SignUpWithPasswordCredentials, + Subscription, + UserAttributes, + UserResponse, + VerifyOtpParams, +) +from .gotrue_admin_api import SyncGoTrueAdminAPI +from .gotrue_base_api import SyncGoTrueBaseAPI +from .gotrue_mfa_api import SyncGoTrueMFAAPI +from .storage import SyncMemoryStorage, SyncSupportedStorage + + +class SyncGoTrueClient(SyncGoTrueBaseAPI): + def __init__( + self, + *, + url: Union[str, None] = None, + headers: Union[Dict[str, str], None] = None, + storage_key: Union[str, None] = None, + auto_refresh_token: bool = True, + persist_session: bool = True, + storage: Union[SyncSupportedStorage, None] = None, + http_client: Union[SyncClient, None] = None, + ) -> None: + SyncGoTrueBaseAPI.__init__( + self, + url=url or GOTRUE_URL, + headers=headers or DEFAULT_HEADERS, + http_client=http_client, + ) + self._storage_key = storage_key or STORAGE_KEY + self._auto_refresh_token = auto_refresh_token + self._persist_session = persist_session + self._storage = storage or SyncMemoryStorage() + self._in_memory_session: Union[Session, None] = None + self._refresh_token_timer: Union[Timer, None] = None + self._network_retries = 0 + self._state_change_emitters: Dict[str, Subscription] = {} + + self.admin = SyncGoTrueAdminAPI( + url=self._url, + headers=self._headers, + http_client=self._http_client, + ) + self.mfa = SyncGoTrueMFAAPI() + self.mfa.challenge = self._challenge + self.mfa.challenge_and_verify = self._challenge_and_verify + self.mfa.enroll = self._enroll + self.mfa.get_authenticator_assurance_level = ( + self._get_authenticator_assurance_level + ) + self.mfa.list_factors = self._list_factors + self.mfa.unenroll = self._unenroll + self.mfa.verify = self._verify + + # Initializations + + def initialize(self, *, url: Union[str, None] = None) -> None: + if url and self._is_implicit_grant_flow(url): + self.initialize_from_url(url) + else: + self.initialize_from_storage() + + def initialize_from_storage(self) -> None: + return self._recover_and_refresh() + + def initialize_from_url(self, url: str) -> None: + try: + if self._is_implicit_grant_flow(url): + session, redirect_type = self._get_session_from_url(url) + self._save_session(session) + self._notify_all_subscribers("SIGNED_IN", session) + if redirect_type == "recovery": + self._notify_all_subscribers("PASSWORD_RECOVERY", session) + except Exception as e: + self._remove_session() + raise e + + # Public methods + + def sign_up( + self, + credentials: SignUpWithPasswordCredentials, + ) -> AuthResponse: + """ + Creates a new user. + """ + self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + password = credentials.get("password") + options = credentials.get("options", {}) + redirect_to = options.get("redirect_to") + data = options.get("data") or {} + captcha_token = options.get("captcha_token") + if email: + response = self._request( + "POST", + "signup", + body={ + "email": email, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + redirect_to=redirect_to, + xform=parse_auth_response, + ) + elif phone: + response = self._request( + "POST", + "signup", + body={ + "phone": phone, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + xform=parse_auth_response, + ) + else: + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number and a password" + ) + if response.session: + self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + def sign_in_with_password( + self, + credentials: SignInWithPasswordCredentials, + ) -> AuthResponse: + """ + Log in an existing user with an email or phone and password. + """ + self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + password = credentials.get("password") + options = credentials.get("options", {}) + data = options.get("data") or {} + captcha_token = options.get("captcha_token") + if email: + response = self._request( + "POST", + "token", + body={ + "email": email, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + query={ + "grant_type": "password", + }, + xform=parse_auth_response, + ) + elif phone: + response = self._request( + "POST", + "token", + body={ + "phone": phone, + "password": password, + "data": data, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + query={ + "grant_type": "password", + }, + xform=parse_auth_response, + ) + else: + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number and a password" + ) + if response.session: + self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + def sign_in_with_oauth( + self, + credentials: SignInWithOAuthCredentials, + ) -> OAuthResponse: + """ + Log in an existing user via a third-party provider. + """ + self._remove_session() + provider = credentials.get("provider") + options = credentials.get("options", {}) + redirect_to = options.get("redirect_to") + scopes = options.get("scopes") + params = options.get("query_params", {}) + if redirect_to: + params["redirect_to"] = redirect_to + if scopes: + params["scopes"] = scopes + url = self._get_url_for_provider(provider, params) + return OAuthResponse(provider=provider, url=url) + + def sign_in_with_otp( + self, + credentials: SignInWithPasswordlessCredentials, + ) -> AuthResponse: + """ + Log in a user using magiclink or a one-time password (OTP). + + If the `{{ .ConfirmationURL }}` variable is specified in + the email template, a magiclink will be sent. + + If the `{{ .Token }}` variable is specified in the email + template, an OTP will be sent. + + If you're using phone sign-ins, only an OTP will be sent. + You won't be able to send a magiclink for phone sign-ins. + """ + self._remove_session() + email = credentials.get("email") + phone = credentials.get("phone") + options = credentials.get("options", {}) + email_redirect_to = options.get("email_redirect_to") + should_create_user = options.get("create_user", True) + data = options.get("data") + captcha_token = options.get("captcha_token") + if email: + return self._request( + "POST", + "otp", + body={ + "email": email, + "data": data, + "create_user": should_create_user, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + redirect_to=email_redirect_to, + xform=parse_auth_response, + ) + if phone: + return self._request( + "POST", + "otp", + body={ + "phone": phone, + "data": data, + "create_user": should_create_user, + "gotrue_meta_security": { + "captcha_token": captcha_token, + }, + }, + xform=parse_auth_response, + ) + raise AuthInvalidCredentialsError( + "You must provide either an email or phone number" + ) + + def verify_otp(self, params: VerifyOtpParams) -> AuthResponse: + """ + Log in a user given a User supplied OTP received via mobile. + """ + self._remove_session() + response = self._request( + "POST", + "verify", + body={ + "gotrue_meta_security": { + "captcha_token": params.get("options", {}).get("captcha_token"), + }, + **params, + }, + redirect_to=params.get("options", {}).get("redirect_to"), + xform=parse_auth_response, + ) + if response.session: + self._save_session(response.session) + self._notify_all_subscribers("SIGNED_IN", response.session) + return response + + def get_session(self) -> Union[Session, None]: + """ + Returns the session, refreshing it if necessary. + + The session returned can be null if the session is not detected which + can happen in the event a user is not signed-in or has logged out. + """ + current_session: Union[Session, None] = None + if self._persist_session: + maybe_session = self._storage.get_item(self._storage_key) + current_session = self._get_valid_session(maybe_session) + if not current_session: + self._remove_session() + else: + current_session = self._in_memory_session + if not current_session: + return None + time_now = round(time()) + has_expired = ( + current_session.expires_at <= time_now + EXPIRY_MARGIN + if current_session.expires_at + else False + ) + return ( + self._call_refresh_token(current_session.refresh_token) + if has_expired + else current_session + ) + + def get_user(self, jwt: Union[str, None] = None) -> UserResponse: + """ + Gets the current user details if there is an existing session. + + Takes in an optional access token `jwt`. If no `jwt` is provided, + `get_user()` will attempt to get the `jwt` from the current session. + """ + if not jwt: + session = self.get_session() + if session: + jwt = session.access_token + return self._request("GET", "user", jwt=jwt, xform=parse_user_response) + + def update_user(self, attributes: UserAttributes) -> UserResponse: + """ + Updates user data, if there is a logged in user. + """ + session = self.get_session() + if not session: + raise AuthSessionMissingError() + response = self._request( + "PUT", + "user", + body=attributes, + jwt=session.access_token, + xform=parse_user_response, + ) + session.user = response.user + self._save_session(session) + self._notify_all_subscribers("USER_UPDATED", session) + return response + + def set_session(self, access_token: str, refresh_token: str) -> AuthResponse: + """ + Sets the session data from the current session. If the current session + is expired, `set_session` will take care of refreshing it to obtain a + new session. + + If the refresh token in the current session is invalid and the current + session has expired, an error will be thrown. + + If the current session does not contain at `expires_at` field, + `set_session` will use the exp claim defined in the access token. + + The current session that minimally contains an access token, + refresh token and a user. + """ + time_now = round(time()) + expires_at = time_now + has_expired = True + session: Union[Session, None] = None + if access_token and access_token.split(".")[1]: + payload = self._decode_jwt(access_token) + exp = payload.get("exp") + if exp: + expires_at = int(exp) + has_expired = expires_at <= time_now + if has_expired: + if not refresh_token: + raise AuthSessionMissingError() + response = self._refresh_access_token(refresh_token) + if not response.session: + return AuthResponse() + session = response.session + else: + response = self.get_user(access_token) + session = Session( + access_token=access_token, + refresh_token=refresh_token, + user=response.user, + token_type="bearer", + expires_in=expires_at - time_now, + expires_at=expires_at, + ) + self._save_session(session) + self._notify_all_subscribers("TOKEN_REFRESHED", session) + return AuthResponse(session=session, user=response.user) + + def refresh_session(self, refresh_token: Union[str, None] = None) -> AuthResponse: + """ + Returns a new session, regardless of expiry status. + + Takes in an optional current session. If not passed in, then refreshSession() + will attempt to retrieve it from getSession(). If the current session's + refresh token is invalid, an error will be thrown. + """ + if not refresh_token: + session = self.get_session() + if session: + refresh_token = session.refresh_token + if not refresh_token: + raise AuthSessionMissingError() + session = self._call_refresh_token(refresh_token) + return AuthResponse(session=session, user=session.user) + + def sign_out(self) -> None: + """ + Inside a browser context, `sign_out` will remove the logged in user from the + browser session and log them out - removing all items from localstorage and + then trigger a `"SIGNED_OUT"` event. + + For server-side management, you can revoke all refresh tokens for a user by + passing a user's JWT through to `api.sign_out`. + + There is no way to revoke a user's access token jwt until it expires. + It is recommended to set a shorter expiry on the jwt for this reason. + """ + session = self.get_session() + access_token = session.access_token if session else None + if access_token: + self.admin.sign_out(access_token) + self._remove_session() + self._notify_all_subscribers("SIGNED_OUT", None) + + def on_auth_state_change( + self, + callback: Callable[[AuthChangeEvent, Union[Session, None]], None], + ) -> Subscription: + """ + Receive a notification every time an auth event happens. + """ + unique_id = str(uuid4()) + + def _unsubscribe() -> None: + self._state_change_emitters.pop(unique_id) + + subscription = Subscription( + id=unique_id, + callback=callback, + unsubscribe=_unsubscribe, + ) + self._state_change_emitters[unique_id] = subscription + return subscription + + def reset_password_email( + self, + email: str, + options: Options = {}, + ) -> None: + """ + Sends a password reset request to an email address. + """ + self._request( + "POST", + "recover", + body={ + "email": email, + "gotrue_meta_security": { + "captcha_token": options.get("captcha_token"), + }, + }, + redirect_to=options.get("redirect_to"), + ) + + # MFA methods + + def _enroll(self, params: MFAEnrollParams) -> AuthMFAEnrollResponse: + session = self.get_session() + if not session: + raise AuthSessionMissingError() + response = self._request( + "POST", + "factors", + body=params, + jwt=session.access_token, + xform=AuthMFAEnrollResponse.parse_obj, + ) + if response.totp.qr_code: + response.totp.qr_code = f"data:image/svg+xml;utf-8,{response.totp.qr_code}" + return response + + def _challenge(self, params: MFAChallengeParams) -> AuthMFAChallengeResponse: + session = self.get_session() + if not session: + raise AuthSessionMissingError() + return self._request( + "POST", + f"factors/{params.get('factor_id')}/challenge", + jwt=session.access_token, + xform=AuthMFAChallengeResponse.parse_obj, + ) + + def _challenge_and_verify( + self, + params: MFAChallengeAndVerifyParams, + ) -> AuthMFAVerifyResponse: + response = self._challenge( + { + "factor_id": params.get("factor_id"), + } + ) + return self._verify( + { + "factor_id": params.get("factor_id"), + "challenge_id": response.id, + "code": params.get("code"), + } + ) + + def _verify(self, params: MFAVerifyParams) -> AuthMFAVerifyResponse: + session = self.get_session() + if not session: + raise AuthSessionMissingError() + response = self._request( + "POST", + f"factors/{params.get('factor_id')}/verify", + body=params, + jwt=session.access_token, + xform=AuthMFAVerifyResponse.parse_obj, + ) + session = Session.parse_obj(response.dict()) + self._save_session(session) + self._notify_all_subscribers("MFA_CHALLENGE_VERIFIED", session) + return response + + def _unenroll(self, params: MFAUnenrollParams) -> AuthMFAUnenrollResponse: + session = self.get_session() + if not session: + raise AuthSessionMissingError() + return self._request( + "DELETE", + f"factors/{params.get('factor_id')}", + jwt=session.access_token, + xform=AuthMFAUnenrollResponse.parse_obj, + ) + + def _list_factors(self) -> AuthMFAListFactorsResponse: + response = self.get_user() + all = response.user.factors or [] + totp = [f for f in all if f.factor_type == "totp" and f.status == "verified"] + return AuthMFAListFactorsResponse(all=all, totp=totp) + + def _get_authenticator_assurance_level( + self, + ) -> AuthMFAGetAuthenticatorAssuranceLevelResponse: + session = self.get_session() + if not session: + return AuthMFAGetAuthenticatorAssuranceLevelResponse( + current_level=None, + next_level=None, + current_authentication_methods=[], + ) + payload = self._decode_jwt(session.access_token) + current_level: Union[AuthenticatorAssuranceLevels, None] = None + if payload.get("aal"): + current_level = payload.get("aal") + verified_factors = [ + f for f in session.user.factors or [] if f.status == "verified" + ] + next_level = "aal2" if verified_factors else current_level + current_authentication_methods = payload.get("amr") or [] + return AuthMFAGetAuthenticatorAssuranceLevelResponse( + current_level=current_level, + next_level=next_level, + current_authentication_methods=current_authentication_methods, + ) + + # Private methods + + def _remove_session(self) -> None: + if self._persist_session: + self._storage.remove_item(self._storage_key) + else: + self._in_memory_session = None + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + + def _get_session_from_url( + self, + url: str, + ) -> Tuple[Session, Union[str, None]]: + if not self._is_implicit_grant_flow(url): + raise AuthImplicitGrantRedirectError("Not a valid implicit grant flow url.") + result = urlparse(url) + params = parse_qs(result.query) + error_description = self._get_param(params, "error_description") + if error_description: + error_code = self._get_param(params, "error_code") + error = self._get_param(params, "error") + if not error_code: + raise AuthImplicitGrantRedirectError("No error_code detected.") + if not error: + raise AuthImplicitGrantRedirectError("No error detected.") + raise AuthImplicitGrantRedirectError( + error_description, + {"code": error_code, "error": error}, + ) + provider_token = self._get_param(params, "provider_token") + provider_refresh_token = self._get_param(params, "provider_refresh_token") + access_token = self._get_param(params, "access_token") + if not access_token: + raise AuthImplicitGrantRedirectError("No access_token detected.") + expires_in = self._get_param(params, "expires_in") + if not expires_in: + raise AuthImplicitGrantRedirectError("No expires_in detected.") + refresh_token = self._get_param(params, "refresh_token") + if not refresh_token: + raise AuthImplicitGrantRedirectError("No refresh_token detected.") + token_type = self._get_param(params, "token_type") + if not token_type: + raise AuthImplicitGrantRedirectError("No token_type detected.") + time_now = round(time()) + expires_at = time_now + int(expires_in) + user = self.get_user(access_token) + session = Session( + provider_token=provider_token, + provider_refresh_token=provider_refresh_token, + access_token=access_token, + expires_in=int(expires_in), + expires_at=expires_at, + refresh_token=refresh_token, + token_type=token_type, + user=user.user, + ) + redirect_type = self._get_param(params, "type") + return session, redirect_type + + def _recover_and_refresh(self) -> None: + raw_session = self._storage.get_item(self._storage_key) + current_session = self._get_valid_session(raw_session) + if not current_session: + if raw_session: + self._remove_session() + return + time_now = round(time()) + expires_at = current_session.expires_at + if expires_at and expires_at < time_now + EXPIRY_MARGIN: + refresh_token = current_session.refresh_token + if self._auto_refresh_token and refresh_token: + self._network_retries += 1 + try: + self._call_refresh_token(refresh_token) + self._network_retries = 0 + except Exception as e: + if ( + isinstance(e, AuthRetryableError) + and self._network_retries < MAX_RETRIES + ): + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = Timer( + (RETRY_INTERVAL ** (self._network_retries * 100)), + self._recover_and_refresh, + ) + self._refresh_token_timer.start() + return + self._remove_session() + return + if self._persist_session: + self._save_session(current_session) + self._notify_all_subscribers("SIGNED_IN", current_session) + + def _call_refresh_token(self, refresh_token: str) -> Session: + if not refresh_token: + raise AuthSessionMissingError() + response = self._refresh_access_token(refresh_token) + if not response.session: + raise AuthSessionMissingError() + self._save_session(response.session) + self._notify_all_subscribers("TOKEN_REFRESHED", response.session) + return response.session + + def _refresh_access_token(self, refresh_token: str) -> AuthResponse: + return self._request( + "POST", + "token", + query={"grant_type": "refresh_token"}, + body={"refresh_token": refresh_token}, + xform=parse_auth_response, + ) + + def _save_session(self, session: Session) -> None: + if not self._persist_session: + self._in_memory_session = session + expire_at = session.expires_at + if expire_at: + time_now = round(time()) + expire_in = expire_at - time_now + refresh_duration_before_expires = ( + EXPIRY_MARGIN if expire_in > EXPIRY_MARGIN else 0.5 + ) + value = (expire_in - refresh_duration_before_expires) * 1000 + self._start_auto_refresh_token(value) + if self._persist_session and session.expires_at: + self._storage.set_item(self._storage_key, session.json()) + + def _start_auto_refresh_token(self, value: float) -> None: + if self._refresh_token_timer: + self._refresh_token_timer.cancel() + self._refresh_token_timer = None + if value <= 0 or not self._auto_refresh_token: + return + + def refresh_token_function(): + self._network_retries += 1 + try: + session = self.get_session() + if session: + self._call_refresh_token(session.refresh_token) + self._network_retries = 0 + except Exception as e: + if ( + isinstance(e, AuthRetryableError) + and self._network_retries < MAX_RETRIES + ): + self._start_auto_refresh_token( + RETRY_INTERVAL ** (self._network_retries * 100) + ) + + self._refresh_token_timer = Timer(value, refresh_token_function) + self._refresh_token_timer.start() + + def _notify_all_subscribers( + self, + event: AuthChangeEvent, + session: Union[Session, None], + ) -> None: + for subscription in self._state_change_emitters.values(): + subscription.callback(event, session) + + def _get_valid_session( + self, + raw_session: Union[str, None], + ) -> Union[Session, None]: + if not raw_session: + return None + data = loads(raw_session) + if not data: + return None + if not data.get("access_token"): + return None + if not data.get("refresh_token"): + return None + if not data.get("expires_at"): + return None + try: + expires_at = int(data["expires_at"]) + data["expires_at"] = expires_at + except ValueError: + return None + try: + return Session.parse_obj(data) + except Exception: + return None + + def _get_param( + self, + query_params: Dict[str, List[str]], + name: str, + ) -> Union[str, None]: + return query_params[name][0] if name in query_params else None + + def _is_implicit_grant_flow(self, url: str) -> bool: + result = urlparse(url) + params = parse_qs(result.query) + return "access_token" in params or "error_description" in params + + def _get_url_for_provider( + self, + provider: Provider, + params: Dict[str, str], + ) -> str: + params = {k: quote(v) for k, v in params.items()} + params["provider"] = quote(provider) + query = urlencode(params) + return f"{self._url}/authorize?{query}" + + def _decode_jwt(self, jwt: str) -> DecodedJWTDict: + """ + Decodes a JWT (without performing any validation). + """ + return decode_jwt_payload(jwt) diff --git a/gotrue/_sync/gotrue_mfa_api.py b/gotrue/_sync/gotrue_mfa_api.py new file mode 100644 index 00000000..16bec8d5 --- /dev/null +++ b/gotrue/_sync/gotrue_mfa_api.py @@ -0,0 +1,94 @@ +from ..types import ( + AuthMFAChallengeResponse, + AuthMFAEnrollResponse, + AuthMFAGetAuthenticatorAssuranceLevelResponse, + AuthMFAListFactorsResponse, + AuthMFAUnenrollResponse, + AuthMFAVerifyResponse, + MFAChallengeAndVerifyParams, + MFAChallengeParams, + MFAEnrollParams, + MFAUnenrollParams, + MFAVerifyParams, +) + + +class SyncGoTrueMFAAPI: + """ + Contains the full multi-factor authentication API. + """ + + def enroll(self, params: MFAEnrollParams) -> AuthMFAEnrollResponse: + """ + Starts the enrollment process for a new Multi-Factor Authentication + factor. This method creates a new factor in the 'unverified' state. + Present the QR code or secret to the user and ask them to add it to their + authenticator app. Ask the user to provide you with an authenticator code + from their app and verify it by calling challenge and then verify. + + The first successful verification of an unverified factor activates the + factor. All other sessions are logged out and the current one gets an + `aal2` authenticator level. + """ + raise NotImplementedError() # pragma: no cover + + def challenge(self, params: MFAChallengeParams) -> AuthMFAChallengeResponse: + """ + Prepares a challenge used to verify that a user has access to a MFA + factor. Provide the challenge ID and verification code by calling `verify`. + """ + raise NotImplementedError() # pragma: no cover + + def challenge_and_verify( + self, + params: MFAChallengeAndVerifyParams, + ) -> AuthMFAVerifyResponse: + """ + Helper method which creates a challenge and immediately uses the given code + to verify against it thereafter. The verification code is provided by the + user by entering a code seen in their authenticator app. + """ + raise NotImplementedError() # pragma: no cover + + def verify(self, params: MFAVerifyParams) -> AuthMFAVerifyResponse: + """ + Verifies a verification code against a challenge. The verification code is + provided by the user by entering a code seen in their authenticator app. + """ + raise NotImplementedError() # pragma: no cover + + def unenroll(self, params: MFAUnenrollParams) -> AuthMFAUnenrollResponse: + """ + Unenroll removes a MFA factor. Unverified factors can safely be ignored + and it's not necessary to unenroll them. Unenrolling a verified MFA factor + cannot be done from a session with an `aal1` authenticator level. + """ + raise NotImplementedError() # pragma: no cover + + def list_factors(self) -> AuthMFAListFactorsResponse: + """ + Returns the list of MFA factors enabled for this user. For most use cases + you should consider using `get_authenticator_assurance_level`. + + This uses a cached version of the factors and avoids incurring a network call. + If you need to update this list, call `get_user` first. + """ + raise NotImplementedError() # pragma: no cover + + def get_authenticator_assurance_level( + self, + ) -> AuthMFAGetAuthenticatorAssuranceLevelResponse: + """ + Returns the Authenticator Assurance Level (AAL) for the active session. + + - `aal1` (or `null`) means that the user's identity has been verified only + with a conventional login (email+password, OTP, magic link, social login, + etc.). + - `aal2` means that the user's identity has been verified both with a + conventional login and at least one MFA factor. + + Although this method returns a promise, it's fairly quick (microseconds) + and rarely uses the network. You can use this to check whether the current + user needs to be shown a screen to verify their MFA factors. + """ + raise NotImplementedError() # pragma: no cover diff --git a/gotrue/constants.py b/gotrue/constants.py index e87dd060..904deb1d 100644 --- a/gotrue/constants.py +++ b/gotrue/constants.py @@ -1,18 +1,14 @@ from __future__ import annotations -from gotrue import __version__ +from typing import Dict + +from . import __version__ GOTRUE_URL = "http://localhost:9999" -AUDIENCE = "" -DEFAULT_HEADERS = { +DEFAULT_HEADERS: Dict[str, str] = { "X-Client-Info": f"gotrue-py/{__version__}", } -EXPIRY_MARGIN = 60 * 1000 +EXPIRY_MARGIN = 10 # seconds +MAX_RETRIES = 10 +RETRY_INTERVAL = 2 # deciseconds STORAGE_KEY = "supabase.auth.token" -COOKIE_OPTIONS = { - "name": "sb:token", - "lifetime": 60 * 60 * 8, - "domain": "", - "path": "/", - "same_site": "lax", -} diff --git a/gotrue/errors.py b/gotrue/errors.py new file mode 100644 index 00000000..742d5d44 --- /dev/null +++ b/gotrue/errors.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import Union + +from typing_extensions import TypedDict + + +class AuthError(Exception): + def __init__(self, message: str) -> None: + Exception.__init__(self, message) + self.message = message + self.name = "AuthError" + + +class AuthApiErrorDict(TypedDict): + name: str + message: str + status: int + + +class AuthApiError(AuthError): + def __init__(self, message: str, status: int) -> None: + AuthError.__init__(self, message) + self.name = "AuthApiError" + self.status = status + + def to_dict(self) -> AuthApiErrorDict: + return { + "name": self.name, + "message": self.message, + "status": self.status, + } + + +class AuthUnknownError(AuthError): + def __init__(self, message: str, original_error: Exception) -> None: + AuthError.__init__(self, message) + self.name = "AuthUnknownError" + self.original_error = original_error + + +class CustomAuthError(AuthError): + def __init__(self, message: str, name: str, status: int) -> None: + AuthError.__init__(self, message) + self.name = name + self.status = status + + def to_dict(self) -> AuthApiErrorDict: + return { + "name": self.name, + "message": self.message, + "status": self.status, + } + + +class AuthSessionMissingError(CustomAuthError): + def __init__(self) -> None: + CustomAuthError.__init__( + self, + "Auth session missing!", + "AuthSessionMissingError", + 400, + ) + + +class AuthInvalidCredentialsError(CustomAuthError): + def __init__(self, message: str) -> None: + CustomAuthError.__init__( + self, + message, + "AuthInvalidCredentialsError", + 400, + ) + + +class AuthImplicitGrantRedirectErrorDetails(TypedDict): + error: str + code: str + + +class AuthImplicitGrantRedirectErrorDict(AuthApiErrorDict): + details: Union[AuthImplicitGrantRedirectErrorDetails, None] + + +class AuthImplicitGrantRedirectError(CustomAuthError): + def __init__( + self, + message: str, + details: Union[AuthImplicitGrantRedirectErrorDetails, None] = None, + ) -> None: + CustomAuthError.__init__( + self, + message, + "AuthImplicitGrantRedirectError", + 500, + ) + self.details = details + + def to_dict(self) -> AuthImplicitGrantRedirectErrorDict: + return { + "name": self.name, + "message": self.message, + "status": self.status, + "details": self.details, + } + + +class AuthRetryableError(CustomAuthError): + def __init__(self, message: str, status: int) -> None: + CustomAuthError.__init__( + self, + message, + "AuthRetryableError", + status, + ) diff --git a/gotrue/exceptions.py b/gotrue/exceptions.py deleted file mode 100644 index 9979410f..00000000 --- a/gotrue/exceptions.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from dataclasses import asdict, dataclass -from typing import Any, Dict - - -@dataclass -class APIError(Exception): - msg: str - code: int - - def __post_init__(self) -> None: - self.msg = str(self.msg) - self.code = int(str(self.code)) - - @classmethod - def parse_dict(cls, **json: dict) -> APIError: - ret = cls(msg="Unknown error", code=-1) - for new_name, new_val in json.items(): - setattr(ret, new_name, new_val) - return ret - - @classmethod - def from_dict(cls, data: dict) -> APIError: - if "msg" in data and "code" in data: - return APIError( - msg=data["msg"], - code=data["code"], - ) - if "error" in data and "error_description" in data: - try: - code = int(data["error"]) - except ValueError: - code = -1 - return APIError( - msg=data["error_description"], - code=code, - ) - if "message" in data: - try: - code = int(data.get("code", -1)) - except ValueError: - code = -1 - return APIError( - msg=data["message"], - code=code, - ) - return cls.parse_dict(**data) - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) diff --git a/gotrue/helpers.py b/gotrue/helpers.py index 069b79b7..6aa73025 100644 --- a/gotrue/helpers.py +++ b/gotrue/helpers.py @@ -1,18 +1,86 @@ from __future__ import annotations -from urllib.parse import quote +from base64 import b64decode +from json import loads +from typing import Any, Union, cast -from httpx import HTTPError, Response +from httpx import HTTPStatusError -from .exceptions import APIError +from .errors import AuthApiError, AuthError, AuthRetryableError, AuthUnknownError +from .types import ( + AuthResponse, + GenerateLinkProperties, + GenerateLinkResponse, + Session, + User, + UserResponse, +) -def encode_uri_component(uri: str) -> str: - return quote(uri.encode("utf-8")) +def parse_auth_response(data: Any) -> AuthResponse: + session: Union[Session, None] = None + if ( + "access_token" in data + and "refresh_token" in data + and "expires_in" in data + and data["access_token"] + and data["refresh_token"] + and data["expires_in"] + ): + session = Session.parse_obj(data) + user = User.parse_obj(data["user"]) if "user" in data else User.parse_obj(data) + return AuthResponse(session=session, user=user) -def check_response(response: Response) -> None: +def parse_link_response(data: Any) -> GenerateLinkResponse: + properties = GenerateLinkProperties( + action_link=data.get("action_link"), + email_otp=data.get("email_otp"), + hashed_token=data.get("hashed_token"), + redirect_to=data.get("redirect_to"), + verification_type=data.get("verification_type"), + ) + user = User.parse_obj({k: v for k, v in data.items() if k not in properties.dict()}) + return GenerateLinkResponse(properties=properties, user=user) + + +def parse_user_response(data: Any) -> UserResponse: + if "user" not in data: + data = {"user": data} + return UserResponse.parse_obj(data) + + +def get_error_message(error: Any) -> str: + props = ["msg", "message", "error_description", "error"] + filter = ( + lambda prop: prop in error if isinstance(error, dict) else hasattr(error, prop) + ) + return next((error[prop] for prop in props if filter(prop)), str(error)) + + +def looks_like_http_status_error(exception: Exception) -> bool: + return isinstance(exception, HTTPStatusError) + + +def handle_exception(exception: Exception) -> AuthError: + if not looks_like_http_status_error(exception): + return AuthRetryableError(get_error_message(exception), 0) + error = cast(HTTPStatusError, exception) try: - response.raise_for_status() - except HTTPError: - raise APIError.from_dict(response.json()) + network_error_codes = [502, 503, 504] + if error.response.status_code in network_error_codes: + return AuthRetryableError( + get_error_message(error), error.response.status_code + ) + json = error.response.json() + return AuthApiError(get_error_message(json), error.response.status_code or 500) + except Exception as e: + return AuthUnknownError(get_error_message(error), e) + + +def decode_jwt_payload(token: str) -> Any: + parts = token.split(".") + if len(parts) != 3: + raise ValueError("JWT is not valid: not a JWT structure") + base64Url = parts[1] + return loads(b64decode(base64Url).decode("utf-8")) diff --git a/gotrue/timer.py b/gotrue/timer.py new file mode 100644 index 00000000..1b6bf9b8 --- /dev/null +++ b/gotrue/timer.py @@ -0,0 +1,44 @@ +import asyncio +from threading import Timer as _Timer +from typing import Any, Callable, Coroutine, Union, cast + + +class Timer: + def __init__( + self, + seconds: float, + function: Callable[[], Union[Coroutine[Any, Any, None], None]], + ) -> None: + self._milliseconds = seconds + self._function = function + self._task: Union[asyncio.Task, None] = None + self._timer: Union[_Timer, None] = None + + def start(self) -> None: + if asyncio.iscoroutinefunction(self._function): + + async def schedule(): + await asyncio.sleep(self._milliseconds / 1000) + await cast(Coroutine[Any, Any, None], self._function()) + + def cleanup(_): + self._task = None + + self._task = asyncio.create_task(schedule()) + self._task.add_done_callback(cleanup) + else: + self._timer = _Timer(self._milliseconds / 1000, self._function) + self._timer.start() + + def cancel(self) -> None: + if self._task is not None: + self._task.cancel() + self._task = None + if self._timer is not None: + self._timer.cancel() + self._timer = None + + def is_alive(self) -> bool: + return self._task is not None or ( + self._timer is not None and self._timer.is_alive() + ) diff --git a/gotrue/types.py b/gotrue/types.py index fdfd294d..74a93136 100644 --- a/gotrue/types.py +++ b/gotrue/types.py @@ -1,167 +1,636 @@ from __future__ import annotations -import sys from datetime import datetime -from enum import Enum from time import time -from typing import Any, Callable, Dict, List, Optional, Type, TypeVar, Union -from uuid import UUID +from typing import Any, Callable, Dict, List, Union -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - -from httpx import Response from pydantic import BaseModel, root_validator +from typing_extensions import Literal, NotRequired, TypedDict + +Provider = Literal[ + "apple", + "azure", + "bitbucket", + "discord", + "facebook", + "github", + "gitlab", + "google", + "keycloak", + "linkedin", + "notion", + "slack", + "spotify", + "twitch", + "twitter", + "workos", +] + +AuthChangeEventMFA = Literal["MFA_CHALLENGE_VERIFIED"] + +AuthChangeEvent = Literal[ + "PASSWORD_RECOVERY", + "SIGNED_IN", + "SIGNED_OUT", + "TOKEN_REFRESHED", + "USER_UPDATED", + "USER_DELETED", + AuthChangeEventMFA, +] + + +class AMREntry(BaseModel): + """ + An authentication methord reference (AMR) entry. + + An entry designates what method was used by the user to verify their + identity and at what time. + """ + + method: Union[Literal["password", "otp", "oauth", "mfa/totp"], str] + """ + Authentication method name. + """ + timestamp: int + """ + Timestamp when the method was successfully used. Represents number of + seconds since 1st January 1970 (UNIX epoch) in UTC. + """ + + +class Options(TypedDict): + redirect_to: NotRequired[str] + data: NotRequired[Any] + + +class AuthResponse(BaseModel): + user: Union[User, None] = None + session: Union[Session, None] = None + + +class OAuthResponse(BaseModel): + provider: Provider + url: str + + +class UserResponse(BaseModel): + user: User + + +class Session(BaseModel): + provider_token: Union[str, None] = None + """ + The oauth provider token. If present, this can be used to make external API + requests to the oauth provider used. + """ + provider_refresh_token: Union[str, None] = None + """ + The oauth provider refresh token. If present, this can be used to refresh + the provider_token via the oauth provider's API. + + Not all oauth providers return a provider refresh token. If the + provider_refresh_token is missing, please refer to the oauth provider's + documentation for information on how to obtain the provider refresh token. + """ + access_token: str + refresh_token: str + expires_in: int + """ + The number of seconds until the token expires (since it was issued). + Returned when a login is confirmed. + """ + expires_at: Union[int, None] = None + """ + A timestamp of when the token will expire. Returned when a login is confirmed. + """ + token_type: str + user: User -from gotrue.helpers import check_response - -T = TypeVar("T", bound=BaseModel) - - -def determine_session_or_user_model_from_response( - response: Response, -) -> Union[Type[Session], Type[User]]: - return Session if "access_token" in response.json() else User - + @root_validator + def validator(cls, values: dict) -> dict: + expires_in = values.get("expires_in") + if expires_in and not values.get("expires_at"): + values["expires_at"] = round(time()) + expires_in + return values -class BaseModelFromResponse(BaseModel): - @classmethod - def parse_response(cls: Type[T], response: Response) -> T: - check_response(response) - return cls.parse_obj(response.json()) +class UserIdentity(BaseModel): + id: str + user_id: str + identity_data: Dict[str, Any] + provider: str + created_at: datetime + last_sign_in_at: datetime + updated_at: Union[datetime, None] = None -class CookieOptions(BaseModelFromResponse): - name: str - """The name of the cookie. Defaults to `sb:token`.""" - lifetime: int - """The cookie lifetime (expiration) in seconds. Set to 8 hours by default.""" - domain: str - """The cookie domain this should run on. - Leave it blank to restrict it to your domain.""" - path: str - same_site: str - """SameSite configuration for the session cookie. - Defaults to 'lax', but can be changed to 'strict' or 'none'. - Set it to false if you want to disable the SameSite setting.""" +class Factor(BaseModel): + """ + A MFA factor. + """ -class Identity(BaseModelFromResponse): id: str - user_id: UUID - provider: str + """ + ID of the factor. + """ + friendly_name: Union[str, None] = None + """ + Friendly name of the factor, useful to disambiguate between multiple factors. + """ + factor_type: Union[Literal["totp"], str] + """ + Type of factor. Only `totp` supported with this version but may change in + future versions. + """ + status: Literal["verified", "unverified"] + """ + Factor's status. + """ created_at: datetime updated_at: datetime - identity_data: Optional[Dict[str, Any]] = None - last_sign_in_at: Optional[datetime] = None -class User(BaseModelFromResponse): +class User(BaseModel): + id: str app_metadata: Dict[str, Any] + user_metadata: Dict[str, Any] aud: str - """The user's audience. Use audiences to group users.""" + confirmation_sent_at: Union[datetime, None] = None + recovery_sent_at: Union[datetime, None] = None + email_change_sent_at: Union[datetime, None] = None + new_email: Union[str, None] = None + invited_at: Union[datetime, None] = None + action_link: Union[str, None] = None + email: Union[str, None] = None + phone: Union[str, None] = None created_at: datetime - id: UUID - user_metadata: Dict[str, Any] - identities: Optional[List[Identity]] = None - confirmation_sent_at: Optional[datetime] = None - action_link: Optional[str] = None - last_sign_in_at: Optional[datetime] = None - phone: Optional[str] = None - phone_confirmed_at: Optional[datetime] = None - recovery_sent_at: Optional[datetime] = None - role: Optional[str] = None - updated_at: Optional[datetime] = None - email_confirmed_at: Optional[datetime] = None - confirmed_at: Optional[datetime] = None - invited_at: Optional[datetime] = None - email: Optional[str] = None - new_email: Optional[str] = None - email_change_sent_at: Optional[datetime] = None - new_phone: Optional[str] = None - phone_change_sent_at: Optional[datetime] = None - - -class UserAttributes(BaseModelFromResponse): - email: Optional[str] = None - """The user's email.""" - password: Optional[str] = None - """The user's password.""" - email_change_token: Optional[str] = None - """An email change token.""" - data: Optional[Any] = None - """A custom data object. Can be any JSON.""" - - -class Session(BaseModelFromResponse): - access_token: str - token_type: str - expires_at: Optional[int] = None - """A timestamp of when the token will expire. Returned when a login is confirmed.""" - expires_in: Optional[int] = None - """The number of seconds until the token expires (since it was issued). - Returned when a login is confirmed.""" - provider_token: Optional[str] = None - refresh_token: Optional[str] = None - user: Optional[User] = None + confirmed_at: Union[datetime, None] = None + email_confirmed_at: Union[datetime, None] = None + phone_confirmed_at: Union[datetime, None] = None + last_sign_in_at: Union[datetime, None] = None + role: Union[str, None] = None + updated_at: Union[datetime, None] = None + identities: Union[List[UserIdentity], None] = None + factors: Union[List[Factor], None] = None - @root_validator - def validator(cls, values: dict) -> dict: - expires_in = values.get("expires_in") - if expires_in and not values.get("expires_at"): - values["expires_at"] = round(time()) + expires_in - return values +class UserAttributes(TypedDict): + email: NotRequired[str] + phone: NotRequired[str] + password: NotRequired[str] + data: NotRequired[Any] -class AuthChangeEvent(str, Enum): - PASSWORD_RECOVERY = "PASSWORD_RECOVERY" - SIGNED_IN = "SIGNED_IN" - SIGNED_OUT = "SIGNED_OUT" - TOKEN_REFRESHED = "TOKEN_REFRESHED" - USER_UPDATED = "USER_UPDATED" - USER_DELETED = "USER_DELETED" +class AdminUserAttributes(UserAttributes, TypedDict): + user_metadata: NotRequired[Any] + app_metadata: NotRequired[Any] + email_confirm: NotRequired[bool] + phone_confirm: NotRequired[bool] + ban_duration: NotRequired[Union[str, Literal["none"]]] -class Subscription(BaseModelFromResponse): - id: UUID - """The subscriber UUID. This will be set by the client.""" - callback: Callable[[AuthChangeEvent, Optional[Session]], None] - """The function to call every time there is an event.""" + +class Subscription(BaseModel): + id: str + """ + The subscriber UUID. This will be set by the client. + """ + callback: Callable[[AuthChangeEvent, Union[Session, None]], None] + """ + The function to call every time there is an event. + """ unsubscribe: Callable[[], None] - """Call this to remove the listener.""" + """ + Call this to remove the listener. + """ + + +class UpdatableFactorAttributes(TypedDict): + friendly_name: str + + +class SignUpWithEmailAndPasswordCredentialsOptions( + TypedDict, +): + email_redirect_to: NotRequired[str] + data: NotRequired[Any] + captcha_token: NotRequired[str] + + +class SignUpWithEmailAndPasswordCredentials(TypedDict): + email: str + password: str + options: NotRequired[SignUpWithEmailAndPasswordCredentialsOptions] + + +class SignUpWithPhoneAndPasswordCredentialsOptions(TypedDict): + data: NotRequired[Any] + captcha_token: NotRequired[str] + + +class SignUpWithPhoneAndPasswordCredentials(TypedDict): + phone: str + password: str + options: NotRequired[SignUpWithPhoneAndPasswordCredentialsOptions] + + +SignUpWithPasswordCredentials = Union[ + SignUpWithEmailAndPasswordCredentials, + SignUpWithPhoneAndPasswordCredentials, +] + + +class SignInWithPasswordCredentialsOptions(TypedDict): + data: NotRequired[Any] + captcha_token: NotRequired[str] + + +class SignInWithEmailAndPasswordCredentials(TypedDict): + email: str + password: str + options: NotRequired[SignInWithPasswordCredentialsOptions] + + +class SignInWithPhoneAndPasswordCredentials(TypedDict): + phone: str + password: str + options: NotRequired[SignInWithPasswordCredentialsOptions] + + +SignInWithPasswordCredentials = Union[ + SignInWithEmailAndPasswordCredentials, + SignInWithPhoneAndPasswordCredentials, +] + +class SignInWithEmailAndPasswordlessCredentialsOptions(TypedDict): + email_redirect_to: NotRequired[str] + should_create_user: NotRequired[bool] + data: NotRequired[Any] + captcha_token: NotRequired[str] -class Provider(str, Enum): - apple = "apple" - azure = "azure" - bitbucket = "bitbucket" - discord = "discord" - facebook = "facebook" - github = "github" - gitlab = "gitlab" - google = "google" - notion = "notion" - slack = "slack" - spotify = "spotify" - twitter = "twitter" - twitch = "twitch" +class SignInWithEmailAndPasswordlessCredentials(TypedDict): + email: str + options: NotRequired[SignInWithEmailAndPasswordlessCredentialsOptions] -class LinkType(str, Enum): - """The type of link.""" - signup = "signup" - magiclink = "magiclink" - recovery = "recovery" - invite = "invite" +class SignInWithPhoneAndPasswordlessCredentialsOptions(TypedDict): + should_create_user: NotRequired[bool] + data: NotRequired[Any] + captcha_token: NotRequired[str] -class UserAttributesDict(TypedDict, total=False): - """Dict version of `UserAttributes`""" +class SignInWithPhoneAndPasswordlessCredentials(TypedDict): + phone: str + options: NotRequired[SignInWithPhoneAndPasswordlessCredentialsOptions] - email: Optional[str] - password: Optional[str] - email_change_token: Optional[str] - data: Optional[Any] + +SignInWithPasswordlessCredentials = Union[ + SignInWithEmailAndPasswordlessCredentials, + SignInWithPhoneAndPasswordlessCredentials, +] + + +class SignInWithOAuthCredentialsOptions(TypedDict): + redirect_to: NotRequired[str] + scopes: NotRequired[str] + query_params: NotRequired[Dict[str, str]] + + +class SignInWithOAuthCredentials(TypedDict): + provider: Provider + options: NotRequired[SignInWithOAuthCredentialsOptions] + + +class VerifyOtpParamsOptions(TypedDict): + redirect_to: NotRequired[str] + captcha_token: NotRequired[str] + + +class VerifyEmailOtpParams(TypedDict): + email: str + token: str + type: Literal[ + "signup", + "invite", + "magiclink", + "recovery", + "email_change", + ] + options: NotRequired[VerifyOtpParamsOptions] + + +class VerifyMobileOtpParams(TypedDict): + phone: str + token: str + type: Literal[ + "sms", + "phone_change", + ] + options: NotRequired[VerifyOtpParamsOptions] + + +VerifyOtpParams = Union[ + VerifyEmailOtpParams, + VerifyMobileOtpParams, +] + + +class GenerateLinkParamsOptions(TypedDict): + redirect_to: NotRequired[str] + + +class GenerateLinkParamsWithDataOptions(GenerateLinkParamsOptions, TypedDict): + data: NotRequired[Any] + + +class GenerateSignupLinkParams(TypedDict): + type: Literal["signup"] + email: str + password: str + options: NotRequired[GenerateLinkParamsWithDataOptions] + + +class GenerateInviteOrMagiclinkParams(TypedDict): + type: Literal["invite", "magiclink"] + email: str + options: NotRequired[GenerateLinkParamsWithDataOptions] + + +class GenerateRecoveryLinkParams(TypedDict): + type: Literal["recovery"] + email: str + options: NotRequired[GenerateLinkParamsOptions] + + +class GenerateEmailChangeLinkParams(TypedDict): + type: Literal["email_change_current", "email_change_new"] + email: str + new_email: str + options: NotRequired[GenerateLinkParamsOptions] + + +GenerateLinkParams = Union[ + GenerateSignupLinkParams, + GenerateInviteOrMagiclinkParams, + GenerateRecoveryLinkParams, + GenerateEmailChangeLinkParams, +] + +GenerateLinkType = Literal[ + "signup", + "invite", + "magiclink", + "recovery", + "email_change_current", + "email_change_new", +] + + +class MFAEnrollParams(TypedDict): + factor_type: Literal["totp"] + issuer: NotRequired[str] + friendly_name: NotRequired[str] + + +class MFAUnenrollParams(TypedDict): + factor_id: str + """ + ID of the factor being unenrolled. + """ + + +class MFAVerifyParams(TypedDict): + factor_id: str + """ + ID of the factor being verified. + """ + challenge_id: str + """ + ID of the challenge being verified. + """ + code: str + """ + Verification code provided by the user. + """ + + +class MFAChallengeParams(TypedDict): + factor_id: str + """ + ID of the factor to be challenged. + """ + + +class MFAChallengeAndVerifyParams(TypedDict): + factor_id: str + """ + ID of the factor being verified. + """ + code: str + """ + Verification code provided by the user. + """ + + +class AuthMFAVerifyResponse(BaseModel): + access_token: str + """ + New access token (JWT) after successful verification. + """ + token_type: str + """ + Type of token, typically `Bearer`. + """ + expires_in: int + """ + Number of seconds in which the access token will expire. + """ + refresh_token: str + """ + Refresh token you can use to obtain new access tokens when expired. + """ + user: User + """ + Updated user profile. + """ + + +class AuthMFAEnrollResponseTotp(BaseModel): + qr_code: str + """ + Contains a QR code encoding the authenticator URI. You can + convert it to a URL by prepending `data:image/svg+xml;utf-8,` to + the value. Avoid logging this value to the console. + """ + secret: str + """ + The TOTP secret (also encoded in the QR code). Show this secret + in a password-style field to the user, in case they are unable to + scan the QR code. Avoid logging this value to the console. + """ + uri: str + """ + The authenticator URI encoded within the QR code, should you need + to use it. Avoid loggin this value to the console. + """ + + +class AuthMFAEnrollResponse(BaseModel): + id: str + """ + ID of the factor that was just enrolled (in an unverified state). + """ + type: Literal["totp"] + """ + Type of MFA factor. Only `totp` supported for now. + """ + totp: AuthMFAEnrollResponseTotp + """ + TOTP enrollment information. + """ + + +class AuthMFAUnenrollResponse(BaseModel): + id: str + """ + ID of the factor that was successfully unenrolled. + """ + + +class AuthMFAChallengeResponse(BaseModel): + id: str + """ + ID of the newly created challenge. + """ + expires_at: int + """ + Timestamp in UNIX seconds when this challenge will no longer be usable. + """ + + +class AuthMFAListFactorsResponse(BaseModel): + all: List[Factor] + """ + All available factors (verified and unverified). + """ + totp: List[Factor] + """ + Only verified TOTP factors. (A subset of `all`.) + """ + + +AuthenticatorAssuranceLevels = Literal["aal1", "aal2"] + + +class AuthMFAGetAuthenticatorAssuranceLevelResponse(BaseModel): + current_level: Union[AuthenticatorAssuranceLevels, None] = None + """ + Current AAL level of the session. + """ + next_level: Union[AuthenticatorAssuranceLevels, None] = None + """ + Next possible AAL level for the session. If the next level is higher + than the current one, the user should go through MFA. + """ + current_authentication_methods: List[AMREntry] + """ + A list of all authentication methods attached to this session. Use + the information here to detect the last time a user verified a + factor, for example if implementing a step-up scenario. + """ + + +class AuthMFAAdminDeleteFactorResponse(BaseModel): + id: str + """ + ID of the factor that was successfully deleted. + """ + + +class AuthMFAAdminDeleteFactorParams(TypedDict): + id: str + """ + ID of the MFA factor to delete. + """ + user_id: str + """ + ID of the user whose factor is being deleted. + """ + + +class AuthMFAAdminListFactorsResponse(BaseModel): + factors: List[Factor] + """ + All factors attached to the user. + """ + + +class AuthMFAAdminListFactorsParams(TypedDict): + user_id: str + """ + ID of the user for which to list all MFA factors. + """ + + +class GenerateLinkProperties(BaseModel): + """ + The properties related to the email link generated. + """ + + action_link: str + """ + The email link to send to the user. The action_link follows the following format: + + auth/v1/verify?type={verification_type}&token={hashed_token}&redirect_to={redirect_to} + """ + email_otp: str + """ + The raw email OTP. + You should send this in the email if you want your users to verify using an + OTP instead of the action link. + """ + hashed_token: str + """ + The hashed token appended to the action link. + """ + redirect_to: str + """ + The URL appended to the action link. + """ + verification_type: GenerateLinkType + """ + The verification type that the email link is associated to. + """ + + +class GenerateLinkResponse(BaseModel): + properties: GenerateLinkProperties + user: User + + +class DecodedJWTDict(TypedDict): + exp: NotRequired[int] + aal: NotRequired[Union[AuthenticatorAssuranceLevels, None]] + amr: NotRequired[Union[List[AMREntry], None]] + + +AMREntry.update_forward_refs() +AuthResponse.update_forward_refs() +OAuthResponse.update_forward_refs() +UserResponse.update_forward_refs() +Session.update_forward_refs() +UserIdentity.update_forward_refs() +Factor.update_forward_refs() +User.update_forward_refs() +Subscription.update_forward_refs() +AuthMFAVerifyResponse.update_forward_refs() +AuthMFAEnrollResponseTotp.update_forward_refs() +AuthMFAEnrollResponse.update_forward_refs() +AuthMFAUnenrollResponse.update_forward_refs() +AuthMFAChallengeResponse.update_forward_refs() +AuthMFAListFactorsResponse.update_forward_refs() +AuthMFAGetAuthenticatorAssuranceLevelResponse.update_forward_refs() +AuthMFAAdminDeleteFactorResponse.update_forward_refs() +AuthMFAAdminListFactorsResponse.update_forward_refs() +GenerateLinkProperties.update_forward_refs() diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index f6d2988f..6f33acd6 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,8 +1,7 @@ # docker-compose.yml version: '3' services: - gotrue: - # Signup enabled, autoconfirm off + gotrue: # Signup enabled, autoconfirm off image: supabase/gotrue:latest ports: - '9999:9999' @@ -42,8 +41,7 @@ services: depends_on: - db restart: on-failure - autoconfirm: - # Signup enabled, autoconfirm on + autoconfirm: # Signup enabled, autoconfirm on image: supabase/gotrue:latest ports: - '9998:9998' @@ -72,8 +70,7 @@ services: depends_on: - db restart: on-failure - disabled: - # Signup disabled + disabled: # Signup disabled image: supabase/gotrue:latest ports: - '9997:9997' @@ -112,6 +109,7 @@ services: image: supabase/postgres:14.1.0 ports: - '5432:5432' + command: postgres -c config_file=/etc/postgresql/postgresql.conf volumes: - ./db:/docker-entrypoint-initdb.d/ environment: diff --git a/poetry.lock b/poetry.lock index a14a8854..555138f6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -15,7 +15,6 @@ files = [ [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] @@ -69,7 +68,6 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} [package.extras] @@ -80,14 +78,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "bleach" -version = "5.0.1" +version = "6.0.0" description = "An easy safelist-based HTML-sanitizing tool." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "bleach-5.0.1-py3-none-any.whl", hash = "sha256:085f7f33c15bd408dd9b17a4ad77c577db66d76203e5984b1bd59baeee948b2a"}, - {file = "bleach-5.0.1.tar.gz", hash = "sha256:0d03255c47eb9bd2f26aa9bb7f2107732e7e8fe195ca2f64709fcf3b0a4a085c"}, + {file = "bleach-6.0.0-py3-none-any.whl", hash = "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4"}, + {file = "bleach-6.0.0.tar.gz", hash = "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414"}, ] [package.dependencies] @@ -96,7 +94,6 @@ webencodings = "*" [package.extras] css = ["tinycss2 (>=1.1.0,<1.2)"] -dev = ["Sphinx (==4.3.2)", "black (==22.3.0)", "build (==0.8.0)", "flake8 (==4.0.1)", "hashin (==0.17.0)", "mypy (==0.961)", "pip-tools (==6.6.2)", "pytest (==7.1.2)", "tox (==3.25.0)", "twine (==4.0.1)", "wheel (==0.37.1)"] [[package]] name = "certifi" @@ -311,7 +308,6 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "click-log" @@ -342,63 +338,63 @@ files = [ [[package]] name = "coverage" -version = "7.0.5" +version = "7.1.0" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "coverage-7.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a7f23bbaeb2a87f90f607730b45564076d870f1fb07b9318d0c21f36871932b"}, - {file = "coverage-7.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c18d47f314b950dbf24a41787ced1474e01ca816011925976d90a88b27c22b89"}, - {file = "coverage-7.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef14d75d86f104f03dea66c13188487151760ef25dd6b2dbd541885185f05f40"}, - {file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66e50680e888840c0995f2ad766e726ce71ca682e3c5f4eee82272c7671d38a2"}, - {file = "coverage-7.0.5-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9fed35ca8c6e946e877893bbac022e8563b94404a605af1d1e6accc7eb73289"}, - {file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d8d04e755934195bdc1db45ba9e040b8d20d046d04d6d77e71b3b34a8cc002d0"}, - {file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e109f1c9a3ece676597831874126555997c48f62bddbcace6ed17be3e372de8"}, - {file = "coverage-7.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0a1890fca2962c4f1ad16551d660b46ea77291fba2cc21c024cd527b9d9c8809"}, - {file = "coverage-7.0.5-cp310-cp310-win32.whl", hash = "sha256:be9fcf32c010da0ba40bf4ee01889d6c737658f4ddff160bd7eb9cac8f094b21"}, - {file = "coverage-7.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:cbfcba14a3225b055a28b3199c3d81cd0ab37d2353ffd7f6fd64844cebab31ad"}, - {file = "coverage-7.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:30b5fec1d34cc932c1bc04017b538ce16bf84e239378b8f75220478645d11fca"}, - {file = "coverage-7.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1caed2367b32cc80a2b7f58a9f46658218a19c6cfe5bc234021966dc3daa01f0"}, - {file = "coverage-7.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d254666d29540a72d17cc0175746cfb03d5123db33e67d1020e42dae611dc196"}, - {file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19245c249aa711d954623d94f23cc94c0fd65865661f20b7781210cb97c471c0"}, - {file = "coverage-7.0.5-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b05ed4b35bf6ee790832f68932baf1f00caa32283d66cc4d455c9e9d115aafc"}, - {file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:29de916ba1099ba2aab76aca101580006adfac5646de9b7c010a0f13867cba45"}, - {file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e057e74e53db78122a3979f908973e171909a58ac20df05c33998d52e6d35757"}, - {file = "coverage-7.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:411d4ff9d041be08fdfc02adf62e89c735b9468f6d8f6427f8a14b6bb0a85095"}, - {file = "coverage-7.0.5-cp311-cp311-win32.whl", hash = "sha256:52ab14b9e09ce052237dfe12d6892dd39b0401690856bcfe75d5baba4bfe2831"}, - {file = "coverage-7.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:1f66862d3a41674ebd8d1a7b6f5387fe5ce353f8719040a986551a545d7d83ea"}, - {file = "coverage-7.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b69522b168a6b64edf0c33ba53eac491c0a8f5cc94fa4337f9c6f4c8f2f5296c"}, - {file = "coverage-7.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436e103950d05b7d7f55e39beeb4d5be298ca3e119e0589c0227e6d0b01ee8c7"}, - {file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c56bec53d6e3154eaff6ea941226e7bd7cc0d99f9b3756c2520fc7a94e6d96"}, - {file = "coverage-7.0.5-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a38362528a9115a4e276e65eeabf67dcfaf57698e17ae388599568a78dcb029"}, - {file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f67472c09a0c7486e27f3275f617c964d25e35727af952869dd496b9b5b7f6a3"}, - {file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:220e3fa77d14c8a507b2d951e463b57a1f7810a6443a26f9b7591ef39047b1b2"}, - {file = "coverage-7.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ecb0f73954892f98611e183f50acdc9e21a4653f294dfbe079da73c6378a6f47"}, - {file = "coverage-7.0.5-cp37-cp37m-win32.whl", hash = "sha256:d8f3e2e0a1d6777e58e834fd5a04657f66affa615dae61dd67c35d1568c38882"}, - {file = "coverage-7.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9e662e6fc4f513b79da5d10a23edd2b87685815b337b1a30cd11307a6679148d"}, - {file = "coverage-7.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:790e4433962c9f454e213b21b0fd4b42310ade9c077e8edcb5113db0818450cb"}, - {file = "coverage-7.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49640bda9bda35b057b0e65b7c43ba706fa2335c9a9896652aebe0fa399e80e6"}, - {file = "coverage-7.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d66187792bfe56f8c18ba986a0e4ae44856b1c645336bd2c776e3386da91e1dd"}, - {file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:276f4cd0001cd83b00817c8db76730938b1ee40f4993b6a905f40a7278103b3a"}, - {file = "coverage-7.0.5-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95304068686545aa368b35dfda1cdfbbdbe2f6fe43de4a2e9baa8ebd71be46e2"}, - {file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:17e01dd8666c445025c29684d4aabf5a90dc6ef1ab25328aa52bedaa95b65ad7"}, - {file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ea76dbcad0b7b0deb265d8c36e0801abcddf6cc1395940a24e3595288b405ca0"}, - {file = "coverage-7.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:50a6adc2be8edd7ee67d1abc3cd20678987c7b9d79cd265de55941e3d0d56499"}, - {file = "coverage-7.0.5-cp38-cp38-win32.whl", hash = "sha256:e4ce984133b888cc3a46867c8b4372c7dee9cee300335e2925e197bcd45b9e16"}, - {file = "coverage-7.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4a950f83fd3f9bca23b77442f3a2b2ea4ac900944d8af9993743774c4fdc57af"}, - {file = "coverage-7.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c2155943896ac78b9b0fd910fb381186d0c345911f5333ee46ac44c8f0e43ab"}, - {file = "coverage-7.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54f7e9705e14b2c9f6abdeb127c390f679f6dbe64ba732788d3015f7f76ef637"}, - {file = "coverage-7.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ee30375b409d9a7ea0f30c50645d436b6f5dfee254edffd27e45a980ad2c7f4"}, - {file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b78729038abea6a5df0d2708dce21e82073463b2d79d10884d7d591e0f385ded"}, - {file = "coverage-7.0.5-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13250b1f0bd023e0c9f11838bdeb60214dd5b6aaf8e8d2f110c7e232a1bff83b"}, - {file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2c407b1950b2d2ffa091f4e225ca19a66a9bd81222f27c56bd12658fc5ca1209"}, - {file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c76a3075e96b9c9ff00df8b5f7f560f5634dffd1658bafb79eb2682867e94f78"}, - {file = "coverage-7.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f26648e1b3b03b6022b48a9b910d0ae209e2d51f50441db5dce5b530fad6d9b1"}, - {file = "coverage-7.0.5-cp39-cp39-win32.whl", hash = "sha256:ba3027deb7abf02859aca49c865ece538aee56dcb4871b4cced23ba4d5088904"}, - {file = "coverage-7.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:949844af60ee96a376aac1ded2a27e134b8c8d35cc006a52903fc06c24a3296f"}, - {file = "coverage-7.0.5-pp37.pp38.pp39-none-any.whl", hash = "sha256:b9727ac4f5cf2cbf87880a63870b5b9730a8ae3a4a360241a0fdaa2f71240ff0"}, - {file = "coverage-7.0.5.tar.gz", hash = "sha256:051afcbd6d2ac39298d62d340f94dbb6a1f31de06dfaf6fcef7b759dd3860c45"}, + {file = "coverage-7.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b946bbcd5a8231383450b195cfb58cb01cbe7f8949f5758566b881df4b33baf"}, + {file = "coverage-7.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ec8e767f13be637d056f7e07e61d089e555f719b387a7070154ad80a0ff31801"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a5a5879a939cb84959d86869132b00176197ca561c664fc21478c1eee60d75"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b643cb30821e7570c0aaf54feaf0bfb630b79059f85741843e9dc23f33aaca2c"}, + {file = "coverage-7.1.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32df215215f3af2c1617a55dbdfb403b772d463d54d219985ac7cd3bf124cada"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:33d1ae9d4079e05ac4cc1ef9e20c648f5afabf1a92adfaf2ccf509c50b85717f"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:29571503c37f2ef2138a306d23e7270687c0efb9cab4bd8038d609b5c2393a3a"}, + {file = "coverage-7.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:63ffd21aa133ff48c4dff7adcc46b7ec8b565491bfc371212122dd999812ea1c"}, + {file = "coverage-7.1.0-cp310-cp310-win32.whl", hash = "sha256:4b14d5e09c656de5038a3f9bfe5228f53439282abcab87317c9f7f1acb280352"}, + {file = "coverage-7.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:8361be1c2c073919500b6601220a6f2f98ea0b6d2fec5014c1d9cfa23dd07038"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:da9b41d4539eefd408c46725fb76ecba3a50a3367cafb7dea5f250d0653c1040"}, + {file = "coverage-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5b15ed7644ae4bee0ecf74fee95808dcc34ba6ace87e8dfbf5cb0dc20eab45a"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d12d076582507ea460ea2a89a8c85cb558f83406c8a41dd641d7be9a32e1274f"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2617759031dae1bf183c16cef8fcfb3de7617f394c813fa5e8e46e9b82d4222"}, + {file = "coverage-7.1.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4e4881fa9e9667afcc742f0c244d9364d197490fbc91d12ac3b5de0bf2df146"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9d58885215094ab4a86a6aef044e42994a2bd76a446dc59b352622655ba6621b"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ffeeb38ee4a80a30a6877c5c4c359e5498eec095878f1581453202bfacc8fbc2"}, + {file = "coverage-7.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3baf5f126f30781b5e93dbefcc8271cb2491647f8283f20ac54d12161dff080e"}, + {file = "coverage-7.1.0-cp311-cp311-win32.whl", hash = "sha256:ded59300d6330be27bc6cf0b74b89ada58069ced87c48eaf9344e5e84b0072f7"}, + {file = "coverage-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a43c7823cd7427b4ed763aa7fb63901ca8288591323b58c9cd6ec31ad910f3c"}, + {file = "coverage-7.1.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a726d742816cb3a8973c8c9a97539c734b3a309345236cd533c4883dda05b8d"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc7c85a150501286f8b56bd8ed3aa4093f4b88fb68c0843d21ff9656f0009d6a"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b4198d85a3755d27e64c52f8c95d6333119e49fd001ae5798dac872c95e0f8"}, + {file = "coverage-7.1.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb726cb861c3117a553f940372a495fe1078249ff5f8a5478c0576c7be12050"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:51b236e764840a6df0661b67e50697aaa0e7d4124ca95e5058fa3d7cbc240b7c"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:7ee5c9bb51695f80878faaa5598040dd6c9e172ddcf490382e8aedb8ec3fec8d"}, + {file = "coverage-7.1.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c31b75ae466c053a98bf26843563b3b3517b8f37da4d47b1c582fdc703112bc3"}, + {file = "coverage-7.1.0-cp37-cp37m-win32.whl", hash = "sha256:3b155caf3760408d1cb903b21e6a97ad4e2bdad43cbc265e3ce0afb8e0057e73"}, + {file = "coverage-7.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2a60d6513781e87047c3e630b33b4d1e89f39836dac6e069ffee28c4786715f5"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f2cba5c6db29ce991029b5e4ac51eb36774458f0a3b8d3137241b32d1bb91f06"}, + {file = "coverage-7.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:beeb129cacea34490ffd4d6153af70509aa3cda20fdda2ea1a2be870dfec8d52"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c45948f613d5d18c9ec5eaa203ce06a653334cf1bd47c783a12d0dd4fd9c851"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef382417db92ba23dfb5864a3fc9be27ea4894e86620d342a116b243ade5d35d"}, + {file = "coverage-7.1.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c7c0d0827e853315c9bbd43c1162c006dd808dbbe297db7ae66cd17b07830f0"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e5cdbb5cafcedea04924568d990e20ce7f1945a1dd54b560f879ee2d57226912"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9817733f0d3ea91bea80de0f79ef971ae94f81ca52f9b66500c6a2fea8e4b4f8"}, + {file = "coverage-7.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:218fe982371ac7387304153ecd51205f14e9d731b34fb0568181abaf7b443ba0"}, + {file = "coverage-7.1.0-cp38-cp38-win32.whl", hash = "sha256:04481245ef966fbd24ae9b9e537ce899ae584d521dfbe78f89cad003c38ca2ab"}, + {file = "coverage-7.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8ae125d1134bf236acba8b83e74c603d1b30e207266121e76484562bc816344c"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2bf1d5f2084c3932b56b962a683074a3692bce7cabd3aa023c987a2a8e7612f6"}, + {file = "coverage-7.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:98b85dd86514d889a2e3dd22ab3c18c9d0019e696478391d86708b805f4ea0fa"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38da2db80cc505a611938d8624801158e409928b136c8916cd2e203970dde4dc"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3164d31078fa9efe406e198aecd2a02d32a62fecbdef74f76dad6a46c7e48311"}, + {file = "coverage-7.1.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db61a79c07331e88b9a9974815c075fbd812bc9dbc4dc44b366b5368a2936063"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ccb092c9ede70b2517a57382a601619d20981f56f440eae7e4d7eaafd1d1d09"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:33ff26d0f6cc3ca8de13d14fde1ff8efe1456b53e3f0273e63cc8b3c84a063d8"}, + {file = "coverage-7.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d47dd659a4ee952e90dc56c97d78132573dc5c7b09d61b416a9deef4ebe01a0c"}, + {file = "coverage-7.1.0-cp39-cp39-win32.whl", hash = "sha256:d248cd4a92065a4d4543b8331660121b31c4148dd00a691bfb7a5cdc7483cfa4"}, + {file = "coverage-7.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:7ed681b0f8e8bcbbffa58ba26fcf5dbc8f79e7997595bf071ed5430d8c08d6f3"}, + {file = "coverage-7.1.0-pp37.pp38.pp39-none-any.whl", hash = "sha256:755e89e32376c850f826c425ece2c35a4fc266c081490eb0a841e7c1cb0d3bda"}, + {file = "coverage-7.1.0.tar.gz", hash = "sha256:10188fe543560ec4874f974b5305cd1a8bdcfa885ee00ea3a03733464c4ca265"}, ] [package.dependencies] @@ -451,6 +447,24 @@ sdist = ["setuptools-rust (>=0.11.4)"] ssh = ["bcrypt (>=3.1.5)"] test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +[[package]] +name = "deprecated" +version = "1.2.13" +description = "Python @deprecated decorator to deprecate old python classes, functions or methods." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "Deprecated-1.2.13-py2.py3-none-any.whl", hash = "sha256:64756e3e14c8c5eea9795d93c524551432a0be75629f8f29e67ab8caf076c76d"}, + {file = "Deprecated-1.2.13.tar.gz", hash = "sha256:43ac5335da90c31c24ba028af536a91d41d53f9e6901ddb021bcc572ce44e38d"}, +] + +[package.dependencies] +wrapt = ">=1.10,<2" + +[package.extras] +dev = ["PyTest", "PyTest (<5)", "PyTest-Cov", "PyTest-Cov (<2.6)", "bump2version (<1)", "configparser (<5)", "importlib-metadata (<3)", "importlib-resources (<4)", "sphinx (<2)", "sphinxcontrib-websupport (<2)", "tox", "zipp (<2)"] + [[package]] name = "distlib" version = "0.3.6" @@ -516,7 +530,6 @@ files = [ [package.dependencies] python-dateutil = ">=2.4" -typing-extensions = {version = ">=3.10.0.1", markers = "python_version < \"3.8\""} [[package]] name = "filelock" @@ -547,7 +560,6 @@ files = [ ] [package.dependencies] -importlib-metadata = {version = ">=1.1.0,<4.3", markers = "python_version < \"3.8\""} mccabe = ">=0.7.0,<0.8.0" pycodestyle = ">=2.9.0,<2.10.0" pyflakes = ">=2.5.0,<2.6.0" @@ -596,7 +608,6 @@ files = [ [package.dependencies] gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} [[package]] name = "h11" @@ -610,9 +621,6 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - [[package]] name = "httpcore" version = "0.16.3" @@ -661,14 +669,14 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "identify" -version = "2.5.13" +version = "2.5.16" description = "File identification library for Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "identify-2.5.13-py2.py3-none-any.whl", hash = "sha256:8aa48ce56e38c28b6faa9f261075dea0a942dfbb42b341b4e711896cbb40f3f7"}, - {file = "identify-2.5.13.tar.gz", hash = "sha256:abb546bca6f470228785338a01b539de8a85bbf46491250ae03363956d8ebb10"}, + {file = "identify-2.5.16-py2.py3-none-any.whl", hash = "sha256:832832a58ecc1b8f33d5e8cb4f7d3db2f5c7fbe922dfee5f958b48fed691501a"}, + {file = "identify-2.5.16.tar.gz", hash = "sha256:c47acedfe6495b1c603ed7e93469b26e839cab38db4155113f36f718f8b3dc47"}, ] [package.extras] @@ -688,23 +696,23 @@ files = [ [[package]] name = "importlib-metadata" -version = "4.2.0" +version = "6.0.0" description = "Read metadata from Python packages" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "importlib_metadata-4.2.0-py3-none-any.whl", hash = "sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b"}, - {file = "importlib_metadata-4.2.0.tar.gz", hash = "sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31"}, + {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, + {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, ] [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["jaraco.packaging (>=8.2)", "rst.linker (>=1.9)", "sphinx"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pep517", "pyfakefs", "pytest (>=4.6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-flake8", "pytest-mypy"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -732,19 +740,19 @@ files = [ [[package]] name = "isort" -version = "5.11.4" +version = "5.12.0" description = "A Python utility / library to sort Python imports." category = "dev" optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.8.0" files = [ - {file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"}, - {file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"}, + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, ] [package.extras] -colors = ["colorama (>=0.4.3,<0.5.0)"] -pipfile-deprecated-finder = ["pipreqs", "requirementslib"] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] @@ -785,25 +793,26 @@ trio = ["async_generator", "trio"] [[package]] name = "keyring" -version = "23.9.3" +version = "23.13.1" description = "Store and access your passwords safely." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "keyring-23.9.3-py3-none-any.whl", hash = "sha256:69732a15cb1433bdfbc3b980a8a36a04878a6cfd7cb99f497b573f31618001c0"}, - {file = "keyring-23.9.3.tar.gz", hash = "sha256:69b01dd83c42f590250fe7a1f503fc229b14de83857314b1933a3ddbf595c4a5"}, + {file = "keyring-23.13.1-py3-none-any.whl", hash = "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd"}, + {file = "keyring-23.13.1.tar.gz", hash = "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678"}, ] [package.dependencies] -importlib-metadata = {version = ">=3.6", markers = "python_version < \"3.10\""} +importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} "jaraco.classes" = "*" jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} -pywin32-ctypes = {version = "<0.1.0 || >0.1.0,<0.1.1 || >0.1.1", markers = "sys_platform == \"win32\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} [package.extras] -docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] +completion = ["shtab"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] testing = ["flake8 (<5)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [[package]] @@ -844,22 +853,22 @@ files = [ [[package]] name = "networkx" -version = "2.6.3" +version = "3.0" description = "Python package for creating and manipulating graphs and networks" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "networkx-2.6.3-py3-none-any.whl", hash = "sha256:80b6b89c77d1dfb64a4c7854981b60aeea6360ac02c6d4e4913319e0a313abef"}, - {file = "networkx-2.6.3.tar.gz", hash = "sha256:c0946ed31d71f1b732b5aaa6da5a0388a345019af232ce2f49c766e2d6795c51"}, + {file = "networkx-3.0-py3-none-any.whl", hash = "sha256:58058d66b1818043527244fab9d41a51fcd7dcc271748015f3c181b8a90c8e2e"}, + {file = "networkx-3.0.tar.gz", hash = "sha256:9a9992345353618ae98339c2b63d8201c381c2944f38a2ab49cb45a4c667e412"}, ] [package.extras] -default = ["matplotlib (>=3.3)", "numpy (>=1.19)", "pandas (>=1.1)", "scipy (>=1.5,!=1.6.1)"] -developer = ["black (==21.5b1)", "pre-commit (>=2.12)"] -doc = ["nb2plots (>=0.6)", "numpydoc (>=1.1)", "pillow (>=8.2)", "pydata-sphinx-theme (>=0.6,<1.0)", "sphinx (>=4.0,<5.0)", "sphinx-gallery (>=0.9,<1.0)", "texext (>=0.6.6)"] -extra = ["lxml (>=4.5)", "pydot (>=1.4.1)", "pygraphviz (>=1.7)"] -test = ["codecov (>=2.1)", "pytest (>=6.2)", "pytest-cov (>=2.12)"] +default = ["matplotlib (>=3.4)", "numpy (>=1.20)", "pandas (>=1.3)", "scipy (>=1.8)"] +developer = ["mypy (>=0.991)", "pre-commit (>=2.20)"] +doc = ["nb2plots (>=0.6)", "numpydoc (>=1.5)", "pillow (>=9.2)", "pydata-sphinx-theme (>=0.11)", "sphinx (==5.2.3)", "sphinx-gallery (>=0.11)", "texext (>=0.6.7)"] +extra = ["lxml (>=4.6)", "pydot (>=1.4.2)", "pygraphviz (>=1.10)", "sympy (>=1.10)"] +test = ["codecov (>=2.1)", "pytest (>=7.2)", "pytest-cov (>=4.0)"] [[package]] name = "nodeenv" @@ -890,14 +899,14 @@ files = [ [[package]] name = "pathspec" -version = "0.10.3" +version = "0.11.0" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.10.3-py3-none-any.whl", hash = "sha256:3c95343af8b756205e2aba76e843ba9520a24dd84f68c22b9f93251507509dd6"}, - {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, + {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, + {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, ] [[package]] @@ -927,9 +936,6 @@ files = [ {file = "platformdirs-2.6.2.tar.gz", hash = "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.4", markers = "python_version < \"3.8\""} - [package.extras] docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-autodoc-typehints (>=1.19.5)"] test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] @@ -946,9 +952,6 @@ files = [ {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] @@ -968,7 +971,6 @@ files = [ [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" @@ -1062,6 +1064,27 @@ files = [ {file = "pyflakes-2.5.0.tar.gz", hash = "sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3"}, ] +[[package]] +name = "pygithub" +version = "1.57" +description = "Use the full Github API v3" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyGithub-1.57-py3-none-any.whl", hash = "sha256:5822febeac2391f1306c55a99af2bc8f86c8bf82ded000030cd02c18f31b731f"}, + {file = "PyGithub-1.57.tar.gz", hash = "sha256:c273f252b278fb81f1769505cc6921bdb6791e1cebd6ac850cc97dad13c31ff3"}, +] + +[package.dependencies] +deprecated = "*" +pyjwt = ">=2.4.0" +pynacl = ">=1.4.0" +requests = ">=2.14.0" + +[package.extras] +integrations = ["cryptography"] + [[package]] name = "pygments" version = "2.14.0" @@ -1077,6 +1100,51 @@ files = [ [package.extras] plugins = ["importlib-metadata"] +[[package]] +name = "pyjwt" +version = "2.6.0" +description = "JSON Web Token implementation in Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"}, + {file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pynacl" +version = "1.5.0" +description = "Python binding to the Networking and Cryptography (NaCl) library" +category = "dev" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, + {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, + {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, + {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, + {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, +] + +[package.dependencies] +cffi = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] + [[package]] name = "pytest" version = "7.2.1" @@ -1093,7 +1161,6 @@ files = [ attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -1116,7 +1183,6 @@ files = [ [package.dependencies] pytest = ">=6.1.0" -typing-extensions = {version = ">=3.7.2", markers = "python_version < \"3.8\""} [package.extras] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] @@ -1512,40 +1578,6 @@ rfc3986 = ">=1.4.0" tqdm = ">=4.14" urllib3 = ">=1.26.0" -[[package]] -name = "typed-ast" -version = "1.5.4" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = ">=3.6" -files = [ - {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, - {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, - {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, - {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, - {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, - {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, - {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, - {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, - {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, - {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, - {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, - {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, - {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, - {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, - {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, - {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, - {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, -] - [[package]] name = "typer" version = "0.4.2" @@ -1627,25 +1659,24 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.16.2" +version = "20.17.1" description = "Virtual Python Environment builder" category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "virtualenv-20.16.2-py2.py3-none-any.whl", hash = "sha256:635b272a8e2f77cb051946f46c60a54ace3cb5e25568228bd6b57fc70eca9ff3"}, - {file = "virtualenv-20.16.2.tar.gz", hash = "sha256:0ef5be6d07181946891f5abc8047fda8bc2f0b4b9bf222c64e6e8963baee76db"}, + {file = "virtualenv-20.17.1-py3-none-any.whl", hash = "sha256:ce3b1684d6e1a20a3e5ed36795a97dfc6af29bc3970ca8dab93e11ac6094b3c4"}, + {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, ] [package.dependencies] -distlib = ">=0.3.1,<1" -filelock = ">=3.2,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -platformdirs = ">=2,<3" +distlib = ">=0.3.6,<1" +filelock = ">=3.4.1,<4" +platformdirs = ">=2.4,<3" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=21.3)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "packaging (>=20.0)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)"] +docs = ["proselint (>=0.13)", "sphinx (>=5.3)", "sphinx-argparse (>=0.3.2)", "sphinx-rtd-theme (>=1)", "towncrier (>=22.8)"] +testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=21.3)", "pytest (>=7.0.1)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.6.1)", "pytest-randomly (>=3.10.3)", "pytest-timeout (>=2.1)"] [[package]] name = "webencodings" @@ -1674,23 +1705,97 @@ files = [ [package.extras] test = ["pytest (>=3.0.0)"] +[[package]] +name = "wrapt" +version = "1.14.1" +description = "Module for decorators, wrappers and monkey patching." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +files = [ + {file = "wrapt-1.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59"}, + {file = "wrapt-1.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462"}, + {file = "wrapt-1.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320"}, + {file = "wrapt-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069"}, + {file = "wrapt-1.14.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656"}, + {file = "wrapt-1.14.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c"}, + {file = "wrapt-1.14.1-cp310-cp310-win32.whl", hash = "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8"}, + {file = "wrapt-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3"}, + {file = "wrapt-1.14.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d"}, + {file = "wrapt-1.14.1-cp35-cp35m-win32.whl", hash = "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7"}, + {file = "wrapt-1.14.1-cp35-cp35m-win_amd64.whl", hash = "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00"}, + {file = "wrapt-1.14.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1"}, + {file = "wrapt-1.14.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1"}, + {file = "wrapt-1.14.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569"}, + {file = "wrapt-1.14.1-cp36-cp36m-win32.whl", hash = "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed"}, + {file = "wrapt-1.14.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471"}, + {file = "wrapt-1.14.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d"}, + {file = "wrapt-1.14.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015"}, + {file = "wrapt-1.14.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a"}, + {file = "wrapt-1.14.1-cp37-cp37m-win32.whl", hash = "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853"}, + {file = "wrapt-1.14.1-cp37-cp37m-win_amd64.whl", hash = "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456"}, + {file = "wrapt-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1"}, + {file = "wrapt-1.14.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0"}, + {file = "wrapt-1.14.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57"}, + {file = "wrapt-1.14.1-cp38-cp38-win32.whl", hash = "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5"}, + {file = "wrapt-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383"}, + {file = "wrapt-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735"}, + {file = "wrapt-1.14.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3"}, + {file = "wrapt-1.14.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe"}, + {file = "wrapt-1.14.1-cp39-cp39-win32.whl", hash = "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5"}, + {file = "wrapt-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb"}, + {file = "wrapt-1.14.1.tar.gz", hash = "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d"}, +] + [[package]] name = "zipp" -version = "3.11.0" +version = "3.12.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, - {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, + {file = "zipp-3.12.0-py3-none-any.whl", hash = "sha256:9eb0a4c5feab9b08871db0d672745b53450d7f26992fd1e4653aa43345e97b86"}, + {file = "zipp-3.12.0.tar.gz", hash = "sha256:73efd63936398aac78fd92b6f4865190119d6c91b531532e798977ea8dd402eb"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "2.0" -python-versions = "^3.7" -content-hash = "dba4ef67f90bf368247bedb590d70a5b48440c843bc47242797ce6d230ddffd1" +python-versions = "^3.9" +content-hash = "507c422406e20b953aeeac32a73089ece9c706a196374097f833bec114789514" diff --git a/pyproject.toml b/pyproject.toml index c35382e3..ce451291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,15 +15,15 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" httpx = "^0.23.0" -pydantic = "^1.9.1" +pydantic = "^1.10.0" [tool.poetry.dev-dependencies] pytest = "^7.2.0" flake8 = "^5.0.4" black = "^22.12.0" -isort = "^5.11.4" +isort = "^5.12.0" pre-commit = "^2.21.0" pytest-cov = "^4.0.0" pytest-depends = "^1.0.1" @@ -32,6 +32,9 @@ Faker = "^16.6.1" unasync-cli = "^0.0.9" python-semantic-release = "^7.32.2" +[tool.poetry.group.dev.dependencies] +pygithub = "^1.57" + [tool.semantic_release] version_variable = "gotrue/__init__.py:__version__" version_toml = "pyproject.toml:tool.poetry.version" diff --git a/scripts/gh-download.py b/scripts/gh-download.py new file mode 100644 index 00000000..91b2e567 --- /dev/null +++ b/scripts/gh-download.py @@ -0,0 +1,121 @@ +# This code was copied from +# https://gist.github.com/pdashford/2e4bcd4fc2343e2fd03efe4da17f577d +# and modified to work with Python 3, type hints, correct format and +# simplified the code to our needs. + +""" +Downloads folders from github repo +Requires PyGithub +pip install PyGithub +""" + +import base64 +import getopt +import os +import shutil +import sys +from typing import Optional + +from github import Github, GithubException +from github.ContentFile import ContentFile +from github.Repository import Repository + + +def get_sha_for_tag(repository: Repository, tag: str) -> str: + """ + Returns a commit PyGithub object for the specified repository and tag. + """ + branches = repository.get_branches() + matched_branches = [match for match in branches if match.name == tag] + if matched_branches: + return matched_branches[0].commit.sha + + tags = repository.get_tags() + matched_tags = [match for match in tags if match.name == tag] + if not matched_tags: + raise ValueError("No Tag or Branch exists with that name") + return matched_tags[0].commit.sha + + +def download_directory(repository: Repository, sha: str, server_path: str) -> None: + """ + Download all contents at server_path with commit tag sha in + the repository. + """ + if os.path.exists(server_path): + shutil.rmtree(server_path) + + os.makedirs(server_path) + contents = repository.get_dir_contents(server_path, ref=sha) + + for content in contents: + print(f"Processing {content.path}") + if content.type == "dir": + os.makedirs(content.path) + download_directory(repository, sha, content.path) + else: + try: + path = content.path + file_content = repository.get_contents(path, ref=sha) + if not isinstance(file_content, ContentFile): + raise ValueError("Expected ContentFile") + with open(content.path, "w+") as file_out: + if file_content.content: + file_data = base64.b64decode(file_content.content) + file_out.write(file_data.decode("utf-8")) + except (GithubException, OSError, ValueError) as exc: + print("Error processing %s: %s", content.path, exc) + + +def usage(): + """ + Prints the usage command lines + """ + print("usage: gh-download --repo=repo --branch=branch --folder=folder") + + +def main(argv): + """ + Main function block + """ + try: + opts, _ = getopt.getopt(argv, "r:b:f:", ["repo=", "branch=", "folder="]) + except getopt.GetoptError as err: + print(err) + usage() + sys.exit(2) + repo: Optional[str] = None + branch: Optional[str] = None + folder: Optional[str] = None + for opt, arg in opts: + if opt in ("-r", "--repo"): + repo = arg + elif opt in ("-b", "--branch"): + branch = arg + elif opt in ("-f", "--folder"): + folder = arg + + if not repo: + print("Repo is required") + usage() + sys.exit(2) + if not branch: + print("Branch is required") + usage() + sys.exit(2) + if not folder: + print("Folder is required") + usage() + sys.exit(2) + + github = Github(None) + repository = github.get_repo(repo) + sha = get_sha_for_tag(repository, branch) + download_directory(repository, sha, folder) + + +if __name__ == "__main__": + """ + Entry point + """ + main(sys.argv[1:]) diff --git a/tests/_async/clients.py b/tests/_async/clients.py new file mode 100644 index 00000000..d62a0366 --- /dev/null +++ b/tests/_async/clients.py @@ -0,0 +1,128 @@ +from jwt import encode + +from gotrue import AsyncGoTrueAdminAPI, AsyncGoTrueClient + +SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT = 9999 +SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT = 9998 +SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT = 9997 + +GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF = ( + f"http://localhost:{SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT}" +) +GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON = ( + f"http://localhost:{SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT}" +) +GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF = ( + f"http://localhost:{SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT}" +) + +GOTRUE_JWT_SECRET = "37c304f8-51aa-419a-a1af-06154e63707a" + +AUTH_ADMIN_JWT = encode( + { + "sub": "1234567890", + "role": "supabase_admin", + }, + GOTRUE_JWT_SECRET, +) + + +def auth_client(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def auth_client_with_session(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=False, + ) + + +def auth_subscription_client(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_enabled_client(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_off_signups_enabled_client(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_disabled_client(): + return AsyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF, + auto_refresh_token=False, + persist_session=True, + ) + + +def auth_admin_api_auto_confirm_enabled_client(): + return AsyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + headers={ + "Authorization": f"Bearer {AUTH_ADMIN_JWT}", + }, + ) + + +def auth_admin_api_auto_confirm_disabled_client(): + return AsyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {AUTH_ADMIN_JWT}", + }, + ) + + +SERVICE_ROLE_JWT = encode( + { + "role": "service_role", + }, + GOTRUE_JWT_SECRET, +) + + +def service_role_api_client(): + return AsyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) + + +def service_role_api_client_with_sms(): + return AsyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) + + +def service_role_api_client_no_sms(): + return AsyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) diff --git a/tests/_async/test_api_with_auto_confirm_disabled.py b/tests/_async/test_api_with_auto_confirm_disabled.py deleted file mode 100644 index 3cd458dd..00000000 --- a/tests/_async/test_api_with_auto_confirm_disabled.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import AsyncIterable - -import pytest -from faker import Faker - -from gotrue import AsyncGoTrueAPI -from gotrue.constants import COOKIE_OPTIONS -from gotrue.types import CookieOptions, LinkType, User - -GOTRUE_URL = "http://localhost:9999" -TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InN1cGFiYXNlX2FkbWluIiwiaWF0IjoxNTE2MjM5MDIyfQ.0sOtTSTfPv5oPZxsjvBO249FI4S4p0ymHoIZ6H6z9Y8" # noqa: E501 - - -@pytest.fixture(name="api") -async def create_api() -> AsyncIterable[AsyncGoTrueAPI]: - async with AsyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -fake = Faker() - -email = f"api_ac_disabled_{fake.email().lower()}" -password = fake.password() - - -async def test_sign_up_with_email_and_password(api: AsyncGoTrueAPI): - try: - response = await api.sign_up_with_email( - email=email, - password=password, - redirect_to="http://localhost:9999/welcome", - data={"status": "alpha"}, - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -email2 = f"api_generate_link_signup_{fake.email().lower()}" -password2 = fake.password() - - -async def test_generate_sign_up_link(api: AsyncGoTrueAPI): - try: - response = await api.generate_link( - type=LinkType.signup, - email=email2, - password=password2, - redirect_to="http://localhost:9999/welcome", - data={"status": "alpha"}, - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -email3 = f"api_generate_link_signup_{fake.email().lower()}" - - -async def test_generate_magic_link(api: AsyncGoTrueAPI): - try: - response = await api.generate_link( - type=LinkType.magiclink, - email=email3, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -async def test_generate_invite_link(api: AsyncGoTrueAPI): - try: - response = await api.generate_link( - type=LinkType.invite, - email=email3, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -async def test_generate_recovery_link(api: AsyncGoTrueAPI): - try: - response = await api.generate_link( - type=LinkType.recovery, - email=email, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_api_with_auto_confirm_enabled.py b/tests/_async/test_api_with_auto_confirm_enabled.py deleted file mode 100644 index 13ff0411..00000000 --- a/tests/_async/test_api_with_auto_confirm_enabled.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import AsyncIterable, Optional - -import pytest -from faker import Faker - -from gotrue import AsyncGoTrueAPI -from gotrue.constants import COOKIE_OPTIONS -from gotrue.types import CookieOptions, Session, User - -GOTRUE_URL = "http://localhost:9998" -TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNjQyMjMyNzUwfQ.TUR8Zu05TtNR25L42soA2trZpc4oBR8-9Pv5r5bvls8" # noqa: E501 - - -@pytest.fixture(name="api") -async def create_api() -> AsyncIterable[AsyncGoTrueAPI]: - async with AsyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -fake = Faker() - -email = f"api_ac_enabled_{fake.email().lower()}" -password = fake.password() -valid_session: Optional[Session] = None - - -async def test_sign_up_with_email(api: AsyncGoTrueAPI): - global valid_session - try: - response = await api.sign_up_with_email( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, Session) - valid_session = response - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email.__name__]) -async def test_get_user(api: AsyncGoTrueAPI): - try: - jwt = valid_session.access_token if valid_session else "" - response = await api.get_user(jwt=jwt) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user.__name__]) -async def test_delete_user(api: AsyncGoTrueAPI): - try: - jwt = valid_session.access_token if valid_session else "" - user = await api.get_user(jwt=jwt) - await api.delete_user(uid=str(user.id), jwt=TOKEN) - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_client_with_auto_confirm_disabled.py b/tests/_async/test_client_with_auto_confirm_disabled.py deleted file mode 100644 index c48d597d..00000000 --- a/tests/_async/test_client_with_auto_confirm_disabled.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import AsyncIterable - -import pytest -from faker import Faker - -from gotrue import AsyncGoTrueClient -from gotrue.exceptions import APIError -from gotrue.types import User - -GOTRUE_URL = "http://localhost:9999" -TEST_TWILIO = False - - -@pytest.fixture(name="client") -async def create_client() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = fake.email().lower() -password = fake.password() -phone = fake.phone_number() # set test number here - - -async def test_sign_up_with_email_and_password(client: AsyncGoTrueClient): - try: - response = await client.sign_up( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, User) - assert not response.email_confirmed_at - assert not response.last_sign_in_at - assert response.email == email - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -async def test_sign_up_with_the_same_user_twice_should_throw_an_error( - client: AsyncGoTrueClient, -): - expected_error_message = "For security purposes, you can only request this after" - try: - await client.sign_up( - email=email, - password=password, - ) - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -async def test_sign_in(client: AsyncGoTrueClient): - expected_error_message = "Email not confirmed" - try: - await client.sign_in( - email=email, - password=password, - ) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -async def test_sign_in_with_the_wrong_password(client: AsyncGoTrueClient): - expected_error_message = "Invalid login credentials" - try: - await client.sign_in( - email=email, - password=password + "2", - ) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.skipif(not TEST_TWILIO, reason="Twilio is not available") -async def test_sign_up_with_phone_and_password(client: AsyncGoTrueClient): - try: - response = await client.sign_up( - phone=phone, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, User) - assert not response.phone_confirmed_at - assert not response.email_confirmed_at - assert not response.last_sign_in_at - assert response.phone == phone - except Exception as e: - assert False, str(e) - - -@pytest.mark.skipif(not TEST_TWILIO, reason="Twilio is not available") -@pytest.mark.depends(on=[test_sign_up_with_phone_and_password.__name__]) -async def test_verify_mobile_otp_errors_on_bad_token(client: AsyncGoTrueClient): - expected_error_message = "Otp has expired or is invalid" - try: - await client.verify_otp(phone=phone, token="123456") - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_client_with_auto_confirm_enabled.py b/tests/_async/test_client_with_auto_confirm_enabled.py deleted file mode 100644 index be4eb104..00000000 --- a/tests/_async/test_client_with_auto_confirm_enabled.py +++ /dev/null @@ -1,463 +0,0 @@ -from typing import AsyncIterable, Optional - -import pytest -from faker import Faker - -from gotrue import AsyncGoTrueClient -from gotrue.exceptions import APIError -from gotrue.types import Session, User, UserAttributes - -GOTRUE_URL = "http://localhost:9998" -TEST_TWILIO = False - - -@pytest.fixture(name="client") -async def create_client() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=True, - ) as client: - yield client - - -@pytest.fixture(name="client_with_session") -async def create_client_with_session() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -@pytest.fixture(name="new_client") -async def create_new_client() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = f"client_ac_enabled_{fake.email().lower()}" -set_session_email = f"client_ac_session_{fake.email().lower()}" -refresh_token_email = f"client_refresh_token_signin_{fake.email().lower()}" -password = fake.password() -access_token: Optional[str] = None - - -async def test_sign_up(client: AsyncGoTrueClient): - try: - response = await client.sign_up( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, Session) - global access_token - access_token = response.access_token - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - assert response.user - assert response.user.id - assert response.user.email == email - assert response.user.email_confirmed_at - assert response.user.last_sign_in_at - assert response.user.created_at - assert response.user.updated_at - assert response.user.app_metadata - assert response.user.app_metadata.get("provider") == "email" - assert response.user.user_metadata - assert response.user.user_metadata.get("status") == "alpha" - except Exception as e: - assert False, str(e) - - -async def test_set_session_should_return_no_error( - client_with_session: AsyncGoTrueClient, -): - try: - response = await client_with_session.sign_up( - email=set_session_email, - password=password, - ) - assert isinstance(response, Session) - assert response.refresh_token - await client_with_session.set_session(refresh_token=response.refresh_token) - data = {"hello": "world"} - response = await client_with_session.update( - attributes=UserAttributes(data=data) - ) - assert response.user_metadata == data - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -async def test_sign_up_the_same_user_twice_should_throw_an_error( - client: AsyncGoTrueClient, -): - expected_error_message = "User already registered" - try: - await client.sign_up( - email=email, - password=password, - ) - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -async def test_set_auth_should_set_the_auth_headers_on_a_new_client( - new_client: AsyncGoTrueClient, -): - try: - assert access_token - await new_client.set_auth(access_token=access_token) - assert new_client.current_session - assert new_client.current_session.access_token == access_token - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends( - on=[test_set_auth_should_set_the_auth_headers_on_a_new_client.__name__] -) -async def test_set_auth_should_set_the_auth_headers_on_a_new_client_and_recover( - new_client: AsyncGoTrueClient, -): - try: - assert access_token - await new_client.init_recover() - await new_client.set_auth(access_token=access_token) - assert new_client.current_session - assert new_client.current_session.access_token == access_token - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -async def test_sign_in(client: AsyncGoTrueClient): - try: - response = await client.sign_in(email=email, password=password) - assert isinstance(response, Session) - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - assert response.user - assert response.user.id - assert response.user.email == email - assert response.user.email_confirmed_at - assert response.user.last_sign_in_at - assert response.user.created_at - assert response.user.updated_at - assert response.user.app_metadata - assert response.user.app_metadata.get("provider") == "email" - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_refresh_token(client_with_session: AsyncGoTrueClient): - try: - response = await client_with_session.sign_up( - email=refresh_token_email, - password=password, - ) - assert isinstance(response, Session) - assert response.refresh_token - response2 = await client_with_session.sign_in( - refresh_token=response.refresh_token - ) - assert isinstance(response2, Session) - assert response2.access_token - assert response2.refresh_token - assert response2.expires_in - assert response2.expires_at - assert response2.user - assert response2.user.id - assert response2.user.email == refresh_token_email - assert response2.user.email_confirmed_at - assert response2.user.last_sign_in_at - assert response2.user.created_at - assert response2.user.updated_at - assert response2.user.app_metadata - assert response2.user.app_metadata.get("provider") == "email" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -async def test_get_user(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = client.user() - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.app_metadata - provider = response.app_metadata.get("provider") - assert provider == "email" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -async def test_get_session(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = client.session() - assert isinstance(response, Session) - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -async def test_update_user(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = await client.update( - attributes=UserAttributes(data={"hello": "world"}) - ) - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -async def test_update_user_dict(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = await client.update(attributes={"data": {"hello": "world"}}) - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_update_user.__name__]) -async def test_get_user_after_update(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = client.user() - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user_after_update.__name__]) -async def test_sign_out(client: AsyncGoTrueClient): - try: - await client.init_recover() - await client.sign_out() - response = client.session() - assert response is None - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_out.__name__]) -async def test_get_user_after_sign_out(client: AsyncGoTrueClient): - try: - await client.init_recover() - response = client.user() - assert not response - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_out.__name__]) -async def test_get_update_user_after_sign_out(client: AsyncGoTrueClient): - expected_error_message = "Not logged in." - try: - await client.init_recover() - await client.update(attributes=UserAttributes(data={"hello": "world"})) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user_after_sign_out.__name__]) -async def test_sign_in_with_the_wrong_password(client: AsyncGoTrueClient): - try: - await client.sign_in(email=email, password=f"{password}2") - assert False - except APIError: - pass - except Exception as e: - assert False, str(e) - - -async def test_sign_up_with_password_none(client: AsyncGoTrueClient): - expected_error_message = "Password must be defined, can't be None." - try: - await client.sign_up(email=email) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -async def test_sign_up_with_email_and_phone_none(client: AsyncGoTrueClient): - expected_error_message = "Email or phone must be defined, both can't be None." - try: - await client.sign_up(password=password) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_all_nones(client: AsyncGoTrueClient): - expected_error_message = ( - "Email, phone, refresh_token, or provider must be defined, " - "all can't be None." - ) - try: - await client.sign_in() - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_magic_link(client: AsyncGoTrueClient): - try: - response = await client.sign_in(email=email) - assert response is None - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -async def test_get_session_from_url(client: AsyncGoTrueClient): - try: - assert access_token - dummy_url = ( - "https://localhost" - f"?access_token={access_token}" - "&refresh_token=refresh_token" - "&token_type=bearer" - "&expires_in=3600" - "&type=recovery" - ) - response = await client.get_session_from_url(url=dummy_url, store_session=True) - assert isinstance(response, Session) - except Exception as e: - assert False, str(e) - - -async def test_get_session_from_url_errors(client: AsyncGoTrueClient): - try: - dummy_url = "https://localhost" - error_description = fake.email() - try: - await client.get_session_from_url( - url=f"{dummy_url}?error_description={error_description}" - ) - - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == error_description - try: - await client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No access_token detected." - dummy_url += "?access_token=access_token" - try: - await client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No refresh_token detected." - dummy_url += "&refresh_token=refresh_token" - try: - await client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No token_type detected." - dummy_url += "&token_type=bearer" - try: - await client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No expires_in detected." - dummy_url += "&expires_in=str" - try: - await client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "Invalid expires_in." - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_update_user_after_sign_out.__name__]) -async def test_refresh_session(client: AsyncGoTrueClient): - try: - response = await client.sign_in(email=email, password=password) - assert isinstance(response, Session) - assert response.refresh_token - response = await client.set_session(refresh_token=response.refresh_token) - assert isinstance(response, Session) - response = await client.refresh_session() - assert isinstance(response, Session) - await client.sign_out() - try: - await client.refresh_session() - assert False - except ValueError as e: - assert str(e) == "Not logged in." - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_client_with_sign_ups_disabled.py b/tests/_async/test_client_with_sign_ups_disabled.py deleted file mode 100644 index cae4bbd8..00000000 --- a/tests/_async/test_client_with_sign_ups_disabled.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import AsyncIterable - -import pytest -from faker import Faker - -from gotrue import AsyncGoTrueAPI, AsyncGoTrueClient -from gotrue.constants import COOKIE_OPTIONS, DEFAULT_HEADERS -from gotrue.exceptions import APIError -from gotrue.types import CookieOptions, LinkType, User, UserAttributes - -GOTRUE_URL = "http://localhost:9997" -AUTH_ADMIN_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InN1cGFiYXNlX2FkbWluIiwiaWF0IjoxNTE2MjM5MDIyfQ.0sOtTSTfPv5oPZxsjvBO249FI4S4p0ymHoIZ6H6z9Y8" # noqa: E501 - - -@pytest.fixture(name="auth_admin") -async def create_auth_admin() -> AsyncIterable[AsyncGoTrueAPI]: - async with AsyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {AUTH_ADMIN_TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -@pytest.fixture(name="client") -async def create_client() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = fake.email().lower() -password = fake.password() - - -async def test_sign_up(client: AsyncGoTrueClient): - expected_error_message = "Signups not allowed for this instance" - try: - await client.sign_up(email=email, password=password) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -invited_user = fake.email().lower() - - -async def test_generate_link_should_be_able_to_generate_multiple_links( - auth_admin: AsyncGoTrueAPI, -): - try: - response = await auth_admin.generate_link( - type=LinkType.invite, - email=invited_user, - redirect_to="http://localhost:9997", - ) - assert isinstance(response, User) - assert response.email == invited_user - assert response.action_link - assert "http://localhost:9997/?token=" in response.action_link - assert response.app_metadata - assert response.app_metadata.get("provider") == "email" - providers = response.app_metadata.get("providers") - assert providers - assert isinstance(providers, list) - assert len(providers) == 1 - assert providers[0] == "email" - assert response.role == "" - assert response.user_metadata == {} - assert response.identities == [] - user = response - response = await auth_admin.generate_link( - type=LinkType.invite, - email=invited_user, - ) - assert isinstance(response, User) - assert response.email == invited_user - assert response.action_link - assert "http://localhost:9997/?token=" in response.action_link - assert response.app_metadata - assert response.app_metadata.get("provider") == "email" - providers = response.app_metadata.get("providers") - assert providers - assert isinstance(providers, list) - assert len(providers) == 1 - assert providers[0] == "email" - assert response.role == "" - assert response.user_metadata == {} - assert response.identities == [] - user_again = response - assert user.id == user_again.id - except Exception as e: - assert False, str(e) - - -email2 = fake.email().lower() - - -async def test_create_user(auth_admin: AsyncGoTrueAPI): - try: - attributes = UserAttributes(email=email2) - response = await auth_admin.create_user(attributes=attributes) - assert isinstance(response, User) - assert response.email == email2 - response = await auth_admin.list_users() - user = next((u for u in response if u.email == email2), None) - assert user - assert user.email == email2 - except Exception as e: - assert False, str(e) - - -def test_default_headers(client: AsyncGoTrueClient): - """Test client for existing default headers""" - default_key = "X-Client-Info" - assert default_key in DEFAULT_HEADERS - assert default_key in client.api.headers - assert client.api.headers[default_key] == DEFAULT_HEADERS[default_key] diff --git a/tests/_async/test_gotrue_admin_api.py b/tests/_async/test_gotrue_admin_api.py new file mode 100644 index 00000000..a70380cf --- /dev/null +++ b/tests/_async/test_gotrue_admin_api.py @@ -0,0 +1,273 @@ +from gotrue.errors import AuthError + +from .clients import ( + auth_client_with_session, + client_api_auto_confirm_disabled_client, + client_api_auto_confirm_off_signups_enabled_client, + service_role_api_client, +) +from .utils import ( + create_new_user_with_email, + mock_app_metadata, + mock_user_credentials, + mock_user_metadata, + mock_verification_otp, +) + + +async def test_create_user_should_create_a_new_user(): + credentials = mock_user_credentials() + response = await create_new_user_with_email(email=credentials.get("email")) + assert response.email == credentials.get("email") + + +async def test_create_user_with_user_metadata(): + user_metadata = mock_user_metadata() + credentials = mock_user_credentials() + response = await service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "user_metadata": user_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert response.user.user_metadata == user_metadata + assert "profile_image" in response.user.user_metadata + + +async def test_create_user_with_app_metadata(): + app_metadata = mock_app_metadata() + credentials = mock_user_credentials() + response = await service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "app_metadata": app_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert "provider" in response.user.app_metadata + assert "providers" in response.user.app_metadata + + +async def test_create_user_with_user_and_app_metadata(): + user_metadata = mock_user_metadata() + app_metadata = mock_app_metadata() + credentials = mock_user_credentials() + response = await service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "user_metadata": user_metadata, + "app_metadata": app_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert "profile_image" in response.user.user_metadata + assert "provider" in response.user.app_metadata + assert "providers" in response.user.app_metadata + + +async def test_list_users_should_return_registered_users(): + credentials = mock_user_credentials() + await create_new_user_with_email(email=credentials.get("email")) + users = await service_role_api_client().list_users() + assert users + emails = [user.email for user in users] + assert emails + assert credentials.get("email") in emails + + +async def test_get_user_fetches_a_user_by_their_access_token(): + credentials = mock_user_credentials() + auth_client_with_session_current_user = auth_client_with_session() + response = await auth_client_with_session_current_user.sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + } + ) + assert response.session + response = await auth_client_with_session_current_user.get_user() + assert response.user.email == credentials.get("email") + + +async def test_get_user_by_id_should_a_registered_user_given_its_user_identifier(): + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + assert user.id + response = await service_role_api_client().get_user_by_id(user.id) + assert response.user.email == credentials.get("email") + + +async def test_modify_email_using_update_user_by_id(): + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + response = await service_role_api_client().update_user_by_id( + user.id, + { + "email": f"new_{user.email}", + }, + ) + assert response.user.email == f"new_{user.email}" + + +async def test_modify_user_metadata_using_update_user_by_id(): + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + user_metadata = {"favorite_color": "yellow"} + response = await service_role_api_client().update_user_by_id( + user.id, + { + "user_metadata": user_metadata, + }, + ) + assert response.user.email == user.email + assert response.user.user_metadata == user_metadata + + +async def test_modify_app_metadata_using_update_user_by_id(): + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + app_metadata = {"roles": ["admin", "publisher"]} + response = await service_role_api_client().update_user_by_id( + user.id, + { + "app_metadata": app_metadata, + }, + ) + assert response.user.email == user.email + assert "roles" in response.user.app_metadata + + +async def test_modify_confirm_email_using_update_user_by_id(): + credentials = mock_user_credentials() + response = await client_api_auto_confirm_off_signups_enabled_client().sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + } + ) + assert response.user + assert not response.user.email_confirmed_at + response = await service_role_api_client().update_user_by_id( + response.user.id, + { + "email_confirm": True, + }, + ) + assert response.user.email_confirmed_at + + +async def test_delete_user_should_be_able_delete_an_existing_user(): + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + await service_role_api_client().delete_user(user.id) + users = await service_role_api_client().list_users() + emails = [user.email for user in users] + assert credentials.get("email") not in emails + + +async def test_generate_link_supports_sign_up_with_generate_confirmation_signup_link(): + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + user_metadata = {"status": "alpha"} + response = await service_role_api_client().generate_link( + { + "type": "signup", + "email": credentials.get("email"), + "password": credentials.get("password"), + "options": { + "data": user_metadata, + "redirect_to": redirect_to, + }, + }, + ) + assert response.user.user_metadata == user_metadata + + +async def test_generate_link_supports_updating_emails_with_generate_email_change_links(): # noqa: E501 + credentials = mock_user_credentials() + user = await create_new_user_with_email(email=credentials.get("email")) + assert user.email + assert user.email == credentials.get("email") + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + response = await service_role_api_client().generate_link( + { + "type": "email_change_current", + "email": user.email, + "new_email": credentials.get("email"), + "options": { + "redirect_to": redirect_to, + }, + }, + ) + assert response.user.new_email == credentials.get("email") + + +async def test_invite_user_by_email_creates_a_new_user_with_an_invited_at_timestamp(): + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + user_metadata = {"status": "alpha"} + response = await service_role_api_client().invite_user_by_email( + credentials.get("email"), + { + "data": user_metadata, + "redirect_to": redirect_to, + }, + ) + assert response.user.invited_at + + +async def test_sign_out_with_an_valid_access_token(): + credentials = mock_user_credentials() + response = await auth_client_with_session().sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + }, + ) + assert response.session + response = await service_role_api_client().sign_out(response.session.access_token) + + +async def test_sign_out_with_an_invalid_access_token(): + try: + await service_role_api_client().sign_out("this-is-a-bad-token") + assert False + except AuthError: + pass + + +async def test_verify_otp_with_non_existent_phone_number(): + credentials = mock_user_credentials() + otp = mock_verification_otp() + try: + await client_api_auto_confirm_disabled_client().verify_otp( + { + "phone": credentials.get("phone"), + "token": otp, + "type": "sms", + }, + ) + assert False + except AuthError as e: + assert e.message == "User not found" + + +async def test_verify_otp_with_invalid_phone_number(): + credentials = mock_user_credentials() + otp = mock_verification_otp() + try: + await client_api_auto_confirm_disabled_client().verify_otp( + { + "phone": f"{credentials.get('phone')}-invalid", + "token": otp, + "type": "sms", + }, + ) + assert False + except AuthError as e: + assert e.message == "Invalid phone number format" diff --git a/tests/_async/test_provider.py b/tests/_async/test_provider.py deleted file mode 100644 index 9f0df26f..00000000 --- a/tests/_async/test_provider.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import AsyncIterable - -import pytest - -from gotrue import AsyncGoTrueClient -from gotrue.types import Provider - -GOTRUE_URL = "http://localhost:9999" - - -@pytest.fixture(name="client") -async def create_client() -> AsyncIterable[AsyncGoTrueClient]: - async with AsyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -async def test_sign_in_with_provider(client: AsyncGoTrueClient): - try: - response = await client.sign_in(provider=Provider.google) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_provider_can_append_a_redirect_url( - client: AsyncGoTrueClient, -): - try: - response = await client.sign_in( - provider=Provider.google, - redirect_to="https://localhost:9000/welcome", - ) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_provider_can_append_scopes(client: AsyncGoTrueClient): - try: - response = await client.sign_in(provider=Provider.google, scopes="repo") - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -async def test_sign_in_with_provider_can_append_multiple_options( - client: AsyncGoTrueClient, -): - try: - response = await client.sign_in( - provider=Provider.google, - redirect_to="https://localhost:9000/welcome", - scopes="repo", - ) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_subscriptions.py b/tests/_async/test_subscriptions.py deleted file mode 100644 index 33b9533e..00000000 --- a/tests/_async/test_subscriptions.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest - -from gotrue import AsyncGoTrueClient -from gotrue.types import Subscription - -GOTRUE_URL = "http://localhost:9999" - - -@pytest.fixture(name="client") -async def create_client() -> AsyncGoTrueClient: - async with AsyncGoTrueClient(url=GOTRUE_URL) as client: - return client - - -@pytest.fixture(name="subscription") -def create_subscription(client: AsyncGoTrueClient): - return client.on_auth_state_change( - callback=lambda _, __: print("Auth state changed") - ) - - -def test_subscribe_a_listener(client: AsyncGoTrueClient, subscription: Subscription): - try: - assert len(client.state_change_emitters) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_subscribe_a_listener.__name__]) -def test_unsubscribe_a_listener(client: AsyncGoTrueClient, subscription: Subscription): - try: - subscription.unsubscribe() - assert not len(client.state_change_emitters) - except Exception as e: - assert False, str(e) diff --git a/tests/_async/test_utils.py b/tests/_async/test_utils.py new file mode 100644 index 00000000..f5a144c4 --- /dev/null +++ b/tests/_async/test_utils.py @@ -0,0 +1,38 @@ +from time import time + +from .utils import ( + create_new_user_with_email, + mock_app_metadata, + mock_user_credentials, + mock_user_metadata, +) + + +def test_mock_user_credentials_has_email(): + credentials = mock_user_credentials() + assert credentials.get("email") + assert credentials.get("password") + + +def test_mock_user_credentials_has_phone(): + credentials = mock_user_credentials() + assert credentials.get("phone") + assert credentials.get("password") + + +async def test_create_new_user_with_email(): + email = f"user+{int(time())}@example.com" + user = await create_new_user_with_email(email=email) + assert user.email == email + + +def test_mock_user_metadata(): + user_metadata = mock_user_metadata() + assert user_metadata + assert user_metadata.get("profile_image") + + +def test_mock_app_metadata(): + app_metadata = mock_app_metadata() + assert app_metadata + assert app_metadata.get("roles") diff --git a/tests/_async/utils.py b/tests/_async/utils.py new file mode 100644 index 00000000..4263c6e4 --- /dev/null +++ b/tests/_async/utils.py @@ -0,0 +1,82 @@ +from random import random +from time import time +from typing import Union + +from faker import Faker +from jwt import encode +from typing_extensions import NotRequired, TypedDict + +from gotrue.types import User + +from .clients import GOTRUE_JWT_SECRET, service_role_api_client + + +def mock_access_token() -> str: + return encode( + { + "sub": "1234567890", + "role": "anon_key", + }, + GOTRUE_JWT_SECRET, + ) + + +class OptionalCredentials(TypedDict): + email: NotRequired[Union[str, None]] + phone: NotRequired[Union[str, None]] + password: NotRequired[Union[str, None]] + + +class Credentials(TypedDict): + email: str + phone: str + password: str + + +def mock_user_credentials( + options: OptionalCredentials = {}, +) -> Credentials: + fake = Faker() + rand_numbers = str(int(time())) + return { + "email": options.get("email") or fake.email(), + "phone": options.get("phone") or f"1{rand_numbers[-11:]}", + "password": options.get("password") or fake.password(), + } + + +def mock_verification_otp() -> str: + return str(int(100000 + random() * 900000)) + + +def mock_user_metadata(): + fake = Faker() + return { + "profile_image": fake.url(), + } + + +def mock_app_metadata(): + return { + "roles": ["editor", "publisher"], + } + + +async def create_new_user_with_email( + *, + email: Union[str, None] = None, + password: Union[str, None] = None, +) -> User: + credentials = mock_user_credentials( + { + "email": email, + "password": password, + } + ) + response = await service_role_api_client().create_user( + { + "email": credentials["email"], + "password": credentials["password"], + } + ) + return response.user diff --git a/tests/_sync/clients.py b/tests/_sync/clients.py new file mode 100644 index 00000000..d7dc428f --- /dev/null +++ b/tests/_sync/clients.py @@ -0,0 +1,128 @@ +from jwt import encode + +from gotrue import SyncGoTrueAdminAPI, SyncGoTrueClient + +SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT = 9999 +SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT = 9998 +SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT = 9997 + +GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF = ( + f"http://localhost:{SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT}" +) +GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON = ( + f"http://localhost:{SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT}" +) +GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF = ( + f"http://localhost:{SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT}" +) + +GOTRUE_JWT_SECRET = "37c304f8-51aa-419a-a1af-06154e63707a" + +AUTH_ADMIN_JWT = encode( + { + "sub": "1234567890", + "role": "supabase_admin", + }, + GOTRUE_JWT_SECRET, +) + + +def auth_client(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def auth_client_with_session(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=False, + ) + + +def auth_subscription_client(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_enabled_client(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_off_signups_enabled_client(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + auto_refresh_token=False, + persist_session=True, + ) + + +def client_api_auto_confirm_disabled_client(): + return SyncGoTrueClient( + url=GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF, + auto_refresh_token=False, + persist_session=True, + ) + + +def auth_admin_api_auto_confirm_enabled_client(): + return SyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + headers={ + "Authorization": f"Bearer {AUTH_ADMIN_JWT}", + }, + ) + + +def auth_admin_api_auto_confirm_disabled_client(): + return SyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {AUTH_ADMIN_JWT}", + }, + ) + + +SERVICE_ROLE_JWT = encode( + { + "role": "service_role", + }, + GOTRUE_JWT_SECRET, +) + + +def service_role_api_client(): + return SyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) + + +def service_role_api_client_with_sms(): + return SyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) + + +def service_role_api_client_no_sms(): + return SyncGoTrueAdminAPI( + url=GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF, + headers={ + "Authorization": f"Bearer {SERVICE_ROLE_JWT}", + }, + ) diff --git a/tests/_sync/test_api_with_auto_confirm_disabled.py b/tests/_sync/test_api_with_auto_confirm_disabled.py deleted file mode 100644 index b87f489c..00000000 --- a/tests/_sync/test_api_with_auto_confirm_disabled.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Iterable - -import pytest -from faker import Faker - -from gotrue import SyncGoTrueAPI -from gotrue.constants import COOKIE_OPTIONS -from gotrue.types import CookieOptions, LinkType, User - -GOTRUE_URL = "http://localhost:9999" -TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InN1cGFiYXNlX2FkbWluIiwiaWF0IjoxNTE2MjM5MDIyfQ.0sOtTSTfPv5oPZxsjvBO249FI4S4p0ymHoIZ6H6z9Y8" # noqa: E501 - - -@pytest.fixture(name="api") -def create_api() -> Iterable[SyncGoTrueAPI]: - with SyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -fake = Faker() - -email = f"api_ac_disabled_{fake.email().lower()}" -password = fake.password() - - -def test_sign_up_with_email_and_password(api: SyncGoTrueAPI): - try: - response = api.sign_up_with_email( - email=email, - password=password, - redirect_to="http://localhost:9999/welcome", - data={"status": "alpha"}, - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -email2 = f"api_generate_link_signup_{fake.email().lower()}" -password2 = fake.password() - - -def test_generate_sign_up_link(api: SyncGoTrueAPI): - try: - response = api.generate_link( - type=LinkType.signup, - email=email2, - password=password2, - redirect_to="http://localhost:9999/welcome", - data={"status": "alpha"}, - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -email3 = f"api_generate_link_signup_{fake.email().lower()}" - - -def test_generate_magic_link(api: SyncGoTrueAPI): - try: - response = api.generate_link( - type=LinkType.magiclink, - email=email3, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -def test_generate_invite_link(api: SyncGoTrueAPI): - try: - response = api.generate_link( - type=LinkType.invite, - email=email3, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -def test_generate_recovery_link(api: SyncGoTrueAPI): - try: - response = api.generate_link( - type=LinkType.recovery, - email=email, - redirect_to="http://localhost:9999/welcome", - ) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_api_with_auto_confirm_enabled.py b/tests/_sync/test_api_with_auto_confirm_enabled.py deleted file mode 100644 index 578646a8..00000000 --- a/tests/_sync/test_api_with_auto_confirm_enabled.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Iterable, Optional - -import pytest -from faker import Faker - -from gotrue import SyncGoTrueAPI -from gotrue.constants import COOKIE_OPTIONS -from gotrue.types import CookieOptions, Session, User - -GOTRUE_URL = "http://localhost:9998" -TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNjQyMjMyNzUwfQ.TUR8Zu05TtNR25L42soA2trZpc4oBR8-9Pv5r5bvls8" # noqa: E501 - - -@pytest.fixture(name="api") -def create_api() -> Iterable[SyncGoTrueAPI]: - with SyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -fake = Faker() - -email = f"api_ac_enabled_{fake.email().lower()}" -password = fake.password() -valid_session: Optional[Session] = None - - -def test_sign_up_with_email(api: SyncGoTrueAPI): - global valid_session - try: - response = api.sign_up_with_email( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, Session) - valid_session = response - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email.__name__]) -def test_get_user(api: SyncGoTrueAPI): - try: - jwt = valid_session.access_token if valid_session else "" - response = api.get_user(jwt=jwt) - assert isinstance(response, User) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user.__name__]) -def test_delete_user(api: SyncGoTrueAPI): - try: - jwt = valid_session.access_token if valid_session else "" - user = api.get_user(jwt=jwt) - api.delete_user(uid=str(user.id), jwt=TOKEN) - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_client_with_auto_confirm_disabled.py b/tests/_sync/test_client_with_auto_confirm_disabled.py deleted file mode 100644 index b9a0a4d0..00000000 --- a/tests/_sync/test_client_with_auto_confirm_disabled.py +++ /dev/null @@ -1,120 +0,0 @@ -from typing import Iterable - -import pytest -from faker import Faker - -from gotrue import SyncGoTrueClient -from gotrue.exceptions import APIError -from gotrue.types import User - -GOTRUE_URL = "http://localhost:9999" -TEST_TWILIO = False - - -@pytest.fixture(name="client") -def create_client() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = fake.email().lower() -password = fake.password() -phone = fake.phone_number() # set test number here - - -def test_sign_up_with_email_and_password(client: SyncGoTrueClient): - try: - response = client.sign_up( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, User) - assert not response.email_confirmed_at - assert not response.last_sign_in_at - assert response.email == email - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -def test_sign_up_with_the_same_user_twice_should_throw_an_error( - client: SyncGoTrueClient, -): - expected_error_message = "For security purposes, you can only request this after" - try: - client.sign_up( - email=email, - password=password, - ) - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -def test_sign_in(client: SyncGoTrueClient): - expected_error_message = "Email not confirmed" - try: - client.sign_in( - email=email, - password=password, - ) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up_with_email_and_password.__name__]) -def test_sign_in_with_the_wrong_password(client: SyncGoTrueClient): - expected_error_message = "Invalid login credentials" - try: - client.sign_in( - email=email, - password=password + "2", - ) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.skipif(not TEST_TWILIO, reason="Twilio is not available") -def test_sign_up_with_phone_and_password(client: SyncGoTrueClient): - try: - response = client.sign_up( - phone=phone, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, User) - assert not response.phone_confirmed_at - assert not response.email_confirmed_at - assert not response.last_sign_in_at - assert response.phone == phone - except Exception as e: - assert False, str(e) - - -@pytest.mark.skipif(not TEST_TWILIO, reason="Twilio is not available") -@pytest.mark.depends(on=[test_sign_up_with_phone_and_password.__name__]) -def test_verify_mobile_otp_errors_on_bad_token(client: SyncGoTrueClient): - expected_error_message = "Otp has expired or is invalid" - try: - client.verify_otp(phone=phone, token="123456") - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_client_with_auto_confirm_enabled.py b/tests/_sync/test_client_with_auto_confirm_enabled.py deleted file mode 100644 index e3a96f81..00000000 --- a/tests/_sync/test_client_with_auto_confirm_enabled.py +++ /dev/null @@ -1,457 +0,0 @@ -from typing import Iterable, Optional - -import pytest -from faker import Faker - -from gotrue import SyncGoTrueClient -from gotrue.exceptions import APIError -from gotrue.types import Session, User, UserAttributes - -GOTRUE_URL = "http://localhost:9998" -TEST_TWILIO = False - - -@pytest.fixture(name="client") -def create_client() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=True, - ) as client: - yield client - - -@pytest.fixture(name="client_with_session") -def create_client_with_session() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -@pytest.fixture(name="new_client") -def create_new_client() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = f"client_ac_enabled_{fake.email().lower()}" -set_session_email = f"client_ac_session_{fake.email().lower()}" -refresh_token_email = f"client_refresh_token_signin_{fake.email().lower()}" -password = fake.password() -access_token: Optional[str] = None - - -def test_sign_up(client: SyncGoTrueClient): - try: - response = client.sign_up( - email=email, - password=password, - data={"status": "alpha"}, - ) - assert isinstance(response, Session) - global access_token - access_token = response.access_token - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - assert response.user - assert response.user.id - assert response.user.email == email - assert response.user.email_confirmed_at - assert response.user.last_sign_in_at - assert response.user.created_at - assert response.user.updated_at - assert response.user.app_metadata - assert response.user.app_metadata.get("provider") == "email" - assert response.user.user_metadata - assert response.user.user_metadata.get("status") == "alpha" - except Exception as e: - assert False, str(e) - - -def test_set_session_should_return_no_error( - client_with_session: SyncGoTrueClient, -): - try: - response = client_with_session.sign_up( - email=set_session_email, - password=password, - ) - assert isinstance(response, Session) - assert response.refresh_token - client_with_session.set_session(refresh_token=response.refresh_token) - data = {"hello": "world"} - response = client_with_session.update(attributes=UserAttributes(data=data)) - assert response.user_metadata == data - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -def test_sign_up_the_same_user_twice_should_throw_an_error( - client: SyncGoTrueClient, -): - expected_error_message = "User already registered" - try: - client.sign_up( - email=email, - password=password, - ) - assert False - except APIError as e: - assert expected_error_message in e.msg - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -def test_set_auth_should_set_the_auth_headers_on_a_new_client( - new_client: SyncGoTrueClient, -): - try: - assert access_token - new_client.set_auth(access_token=access_token) - assert new_client.current_session - assert new_client.current_session.access_token == access_token - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends( - on=[test_set_auth_should_set_the_auth_headers_on_a_new_client.__name__] -) -def test_set_auth_should_set_the_auth_headers_on_a_new_client_and_recover( - new_client: SyncGoTrueClient, -): - try: - assert access_token - new_client.init_recover() - new_client.set_auth(access_token=access_token) - assert new_client.current_session - assert new_client.current_session.access_token == access_token - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -def test_sign_in(client: SyncGoTrueClient): - try: - response = client.sign_in(email=email, password=password) - assert isinstance(response, Session) - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - assert response.user - assert response.user.id - assert response.user.email == email - assert response.user.email_confirmed_at - assert response.user.last_sign_in_at - assert response.user.created_at - assert response.user.updated_at - assert response.user.app_metadata - assert response.user.app_metadata.get("provider") == "email" - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_refresh_token(client_with_session: SyncGoTrueClient): - try: - response = client_with_session.sign_up( - email=refresh_token_email, - password=password, - ) - assert isinstance(response, Session) - assert response.refresh_token - response2 = client_with_session.sign_in(refresh_token=response.refresh_token) - assert isinstance(response2, Session) - assert response2.access_token - assert response2.refresh_token - assert response2.expires_in - assert response2.expires_at - assert response2.user - assert response2.user.id - assert response2.user.email == refresh_token_email - assert response2.user.email_confirmed_at - assert response2.user.last_sign_in_at - assert response2.user.created_at - assert response2.user.updated_at - assert response2.user.app_metadata - assert response2.user.app_metadata.get("provider") == "email" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -def test_get_user(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.user() - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.app_metadata - provider = response.app_metadata.get("provider") - assert provider == "email" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -def test_get_session(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.session() - assert isinstance(response, Session) - assert response.access_token - assert response.refresh_token - assert response.expires_in - assert response.expires_at - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -def test_update_user(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.update(attributes=UserAttributes(data={"hello": "world"})) - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_in.__name__]) -def test_update_user_dict(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.update(attributes={"data": {"hello": "world"}}) - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_update_user.__name__]) -def test_get_user_after_update(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.user() - assert isinstance(response, User) - assert response.id - assert response.email == email - assert response.email_confirmed_at - assert response.last_sign_in_at - assert response.created_at - assert response.updated_at - assert response.user_metadata - assert response.user_metadata.get("hello") == "world" - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user_after_update.__name__]) -def test_sign_out(client: SyncGoTrueClient): - try: - client.init_recover() - client.sign_out() - response = client.session() - assert response is None - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_out.__name__]) -def test_get_user_after_sign_out(client: SyncGoTrueClient): - try: - client.init_recover() - response = client.user() - assert not response - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_out.__name__]) -def test_get_update_user_after_sign_out(client: SyncGoTrueClient): - expected_error_message = "Not logged in." - try: - client.init_recover() - client.update(attributes=UserAttributes(data={"hello": "world"})) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_user_after_sign_out.__name__]) -def test_sign_in_with_the_wrong_password(client: SyncGoTrueClient): - try: - client.sign_in(email=email, password=f"{password}2") - assert False - except APIError: - pass - except Exception as e: - assert False, str(e) - - -def test_sign_up_with_password_none(client: SyncGoTrueClient): - expected_error_message = "Password must be defined, can't be None." - try: - client.sign_up(email=email) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -def test_sign_up_with_email_and_phone_none(client: SyncGoTrueClient): - expected_error_message = "Email or phone must be defined, both can't be None." - try: - client.sign_up(password=password) - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_all_nones(client: SyncGoTrueClient): - expected_error_message = ( - "Email, phone, refresh_token, or provider must be defined, " - "all can't be None." - ) - try: - client.sign_in() - assert False - except ValueError as e: - assert str(e) == expected_error_message - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_magic_link(client: SyncGoTrueClient): - try: - response = client.sign_in(email=email) - assert response is None - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_sign_up.__name__]) -def test_get_session_from_url(client: SyncGoTrueClient): - try: - assert access_token - dummy_url = ( - "https://localhost" - f"?access_token={access_token}" - "&refresh_token=refresh_token" - "&token_type=bearer" - "&expires_in=3600" - "&type=recovery" - ) - response = client.get_session_from_url(url=dummy_url, store_session=True) - assert isinstance(response, Session) - except Exception as e: - assert False, str(e) - - -def test_get_session_from_url_errors(client: SyncGoTrueClient): - try: - dummy_url = "https://localhost" - error_description = fake.email() - try: - client.get_session_from_url( - url=f"{dummy_url}?error_description={error_description}" - ) - - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == error_description - try: - client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No access_token detected." - dummy_url += "?access_token=access_token" - try: - client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No refresh_token detected." - dummy_url += "&refresh_token=refresh_token" - try: - client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No token_type detected." - dummy_url += "&token_type=bearer" - try: - client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "No expires_in detected." - dummy_url += "&expires_in=str" - try: - client.get_session_from_url(url=dummy_url) - assert False - except APIError as e: - assert e.code == 400 - assert e.msg == "Invalid expires_in." - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_get_update_user_after_sign_out.__name__]) -def test_refresh_session(client: SyncGoTrueClient): - try: - response = client.sign_in(email=email, password=password) - assert isinstance(response, Session) - assert response.refresh_token - response = client.set_session(refresh_token=response.refresh_token) - assert isinstance(response, Session) - response = client.refresh_session() - assert isinstance(response, Session) - client.sign_out() - try: - client.refresh_session() - assert False - except ValueError as e: - assert str(e) == "Not logged in." - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_client_with_sign_ups_disabled.py b/tests/_sync/test_client_with_sign_ups_disabled.py deleted file mode 100644 index 0e0cb9c8..00000000 --- a/tests/_sync/test_client_with_sign_ups_disabled.py +++ /dev/null @@ -1,125 +0,0 @@ -from typing import Iterable - -import pytest -from faker import Faker - -from gotrue import SyncGoTrueAPI, SyncGoTrueClient -from gotrue.constants import COOKIE_OPTIONS, DEFAULT_HEADERS -from gotrue.exceptions import APIError -from gotrue.types import CookieOptions, LinkType, User, UserAttributes - -GOTRUE_URL = "http://localhost:9997" -AUTH_ADMIN_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InN1cGFiYXNlX2FkbWluIiwiaWF0IjoxNTE2MjM5MDIyfQ.0sOtTSTfPv5oPZxsjvBO249FI4S4p0ymHoIZ6H6z9Y8" # noqa: E501 - - -@pytest.fixture(name="auth_admin") -def create_auth_admin() -> Iterable[SyncGoTrueAPI]: - with SyncGoTrueAPI( - url=GOTRUE_URL, - headers={"Authorization": f"Bearer {AUTH_ADMIN_TOKEN}"}, - cookie_options=CookieOptions.parse_obj(COOKIE_OPTIONS), - ) as api: - yield api - - -@pytest.fixture(name="client") -def create_client() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -fake = Faker() - -email = fake.email().lower() -password = fake.password() - - -def test_sign_up(client: SyncGoTrueClient): - expected_error_message = "Signups not allowed for this instance" - try: - client.sign_up(email=email, password=password) - assert False - except APIError as e: - assert e.msg == expected_error_message - except Exception as e: - assert False, str(e) - - -invited_user = fake.email().lower() - - -def test_generate_link_should_be_able_to_generate_multiple_links( - auth_admin: SyncGoTrueAPI, -): - try: - response = auth_admin.generate_link( - type=LinkType.invite, - email=invited_user, - redirect_to="http://localhost:9997", - ) - assert isinstance(response, User) - assert response.email == invited_user - assert response.action_link - assert "http://localhost:9997/?token=" in response.action_link - assert response.app_metadata - assert response.app_metadata.get("provider") == "email" - providers = response.app_metadata.get("providers") - assert providers - assert isinstance(providers, list) - assert len(providers) == 1 - assert providers[0] == "email" - assert response.role == "" - assert response.user_metadata == {} - assert response.identities == [] - user = response - response = auth_admin.generate_link( - type=LinkType.invite, - email=invited_user, - ) - assert isinstance(response, User) - assert response.email == invited_user - assert response.action_link - assert "http://localhost:9997/?token=" in response.action_link - assert response.app_metadata - assert response.app_metadata.get("provider") == "email" - providers = response.app_metadata.get("providers") - assert providers - assert isinstance(providers, list) - assert len(providers) == 1 - assert providers[0] == "email" - assert response.role == "" - assert response.user_metadata == {} - assert response.identities == [] - user_again = response - assert user.id == user_again.id - except Exception as e: - assert False, str(e) - - -email2 = fake.email().lower() - - -def test_create_user(auth_admin: SyncGoTrueAPI): - try: - attributes = UserAttributes(email=email2) - response = auth_admin.create_user(attributes=attributes) - assert isinstance(response, User) - assert response.email == email2 - response = auth_admin.list_users() - user = next((u for u in response if u.email == email2), None) - assert user - assert user.email == email2 - except Exception as e: - assert False, str(e) - - -def test_default_headers(client: SyncGoTrueClient): - """Test client for existing default headers""" - default_key = "X-Client-Info" - assert default_key in DEFAULT_HEADERS - assert default_key in client.api.headers - assert client.api.headers[default_key] == DEFAULT_HEADERS[default_key] diff --git a/tests/_sync/test_gotrue_admin_api.py b/tests/_sync/test_gotrue_admin_api.py new file mode 100644 index 00000000..34cd9580 --- /dev/null +++ b/tests/_sync/test_gotrue_admin_api.py @@ -0,0 +1,273 @@ +from gotrue.errors import AuthError + +from .clients import ( + auth_client_with_session, + client_api_auto_confirm_disabled_client, + client_api_auto_confirm_off_signups_enabled_client, + service_role_api_client, +) +from .utils import ( + create_new_user_with_email, + mock_app_metadata, + mock_user_credentials, + mock_user_metadata, + mock_verification_otp, +) + + +def test_create_user_should_create_a_new_user(): + credentials = mock_user_credentials() + response = create_new_user_with_email(email=credentials.get("email")) + assert response.email == credentials.get("email") + + +def test_create_user_with_user_metadata(): + user_metadata = mock_user_metadata() + credentials = mock_user_credentials() + response = service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "user_metadata": user_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert response.user.user_metadata == user_metadata + assert "profile_image" in response.user.user_metadata + + +def test_create_user_with_app_metadata(): + app_metadata = mock_app_metadata() + credentials = mock_user_credentials() + response = service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "app_metadata": app_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert "provider" in response.user.app_metadata + assert "providers" in response.user.app_metadata + + +def test_create_user_with_user_and_app_metadata(): + user_metadata = mock_user_metadata() + app_metadata = mock_app_metadata() + credentials = mock_user_credentials() + response = service_role_api_client().create_user( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + "user_metadata": user_metadata, + "app_metadata": app_metadata, + } + ) + assert response.user.email == credentials.get("email") + assert "profile_image" in response.user.user_metadata + assert "provider" in response.user.app_metadata + assert "providers" in response.user.app_metadata + + +def test_list_users_should_return_registered_users(): + credentials = mock_user_credentials() + create_new_user_with_email(email=credentials.get("email")) + users = service_role_api_client().list_users() + assert users + emails = [user.email for user in users] + assert emails + assert credentials.get("email") in emails + + +def test_get_user_fetches_a_user_by_their_access_token(): + credentials = mock_user_credentials() + auth_client_with_session_current_user = auth_client_with_session() + response = auth_client_with_session_current_user.sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + } + ) + assert response.session + response = auth_client_with_session_current_user.get_user() + assert response.user.email == credentials.get("email") + + +def test_get_user_by_id_should_a_registered_user_given_its_user_identifier(): + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + assert user.id + response = service_role_api_client().get_user_by_id(user.id) + assert response.user.email == credentials.get("email") + + +def test_modify_email_using_update_user_by_id(): + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + response = service_role_api_client().update_user_by_id( + user.id, + { + "email": f"new_{user.email}", + }, + ) + assert response.user.email == f"new_{user.email}" + + +def test_modify_user_metadata_using_update_user_by_id(): + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + user_metadata = {"favorite_color": "yellow"} + response = service_role_api_client().update_user_by_id( + user.id, + { + "user_metadata": user_metadata, + }, + ) + assert response.user.email == user.email + assert response.user.user_metadata == user_metadata + + +def test_modify_app_metadata_using_update_user_by_id(): + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + app_metadata = {"roles": ["admin", "publisher"]} + response = service_role_api_client().update_user_by_id( + user.id, + { + "app_metadata": app_metadata, + }, + ) + assert response.user.email == user.email + assert "roles" in response.user.app_metadata + + +def test_modify_confirm_email_using_update_user_by_id(): + credentials = mock_user_credentials() + response = client_api_auto_confirm_off_signups_enabled_client().sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + } + ) + assert response.user + assert not response.user.email_confirmed_at + response = service_role_api_client().update_user_by_id( + response.user.id, + { + "email_confirm": True, + }, + ) + assert response.user.email_confirmed_at + + +def test_delete_user_should_be_able_delete_an_existing_user(): + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + service_role_api_client().delete_user(user.id) + users = service_role_api_client().list_users() + emails = [user.email for user in users] + assert credentials.get("email") not in emails + + +def test_generate_link_supports_sign_up_with_generate_confirmation_signup_link(): + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + user_metadata = {"status": "alpha"} + response = service_role_api_client().generate_link( + { + "type": "signup", + "email": credentials.get("email"), + "password": credentials.get("password"), + "options": { + "data": user_metadata, + "redirect_to": redirect_to, + }, + }, + ) + assert response.user.user_metadata == user_metadata + + +def test_generate_link_supports_updating_emails_with_generate_email_change_links(): # noqa: E501 + credentials = mock_user_credentials() + user = create_new_user_with_email(email=credentials.get("email")) + assert user.email + assert user.email == credentials.get("email") + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + response = service_role_api_client().generate_link( + { + "type": "email_change_current", + "email": user.email, + "new_email": credentials.get("email"), + "options": { + "redirect_to": redirect_to, + }, + }, + ) + assert response.user.new_email == credentials.get("email") + + +def test_invite_user_by_email_creates_a_new_user_with_an_invited_at_timestamp(): + credentials = mock_user_credentials() + redirect_to = "http://localhost:9999/welcome" + user_metadata = {"status": "alpha"} + response = service_role_api_client().invite_user_by_email( + credentials.get("email"), + { + "data": user_metadata, + "redirect_to": redirect_to, + }, + ) + assert response.user.invited_at + + +def test_sign_out_with_an_valid_access_token(): + credentials = mock_user_credentials() + response = auth_client_with_session().sign_up( + { + "email": credentials.get("email"), + "password": credentials.get("password"), + }, + ) + assert response.session + response = service_role_api_client().sign_out(response.session.access_token) + + +def test_sign_out_with_an_invalid_access_token(): + try: + service_role_api_client().sign_out("this-is-a-bad-token") + assert False + except AuthError: + pass + + +def test_verify_otp_with_non_existent_phone_number(): + credentials = mock_user_credentials() + otp = mock_verification_otp() + try: + client_api_auto_confirm_disabled_client().verify_otp( + { + "phone": credentials.get("phone"), + "token": otp, + "type": "sms", + }, + ) + assert False + except AuthError as e: + assert e.message == "User not found" + + +def test_verify_otp_with_invalid_phone_number(): + credentials = mock_user_credentials() + otp = mock_verification_otp() + try: + client_api_auto_confirm_disabled_client().verify_otp( + { + "phone": f"{credentials.get('phone')}-invalid", + "token": otp, + "type": "sms", + }, + ) + assert False + except AuthError as e: + assert e.message == "Invalid phone number format" diff --git a/tests/_sync/test_provider.py b/tests/_sync/test_provider.py deleted file mode 100644 index bb6dcc38..00000000 --- a/tests/_sync/test_provider.py +++ /dev/null @@ -1,61 +0,0 @@ -from typing import Iterable - -import pytest - -from gotrue import SyncGoTrueClient -from gotrue.types import Provider - -GOTRUE_URL = "http://localhost:9999" - - -@pytest.fixture(name="client") -def create_client() -> Iterable[SyncGoTrueClient]: - with SyncGoTrueClient( - url=GOTRUE_URL, - auto_refresh_token=False, - persist_session=False, - ) as client: - yield client - - -def test_sign_in_with_provider(client: SyncGoTrueClient): - try: - response = client.sign_in(provider=Provider.google) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_provider_can_append_a_redirect_url( - client: SyncGoTrueClient, -): - try: - response = client.sign_in( - provider=Provider.google, - redirect_to="https://localhost:9000/welcome", - ) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_provider_can_append_scopes(client: SyncGoTrueClient): - try: - response = client.sign_in(provider=Provider.google, scopes="repo") - assert isinstance(response, str) - except Exception as e: - assert False, str(e) - - -def test_sign_in_with_provider_can_append_multiple_options( - client: SyncGoTrueClient, -): - try: - response = client.sign_in( - provider=Provider.google, - redirect_to="https://localhost:9000/welcome", - scopes="repo", - ) - assert isinstance(response, str) - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_subscriptions.py b/tests/_sync/test_subscriptions.py deleted file mode 100644 index c9df5808..00000000 --- a/tests/_sync/test_subscriptions.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest - -from gotrue import SyncGoTrueClient -from gotrue.types import Subscription - -GOTRUE_URL = "http://localhost:9999" - - -@pytest.fixture(name="client") -def create_client() -> SyncGoTrueClient: - with SyncGoTrueClient(url=GOTRUE_URL) as client: - return client - - -@pytest.fixture(name="subscription") -def create_subscription(client: SyncGoTrueClient): - return client.on_auth_state_change( - callback=lambda _, __: print("Auth state changed") - ) - - -def test_subscribe_a_listener(client: SyncGoTrueClient, subscription: Subscription): - try: - assert len(client.state_change_emitters) - except Exception as e: - assert False, str(e) - - -@pytest.mark.depends(on=[test_subscribe_a_listener.__name__]) -def test_unsubscribe_a_listener(client: SyncGoTrueClient, subscription: Subscription): - try: - subscription.unsubscribe() - assert not len(client.state_change_emitters) - except Exception as e: - assert False, str(e) diff --git a/tests/_sync/test_utils.py b/tests/_sync/test_utils.py new file mode 100644 index 00000000..23b4ac9c --- /dev/null +++ b/tests/_sync/test_utils.py @@ -0,0 +1,38 @@ +from time import time + +from .utils import ( + create_new_user_with_email, + mock_app_metadata, + mock_user_credentials, + mock_user_metadata, +) + + +def test_mock_user_credentials_has_email(): + credentials = mock_user_credentials() + assert credentials.get("email") + assert credentials.get("password") + + +def test_mock_user_credentials_has_phone(): + credentials = mock_user_credentials() + assert credentials.get("phone") + assert credentials.get("password") + + +def test_create_new_user_with_email(): + email = f"user+{int(time())}@example.com" + user = create_new_user_with_email(email=email) + assert user.email == email + + +def test_mock_user_metadata(): + user_metadata = mock_user_metadata() + assert user_metadata + assert user_metadata.get("profile_image") + + +def test_mock_app_metadata(): + app_metadata = mock_app_metadata() + assert app_metadata + assert app_metadata.get("roles") diff --git a/tests/_sync/utils.py b/tests/_sync/utils.py new file mode 100644 index 00000000..e85c56fd --- /dev/null +++ b/tests/_sync/utils.py @@ -0,0 +1,82 @@ +from random import random +from time import time +from typing import Union + +from faker import Faker +from jwt import encode +from typing_extensions import NotRequired, TypedDict + +from gotrue.types import User + +from .clients import GOTRUE_JWT_SECRET, service_role_api_client + + +def mock_access_token() -> str: + return encode( + { + "sub": "1234567890", + "role": "anon_key", + }, + GOTRUE_JWT_SECRET, + ) + + +class OptionalCredentials(TypedDict): + email: NotRequired[Union[str, None]] + phone: NotRequired[Union[str, None]] + password: NotRequired[Union[str, None]] + + +class Credentials(TypedDict): + email: str + phone: str + password: str + + +def mock_user_credentials( + options: OptionalCredentials = {}, +) -> Credentials: + fake = Faker() + rand_numbers = str(int(time())) + return { + "email": options.get("email") or fake.email(), + "phone": options.get("phone") or f"1{rand_numbers[-11:]}", + "password": options.get("password") or fake.password(), + } + + +def mock_verification_otp() -> str: + return str(int(100000 + random() * 900000)) + + +def mock_user_metadata(): + fake = Faker() + return { + "profile_image": fake.url(), + } + + +def mock_app_metadata(): + return { + "roles": ["editor", "publisher"], + } + + +def create_new_user_with_email( + *, + email: Union[str, None] = None, + password: Union[str, None] = None, +) -> User: + credentials = mock_user_credentials( + { + "email": email, + "password": password, + } + ) + response = service_role_api_client().create_user( + { + "email": credentials["email"], + "password": credentials["password"], + } + ) + return response.user