Skip to content

Commit d97ea0e

Browse files
committed
Issue test tokens from a local Keycloak realm
Replace the bespoke oauth2-mock-server image with Keycloak. The mock was built for this suite alone; Keycloak is a conformant OpenID Connect server that flotilla and sara can also be run against for local development, and that an operator deploying outside Azure could use in earnest. custom_realms/robotics-realm.json is the single realm definition, mounted both here and by those repositories' compose files, so a local run and a CI run exercise the same clients, scopes and roles. Keycloak does not derive a token's audience from the requested scope the way Entra does, so each API gets a client scope carrying one audience mapper: isar-api yields aud=isar-test. Exactly one such scope may be requested per token -- two audience mappers make Keycloak emit aud as an array, which fastapi-azure-auth rejects outright. Three protocol mappers exist only to satisfy that library's Entra-shaped token model: a hardcoded ver, a hardcoded nbf (Keycloak emits none and the library requires it) and a flat roles claim, since the default nested realm_access.roles maps to neither ISAR's User.roles nor .NET's ClaimTypes.Role. Keycloak has no equivalent of the mock's /issue-token, so a role set is chosen by picking the service account that holds it. The negative assertions gain a client holding Role.User.HUA alone, kept distinct from the client with no roles because Flotilla answers 403 for an insufficient role but 401 for a token carrying none. Two things the spike caught. Waiting on the discovery document is not enough: it answers while clients are still importing, and a token minted in that window comes back 401, so readiness now mints a real token. And the client credentials are constants rather than settings, because a setting named INTEGRATION_TESTS_CLIENT_SECRET is silently overridden by the stale Entra value of that name still sitting in developers' .env files -- which presents only as an unexplained 401.
1 parent 694cdb2 commit d97ea0e

18 files changed

Lines changed: 810 additions & 486 deletions

File tree

.github/workflows/run_integration_tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ on:
1515
Azure client secret for the service principal with read access to the
1616
integration-test key vault, which now holds only the MQTT credentials.
1717
No longer used for authentication between the services: those tokens come
18-
from a local OAuth2 mock issuer. Kept optional rather than removed because
18+
from a local Keycloak realm. Kept optional rather than removed because
1919
this workflow is consumed at @main by every robotics repo and each one
2020
declares it; removing it outright would break them all at once.
2121
@@ -32,7 +32,7 @@ permissions:
3232

3333
env:
3434
# Only MQTT credentials are still read from the key vault. Authentication between
35-
# the services is handled by a local OAuth2 mock issuer started by the test suite,
35+
# the services is handled by a local Keycloak realm started by the test suite,
3636
# so no Entra ID app registrations are involved.
3737
KEYVAULT_NAME: FlotillaTestsKv
3838
AZURE_TENANT_ID: 3aa4a235-b6e2-48d5-9195-7fcf05b459b0
@@ -66,7 +66,7 @@ jobs:
6666
# against the hashed passwd_file committed in equinor/flotilla, so these
6767
# remain real secrets. The app-registration client secrets that used to
6868
# be fetched here are gone: service-to-service auth now runs against a
69-
# local OAuth2 mock issuer.
69+
# local Keycloak realm.
7070
secrets=(
7171
FLOTILLA-MQTT-PASSWORD
7272
FLOTILLA-BROKER-SERVER-KEY

README.md

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,58 @@ The following components are currently included in the integration tests:
1212
- Azure Blob Storage (emulated with Azurite)
1313
- [ISAR Robot](https://github.com/equinor/isar-robot) (your friendly neighbourhood mocked robot which provides the answers you need)
1414
- [SARA](https://github.com/equinor/sara) (storage and analysis of robot acquired data)
15-
- A local OAuth2 mock issuer (see [Authentication](#authentication))
15+
- A local Keycloak realm (see [Authentication](#authentication))
1616

1717
## Authentication
1818
The tests run with authentication **enabled and genuinely exercised**, but without Microsoft
19-
Entra ID. A local [oauth2-mock-server](https://github.com/axa-group/oauth2-mock-server)
20-
container acts as the OpenID Connect issuer for the whole stack:
21-
22-
- Flotilla and SARA run with `ASPNETCORE_ENVIRONMENT=IntegrationTest`, which selects their
23-
`appsettings.IntegrationTest.json` and points token validation at the mock.
24-
- ISAR is pointed at the mock with `ISAR_OPENID_CONFIG_URL`.
25-
- Flotilla acquires its downstream ISAR/SARA tokens from the mock too, via a
26-
`GenericOidcAuthorizationHeaderProvider` registered only in that environment.
19+
Entra ID. A [Keycloak](https://www.keycloak.org/) container acts as the OpenID Connect issuer
20+
for the whole stack:
21+
22+
- Flotilla and SARA run with `ASPNETCORE_ENVIRONMENT=IntegrationTest`, whose
23+
`appsettings.IntegrationTest.json` sets `Authentication:Provider=Oidc` and points token
24+
validation at the realm.
25+
- ISAR is pointed at the realm with `ISAR_OPENID_CONFIG_URL`.
26+
- Flotilla acquires its downstream ISAR/SARA tokens from the realm too, via
27+
`GenericOidcAuthorizationHeaderProvider`.
2728
- The test process mints its own tokens from the same issuer.
2829

2930
This means **no app registrations, no tenant and no client secrets** are needed, and there is
3031
nothing to rotate. The container fixtures assert that each service rejects unauthenticated
3132
callers before any test runs, and the mission tests attempt unauthorised interference mid-flight
32-
(wrong audience, missing role) and then assert the mission completed unaffected — so a
33+
(wrong audience, insufficient role) and then assert the mission completed unaffected — so a
3334
misconfiguration cannot silently disable authentication.
3435

3536
MQTT is the one exception: it uses username/password validated by the broker against the
3637
hashed `passwd_file` committed in `equinor/flotilla`, so those credentials remain real secrets.
3738

39+
### The realm
40+
`robotics_integration_tests/custom_realms/robotics-realm.json` is imported at startup. It is
41+
also what a developer mounts to run flotilla or sara against Keycloak locally (see those
42+
repositories' READMEs), so a local run and a CI run exercise the same clients, scopes and roles.
43+
44+
Tokens are minted with the client credentials grant. Unlike Entra, Keycloak does not derive the
45+
audience from the requested scope: each API has a client scope — `isar-api`, `sara-api`,
46+
`flotilla-api`, `pointilla-api` — carrying a single audience mapper onto `isar-test`,
47+
`sara-test` and so on.
48+
49+
**Request exactly one API scope per token.** Two audience mappers make Keycloak emit `aud` as an
50+
array, which `fastapi-azure-auth` rejects, so ISAR would answer an opaque 401.
51+
52+
Because Keycloak has no ad-hoc "issue me a token with these claims" endpoint, a role set is
53+
chosen by picking the service account that holds it:
54+
55+
| Client | Roles |
56+
| --- | --- |
57+
| `integration-tests` | every role the three services require |
58+
| `integration-tests-limited-role` | `Role.User.HUA` only — recognised, but insufficient |
59+
| `integration-tests-no-role` | none |
60+
| `flotilla-test` | used by Flotilla for its downstream ISAR/SARA calls |
61+
62+
The realm also carries three protocol mappers that exist only to satisfy `fastapi-azure-auth`'s
63+
Entra-shaped token model, which ISAR uses: a hardcoded `ver`, a hardcoded `nbf` (Keycloak does
64+
not emit one, and the library requires it) and a **flat** `roles` claim, since Keycloak's default
65+
nested `realm_access.roles` maps to neither ISAR's `User.roles` nor .NET's `ClaimTypes.Role`.
66+
3867
## Run the integration tests through remote workflow call
3968
To run the integration tests in a remote repository, this [workflow](./.github/workflows/run_integration_tests.yml) has been set up.
4069

@@ -142,5 +171,4 @@ Two things worth knowing:
142171
- `isar-robot`'s `uv.lock` pins `isar` from PyPI, so the locally built `isar` wheel is installed
143172
over the released one.
144173

145-
The mosquitto broker is always the published image, and the OAuth2 mock is built automatically
146-
by the test fixtures.
174+
The mosquitto broker and Keycloak are always the published images.

robotics_integration_tests/armada.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111
from robotics_integration_tests.custom_containers.isar import IsarRobot
1212
from robotics_integration_tests.custom_containers.mosquitto import FlotillaBroker
13-
from robotics_integration_tests.custom_containers.oauth2_mock import OAuth2Mock
13+
from robotics_integration_tests.custom_containers.keycloak import Keycloak
1414
from robotics_integration_tests.custom_containers.postgres import (
1515
FlotillaDatabase,
1616
SaraDatabase,
@@ -23,7 +23,7 @@
2323
class Armada:
2424
def __init__(self) -> None:
2525
self.network: Network | None = None
26-
self.oauth_mock: OAuth2Mock | None = None
26+
self.keycloak: Keycloak | None = None
2727
self.test_id: str = ""
2828
self.flotilla_database: FlotillaDatabase | None = None
2929
self.flotilla_broker: FlotillaBroker | None = None

robotics_integration_tests/conftest.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@
4343
create_flotilla_broker_container,
4444
FlotillaBroker,
4545
)
46-
from robotics_integration_tests.custom_containers.oauth2_mock import (
47-
OAuth2Mock,
48-
create_oauth2_mock_container,
46+
from robotics_integration_tests.custom_containers.keycloak import (
47+
Keycloak,
48+
create_keycloak_container,
4949
)
5050
from robotics_integration_tests.custom_containers.postgres import (
5151
SaraDatabase,
@@ -66,8 +66,8 @@
6666
)
6767
from robotics_integration_tests.settings.settings import settings
6868
from robotics_integration_tests.utilities.authentication import (
69-
configure_mock_issuer,
70-
reset_mock_issuer,
69+
configure_issuer,
70+
reset_issuer,
7171
)
7272
from robotics_integration_tests.utilities.authentication_assertions import (
7373
assert_authentication_is_enforced,
@@ -100,6 +100,7 @@ def _pull_latest_images() -> None:
100100
native_images = [
101101
settings.POSTGRESQL_IMAGE,
102102
settings.AZURITE_IMAGE,
103+
settings.KEYCLOAK_IMAGE,
103104
]
104105

105106
for image in amd64_images:
@@ -144,30 +145,34 @@ def network():
144145

145146

146147
@pytest.fixture
147-
def oauth_mock(network: Network, test_id: str):
148+
def keycloak(network: Network, test_id: str):
148149
"""Local OpenID Connect issuer standing in for Azure Entra ID.
149150
150151
Every service in the stack validates its access tokens against this
151152
container, and the test process mints its own tokens from it, so the suite
152153
needs no app registrations, no tenant and no client secrets.
154+
155+
The realm is imported from custom_realms/, the same file a developer mounts
156+
to run flotilla or sara against Keycloak locally, so a local run and a CI run
157+
exercise the same clients, scopes and roles.
153158
"""
154-
container, mock = create_oauth2_mock_container(
159+
container, issuer = create_keycloak_container(
155160
network=network,
156-
alias=settings.OAUTH_MOCK_ALIAS,
157-
port=settings.OAUTH_MOCK_PORT,
161+
alias=settings.KEYCLOAK_ALIAS,
162+
port=settings.KEYCLOAK_PORT,
158163
test_id=test_id,
159164
)
160165
with container:
161-
wait_for_port_mapping_to_be_available(container=container, port=mock.port)
162-
mock.wait_until_ready()
166+
wait_for_port_mapping_to_be_available(container=container, port=issuer.port)
167+
issuer.wait_until_ready()
163168

164169
# The API helpers build auth headers from module functions with no access
165170
# to fixtures, so the host URL is handed over globally.
166-
configure_mock_issuer(mock.host_url)
171+
configure_issuer(issuer.host_url)
167172
try:
168-
yield mock
173+
yield issuer
169174
finally:
170-
reset_mock_issuer()
175+
reset_issuer()
171176

172177

173178
@pytest.fixture
@@ -314,7 +319,7 @@ def teams_webhook_receiver(network: Network, test_id: str):
314319
@pytest.fixture
315320
def flotilla_backend(
316321
network: Network,
317-
oauth_mock: OAuth2Mock,
322+
keycloak: Keycloak,
318323
flotilla_database: FlotillaDatabase,
319324
teams_webhook_receiver: TeamsWebhookReceiver,
320325
test_id: str,
@@ -356,7 +361,7 @@ def flotilla_backend(
356361
@pytest.fixture
357362
def sara(
358363
network: Network,
359-
oauth_mock: OAuth2Mock,
364+
keycloak: Keycloak,
360365
sara_database: SaraDatabase,
361366
armada_storage: ArmadaStorage,
362367
test_id: str,
@@ -394,7 +399,7 @@ def sara(
394399
def armada_without_robots(
395400
network: Network,
396401
test_id: str,
397-
oauth_mock: OAuth2Mock,
402+
keycloak: Keycloak,
398403
flotilla_broker: FlotillaBroker,
399404
flotilla_database: FlotillaDatabase,
400405
flotilla_backend: FlotillaBackend,
@@ -407,7 +412,7 @@ def armada_without_robots(
407412

408413
armada.network = network
409414
armada.test_id = test_id
410-
armada.oauth_mock = oauth_mock
415+
armada.keycloak = keycloak
411416
armada.sara_database = sara_database
412417
armada.sara = sara
413418
armada.flotilla_database = flotilla_database
@@ -451,7 +456,7 @@ def armada_with_single_successful_robot(armada_without_robots: Armada):
451456
blob_conn_data, blob_conn_metadata = _blob_connection_strings(armada)
452457
with create_isar_robot_container(
453458
network=armada.network,
454-
openid_config_url=armada.oauth_mock.internal_openid_config_url,
459+
openid_config_url=armada.keycloak.internal_openid_config_url,
455460
image=settings.ISAR_ROBOT_IMAGE,
456461
name=settings.ISAR_ROBOT_NAME,
457462
port=settings.ISAR_ROBOT_PORT,
@@ -486,7 +491,7 @@ def armada_with_single_failing_robot(armada_without_robots: Armada):
486491

487492
with create_isar_robot_container(
488493
network=armada.network,
489-
openid_config_url=armada.oauth_mock.internal_openid_config_url,
494+
openid_config_url=armada.keycloak.internal_openid_config_url,
490495
image=settings.ISAR_ROBOT_IMAGE,
491496
name=settings.ISAR_ROBOT_NAME,
492497
port=settings.ISAR_ROBOT_PORT,
@@ -572,7 +577,7 @@ def armada_with_multiple_robots(armada_without_robots: Armada):
572577
container = stack.enter_context(
573578
create_isar_robot_container(
574579
network=armada.network,
575-
openid_config_url=armada.oauth_mock.internal_openid_config_url,
580+
openid_config_url=armada.keycloak.internal_openid_config_url,
576581
image=settings.ISAR_ROBOT_IMAGE,
577582
name=cfg["name"],
578583
port=settings.ISAR_ROBOT_PORT,

robotics_integration_tests/custom_containers/isar.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ def create_isar_robot_container(
5959
.with_kwargs(platform="linux/amd64")
6060
.with_env("ISAR_MQTT_HOST", settings.FLOTILLA_BROKER_ALIAS)
6161
.with_env("ISAR_MQTT_PASSWORD", settings.ISAR_MQTT_PASSWORD)
62-
# Validate access tokens against the local mock issuer rather than Entra
63-
# ID. ISAR_AZURE_CLIENT_ID is the expected `aud`; note that settings.py
62+
# Validate access tokens against the local Keycloak realm rather than
63+
# Entra ID. ISAR_AZURE_CLIENT_ID is the expected `aud`; note that settings.py
6464
# lets a bare AZURE_CLIENT_ID override it, so that variable must not be
6565
# set here.
6666
.with_env("ISAR_OPENID_CONFIG_URL", openid_config_url)

0 commit comments

Comments
 (0)