Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions src/isar/apis/security/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from fastapi import Depends
from fastapi.security.base import SecurityBase
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer
from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase
from fastapi_azure_auth.exceptions import InvalidAuthHttp
from fastapi_azure_auth.user import User
from pydantic import BaseModel
Expand All @@ -21,13 +22,47 @@ def __init__(self) -> None:
self.scheme_name = "No Security"


azure_scheme = SingleTenantAzureAuthorizationCodeBearer(
app_client_id=settings.AZURE_CLIENT_ID,
tenant_id=settings.AZURE_TENANT_ID,
scopes={
def build_azure_scheme() -> AzureAuthorizationCodeBearerBase:
"""
Build the security scheme used to validate access tokens.

By default this is a single tenant Azure Entra ID scheme. If
``settings.OPENID_CONFIG_URL`` is set, the OpenID Connect discovery document is
read from that URL instead, which allows ISAR to be pointed at a different
OpenID provider such as a local mock issuer used by the integration tests.

``SingleTenantAzureAuthorizationCodeBearer`` does not accept an
``openid_config_url`` argument, so the base class is used directly in that case.
Issuer validation remains enabled either way; the expected issuer is taken from
the discovery document.

Returns
-------
AzureAuthorizationCodeBearerBase
The configured security scheme.
"""
scopes: dict[str, str] = {
f"api://{settings.AZURE_CLIENT_ID}/user_impersonation": "user_impersonation",

@olaals olaals Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI-generated review comment: This observation was produced with AI assistance and should be validated and discussed by the team before deciding on an implementation.

This Entra-style scope name does not establish the access token audience. OAuth/OIDC providers and test servers differ in how they derive aud: some use requested scopes, others require a resource or audience parameter, and others need explicit claim mappings. ISAR then validates aud exactly against AZURE_CLIENT_ID, so a token requested with api://<id>/user_impersonation can be validly issued but rejected because its audience is the full scope or another configured resource. Authorization additionally depends on the non-standard top-level roles claim containing Mission.Control; declaring a scope here does not enforce that scope because the endpoint uses Depends rather than Security(..., scopes=[...]).

I recommend separating provider-neutral settings such as expected audience, authorization scopes, required role/claim name, and discovery URL instead of deriving all of them from AZURE_CLIENT_ID. Configure the test issuer to emit the exact audience and role contract, or support a configurable resource/audience request parameter in the token-producing client. Then test matching and mismatching audience, missing and present Mission.Control, and, if the scope is intended as authorization, enforce and test it explicitly.

Scores

  • Overall importance: 9/10
  • Correctness-related: 10/10
  • Security-related: 9/10
  • Interoperability-related: 9/10
  • Flexibility-related: 8/10
  • Confidence: 9/10

},
)
}

if settings.OPENID_CONFIG_URL:
return AzureAuthorizationCodeBearerBase(

@olaals olaals Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI-generated review comment: This observation was produced with AI assistance and should be validated and discussed by the team before deciding on an implementation.

Overriding discovery on AzureAuthorizationCodeBearerBase does not make token validation provider-neutral. The dependency still parses tokens into its Azure-specific User model, which requires claims such as ver and models aud as a single string, and its validator currently hard-codes RS256. A standards-compatible provider or test server may omit Azure’s ver claim, emit a valid array-form aud, or advertise another signing algorithm. Such a correctly signed token would still be rejected with 401.

Possible directions are to add a separate generic JWT bearer implementation using PyJWT/Authlib with discovery-backed JWKS and explicit issuer, audience, lifetime, and algorithm validation; introduce an adapter that validates generic claims and maps only the fields ISAR actually needs; or keep this class but describe and test the feature as an Azure-shaped test issuer rather than generic OIDC. If algorithms are configurable, restrict them to an explicit allow-list and cross-check discovery/JWK metadata rather than trusting the token header. Please add integration tests using real discovery/JWKS tokens without ver, with string and array audiences, and with accepted/rejected algorithms.

Scores

  • Overall importance: 10/10
  • Correctness-related: 10/10
  • Security-related: 9/10
  • Interoperability-related: 10/10
  • Flexibility-related: 9/10
  • Confidence: 10/10

app_client_id=settings.AZURE_CLIENT_ID,
tenant_id=settings.AZURE_TENANT_ID,
scopes=scopes,
openid_config_url=settings.OPENID_CONFIG_URL,
openapi_authorization_url=settings.OPENAPI_AUTHORIZATION_URL,
openapi_token_url=settings.OPENAPI_TOKEN_URL,
)

return SingleTenantAzureAuthorizationCodeBearer(
app_client_id=settings.AZURE_CLIENT_ID,
tenant_id=settings.AZURE_TENANT_ID,
scopes=scopes,
)


azure_scheme: AzureAuthorizationCodeBearerBase = build_azure_scheme()


async def validate_has_role(user: User = Depends(azure_scheme)) -> None:
Expand Down
13 changes: 13 additions & 0 deletions src/isar/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ class Settings(BaseSettings):
# ChainedTokenCredential (e.g. "WorkloadIdentity,ClientSecret").
ALLOWED_AUTH_METHODS: str = Field(default="ClientSecret")

# Override the OpenID Connect discovery document URL used to validate access
# tokens. When unset (the default), the URL is derived from AZURE_TENANT_ID and
# points at Azure Entra ID. Set this to point ISAR at a different OpenID
# provider, such as a local mock issuer used by the integration tests.
OPENID_CONFIG_URL: str | None = Field(default=None)

# Override the authorization and token URLs advertised in the generated OpenAPI
# document, so that Swagger's "Authorize" button does not point at
# login.microsoftonline.com when a non-Azure issuer is configured. Only affect
# the API documentation; token validation is governed by OPENID_CONFIG_URL.
OPENAPI_AUTHORIZATION_URL: str | None = Field(default=None)
OPENAPI_TOKEN_URL: str | None = Field(default=None)

# MQTT username
# The username and password is set by the MQTT broker and must be known in advance
# The password should be set as an environment variable "MQTT_PASSWORD"
Expand Down
58 changes: 58 additions & 0 deletions tests/isar/apis/security/test_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
import jwt
import pytest
from fastapi.testclient import TestClient
from fastapi_azure_auth import SingleTenantAzureAuthorizationCodeBearer
from fastapi_azure_auth.auth import AzureAuthorizationCodeBearerBase
from pytest import MonkeyPatch

from isar.apis.security.authentication import build_azure_scheme
from isar.config.settings import settings


def stub_access_token() -> str:
Expand Down Expand Up @@ -30,3 +36,55 @@ def test_authentication(
)

assert response.status_code == expected_status_code


class TestBuildAzureScheme:
def test_defaults_to_single_tenant_azure_scheme(
self, monkeypatch: MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "OPENID_CONFIG_URL", None)

scheme = build_azure_scheme()

assert isinstance(scheme, SingleTenantAzureAuthorizationCodeBearer)
# No override, so the discovery document URL is derived from the tenant ID
# and points at Azure Entra ID.
assert scheme.openid_config.config_url is None
assert scheme.openid_config.tenant_id == settings.AZURE_TENANT_ID
assert scheme.app_client_id == settings.AZURE_CLIENT_ID

def test_openid_config_url_is_honoured(self, monkeypatch: MonkeyPatch) -> None:
config_url = "http://oauth-mock:8080/.well-known/openid-configuration"
authorization_url = "http://oauth-mock:8080/authorize"
token_url = "http://oauth-mock:8080/token"

monkeypatch.setattr(settings, "OPENID_CONFIG_URL", config_url)
monkeypatch.setattr(settings, "OPENAPI_AUTHORIZATION_URL", authorization_url)
monkeypatch.setattr(settings, "OPENAPI_TOKEN_URL", token_url)

scheme = build_azure_scheme()

assert not isinstance(scheme, SingleTenantAzureAuthorizationCodeBearer)
assert isinstance(scheme, AzureAuthorizationCodeBearerBase)
assert scheme.openid_config.config_url == config_url
assert scheme.authorization_url == authorization_url
assert scheme.token_url == token_url
# The audience is still ISAR's own client ID, and issuer validation stays on.
assert scheme.app_client_id == settings.AZURE_CLIENT_ID
assert scheme.validate_iss is True

def test_openapi_urls_fall_back_to_azure_when_unset(
self, monkeypatch: MonkeyPatch
) -> None:
monkeypatch.setattr(
settings,
"OPENID_CONFIG_URL",
"http://oauth-mock:8080/.well-known/openid-configuration",
)
monkeypatch.setattr(settings, "OPENAPI_AUTHORIZATION_URL", None)
monkeypatch.setattr(settings, "OPENAPI_TOKEN_URL", None)

scheme = build_azure_scheme()

assert scheme.authorization_url is not None
assert settings.AZURE_TENANT_ID in scheme.authorization_url
Loading