From beeca57c1e902dfd5c6de9d025099f2b3c42102d Mon Sep 17 00:00:00 2001 From: doas root Date: Tue, 18 Nov 2025 23:01:42 +0300 Subject: [PATCH] feat(): yandex oauth client --- .../adapters/oauth/__init__.py | 0 src/template_project/adapters/oauth/yandex.py | 87 +++++++++++++++++++ .../application/auth_identity/errors.py | 5 +- .../application/common/oauth_client.py | 42 +++++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/template_project/adapters/oauth/__init__.py create mode 100644 src/template_project/adapters/oauth/yandex.py create mode 100644 src/template_project/application/common/oauth_client.py diff --git a/src/template_project/adapters/oauth/__init__.py b/src/template_project/adapters/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/template_project/adapters/oauth/yandex.py b/src/template_project/adapters/oauth/yandex.py new file mode 100644 index 0000000..6891709 --- /dev/null +++ b/src/template_project/adapters/oauth/yandex.py @@ -0,0 +1,87 @@ +from typing import override, Any +from json import JSONDecodeError + +import httpx + +from template_project.application.common.containers import SecretString +from template_project.application.common.oauth_client import ( + OAuthClient, + OAuthExchangeCodeError, + OAuthLoadUserInfoError, + OAuthTokenResponse, + OAuthUserInfo, +) + + +class YandexOAuthClient(OAuthClient): + def __init__( + self, + client_id: str, + client_secret: SecretString, + *, + timeout: float = 10.0, + ) -> None: + self._client_id = client_id + self._client_secret = client_secret + self._timeout = timeout + + self._token_url = "https://oauth.yandex.ru/token" # noqa: S105 + self._userinfo_url = "https://login.yandex.ru/info" + self._avatar_url_interpolate = "https://avatars.yandex.net/get-yapic/{avatar_id}/islands-200" + + @override + async def exchange_code(self, code: str) -> OAuthTokenResponse: + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.post( + self._token_url, + data={ + "grant_type": "authorization_code", + "code": code, + "client_id": self._client_id, + "client_secret": self._client_secret.get_value(), + }, + ) + response.raise_for_status() + data = response.json() + + except (httpx.HTTPError, JSONDecodeError) as e: + raise OAuthExchangeCodeError from e + + return OAuthTokenResponse( + access_token=data["access_token"], + expires_in=data.get("expires_in"), + ) + + @override + async def load_user_info(self, access_token: str) -> OAuthUserInfo: + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: + response = await client.get( + self._userinfo_url, + params={"format": "json"}, + headers={"Authorization": f"OAuth {access_token}"}, + ) + response.raise_for_status() + data = response.json() + + except (httpx.HTTPError, JSONDecodeError) as e: + raise OAuthLoadUserInfoError from e + + return OAuthUserInfo( + user_id=str(data["id"]), + email=data.get("default_email"), + display_name=data.get("display_name"), + first_name=data.get("first_name"), + last_name=data.get("last_name"), + avatar_url=self._extract_avatar(data), + phone=data.get("default_phone", {}).get("number"), + ) + + def _extract_avatar(self, data: dict[str, Any]) -> str | None: + avatar_id = data.get("default_avatar_id") + + if not avatar_id or not isinstance(avatar_id, str): + return None + + return self._avatar_url_interpolate.format(avatar_id=avatar_id) diff --git a/src/template_project/application/auth_identity/errors.py b/src/template_project/application/auth_identity/errors.py index d883050..6cac465 100644 --- a/src/template_project/application/auth_identity/errors.py +++ b/src/template_project/application/auth_identity/errors.py @@ -1,11 +1,8 @@ -from typing import TYPE_CHECKING, override +from typing import override from template_project.application.auth_identity.entity import AuthMethod from template_project.application.common.errors import ApplicationError, to_error -if TYPE_CHECKING: - pass - @to_error class UserAlreadyExistsError(ApplicationError): diff --git a/src/template_project/application/common/oauth_client.py b/src/template_project/application/common/oauth_client.py new file mode 100644 index 0000000..7008041 --- /dev/null +++ b/src/template_project/application/common/oauth_client.py @@ -0,0 +1,42 @@ +from abc import abstractmethod +from typing import Protocol + +from template_project.application.common.data_structure import to_data_structure +from template_project.application.common.errors import ApplicationError, to_error + + +@to_data_structure +class OAuthTokenResponse: + access_token: str + expires_in: int | None = None + + +@to_data_structure +class OAuthUserInfo: + user_id: str + email: str | None = None + display_name: str | None = None + first_name: str | None = None + last_name: str | None = None + avatar_url: str | None = None + phone: str | None = None + + +@to_error +class OAuthExchangeCodeError(ApplicationError): + pass + + +@to_error +class OAuthLoadUserInfoError(ApplicationError): + pass + + +class OAuthClient(Protocol): + @abstractmethod + async def exchange_code(self, code: str) -> OAuthTokenResponse: + raise NotImplementedError + + @abstractmethod + async def load_user_info(self, access_token: str) -> OAuthUserInfo: + raise NotImplementedError