Skip to content

Conversation

@ZohebShaikh
Copy link
Contributor

@ZohebShaikh ZohebShaikh commented Jan 19, 2026

Option 1: Service account with write access

One approach is to use a service account that is allowed to write.

For example, we could create a service account (e.g. ixx-tiled-writer) that has permissions for the ixx beamline only. This service account would be able to write to all ixx beamline sessions exposed via ixx-blueapi.diamond.ac.uk, but would not be able to write to sessions belonging to other beamlines.We will need to add AuthZ in blueapi to make sure that the person writing to the session has access to it.

This would require creating a dedicated Keycloak client (ixx-tiled-writer) with its own client ID and secret.
This information will be added as a hard coded token claims as

{
  "fedid": "ixx-tiled-writer",
  "permissions": ["ixx-admin"]
}

Downside:
If the client ID and secret are leaked, a malicious actor would gain read/write access to all ixx beamline sessions (though still not delete access). While scoped, this is still a significant risk.

An alternative service-account approach is to not hardcode any beamline permissions into the token at all. In this model, a single service account will have write to any beamline session, and blueapi would perform an explicit authorization check before allowing writes, ensuring the caller has access to the target beamline session.

This is effectively equivalent to an API-key style integration with Tiled.

Security concern:
If this single service account is leaked, the attacker would gain read/write access to all beamline data (again, excluding delete). This is a much larger blast radius and therefore a serious vulnerability.

The core reason this problem exists is that, in this approach, we are not propagating end-user identity ( fedid) through to Tiled.
While this approach is simpler to implement, it is significantly less secure, as a malicious actor could read data that is not intended for them.

Note: I couldn’t find a approach where we encode the fedid that we have got from the blueapi token into the service account token dynamically.(might not be possible because you will be able to impersonate anyone)

Option 2: Token exchange preserving user identity (fedid)

The second approach is more complex but significantly more secure: using Keycloak token exchange to preserve the original user identity and permissions.

In this model, ixx-blueapi uses its client secret to exchange a user access token for another token that is scoped for Tiled. Importantly, the exchanged token retains the same user identity and permissions as the original token.

This requires enabling the following settings on the ixx-blueapi client:

  • "standard.token.exchange.enabled": "true"
  • "standard.token.exchange.enableRefreshRequestedTokenType": "SAME_SESSION"

With token exchange:

  • A valid user access token is required to perform the exchange.
  • The resulting token grants only the permissions the user already has.
  • Even if the ixx-blueapi client secret is leaked, it cannot be used to gain additional access to Tiled data + You will need a access token with a valid session and access token only lasts for 5 mins in identity-test and 1 minute in authn

Implications for blueapi-cli

To support this, the blueapi-cli Keycloak client must behave like other clients(argocd,etc) in the realm. In particular:

  • It must have the same session permissions. ( No offline_access)
  • Users will be logged out after ~30 minutes of idle time.

Because token exchange never creates a new user session, a service account alone cannot be used here. We therefore need a client that can establish a user session.

“Token exchange never creates a new user session. In case that requested_token_type is a refresh token, it may eventually create a new client session in the user session for the requester client (if the client session was not yet created).”

We will need device flow client (ixx-blueapi-cli) with:

  • Audience set to ixx-blueapi (the private client backing ixx-blueapi.diamond.ac.uk)
  • Support for token exchange against ixx-blueapi

I have verified that long scans continue to work correctly: the session remains active because the token is continually exchanged and the user is actively interacting with blueapi, even though this happens in a machine-to-machine style workflow.

Will this have any impact on the GDA side ,as far as I know GDA is per beamline so it could easily have a device flow client per beamline. ?

Testing changes

System tests were updated to use device flow login via Playwright, which opens a browser and performs a real login. This works correctly in CI.

This change was necessary because service accounts do not have user sessions and therefore cannot perform token exchange.

When using Playwright, you must run:

playwright install

Alternatively, you can comment out the Playwright login and perform a manual login using:

blueapi -c tests/system_tests/config-cli.yaml login

References


Related PR in Tiled

There is also a related PR in Tiled adding support for custom authentication in the Tiled client:
bluesky/tiled#1269

@ZohebShaikh ZohebShaikh changed the title Token exchange feat: Token exchange for tiled insertion Jan 19, 2026
@codecov
Copy link

codecov bot commented Jan 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.15%. Comparing base (162ef03) to head (5479b4a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1342      +/-   ##
==========================================
+ Coverage   95.00%   95.15%   +0.14%     
==========================================
  Files          42       43       +1     
  Lines        2765     2848      +83     
==========================================
+ Hits         2627     2710      +83     
  Misses        138      138              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ZohebShaikh ZohebShaikh marked this pull request as ready for review January 21, 2026 10:42
@ZohebShaikh ZohebShaikh requested a review from a team as a code owner January 21, 2026 10:42
Copy link
Contributor

@tpoliaw tpoliaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might take a while to understand properly but I've added a few comments from the first pass through.

From reading this and the keycloak docs I think I'm on board with using token exchange but I'm still trying to figure out how it all fits together.

@@ -0,0 +1,4 @@
CONTEXT_HEADER = "traceparent"
VENDOR_CONTEXT_HEADER = "tracestate"
AUTHORIZAITON_HEADER = "authorization"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
AUTHORIZAITON_HEADER = "authorization"
AUTHORIZATION_HEADER = "authorization"

)
url: HttpUrl = HttpUrl("http://localhost:8407")
api_key: str | None = os.environ.get("TILED_SINGLE_USER_API_KEY", None)
token_exchange_secret: str = Field(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe protect the client secret in case the config ever makes its way into logging etc

Suggested change
token_exchange_secret: str = Field(
token_exchange_secret: SecretStr = Field(

description="Token exchange client secret", default=""
)
token_url: str = Field(default="")
token_exchange_client_id: str = Field(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the client_id of the tiled service? The keycloak docs use the terms requester-client and target-client - could we use something similar here to distinguish which client this should be?

)
url: HttpUrl = HttpUrl("http://localhost:8407")
api_key: str | None = os.environ.get("TILED_SINGLE_USER_API_KEY", None)
token_exchange_secret: str = Field(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these three fields always going to be all present or all absent? Could we make it into a nested TokenExchange | None field that in turn has every field required?



for client in "system-test-blueapi" "ixx-cli-blueapi"; do
for client in "system-test-blueapi" "ixx-cli-blueapi" "ixx-blueapi" "tiled" "tiled-cli"; do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to be a for-loop? It looks like you're looping over the clients and then immediately using a case statement to do something different for each one.

Comment on lines +86 to +87
# ports:
# - 4181:4181
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete?

Comment on lines +156 to +157
match = re.search(r"(https?://\S+)", line)
if match:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
match = re.search(r"(https?://\S+)", line)
if match:
if match := re.search(r"(https?://\S+)", line):

Comment on lines +184 to +194
if configuration.oidc is None:
raise InvalidConfigError(
"Tiled has been configured but oidc configuration is missing "
"this field is required to make authorization decisions."
)
if tiled_conf.token_exchange_secret == "":
raise InvalidConfigError(
"Tiled has been enabled but Token exchange secret has not been set "
"this field is required to enable tiled insertion."
)
tiled_conf.token_url = configuration.oidc.token_endpoint
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean we can't run blueapi against unauthenticated local tiled instances for testing?

if self._refresh_token is None:
raise Exception("Cannot refresh session as no refresh token available")
with self._sync_lock:
response = httpx.post(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to use httpx? I think we're using requests for all other blueapi http stuff (including in this module) and keeping it to a single library would be good if we can. There's an issue to look at moving everything to httpx but having a split for different things is confusing.

Comment on lines +190 to +195
if pass_through_headers is None:
raise ValueError(
"Tiled config is enabled but no "
f"{AUTHORIZAITON_HEADER} header in request"
)
authorization_header_value = pass_through_headers.get(AUTHORIZAITON_HEADER)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't check that the pass through headers contains the auth header. Might as well try and get it and fail if it's not there. Might need to default pass_through_headers if it's None (or make it required?) - numtracker is already needing to do it above.

It would also be good if we could still run blueapi in testing using an unauthenticated instance of tiled. Could we make auth optional if it's not configured then make the error useful if we get a 401 back from tiled?

Suggested change
if pass_through_headers is None:
raise ValueError(
"Tiled config is enabled but no "
f"{AUTHORIZAITON_HEADER} header in request"
)
authorization_header_value = pass_through_headers.get(AUTHORIZAITON_HEADER)
if not (auth_header := pass_through_headers.get(AUTHORIZATION_HEADER)):
raise ValueError(
"Tiled config is enabled but no "
f"{AUTHORIZAITON_HEADER} header in request"
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants