diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eec3971ab..bf6a4e263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras + run: uv sync --all-extras --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" # TODO Find a new image to use. # https://github.com/bluesky/tiled/issues/1109 @@ -128,7 +128,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras + run: uv sync --all-extras --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" env: TILED_BUILD_SKIP_UI: 1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e4e8ca48c..a16b158b2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -21,7 +21,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras --group docs + run: uv sync --all-extras --group docs --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" - name: Build Docs shell: bash -l {0} diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index b590ad028..fa884fc79 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -32,7 +32,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install tiled - run: uv sync --all-extras --group docs + run: uv sync --all-extras --group docs --with "bluesky-authentication @ git+https://github.com/davidpcls/bluesky-authentication.git@main" - name: Build Docs shell: bash -l {0} diff --git a/README.md b/README.md index 0df2c61ad..f2c13dc34 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ on the `conda-forge` channel, which pixi uses by default. See [Getting Started][] for more detailed installation instructions. + ## First Steps See [What is Tiled?][] for an overview of Tiled's goals. Then see diff --git a/config.example.yml b/config.example.yml index 329f74c9f..f7a0f0304 100644 --- a/config.example.yml +++ b/config.example.yml @@ -162,6 +162,10 @@ # Authentication # ----------------------------------------------------------------------------- authentication: + # Canonical authenticator import path: + # bluesky_authentication.authenticators:ClassName + # Backward-compatible legacy path still supported: + # tiled.authenticators:ClassName # --------------------------------------------------------------------------- # Identity Providers @@ -176,7 +180,7 @@ authentication: # # providers: # - provider: local - # authenticator: tiled.authenticators:PAMAuthenticator + # authenticator: bluesky_authentication.authenticators:PAMAuthenticator # args: # service: login # PAM service name. Default is "login". @@ -200,7 +204,7 @@ authentication: # # providers: # - provider: google # Must match the label used in the redirect URIs above - # authenticator: tiled.authenticators:OIDCAuthenticator + # authenticator: bluesky_authentication.authenticators:OIDCAuthenticator # args: # audience: tiled # Checked against the "aud" claim in the token # client_id: YOUR_CLIENT_ID_HERE @@ -226,7 +230,7 @@ authentication: # # providers: # - provider: toy - # authenticator: tiled.authenticators:DictionaryAuthenticator + # authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator # args: # users_to_passwords: # alice: ${ALICE_PASSWORD} @@ -239,7 +243,7 @@ authentication: # # providers: # - provider: toy - # authenticator: tiled.authenticators:DummyAuthenticator + # authenticator: bluesky_authentication.authenticators:DummyAuthenticator # --------------------------------------------------------------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index ac2eb6317..be12e5958 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -324,7 +324,7 @@ def parse_schema(d, md=[], depth=0, pre=""): providers=[ AuthenticationProviderSpec( provider="dummy", - authenticator="tiled.authenticators:DummyAuthenticator", + authenticator="bluesky_authentication.authenticators:DummyAuthenticator", ) ] ), diff --git a/docs/source/explanations/security.md b/docs/source/explanations/security.md index 2856cb9fd..645998642 100644 --- a/docs/source/explanations/security.md +++ b/docs/source/explanations/security.md @@ -132,6 +132,10 @@ Tiled is designed to integrate with external user-management systems via a plugg Authenticator interface. For those familiar with JupyterHub, these are very similar to JupyterHub Authenticators. Authenticators fall into two groups: +The canonical import path for built-in authenticators is +`bluesky_authentication.authenticators:*`. For backward compatibility, +`tiled.authenticators:*` remains supported for existing configurations. + * Authenticators that accept user credentials directly, following the OAuth2 and OpenAPI standards as shown below, and validate the credentials using some underlying authentication mechanism, such as PAM. @@ -176,7 +180,7 @@ The configuration file(s) should include: ```yaml authentication: - authenticator: tiled.authenticators:PAMAuthenticator + authenticator: bluesky_authentication.authenticators:PAMAuthenticator ``` Here is a complete working example: @@ -185,7 +189,7 @@ Here is a complete working example: # pam_config.yml authentication: providers: - - authenticator: tiled.authenticators:PAMAuthenticator + - authenticator: bluesky_authentication.authenticators:PAMAuthenticator # This 'provider' can be any string; it is used to differentiate # authentication providers when multiple ones are supported. provider: local @@ -251,7 +255,7 @@ The configuration file(s) must include the following. authentication: providers: - provider: example.com - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: # Values should come from your OIDC provider configuration # The audience claim is checked by the OIDC Client (Tiled) @@ -282,7 +286,7 @@ should only for used for development and demos. authentication: providers: - provider: toy - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} @@ -304,7 +308,7 @@ The ``DummyAuthenticator`` accepts *any* username and password combination. authentication: providers: - provider: toy - authenticator: tiled.authenticators:DummyAuthenticator + authenticator: bluesky_authentication.authenticators:DummyAuthenticator trees: - path: / tree: tiled.examples.generated_minimal:tree diff --git a/example_configs/external_service/custom.py b/example_configs/external_service/custom.py index 43b1e5b1b..bc5234c42 100644 --- a/example_configs/external_service/custom.py +++ b/example_configs/external_service/custom.py @@ -1,7 +1,7 @@ import numpy +from bluesky_authentication.protocols import UserSessionState from tiled.adapters.array import ArrayAdapter -from tiled.authenticators import UserSessionState from tiled.server.protocols import InternalAuthenticator from tiled.structures.core import StructureFamily diff --git a/example_configs/google_auth.yml b/example_configs/google_auth.yml index 0848a2d9c..ea493e191 100644 --- a/example_configs/google_auth.yml +++ b/example_configs/google_auth.yml @@ -2,7 +2,7 @@ authentication: providers: - provider: google - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: tiled # something unique to ensure received headers are for you # These values come from https://console.cloud.google.com/apis/credential diff --git a/example_configs/keycloak_oidc/config.yaml b/example_configs/keycloak_oidc/config.yaml index 0c0ccfc65..2a34df572 100644 --- a/example_configs/keycloak_oidc/config.yaml +++ b/example_configs/keycloak_oidc/config.yaml @@ -1,7 +1,7 @@ authentication: providers: - provider: keycloak_oidc - authenticator: tiled.authenticators:ProxiedOIDCAuthenticator + authenticator: bluesky_authentication.authenticators:ProxiedOIDCAuthenticator args: audience: tiled_aud client_id: tiled diff --git a/example_configs/multiple_providers.yml b/example_configs/multiple_providers.yml index c97bcf65c..45ea486a2 100644 --- a/example_configs/multiple_providers.yml +++ b/example_configs/multiple_providers.yml @@ -1,21 +1,21 @@ authentication: providers: - provider: one - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} bob: ${BOB_PASSWORD} cara: ${CARA_PASSWORD} - provider: two - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} bob: ${BOB_PASSWORD} cara: ${CARA_PASSWORD} - provider: three - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} diff --git a/example_configs/orcid_auth.yml b/example_configs/orcid_auth.yml index fade7cd5c..53c9287ee 100644 --- a/example_configs/orcid_auth.yml +++ b/example_configs/orcid_auth.yml @@ -2,7 +2,7 @@ authentication: providers: - provider: orcid - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: tiled # something unique to ensure received headers are for you # These values come from https://orcid.org/developer-tools diff --git a/example_configs/saml.yml b/example_configs/saml.yml index a03596114..f6213b3af 100644 --- a/example_configs/saml.yml +++ b/example_configs/saml.yml @@ -4,7 +4,7 @@ authentication: providers: - provider: saml - authenticator: tiled.authenticators:SAMLAuthenticator + authenticator: bluesky_authentication.authenticators:SAMLAuthenticator args: attribute_name: "email" saml_settings: diff --git a/example_configs/simple_oidc/config.yml b/example_configs/simple_oidc/config.yml index 5dbfc1c83..b45c03bb8 100644 --- a/example_configs/simple_oidc/config.yml +++ b/example_configs/simple_oidc/config.yml @@ -7,7 +7,7 @@ authentication: providers: - provider: simple_oidc - authenticator: tiled.authenticators:OIDCAuthenticator + authenticator: bluesky_authentication.authenticators:OIDCAuthenticator args: audience: ${OIDC_CLIENT_ID} client_id: ${OIDC_CLIENT_ID} diff --git a/example_configs/toy_authentication.yml b/example_configs/toy_authentication.yml index fef68eb2a..da572a2a2 100644 --- a/example_configs/toy_authentication.yml +++ b/example_configs/toy_authentication.yml @@ -1,7 +1,7 @@ authentication: providers: - provider: toy - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: ${ALICE_PASSWORD} diff --git a/pyproject.toml b/pyproject.toml index e14674e4c..53b579709 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ requires-python = ">=3.10" # data structures you care about. # Use pip install tiled[all] to get everything. dependencies = [ + "bluesky-authentication", "httpx >=0.20.0,!=0.23.1", "json-merge-patch", "jsonpatch", diff --git a/tests/conftest.py b/tests/conftest.py index 5bad1c977..51d4cd204 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -373,7 +373,7 @@ def minio_uri(): "providers": [ { "provider": "toy", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { "users_to_passwords": {"alice": "secret1", "bob": "secret2"}, }, diff --git a/tests/test_access_control.py b/tests/test_access_control.py index 1ea4e0f3b..0b2178064 100644 --- a/tests/test_access_control.py +++ b/tests/test_access_control.py @@ -34,7 +34,7 @@ "providers": [ { "provider": "toy", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { "users_to_passwords": { "alice": "alice", diff --git a/tests/test_authentication.py b/tests/test_authentication.py index d46f72675..10ad0cff0 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -260,12 +260,12 @@ def test_multiple_providers(enter_username_password, config, monkeypatch): [ { "provider": "second", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": {"users_to_passwords": {"cara": "secret3", "doug": "secret4"}}, }, { "provider": "third", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { # Duplicate 'cara' username. "users_to_passwords": {"cara": "secret5", "emilia": "secret6"} @@ -292,12 +292,12 @@ def test_multiple_providers_name_collision(config): config["authentication"]["providers"] = [ { "provider": "some_name", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": {"users_to_passwords": {"cara": "secret3", "doug": "secret4"}}, }, { "provider": "some_name", # duplicate! - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", "args": { # Duplicate 'cara' username. "users_to_passwords": {"cara": "secret5", "emilia": "secret6"} diff --git a/tests/test_authenticators.py b/tests/test_authenticators.py deleted file mode 100644 index 7ca3f3148..000000000 --- a/tests/test_authenticators.py +++ /dev/null @@ -1,350 +0,0 @@ -import asyncio -import logging -import os -import time -from typing import Any, Tuple - -import httpx -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from jose import ExpiredSignatureError, jwt -from jose.backends import RSAKey -from respx import MockRouter -from starlette.datastructures import URL, QueryParams - -from tiled.authenticators import ( - EntraAuthenticator, - LDAPAuthenticator, - OIDCAuthenticator, - ProxiedOIDCAuthenticator, -) - -# Set this if there is an LDAP container running for testing. -# See continuous_integration/docker-configs/ldap-docker-compose.yml -TILED_TEST_LDAP = os.getenv("TILED_TEST_LDAP") - - -# fmt: off -@pytest.mark.filterwarnings("ignore", category=DeprecationWarning) -@pytest.mark.parametrize("ldap_server_address, ldap_server_port", [ - ("localhost", 1389), - ("localhost:1389", 904), # Random port, ignored - ("localhost:1389", None), - ("127.0.0.1", 1389), - ("127.0.0.1:1389", 904), - (["localhost"], 1389), - (["localhost", "127.0.0.1"], 1389), - (["localhost", "127.0.0.1:1389"], 1389), - (["localhost:1389", "127.0.0.1:1389"], None), -]) -# fmt: on -@pytest.mark.parametrize("use_tls,use_ssl", [(False, False)]) -def test_LDAPAuthenticator_01(use_tls, use_ssl, ldap_server_address, ldap_server_port): - """ - Basic test for ``LDAPAuthenticator``. - - TODO: The test could be extended with enabled TLS or SSL, but it requires configuration - of the LDAP server. - """ - if not TILED_TEST_LDAP: - pytest.skip("Run an LDAP container and set TILED_TEST_LDAP to run") - authenticator = LDAPAuthenticator( - ldap_server_address, - ldap_server_port, - bind_dn_template="cn={username},ou=users,dc=example,dc=org", - use_tls=use_tls, - use_ssl=use_ssl, - ) - - async def testing(): - assert (await authenticator.authenticate("user01", "password1")).user_name == "user01" - assert (await authenticator.authenticate("user02", "password2")).user_name == "user02" - assert (await authenticator.authenticate("user02a", "password2")) is None - assert (await authenticator.authenticate("user02", "password2a")) is None - - asyncio.run(testing()) - - -@pytest.fixture -def well_known_url(base_url: str) -> str: - return f"{base_url}.well-known/openid-configuration" - - -@pytest.fixture -def mock_oidc_server( - respx_mock: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - json_web_keyset: list[dict[str, Any]], -) -> MockRouter: - respx_mock.get(well_known_url).mock( - return_value=httpx.Response(httpx.codes.OK, json=well_known_response) - ) - respx_mock.get(well_known_response["jwks_uri"]).mock( - return_value=httpx.Response(httpx.codes.OK, json={"keys": json_web_keyset}) - ) - return respx_mock - - -def test_oidc_authenticator_caching( - mock_oidc_server: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - json_web_keyset: list[dict[str, Any]] -): - - authenticator = OIDCAuthenticator("tiled", "tiled", "secret", well_known_uri=well_known_url) - assert authenticator.client_id == "tiled" - assert authenticator.authorization_endpoint == well_known_response["authorization_endpoint"] - assert authenticator.id_token_signing_alg_values_supported == well_known_response[ - "id_token_signing_alg_values_supported" - ] - assert authenticator.issuer == well_known_response["issuer"] - assert authenticator.jwks_uri == well_known_response["jwks_uri"] - assert authenticator.token_endpoint == well_known_response["token_endpoint"] - assert authenticator.device_authorization_endpoint == well_known_response["device_authorization_endpoint"] - assert authenticator.end_session_endpoint == well_known_response["end_session_endpoint"] - - # Cached the result of .well_known, only GET once - assert len(mock_oidc_server.calls) == 1 - call_request = mock_oidc_server.calls[0].request - assert call_request.method == "GET" - assert call_request.url == well_known_url - - assert authenticator.keys() == json_web_keyset - assert len(mock_oidc_server.calls) == 2 # Called also to jwks - keys_request = mock_oidc_server.calls[1].request - assert keys_request.method == "GET" - assert keys_request.url == well_known_response["jwks_uri"] - - for _ in range(10): - assert authenticator.keys() == json_web_keyset - - assert len(mock_oidc_server.calls) == 2 # Getting keys is cached - keys_request = mock_oidc_server.calls[1].request - assert keys_request.method == "GET" - assert keys_request.url == well_known_response["jwks_uri"] - - -@pytest.mark.parametrize("issued", [True, False]) -@pytest.mark.parametrize("expired", [True, False]) -def test_oidc_decoding( - mock_oidc_server: MockRouter, - well_known_url: str, - issued: bool, - expired: bool, - keys: Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey] -): - private_key, _ = keys - authenticator = OIDCAuthenticator("tiled", "tiled", "secret", well_known_uri=well_known_url) - access_token = token(issued, expired) - encrypted_access_token = encrypted_token(access_token, private_key) - - if not expired: - # Decode does not currently care if issued_at_time > current time - assert authenticator.decode_token(encrypted_access_token) == access_token - - else: - with pytest.raises(ExpiredSignatureError): - authenticator.decode_token(encrypted_access_token) - - -def test_entra_decoding_ignores_unmapped_scopes(caplog): - def mock_decode_token(self, id_token, access_token): - return { - "iss": "https://login.microsoftonline.com/example-tenant/v2.0", - "sub": "opaque-sub", - "preferred_username": "alice@example.org", - "scp": "known.scope unknown.scope", - } - - original_decode_token = OIDCAuthenticator.decode_token - OIDCAuthenticator.decode_token = mock_decode_token - try: - caplog.set_level(logging.WARNING) - - authenticator = object.__new__(EntraAuthenticator) - authenticator.scopes_map = {"known.scope": ["read:metadata"]} - claims = authenticator.decode_token("id-token", "access-token") - - assert claims["entra_sub"] == "opaque-sub" - assert claims["entra_username"] == "alice@example.org" - assert claims["user"] == "alice" - assert claims["scope"] == "read:metadata" - assert any( - "Unmapped Entra scope in 'scp': unknown.scope" in record.message - for record in caplog.records - ) - finally: - OIDCAuthenticator.decode_token = original_decode_token - - -@pytest.fixture -def keys() -> Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]: - # Key generated just for these tests - private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - public_key = private_key.public_key() - return (private_key, public_key) - - -@pytest.fixture -def json_web_keyset(keys: Tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> list[dict[str, Any]]: - _, public_key = keys - return [ - RSAKey(key=public_key, algorithm="RS256").to_dict() - ] - - -def token(issued: bool, expired: bool) -> dict[str, str]: - now = time.time() - dummy_token = { - "aud": "tiled", - "exp": (now - 1500) if expired else (now + 1500), - "iat": (now - 1500) if issued else (now + 1500), - "iss": "https://example.com/realms/example", - "sub": "Jane Doe", - } - return dummy_token - - -def encrypted_token(token: dict[str, str], private_key: rsa.RSAPrivateKey) -> str: - return jwt.encode( - token, - key=private_key, - algorithm="RS256", - headers={"kid": "secret"}, - ) - - -@pytest.mark.asyncio -async def test_proxied_oidc_token_retrieval(well_known_url: str, mock_oidc_server: MockRouter): - authenticator = ProxiedOIDCAuthenticator("tiled", "tiled", well_known_url, device_flow_client_id="tiled-cli") - test_request = httpx.Request("GET", "http://example.com", headers={ - "Authorization": "bearer FOO" - }) - - assert "FOO" == await authenticator.oauth2_schema(test_request) - - -def create_mock_OIDC_request(query_params=None): - """Helper function to create a realistic request object for testing.""" - if query_params is None: - query_params = {} - - class MockRequest: - def __init__(self, query_params): - self.query_params = QueryParams(query_params) - self.scope = { - "type": "http", - "scheme": "http", - "server": ("localhost", 8000), - "path": "/api/v1/auth/provider/orcid/code", - "headers": [] - } - self.headers = {"host": "localhost:8000"} - self.url = URL("http://localhost:8000/api/v1/auth/provider/orcid/code") - - return MockRequest(query_params) - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_mock( - mock_oidc_server: MockRouter, - well_known_url: str, - well_known_response: dict[str, Any], - monkeypatch -): - """ - Test OIDCAuthenticator with mocked external dependencies using respx. - """ - # Mock JWT token payload - mock_jwt_payload = { - "sub": "0009-0008-8698-7745", - "aud": "APP-TEST-CLIENT-ID", - "iss": well_known_response["issuer"], - "exp": 9999999999, # Far future - "iat": 1000000000, - "given_name": "Test User" - } - - # Add token exchange endpoint to existing mock_oidc_server - mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response(200, json={ - "access_token": "mock-access-token", - "id_token": "mock-id-token", - "token_type": "bearer" - }) - ) - - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url # Use the fixture - ) - - mock_request = create_mock_OIDC_request({"code": "test-auth-code"}) - - def mock_jwt_decode(*args, **kwargs): - return mock_jwt_payload - - def mock_jwk_construct(*args, **kwargs): - class MockJWK: - pass - return MockJWK() - - monkeypatch.setattr("jose.jwt.decode", mock_jwt_decode) - monkeypatch.setattr("jose.jwk.construct", mock_jwk_construct) - - # Test authentication - user_session = await authenticator.authenticate(mock_request) - - assert user_session is not None - assert user_session.user_name == "0009-0008-8698-7745" - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_missing_code_parameter(well_known_url: str): - """ - Test OIDCAuthenticator when the 'code' query parameter is missing. - """ - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url # Use the fixture - ) - - mock_request = create_mock_OIDC_request({}) # Empty, no code parameter - - result = await authenticator.authenticate(mock_request) - assert result is None - - -@pytest.mark.asyncio -async def test_OIDCAuthenticator_token_exchange_failure( - well_known_url: str, mock_oidc_server, well_known_response -): - """ - Test OIDCAuthenticator when token exchange fails. - """ - # Mock the token exchange endpoint to return an error - mock_oidc_server.post(well_known_response["token_endpoint"]).mock( - return_value=httpx.Response(400, json={ - 'error': 'invalid_client', - 'error_description': 'Client not found: APP-TEST-CLIENT-ID' - }) - ) - - authenticator = OIDCAuthenticator( - audience="APP-TEST-CLIENT-ID", - client_id="APP-TEST-CLIENT-ID", - client_secret="test-secret", - well_known_uri=well_known_url - ) - - mock_request = create_mock_OIDC_request({"code": "invalid-code"}) - - # This should return None, not raise an exception - result = await authenticator.authenticate(mock_request) - assert result is None diff --git a/tests/test_config.py b/tests/test_config.py index 4082ce44c..2b6145662 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -193,11 +193,11 @@ def test_duplicate_auth_providers(): "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:DummyAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DummyAuthenticator", }, { "provider": "one", - "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DictionaryAuthenticator", }, ] } @@ -205,6 +205,27 @@ def test_duplicate_auth_providers(): ) +def test_legacy_authenticator_import_path_is_supported(): + config = Config.model_validate( + { + "trees": [{"tree": f"{__name__}:tree", "path": "/"}], + "authentication": { + "providers": [ + { + "provider": "legacy", + "authenticator": "tiled.authenticators:DictionaryAuthenticator", + "args": { + "users_to_passwords": {"alice": "secret"}, + }, + } + ], + "secret_keys": ["SECRET"], + }, + } + ) + assert "legacy" in config.authentication.authenticators + + @respx.mock def test_proxied_authenticator_single_instance_required( well_known_response: dict[str, Any] @@ -226,7 +247,7 @@ def test_proxied_authenticator_single_instance_required( "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", @@ -236,7 +257,7 @@ def test_proxied_authenticator_single_instance_required( }, { "provider": "two", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", @@ -268,11 +289,11 @@ def test_proxied_authenticator_is_not_used_with_other_authenticators( "providers": [ { "provider": "one", - "authenticator": "tiled.authenticators:DummyAuthenticator", + "authenticator": "bluesky_authentication.authenticators:DummyAuthenticator", }, { "provider": "two", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled", "client_id": "tiled", diff --git a/tests/test_configs/config_in_memory_authn.yml b/tests/test_configs/config_in_memory_authn.yml index 9377c2a56..07b5986bd 100644 --- a/tests/test_configs/config_in_memory_authn.yml +++ b/tests/test_configs/config_in_memory_authn.yml @@ -8,7 +8,7 @@ uvicorn: authentication: providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: PASSWORD diff --git a/tests/test_configs/config_missing_secret_keys.yml b/tests/test_configs/config_missing_secret_keys.yml index 662405a16..ddcb20a5c 100644 --- a/tests/test_configs/config_missing_secret_keys.yml +++ b/tests/test_configs/config_missing_secret_keys.yml @@ -8,7 +8,7 @@ uvicorn: authentication: providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: secret1 diff --git a/tests/test_configs/config_missing_secret_keys_public.yml b/tests/test_configs/config_missing_secret_keys_public.yml index 0639d3415..c5efeda00 100644 --- a/tests/test_configs/config_missing_secret_keys_public.yml +++ b/tests/test_configs/config_missing_secret_keys_public.yml @@ -11,7 +11,7 @@ authentication: allow_anonymous_access: true providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: secret1 diff --git a/tests/test_configs/config_with_secret_keys.yml b/tests/test_configs/config_with_secret_keys.yml index 438318096..13a4e8196 100644 --- a/tests/test_configs/config_with_secret_keys.yml +++ b/tests/test_configs/config_with_secret_keys.yml @@ -11,7 +11,7 @@ authentication: allow_anonymous_access: true providers: - provider: test - authenticator: tiled.authenticators:DictionaryAuthenticator + authenticator: bluesky_authentication.authenticators:DictionaryAuthenticator args: users_to_passwords: alice: PASSWORD diff --git a/tests/test_device_flow.py b/tests/test_device_flow.py index ca40e9137..097cba567 100644 --- a/tests/test_device_flow.py +++ b/tests/test_device_flow.py @@ -32,7 +32,7 @@ def oidc_config(well_known_url: str) -> Dict[str, Any]: "providers": [ { "provider": "keycloak_oidc", - "authenticator": "tiled.authenticators:ProxiedOIDCAuthenticator", + "authenticator": "bluesky_authentication.authenticators:ProxiedOIDCAuthenticator", "args": { "audience": "tiled_aud", "client_id": "tiled", @@ -250,7 +250,7 @@ def decoded_token(base_url: str) -> Dict[str, Any]: @pytest.fixture -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def client( decode_token: MagicMock, context, @@ -275,7 +275,7 @@ def client( return client -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_whoami_endpoint( decode_token: MagicMock, client, @@ -286,7 +286,7 @@ def test_whoami_endpoint( assert info["identities"][0]["id"] == decoded_token["sub"] -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_client_refresh( decode_token: MagicMock, context, @@ -336,7 +336,7 @@ def test_client_refresh( assert client.context.whoami() == {} -@patch("tiled.authenticators.OIDCAuthenticator.decode_token") +@patch("bluesky_authentication.authenticators.OIDCAuthenticator.decode_token") def test_logout( decode_token: MagicMock, context, diff --git a/tiled/authenticators.py b/tiled/authenticators.py index c3126481c..8c771875a 100644 --- a/tiled/authenticators.py +++ b/tiled/authenticators.py @@ -1,1185 +1,58 @@ -import asyncio -import base64 -import functools -import logging -import re -import secrets -import uuid -from collections.abc import Iterable -from datetime import timedelta -from typing import Any, Dict, List, Mapping, Optional, cast +import warnings -import httpx -from cachetools import TTLCache, cached -from fastapi import APIRouter, Request -from fastapi.security import OAuth2, OAuth2AuthorizationCodeBearer -from jose import JWTError, jwt -from pydantic import Secret -from starlette.responses import RedirectResponse - -from .server.protocols import ( - ExternalAuthenticator, - InternalAuthenticator, - UserSessionState, -) -from .server.utils import get_root_url -from .utils import modules_available - -logger = logging.getLogger(__name__) - - -class DummyAuthenticator(InternalAuthenticator): - """ - For test and demo purposes only! - - Accept any username and any password. - - """ - - def __init__(self, confirmation_message: str = ""): - self.confirmation_message = confirmation_message - - async def authenticate(self, username: str, password: str) -> UserSessionState: - return UserSessionState(username, {}) - - -class DictionaryAuthenticator(InternalAuthenticator): - """ - For test and demo purposes only! - - Check passwords from a dictionary of usernames mapped to passwords. - """ - - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - users_to_password: - type: object - description: | - Mapping usernames to password. Environment variable expansion should be - used to avoid placing passwords directly in configuration. - confirmation_message: - type: string - description: May be displayed by client after successful login. -""" - - def __init__( - self, users_to_passwords: Mapping[str, str], confirmation_message: str = "" - ): - self._users_to_passwords = users_to_passwords - self.confirmation_message = confirmation_message - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - true_password = self._users_to_passwords.get(username) - if not true_password: - # Username is not valid. - return - if secrets.compare_digest(true_password, password): - return UserSessionState(username, {}) - - -class PAMAuthenticator(InternalAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - service: - type: string - description: PAM service. Default is 'login'. - confirmation_message: - type: string - description: May be displayed by client after successful login. -""" - - def __init__(self, service: str = "login", confirmation_message: str = ""): - if not modules_available("pamela"): - raise ModuleNotFoundError( - "This PAMAuthenticator requires the module 'pamela' to be installed." - ) - self.service = service - self.confirmation_message = confirmation_message - # TODO Try to open a PAM session. - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - import pamela - - try: - pamela.authenticate(username, password, service=self.service) - return UserSessionState(username, {}) - except pamela.PAMError: - # Authentication failed. - return - - -class OIDCAuthenticator(ExternalAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - audience: - type: string - client_id: - type: string - client_secret: - type: string - well_known_uri: - type: string - confirmation_message: - type: string - redirect_on_success: - type: string - redirect_on_failure: - type: string -""" - - def __init__( - self, - audience: str, - client_id: str, - client_secret: str, - well_known_uri: str, - confirmation_message: str = "", - redirect_on_success: Optional[str] = None, - redirect_on_failure: Optional[str] = None, - ): - self._audience = audience - self._client_id = client_id - self._client_secret = Secret(client_secret) - self._well_known_url = well_known_uri - self.confirmation_message = confirmation_message - self.redirect_on_success = redirect_on_success - self.redirect_on_failure = redirect_on_failure - - @functools.cached_property - def _config_from_oidc_url(self) -> dict[str, Any]: - response: httpx.Response = httpx.get(self._well_known_url) - response.raise_for_status() - return response.json() - - @functools.cached_property - def client_id(self) -> str: - return self._client_id - - @functools.cached_property - def id_token_signing_alg_values_supported(self) -> list[str]: - return cast( - list[str], - self._config_from_oidc_url.get("id_token_signing_alg_values_supported"), - ) - - @functools.cached_property - def issuer(self) -> str: - return cast(str, self._config_from_oidc_url.get("issuer")) - - @functools.cached_property - def jwks_uri(self) -> str: - return cast(str, self._config_from_oidc_url.get("jwks_uri")) - - @functools.cached_property - def token_endpoint(self) -> str: - return cast(str, self._config_from_oidc_url.get("token_endpoint")) - - @functools.cached_property - def authorization_endpoint(self) -> httpx.URL: - return httpx.URL( - cast(str, self._config_from_oidc_url.get("authorization_endpoint")) - ) - - @functools.cached_property - def device_authorization_endpoint(self) -> str: - return cast( - str, self._config_from_oidc_url.get("device_authorization_endpoint") - ) - - @functools.cached_property - def end_session_endpoint(self) -> str: - return cast(str, self._config_from_oidc_url.get("end_session_endpoint")) - - @cached(TTLCache(maxsize=1, ttl=timedelta(hours=1).total_seconds())) - def keys(self) -> List[str]: - return httpx.get(self.jwks_uri).raise_for_status().json().get("keys", []) - - def decode_token( - self, id_token: str, access_token: Optional[str] = None - ) -> dict[str, Any]: - return jwt.decode( - id_token, - key=self.keys(), - algorithms=self.id_token_signing_alg_values_supported, - audience=self._audience, - issuer=self.issuer, - access_token=access_token, - ) - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - code = request.query_params.get("code") - if not code: - logger.warning( - "Authentication failed: No authorization code parameter provided." - ) - return None - # A proxy in the middle may make the request into something like - # 'http://localhost:8000/...' so we fix the first part but keep - # the original URI path. - redirect_uri = f"{get_root_url(request)}{request.url.path}" - response = await exchange_code( - self.token_endpoint, - code, - self._client_id, - self._client_secret.get_secret_value(), - redirect_uri, - ) - response_body = response.json() - if response.is_error: - logger.error("Authentication error: %r", response_body) - return None - response_body = response.json() - id_token = response_body["id_token"] - access_token = response_body["access_token"] - try: - verified_body = self.decode_token(id_token, access_token) - except JWTError: - logger.exception( - "Authentication error. Unverified token: %r", - jwt.get_unverified_claims(id_token), - ) - return None - return UserSessionState(verified_body["sub"], {}) - - -class ProxiedOIDCAuthenticator(OIDCAuthenticator): - configuration_schema = """ -$schema": http://json-schema.org/draft-07/schema# -type: object -additionalProperties: false -properties: - audience: - type: string - client_id: - type: string - well_known_uri: - type: string - scopes: - type: array - items: - type: string - description: | - Optional list of OAuth2 scopes to request. If provided, authorization - should be enforced by an external policy agent (for example ExternalPolicyDecisionPoint) - rather than by this authenticator. - device_flow_client_id: - type: string - confirmation_message: - type: string -""" - - def __init__( - self, - audience: str, - client_id: str, - well_known_uri: str, - device_flow_client_id: str, - scopes: Optional[List[str]] = None, - confirmation_message: str = "", - ): - super().__init__( - audience=audience, - client_id=client_id, - client_secret="", - well_known_uri=well_known_uri, - confirmation_message=confirmation_message, - ) - self.scopes = scopes - self.device_flow_client_id = device_flow_client_id - self._oidc_bearer = OAuth2AuthorizationCodeBearer( - authorizationUrl=str(self.authorization_endpoint), - tokenUrl=self.token_endpoint, - ) - - @property - def oauth2_schema(self) -> OAuth2: - return self._oidc_bearer - - -class EntraAuthenticator(ProxiedOIDCAuthenticator): - def __init__( - self, - audience: str, - client_id: str, - well_known_uri: str, - device_flow_client_id: str, - extra_scopes: Optional[List[str]] = None, - confirmation_message: str = "", - scopes_map: Optional[Dict[str, list[str]]] = None, - client_secret: str = "", - redirect_on_success: Optional[str] = None, - ): - self.scopes_map = scopes_map if scopes_map is not None else {} - self.extra_scopes = extra_scopes or [] - super().__init__( - audience, - client_id, - well_known_uri, - device_flow_client_id, - scopes=None, # not used by Entra; enforcement is via scopes_map - confirmation_message=confirmation_message, - ) - # Override the empty secret from ProxiedOIDCAuthenticator if provided. - if client_secret: - self._client_secret = Secret(client_secret) - self.redirect_on_success = redirect_on_success - - @property - def scopes(self): - mapped = set() - for tiled_scopes in self.scopes_map.values(): - mapped.update(tiled_scopes) - return list(mapped) - - @scopes.setter - def scopes(self, value): - pass # ignored; scopes are derived from scopes_map - - def decode_token( - self, id_token: str, access_token: Optional[str] = None - ) -> dict[str, Any]: - claims = super().decode_token(id_token, access_token) - - # sub generated by Entra is an opaque string; generate a stable UUID - # for Tiled based on "iss|sub" for uniqueness across tenants. - # Preserve the original Entra sub separately so it can be used as a - # fallback display name — it is more human-readable than the UUID5 hex. - original_sub = claims.get("sub") - issuer = claims.get("iss", "") - claims["sub"] = uuid.uuid5(uuid.NAMESPACE_URL, f"{issuer}|{original_sub}").hex - claims["entra_sub"] = original_sub - - # Derive a human-readable username from the token claims. - # Priority: nameID (explicit app config) → preferred_username (v2 tokens) - # → upn (v1 tokens) → email → original Entra sub (opaque but stable and - # meaningful, unlike the UUID5 hex stored in claims["sub"]). - # - # Note: preferred_username / upn are often absent from *access* tokens - # unless explicitly added as optional claims in the Entra app registration. - # They are typically present in id_tokens. If none are found, the - # original_sub is used and a warning is emitted so operators know to add - # the optional claim. - claims["entra_username"] = ( - claims.get("nameID") - or claims.get("preferred_username") - or claims.get("upn") - or claims.get("email") - ) - - if user := claims.get("entra_username"): - user = user.strip() - if "\\" in user: - user = user.rsplit("\\", 1)[-1] - elif "@" in user: - user = user.split("@", 1)[0] - else: - # No human-readable claim was found. Fall back to the original - # Entra sub (opaque but at least stable and not a UUID5 hex). - # This produces a workable identity but authz policies that match - # on username will need to use the Entra sub value. - user = original_sub - logger.warning( - "EntraAuthenticator: no human-readable username claim found in token " - "(checked nameID, preferred_username, upn, email). " - "Falling back to Entra sub=%r. " - "To fix: add 'preferred_username' as an optional claim in the " - "Entra app registration → Token configuration → Optional claims → Access token.", - original_sub, - ) - claims["user"] = user - - # Translate Entra scopes to tiled scopes. - # The "scp" claim is present in access tokens but may be absent from - # id_tokens (e.g. during the authorization code flow). When absent, - # assume all mapped scopes were granted (Entra would not have issued - # the tokens if the user lacked the requested scopes). - scp_raw = claims.get("scp", "") - tiled_scope_set = set() - if scp_raw: - for scope in scp_raw.split(" "): - mapped_scopes = self.scopes_map.get(scope) - if mapped_scopes is None: - logger.warning("Unmapped Entra scope in 'scp': %s", scope) - continue - tiled_scope_set.update(mapped_scopes) - else: - # No scp claim — grant all tiled scopes from the map. - for mapped_scopes in self.scopes_map.values(): - tiled_scope_set.update(mapped_scopes) - claims["scope"] = " ".join(tiled_scope_set) - - return claims - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - """Complete the Entra OIDC authorization-code flow and return a session. - - After a successful code exchange the Entra ``access_token`` and - ``refresh_token`` are stored in ``UserSessionState.state`` under the - keys ``entra_access_token`` and ``entra_refresh_token`` respectively. - Tiled persists this state in the session DB and embeds it verbatim in - every Tiled HMAC access token, making the tokens available to downstream - services that rely on Tiled authentication via ``get_session_state()``. - - Security note: the Entra access token is therefore visible inside the - Tiled JWT (base64-encoded, not encrypted). The Tiled access token is - short-lived (default 15 min) and only transmitted over HTTPS, which - limits the exposure window. - - The ``refresh_token`` enables silent renewal: when the Entra access - token expires (~1 h), a downstream service can call the Entra token - endpoint with ``grant_type=refresh_token`` to obtain a fresh pair and - write it back to the session DB so subsequent Tiled ``slide_session`` - calls propagate the update automatically. - """ - code = request.query_params.get("code") - if not code: - logger.warning( - "Authentication failed: No authorization code parameter provided." - ) - return None - redirect_uri = f"{get_root_url(request)}{request.url.path}" - response = await exchange_code( - self.token_endpoint, - code, - self._client_id, - self._client_secret.get_secret_value(), - redirect_uri, - extra_scopes=self.extra_scopes, - ) - response_body = response.json() - if response.is_error: - logger.error("Authentication error: %r", response_body) - return None - id_token = response_body["id_token"] - access_token = response_body.get("access_token") - refresh_token = response_body.get("refresh_token") - try: - verified_body = self.decode_token(id_token, access_token) - except JWTError: - logger.exception( - "Authentication error. Unverified token: %r", - jwt.get_unverified_claims(id_token), - ) - return None - # Log the id_token claims available for username resolution so - # misconfigurations (missing optional claims) are easy to diagnose. - # Logged at DEBUG to avoid leaking PII in production logs by default. - logger.debug( - "EntraAuthenticator.authenticate: id_token claims present: %s", - sorted(verified_body.keys()), - ) - logger.debug( - "EntraAuthenticator.authenticate: entra_username=%r user=%r entra_sub=%r", - verified_body.get("entra_username"), - verified_body.get("user"), - verified_body.get("entra_sub"), - ) - # Use the human-readable username (e.g. "dallan") instead of the - # opaque UUID-based "sub" that OIDCAuthenticator would use. - username = verified_body.get("user") or verified_body["sub"] - # Store the Entra access and refresh tokens so that downstream - # services that rely on Tiled authentication can perform an OBO exchange - # to obtain per-user tokens for other services. The refresh token - # allows silent renewal without requiring the user to re-authenticate. - state: dict = {} - if access_token: - state["entra_access_token"] = access_token - if refresh_token: - state["entra_refresh_token"] = refresh_token - return UserSessionState(username, state) - - -async def exchange_code( - token_uri: str, - auth_code: str, - client_id: str, - client_secret: str, - redirect_uri: str, - extra_scopes: Optional[List[str]] = None, -) -> httpx.Response: - """Exchange an authorization code for tokens at the IdP token endpoint. - - Explicitly requests ``openid offline_access`` scopes in the token POST body - so that the IdP returns a ``refresh_token`` unconditionally. This is safe - even when ``offline_access`` was already included in the authorization URL - scope — the IdP simply ignores duplicates. Including it here makes the - refresh token reliable regardless of how the authorization URL was - constructed, which is important for downstream OBO refresh flows. - - ``extra_scopes`` (e.g. ``["api:///access_as_user"]``) are - appended to the scope string. Entra only issues an ``access_token`` whose - ``aud`` matches the requested resource scope, so any scope that a downstream - OBO exchange will use as the ``assertion`` audience **must** be included - here — requesting it only on the authorization URL redirect is not - sufficient, because Entra does not carry scopes from the redirect into the - token POST implicitly. - """ - scopes = {"openid", "offline_access"} - if extra_scopes: - scopes.update(extra_scopes) - auth_value = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode() - response = httpx.post( - url=token_uri, - data={ - "grant_type": "authorization_code", - "client_id": client_id, - "redirect_uri": redirect_uri, - "code": auth_code, - "client_secret": client_secret, - "scope": " ".join(sorted(scopes)), - }, - headers={"Authorization": f"Basic {auth_value}"}, - ) - return response - - -class SAMLAuthenticator(ExternalAuthenticator): - def __init__( - self, - saml_settings, # See EXAMPLE_SAML_SETTINGS below. - attribute_name: str, # which SAML attribute to use as 'id' for Identity - confirmation_message: str = "", - ): - self.saml_settings = saml_settings - self.attribute_name = attribute_name - self.confirmation_message = confirmation_message - self.authorization_endpoint = "/login" - - router = APIRouter() - - if not modules_available("onelogin"): - # The PyPI package name is 'python3-saml' - # but it imports as 'onelogin'. - # https://github.com/onelogin/python3-saml - raise ModuleNotFoundError( - "This SAMLAuthenticator requires 'python3-saml' to be installed." - ) - - from onelogin.saml2.auth import OneLogin_Saml2_Auth - - @router.get("/login") - async def saml_login(request: Request) -> RedirectResponse: - req = await prepare_saml_from_fastapi_request(request) - auth = OneLogin_Saml2_Auth(req, self.saml_settings) - callback_url = auth.login() - return RedirectResponse(url=callback_url) - - self.include_routers = [router] - - async def authenticate(self, request: Request) -> Optional[UserSessionState]: - if not modules_available("onelogin"): - raise ModuleNotFoundError( - "This SAMLAuthenticator requires the module 'oneline' to be installed." - ) - from onelogin.saml2.auth import OneLogin_Saml2_Auth - - req = await prepare_saml_from_fastapi_request(request, True) - auth = OneLogin_Saml2_Auth(req, self.saml_settings) - auth.process_response() # Process IdP response - errors = auth.get_errors() # This method receives an array with the errors - if errors: - raise Exception( - "Error when processing SAML Response: %s %s" - % (", ".join(errors), auth.get_last_error_reason()) - ) - if auth.is_authenticated(): - # Return a string that the Identity can use as id. - attribute_as_list = auth.get_attributes()[self.attribute_name] - # Confused in what situation this would have more than one item.... - assert len(attribute_as_list) == 1 - return UserSessionState(attribute_as_list[0], {}) - else: - return None - - -async def prepare_saml_from_fastapi_request(request: Request) -> Mapping[str, str]: - form_data = await request.form() - rv = { - "http_host": request.client.host, - "server_port": request.url.port, - "script_name": request.url.path, - "post_data": {}, - "get_data": {} - # Advanced request options - # "https": "", - # "request_uri": "", - # "query_string": "", - # "validate_signature_from_qs": False, - # "lowercase_urlencoding": False - } - if request.query_params: - rv["get_data"] = (request.query_params,) - if "SAMLResponse" in form_data: - SAMLResponse = form_data["SAMLResponse"] - rv["post_data"]["SAMLResponse"] = SAMLResponse - if "RelayState" in form_data: - RelayState = form_data["RelayState"] - rv["post_data"]["RelayState"] = RelayState - return rv +def _ensure_shared_package_on_path() -> None: + try: + import bluesky_authentication # noqa: F401 -class LDAPAuthenticator(InternalAuthenticator): - """ - The authenticator code is based on https://github.com/jupyterhub/ldapauthenticator - The parameter ``use_tls`` was added for convenience of testing. + return + except ModuleNotFoundError: + pass - Parameters - ---------- - server_address: str or list(str) - Address(es) of the LDAP server(s) to contact. A string value may represent a single - server, a list of strings may represent one or more servers. If a server address - includes port, then the value of ``server_port`` is ignored, otherwise ``server_port`` - or the default port is used to access the server. + import sys + from pathlib import Path - Could be an IP address or hostname. - server_port: int or None - Port on which to contact the LDAP server. Default port is used if ``None``. + candidate = Path(__file__).resolve().parents[2] / "bluesky-authentication" / "src" + if candidate.exists(): + sys.path.insert(0, str(candidate)) - Defaults to ``636`` if ``use_ssl`` is set, ``389`` otherwise. - use_ssl: boolean - Use SSL to communicate with the LDAP server. - Deprecated in version 3 of LDAP. Your LDAP server must be configured to support this, however. - use_tls: boolean - Enable/disable TLS if ``use_ssl`` is False. By default TLS is enabled. It should not be disabled - in production systems. +_ensure_shared_package_on_path() - connect_timeout: float - Timeout used for connecting to the LDAP server. Default: 5. - - receive_timeout: float - Timeout used for communication with the LDAP server, e.g. this timeout is used to wait for - completion of 2FA. For smooth operation it should probably exceed timeout set at LDAP server. - Default: 60. - - bind_dn_template: list or str - Template from which to construct the full dn - when authenticating to LDAP. ``{username}`` is replaced - with the actual username used to log in. - - If your LDAP is set in such a way that the userdn can not - be formed from a template, but must be looked up with an attribute - (such as uid or ``sAMAccountName``), please see ``lookup_dn``. It might - be particularly relevant for ActiveDirectory installs. - - Unicode Example: - - .. code-block:: - - "uid={username},ou=people,dc=wikimedia,dc=org" - - List Example: - - .. code-block:: - - [ - "uid={username},ou=people,dc=wikimedia,dc=org", - "uid={username},ou=Developers,dc=wikimedia,dc=org" - ] - allowed_groups: list or None - List of LDAP group DNs that users could be members of to be granted access. - - If a user is in any one of the listed groups, then that user is granted access. - Membership is tested by fetching info about each group and looking for the User's - dn to be a value of one of `member` or `uniqueMember`, *or* if the username being - used to log in with is value of the `uid`. - - Set to an empty list or None to allow all users that have an LDAP account to log in, - without performing any group membership checks. - valid_username_regex: str - Regex for validating usernames - those that do not match this regex will be rejected. - - This is primarily used as a measure against LDAP injection, which has fatal security - considerations. The default works for most LDAP installations, but some users might need - to modify it to fit their custom installs. If you are modifying it, be sure to understand - the implications of allowing additional characters in usernames and what that means for - LDAP injection issues. See https://www.owasp.org/index.php/LDAP_injection for an overview - of LDAP injection. - lookup_dn: boolean - Form user's DN by looking up an entry from directory - - By default, LDAPAuthenticator finds the user's DN by using `bind_dn_template`. - However, in some installations, the user's DN does not contain the username, and - hence needs to be looked up. You can set this to True and then use ``user_search_base`` - and ``user_attribute`` to accomplish this. - user_search_base: str - Base for looking up user accounts in the directory, if `lookup_dn` is set to True. - - LDAPAuthenticator will search all objects matching under this base where the `user_attribute` - is set to the current username to form the userdn. - - For example, if all users objects existed under the base ou=people,dc=wikimedia,dc=org, and - the username users use is set with the attribute `uid`, you can use the following config: - - .. code-block:: - - c.LDAPAuthenticator.lookup_dn = True - c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})' - c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account' - c.LDAPAuthenticator.lookup_dn_search_password = 'secret' - c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org' - c.LDAPAuthenticator.user_attribute = 'sAMAccountName' - c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn' - c.LDAPAuthenticator.bind_dn_template = '{username}' - user_attribute: str - Attribute containing user's name, if ``lookup_dn`` is set to True. - - See ``user_search_base`` for info on how this attribute is used. - - For most LDAP servers, this is uid. For Active Directory, it is - sAMAccountName. - lookup_dn_search_filter: str or None - How to query LDAP for user name lookup, if ``lookup_dn`` is set to True. - lookup_dn_search_user: str or None - Technical account for user lookup, if ``lookup_dn`` is set to True. - - If both lookup_dn_search_user and lookup_dn_search_password are None, - then anonymous LDAP query will be done. - lookup_dn_search_password: str or None - Technical account for user lookup, if ``lookup_dn`` is set to True. - lookup_dn_user_dn_attribute: str or None - Attribute containing user's name needed for building DN string, if ``lookup_dn`` is set to True. - - See ``user_search_base`` for info on how this attribute is used. - - For most LDAP servers, this is username. For Active Directory, it is cn. - escape_userdn: boolean - If set to True, escape special chars in userdn when authenticating in LDAP. - - On some LDAP servers, when userdn contains chars like '(', ')', '\' - authentication may fail when those chars - are not escaped. - search_filter: str - LDAP3 Search Filter whose results are allowed access - attributes: list or None - List of attributes to be searched - auth_state_attributes: list or None - List of attributes to be returned in auth_state for a user - use_lookup_dn_username: boolean - If set to true uses the ``lookup_dn_user_dn_attribute`` attribute as username instead of - the supplied one. - - This can be useful in an heterogeneous environment, when supplying a UNIX username - to authenticate against AD. - confirmation_message: str - May be displayed by client after successful login. - - Examples - -------- - - Using the authenticator class (the code runs in ``asyncio`` loop): - - .. code-block:: - - from bluesky_httpserver.authenticators import LDAPAuthenticator - authenticator = LDAPAuthenticator( - "localhost", 1389, bind_dn_template="cn={username},ou=users,dc=example,dc=org", use_tls=False - ) - await authenticator.authenticate("user01", "password1") - await authenticator.authenticate("user02", "password2") - - - Simple example of a config file (e.g. ``config_ldap.yml``): - - .. code-block:: - - uvicorn: - host: localhost - port: 60610 - authentication: - providers: - - provider: ldap_local - authenticator: bluesky_httpserver.authenticators:LDAPAuthenticator - args: - server_address: localhost - server_port: 1389 - bind_dn_template: "cn={username},ou=users,dc=example,dc=org" - use_tls: false - use_ssl: false - tiled_admins: - - provider: ldap_local - id: user02 - """ - - def __init__( - self, - server_address, - server_port=None, - *, - use_ssl=False, - use_tls=True, - connect_timeout=5, - receive_timeout=60, - bind_dn_template=None, - allowed_groups=None, - valid_username_regex=r"^[a-z][.a-z0-9_-]*$", - lookup_dn=False, - user_search_base=None, - user_attribute=None, - lookup_dn_search_filter="({login_attr}={login})", - lookup_dn_search_user=None, - lookup_dn_search_password=None, - lookup_dn_user_dn_attribute=None, - escape_userdn=False, - search_filter="", - attributes=None, - auth_state_attributes=None, - use_lookup_dn_username=True, - confirmation_message="", - ): - self.use_ssl = use_ssl - self.use_tls = use_tls - self.connect_timeout = connect_timeout - self.receive_timeout = receive_timeout - self.bind_dn_template = bind_dn_template - self.allowed_groups = allowed_groups - self.valid_username_regex = valid_username_regex - self.lookup_dn = lookup_dn - self.user_search_base = user_search_base - self.user_attribute = user_attribute - self.lookup_dn_search_filter = lookup_dn_search_filter - self.lookup_dn_search_user = lookup_dn_search_user - self.lookup_dn_search_password = lookup_dn_search_password - self.lookup_dn_user_dn_attribute = lookup_dn_user_dn_attribute - self.escape_userdn = escape_userdn - self.search_filter = search_filter - self.attributes = attributes if attributes else [] - self.auth_state_attributes = ( - auth_state_attributes if auth_state_attributes else [] - ) - self.use_lookup_dn_username = use_lookup_dn_username - - if isinstance(server_address, str): - server_address_list = [server_address] - elif isinstance(server_address, Iterable): - server_address_list = list(server_address) - else: - raise TypeError( - f"Unsupported type of `server_address` (list): server_address={server_address} " - f"type(server_address)={type(server_address)}" - ) - if not server_address_list: - raise ValueError( - "No servers are specified: 'server_address' is an empty list" - ) - - self.server_address_list = server_address_list - self.server_port = ( - server_port if server_port is not None else self._server_port_default() - ) - self.confirmation_message = confirmation_message - - def _server_port_default(self): - if self.use_ssl: - return 636 # default SSL port for LDAP - else: - return 389 # default plaintext port for LDAP - - async def resolve_username(self, username_supplied_by_user): - import ldap3 - - search_dn = self.lookup_dn_search_user - if self.escape_userdn: - search_dn = ldap3.utils.conv.escape_filter_chars(search_dn) - conn = await asyncio.get_running_loop().run_in_executor( - None, self.get_connection, search_dn, self.lookup_dn_search_password - ) - is_bound = await asyncio.get_running_loop().run_in_executor(None, conn.bind) - if not is_bound: - msg = "Failed to connect to LDAP server with search user '{search_dn}'" - self.log.warning(msg.format(search_dn=search_dn)) - return (None, None) - - search_filter = self.lookup_dn_search_filter.format( - login_attr=self.user_attribute, login=username_supplied_by_user - ) - msg = "\n".join( - [ - "Looking up user with:", - " search_base = '{search_base}'", - " search_filter = '{search_filter}'", - " attributes = '{attributes}'", - ] - ) - logger.debug( - msg.format( - search_base=self.user_search_base, - search_filter=search_filter, - attributes=self.user_attribute, - ) - ) - - search_func = functools.partial( - conn.search, - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=search_filter, - attributes=[self.lookup_dn_user_dn_attribute], - ) - await asyncio.get_running_loop().run_in_executor(None, search_func) - - response = conn.response - if len(response) == 0 or "attributes" not in response[0].keys(): - msg = ( - "No entry found for user '{username}' " - "when looking up attribute '{attribute}'" - ) - logger.warning( - msg.format( - username=username_supplied_by_user, attribute=self.user_attribute - ) - ) - return (None, None) - - user_dn = response[0]["attributes"][self.lookup_dn_user_dn_attribute] - if isinstance(user_dn, list): - if len(user_dn) == 0: - return (None, None) - elif len(user_dn) == 1: - user_dn = user_dn[0] - else: - msg = ( - "A lookup of the username '{username}' returned a list " - "of entries for the attribute '{attribute}'. Only the " - "first among these ('{first_entry}') was used. The other " - "entries ({other_entries}) were ignored." - ) - logger.warning( - msg.format( - username=username_supplied_by_user, - attribute=self.lookup_dn_user_dn_attribute, - first_entry=user_dn[0], - other_entries=", ".join(user_dn[1:]), - ) - ) - user_dn = user_dn[0] - - return (user_dn, response[0]["dn"]) - - def get_connection(self, userdn, password): - import ldap3 - - # NOTE: setting 'active=False' essentially disables exclusion of inactive servers from the pool. - # It probably does not matter if the pool contains only one server, but it could have implications - # when there are multiple servers in the pool. It is not clear what those implications are. - # But using the default 'activate=True' results in the thread being blocked indefinitely - # at the step of creating 'ldap3.Connection' regardless of timeouts in case all the servers are - # inactive (e.g. the pool has one server and it is unaccessible), which is unacceptable. - # Further investigation may be needed in the future. - server_pool = ldap3.ServerPool(None, ldap3.RANDOM, active=False) - for address in self.server_address_list: - if re.search(r".+:\d+", address): - # Port is found in the address - address_split = address.split(":") - server_addr = ":".join(address_split[:-1]) - server_port = int(address_split[-1]) - else: - # Use the default port - server_addr = address - server_port = self.server_port - - server = ldap3.Server( - server_addr, - port=server_port, - use_ssl=self.use_ssl, - connect_timeout=self.connect_timeout, - ) - server_pool.add(server) - - auto_bind_no_ssl = ( - ldap3.AUTO_BIND_TLS_BEFORE_BIND if self.use_tls else ldap3.AUTO_BIND_NO_TLS - ) - auto_bind = ldap3.AUTO_BIND_NO_TLS if self.use_ssl else auto_bind_no_ssl - conn = ldap3.Connection( - server_pool, - user=userdn, - password=password, - auto_bind=auto_bind, - receive_timeout=self.receive_timeout, - ) - return conn - - async def get_user_attributes(self, conn, userdn): - attrs = {} - if self.auth_state_attributes: - search_func = functools.partial( - conn.search, - userdn, - "(objectClass=*)", - attributes=self.auth_state_attributes, - ) - found = await asyncio.get_running_loop().run_in_executor(None, search_func) - if found: - attrs = conn.entries[0].entry_attributes_as_dict - return attrs - - async def authenticate( - self, username: str, password: str - ) -> Optional[UserSessionState]: - import ldap3 - - username_saved = username # Save the user name passed as a parameter - - # Protect against invalid usernames as well as LDAP injection attacks - if not re.match(self.valid_username_regex, username): - logger.warning( - "username:%s Illegal characters in username, must match regex %s", - username, - self.valid_username_regex, - ) - return None - - # No empty passwords! - if password is None or password.strip() == "": - logger.warning("username:%s Login denied for blank password", username) - return None - - # bind_dn_template should be of type List[str] - bind_dn_template = self.bind_dn_template - if isinstance(bind_dn_template, str): - bind_dn_template = [bind_dn_template] - - # sanity check - if not self.lookup_dn and not bind_dn_template: - logger.warning( - "Login not allowed, please configure 'lookup_dn' or 'bind_dn_template'." - ) - return None - - if self.lookup_dn: - username, resolved_dn = await self.resolve_username(username) - if not username: - return None - if str(self.lookup_dn_user_dn_attribute).upper() == "CN": - # Only escape commas if the lookup attribute is CN - username = re.subn(r"([^\\]),", r"\1\,", username)[0] - if not bind_dn_template: - bind_dn_template = [resolved_dn] - - is_bound = False - for dn in bind_dn_template: - if not dn: - logger.warning("Ignoring blank 'bind_dn_template' entry!") - continue - userdn = dn.format(username=username) - if self.escape_userdn: - userdn = ldap3.utils.conv.escape_filter_chars(userdn) - msg = "Attempting to bind {username} with {userdn}" - logger.debug(msg.format(username=username, userdn=userdn)) - msg = "Status of user bind {username} with {userdn} : {is_bound}" - try: - conn = await asyncio.get_running_loop().run_in_executor( - None, self.get_connection, userdn, password - ) - except ldap3.core.exceptions.LDAPBindError as exc: - is_bound = False - msg += "\n{exc_type}: {exc_msg}".format( - exc_type=exc.__class__.__name__, - exc_msg=exc.args[0] if exc.args else "", - ) - else: - if conn.bound: - is_bound = True - else: - is_bound = await asyncio.get_running_loop().run_in_executor( - None, conn.bind - ) - - msg = msg.format(username=username, userdn=userdn, is_bound=is_bound) - logger.debug(msg) - if is_bound: - break - - if not is_bound: - msg = "Invalid password for user '{username}'" - logger.warning(msg.format(username=username)) - return None - - if self.search_filter: - search_filter = self.search_filter.format( - userattr=self.user_attribute, username=username - ) - - search_func = functools.partial( - conn.search, - search_base=self.user_search_base, - search_scope=ldap3.SUBTREE, - search_filter=search_filter, - attributes=self.attributes, - ) - await asyncio.get_running_loop().run_in_executor(None, search_func) - - n_users = len(conn.response) - if n_users == 0: - msg = "User with '{userattr}={username}' not found in directory" - logger.warning( - msg.format(userattr=self.user_attribute, username=username) - ) - return None - if n_users > 1: - msg = ( - "Duplicate users found! " - "{n_users} users found with '{userattr}={username}'" - ) - logger.warning( - msg.format( - userattr=self.user_attribute, username=username, n_users=n_users - ) - ) - return None - - if self.allowed_groups: - logger.debug("username:%s Using dn %s", username, userdn) - found = False - for group in self.allowed_groups: - group_filter = ( - "(|" - "(member={userdn})" - "(uniqueMember={userdn})" - "(memberUid={uid})" - ")" - ) - group_filter = group_filter.format(userdn=userdn, uid=username) - group_attributes = ["member", "uniqueMember", "memberUid"] - - search_func = functools.partial( - conn.search, - group, - search_scope=ldap3.BASE, - search_filter=group_filter, - attributes=group_attributes, - ) - found = await asyncio.get_running_loop().run_in_executor( - None, search_func - ) - if found: - break - - if not found: - # If we reach here, then none of the groups matched - msg = "username:{username} User not in any of the allowed groups" - logger.warning(msg.format(username=username)) - return None +warnings.warn( + "Importing authenticators from 'tiled.authenticators' is deprecated and will be " + "removed in a future release. Use 'bluesky_authentication.authenticators' and " + "'bluesky_authentication.protocols' instead.", + DeprecationWarning, + stacklevel=2, +) - if not self.use_lookup_dn_username: - username = username_saved +from bluesky_authentication.authenticators import ( # noqa: F401 + DictionaryAuthenticator, + DummyAuthenticator, + EntraAuthenticator, + LDAPAuthenticator, + OIDCAuthenticator, + PAMAuthenticator, + ProxiedOIDCAuthenticator, + SAMLAuthenticator, +) +from bluesky_authentication.protocols import ( # noqa: F401 + ExternalAuthenticator, + InternalAuthenticator, + UserSessionState, +) - user_info = await self.get_user_attributes(conn, userdn) - if user_info: - logger.debug("username:%s attributes:%s", username, user_info) - # this path might never have been worked out...is it ever hit? - return UserSessionState(username, user_info) - return UserSessionState(username, {}) +__all__ = [ + "DictionaryAuthenticator", + "DummyAuthenticator", + "EntraAuthenticator", + "ExternalAuthenticator", + "InternalAuthenticator", + "LDAPAuthenticator", + "OIDCAuthenticator", + "PAMAuthenticator", + "ProxiedOIDCAuthenticator", + "SAMLAuthenticator", + "UserSessionState", +] diff --git a/tiled/config.py b/tiled/config.py index a94281e88..83abd972c 100644 --- a/tiled/config.py +++ b/tiled/config.py @@ -17,7 +17,7 @@ SettingsConfigDict, ) -from tiled.authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.server.protocols import ExternalAuthenticator, InternalAuthenticator from tiled.type_aliases import AppTask, TaskMap diff --git a/tiled/config_schemas/service_configuration.yml b/tiled/config_schemas/service_configuration.yml index 38d1adc73..671b08129 100644 --- a/tiled/config_schemas/service_configuration.yml +++ b/tiled/config_schemas/service_configuration.yml @@ -179,9 +179,12 @@ properties: description: | Type of Authenticator to use. - These are typically from the tiled.authenticators module, + These are typically from the bluesky_authentication.authenticators module, though user-defined ones may be used as well. + For backward compatibility, import paths from the deprecated + tiled.authenticators module are also supported. + This is given as an import path. In an import path, packages/modules are separated by dots, and the object itself it separated by a colon. diff --git a/tiled/server/app.py b/tiled/server/app.py index 73506b749..259477df5 100644 --- a/tiled/server/app.py +++ b/tiled/server/app.py @@ -37,7 +37,7 @@ ) from ..access_control.protocols import AccessPolicy -from ..authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from ..catalog.adapter import WouldDeleteData from ..config import ( Authentication, diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 5328a7d9b..708f94aa8 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -41,8 +41,8 @@ HTTP_409_CONFLICT, ) +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.access_control.scopes import NO_SCOPES, PUBLIC_SCOPES, SINGLE_USER_SCOPES -from tiled.authenticators import ProxiedOIDCAuthenticator # To hide third-party warning # .../jose/backends/cryptography_backend.py:18: CryptographyDeprecationWarning: diff --git a/tiled/server/protocols.py b/tiled/server/protocols.py index 6df1ac8e6..26902c658 100644 --- a/tiled/server/protocols.py +++ b/tiled/server/protocols.py @@ -1,23 +1,46 @@ -from abc import ABC -from dataclasses import dataclass -from typing import Optional +def _ensure_shared_package_on_path() -> None: + try: + import bluesky_authentication # noqa: F401 -from fastapi import Request + return + except ModuleNotFoundError: + pass + import sys + from pathlib import Path -@dataclass -class UserSessionState: - """Data transfer class to communicate custom session state information.""" + candidate = Path(__file__).resolve().parents[3] / "bluesky-authentication" / "src" + if candidate.exists(): + sys.path.insert(0, str(candidate)) - user_name: str - state: dict = None +_ensure_shared_package_on_path() -class InternalAuthenticator(ABC): - def authenticate(self, username: str, password: str) -> Optional[UserSessionState]: - raise NotImplementedError +try: + from bluesky_authentication.protocols import ( + ExternalAuthenticator, + InternalAuthenticator, + UserSessionState, + ) +except ModuleNotFoundError: + from abc import ABC + from dataclasses import dataclass + from typing import Optional -class ExternalAuthenticator(ABC): - def authenticate(self, request: Request) -> Optional[UserSessionState]: - raise NotImplementedError + from fastapi import Request + + @dataclass + class UserSessionState: + """Data transfer class to communicate custom session state information.""" + + user_name: str + state: dict = None + + class InternalAuthenticator(ABC): + def authenticate(self, username: str, password: str) -> Optional[UserSessionState]: + raise NotImplementedError + + class ExternalAuthenticator(ABC): + def authenticate(self, request: Request) -> Optional[UserSessionState]: + raise NotImplementedError diff --git a/tiled/server/router.py b/tiled/server/router.py index 2d5d96cdb..8170bbec0 100644 --- a/tiled/server/router.py +++ b/tiled/server/router.py @@ -40,7 +40,7 @@ ) from tiled.adapters.protocols import AnyAdapter -from tiled.authenticators import ProxiedOIDCAuthenticator +from bluesky_authentication.authenticators import ProxiedOIDCAuthenticator from tiled.media_type_registration import SerializationRegistry from tiled.query_registration import QueryRegistry from tiled.schemas import About