diff --git a/synapse_token_authenticator/config.py b/synapse_token_authenticator/config.py index 59ce8bc..9c9cfe9 100644 --- a/synapse_token_authenticator/config.py +++ b/synapse_token_authenticator/config.py @@ -9,7 +9,11 @@ Validator, parse_validator, ) -from synapse_token_authenticator.utils import basic_auth, bearer_auth +from synapse_token_authenticator.http_auth import ( + HttpAuth, + NoAuth, + parse_auth, +) class OIDCConfig: @@ -104,9 +108,7 @@ class IntrospectionValidationConfig: def __post_init__(self): if not isinstance(self.validator, Exist): self.validator = parse_validator(self.validator) - - if not isinstance(self.auth, NoAuth): - self.auth = parse_auth(self.auth) + self.auth = parse_auth(self.auth, context=type(self).__name__) @dataclass class NotifyOnRegistration: @@ -115,8 +117,7 @@ class NotifyOnRegistration: interrupt_on_error: bool = True def __post_init__(self): - if not isinstance(self.auth, NoAuth): - self.auth = parse_auth(self.auth) + self.auth = parse_auth(self.auth, context=type(self).__name__) @dataclass class OAuthConfig: @@ -226,53 +227,3 @@ def verify_jwt_based_cfg(cfg): ]: error_msg = f"Unknown algorithm: '{cfg.algorithm}'" raise Exception(error_msg) - - -@dataclass -class NoAuth: - def header_map(self): - return {} - - -@dataclass -class BasicAuth: - username: str - password: str - - def header_map(self): - return basic_auth(self.username, self.password) - - -@dataclass -class BearerAuth: - token: str - - def header_map(self): - return bearer_auth(self.token) - - -HttpAuth: TypeAlias = BasicAuth | BearerAuth | NoAuth - - -def parse_auth(d: dict | list) -> HttpAuth: - if isinstance(d, dict): - auth_type = d.pop("type") - if auth_type is None: - return NoAuth() - if auth_type == "basic": - return BasicAuth(**d) - if auth_type == "bearer": - return BearerAuth(**d) - error = f"Unknown HttpAuth type {auth_type}" - raise Exception(error) - if isinstance(d, list): - auth_type = d.pop(0) - if auth_type is None: - return NoAuth() - if auth_type == "basic": - return BasicAuth(*d) - if auth_type == "bearer": - return BearerAuth(*d) - error = f"Unknown HttpAuth type {auth_type}" - raise Exception(error) - raise Exception("HttpAuth parsing failed, expected list or dict") diff --git a/synapse_token_authenticator/http_auth.py b/synapse_token_authenticator/http_auth.py new file mode 100644 index 0000000..48ed132 --- /dev/null +++ b/synapse_token_authenticator/http_auth.py @@ -0,0 +1,90 @@ +import logging +from base64 import b64encode +from typing import TypeAlias + +from pydantic import BaseModel, ConfigDict + +logger = logging.getLogger(__name__) + + +class NoAuth(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + def header_map(self) -> dict[bytes, list[bytes]]: + return {} + + +class BasicAuth(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + username: str + password: str + + def header_map(self) -> dict[bytes, list[bytes]]: + token = b64encode( + b":".join((self.username.encode("utf-8"), self.password.encode("utf-8"))) + ) + return {b"Authorization": [b"Basic " + token]} + + +class BearerAuth(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + token: str + + def header_map(self) -> dict[bytes, list[bytes]]: + return {b"Authorization": [b"Bearer " + self.token.encode("utf-8")]} + + +HttpAuth: TypeAlias = NoAuth | BasicAuth | BearerAuth + + +def parse_dict_auth(value: dict) -> HttpAuth: + auth_type = value.pop("type") + # Declaring the auth block without a 'type' parameter is an error and should raise + # the KeyError. If a user don't want to use authentication system, they should not + # include the auth block at all. + if auth_type is None: + return NoAuth() + if auth_type == "basic": + return BasicAuth(**value) + if auth_type == "bearer": + return BearerAuth(**value) + raise Exception(f"Unknown HttpAuth type '{auth_type}'") + + +def parse_list_auth(value: list) -> HttpAuth: + auth_type = value.pop(0) + # Declaring the auth block without a 'type' information is an error and should + # raise the IndexError. If a user don't want to use authentication system, they + # should not include the auth block at all. + if auth_type is None: + return NoAuth() + if auth_type == "basic": + if len(value) != 2: + raise Exception("BasicAuth expects username and password") + return BasicAuth(username=value[0], password=value[1]) + if auth_type == "bearer": + if len(value) != 1: + raise Exception("BearerAuth expects a single token") + return BearerAuth(token=value[0]) + raise Exception(f"Unknown HttpAuth type '{auth_type}'") + + +def parse_auth( + value: dict | list | HttpAuth, *, context: str | None = None +) -> HttpAuth: + if isinstance(value, (NoAuth, BasicAuth, BearerAuth)): + return value + try: + if isinstance(value, dict): + return parse_dict_auth(value) + if isinstance(value, list): + return parse_list_auth(value) + raise Exception("HttpAuth parsing failed, expected list or dict") + except Exception as e: + if context: + logger.error("%s: HttpAuth configuration error: %s", context, e) + else: + logger.error("HttpAuth configuration error: %s", e) + raise e from e diff --git a/synapse_token_authenticator/resources/__init__.py b/synapse_token_authenticator/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/synapse_token_authenticator/resources/login_metadata.py b/synapse_token_authenticator/resources/login_metadata.py new file mode 100644 index 0000000..0272ab6 --- /dev/null +++ b/synapse_token_authenticator/resources/login_metadata.py @@ -0,0 +1,30 @@ +import json +from urllib.parse import urljoin + +from twisted.web import resource + +from synapse_token_authenticator.config import ( + OIDCConfig, +) + + +class LoginMetadataResource(resource.Resource): + def __init__(self, oidc_config: OIDCConfig): + self.issuer = oidc_config.issuer + self.metadata_url = urljoin( + oidc_config.issuer, "/.well-known/openid-configuration" + ) + self.organization_id = oidc_config.organization_id + self.project_id = oidc_config.project_id + + def render_GET(self, request): + request.setHeader(b"content-type", b"application/json") + request.setHeader(b"access-control-allow-origin", b"*") + return json.dumps( + { + "issuer": self.issuer, + "issuer-metadata": self.metadata_url, + "organization-id": self.organization_id, + "project-id": self.project_id, + } + ).encode("utf-8") diff --git a/synapse_token_authenticator/resources/metadata.py b/synapse_token_authenticator/resources/metadata.py new file mode 100644 index 0000000..7d5bbfb --- /dev/null +++ b/synapse_token_authenticator/resources/metadata.py @@ -0,0 +1,13 @@ +import json + +from twisted.web import resource + + +class MetadataResource(resource.Resource): + def __init__(self, resource: object): + self.resource = resource + + def render_GET(self, request): + request.setHeader(b"content-type", b"application/json") + request.setHeader(b"access-control-allow-origin", b"*") + return json.dumps(self.resource).encode("utf-8") diff --git a/synapse_token_authenticator/resources/public_key.py b/synapse_token_authenticator/resources/public_key.py new file mode 100644 index 0000000..680c4b4 --- /dev/null +++ b/synapse_token_authenticator/resources/public_key.py @@ -0,0 +1,12 @@ +from jwcrypto.jwk import JWKSet +from twisted.web import resource + + +class PublicKeysResource(resource.Resource): + def __init__(self, keys: JWKSet): + self.keys = keys.export(private_keys=False).encode("utf-8") + + def render_GET(self, request): + request.setHeader(b"content-type", b"application/json") + request.setHeader(b"access-control-allow-origin", b"*") + return self.keys diff --git a/synapse_token_authenticator/token_authenticator.py b/synapse_token_authenticator/token_authenticator.py index 8940d7a..9a421e4 100644 --- a/synapse_token_authenticator/token_authenticator.py +++ b/synapse_token_authenticator/token_authenticator.py @@ -13,29 +13,27 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import base64 -import json import logging import re from collections.abc import Awaitable, Callable from http import HTTPStatus from typing import Any -from urllib.parse import urljoin -import synapse from jwcrypto import jwk, jwt from jwcrypto.common import JWException, json_decode from jwcrypto.jwk import JWKSet from synapse.api.errors import HttpResponseException -from synapse.module_api import ModuleApi +from synapse.module_api import JsonDict, LoginResponse, ModuleApi from synapse.types import UserID from twisted.internet import defer -from twisted.web import resource -from synapse_token_authenticator.config import OIDCConfig, TokenAuthenticatorConfig +from synapse_token_authenticator.config import TokenAuthenticatorConfig +from synapse_token_authenticator.http_auth import BasicAuth +from synapse_token_authenticator.resources.login_metadata import LoginMetadataResource +from synapse_token_authenticator.resources.metadata import MetadataResource +from synapse_token_authenticator.resources.public_key import PublicKeysResource from synapse_token_authenticator.utils import ( - MetadataResource, all_list_elems_are_equal_return_the_elem, - basic_auth, get_oidp_metadata, get_path_in_dict, if_not_none, @@ -75,7 +73,7 @@ def __init__(self, config: TokenAuthenticatorConfig, module_api: ModuleApi): self.api.register_web_resource( "/_famedly/login/com.famedly.login.token.oidc", - self.LoginMetadataResource(oidc), + LoginMetadataResource(oidc), ) if (cfg := getattr(self.config, "oauth", None)) is not None: @@ -102,51 +100,16 @@ def __init__(self, config: TokenAuthenticatorConfig, module_api: ModuleApi): keys = JWKSet() keys.add(cfg.enc_jwk) self.api.register_web_resource( - cfg.enc_jwks_endpoint, self.PublicKeysResource(keys) + cfg.enc_jwks_endpoint, PublicKeysResource(keys) ) auth_checkers[("com.famedly.login.token.epa", ("token",))] = self.check_epa self.api.register_password_auth_provider_callbacks(auth_checkers=auth_checkers) - class LoginMetadataResource(resource.Resource): - def __init__(self, oidc_config: OIDCConfig): - self.issuer = oidc_config.issuer - self.metadata_url = urljoin( - oidc_config.issuer, "/.well-known/openid-configuration" - ) - self.organization_id = oidc_config.organization_id - self.project_id = oidc_config.project_id - - def render_GET(self, request): - request.setHeader(b"content-type", b"application/json") - request.setHeader(b"access-control-allow-origin", b"*") - return json.dumps( - { - "issuer": self.issuer, - "issuer-metadata": self.metadata_url, - "organization-id": self.organization_id, - "project-id": self.project_id, - } - ).encode("utf-8") - - class PublicKeysResource(resource.Resource): - def __init__(self, keys: JWKSet): - self.keys = keys.export(private_keys=False).encode("utf-8") - - def render_GET(self, request): - request.setHeader(b"content-type", b"application/json") - request.setHeader(b"access-control-allow-origin", b"*") - return self.keys - async def check_jwt_auth( - self, username: str, login_type: str, login_dict: "synapse.module_api.JsonDict" - ) -> ( - tuple[ - str, Callable[["synapse.module_api.LoginResponse"], Awaitable[None]] | None - ] - | None - ): + self, username: str, login_type: str, login_dict: JsonDict + ) -> tuple[str, Callable[[LoginResponse], Awaitable[None]] | None] | None: logger.info("Receiving auth request") if login_type != "com.famedly.login.token": logger.info("Wrong login type") @@ -230,13 +193,8 @@ async def check_jwt_auth( return (user_id_str, None) async def check_oidc_auth( - self, username: str, login_type: str, login_dict: "synapse.module_api.JsonDict" - ) -> ( - tuple[ - str, Callable[["synapse.module_api.LoginResponse"], Awaitable[None]] | None - ] - | None - ): + self, username: str, login_type: str, login_dict: JsonDict + ) -> tuple[str, Callable[[LoginResponse], Awaitable[None]] | None] | None: logger.info("Receiving auth request") if login_type != "com.famedly.login.token.oidc": logger.info("Wrong login type") @@ -257,7 +215,9 @@ async def check_oidc_auth( introspection_resp = await client.post_urlencoded_get_json( oidc_metadata.introspection_endpoint, data, - headers=basic_auth(oidc.client_id, oidc.client_secret), + headers=BasicAuth( + username=oidc.client_id, password=oidc.client_secret + ).header_map(), ) except HttpResponseException as e: if e.code == HTTPStatus.UNAUTHORIZED: @@ -317,13 +277,8 @@ async def check_oidc_auth( return (user_id_str, None) async def check_oauth( - self, username: str, login_type: str, login_dict: "synapse.module_api.JsonDict" - ) -> ( - tuple[ - str, Callable[["synapse.module_api.LoginResponse"], Awaitable[None]] | None - ] - | None - ): + self, username: str, login_type: str, login_dict: JsonDict + ) -> tuple[str, Callable[[LoginResponse], Awaitable[None]] | None] | None: config = self.config.oauth logger.info("Receiving auth request") if login_type != "com.famedly.login.token.oauth": @@ -618,13 +573,8 @@ def get_from_set(set_): return (fully_qualified_uid, None) async def check_epa( - self, _username: str, login_type: str, login_dict: "synapse.module_api.JsonDict" - ) -> ( - tuple[ - str, Callable[["synapse.module_api.LoginResponse"], Awaitable[None]] | None - ] - | None - ): + self, _username: str, login_type: str, login_dict: JsonDict + ) -> tuple[str, Callable[[LoginResponse], Awaitable[None]] | None] | None: config = self.config.epa logger.info("Receiving auth request") if login_type != "com.famedly.login.token.epa": @@ -748,7 +698,7 @@ def _add_user_email(self, user_id, email) -> defer.Deferred: def _get_external_id( self, fully_qualified_uid: str - ) -> "defer.Deferred[list[tuple[str, str]]]": + ) -> defer.Deferred[list[tuple[str, str]]]: return defer.ensureDeferred( self.api._store.get_external_ids_by_user(fully_qualified_uid) ) diff --git a/synapse_token_authenticator/utils.py b/synapse_token_authenticator/utils.py index 4fb1511..5cd2cd6 100644 --- a/synapse_token_authenticator/utils.py +++ b/synapse_token_authenticator/utils.py @@ -1,10 +1,6 @@ -import json -from base64 import b64encode from typing import Any from urllib.parse import urljoin -from twisted.web import resource - class OpenIDProviderMetadata: """ @@ -27,17 +23,6 @@ async def get_oidp_metadata(issuer, client) -> OpenIDProviderMetadata: return OpenIDProviderMetadata(issuer, config) -def basic_auth(username: str, password: str) -> dict[bytes, list[bytes]]: - authorization = b64encode( - b":".join((username.encode("utf8"), password.encode("utf8"))) - ) - return {b"Authorization": [b"Basic " + authorization]} - - -def bearer_auth(token: str) -> dict[bytes, list[bytes]]: - return {b"Authorization": [b"Bearer " + token.encode("utf8")]} - - def if_not_none(f): return lambda x: (f(x) if x is not None else None) @@ -81,13 +66,3 @@ def validate_scopes(required_scopes: str | list[str], provided_scopes: str) -> b required_scopes = required_scopes.split() provided_scopes_list = provided_scopes.split() return all(scope in provided_scopes_list for scope in required_scopes) - - -class MetadataResource(resource.Resource): - def __init__(self, resource: object): - self.resource = resource - - def render_GET(self, request): - request.setHeader(b"content-type", b"application/json") - request.setHeader(b"access-control-allow-origin", b"*") - return json.dumps(self.resource).encode("utf-8") diff --git a/tests/test_http_auth.py b/tests/test_http_auth.py new file mode 100644 index 0000000..72c164f --- /dev/null +++ b/tests/test_http_auth.py @@ -0,0 +1,221 @@ +import logging + +import pytest +from pydantic import ValidationError + +from synapse_token_authenticator.config import TokenAuthenticatorConfig +from synapse_token_authenticator.http_auth import ( + BasicAuth, + BearerAuth, + NoAuth, + parse_auth, + parse_dict_auth, + parse_list_auth, +) + + +class TestHttpAuth: + def test_parse_auth_invalid_format(self): + with pytest.raises(Exception) as e: + parse_auth("something invalid") + assert e.value.args[0] == "HttpAuth parsing failed, expected list or dict" + + def test_no_auth(self): + no_auth = NoAuth() + assert no_auth.header_map() == {} + + def test_basic_auth(self): + basic_auth = BasicAuth(username="user", password="pass") + assert basic_auth.header_map() == {b"Authorization": [b"Basic dXNlcjpwYXNz"]} + + def test_bearer_auth(self): + bearer_auth = BearerAuth(token="token") + assert bearer_auth.header_map() == {b"Authorization": [b"Bearer token"]} + + def test_parse_dict_auth(self): + assert parse_dict_auth({"type": None}) == NoAuth() + assert parse_dict_auth( + {"type": "basic", "username": "user", "password": "pass"} + ) == BasicAuth(username="user", password="pass") + assert parse_dict_auth({"type": "bearer", "token": "token"}) == BearerAuth( + token="token" + ) + + def test_parse_dict_auth_missing_type(self): + with pytest.raises(Exception) as e: + parse_dict_auth({"username": "user", "password": "pass"}) + assert e.value.args[0] == "type" + + def test_parse_dict_auth_basic_extra_fields(self): + with pytest.raises(ValidationError) as e: + parse_dict_auth( + { + "type": "basic", + "username": "user", + "password": "pass", + "extra": "field", + } + ) + assert "Extra inputs are not permitted" in str(e) + + def test_parse_dict_auth_basic_missing_username(self): + with pytest.raises(ValidationError) as e: + parse_dict_auth({"type": "basic", "password": "pass"}) + assert "Field required" in str(e) + + def test_parse_dict_auth_unknown_http_auth_type(self): + with pytest.raises(Exception) as e: + parse_dict_auth({"type": "unknown", "token": "token"}) + assert e.value.args[0] == "Unknown HttpAuth type 'unknown'" + + def test_parse_list_auth_basic_empty_list(self): + with pytest.raises(Exception) as e: + parse_list_auth([]) + assert e.value.args[0] == "pop from empty list" + + def test_parse_auth_list(self): + assert parse_list_auth([None]) == NoAuth() + assert parse_list_auth(["basic", "user", "pass"]) == BasicAuth( + username="user", password="pass" + ) + assert parse_list_auth(["bearer", "token"]) == BearerAuth(token="token") + + def test_parse_list_auth_basic_missing_username(self): + with pytest.raises(Exception) as e: + parse_list_auth(["basic", "pass"]) + assert e.value.args[0] == "BasicAuth expects username and password" + + def test_parse_list_auth_basic_extra_fields(self): + with pytest.raises(Exception) as e: + parse_list_auth(["basic", "user", "pass", "extra", "field"]) + assert e.value.args[0] == "BasicAuth expects username and password" + + def test_parse_list_auth_bearer_extra_fields(self): + with pytest.raises(Exception) as e: + parse_list_auth(["bearer", "token", "extra", "field"]) + assert e.value.args[0] == "BearerAuth expects a single token" + + def test_parse_list_auth_unknown_http_auth_type(self): + with pytest.raises(Exception) as e: + parse_list_auth(["unknown"]) + assert e.value.args[0] == "Unknown HttpAuth type 'unknown'" + + +class TestHttpAuthConfigCoercion: + def test_introspection_auth(self): + dict_cfg = TokenAuthenticatorConfig( + { + "oauth": { + "introspection_validation": { + "endpoint": "http://idp.test/introspect", + "auth": { + "type": "basic", + "username": "user", + "password": "pass", + }, + }, + "notify_on_registration": { + "url": "http://iop.test/notify", + "auth": {"type": "bearer", "token": "token"}, + }, + } + } + ) + introspection_auth = dict_cfg.oauth.introspection_validation.auth + assert introspection_auth == BasicAuth(username="user", password="pass") + assert introspection_auth.header_map() == { + b"Authorization": [b"Basic dXNlcjpwYXNz"] + } + + notify_on_registration_auth = dict_cfg.oauth.notify_on_registration.auth + assert notify_on_registration_auth == BearerAuth(token="token") + assert notify_on_registration_auth.header_map() == { + b"Authorization": [b"Bearer token"] + } + + list_cfg = TokenAuthenticatorConfig( + { + "oauth": { + "introspection_validation": { + "endpoint": "http://idp.test/introspect", + "auth": ["bearer", "token"], + }, + "notify_on_registration": { + "url": "http://iop.test/notify", + "auth": ["basic", "user", "pass"], + }, + } + } + ) + introspection_auth = list_cfg.oauth.introspection_validation.auth + assert introspection_auth == BearerAuth(token="token") + assert introspection_auth.header_map() == {b"Authorization": [b"Bearer token"]} + + notify_on_registration_auth = list_cfg.oauth.notify_on_registration.auth + assert notify_on_registration_auth == BasicAuth( + username="user", password="pass" + ) + assert notify_on_registration_auth.header_map() == { + b"Authorization": [b"Basic dXNlcjpwYXNz"] + } + + def test_auth_defaults_to_no_auth(self): + cfg = TokenAuthenticatorConfig( + { + "oauth": { + "introspection_validation": { + "endpoint": "http://idp.test/introspect", + }, + "notify_on_registration": { + "url": "http://iop.test/notify", + }, + } + } + ) + assert cfg.oauth.introspection_validation.auth == NoAuth() + assert cfg.oauth.notify_on_registration.auth == NoAuth() + assert cfg.oauth.introspection_validation.auth.header_map() == {} + assert cfg.oauth.notify_on_registration.auth.header_map() == {} + + def test_introspection_auth_error_logs_config_class(self, caplog): + with ( + caplog.at_level(logging.ERROR), + pytest.raises(Exception), + ): + TokenAuthenticatorConfig( + { + "oauth": { + "introspection_validation": { + "endpoint": "http://idp.test/introspect", + "auth": {"type": "unknown"}, + }, + } + } + ) + assert ( + "IntrospectionValidationConfig: HttpAuth configuration error: Unknown HttpAuth type 'unknown'" + in caplog.text + ) + + def test_notify_on_registration_auth_error_logs_config_class(self, caplog): + with ( + caplog.at_level(logging.ERROR), + pytest.raises(Exception), + ): + TokenAuthenticatorConfig( + { + "oauth": { + "introspection_validation": { + "endpoint": "http://idp.test/introspect", + }, + "notify_on_registration": { + "url": "http://iop.test/notify", + "auth": ["unknown"], + }, + } + } + ) + assert ( + "NotifyOnRegistration: HttpAuth configuration error: Unknown HttpAuth type 'unknown'" + in caplog.text + ) diff --git a/tests/test_utils/__init__.py b/tests/test_utils/__init__.py index 96c3db2..b7d4ed4 100644 --- a/tests/test_utils/__init__.py +++ b/tests/test_utils/__init__.py @@ -20,7 +20,7 @@ import sys import warnings from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Self, TypeVar import attr import zope.interface @@ -116,7 +116,7 @@ def deliverBody(self, protocol: IProtocol) -> None: protocol.connectionLost(Failure(ResponseDone())) @classmethod - def json(cls, *, code: int = 200, payload: JsonSerializable) -> "FakeResponse": + def json(cls, *, code: int = 200, payload: JsonSerializable) -> Self: headers = Headers({"Content-Type": ["application/json"]}) body = json.dumps(payload).encode("utf-8") return cls(code=code, body=body, headers=headers)