From 420fbccf1480cd418663084ed7d35627f34e58ae Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Mon, 13 Jul 2026 15:29:22 +0300 Subject: [PATCH 1/2] feat: implement dynamic server URL handling for JWT issuer --- fastid/auth/config.py | 22 +----- fastid/auth/grants.py | 14 ++-- fastid/auth/server.py | 28 +++++++ fastid/auth/use_cases.py | 6 +- fastid/core/config.py | 19 ++--- fastid/core/dependencies.py | 6 +- fastid/core/urls.py | 4 + fastid/frontend/dependencies.py | 4 +- fastid/frontend/openid.py | 30 ++++---- fastid/frontend/router.py | 9 ++- fastid/integrations/config.py | 13 ---- fastid/integrations/dependencies.py | 33 ++++---- fastid/notify/use_cases.py | 5 +- fastid/oauth/metadata.py | 7 +- fastid/oauth/router.py | 9 ++- fastid/oauth/use_cases.py | 12 ++- fastid/security/jwt.py | 5 +- fastid/security/manager.py | 28 +++++-- .../api/auth/test_authorize_password_grant.py | 33 ++++++++ tests/api/auth/test_openid_configuration.py | 75 ++++++++++++++++++- tests/api/auth/test_userinfo.py | 2 +- tests/api/oauth/test_oauth_login.py | 8 ++ 22 files changed, 261 insertions(+), 111 deletions(-) create mode 100644 fastid/auth/server.py create mode 100644 fastid/core/urls.py diff --git a/fastid/auth/config.py b/fastid/auth/config.py index a677625..405ecae 100644 --- a/fastid/auth/config.py +++ b/fastid/auth/config.py @@ -1,11 +1,11 @@ from collections.abc import Sequence from pathlib import Path -from fastid.core.config import core_settings from fastid.core.schemas import BaseSettings class AuthSettings(BaseSettings): + server_url: str | None = None jwt_algorithm: str = "HS256" jwt_id_algorithm: str = "RS256" jwt_key: Path = Path("certs") / "secret.key" @@ -28,25 +28,5 @@ class AuthSettings(BaseSettings): app_expires_in_seconds: int = 60 - @property - def issuer(self) -> str: - return core_settings.frontend_url - - @property - def authorization_endpoint(self) -> str: - return f"{core_settings.frontend_url}/authorize" - - @property - def token_endpoint(self) -> str: - return f"{core_settings.api_url}/token" - - @property - def userinfo_endpoint(self) -> str: - return f"{core_settings.api_url}/userinfo" - - @property - def jwks_uri(self) -> str: - return f"{core_settings.frontend_url}/.well-known/jwks.json" - auth_settings = AuthSettings() diff --git a/fastid/auth/grants.py b/fastid/auth/grants.py index 707cb1c..0177ab5 100644 --- a/fastid/auth/grants.py +++ b/fastid/auth/grants.py @@ -30,6 +30,7 @@ TokenResponse, UserDTO, ) +from fastid.auth.server import ServerURLDep from fastid.cache.dependencies import CacheDep from fastid.cache.exceptions import KeyNotFoundError from fastid.core.base import UseCase @@ -43,9 +44,10 @@ class Grant(UseCase): - def __init__(self, uow: UOWDep, cache: CacheDep) -> None: + def __init__(self, uow: UOWDep, cache: CacheDep, server_url: ServerURLDep) -> None: self.uow = uow self.cache = cache + self.server_url = server_url self.token_backend = jwt_backend @abstractmethod @@ -125,14 +127,14 @@ def _get_token_response(self, scope: str, tokens: dict[str, Any]) -> TokenRespon def _issue_at(self, user: User, scope: str, tokens: dict[str, dict[str, Any]]) -> None: schema = JWTPayload(sub=str(user.id), scope=scope) - token, payload = self.token_backend.create("access", schema) + token, payload = self.token_backend.create("access", schema, issuer=self.server_url) tokens["access"]["is_issued"] = True tokens["access"]["token"] = token tokens["access"]["payload"] = payload def _issue_rt(self, user: User, scope: str, tokens: dict[str, dict[str, Any]]) -> None: schema = JWTPayload(sub=str(user.id), scope=scope) - token, payload = self.token_backend.create("refresh", schema) + token, payload = self.token_backend.create("refresh", schema, issuer=self.server_url) tokens["refresh"]["is_issued"] = True tokens["refresh"]["token"] = token tokens["refresh"]["payload"] = payload @@ -146,7 +148,7 @@ def _issue_it(self, user: User, tokens: dict[str, dict[str, Any]]) -> None: email=user.email, email_verified=user.is_verified, ) - token, payload = self.token_backend.create("id", schema) + token, payload = self.token_backend.create("id", schema, issuer=self.server_url) tokens["id"]["is_issued"] = True tokens["id"]["token"] = token tokens["id"]["payload"] = payload @@ -203,7 +205,7 @@ async def authorize( ) -> AuthorizationResponse: await self.authenticate_client(form.client_id, form.client_secret) try: - content = self.token_backend.validate("refresh", form.refresh_token) + content = self.token_backend.validate("refresh", form.refresh_token, issuer=self.server_url) except JWTError as e: raise InvalidTokenError from e assert content.scope is not None @@ -228,7 +230,7 @@ async def _issue_app_tokens(self, app: App, scope: str) -> dict[str, dict[str, A for token_type in ["access", "refresh", "id"] } schema = JWTPayload(sub=str(app.id), scope=scope) - token, payload = await self.token_backend.create_async("access", schema) + token, payload = await self.token_backend.create_async("access", schema, issuer=self.server_url) tokens["access"]["is_issued"] = True tokens["access"]["token"] = token tokens["access"]["payload"] = payload diff --git a/fastid/auth/server.py b/fastid/auth/server.py new file mode 100644 index 0000000..e76ace4 --- /dev/null +++ b/fastid/auth/server.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import Depends, Request + +from fastid.auth.config import auth_settings +from fastid.core.config import CoreSettings +from fastid.core.dependencies import get_core_settings + + +def get_server_url( + request: Request, + settings: CoreSettings = Depends(get_core_settings), +) -> str: + if auth_settings.server_url is not None: + return auth_settings.server_url.rstrip("/") + if settings.behind_proxy: + forwarded_host = request.headers.get("X-Forwarded-Host") + forwarded_proto = request.headers.get("X-Forwarded-Proto", "http") + if forwarded_host: + return f"{forwarded_proto}://{forwarded_host}" + base_url = str(request.base_url).rstrip("/") + root_path = str(request.scope.get("root_path", "")).rstrip("/") + if root_path and base_url.endswith(root_path): + return base_url[: -len(root_path)] + return base_url + + +ServerURLDep = Annotated[str, Depends(get_server_url)] diff --git a/fastid/auth/use_cases.py b/fastid/auth/use_cases.py index 04c4748..25628dc 100644 --- a/fastid/auth/use_cases.py +++ b/fastid/auth/use_cases.py @@ -2,6 +2,7 @@ from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification from fastid.auth.schemas import UserCreate +from fastid.auth.server import ServerURLDep from fastid.core.base import UseCase from fastid.database.dependencies import UOWDep from fastid.database.exceptions import NoResultFoundError @@ -11,8 +12,9 @@ class AuthUseCases(UseCase): - def __init__(self, uow: UOWDep) -> None: + def __init__(self, uow: UOWDep, server_url: ServerURLDep) -> None: self.uow = uow + self.server_url = server_url async def register(self, dto: UserCreate) -> User: try: @@ -28,7 +30,7 @@ async def register(self, dto: UserCreate) -> User: async def get_userinfo(self, token: str, *, token_type: str = "access") -> User: # noqa: S107 try: - payload = jwt_backend.validate(token_type, token) + payload = jwt_backend.validate(token_type, token, issuer=self.server_url) except JWTError as e: raise InvalidTokenError from e try: diff --git a/fastid/core/config.py b/fastid/core/config.py index 2a84e39..cc6a190 100644 --- a/fastid/core/config.py +++ b/fastid/core/config.py @@ -4,6 +4,7 @@ from pydantic_settings import SettingsConfigDict from fastid.core.schemas import ENV_PREFIX, BaseEnum, BaseSettings +from fastid.core.urls import join_url_path class Environment(BaseEnum): @@ -14,24 +15,14 @@ class Environment(BaseEnum): class CoreSettings(BaseSettings): - env: Environment = Environment.local + env: Environment = Environment.prod debug: bool = False + behind_proxy: bool = True - base_url: str = "http://localhost:8012" api_path: str = "/api/v1" admin_path: str = "/admin" frontend_path: str = "" - @property - def api_url(self) -> str: - return f"{self.base_url}/{self.api_path[1:]}" - - @property - def frontend_url(self) -> str: - if self.frontend_path == "": - return self.base_url - return f"{self.base_url}/{self.frontend_path[1:]}" - core_settings = CoreSettings() @@ -42,8 +33,8 @@ class BrandingSettings(BaseSettings): api_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} API") frontend_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} Frontend") admin_swagger_title: str = Field(default_factory=lambda data: f"{data['title']} Admin") - admin_favicon_url: str = f"{core_settings.frontend_url}/static/assets/favicon.png" - admin_logo_url: str = f"{core_settings.frontend_url}/static/assets/logo_text.png" + admin_favicon_url: str = join_url_path(core_settings.frontend_path, "static/assets/favicon.png") + admin_logo_url: str = join_url_path(core_settings.frontend_path, "static/assets/logo_text.png") notify_from: str = Field(default_factory=lambda data: data["title"]) model_config = SettingsConfigDict(env_prefix=f"{ENV_PREFIX}branding_") diff --git a/fastid/core/dependencies.py b/fastid/core/dependencies.py index a23cd88..b91c810 100644 --- a/fastid/core/dependencies.py +++ b/fastid/core/dependencies.py @@ -1,6 +1,10 @@ -from fastid.core.config import branding_settings +from fastid.core.config import CoreSettings, branding_settings, core_settings from fastid.core.logging.config import config_dict from fastid.core.logging.provider import LogProvider log_provider = LogProvider(config_dict) log = log_provider.logger(branding_settings.service_name) + + +def get_core_settings() -> CoreSettings: + return core_settings diff --git a/fastid/core/urls.py b/fastid/core/urls.py new file mode 100644 index 0000000..6f01332 --- /dev/null +++ b/fastid/core/urls.py @@ -0,0 +1,4 @@ +def join_url_path(*parts: str) -> str: + """Join application URL path components into one root-relative path.""" + segments = [segment.strip("/") for part in parts if part for segment in part.split("/") if segment] + return f"/{'/'.join(segments)}" diff --git a/fastid/frontend/dependencies.py b/fastid/frontend/dependencies.py index 6902517..b395be6 100644 --- a/fastid/frontend/dependencies.py +++ b/fastid/frontend/dependencies.py @@ -8,6 +8,7 @@ from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User from fastid.auth.schemas import OAuth2ConsentRequest +from fastid.auth.server import ServerURLDep from fastid.security.exceptions import JWTError from fastid.security.jwt import ( jwt_backend, @@ -42,12 +43,13 @@ async def get_user( def is_action_verified( request: Request, + server_url: ServerURLDep, ) -> bool: token = vt_transport.get_token(request) if token is None: return False try: - jwt_backend.validate("verify", token) + jwt_backend.validate("verify", token, issuer=server_url) except JWTError: return False return True diff --git a/fastid/frontend/openid.py b/fastid/frontend/openid.py index 2f84352..d1458a4 100644 --- a/fastid/frontend/openid.py +++ b/fastid/frontend/openid.py @@ -5,21 +5,25 @@ from fastid.auth.config import auth_settings from fastid.auth.schemas import JWK, JWKS, DiscoveryDocument +from fastid.core.config import core_settings +from fastid.core.urls import join_url_path from fastid.security.schemas import JWTPayload -discovery_document = DiscoveryDocument( - issuer=auth_settings.issuer, - authorization_endpoint=auth_settings.authorization_endpoint, - token_endpoint=auth_settings.token_endpoint, - userinfo_endpoint=auth_settings.userinfo_endpoint, - jwks_uri=auth_settings.jwks_uri, - scopes_supported=["openid", "email", "name", "offline_access"], - response_types_supported=["code"], - grant_types_supported=["authorization_code", "refresh_token"], - subject_types_supported=["public"], - id_token_signing_alg_values_supported=["RS256"], - claims_supported=list(JWTPayload.model_fields), -) + +def get_discovery_document(server_url: str) -> DiscoveryDocument: + return DiscoveryDocument( + issuer=server_url, + authorization_endpoint=f"{server_url}{join_url_path(core_settings.frontend_path, 'authorize')}", + token_endpoint=f"{server_url}{join_url_path(core_settings.api_path, 'token')}", + userinfo_endpoint=f"{server_url}{join_url_path(core_settings.api_path, 'userinfo')}", + jwks_uri=f"{server_url}{join_url_path(core_settings.frontend_path, '.well-known/jwks.json')}", + scopes_supported=["openid", "email", "name", "offline_access"], + response_types_supported=["code"], + grant_types_supported=["authorization_code", "refresh_token"], + subject_types_supported=["public"], + id_token_signing_alg_values_supported=["RS256"], + claims_supported=list(JWTPayload.model_fields), + ) def get_jwks() -> JWKS: diff --git a/fastid/frontend/router.py b/fastid/frontend/router.py index a753990..7411931 100644 --- a/fastid/frontend/router.py +++ b/fastid/frontend/router.py @@ -7,13 +7,14 @@ from fastid.auth.grants import AuthorizationCodeGrant from fastid.auth.models import User from fastid.auth.schemas import JWKS, DiscoveryDocument, OAuth2ConsentRequest +from fastid.auth.server import ServerURLDep from fastid.frontend.dependencies import ( get_user, get_user_or_none, is_action_verified, valid_consent, ) -from fastid.frontend.openid import discovery_document, jwks +from fastid.frontend.openid import get_discovery_document, jwks from fastid.frontend.templating import templates from fastid.notify.schemas import UserAction from fastid.oauth.dependencies import OAuthAccountsDep @@ -155,10 +156,10 @@ def logout() -> Any: @router.get("/.well-known/openid-configuration") -def openid_configuration() -> DiscoveryDocument: - return discovery_document +def openid_configuration(server_url: ServerURLDep) -> DiscoveryDocument: + return get_discovery_document(server_url) -@router.get("/.well-known/jwks.json") +@router.get("/.well-known/jwks.json", name="openid_jwks") def get_jwks() -> JWKS: return jwks diff --git a/fastid/integrations/config.py b/fastid/integrations/config.py index 87ba6e1..f4630e7 100644 --- a/fastid/integrations/config.py +++ b/fastid/integrations/config.py @@ -1,4 +1,3 @@ -from fastid.core.config import core_settings from fastid.core.schemas import BaseSettings @@ -19,17 +18,5 @@ class IntegrationSettings(BaseSettings): telegram_notification_enabled: bool = False telegram_bot_token: str = "" - @property - def base_authorization_url(self) -> str: - return f"{core_settings.api_url}/oauth/login" - - @property - def base_redirect_url(self) -> str: - return f"{core_settings.api_url}/oauth/callback" - - @property - def base_revoke_url(self) -> str: - return f"{core_settings.api_url}/oauth/revoke" - integration_settings = IntegrationSettings() diff --git a/fastid/integrations/dependencies.py b/fastid/integrations/dependencies.py index f5d18ac..ff7d448 100644 --- a/fastid/integrations/dependencies.py +++ b/fastid/integrations/dependencies.py @@ -4,7 +4,9 @@ from aiogram.client.default import DefaultBotProperties from fastapi import Depends +from fastid.auth.server import ServerURLDep from fastid.core.config import core_settings +from fastid.core.urls import join_url_path from fastid.integrations.config import integration_settings from fastid.integrations.google.oauth import GoogleSSO from fastid.integrations.registries import OAuth2Registry @@ -14,54 +16,55 @@ from fastid.integrations.yandex.oauth import YandexSSO from fastid.oauth.exceptions import OAuthProviderDisabledError -registry = OAuth2Registry() - -@registry.provider("google") -def get_google_sso() -> GoogleSSO: +def get_google_sso(redirect_uri: str) -> GoogleSSO: if not integration_settings.google_oauth_enabled: raise OAuthProviderDisabledError return GoogleSSO( integration_settings.google_client_id, integration_settings.google_client_secret, - f"{integration_settings.base_redirect_url}/google", + redirect_uri, ) -@registry.provider("yandex") -def get_yandex_sso() -> YandexSSO: +def get_yandex_sso(redirect_uri: str) -> YandexSSO: if not integration_settings.yandex_oauth_enabled: raise OAuthProviderDisabledError return YandexSSO( integration_settings.yandex_client_id, integration_settings.yandex_client_secret, - f"{integration_settings.base_redirect_url}/yandex", + redirect_uri, ) -@registry.provider("vk") -def get_vk_sso() -> VKSSO: +def get_vk_sso(redirect_uri: str) -> 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", + redirect_uri, ) -def get_registry() -> OAuth2Registry: +def get_registry(server_url: ServerURLDep) -> OAuth2Registry: + registry = OAuth2Registry() + callback_url = f"{server_url}{join_url_path(core_settings.api_path, 'oauth/callback')}" + registry.provider("google")(lambda: get_google_sso(f"{callback_url}/google")) + registry.provider("yandex")(lambda: get_yandex_sso(f"{callback_url}/yandex")) + registry.provider("vk")(lambda: get_vk_sso(f"{callback_url}/vk")) return registry OAuth2RegistryDep = Annotated[OAuth2Registry, Depends(get_registry)] -def get_telegram_widget() -> TelegramLoginWidget: +def get_telegram_widget(server_url: ServerURLDep) -> TelegramLoginWidget: + oauth_url = f"{server_url}{join_url_path(core_settings.api_path, 'oauth')}" return TelegramLoginWidget( integration_settings.telegram_bot_token, - f"{core_settings.api_url}/oauth/redirect/telegram", - f"{core_settings.api_url}/oauth/callback/telegram", + f"{oauth_url}/redirect/telegram", + f"{oauth_url}/callback/telegram", ) diff --git a/fastid/notify/use_cases.py b/fastid/notify/use_cases.py index bcb8e05..1208da4 100644 --- a/fastid/notify/use_cases.py +++ b/fastid/notify/use_cases.py @@ -5,6 +5,7 @@ from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification from fastid.auth.schemas import Contact, ContactType +from fastid.auth.server import ServerURLDep from fastid.cache.dependencies import CacheDep from fastid.cache.exceptions import KeyNotFoundError from fastid.core.base import UseCase @@ -43,11 +44,13 @@ def __init__( mail: MailDep, telegram: TelegramNotificationsDep, cache: CacheDep, + server_url: ServerURLDep, ) -> None: self.uow = uow self.mail = mail self.telegram = telegram self.cache = cache + self.server_url = server_url @transactional async def push_email(self, user: User, dto: PushNotificationRequest, contact: Contact | None = None) -> None: @@ -136,7 +139,7 @@ async def verify_otp(self, user: User | None, dto: VerifyOTPRequest) -> str: user = await self._get_user_by_email(dto.email) assert user is not None await self.validate_otp(user, dto.code) - token_data = jwt_backend.create("verify", JWTPayload(sub=str(user.id))) + token_data = jwt_backend.create("verify", JWTPayload(sub=str(user.id)), issuer=self.server_url) return token_data[0] @transactional diff --git a/fastid/oauth/metadata.py b/fastid/oauth/metadata.py index a45f7a1..7859d8e 100644 --- a/fastid/oauth/metadata.py +++ b/fastid/oauth/metadata.py @@ -1,3 +1,5 @@ +from fastid.core.config import core_settings +from fastid.core.urls import join_url_path from fastid.integrations.config import ( integration_settings, ) @@ -5,8 +7,9 @@ UI_META = UIProviderMeta() -BASE_AUTHORIZATION_URL = integration_settings.base_authorization_url -BASE_REVOKE_URL = integration_settings.base_revoke_url +BASE_OAUTH_URL = join_url_path(core_settings.api_path, "oauth") +BASE_AUTHORIZATION_URL = f"{BASE_OAUTH_URL}/login" +BASE_REVOKE_URL = f"{BASE_OAUTH_URL}/revoke" UI_META.providers["google"] = UIProviderMetaEntry( name="google", diff --git a/fastid/oauth/router.py b/fastid/oauth/router.py index 5d1fa0a..17966f6 100644 --- a/fastid/oauth/router.py +++ b/fastid/oauth/router.py @@ -6,10 +6,11 @@ from starlette.requests import Request from starlette.responses import HTMLResponse -from fastid.auth.config import auth_settings from fastid.auth.dependencies import UserDep, UserOrNoneDep, cookie_transport from fastid.auth.schemas import OAuth2Callback +from fastid.core.config import core_settings from fastid.core.dependencies import log +from fastid.core.urls import join_url_path from fastid.database.schemas import PageDTO from fastid.integrations.dependencies import TelegramWidgetDep from fastid.integrations.schemas import TelegramCallback @@ -56,7 +57,7 @@ async def telegram_callback( callback: Annotated[TelegramCallback, Depends()], ) -> Any: log.info("OAuth callback received: request_url=%s", str(request.url)) - response: Response = RedirectResponse(url=auth_settings.authorization_endpoint) + response: Response = RedirectResponse(url=join_url_path(core_settings.frontend_path, "authorize")) if user is not None: await service.connect(user, "telegram", callback) return response @@ -78,7 +79,7 @@ async def oauth_get_callback( callback: Annotated[OAuth2Callback, Depends()], ) -> Any: log.info("OAuth callback received: request_url=%s", str(request.url)) - response: Response = RedirectResponse(url=auth_settings.authorization_endpoint) + response: Response = RedirectResponse(url=join_url_path(core_settings.frontend_path, "authorize")) if user is not None: await service.connect(user, provider, callback) return response @@ -98,7 +99,7 @@ async def oauth_revoke( provider: str, ) -> Any: await service.revoke(user, provider) - return RedirectResponse(url=auth_settings.authorization_endpoint) + return RedirectResponse(url=join_url_path(core_settings.frontend_path, "authorize")) @router.get( diff --git a/fastid/oauth/use_cases.py b/fastid/oauth/use_cases.py index 88b676f..d7f108b 100644 --- a/fastid/oauth/use_cases.py +++ b/fastid/oauth/use_cases.py @@ -4,6 +4,7 @@ from fastid.auth.models import User from fastid.auth.repositories import EmailUserSpecification from fastid.auth.schemas import OAuth2Callback, TokenResponse +from fastid.auth.server import ServerURLDep from fastid.core.base import UseCase from fastid.database.dependencies import UOWDep from fastid.database.exceptions import NoResultFoundError @@ -27,10 +28,17 @@ class OAuthUseCases(UseCase): - def __init__(self, uow: UOWDep, registry: OAuth2RegistryDep, telegram_widget: TelegramWidgetDep) -> None: + def __init__( + self, + uow: UOWDep, + registry: OAuth2RegistryDep, + telegram_widget: TelegramWidgetDep, + server_url: ServerURLDep, + ) -> None: self.uow = uow self.registry = registry self.telegram_widget = telegram_widget + self.server_url = server_url async def get_login_url(self, provider: str) -> str: async with self._get_client(provider) as client: @@ -53,7 +61,7 @@ async def login(self, provider: str, callback: OAuth2Callback | TelegramCallback account = await self._register(open_id) user = await self.uow.users.get(account.user_id) await self.uow.commit() - token_data = jwt_backend.create("access", JWTPayload(sub=str(user.id))) + token_data = jwt_backend.create("access", JWTPayload(sub=str(user.id)), issuer=self.server_url) return TokenResponse(access_token=token_data[0]) async def connect( diff --git a/fastid/security/jwt.py b/fastid/security/jwt.py index c6eb581..ba38a84 100644 --- a/fastid/security/jwt.py +++ b/fastid/security/jwt.py @@ -1,18 +1,17 @@ import datetime from fastid.auth.config import auth_settings -from fastid.core.config import core_settings from fastid.security.manager import JWTManager from fastid.security.schemas import JWTConfig kwargs = { "algorithm": auth_settings.jwt_algorithm, - "issuer": core_settings.frontend_url, + "issuer": auth_settings.server_url, "key": auth_settings.jwt_key.read_text(), } id_kwargs = { "algorithm": auth_settings.jwt_id_algorithm, - "issuer": core_settings.frontend_url, + "issuer": auth_settings.server_url, "private_key": auth_settings.jwt_private_key.read_text(), "public_key": auth_settings.jwt_public_key.read_text(), } diff --git a/fastid/security/manager.py b/fastid/security/manager.py index ccdcd98..ec9c8a9 100644 --- a/fastid/security/manager.py +++ b/fastid/security/manager.py @@ -16,15 +16,23 @@ class JWTManager: # pragma: nocover def __init__(self, *config: JWTConfig) -> None: self.config: MutableMapping[str, JWTConfig] = {t.type: t for t in config} - async def create_async(self, token_type: str, payload: JWTPayload) -> tuple[str, dict[str, Any]]: + async def create_async( + self, + token_type: str, + payload: JWTPayload, + *, + issuer: str | None = None, + ) -> tuple[str, dict[str, Any]]: config = self.config[token_type] now = datetime.datetime.now(datetime.UTC) claims = dict( - iss=config.issuer, typ=token_type, iat=now, **payload.model_dump(exclude_none=True), ) + resolved_issuer = issuer if issuer is not None else config.issuer + if resolved_issuer is not None: + claims["iss"] = resolved_issuer if config.expires_in is not None: claims["exp"] = now + config.expires_in assert config.private_key is not None @@ -36,15 +44,23 @@ async def create_async(self, token_type: str, payload: JWTPayload) -> tuple[str, ) return token, claims - def create(self, token_type: str, payload: JWTPayload) -> tuple[str, dict[str, Any]]: + def create( + self, + token_type: str, + payload: JWTPayload, + *, + issuer: str | None = None, + ) -> tuple[str, dict[str, Any]]: config = self.config[token_type] now = datetime.datetime.now(datetime.UTC) claims = dict( - iss=config.issuer, typ=token_type, iat=now, **payload.model_dump(exclude_none=True), ) + resolved_issuer = issuer if issuer is not None else config.issuer + if resolved_issuer is not None: + claims["iss"] = resolved_issuer if config.expires_in is not None: claims["exp"] = now + config.expires_in assert config.private_key is not None @@ -59,6 +75,8 @@ def validate( self, token_type: str, token: str, + *, + issuer: str | None = None, ) -> JWTPayload: config = self.config[token_type] assert config.public_key is not None @@ -67,7 +85,7 @@ def validate( token, config.public_key, algorithms=[config.algorithm], - issuer=config.issuer, + issuer=issuer if issuer is not None else config.issuer, ) except JWTInvalidTokenError as e: raise TokenError from e diff --git a/tests/api/auth/test_authorize_password_grant.py b/tests/api/auth/test_authorize_password_grant.py index b9d8cab..a8575e4 100644 --- a/tests/api/auth/test_authorize_password_grant.py +++ b/tests/api/auth/test_authorize_password_grant.py @@ -1,7 +1,9 @@ +import jwt import pytest from httpx import AsyncClient from starlette import status +from fastid.auth.config import auth_settings from fastid.auth.schemas import UserDTO from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW @@ -28,6 +30,37 @@ async def test_authorize_password_grant( pytest.fail("No webhook event created") +async def test_dynamic_server_url_is_used_as_jwt_issuer( + client: AsyncClient, + user: UserDTO, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth_settings, "server_url", None) + assert user.email is not None + + token = await authorize_password_grant(client, user.email, mocks.USER_CREATE.password) + assert token.access_token is not None + claims = jwt.decode(token.access_token, options={"verify_signature": False}) + + assert claims["iss"] == "http://testserver" + + +async def test_configured_server_url_is_used_as_jwt_issuer( + client: AsyncClient, + user: UserDTO, + monkeypatch: pytest.MonkeyPatch, +) -> None: + server_url = "https://configured.example.com" + monkeypatch.setattr(auth_settings, "server_url", server_url) + assert user.email is not None + + token = await authorize_password_grant(client, user.email, mocks.USER_CREATE.password) + assert token.access_token is not None + claims = jwt.decode(token.access_token, options={"verify_signature": False}) + + assert claims["iss"] == server_url + + async def test_authorize_password_grant_not_exists(client: AsyncClient) -> None: response = await client.post( "/token", diff --git a/tests/api/auth/test_openid_configuration.py b/tests/api/auth/test_openid_configuration.py index 2cd1e95..68d2b3d 100644 --- a/tests/api/auth/test_openid_configuration.py +++ b/tests/api/auth/test_openid_configuration.py @@ -1,13 +1,82 @@ +import pytest from httpx import AsyncClient from starlette import status +from fastid.auth.config import auth_settings from fastid.auth.schemas import JWKS, DiscoveryDocument +from fastid.core.config import core_settings -async def test_openid_configuration(frontend_client: AsyncClient) -> None: - response = await frontend_client.get("/.well-known/openid-configuration") +async def test_openid_configuration( + frontend_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth_settings, "server_url", None) + monkeypatch.setattr(core_settings, "behind_proxy", False) + response = await frontend_client.get( + "/.well-known/openid-configuration", + headers={ + "X-Forwarded-Host": "ignored.example.com", + "X-Forwarded-Proto": "https", + }, + ) assert response.status_code == status.HTTP_200_OK - DiscoveryDocument.model_validate_json(response.content) + document = DiscoveryDocument.model_validate_json(response.content) + request_origin = "http://testserver" + assert document.issuer == request_origin + assert document.authorization_endpoint == f"{request_origin}/authorize" + assert document.token_endpoint == f"{request_origin}/api/v1/token" + assert document.userinfo_endpoint == f"{request_origin}/api/v1/userinfo" + assert document.jwks_uri == f"{request_origin}/.well-known/jwks.json" + + +async def test_openid_configuration_behind_proxy( + frontend_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(auth_settings, "server_url", None) + monkeypatch.setattr(core_settings, "behind_proxy", True) + response = await frontend_client.get( + "/.well-known/openid-configuration", + headers={ + "X-Forwarded-Host": "identity.example.com", + "X-Forwarded-Proto": "https", + }, + ) + + assert response.status_code == status.HTTP_200_OK + document = DiscoveryDocument.model_validate_json(response.content) + proxy_origin = "https://identity.example.com" + assert document.issuer == proxy_origin + assert document.authorization_endpoint == f"{proxy_origin}/authorize" + assert document.token_endpoint == f"{proxy_origin}/api/v1/token" + assert document.userinfo_endpoint == f"{proxy_origin}/api/v1/userinfo" + assert document.jwks_uri == f"{proxy_origin}/.well-known/jwks.json" + + +async def test_openid_configuration_with_configured_server_url( + frontend_client: AsyncClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + server_url = "https://configured.example.com" + monkeypatch.setattr(auth_settings, "server_url", f"{server_url}/") + monkeypatch.setattr(core_settings, "behind_proxy", True) + + response = await frontend_client.get( + "/.well-known/openid-configuration", + headers={ + "X-Forwarded-Host": "ignored.example.com", + "X-Forwarded-Proto": "http", + }, + ) + + assert response.status_code == status.HTTP_200_OK + document = DiscoveryDocument.model_validate_json(response.content) + assert document.issuer == server_url + assert document.authorization_endpoint == f"{server_url}/authorize" + assert document.token_endpoint == f"{server_url}/api/v1/token" + assert document.userinfo_endpoint == f"{server_url}/api/v1/userinfo" + assert document.jwks_uri == f"{server_url}/.well-known/jwks.json" async def test_jwks(frontend_client: AsyncClient) -> None: diff --git a/tests/api/auth/test_userinfo.py b/tests/api/auth/test_userinfo.py index 4c77960..9250833 100644 --- a/tests/api/auth/test_userinfo.py +++ b/tests/api/auth/test_userinfo.py @@ -16,7 +16,7 @@ async def test_userinfo(client: AsyncClient, user: UserDTO, user_token: TokenRes async def test_userinfo_user_not_exists(client: AsyncClient, user: UserDTO) -> None: - token, _ = jwt_backend.create("access", JWTPayload(sub=uuid_hex())) + token, _ = jwt_backend.create("access", JWTPayload(sub=uuid_hex()), issuer="http://testserver") response = await client.get("/userinfo", headers={"Authorization": f"Bearer {token}"}) assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/tests/api/oauth/test_oauth_login.py b/tests/api/oauth/test_oauth_login.py index b75152a..3a00315 100644 --- a/tests/api/oauth/test_oauth_login.py +++ b/tests/api/oauth/test_oauth_login.py @@ -1,3 +1,5 @@ +from urllib.parse import parse_qs, urlsplit + import pytest from httpx import AsyncClient from starlette import status @@ -12,3 +14,9 @@ async def test_oauth_login(client: AsyncClient, user_token: TokenResponse, provi headers={"Authorization": f"Bearer {user_token.access_token}"}, ) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT + location = response.headers["location"] + params = parse_qs(urlsplit(location).query) + if provider == "telegram": + assert params["origin"] == ["http://testserver/api/v1/oauth/redirect/telegram"] + else: + assert params["redirect_uri"] == [f"http://testserver/api/v1/oauth/callback/{provider}"] From 1569ac141062eb82350d0166865554b58b690368 Mon Sep 17 00:00:00 2001 From: "ivan.stasevich" Date: Mon, 13 Jul 2026 15:43:28 +0300 Subject: [PATCH 2/2] feat: add public URL configuration and validation for JWT issuer --- fastid/auth/config.py | 8 +++++++- fastid/core/config.py | 18 +++++++++++++++++- .../api/auth/test_authorize_password_grant.py | 8 ++++---- tests/api/auth/test_openid_configuration.py | 9 +++++---- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/fastid/auth/config.py b/fastid/auth/config.py index 405ecae..fae1fc7 100644 --- a/fastid/auth/config.py +++ b/fastid/auth/config.py @@ -1,11 +1,11 @@ from collections.abc import Sequence from pathlib import Path +from fastid.core.config import core_settings from fastid.core.schemas import BaseSettings class AuthSettings(BaseSettings): - server_url: str | None = None jwt_algorithm: str = "HS256" jwt_id_algorithm: str = "RS256" jwt_key: Path = Path("certs") / "secret.key" @@ -28,5 +28,11 @@ class AuthSettings(BaseSettings): app_expires_in_seconds: int = 60 + @property + def server_url(self) -> str | None: + if core_settings.public_url is None: + return None + return core_settings.public_url.rstrip("/") + auth_settings = AuthSettings() diff --git a/fastid/core/config.py b/fastid/core/config.py index cc6a190..856a568 100644 --- a/fastid/core/config.py +++ b/fastid/core/config.py @@ -1,6 +1,7 @@ from enum import auto +from urllib.parse import urlsplit -from pydantic import Field +from pydantic import Field, field_validator from pydantic_settings import SettingsConfigDict from fastid.core.schemas import ENV_PREFIX, BaseEnum, BaseSettings @@ -18,11 +19,26 @@ class CoreSettings(BaseSettings): env: Environment = Environment.prod debug: bool = False behind_proxy: bool = True + public_url: str | None = None api_path: str = "/api/v1" admin_path: str = "/admin" frontend_path: str = "" + @field_validator("public_url") + @classmethod + def validate_public_url(cls, value: str | None) -> str | None: + if value is None: + return None + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + msg = "public_url must be an absolute HTTP(S) URL" + raise ValueError(msg) + if parsed.query or parsed.fragment: + msg = "public_url must not contain a query or fragment" + raise ValueError(msg) + return value.rstrip("/") + core_settings = CoreSettings() diff --git a/tests/api/auth/test_authorize_password_grant.py b/tests/api/auth/test_authorize_password_grant.py index a8575e4..4a2b4dc 100644 --- a/tests/api/auth/test_authorize_password_grant.py +++ b/tests/api/auth/test_authorize_password_grant.py @@ -3,8 +3,8 @@ from httpx import AsyncClient from starlette import status -from fastid.auth.config import auth_settings from fastid.auth.schemas import UserDTO +from fastid.core.config import core_settings from fastid.database.exceptions import NoResultFoundError from fastid.database.uow import SQLAlchemyUOW from fastid.webhooks.models import Webhook @@ -35,7 +35,7 @@ async def test_dynamic_server_url_is_used_as_jwt_issuer( user: UserDTO, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(auth_settings, "server_url", None) + monkeypatch.setattr(core_settings, "public_url", None) assert user.email is not None token = await authorize_password_grant(client, user.email, mocks.USER_CREATE.password) @@ -45,13 +45,13 @@ async def test_dynamic_server_url_is_used_as_jwt_issuer( assert claims["iss"] == "http://testserver" -async def test_configured_server_url_is_used_as_jwt_issuer( +async def test_configured_public_url_is_used_as_jwt_issuer( client: AsyncClient, user: UserDTO, monkeypatch: pytest.MonkeyPatch, ) -> None: server_url = "https://configured.example.com" - monkeypatch.setattr(auth_settings, "server_url", server_url) + monkeypatch.setattr(core_settings, "public_url", server_url) assert user.email is not None token = await authorize_password_grant(client, user.email, mocks.USER_CREATE.password) diff --git a/tests/api/auth/test_openid_configuration.py b/tests/api/auth/test_openid_configuration.py index 68d2b3d..24c8335 100644 --- a/tests/api/auth/test_openid_configuration.py +++ b/tests/api/auth/test_openid_configuration.py @@ -11,7 +11,7 @@ async def test_openid_configuration( frontend_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(auth_settings, "server_url", None) + monkeypatch.setattr(core_settings, "public_url", None) monkeypatch.setattr(core_settings, "behind_proxy", False) response = await frontend_client.get( "/.well-known/openid-configuration", @@ -34,7 +34,7 @@ async def test_openid_configuration_behind_proxy( frontend_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(auth_settings, "server_url", None) + monkeypatch.setattr(core_settings, "public_url", None) monkeypatch.setattr(core_settings, "behind_proxy", True) response = await frontend_client.get( "/.well-known/openid-configuration", @@ -54,12 +54,12 @@ async def test_openid_configuration_behind_proxy( assert document.jwks_uri == f"{proxy_origin}/.well-known/jwks.json" -async def test_openid_configuration_with_configured_server_url( +async def test_openid_configuration_with_configured_public_url( frontend_client: AsyncClient, monkeypatch: pytest.MonkeyPatch, ) -> None: server_url = "https://configured.example.com" - monkeypatch.setattr(auth_settings, "server_url", f"{server_url}/") + monkeypatch.setattr(core_settings, "public_url", f"{server_url}/") monkeypatch.setattr(core_settings, "behind_proxy", True) response = await frontend_client.get( @@ -72,6 +72,7 @@ async def test_openid_configuration_with_configured_server_url( assert response.status_code == status.HTTP_200_OK document = DiscoveryDocument.model_validate_json(response.content) + assert auth_settings.server_url == server_url assert document.issuer == server_url assert document.authorization_endpoint == f"{server_url}/authorize" assert document.token_endpoint == f"{server_url}/api/v1/token"