From a70a4b071d1cc37aaedcc1bf643f66f7750e52d5 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:09:35 +0000 Subject: [PATCH 01/11] add service account --- helm/blueapi/config_schema.json | 36 +++++++- helm/blueapi/values.schema.json | 36 +++++++- pyproject.toml | 4 +- src/blueapi/config.py | 16 +++- src/blueapi/core/context.py | 8 ++ src/blueapi/service/authentication.py | 29 +++++- src/blueapi/service/interface.py | 21 +++-- tests/system_tests/compose.yaml | 12 +-- tests/system_tests/config.yaml | 3 + .../services/opa_config/config.yaml | 2 +- .../services/tiled_config/config.yml | 5 -- .../system_tests/services/tiled_config/dls.py | 88 +++++++++++++++++-- tests/system_tests/test_blueapi_system.py | 2 +- tests/unit_tests/core/test_context.py | 4 +- tests/unit_tests/test_config.py | 4 +- uv.lock | 12 +-- 16 files changed, 236 insertions(+), 46 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index d54bc5357b..3fc0583853 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -435,6 +435,34 @@ "type": "object", "$id": "ScratchRepository" }, + "ServiceAccount": { + "additionalProperties": false, + "properties": { + "client_id": { + "default": "", + "description": "Service account client ID", + "title": "Client Id", + "type": "string" + }, + "client_secret": { + "default": "", + "description": "Service account client secret", + "format": "password", + "title": "Client Secret", + "type": "string", + "writeOnly": true + }, + "token_url": { + "default": "", + "description": "Field overridden by OIDCConfig.token_endpoint", + "title": "Token Url", + "type": "string" + } + }, + "title": "ServiceAccount", + "type": "object", + "$id": "ServiceAccount" + }, "StompConfig": { "additionalProperties": false, "description": "Config for connecting to stomp broker", @@ -486,17 +514,21 @@ "title": "Url", "type": "string" }, - "api_key": { + "authentication": { "anyOf": [ { "type": "string" }, + { + "$ref": "ServiceAccount" + }, { "type": "null" } ], "default": null, - "title": "Api Key" + "description": "Tiled Authentication can be API_KEY or OIDC Service account", + "title": "Authentication" } }, "title": "TiledConfig", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 1578c0dad8..7abdafbd14 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -855,6 +855,34 @@ }, "additionalProperties": false }, + "ServiceAccount": { + "$id": "ServiceAccount", + "title": "ServiceAccount", + "type": "object", + "properties": { + "client_id": { + "title": "Client Id", + "description": "Service account client ID", + "default": "", + "type": "string" + }, + "client_secret": { + "title": "Client Secret", + "description": "Service account client secret", + "writeOnly": true, + "default": "", + "type": "string", + "format": "password" + }, + "token_url": { + "title": "Token Url", + "description": "Field overridden by OIDCConfig.token_endpoint", + "default": "", + "type": "string" + } + }, + "additionalProperties": false + }, "StompConfig": { "$id": "StompConfig", "title": "StompConfig", @@ -893,12 +921,16 @@ "title": "TiledConfig", "type": "object", "properties": { - "api_key": { - "title": "Api Key", + "authentication": { + "title": "Authentication", + "description": "Tiled Authentication can be API_KEY or OIDC Service account", "anyOf": [ { "type": "string" }, + { + "$ref": "ServiceAccount" + }, { "type": "null" } diff --git a/pyproject.toml b/pyproject.toml index dda9611ea9..d50b62f3db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ ] description = "Lightweight bluesky-as-a-service wrapper application. Also usable as a library." dependencies = [ - "tiled[client]>=0.2.3", + "tiled[client]@ git+https://github.com/bluesky/tiled.git@main", "bluesky[plotting]>=1.14.0", # plotting includes matplotlib, required for BestEffortCallback in run plans "ophyd-async>=0.13.5", "aioca", @@ -68,7 +68,7 @@ dev = [ "mock", "jwcrypto", "deepdiff", - "tiled[minimal-server]>=0.2.3", # For system-test of dls.py + "tiled[minimal-server]@ git+https://github.com/bluesky/tiled.git@main", # For system-test of dls.py ] [project.scripts] diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 2140168607..225cc9c290 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -16,6 +16,7 @@ BaseModel, Field, HttpUrl, + SecretStr, TypeAdapter, UrlConstraints, ValidationError, @@ -106,13 +107,26 @@ class StompConfig(BlueapiBaseModel): ) +class ServiceAccount(BlueapiBaseModel): + client_id: str = Field(description="Service account client ID", default="") + client_secret: SecretStr = Field( + description="Service account client secret", default=SecretStr("") + ) + token_url: str = Field( + description="Field overridden by OIDCConfig.token_endpoint", default="" + ) + + class TiledConfig(BlueapiBaseModel): enabled: bool = Field( description="True if blueapi should forward data to a Tiled instance", default=False, ) url: HttpUrl = HttpUrl("http://localhost:8407") - api_key: str | None = os.environ.get("TILED_SINGLE_USER_API_KEY", None) + authentication: str | ServiceAccount | None = Field( + description="Tiled Authentication can be API_KEY or OIDC Service account", + default=os.environ.get("TILED_SINGLE_USER_API_KEY", None), + ) class WorkerEventConfig(BlueapiBaseModel): diff --git a/src/blueapi/core/context.py b/src/blueapi/core/context.py index 9bf29aec3d..b6c01c60c8 100644 --- a/src/blueapi/core/context.py +++ b/src/blueapi/core/context.py @@ -30,6 +30,7 @@ DodalSource, EnvironmentConfig, PlanSource, + ServiceAccount, TiledConfig, ) from blueapi.core.protocols import DeviceManager @@ -181,6 +182,13 @@ def _update_scan_num(md: dict[str, Any]) -> int: "Tiled has been configured but `instrument` metadata is not set - " "this field is required to make authorization decisions." ) + if isinstance(tiled_conf.authentication, ServiceAccount): + if configuration.oidc is None: + raise InvalidConfigError( + "Tiled has been configured but oidc configuration is missing " + "this field is required to make authorization decisions." + ) + tiled_conf.authentication.token_url = configuration.oidc.token_endpoint self.tiled_conf = tiled_conf def find_device(self, addr: str | list[str]) -> Device | None: diff --git a/src/blueapi/service/authentication.py b/src/blueapi/service/authentication.py index 77bb0f6c0c..b1bd839d7b 100644 --- a/src/blueapi/service/authentication.py +++ b/src/blueapi/service/authentication.py @@ -2,6 +2,7 @@ import base64 import os +import threading import time import webbrowser from abc import ABC, abstractmethod @@ -10,12 +11,13 @@ from pathlib import Path from typing import Any, cast +import httpx import jwt import requests from pydantic import TypeAdapter from requests.auth import AuthBase -from blueapi.config import OIDCConfig +from blueapi.config import OIDCConfig, ServiceAccount from blueapi.service.model import Cache DEFAULT_CACHE_DIR = "~/.cache/" @@ -239,3 +241,28 @@ def __call__(self, request): if self.token: request.headers["Authorization"] = f"Bearer {self.token}" return request + + +class TiledAuth(httpx.Auth): + def __init__(self, tiled_auth: ServiceAccount): + if tiled_auth.token_url == "": + raise RuntimeError("Token URL is not set please check oidc config") + self._tiled_auth: ServiceAccount = tiled_auth + self._sync_lock = threading.RLock() + + def get_access_token(self): + with self._sync_lock: + response = requests.post( + self._tiled_auth.token_url, + data={ + "client_id": self._tiled_auth.client_id, + "client_secret": self._tiled_auth.client_secret.get_secret_value(), + "grant_type": "client_credentials", + }, + ) + response.raise_for_status() + return response.json().get("access_token") + + def sync_auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self.get_access_token()}" + yield request diff --git a/src/blueapi/service/interface.py b/src/blueapi/service/interface.py index 6acc29ab7e..388761cf0f 100644 --- a/src/blueapi/service/interface.py +++ b/src/blueapi/service/interface.py @@ -8,10 +8,11 @@ from tiled.client import from_uri from blueapi.cli.scratch import get_python_environment -from blueapi.config import ApplicationConfig, OIDCConfig, StompConfig +from blueapi.config import ApplicationConfig, OIDCConfig, ServiceAccount, StompConfig from blueapi.core.context import BlueskyContext from blueapi.core.event import EventStream from blueapi.log import set_up_logging +from blueapi.service.authentication import TiledAuth from blueapi.service.model import ( DeviceModel, PlanModel, @@ -188,11 +189,19 @@ def begin_task( if tiled_config := active_context.tiled_conf: # Tiled queries the root node, so must create an authorized client - tiled_client = from_uri( - str(tiled_config.url), - api_key=tiled_config.api_key, - headers=pass_through_headers, - ) + if isinstance(tiled_config.authentication, ServiceAccount): + tiled_client = from_uri( + str(tiled_config.url), + auth=TiledAuth(tiled_auth=tiled_config.authentication), + headers=pass_through_headers, + ) + else: + tiled_client = from_uri( + str(tiled_config.url), + api_key=tiled_config.authentication, + headers=pass_through_headers, + ) + tiled_writer_token = active_context.run_engine.subscribe( TiledWriter(tiled_client, batch_size=1) ) diff --git a/tests/system_tests/compose.yaml b/tests/system_tests/compose.yaml index f18d1e41c6..90afc19b4b 100644 --- a/tests/system_tests/compose.yaml +++ b/tests/system_tests/compose.yaml @@ -39,16 +39,16 @@ services: retries: 10 start_period: 30s - tiled: - image: ghcr.io/bluesky/tiled:0.2.3 + tiled: + image: ghcr.io/zohebshaikh/tiled:0.10.1 network_mode: host environment: - PYTHONPATH=/deploy/ - volumes: + volumes: - ./services/tiled_config:/deploy/config - command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"] - depends_on: - keycloak: + command: ["tiled", "serve", "config", "--host", "0.0.0.0", "--port", "8407"] + depends_on: + keycloak: condition: service_healthy opa: diff --git a/tests/system_tests/config.yaml b/tests/system_tests/config.yaml index a0a6ed9027..714933ccdf 100644 --- a/tests/system_tests/config.yaml +++ b/tests/system_tests/config.yaml @@ -18,6 +18,9 @@ numtracker: tiled: enabled: true url: http://localhost:8407/api/v1 + authentication: + client_id: "tiled-writer" + client_secret: "secret" oidc: well_known_url: "http://localhost:8081/realms/master/.well-known/openid-configuration" client_id: "ixx-cli-blueapi" diff --git a/tests/system_tests/services/opa_config/config.yaml b/tests/system_tests/services/opa_config/config.yaml index 67f073435d..c64cd0c0a3 100644 --- a/tests/system_tests/services/opa_config/config.yaml +++ b/tests/system_tests/services/opa_config/config.yaml @@ -5,7 +5,7 @@ services: bundles: diamond-policies: service: ghcr - resource: ghcr.io/diamondlightsource/authz-policy:0.0.18 + resource: ghcr.io/zohebshaikh/authz-policy:0.2.0 polling: min_delay_seconds: 30 max_delay_seconds: 120 diff --git a/tests/system_tests/services/tiled_config/config.yml b/tests/system_tests/services/tiled_config/config.yml index 94f82d520c..9bceabb28c 100644 --- a/tests/system_tests/services/tiled_config/config.yml +++ b/tests/system_tests/services/tiled_config/config.yml @@ -1,9 +1,4 @@ -database: - uri: sqlite:////storage/auth.db - init_if_not_exists: true authentication: - # Any HTTP client that can connect can read, an API key is still required to write. - allow_anonymous_access: true providers: - provider: keycloak_oidc authenticator: tiled.authenticators:ProxiedOIDCAuthenticator diff --git a/tests/system_tests/services/tiled_config/dls.py b/tests/system_tests/services/tiled_config/dls.py index d5df5b3db5..ecb7c93ffb 100644 --- a/tests/system_tests/services/tiled_config/dls.py +++ b/tests/system_tests/services/tiled_config/dls.py @@ -1,9 +1,19 @@ import json +import logging from pydantic import BaseModel, HttpUrl, TypeAdapter -from tiled.access_control.access_policies import ExternalPolicyDecisionPoint +from tiled.access_control.access_policies import ( + ALL_ACCESS, + NO_ACCESS, + ExternalPolicyDecisionPoint, + ResultHolder, +) +from tiled.adapters.protocols import BaseAdapter +from tiled.queries import AccessBlobFilter from tiled.server.schemas import Principal, PrincipalType -from tiled.type_aliases import AccessBlob, AccessTags, Scopes +from tiled.type_aliases import AccessBlob, AccessTags, Filters, Scopes + +logger = logging.getLogger(__name__) class DiamondAccessBlob(BaseModel): @@ -17,10 +27,10 @@ def __init__( self, authorization_provider: HttpUrl, token_audience: str, - create_node_endpoint: str = "session/write_to_beamline_visit", + create_node_endpoint: str = "tiled/user_session", allowed_tags_endpoint: str = "tiled/user_sessions", scopes_endpoint: str = "tiled/scopes", - modify_node_endpoint: str | None = None, + modify_node_endpoint: str = "tiled/modify_session", empty_access_blob_public: bool = True, provider: str | None = None, ): @@ -37,6 +47,47 @@ def __init__( empty_access_blob_public=empty_access_blob_public, ) + async def init_node( + self, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + access_blob: AccessBlob | None = None, + ) -> tuple[bool, AccessBlob | None]: + if access_blob is None and self._empty_access_blob_public is not None: + return self._empty_access_blob_public, access_blob + decision = await self._get_external_decision( + self._create_node, + self.build_input(principal, authn_access_tags, authn_scopes, access_blob), + ResultHolder[int], + ) + if decision and decision.result is not None: + return (True, {"tags": [decision.result]}) + raise ValueError("Permission denied not able to add the node") + + async def modify_node( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + access_blob: AccessBlob | None, + ) -> tuple[bool, AccessBlob | None]: + if access_blob == node.access_blob: # type: ignore + logger.info( + "Node access_blob not modified;" + f" access_blob is identical: {access_blob}" + ) + return (False, node.access_blob) # type: ignore + decision = await self._get_external_decision( + self._modify_node, + self.build_input(principal, authn_access_tags, authn_scopes, access_blob), + ResultHolder[bool], + ) + if decision: + return (decision.result, access_blob) + raise ValueError("Permission denied not able to add the node") + def build_input( self, principal: Principal, @@ -44,7 +95,7 @@ def build_input( authn_scopes: Scopes, access_blob: AccessBlob | None = None, ) -> str: - _input = {"audience": self._token_audience} + _input: dict[str, str | int] = {"audience": self._token_audience} if ( principal.type is PrincipalType.external @@ -57,7 +108,30 @@ def build_input( and "tags" in access_blob and len(access_blob["tags"]) > 0 ): - blob = self._type_adapter.validate_json(access_blob["tags"][0]) - _input.update(blob.model_dump()) + if isinstance(tags := access_blob["tags"][0], str): + blob = self._type_adapter.validate_json(tags) + _input.update(blob.model_dump()) + elif isinstance(tags, int): + _input["session"] = str(tags) return json.dumps({"input": _input}) + + async def filters( + self, + node: BaseAdapter, + principal: Principal, + authn_access_tags: AccessTags | None, + authn_scopes: Scopes, + scopes: Scopes, + ) -> Filters: + tags = await self._get_external_decision( + self._user_tags, + self.build_input(principal, authn_access_tags, authn_scopes), + ResultHolder[list[int | str]], + ) + if tags is not None: + if tags.result == ["*"]: + return ALL_ACCESS + return [AccessBlobFilter(tags=tags.result, user_id=None)] # type: ignore + else: + return NO_ACCESS # type: ignore diff --git a/tests/system_tests/test_blueapi_system.py b/tests/system_tests/test_blueapi_system.py index 6c8304cc19..426ba31a17 100644 --- a/tests/system_tests/test_blueapi_system.py +++ b/tests/system_tests/test_blueapi_system.py @@ -585,6 +585,6 @@ def on_event(event: AnyEvent) -> None: with pytest.raises( BlueskyStreamingError, - match="404: No such entry", + match="403: Access policy rejects the provided access blob", ): client_with_stomp.run_task(task, on_event) diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 2f31784aa9..278055256a 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -860,7 +860,7 @@ def test_setup_default_not_makes_tiled_inserter(): @pytest.mark.parametrize("api_key", [None, "foo"]) def test_setup_with_tiled_makes_tiled_inserter(api_key: str | None): - config = TiledConfig(enabled=True, api_key=api_key) + config = TiledConfig(enabled=True, authentication=api_key) context = BlueskyContext( ApplicationConfig( tiled=config, @@ -872,7 +872,7 @@ def test_setup_with_tiled_makes_tiled_inserter(api_key: str | None): @pytest.mark.parametrize("api_key", [None, "foo"]) def test_must_have_instrument_set_for_tiled(api_key: str | None): - config = TiledConfig(enabled=True, api_key=api_key) + config = TiledConfig(enabled=True, authentication=api_key) with pytest.raises(InvalidConfigError): BlueskyContext( ApplicationConfig(tiled=config, env=EnvironmentConfig(metadata=None)) diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py index 26621502a4..30fe551c4c 100644 --- a/tests/unit_tests/test_config.py +++ b/tests/unit_tests/test_config.py @@ -288,7 +288,7 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth": {"username": "guest", "password": "guest"}, }, "tiled": { - "api_key": None, + "authentication": None, "enabled": False, "url": "http://localhost:8407/", }, @@ -345,7 +345,7 @@ def test_config_yaml_parsed(temp_yaml_config_file): "auth": {"username": "guest", "password": "guest"}, }, "tiled": { - "api_key": None, + "authentication": None, "enabled": False, "url": "http://localhost:8407/", }, diff --git a/uv.lock b/uv.lock index 1eb18bb2a0..1746ee2691 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ requires-dist = [ { name = "requests" }, { name = "stomp-py" }, { name = "super-state-machine" }, - { name = "tiled", extras = ["client"], specifier = ">=0.2.3" }, + { name = "tiled", extras = ["client"], git = "https://github.com/bluesky/tiled.git?rev=main" }, { name = "tomlkit" }, { name = "uvicorn" }, ] @@ -521,7 +521,7 @@ dev = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinxcontrib-openapi" }, - { name = "tiled", extras = ["minimal-server"], specifier = ">=0.2.3" }, + { name = "tiled", extras = ["minimal-server"], git = "https://github.com/bluesky/tiled.git?rev=main" }, { name = "tox-uv" }, { name = "types-mock" }, { name = "types-pyyaml" }, @@ -5265,8 +5265,8 @@ wheels = [ [[package]] name = "tiled" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } +version = "0.2.4.dev25+g7f2d80167" +source = { git = "https://github.com/bluesky/tiled.git?rev=main#7f2d80167030694e2d74b249772455728be161f1" } dependencies = [ { name = "httpx" }, { name = "json-merge-patch" }, @@ -5280,10 +5280,6 @@ dependencies = [ { name = "pyyaml" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/1e/cc60843c6f40655718dcf2fc9947e2b360fdfa40d4374db361643c1e5341/tiled-0.2.3.tar.gz", hash = "sha256:ce6a8acc5047e767dcc70520d166b10e0840f3e2b9970a06dcf7f9f9f1fb3f7b", size = 2493279, upload-time = "2025-12-17T20:36:45.781Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/3c/5098fb82017078cd7324b78c34f136d389e44251719f6332abbad455815d/tiled-0.2.3-py3-none-any.whl", hash = "sha256:f926cd097a430739ad0baddb45540aaeeb868eba9650100e9ec0200ddb866ba5", size = 1846157, upload-time = "2025-12-17T20:36:42.93Z" }, -] [package.optional-dependencies] client = [ From 224862a9714c04567c5f0b2980e29ccd1ddd838b Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:16:34 +0000 Subject: [PATCH 02/11] Add service account to keycloak --- .../keycloak_config/service-account.json | 51 +++++++++++++++++++ .../services/keycloak_config/startup.sh | 19 +++++-- 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/system_tests/services/keycloak_config/service-account.json diff --git a/tests/system_tests/services/keycloak_config/service-account.json b/tests/system_tests/services/keycloak_config/service-account.json new file mode 100644 index 0000000000..f7e58d4da5 --- /dev/null +++ b/tests/system_tests/services/keycloak_config/service-account.json @@ -0,0 +1,51 @@ +{ + "protocolMappers": [ + { + "name": "subject", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "claim.value": "{\"permissions\":[],\"proposals\":[12345],\"sessions\":[]}", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "subject", + "jsonType.label": "JSON", + "access.tokenResponse.claim": "false" + } + }, + { + "name": "fedid", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "claim.value": " ", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "lightweight.claim": "false", + "access.token.claim": "true", + "claim.name": "fedid", + "jsonType.label": "String", + "access.tokenResponse.claim": "false" + } + }, + { + "name": "tiled", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "false", + "lightweight.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true", + "included.custom.audience": "tiled-writer" + } + } + ] +} diff --git a/tests/system_tests/services/keycloak_config/startup.sh b/tests/system_tests/services/keycloak_config/startup.sh index 1499f628b1..d7c555c8bf 100644 --- a/tests/system_tests/services/keycloak_config/startup.sh +++ b/tests/system_tests/services/keycloak_config/startup.sh @@ -34,8 +34,13 @@ create_client() { echo ">> Creating $client_id..." local tmpfile=$(mktemp) - # Use sed to replace placeholders in the JSON template - sed "s/__AUDIENCE__/$aud/g; s/__CLAIM_VALUE__/alice/g" "$TEMPLATE" > "$tmpfile" + if [[ "$client_id" == "tiled-writer" ]]; then + # Just copy your predefined file + cp /tmp/config/service-account.json "$tmpfile" + else + # Use sed to replace placeholders in the JSON template + sed "s/__AUDIENCE__/$aud/g; s/__CLAIM_VALUE__/alice/g" "$TEMPLATE" > "$tmpfile" + fi kcreg.sh create -x -s clientId="$client_id" -f "$tmpfile" "$@" rm "$tmpfile" @@ -45,18 +50,18 @@ create_client() { # System Test create_client "system-test-blueapi" "ixx-blueapi" \ - -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' -s attributes='{"access.token.lifespan":"86400"}' + -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' # ixx CLI create_client "ixx-cli-blueapi" "ixx-blueapi" \ -s standardFlowEnabled=false -s publicClient=true -s 'redirectUris=["/*"]' \ - -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true","access.token.lifespan":"86400"}' + -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' # ixx BlueAPI create_client "ixx-blueapi" "ixx-blueapi" \ -s standardFlowEnabled=true -s secret="blueapi-secret" -s rootUrl="http://localhost:4180" \ -s 'redirectUris=["http://localhost:4180/*"]' \ - -s 'attributes={"frontchannel.logout.session.required":"true","use.refresh.tokens":"true","access.token.lifespan":"86400"}' + -s 'attributes={"frontchannel.logout.session.required":"true","use.refresh.tokens":"true"}' # Tiled create_client "tiled" "tiled" \ @@ -67,3 +72,7 @@ create_client "tiled" "tiled" \ create_client "tiled-cli" "tiled" \ -s standardFlowEnabled=false -s publicClient=true -s 'redirectUris=["/*"]' \ -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' + +# Service account tiled-writer +create_client "system-test-blueapi" "" \ + -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' From 93be2bd83d271f512ce035743a5360c4aeb24a40 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:30:24 +0000 Subject: [PATCH 03/11] Change client name --- tests/system_tests/services/keycloak_config/startup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system_tests/services/keycloak_config/startup.sh b/tests/system_tests/services/keycloak_config/startup.sh index d7c555c8bf..4f933355d7 100644 --- a/tests/system_tests/services/keycloak_config/startup.sh +++ b/tests/system_tests/services/keycloak_config/startup.sh @@ -74,5 +74,5 @@ create_client "tiled-cli" "tiled" \ -s 'attributes={"frontchannel.logout.session.required":"true","oauth2.device.authorization.grant.enabled":"true","use.refresh.tokens":"true","backchannel.logout.session.required":"true"}' # Service account tiled-writer -create_client "system-test-blueapi" "" \ +create_client "tiled-writer" "" \ -s secret="secret" -s standardFlowEnabled=false -s serviceAccountsEnabled=true -s 'redirectUris=["/*"]' From d8346da1ae9fe91feef7800e2936938f11f6ea21 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:03:35 +0000 Subject: [PATCH 04/11] add unit tests --- tests/unit_tests/core/test_context.py | 16 +++++++ .../unit_tests/service/test_authentication.py | 47 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 278055256a..c02b104b80 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -40,6 +40,7 @@ EnvironmentConfig, MetadataConfig, PlanSource, + ServiceAccount, TiledConfig, ) from blueapi.core import BlueskyContext, is_bluesky_compatible_device @@ -877,3 +878,18 @@ def test_must_have_instrument_set_for_tiled(api_key: str | None): BlueskyContext( ApplicationConfig(tiled=config, env=EnvironmentConfig(metadata=None)) ) + + +def test_must_have_oidc_config_for_tiled(): + config = TiledConfig(enabled=True, authentication=ServiceAccount()) + with pytest.raises( + InvalidConfigError, + match="Tiled has been configured but oidc configuration is missing", + ): + BlueskyContext( + ApplicationConfig( + tiled=config, + env=EnvironmentConfig(metadata=MetadataConfig(instrument="ixx")), + oidc=None, + ) + ) diff --git a/tests/unit_tests/service/test_authentication.py b/tests/unit_tests/service/test_authentication.py index 281c2be035..502b73ca2c 100644 --- a/tests/unit_tests/service/test_authentication.py +++ b/tests/unit_tests/service/test_authentication.py @@ -6,11 +6,16 @@ import jwt import pytest import responses +from pydantic import SecretStr from starlette.status import HTTP_403_FORBIDDEN -from blueapi.config import OIDCConfig +from blueapi.config import OIDCConfig, ServiceAccount from blueapi.service import main -from blueapi.service.authentication import SessionCacheManager, SessionManager +from blueapi.service.authentication import ( + SessionCacheManager, + SessionManager, + TiledAuth, +) @pytest.fixture @@ -137,3 +142,41 @@ def test_session_cache_manager_returns_writable_file_path(tmp_path): Path(cache._file_path).touch() assert os.path.isfile(cache._file_path) assert cache._file_path == f"{tmp_path}/blueapi_cache" + + +def test_tiled_auth_raises_exception(): + with pytest.raises( + RuntimeError, match="Token URL is not set please check oidc config" + ): + auth = ServiceAccount() + TiledAuth(tiled_auth=auth) + + +def test_tiled_auth_sync_auth_flow(): + client_id = "client" + client_secret = SecretStr("secret") + token_url = "http://keycloak.com/token" + access_token = "access_token" + + with responses.RequestsMock(assert_all_requests_are_fired=True) as requests_mock: + requests_mock.post( + url=token_url, + json={"access_token": access_token}, + status=200, + ) + + tiled_auth = TiledAuth( + tiled_auth=ServiceAccount( + client_id=client_id, + client_secret=client_secret, + token_url=token_url, + ) + ) + + request = Mock() + request.headers = {} + + flow = tiled_auth.sync_auth_flow(request) + result = next(flow) + + assert result.headers["Authorization"] == f"Bearer {access_token}" From 25f780c259cf312d7e3e7c533aa6d625319f50b6 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:25:11 +0000 Subject: [PATCH 05/11] Add unit test --- src/blueapi/service/interface.py | 1 - tests/unit_tests/core/test_context.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/blueapi/service/interface.py b/src/blueapi/service/interface.py index 388761cf0f..c8b1e07245 100644 --- a/src/blueapi/service/interface.py +++ b/src/blueapi/service/interface.py @@ -193,7 +193,6 @@ def begin_task( tiled_client = from_uri( str(tiled_config.url), auth=TiledAuth(tiled_auth=tiled_config.authentication), - headers=pass_through_headers, ) else: tiled_client = from_uri( diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index c02b104b80..3ebff721d0 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -7,6 +7,7 @@ from unittest.mock import ANY, MagicMock, Mock, patch import pytest +import responses from bluesky.protocols import ( Descriptor, Movable, @@ -39,6 +40,7 @@ DodalSource, EnvironmentConfig, MetadataConfig, + OIDCConfig, PlanSource, ServiceAccount, TiledConfig, @@ -893,3 +895,18 @@ def test_must_have_oidc_config_for_tiled(): oidc=None, ) ) + + +def test_token_url_set_for_tiled( + mock_authn_server: responses.RequestsMock, oidc_config: OIDCConfig +): + config = TiledConfig(enabled=True, authentication=ServiceAccount()) + + context = BlueskyContext( + ApplicationConfig( + tiled=config, + env=EnvironmentConfig(metadata=MetadataConfig(instrument="ixx")), + oidc=oidc_config, + ) + ) + assert context.tiled_conf.authentication.token_url == oidc_config.token_endpoint From 521013b68ac08a958c30ef4647c9f9b749a1a3ff Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:33:29 +0000 Subject: [PATCH 06/11] Add type ignore --- tests/unit_tests/core/test_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/core/test_context.py b/tests/unit_tests/core/test_context.py index 3ebff721d0..9ac94482cf 100644 --- a/tests/unit_tests/core/test_context.py +++ b/tests/unit_tests/core/test_context.py @@ -909,4 +909,4 @@ def test_token_url_set_for_tiled( oidc=oidc_config, ) ) - assert context.tiled_conf.authentication.token_url == oidc_config.token_endpoint + assert context.tiled_conf.authentication.token_url == oidc_config.token_endpoint # type:ignore From 9f2eaa78d26215de260774f004a37456e49678ea Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:47:55 +0000 Subject: [PATCH 07/11] Update tiled version --- tests/system_tests/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system_tests/compose.yaml b/tests/system_tests/compose.yaml index 90afc19b4b..b3dfdeadfc 100644 --- a/tests/system_tests/compose.yaml +++ b/tests/system_tests/compose.yaml @@ -40,7 +40,7 @@ services: start_period: 30s tiled: - image: ghcr.io/zohebshaikh/tiled:0.10.1 + image: ghcr.io/bluesky/tiled:0.2.4 network_mode: host environment: - PYTHONPATH=/deploy/ From 2037c275e5d49e41da74fca1c144bcf36df315ba Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:53:09 +0000 Subject: [PATCH 08/11] Update tiled --- pyproject.toml | 4 ++-- uv.lock | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d50b62f3db..9ce698aece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ classifiers = [ ] description = "Lightweight bluesky-as-a-service wrapper application. Also usable as a library." dependencies = [ - "tiled[client]@ git+https://github.com/bluesky/tiled.git@main", + "tiled[client]>=0.2.4", "bluesky[plotting]>=1.14.0", # plotting includes matplotlib, required for BestEffortCallback in run plans "ophyd-async>=0.13.5", "aioca", @@ -68,7 +68,7 @@ dev = [ "mock", "jwcrypto", "deepdiff", - "tiled[minimal-server]@ git+https://github.com/bluesky/tiled.git@main", # For system-test of dls.py + "tiled[minimal-server]>=0.2.4", # For system-test of dls.py ] [project.scripts] diff --git a/uv.lock b/uv.lock index 1746ee2691..6b47e75f26 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ requires-dist = [ { name = "requests" }, { name = "stomp-py" }, { name = "super-state-machine" }, - { name = "tiled", extras = ["client"], git = "https://github.com/bluesky/tiled.git?rev=main" }, + { name = "tiled", extras = ["client"], specifier = ">=0.2.4" }, { name = "tomlkit" }, { name = "uvicorn" }, ] @@ -521,7 +521,7 @@ dev = [ { name = "sphinx-copybutton" }, { name = "sphinx-design" }, { name = "sphinxcontrib-openapi" }, - { name = "tiled", extras = ["minimal-server"], git = "https://github.com/bluesky/tiled.git?rev=main" }, + { name = "tiled", extras = ["minimal-server"], specifier = ">=0.2.4" }, { name = "tox-uv" }, { name = "types-mock" }, { name = "types-pyyaml" }, @@ -5265,8 +5265,8 @@ wheels = [ [[package]] name = "tiled" -version = "0.2.4.dev25+g7f2d80167" -source = { git = "https://github.com/bluesky/tiled.git?rev=main#7f2d80167030694e2d74b249772455728be161f1" } +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "json-merge-patch" }, @@ -5280,6 +5280,10 @@ dependencies = [ { name = "pyyaml" }, { name = "typer" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/f6/42/31d55c627a912462e11aed1912fe1737f2a3c9ae0f347cb25ab05056e8af/tiled-0.2.4.tar.gz", hash = "sha256:406be3fd9303e3343243714a4a7611138ef8b3e0f00cc2ffdacd8af3f1a3e6c5", size = 2500704, upload-time = "2026-02-18T13:37:50.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2c/e631f7d8f24d774cba4cc392265e6d53df1b99e48ace6390330c653c887e/tiled-0.2.4-py3-none-any.whl", hash = "sha256:4cf3c82e5578d793f31f5471d95fdfb58f74c3db5a1310df0a781cfb8c226db7", size = 1702254, upload-time = "2026-02-18T13:37:47.678Z" }, +] [package.optional-dependencies] client = [ From 009397acc45360bc6def80c079d3ded7d0e5390e Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:23:46 +0000 Subject: [PATCH 09/11] fedid check --- .../keycloak_config/service-account.json | 17 ----------------- .../services/opa_config/config.yaml | 2 +- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/tests/system_tests/services/keycloak_config/service-account.json b/tests/system_tests/services/keycloak_config/service-account.json index f7e58d4da5..91804980a5 100644 --- a/tests/system_tests/services/keycloak_config/service-account.json +++ b/tests/system_tests/services/keycloak_config/service-account.json @@ -17,23 +17,6 @@ "access.tokenResponse.claim": "false" } }, - { - "name": "fedid", - "protocol": "openid-connect", - "protocolMapper": "oidc-hardcoded-claim-mapper", - "consentRequired": false, - "config": { - "introspection.token.claim": "true", - "claim.value": " ", - "userinfo.token.claim": "true", - "id.token.claim": "true", - "lightweight.claim": "false", - "access.token.claim": "true", - "claim.name": "fedid", - "jsonType.label": "String", - "access.tokenResponse.claim": "false" - } - }, { "name": "tiled", "protocol": "openid-connect", diff --git a/tests/system_tests/services/opa_config/config.yaml b/tests/system_tests/services/opa_config/config.yaml index c64cd0c0a3..e747da019d 100644 --- a/tests/system_tests/services/opa_config/config.yaml +++ b/tests/system_tests/services/opa_config/config.yaml @@ -5,7 +5,7 @@ services: bundles: diamond-policies: service: ghcr - resource: ghcr.io/zohebshaikh/authz-policy:0.2.0 + resource: ghcr.io/zohebshaikh/authz-policy:0.2.2 polling: min_delay_seconds: 30 max_delay_seconds: 120 From 2ab3e985c432837e03f474b024eca86635ebce99 Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:45:25 +0000 Subject: [PATCH 10/11] remove comments --- tests/system_tests/services/keycloak_config/startup.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/system_tests/services/keycloak_config/startup.sh b/tests/system_tests/services/keycloak_config/startup.sh index 4f933355d7..6ca37c2953 100644 --- a/tests/system_tests/services/keycloak_config/startup.sh +++ b/tests/system_tests/services/keycloak_config/startup.sh @@ -35,7 +35,6 @@ create_client() { local tmpfile=$(mktemp) if [[ "$client_id" == "tiled-writer" ]]; then - # Just copy your predefined file cp /tmp/config/service-account.json "$tmpfile" else # Use sed to replace placeholders in the JSON template From a7095e16cbcea35abe28fd9792f2bf8d282f4b7d Mon Sep 17 00:00:00 2001 From: Zoheb Shaikh <26975142+ZohebShaikh@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:12:24 +0000 Subject: [PATCH 11/11] add code review comments --- helm/blueapi/config_schema.json | 6 ---- helm/blueapi/values.schema.json | 6 ---- pyproject.toml | 1 + src/blueapi/config.py | 3 +- src/blueapi/service/authentication.py | 2 +- .../system_tests/services/tiled_config/dls.py | 26 +++++++++++++- .../unit_tests/service/test_authentication.py | 36 ++++++++++--------- uv.lock | 14 ++++++++ 8 files changed, 62 insertions(+), 32 deletions(-) diff --git a/helm/blueapi/config_schema.json b/helm/blueapi/config_schema.json index 3fc0583853..6a7b3d33c0 100644 --- a/helm/blueapi/config_schema.json +++ b/helm/blueapi/config_schema.json @@ -451,12 +451,6 @@ "title": "Client Secret", "type": "string", "writeOnly": true - }, - "token_url": { - "default": "", - "description": "Field overridden by OIDCConfig.token_endpoint", - "title": "Token Url", - "type": "string" } }, "title": "ServiceAccount", diff --git a/helm/blueapi/values.schema.json b/helm/blueapi/values.schema.json index 7abdafbd14..53d40a70b1 100644 --- a/helm/blueapi/values.schema.json +++ b/helm/blueapi/values.schema.json @@ -873,12 +873,6 @@ "default": "", "type": "string", "format": "password" - }, - "token_url": { - "title": "Token Url", - "description": "Field overridden by OIDCConfig.token_endpoint", - "default": "", - "type": "string" } }, "additionalProperties": false diff --git a/pyproject.toml b/pyproject.toml index 9ce698aece..0f441c7dc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ dev = [ "jwcrypto", "deepdiff", "tiled[minimal-server]>=0.2.4", # For system-test of dls.py + "respx" ] [project.scripts] diff --git a/src/blueapi/config.py b/src/blueapi/config.py index 225cc9c290..09b19666ef 100644 --- a/src/blueapi/config.py +++ b/src/blueapi/config.py @@ -22,6 +22,7 @@ ValidationError, field_validator, ) +from pydantic.json_schema import SkipJsonSchema from blueapi.utils import BlueapiBaseModel, InvalidConfigError @@ -112,7 +113,7 @@ class ServiceAccount(BlueapiBaseModel): client_secret: SecretStr = Field( description="Service account client secret", default=SecretStr("") ) - token_url: str = Field( + token_url: SkipJsonSchema[str] = Field( description="Field overridden by OIDCConfig.token_endpoint", default="" ) diff --git a/src/blueapi/service/authentication.py b/src/blueapi/service/authentication.py index b1bd839d7b..c0a32b26f4 100644 --- a/src/blueapi/service/authentication.py +++ b/src/blueapi/service/authentication.py @@ -252,7 +252,7 @@ def __init__(self, tiled_auth: ServiceAccount): def get_access_token(self): with self._sync_lock: - response = requests.post( + response = httpx.post( self._tiled_auth.token_url, data={ "client_id": self._tiled_auth.client_id, diff --git a/tests/system_tests/services/tiled_config/dls.py b/tests/system_tests/services/tiled_config/dls.py index ecb7c93ffb..85aa1cc6a7 100644 --- a/tests/system_tests/services/tiled_config/dls.py +++ b/tests/system_tests/services/tiled_config/dls.py @@ -1,7 +1,11 @@ import json import logging +from fastapi import HTTPException from pydantic import BaseModel, HttpUrl, TypeAdapter +from starlette.status import ( + HTTP_401_UNAUTHORIZED, +) from tiled.access_control.access_policies import ( ALL_ACCESS, NO_ACCESS, @@ -22,6 +26,22 @@ class DiamondAccessBlob(BaseModel): beamline: str +def _check_principal(principal: Principal | None): + if not isinstance(principal, Principal): + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Principal is None", + headers={"WWW-Authenticate": "Bearer"}, + ) + if principal.type != PrincipalType.external: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail=f"Principal of type {PrincipalType.external}" + f" required but given {principal.type}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + class DiamondOpenPolicyAgentAuthorizationPolicy(ExternalPolicyDecisionPoint): def __init__( self, @@ -54,6 +74,7 @@ async def init_node( authn_scopes: Scopes, access_blob: AccessBlob | None = None, ) -> tuple[bool, AccessBlob | None]: + _check_principal(principal) if access_blob is None and self._empty_access_blob_public is not None: return self._empty_access_blob_public, access_blob decision = await self._get_external_decision( @@ -73,6 +94,7 @@ async def modify_node( authn_scopes: Scopes, access_blob: AccessBlob | None, ) -> tuple[bool, AccessBlob | None]: + _check_principal(principal) if access_blob == node.access_blob: # type: ignore logger.info( "Node access_blob not modified;" @@ -98,7 +120,8 @@ def build_input( _input: dict[str, str | int] = {"audience": self._token_audience} if ( - principal.type is PrincipalType.external + isinstance(principal, Principal) + and principal.type is PrincipalType.external and principal.access_token is not None ): _input["token"] = principal.access_token.get_secret_value() @@ -124,6 +147,7 @@ async def filters( authn_scopes: Scopes, scopes: Scopes, ) -> Filters: + _check_principal(principal) tags = await self._get_external_decision( self._user_tags, self.build_input(principal, authn_access_tags, authn_scopes), diff --git a/tests/unit_tests/service/test_authentication.py b/tests/unit_tests/service/test_authentication.py index 502b73ca2c..88227706be 100644 --- a/tests/unit_tests/service/test_authentication.py +++ b/tests/unit_tests/service/test_authentication.py @@ -3,11 +3,13 @@ from typing import Any from unittest.mock import Mock, patch +import httpx import jwt import pytest import responses +import respx from pydantic import SecretStr -from starlette.status import HTTP_403_FORBIDDEN +from starlette.status import HTTP_200_OK, HTTP_403_FORBIDDEN from blueapi.config import OIDCConfig, ServiceAccount from blueapi.service import main @@ -152,31 +154,31 @@ def test_tiled_auth_raises_exception(): TiledAuth(tiled_auth=auth) +@respx.mock def test_tiled_auth_sync_auth_flow(): client_id = "client" client_secret = SecretStr("secret") token_url = "http://keycloak.com/token" access_token = "access_token" - with responses.RequestsMock(assert_all_requests_are_fired=True) as requests_mock: - requests_mock.post( - url=token_url, - json={"access_token": access_token}, - status=200, + respx.post(token_url).mock( + return_value=httpx.Response( + status_code=HTTP_200_OK, json={"access_token": access_token} ) + ) - tiled_auth = TiledAuth( - tiled_auth=ServiceAccount( - client_id=client_id, - client_secret=client_secret, - token_url=token_url, - ) + tiled_auth = TiledAuth( + tiled_auth=ServiceAccount( + client_id=client_id, + client_secret=client_secret, + token_url=token_url, ) + ) - request = Mock() - request.headers = {} + request = Mock() + request.headers = {} - flow = tiled_auth.sync_auth_flow(request) - result = next(flow) + flow = tiled_auth.sync_auth_flow(request) + result = next(flow) - assert result.headers["Authorization"] == f"Bearer {access_token}" + assert result.headers["Authorization"] == f"Bearer {access_token}" diff --git a/uv.lock b/uv.lock index 6b47e75f26..f1bfac99f5 100644 --- a/uv.lock +++ b/uv.lock @@ -458,6 +458,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "responses" }, + { name = "respx" }, { name = "ruff" }, { name = "semver" }, { name = "sphinx-autobuild" }, @@ -514,6 +515,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "responses" }, + { name = "respx" }, { name = "ruff" }, { name = "semver" }, { name = "sphinx-autobuild", specifier = ">=2024.4.16" }, @@ -4469,6 +4471,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" }, ] +[[package]] +name = "respx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" }, +] + [[package]] name = "rich" version = "14.3.2"