Skip to content

Commit 004a3a0

Browse files
committed
Support non-Azure OpenID providers
1 parent 4e369c4 commit 004a3a0

4 files changed

Lines changed: 204 additions & 14 deletions

File tree

README.md

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,22 +270,47 @@ option in the dictionary.
270270

271271
## API authentication
272272

273-
The API has an option to include user authentication. This can be enabled by setting the environment variable
273+
The API validates OAuth2 access tokens. Authentication is controlled by
274274

275275
```
276276
ISAR_AUTHENTICATION_ENABLED = true
277277
```
278278

279-
By default, the `local` storage module is used and API authentication is disabled. If using Azure Blob Storage a set of
280-
environment variables must be available which gives access to an app registration that may use the storage account.
281-
Enabling API authentication also requires the same environment variables. The required variables are
279+
which is **enabled by default**; set it to `false` to turn authentication off.
280+
281+
A token is accepted when its issuer matches the configured OpenID provider, its audience equals
282+
`ISAR_AZURE_CLIENT_ID`, its signature and lifetime are valid, and it carries the role named by
283+
`ISAR_REQUIRED_ROLE` (default `Mission.Control`) in a top-level `roles` claim.
284+
285+
### Azure Entra ID (default)
286+
287+
Requires an app registration, configured through
288+
289+
```
290+
ISAR_AZURE_CLIENT_ID
291+
ISAR_AZURE_TENANT_ID
292+
```
293+
294+
The same app registration is used for Azure Blob Storage, which additionally needs the bare
295+
`AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and `AZURE_CLIENT_SECRET` variables that
296+
`EnvironmentCredential` reads.
297+
298+
### Any other OpenID Connect provider
299+
300+
`ISAR_OPENID_CONFIG_URL` points ISAR at a different provider, such as a Keycloak realm:
282301

283302
```
284-
AZURE_CLIENT_ID
285-
AZURE_TENANT_ID
286-
AZURE_CLIENT_SECRET
303+
ISAR_OPENID_CONFIG_URL = http://localhost:8080/realms/robotics/.well-known/openid-configuration
304+
ISAR_AZURE_CLIENT_ID = isar-test # the expected audience
305+
ISAR_OPENID_SCOPE = isar-api # the scope Swagger requests
306+
ISAR_OPENAPI_AUTHORIZATION_URL = http://localhost:8080/realms/robotics/protocol/openid-connect/auth
307+
ISAR_OPENAPI_TOKEN_URL = http://localhost:8080/realms/robotics/protocol/openid-connect/token
287308
```
288309

310+
The last three variables only affect Swagger's "Authorize" button. The provider must emit `nbf`
311+
and a `ver` claim of `"1.0"` or `"2.0"`, place roles in a flat top-level `roles` array, and
312+
issue `aud` as a single string rather than the array form RFC 7519 also permits.
313+
289314
## MQTT communication
290315

291316
ISAR is able to publish parts of its internal state to topics on an MQTT broker whenever they change.

src/isar/apis/security/authentication.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from fastapi import Depends
66
from fastapi.security.base import SecurityBase
77
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer
8+
from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase
89
from fastapi_azure_auth.exceptions import InvalidAuthHttp
910
from fastapi_azure_auth.user import User
1011
from pydantic import BaseModel
@@ -22,13 +23,43 @@ def __init__(self) -> None:
2223
self.scheme_name = "No Security"
2324

2425

25-
azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
26-
app_client_id=settings.AZURE_CLIENT_ID,
27-
tenant_id=settings.AZURE_TENANT_ID,
28-
scopes={
29-
f"api://{settings.AZURE_CLIENT_ID}/user_impersonation": "user_impersonation",
30-
},
31-
)
26+
def build_azure_scheme() -> AzureAuthorizationCodeBearerBase:
27+
"""
28+
Build the security scheme used to validate access tokens.
29+
30+
Azure Entra ID by default, or the provider given by
31+
``settings.OPENID_CONFIG_URL``. The base class is used for the latter because
32+
``SingleTenantAzureAuthorizationCodeBearer`` does not accept an
33+
``openid_config_url``. Issuer validation stays enabled either way.
34+
35+
Returns
36+
-------
37+
AzureAuthorizationCodeBearerBase
38+
The configured security scheme.
39+
"""
40+
scope_name: str = (
41+
settings.OPENID_SCOPE or f"api://{settings.AZURE_CLIENT_ID}/user_impersonation"
42+
)
43+
scopes: dict[str, str] = {scope_name: scope_name.rsplit("/", maxsplit=1)[-1]}
44+
45+
if settings.OPENID_CONFIG_URL:
46+
return AzureAuthorizationCodeBearerBase(
47+
app_client_id=settings.AZURE_CLIENT_ID,
48+
tenant_id=settings.AZURE_TENANT_ID,
49+
scopes=scopes,
50+
openid_config_url=settings.OPENID_CONFIG_URL,
51+
openapi_authorization_url=settings.OPENAPI_AUTHORIZATION_URL,
52+
openapi_token_url=settings.OPENAPI_TOKEN_URL,
53+
)
54+
55+
return SingleTenantAzureAuthorizationCodeBearer(
56+
app_client_id=settings.AZURE_CLIENT_ID,
57+
tenant_id=settings.AZURE_TENANT_ID,
58+
scopes=scopes,
59+
)
60+
61+
62+
azure_scheme: AzureAuthorizationCodeBearerBase = build_azure_scheme()
3263

3364

3465
async def validate_has_role(user: User = Depends(azure_scheme)) -> None:

src/isar/config/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,18 @@ class Settings(BaseSettings):
122122
# ChainedTokenCredential (e.g. "WorkloadIdentity,ClientSecret").
123123
ALLOWED_AUTH_METHODS: str = Field(default="ClientSecret")
124124

125+
# OpenID Connect discovery document URL. Unset means Azure Entra ID, derived
126+
# from AZURE_TENANT_ID. Set it to use another provider, such as Keycloak.
127+
OPENID_CONFIG_URL: str | None = Field(default=None)
128+
129+
# Swagger's "Authorize" button only. Validation is unaffected.
130+
OPENAPI_AUTHORIZATION_URL: str | None = Field(default=None)
131+
OPENAPI_TOKEN_URL: str | None = Field(default=None)
132+
133+
# The scope Swagger requests. Entra derives the audience from the scope, hence
134+
# the "api://<client id>/user_impersonation" default; other providers do not.
135+
OPENID_SCOPE: str | None = Field(default=None)
136+
125137
# MQTT username
126138
# The username and password is set by the MQTT broker and must be known in advance
127139
# The password should be set as an environment variable "MQTT_PASSWORD"

tests/isar/apis/security/test_authentication.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
import jwt
44
import pytest
55
from fastapi.testclient import TestClient
6+
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer
7+
from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase
8+
from fastapi_azure_auth.user import User
9+
from pydantic import ValidationError
10+
from pytest import MonkeyPatch
11+
12+
from isar.apis.security.authentication import build_azure_scheme
13+
from isar.config.settings import settings
14+
15+
16+
def advertised_scopes(scheme: AzureAuthorizationCodeBearerBase) -> dict[str, str]:
17+
"""Scopes offered by Swagger's Authorize button, for the given scheme."""
18+
return scheme.oauth.model.flows.authorizationCode.scopes
619

720

821
def stub_access_token() -> str:
@@ -30,3 +43,112 @@ def test_authentication(
3043
)
3144

3245
assert response.status_code == expected_status_code
46+
47+
48+
class TestBuildAzureScheme:
49+
def test_defaults_to_single_tenant_azure_scheme(
50+
self, monkeypatch: MonkeyPatch
51+
) -> None:
52+
monkeypatch.setattr(settings, "OPENID_CONFIG_URL", None)
53+
54+
scheme = build_azure_scheme()
55+
56+
assert isinstance(scheme, SingleTenantAzureAuthorizationCodeBearer)
57+
assert scheme.openid_config.config_url is None
58+
assert scheme.openid_config.tenant_id == settings.AZURE_TENANT_ID
59+
assert scheme.app_client_id == settings.AZURE_CLIENT_ID
60+
61+
def test_openid_config_url_is_honoured(self, monkeypatch: MonkeyPatch) -> None:
62+
config_url = (
63+
"http://keycloak:8080/realms/robotics/.well-known/openid-configuration"
64+
)
65+
authorization_url = (
66+
"http://keycloak:8080/realms/robotics/protocol/openid-connect/auth"
67+
)
68+
token_url = "http://keycloak:8080/realms/robotics/protocol/openid-connect/token"
69+
70+
monkeypatch.setattr(settings, "OPENID_CONFIG_URL", config_url)
71+
monkeypatch.setattr(settings, "OPENAPI_AUTHORIZATION_URL", authorization_url)
72+
monkeypatch.setattr(settings, "OPENAPI_TOKEN_URL", token_url)
73+
74+
scheme = build_azure_scheme()
75+
76+
assert not isinstance(scheme, SingleTenantAzureAuthorizationCodeBearer)
77+
assert isinstance(scheme, AzureAuthorizationCodeBearerBase)
78+
assert scheme.openid_config.config_url == config_url
79+
assert scheme.authorization_url == authorization_url
80+
assert scheme.token_url == token_url
81+
assert scheme.app_client_id == settings.AZURE_CLIENT_ID
82+
assert scheme.validate_iss is True
83+
84+
def test_openapi_urls_fall_back_to_azure_when_unset(
85+
self, monkeypatch: MonkeyPatch
86+
) -> None:
87+
monkeypatch.setattr(
88+
settings,
89+
"OPENID_CONFIG_URL",
90+
"http://keycloak:8080/realms/robotics/.well-known/openid-configuration",
91+
)
92+
monkeypatch.setattr(settings, "OPENAPI_AUTHORIZATION_URL", None)
93+
monkeypatch.setattr(settings, "OPENAPI_TOKEN_URL", None)
94+
95+
scheme = build_azure_scheme()
96+
97+
assert scheme.authorization_url is not None
98+
assert settings.AZURE_TENANT_ID in scheme.authorization_url
99+
100+
def test_scope_defaults_to_the_entra_shaped_scope(
101+
self, monkeypatch: MonkeyPatch
102+
) -> None:
103+
monkeypatch.setattr(settings, "OPENID_SCOPE", None)
104+
105+
scheme = build_azure_scheme()
106+
107+
expected = f"api://{settings.AZURE_CLIENT_ID}/user_impersonation"
108+
assert advertised_scopes(scheme) == {expected: "user_impersonation"}
109+
110+
def test_openid_scope_is_honoured(self, monkeypatch: MonkeyPatch) -> None:
111+
monkeypatch.setattr(settings, "OPENID_SCOPE", "isar-api")
112+
113+
scheme = build_azure_scheme()
114+
115+
assert advertised_scopes(scheme) == {"isar-api": "isar-api"}
116+
assert scheme.app_client_id == settings.AZURE_CLIENT_ID
117+
118+
119+
class TestAudienceClaimShape:
120+
"""Pin the audience shapes ISAR accepts.
121+
122+
``fastapi_azure_auth`` declares ``aud`` as a plain ``str``, so the array form
123+
RFC 7519 also permits is rejected with an opaque 401. Asserted here so that a
124+
dependency upgrade lifting the restriction is noticed.
125+
"""
126+
127+
def test_string_audience_is_accepted(self) -> None:
128+
user = User(
129+
aud=settings.AZURE_CLIENT_ID,
130+
claims={},
131+
access_token="",
132+
iss="",
133+
sub="",
134+
exp=0,
135+
iat=0,
136+
nbf=0,
137+
ver="2.0",
138+
)
139+
140+
assert user.aud == settings.AZURE_CLIENT_ID
141+
142+
def test_array_audience_is_rejected(self) -> None:
143+
with pytest.raises(ValidationError):
144+
User(
145+
aud=[settings.AZURE_CLIENT_ID, "another-audience"],
146+
claims={},
147+
access_token="",
148+
iss="",
149+
sub="",
150+
exp=0,
151+
iat=0,
152+
nbf=0,
153+
ver="2.0",
154+
)

0 commit comments

Comments
 (0)