Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
13 changes: 11 additions & 2 deletions fastid/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import json
from collections.abc import (
Mapping, # noqa: TCH003
Sequence, # noqa: TCH003
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -310,8 +321,6 @@ class TokenResponse(BaseModel):
description="Token expiration time in seconds",
)

model_config = ConfigDict(extra="allow")


class DiscoveryDocument(BaseModel):
"""
Expand Down
30 changes: 24 additions & 6 deletions fastid/integrations/base/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions fastid/integrations/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
12 changes: 12 additions & 0 deletions fastid/integrations/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions fastid/integrations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Empty file.
65 changes: 65 additions & 0 deletions fastid/integrations/vk/oauth.py
Original file line number Diff line number Diff line change
@@ -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"),
)
9 changes: 9 additions & 0 deletions fastid/oauth/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
24 changes: 20 additions & 4 deletions tests/api/oauth/test_oauth_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,27 @@
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)
userinfo_mock = AsyncMock(return_value=openid)
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,
Expand All @@ -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,
Expand All @@ -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}",
Expand All @@ -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,
Expand All @@ -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}",
Expand Down
2 changes: 1 addition & 1 deletion tests/api/oauth/test_oauth_inspect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 1 addition & 1 deletion tests/api/oauth/test_oauth_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
Empty file added tests/integrations/__init__.py
Empty file.
Empty file.
Loading
Loading