diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 228dd75..6158067 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -72,6 +72,7 @@ jobs: FASTID_REDIS_URL: redis://${{ matrix.redis-user }}:${{ matrix.redis-password }}@${{ matrix.redis-host }}:${{ matrix.redis-port }} FASTID_GOOGLE_OAUTH_ENABLED: 1 FASTID_YANDEX_OAUTH_ENABLED: 1 + FASTID_VK_OAUTH_ENABLED: 1 FASTID_SMTP_ENABLED: 1 FASTID_TELEGRAM_WIDGET_ENABLED: 1 FASTID_TELEGRAM_NOTIFICATION_ENABLED: 1 diff --git a/Makefile b/Makefile index 9f341f0..11caafb 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,10 @@ deps: up: docker compose -f docker-compose.dev.yml up --build --remove-orphans --wait +.PHONY: start +start: + docker compose -f docker-compose.dev.yml start + .PHONY: build build: docker build -t fastid:latest -f docker/Dockerfile . diff --git a/fastid/auth/schemas.py b/fastid/auth/schemas.py index edf1be2..a6e70a0 100644 --- a/fastid/auth/schemas.py +++ b/fastid/auth/schemas.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import ( Mapping, # noqa: TCH003 Sequence, # noqa: TCH003 @@ -169,10 +170,20 @@ def scopes(self) -> Sequence[str]: class OAuth2Callback(BaseModel): + @model_validator(mode="before") + @classmethod + def unpack_payload(cls, values: Any) -> Any: + if isinstance(values, dict) and isinstance(values.get("payload"), str): + values = {**values, **json.loads(values["payload"])} + return values + """ Callback is sent by the authorization server to the client after the resource owner grants permissions. """ + payload: str | None = Field(None, exclude=True) + device_id: str | None = None + """VK ID device identifier returned with the authorization code.""" code: str | None = None """ The authorization code is used to obtain an access token. @@ -310,8 +321,6 @@ class TokenResponse(BaseModel): description="Token expiration time in seconds", ) - model_config = ConfigDict(extra="allow") - class DiscoveryDocument(BaseModel): """ diff --git a/fastid/integrations/base/oauth.py b/fastid/integrations/base/oauth.py index 4bab407..f3976c8 100644 --- a/fastid/integrations/base/oauth.py +++ b/fastid/integrations/base/oauth.py @@ -21,25 +21,30 @@ UserinfoError, ) from fastid.integrations.schemas import LoginResponse, UserinfoResponse -from fastid.integrations.utils import generate_random_state +from fastid.integrations.utils import generate_pkce_challenge, generate_pkce_verifier, generate_random_state class OAuth2Client: default_meta: ClassVar[ProviderMeta] + default_use_pkce: ClassVar[bool] = False + pkce_challenge_method: ClassVar[str] = "S256" + use_client_secret: ClassVar[bool] = True - def __init__( + def __init__( # noqa: PLR0913 self, client_id: str, client_secret: str, redirect_uri: str | None = None, scope: Sequence[str] | None = None, meta: ProviderMeta | None = None, + use_pkce: bool | None = None, ) -> None: self.meta = meta or self.default_meta self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri self.use_state = self.meta.use_state + self.use_pkce = self.default_use_pkce if use_pkce is None else use_pkce self.discovery_url = self.meta.discovery_url self._discovery = None @@ -96,8 +101,15 @@ async def login_url( params: dict[str, Any] | None = None, ) -> str: params = params or {} - if self.use_state: - params |= {"state": state or generate_random_state()} + if self.use_state or self.use_pkce: + state = state or generate_random_state() + params |= {"state": state} + if self.use_pkce: + assert state is not None + params |= { + "code_challenge": generate_pkce_challenge(generate_pkce_verifier(state, self.client_secret)), + "code_challenge_method": self.pkce_challenge_method, + } redirect_uri = redirect_uri or self.redirect_uri if redirect_uri is None: msg = "redirect_uri must be provided, either at construction or request time" @@ -119,7 +131,7 @@ async def login( headers: dict[str, str] | None = None, ) -> LoginResponse: request = self._prepare_token_request(callback, body=body, headers=headers) - auth = httpx.BasicAuth(self.client_id, self.client_secret) + auth = httpx.BasicAuth(self.client_id, self.client_secret) if self.use_client_secret else None response = await self.client.send( request, auth=auth, @@ -165,14 +177,20 @@ def _prepare_token_request( msg = "State was not found in the callback" raise StateError(msg) body |= {"state": callback.state} + if self.use_pkce: + if not callback.state: + msg = "PKCE code verifier was not found in the callback state" + raise StateError(msg) + body |= {"code_verifier": generate_pkce_verifier(callback.state, self.client_secret)} body = { "grant_type": "authorization_code", "code": callback.code, "redirect_uri": callback.redirect_uri or self.redirect_uri, "client_id": self.client_id, - "client_secret": self.client_secret, **body, } + if self.use_client_secret: + body["client_secret"] = self.client_secret return httpx.Request( "post", self.discovery.token_endpoint, diff --git a/fastid/integrations/config.py b/fastid/integrations/config.py index d4a8831..87ba6e1 100644 --- a/fastid/integrations/config.py +++ b/fastid/integrations/config.py @@ -11,6 +11,10 @@ class IntegrationSettings(BaseSettings): yandex_client_id: str = "" yandex_client_secret: str = "" + vk_oauth_enabled: bool = False + vk_client_id: str = "" + vk_client_secret: str = "" + telegram_widget_enabled: bool = False telegram_notification_enabled: bool = False telegram_bot_token: str = "" diff --git a/fastid/integrations/dependencies.py b/fastid/integrations/dependencies.py index b60271c..f5d18ac 100644 --- a/fastid/integrations/dependencies.py +++ b/fastid/integrations/dependencies.py @@ -10,6 +10,7 @@ from fastid.integrations.registries import OAuth2Registry from fastid.integrations.telegram.login import TelegramLoginWidget from fastid.integrations.telegram.notifications import TelegramNotificationClient +from fastid.integrations.vk.oauth import VKSSO from fastid.integrations.yandex.oauth import YandexSSO from fastid.oauth.exceptions import OAuthProviderDisabledError @@ -38,6 +39,17 @@ def get_yandex_sso() -> YandexSSO: ) +@registry.provider("vk") +def get_vk_sso() -> VKSSO: + if not integration_settings.vk_oauth_enabled: + raise OAuthProviderDisabledError + return VKSSO( + integration_settings.vk_client_id, + integration_settings.vk_client_secret, + f"{integration_settings.base_redirect_url}/vk", + ) + + def get_registry() -> OAuth2Registry: return registry diff --git a/fastid/integrations/utils.py b/fastid/integrations/utils.py index 0d262d2..7f1865b 100644 --- a/fastid/integrations/utils.py +++ b/fastid/integrations/utils.py @@ -13,6 +13,16 @@ def generate_random_state(length: int = 64) -> str: return base64.urlsafe_b64encode(os.urandom(bytes_length)).decode("utf-8") +def generate_pkce_verifier(state: str, secret: str) -> str: + digest = hmac.new(secret.encode(), state.encode(), hashlib.sha256).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + +def generate_pkce_challenge(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode()).digest() + return base64.urlsafe_b64encode(digest).decode().rstrip("=") + + def replace_localhost(url: Any) -> str: return str(url).replace("localhost", "127.0.0.1", 1) diff --git a/fastid/integrations/vk/__init__.py b/fastid/integrations/vk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fastid/integrations/vk/oauth.py b/fastid/integrations/vk/oauth.py new file mode 100644 index 0000000..fe91711 --- /dev/null +++ b/fastid/integrations/vk/oauth.py @@ -0,0 +1,65 @@ +from typing import Any + +from fastid.auth.schemas import DiscoveryDocument, OAuth2Callback, OpenID, ProviderMeta +from fastid.integrations.base.oauth import OAuth2Client +from fastid.integrations.exceptions import UserinfoError +from fastid.integrations.schemas import UserinfoResponse + + +class VKSSO(OAuth2Client): + default_use_pkce = True + pkce_challenge_method = "s256" + use_client_secret = False + default_meta = ProviderMeta( + name="vk", + title="VK", + discovery=DiscoveryDocument( + authorization_endpoint="https://id.vk.com/authorize", + token_endpoint="https://id.vk.com/oauth2/auth", # noqa: S106 + userinfo_endpoint="https://id.vk.com/oauth2/user_info", + ), + scope=["email"], + ) + + def _prepare_token_request( + self, + callback: OAuth2Callback, + *, + body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> Any: + body = body or {} + if callback.device_id is not None: + body["device_id"] = callback.device_id + return super()._prepare_token_request(callback, body=body, headers=headers) + + async def userinfo(self) -> UserinfoResponse: + assert self.discovery.userinfo_endpoint is not None + response = await self.client.post( + self.discovery.userinfo_endpoint, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data={ + "client_id": self.client_id, + "access_token": self.token.access_token, + }, + ) + content = response.json() + user = content.get("user") + if response.is_error or not user: + msg = "Getting userinfo failed: %s" + raise UserinfoError(msg, content) + + userinfo = await self.convert_userinfo(user) + return UserinfoResponse(userinfo=userinfo, userinfo_raw=content) + + async def convert_userinfo(self, response: dict[str, Any]) -> OpenID: + first_name = response.get("first_name") + last_name = response.get("last_name") + return OpenID( + id=str(response["user_id"]), + email=response.get("email"), + first_name=first_name, + last_name=last_name, + display_name=" ".join(filter(None, (first_name, last_name))) or None, + picture=response.get("avatar"), + ) diff --git a/fastid/oauth/metadata.py b/fastid/oauth/metadata.py index 0bbe521..a45f7a1 100644 --- a/fastid/oauth/metadata.py +++ b/fastid/oauth/metadata.py @@ -35,3 +35,12 @@ revoke_url=f"{BASE_REVOKE_URL}/yandex", enabled=integration_settings.yandex_oauth_enabled, ) +UI_META.providers["vk"] = UIProviderMetaEntry( + name="vk", + title="VK", + icon="fa-vk", + color="#0077FF", + authorization_url=f"{BASE_AUTHORIZATION_URL}/vk", + revoke_url=f"{BASE_REVOKE_URL}/vk", + enabled=integration_settings.vk_oauth_enabled, +) diff --git a/tests/api/oauth/test_oauth_callback.py b/tests/api/oauth/test_oauth_callback.py index 15059d1..689c01f 100644 --- a/tests/api/oauth/test_oauth_callback.py +++ b/tests/api/oauth/test_oauth_callback.py @@ -9,7 +9,10 @@ from tests import mocks -@pytest.mark.parametrize(("provider", "openid"), [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID)]) +@pytest.mark.parametrize( + ("provider", "openid"), + [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID), ("vk", mocks.VK_OPENID)], +) async def test_oauth_callback_authorize(client: AsyncClient, provider: str, openid: OpenID) -> None: params = mocks.OAUTH_CALLBACK.model_dump(mode="json", exclude_unset=True) authorize_mock = AsyncMock(return_value=mocks.LOGIN_RESPONSE) @@ -17,12 +20,16 @@ async def test_oauth_callback_authorize(client: AsyncClient, provider: str, open with ( patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), + patch("fastid.integrations.vk.oauth.VKSSO.userinfo", new=userinfo_mock), ): response = await client.get(f"/oauth/callback/{provider}", params=params) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT -@pytest.mark.parametrize(("provider", "openid"), [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID)]) +@pytest.mark.parametrize( + ("provider", "openid"), + [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID), ("vk", mocks.VK_OPENID)], +) async def test_oauth_callback_authorize_email_exists( client: AsyncClient, user: UserDTO, @@ -37,12 +44,16 @@ async def test_oauth_callback_authorize_email_exists( with ( patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), + patch("fastid.integrations.vk.oauth.VKSSO.userinfo", new=userinfo_mock), ): response = await client.get(f"/oauth/callback/{provider}", params=params) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT -@pytest.mark.parametrize(("provider", "openid"), [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID)]) +@pytest.mark.parametrize( + ("provider", "openid"), + [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID), ("vk", mocks.VK_OPENID)], +) async def test_oauth_callback_connect( client: AsyncClient, user_token: TokenResponse, @@ -55,6 +66,7 @@ async def test_oauth_callback_connect( with ( patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), + patch("fastid.integrations.vk.oauth.VKSSO.userinfo", new=userinfo_mock), ): response = await client.get( f"/oauth/callback/{provider}", @@ -64,7 +76,10 @@ async def test_oauth_callback_connect( assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT -@pytest.mark.parametrize(("provider", "openid"), [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID)]) +@pytest.mark.parametrize( + ("provider", "openid"), + [("google", mocks.GOOGLE_OPENID), ("yandex", mocks.YANDEX_OPENID), ("vk", mocks.VK_OPENID)], +) async def test_oauth_callback_double_connect( client: AsyncClient, user_token: TokenResponse, @@ -77,6 +92,7 @@ async def test_oauth_callback_double_connect( with ( patch("fastid.integrations.base.oauth.OAuth2Client.login", new=authorize_mock), patch("fastid.integrations.base.oauth.OAuth2Client.userinfo", new=userinfo_mock), + patch("fastid.integrations.vk.oauth.VKSSO.userinfo", new=userinfo_mock), ): response = await client.get( f"/oauth/callback/{provider}", diff --git a/tests/api/oauth/test_oauth_inspect.py b/tests/api/oauth/test_oauth_inspect.py index 8782b20..dfa20dc 100644 --- a/tests/api/oauth/test_oauth_inspect.py +++ b/tests/api/oauth/test_oauth_inspect.py @@ -6,7 +6,7 @@ from fastid.oauth.schemas import InspectProviderResponse -@pytest.mark.parametrize("provider", ["google", "yandex", "telegram"]) +@pytest.mark.parametrize("provider", ["google", "yandex", "vk", "telegram"]) async def test_oauth_inspect(client: AsyncClient, user_token: TokenResponse, provider: str) -> None: response = await client.get( f"/oauth/inspect/{provider}", diff --git a/tests/api/oauth/test_oauth_login.py b/tests/api/oauth/test_oauth_login.py index 1fa6f21..b75152a 100644 --- a/tests/api/oauth/test_oauth_login.py +++ b/tests/api/oauth/test_oauth_login.py @@ -5,7 +5,7 @@ from fastid.auth.schemas import TokenResponse -@pytest.mark.parametrize("provider", ["google", "yandex", "telegram"]) +@pytest.mark.parametrize("provider", ["google", "yandex", "vk", "telegram"]) async def test_oauth_login(client: AsyncClient, user_token: TokenResponse, provider: str) -> None: response = await client.get( f"/oauth/login/{provider}", diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integrations/vk/__init__.py b/tests/integrations/vk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integrations/vk/test_oauth.py b/tests/integrations/vk/test_oauth.py new file mode 100644 index 0000000..a2ae78f --- /dev/null +++ b/tests/integrations/vk/test_oauth.py @@ -0,0 +1,70 @@ +import json +from urllib.parse import parse_qs, urlparse + +from fastid.auth.schemas import OAuth2Callback +from fastid.integrations.utils import generate_pkce_challenge, generate_pkce_verifier +from fastid.integrations.vk.oauth import VKSSO + + +async def test_pkce_enabled_by_default() -> None: + client = VKSSO("client-id", "client-secret", "https://example.com/callback") + + login_url = await client.login_url(state="random-state-with-at-least-32-bytes") + params = parse_qs(urlparse(login_url).query) + + state = "random-state-with-at-least-32-bytes" + verifier = generate_pkce_verifier(state, "client-secret") + assert params["state"] == [state] + assert params["code_challenge"] == [generate_pkce_challenge(verifier)] + assert params["code_challenge_method"] == ["s256"] + assert urlparse(login_url).netloc == "id.vk.com" + + callback = OAuth2Callback(code="code", state=state, device_id="device-id") + request = client._prepare_token_request(callback) # noqa: SLF001 + token_params = parse_qs(request.content.decode()) + assert token_params["code_verifier"] == [verifier] + assert token_params["device_id"] == ["device-id"] + assert "client_secret" not in token_params + + +async def test_pkce_can_be_disabled() -> None: + client = VKSSO("client-id", "client-secret", "https://example.com/callback", use_pkce=False) + + login_url = await client.login_url(state="state") + assert "code_challenge" not in parse_qs(urlparse(login_url).query) + + request = client._prepare_token_request( # noqa: SLF001 + OAuth2Callback(code="code", state="state", device_id="device-id") + ) + assert "code_verifier" not in parse_qs(request.content.decode()) + + +def test_callback_payload() -> None: + payload = json.dumps({"code": "code", "state": "state", "type": "code_v2", "device_id": "device-id"}) + + callback = OAuth2Callback(payload=payload) + + assert callback.code == "code" + assert callback.state == "state" + assert callback.device_id == "device-id" + + +async def test_convert_userinfo() -> None: + client = VKSSO("client-id", "client-secret", "https://example.com/callback") + + user = await client.convert_userinfo( + { + "user_id": "42", + "email": "pavel@example.com", + "first_name": "Pavel", + "last_name": "Durov", + "avatar": "https://example.com/photo.jpg", + } + ) + + assert user.id == "42" + assert user.email == "pavel@example.com" + assert user.first_name == "Pavel" + assert user.last_name == "Durov" + assert user.display_name == "Pavel Durov" + assert user.picture == "https://example.com/photo.jpg" diff --git a/tests/mocks.py b/tests/mocks.py index b9bcc70..987d58c 100644 --- a/tests/mocks.py +++ b/tests/mocks.py @@ -95,6 +95,7 @@ def userinfo_response_factory() -> UserinfoResponse: GOOGLE_OPENID = userinfo_response_factory() YANDEX_OPENID = userinfo_response_factory() +VK_OPENID = userinfo_response_factory() TELEGRAM_OPENID = userinfo_response_factory() TELEGRAM_OPENID.userinfo.email = None