From 68ed266df8980f2b31474a1c5a53511947c4441e Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:20:05 +0000 Subject: [PATCH 1/2] feat: validate the issuer and cache signing keys in the OIDC provider The PKCE provider never passed an expected issuer to the token decode. Microsoft Entra ID signs every tenant's tokens from a shared key set, so a tenant-scoped JWKS URL isolates nothing and the audience check only holds while an app registration stays single-tenant. It also refetched the key set on every authenticated request, and reached an unguarded b64decode, json.loads and requests.get, so a bearer token that was not a JWT returned an unauthenticated 500. --- src/dispatch/config.py | 48 +- src/dispatch/plugins/dispatch_core/plugin.py | 196 +++++-- tests/auth/conftest.py | 35 ++ tests/auth/test_auth_providers.py | 40 +- tests/auth/test_entra_end_to_end.py | 141 +++++ tests/auth/test_oidc_entra.py | 584 +++++++++++++++++++ 6 files changed, 994 insertions(+), 50 deletions(-) create mode 100644 tests/auth/test_entra_end_to_end.py create mode 100644 tests/auth/test_oidc_entra.py diff --git a/src/dispatch/config.py b/src/dispatch/config.py index 754ae727b..74d12d80a 100644 --- a/src/dispatch/config.py +++ b/src/dispatch/config.py @@ -122,12 +122,6 @@ def __str__(self) -> str: DISPATCH_JWT_AUDIENCE = config("DISPATCH_JWT_AUDIENCE", default=None) DISPATCH_JWT_EMAIL_OVERRIDE = config("DISPATCH_JWT_EMAIL_OVERRIDE", default=None) -if DISPATCH_AUTHENTICATION_PROVIDER_SLUG == "dispatch-auth-provider-pkce": - if not DISPATCH_JWT_AUDIENCE: - log.warn("No JWT Audience specified. This is required for IdPs like Okta") - if not DISPATCH_JWT_EMAIL_OVERRIDE: - log.warn("No JWT Email Override specified. 'email' is expected in the idtoken.") - DISPATCH_JWT_SECRET = config("DISPATCH_JWT_SECRET", default=None) DISPATCH_JWT_ALG = config("DISPATCH_JWT_ALG", default="HS256") DISPATCH_JWT_EXP = config("DISPATCH_JWT_EXP", cast=int, default=86400) # Seconds @@ -144,13 +138,51 @@ def __str__(self) -> str: "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", default=None ) -DISPATCH_PKCE_DONT_VERIFY_AT_HASH = config("DISPATCH_PKCE_DONT_VERIFY_AT_HASH", default=False) +# The exact `iss` an id token must carry. An identity provider's JWKS is not +# necessarily scoped to one tenant -- Microsoft Entra ID serves overlapping +# signing keys across every tenant -- so a valid signature says the token came +# from that provider, not that it came from *your* directory. +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER = config( + "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER", default=None +) + +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_CACHE_SECONDS = config( + "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_CACHE_SECONDS", cast=int, default=300 +) + +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_TIMEOUT_SECONDS = config( + "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_TIMEOUT_SECONDS", cast=int, default=5 +) + +# Tolerance applied to exp/nbf/iat. Zero means any clock drift between this host +# and the identity provider fails every login with an indistinguishable 401. +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS = config( + "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS", cast=int, default=60 +) + +# cast=bool is required: without it "false" arrives as a non-empty string and +# silently disables at_hash verification -- the opposite of what was asked for. +DISPATCH_PKCE_DONT_VERIFY_AT_HASH = config( + "DISPATCH_PKCE_DONT_VERIFY_AT_HASH", cast=bool, default=False +) if DISPATCH_AUTHENTICATION_PROVIDER_SLUG == "dispatch-auth-provider-pkce": if not DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS: - log.warn( + log.warning( "No PKCE JWKS url provided, this is required if you are using PKCE authentication." ) + if not DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER: + log.warning( + "No PKCE issuer specified. Set DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER to the " + "exact 'iss' your identity provider mints, or any tenant it serves is accepted." + ) + if not DISPATCH_JWT_AUDIENCE: + log.warning( + "No JWT Audience specified. Set DISPATCH_JWT_AUDIENCE to the client id the id token " + "is issued for; required for IdPs like Okta and Microsoft Entra ID." + ) + if not DISPATCH_JWT_EMAIL_OVERRIDE: + log.warning("No JWT Email Override specified. 'email' is expected in the idtoken.") DISPATCH_AUTHENTICATION_PROVIDER_HEADER_NAME = config( "DISPATCH_AUTHENTICATION_PROVIDER_HEADER_NAME", default="remote-user" diff --git a/src/dispatch/plugins/dispatch_core/plugin.py b/src/dispatch/plugins/dispatch_core/plugin.py index 8bdd63e18..501a01e60 100644 --- a/src/dispatch/plugins/dispatch_core/plugin.py +++ b/src/dispatch/plugins/dispatch_core/plugin.py @@ -28,7 +28,11 @@ DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_EMAIL_CLAIM, DISPATCH_AUTHENTICATION_PROVIDER_AWS_ALB_PUBLIC_KEY_CACHE_SECONDS, DISPATCH_AUTHENTICATION_PROVIDER_HEADER_NAME, + DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER, DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS, + DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_CACHE_SECONDS, + DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_TIMEOUT_SECONDS, + DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS, DISPATCH_JWT_ALG, DISPATCH_JWT_AUDIENCE, DISPATCH_JWT_EMAIL_OVERRIDE, @@ -101,10 +105,92 @@ def get_current_user(self, request: Request, **kwargs): return data["email"] +# Floor between repeated fetches of the same key set, whether an unknown kid +# asked for one or a previous fetch failed. Key rotation still lands within this +# window; a token replaying junk kids cannot amplify past it. +JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30 + +_NEVER = float("-inf") + + +class JwksUnavailableError(Exception): + """The identity provider's signing keys could not be retrieved.""" + + +class JwksCache: + """Caches an OIDC provider's signing keys, refreshing on an unknown `kid`. + + Every authenticated request decodes a token, so an uncached fetch here puts + a synchronous call to the identity provider on Dispatch's hottest path. + Refreshing on an unknown kid, and not only on expiry, is what keeps a + rotated signing key from 401ing every request until the TTL runs out. + + Each entry carries three stamps because each throttles a different thing: + `fetched_at` ages the keys, `missed_at` bounds how often an unknown kid can + force a refetch, and `failed_at` bounds retries while the provider is down. + Collapsing them into one stamp makes the successful fetch that populates the + cache throttle the rotation refetch that should follow it. + """ + + def __init__(self): + self._entries: dict[str, dict] = {} + + def clear(self) -> None: + self._entries.clear() + + def get_key(self, url: str, kid: str, *, ttl: int, timeout: int) -> dict | None: + """Returns the published key with this kid, or None if there is no such key.""" + now = time.monotonic() + entry = self._entries.get(url) + if entry is None: + entry = self._fetch(url, None, now, timeout, missed=False) + + # Never longer than the TTL: a short TTL is an explicit request for + # fresher keys and must not be held back by the refresh floor. + refresh_interval = min(JWKS_MIN_REFRESH_INTERVAL_SECONDS, ttl) + retry_allowed = now - entry["failed_at"] >= refresh_interval + + if retry_allowed and now - entry["fetched_at"] >= ttl: + entry = self._fetch(url, entry, now, timeout, missed=False) + elif ( + retry_allowed + and kid not in entry["keys"] + and now - entry["missed_at"] >= refresh_interval + ): + entry = self._fetch(url, entry, now, timeout, missed=True) + + return entry["keys"].get(kid) + + def _fetch(self, url: str, entry: dict | None, now: float, timeout: int, *, missed: bool): + missed_at = now if missed else (entry["missed_at"] if entry else _NEVER) + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + keys = {key["kid"]: key for key in response.json()["keys"] if key.get("kid")} + except (requests.RequestException, AttributeError, KeyError, TypeError, ValueError) as err: + if entry is None: + raise JwksUnavailableError(f"Unable to fetch signing keys from {url}") from err + # Serving the keys already held beats failing every request for the + # length of the provider's outage. The successful-fetch stamp is + # deliberately not moved, so the next retry past the floor tries + # again rather than settling on stale keys. + log.warning("Unable to refresh signing keys from %s, using cached keys: %s", url, err) + entry["failed_at"] = now + entry["missed_at"] = missed_at + return entry + + entry = {"keys": keys, "fetched_at": now, "missed_at": missed_at, "failed_at": _NEVER} + self._entries[url] = entry + return entry + + +jwks_cache = JwksCache() + + class PKCEAuthProviderPlugin(AuthenticationProviderPlugin): title = "Dispatch Plugin - PKCE Authentication Provider" slug = "dispatch-auth-provider-pkce" - description = "Generic PCKE authentication provider." + description = "Generic OpenID Connect (PKCE) authentication provider." version = dispatch_plugin.__version__ author = "Netflix" @@ -116,27 +202,48 @@ def get_current_user(self, request: Request, **kwargs): status_code=HTTP_401_UNAUTHORIZED, detail=[{"msg": "Could not validate credentials"}] ) + if not DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS: + log.error( + "Unable to authenticate. DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS is not set." + ) + raise credentials_exception + authorization: str = request.headers.get( "Authorization", request.headers.get("authorization") ) - scheme, param = get_authorization_scheme_param(authorization) - if not authorization or scheme.lower() != "bearer": + scheme, token = get_authorization_scheme_param(authorization) + # `token` is the parsed parameter; re-splitting the header IndexErrors + # on a bearer scheme with an empty token, i.e. an unauthenticated 500. + if not authorization or scheme.lower() != "bearer" or not token: + log.warning("Unable to authenticate. Malformed authorization header.") raise credentials_exception - token = authorization.split()[1] + try: + # The library parser, not a hand-rolled base64 decode: the header is + # base64url and attacker-supplied, so anything that is not a JWT has + # to come back as a rejection rather than an unhandled 500. + kid = jwt.get_unverified_header(token).get("kid") + except (JWTError, JWKError) as err: + log.warning("Unable to authenticate. Unreadable token header: %s", err) + raise credentials_exception from err - # Parse out the Key information. Add padding just in case - key_info = json.loads(base64.b64decode(token.split(".")[0] + "=========").decode("utf-8")) + if not kid: + log.warning("Unable to authenticate. Token header carries no kid.") + raise credentials_exception - # Grab all possible keys to account for key rotation and find the right key - keys = requests.get(DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS).json()["keys"] - key = None - for potential_key in keys: - if potential_key["kid"] == key_info["kid"]: - key = potential_key + try: + key = jwks_cache.get_key( + DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS, + kid, + ttl=DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_CACHE_SECONDS, + timeout=DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_TIMEOUT_SECONDS, + ) + except JwksUnavailableError as err: + log.error("Unable to authenticate. %s", err) + raise credentials_exception from err if key is None: - log.warning(f"No JWKS key matches the token's kid: {key_info.get('kid')}") + log.warning("Unable to authenticate. No JWKS key matches the token's kid: %s", kid) raise credentials_exception # The key's own alg, else the asymmetric families a JWKS can serve. @@ -148,30 +255,51 @@ def get_current_user(self, request: Request, **kwargs): else ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"] ) + # require_exp: an id token without one never expires, and OpenID Connect + # makes the claim mandatory, so its absence is a malformed token rather + # than a token that opted out of expiry. + jwt_opts = { + "leeway": DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS, + "require_exp": True, + } + if DISPATCH_JWT_AUDIENCE: + # python-jose skips the audience check entirely when the token has + # no aud, so expecting one has to be stated as a requirement too. + jwt_opts["require_aud"] = True + if DISPATCH_PKCE_DONT_VERIFY_AT_HASH: + jwt_opts["verify_at_hash"] = False + try: - jwt_opts = {} - if DISPATCH_PKCE_DONT_VERIFY_AT_HASH: - jwt_opts = {"verify_at_hash": False} - # If DISPATCH_JWT_AUDIENCE is defined, the we must include audience in the decode - if DISPATCH_JWT_AUDIENCE: - data = jwt.decode( - token, - key, - algorithms=algorithms, - audience=DISPATCH_JWT_AUDIENCE, - options=jwt_opts, - ) - else: - data = jwt.decode(token, key, algorithms=algorithms, options=jwt_opts) - except JWTError as err: - log.debug("JWT Decode error: {}".format(err)) + # audience/issuer are passed only when configured, but neither is + # fail-open: python-jose rejects a token whose aud is present while + # none is expected, and rejects a missing iss once one is expected. + data = jwt.decode( + token, + key, + algorithms=algorithms, + audience=DISPATCH_JWT_AUDIENCE, + issuer=DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER, + options=jwt_opts, + ) + except (JWTError, JWKError) as err: + log.warning("Unable to authenticate. Token rejected: %s", err) raise credentials_exception from err - # Support overriding where email is returned in the id token - if DISPATCH_JWT_EMAIL_OVERRIDE: - return data[DISPATCH_JWT_EMAIL_OVERRIDE] - else: - return data["email"] + # Support overriding where email is returned in the id token. + email_claim = DISPATCH_JWT_EMAIL_OVERRIDE or "email" + email = data.get(email_claim) + # isinstance, not just truthiness: this value becomes a user identity, + # and a claim that is a list or an object would otherwise be carried all + # the way to the pydantic model that provisions the account. + if not isinstance(email, str) or not email: + log.warning( + "Unable to authenticate. Token carries no usable '%s' claim. Set " + "DISPATCH_JWT_EMAIL_OVERRIDE to the claim your provider issues.", + email_claim, + ) + raise credentials_exception + + return email class HeaderAuthProviderPlugin(AuthenticationProviderPlugin): diff --git a/tests/auth/conftest.py b/tests/auth/conftest.py index 3d0285239..5c3b89f73 100644 --- a/tests/auth/conftest.py +++ b/tests/auth/conftest.py @@ -80,3 +80,38 @@ def api_client(session): from dispatch.main import api return TestClient(api) + + +@pytest.fixture +def signing_keys(): + """Two RSA keypairs published under one JWKS, as a rotation window looks.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from jose import jwk + + def keypair(kid): + private = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = private.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + public_pem = ( + private.public_key() + .public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + .decode() + ) + entry = {**jwk.construct(public_pem, "RS256").to_dict(), "kid": kid, "alg": "RS256"} + # to_dict returns bytes for the modulus and exponent; the real endpoint + # serves JSON, so hand the plugin the same str shape it would see. + entry = {k: v.decode() if isinstance(v, bytes) else v for k, v in entry.items()} + return pem, entry + + current_pem, current_jwk = keypair("current-key") + next_pem, next_jwk = keypair("next-key") + return { + "current": (current_pem, current_jwk), + "next": (next_pem, next_jwk), + } diff --git a/tests/auth/test_auth_providers.py b/tests/auth/test_auth_providers.py index 486d0966d..2be011aeb 100644 --- a/tests/auth/test_auth_providers.py +++ b/tests/auth/test_auth_providers.py @@ -223,18 +223,44 @@ def rsa_keypair(): @pytest.fixture def published_jwks(monkeypatch, rsa_keypair): - """Serve the keypair's public half where the plugin fetches its JWKS.""" + """Serve the keypair's public half where the plugin fetches its JWKS. + + The JWKS url has to be set too: with none configured the provider rejects + every request before it looks at the token, which would leave the rejection + tests below passing for the wrong reason. The key cache is process-global, + so it is emptied around each test to keep one test's keys out of the next. + """ import dispatch.plugins.dispatch_core.plugin as core_plugin _, _, jwks_entry = rsa_keypair class FakeResponse: + @staticmethod + def raise_for_status(): + return None + @staticmethod def json(): return {"keys": [jwks_entry]} monkeypatch.setattr(core_plugin.requests, "get", lambda *a, **kw: FakeResponse()) - return jwks_entry + monkeypatch.setattr( + core_plugin, + "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", + "https://idp.example.com/keys", + ) + core_plugin.jwks_cache.clear() + yield jwks_entry + core_plugin.jwks_cache.clear() + + +def pkce_claims(**overrides) -> dict: + """The minimum an id token needs to be accepted, plus any overrides.""" + return { + "email": "ada@example.com", + "exp": int((datetime.utcnow() + timedelta(hours=1)).timestamp()), + **overrides, + } def pkce_request(token: str) -> Request: @@ -244,9 +270,7 @@ def pkce_request(token: str) -> Request: def test_pkce_accepts_a_token_signed_by_the_published_key(rsa_keypair, published_jwks): """The success path: an RS256 token whose kid matches the JWKS.""" private_pem, _, _ = rsa_keypair - token = jwt.encode( - {"email": "ada@example.com"}, private_pem, algorithm="RS256", headers={"kid": "test-key"} - ) + token = jwt.encode(pkce_claims(), private_pem, algorithm="RS256", headers={"kid": "test-key"}) assert PKCEAuthProviderPlugin().get_current_user(pkce_request(token)) == "ada@example.com" @@ -271,7 +295,7 @@ def b64(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode().rstrip("=") header = b64(json.dumps({"alg": "HS256", "typ": "JWT", "kid": "test-key"}).encode()) - payload = b64(json.dumps({"email": "attacker@example.com"}).encode()) + payload = b64(json.dumps(pkce_claims(email="attacker@example.com")).encode()) signing_input = f"{header}.{payload}".encode() signature = b64(hmac.new(public_pem.encode(), signing_input, hashlib.sha256).digest()) forged = f"{header}.{payload}.{signature}" @@ -295,7 +319,7 @@ def test_pkce_rejects_a_token_signed_by_a_key_the_provider_does_not_publish( serialization.NoEncryption(), ).decode() forged = jwt.encode( - {"email": "attacker@example.com"}, + pkce_claims(email="attacker@example.com"), attacker_pem, algorithm="RS256", headers={"kid": "test-key"}, @@ -314,7 +338,7 @@ def test_pkce_rejects_a_token_whose_kid_matches_no_published_key(rsa_keypair, pu """ private_pem, _, _ = rsa_keypair token = jwt.encode( - {"email": "ada@example.com"}, private_pem, algorithm="RS256", headers={"kid": "rotated-out"} + pkce_claims(), private_pem, algorithm="RS256", headers={"kid": "rotated-out"} ) with pytest.raises(HTTPException) as exc: diff --git a/tests/auth/test_entra_end_to_end.py b/tests/auth/test_entra_end_to_end.py new file mode 100644 index 000000000..2ce95605c --- /dev/null +++ b/tests/auth/test_entra_end_to_end.py @@ -0,0 +1,141 @@ +"""The whole Entra sign-in path, over real HTTP, against a stand-in issuer. + +tests/auth/test_oidc_entra.py exercises the provider directly with the key +endpoint patched out. This runs a real HTTP server that serves a JWKS the way +`login.microsoftonline.com//discovery/v2.0/keys` does, and drives an +Entra-shaped id token through the running application: ASGI request -> +db_session_middleware -> PKCE provider -> JWKS fetch over the network -> user +provisioning -> permission class -> route. + +Nothing here needs an Entra tenant. What the stand-in cannot prove is the +browser half of the flow -- the authorization request, PKCE exchange and SPA +CORS behaviour that only Entra itself implements. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest +from jose import jwt + +import dispatch.plugins.dispatch_core.plugin as core_plugin +from dispatch.plugins.dispatch_core.plugin import PKCEAuthProviderPlugin + +from tests.auth.test_oidc_entra import ( + OTHER_TENANT_ID, + TENANT_ID, + UPN, + entra_claims, +) + + +class _KeysHandler(BaseHTTPRequestHandler): + """Serves one tenant's key set, and 404s anything else.""" + + def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's interface + if self.path != f"/{TENANT_ID}/discovery/v2.0/keys": + self.send_error(404) + return + body = json.dumps({"keys": self.server.keys}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + return + + +@pytest.fixture +def entra_issuer(signing_keys): + """A stand-in for the tenant's OpenID Connect key endpoint.""" + server = ThreadingHTTPServer(("127.0.0.1", 0), _KeysHandler) + server.keys = [signing_keys["current"][1]] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + host, port = server.server_address[:2] + server.jwks_url = f"http://{host}:{port}/{TENANT_ID}/discovery/v2.0/keys" + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +@pytest.fixture +def entra_deployment(entra_issuer, monkeypatch, session): + """Configure the app exactly as the Entra documentation says to.""" + from dispatch.auth import service as auth_service + from dispatch.plugins.base import register + + issuer = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" + client_id = "11112222-bbbb-3333-cccc-4444dddd5555" + + register(PKCEAuthProviderPlugin) + monkeypatch.setattr( + auth_service, "DISPATCH_AUTHENTICATION_PROVIDER_SLUG", "dispatch-auth-provider-pkce" + ) + monkeypatch.setattr( + core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", entra_issuer.jwks_url + ) + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER", issuer) + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_AUDIENCE", client_id) + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_EMAIL_OVERRIDE", "preferred_username") + core_plugin.jwks_cache.clear() + yield entra_issuer + core_plugin.jwks_cache.clear() + + +def auth_header(signing_keys, **claim_overrides) -> dict: + pem, entry = signing_keys["current"] + token = jwt.encode( + entra_claims(**claim_overrides), pem, algorithm="RS256", headers={"kid": entry["kid"]} + ) + return {"Authorization": f"Bearer {token}"} + + +def test_an_entra_user_can_call_the_api_and_is_provisioned_on_the_way( + entra_deployment, api_client, signing_keys, session +): + """The success path, end to end and over the wire.""" + from dispatch.auth import service as auth_service + from dispatch.enums import UserRoles + + response = api_client.get("/default/incidents", headers=auth_header(signing_keys)) + + assert response.status_code == 200 + + user = auth_service.get_by_email(db_session=session, email=UPN) + assert user is not None + assert user.get_organization_role("default") == UserRoles.member + + +def test_a_foreign_tenants_token_is_refused_by_the_running_app( + entra_deployment, api_client, signing_keys, session +): + """Signed by the trusted key, rejected on its issuer, and no account left behind.""" + from dispatch.auth import service as auth_service + + response = api_client.get( + "/default/incidents", + headers=auth_header( + signing_keys, iss=f"https://login.microsoftonline.com/{OTHER_TENANT_ID}/v2.0" + ), + ) + + assert response.status_code == 401 + assert auth_service.get_by_email(db_session=session, email=UPN) is None + + +def test_an_unreachable_key_endpoint_refuses_rather_than_erroring( + entra_deployment, api_client, signing_keys +): + """A provider outage with a cold cache must be a 401, not a 500.""" + entra_deployment.shutdown() + + response = api_client.get("/default/incidents", headers=auth_header(signing_keys)) + + assert response.status_code == 401 diff --git a/tests/auth/test_oidc_entra.py b/tests/auth/test_oidc_entra.py new file mode 100644 index 000000000..f228eace8 --- /dev/null +++ b/tests/auth/test_oidc_entra.py @@ -0,0 +1,584 @@ +"""OpenID Connect id token validation, exercised with Microsoft Entra ID tokens. + +Dispatch authenticates against Entra through the generic PKCE provider, so +these are tests of that provider -- but written against the claims, issuer and +key handling a real Entra tenant produces, because the ways this integration +can be wrong are specific to what Entra actually mints. + +The load-bearing case is `test_a_token_from_another_entra_tenant_is_rejected`. +Entra serves overlapping signing keys across every tenant (the same `kid` is +published at /common and at an individual tenant's endpoint), so a valid +signature proves the token came from Microsoft, not that it came from *your* +directory. Only the issuer check makes that distinction. +""" + +from datetime import datetime, timedelta + +import pytest +import requests +from fastapi import HTTPException +from jose import jwt +from starlette.requests import Request + +import dispatch.plugins.dispatch_core.plugin as core_plugin +from dispatch.plugins.dispatch_core.plugin import PKCEAuthProviderPlugin + +TENANT_ID = "aaaabbbb-0000-cccc-1111-dddd2222eeee" +OTHER_TENANT_ID = "99990000-1111-2222-3333-444455556666" +CLIENT_ID = "11112222-bbbb-3333-cccc-4444dddd5555" +ISSUER = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" +JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys" +UPN = "ada@contoso.onmicrosoft.com" + + +def entra_claims(**overrides) -> dict: + """A v2.0 id token as the Entra /token endpoint issues it. + + Deliberately complete: `aio`, `rh` and `uti` are opaque claims Dispatch + must ignore, and `at_hash` is absent because Entra emits it only from + /authorize, never from the code-for-token exchange this flow uses. + """ + now = datetime.utcnow() + claims = { + "aud": CLIENT_ID, + "iss": ISSUER, + "iat": int(now.timestamp()), + "nbf": int(now.timestamp()), + "exp": int((now + timedelta(hours=1)).timestamp()), + "aio": "AXQAi/8TAAAA6ptu6Ge3s0Dw==", + "name": "Ada Lovelace", + "oid": "77778888-eeee-9999-ffff-0000aaaa1111", + "preferred_username": UPN, + "rh": "0.AR0AptQ.", + "sub": "8sKcMPmVJTHRr0NcXTaLZOsRUgAJoZ2FbtQ0lDBmzXQ", + "tid": TENANT_ID, + "uti": "bMxUYPMuTUCkAB2rCwUAAA", + "ver": "2.0", + } + claims.update(overrides) + return {k: v for k, v in claims.items() if v is not None} + + +class FakeJwksEndpoint: + """Stands in for login.microsoftonline.com/.../discovery/v2.0/keys.""" + + def __init__(self, keys): + self.keys = list(keys) + self.calls = 0 + self.error = None + + def get(self, url, timeout=None): + self.calls += 1 + if self.error: + raise self.error + endpoint = self + + class Response: + @staticmethod + def raise_for_status(): + return None + + @staticmethod + def json(): + return {"keys": endpoint.keys} + + return Response() + + +@pytest.fixture +def entra(monkeypatch, signing_keys): + """A configured, single-tenant Entra deployment with its JWKS served.""" + endpoint = FakeJwksEndpoint([signing_keys["current"][1]]) + monkeypatch.setattr(core_plugin.requests, "get", endpoint.get) + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", JWKS_URL) + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER", ISSUER) + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_AUDIENCE", CLIENT_ID) + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_EMAIL_OVERRIDE", "preferred_username") + core_plugin.jwks_cache.clear() + yield endpoint + core_plugin.jwks_cache.clear() + + +def id_token(claims, signing_keys, key="current", kid=None) -> str: + pem, entry = signing_keys[key] + return jwt.encode(claims, pem, algorithm="RS256", headers={"kid": kid or entry["kid"]}) + + +def bearer(token: str) -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/api/v1/default/incidents", + "headers": [(b"authorization", f"Bearer {token}".encode())], + "query_string": b"", + } + ) + + +def assert_rejected(token): + with pytest.raises(HTTPException) as exc: + PKCEAuthProviderPlugin().get_current_user(bearer(token)) + assert exc.value.status_code == 401 + + +# --- the success path ---------------------------------------------------- + + +def test_an_entra_id_token_identifies_its_user(entra, signing_keys): + """A well-formed v2.0 id token from the configured tenant authenticates.""" + token = id_token(entra_claims(), signing_keys) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == UPN + + +def test_an_entra_id_token_carries_no_at_hash_to_verify(entra, signing_keys): + """Entra emits at_hash only from /authorize, so the code flow needs no opt-out. + + DISPATCH_PKCE_DONT_VERIFY_AT_HASH exists for providers that do send one. + Pinned here because recommending it for Entra would be advising operators + to switch off a check for a claim their tokens never contain. + """ + assert "at_hash" not in entra_claims() + + token = id_token(entra_claims(), signing_keys) + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == UPN + + +# --- issuer: the tenant boundary ----------------------------------------- + + +def test_a_token_from_another_entra_tenant_is_rejected(entra, signing_keys): + """Signed by the very key this deployment trusts, but issued elsewhere. + + This is not hypothetical: Microsoft signs every tenant's tokens from a + shared key set, so the token below verifies. If the app registration is + ever switched to multitenant -- or a second registration names the same + client id -- the audience check stops isolating tenants too, and the + issuer is all that is left between a stranger's directory and a Dispatch + session that auto-provisions on first sight. + """ + foreign = entra_claims( + iss=f"https://login.microsoftonline.com/{OTHER_TENANT_ID}/v2.0", + tid=OTHER_TENANT_ID, + preferred_username="attacker@evil.onmicrosoft.com", + ) + + assert_rejected(id_token(foreign, signing_keys)) + + +def test_a_token_from_the_common_endpoint_is_rejected(entra, signing_keys): + """`/common` never appears in an `iss`; a token claiming it is not ours.""" + assert_rejected( + id_token(entra_claims(iss="https://login.microsoftonline.com/common/v2.0"), signing_keys) + ) + + +def test_a_token_with_no_issuer_is_rejected(entra, signing_keys): + """An expected issuer must be a requirement, not a comparison that is skipped.""" + assert_rejected(id_token(entra_claims(iss=None), signing_keys)) + + +def test_without_a_configured_issuer_any_tenant_is_accepted( + entra, signing_keys, monkeypatch, caplog +): + """The backwards-compatible default, pinned so the exposure stays visible. + + Existing PKCE deployments predate this setting and must keep working, so + an unset issuer cannot start rejecting tokens. What it must not do is be + silent: config.py warns at startup, and this is what that warning is about. + """ + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER", None) + foreign = entra_claims( + iss=f"https://login.microsoftonline.com/{OTHER_TENANT_ID}/v2.0", tid=OTHER_TENANT_ID + ) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(id_token(foreign, signing_keys))) == UPN + + +# --- audience ------------------------------------------------------------ + + +def test_a_token_issued_for_a_different_application_is_rejected(entra, signing_keys): + """A token for another app in the same tenant is not a Dispatch credential.""" + assert_rejected( + id_token(entra_claims(aud="00000003-0000-0000-c000-000000000000"), signing_keys) + ) + + +def test_a_token_is_rejected_when_no_audience_is_configured(entra, signing_keys, monkeypatch): + """Unset audience must fail closed rather than skip the check. + + Every Entra id token carries `aud`, and python-jose rejects a present + audience that nothing expects -- so a deployment that forgets + DISPATCH_JWT_AUDIENCE gets 401s, not an unchecked audience. + """ + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_AUDIENCE", None) + + assert_rejected(id_token(entra_claims(), signing_keys)) + + +# --- signature ----------------------------------------------------------- + + +def test_a_token_signed_by_a_key_entra_does_not_publish_is_rejected(entra, signing_keys): + """A key of the attacker's own, announced under a published kid.""" + forged = id_token(entra_claims(), signing_keys, key="next", kid="current-key") + + assert_rejected(forged) + + +# --- lifetime and clock skew --------------------------------------------- + + +def test_an_expired_id_token_is_rejected(entra, signing_keys): + now = datetime.utcnow() + expired = entra_claims(exp=int((now - timedelta(minutes=10)).timestamp())) + + assert_rejected(id_token(expired, signing_keys)) + + +def test_an_id_token_with_no_expiry_is_rejected(entra, signing_keys): + """OpenID Connect requires `exp`; without it the token never stops working.""" + assert_rejected(id_token(entra_claims(exp=None), signing_keys)) + + +def test_a_token_within_the_configured_clock_skew_is_accepted(entra, signing_keys, monkeypatch): + """Entra stamps `nbf` at issue time, so an unsynced host would 401 everyone.""" + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS", 60) + ahead = datetime.utcnow() + timedelta(seconds=30) + skewed = entra_claims(iat=int(ahead.timestamp()), nbf=int(ahead.timestamp())) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(id_token(skewed, signing_keys))) == UPN + + +def test_a_token_beyond_the_configured_clock_skew_is_rejected(entra, signing_keys, monkeypatch): + """Leeway is a tolerance, not a way to switch expiry off.""" + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS", 60) + long_expired = entra_claims( + exp=int((datetime.utcnow() - timedelta(minutes=30)).timestamp()), + ) + + assert_rejected(id_token(long_expired, signing_keys)) + + +# --- which claim carries the identity ------------------------------------ + + +def test_the_configured_claim_wins_over_the_email_claim(entra, signing_keys): + """`preferred_username` is the UPN; `email` is whatever the user set. + + Microsoft documents `email` as user-mutable and not guaranteed correct. + A deployment that names preferred_username must get the UPN even when both + claims are present and they disagree. + """ + token = id_token(entra_claims(email="ada.personal@example.com"), signing_keys) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == UPN + + +def test_the_email_claim_is_used_when_no_override_is_configured(entra, signing_keys, monkeypatch): + monkeypatch.setattr(core_plugin, "DISPATCH_JWT_EMAIL_OVERRIDE", None) + token = id_token(entra_claims(email="ada@contoso.com"), signing_keys) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == "ada@contoso.com" + + +def test_a_token_missing_the_configured_identity_claim_is_rejected(entra, signing_keys): + """The common Entra misconfiguration, and it must 401 rather than 500. + + `email` is not emitted for managed users unless the optional claim or the + email scope is configured, so pointing DISPATCH_JWT_EMAIL_OVERRIDE at a + claim the tenant does not issue is the first thing an operator gets wrong. + """ + assert_rejected(id_token(entra_claims(preferred_username=None), signing_keys)) + + +def test_a_token_whose_identity_claim_is_empty_is_rejected(entra, signing_keys): + """An empty string would otherwise become a Dispatch user with no email.""" + assert_rejected(id_token(entra_claims(preferred_username=""), signing_keys)) + + +# --- malformed credentials must not 500 ---------------------------------- + + +@pytest.mark.parametrize( + "token", + ["opaque-session-token", "a.b.c", "...", "ZXlKaGJHY2lPaUpTVXpJMU5pSjk"], + ids=["opaque", "three-empty-segments", "dots", "base64-but-not-a-jwt"], +) +def test_a_bearer_credential_that_is_not_a_jwt_is_rejected(entra, token): + """These once reached an unguarded b64decode/json.loads: an unauthenticated 500.""" + assert_rejected(token) + + +def test_a_token_with_no_kid_is_rejected(entra, signing_keys): + pem, _ = signing_keys["current"] + token = jwt.encode(entra_claims(), pem, algorithm="RS256") + + assert_rejected(token) + + +@pytest.mark.parametrize( + "headers", + [{}, {"authorization": "Bearer "}, {"authorization": "Basic dXNlcjpwYXNz"}], + ids=["absent", "empty-token", "wrong-scheme"], +) +def test_an_unusable_authorization_header_is_rejected(entra, headers): + request = Request( + { + "type": "http", + "method": "GET", + "path": "/api/v1/default/incidents", + "headers": [(k.encode(), v.encode()) for k, v in headers.items()], + "query_string": b"", + } + ) + + with pytest.raises(HTTPException) as exc: + PKCEAuthProviderPlugin().get_current_user(request) + assert exc.value.status_code == 401 + + +def test_an_unconfigured_provider_rejects_rather_than_erroring(monkeypatch, signing_keys): + """No JWKS url means requests.get(None): a 500 on every request.""" + monkeypatch.setattr(core_plugin, "DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS", None) + + assert_rejected(id_token(entra_claims(), signing_keys)) + + +# --- key handling: caching, rotation, provider outages ------------------- + + +def test_the_key_set_is_not_refetched_for_every_request(entra, signing_keys): + """Uncached, each authenticated API call blocks on login.microsoftonline.com.""" + token = id_token(entra_claims(), signing_keys) + + for _ in range(5): + PKCEAuthProviderPlugin().get_current_user(bearer(token)) + + assert entra.calls == 1 + + +def test_a_rotated_signing_key_is_picked_up_without_waiting_for_the_cache(entra, signing_keys): + """Entra rotates keys on its own schedule, mid-TTL. + + A cache that only refreshes on expiry would 401 every request signed by the + new key until the TTL ran out, so an unknown kid has to force one refetch. + """ + PKCEAuthProviderPlugin().get_current_user(bearer(id_token(entra_claims(), signing_keys))) + assert entra.calls == 1 + + entra.keys = [signing_keys["current"][1], signing_keys["next"][1]] + rotated = id_token(entra_claims(), signing_keys, key="next") + + assert PKCEAuthProviderPlugin().get_current_user(bearer(rotated)) == UPN + assert entra.calls == 2 + + +def test_an_unknown_kid_is_rejected_once_the_key_set_has_been_refreshed(entra, signing_keys): + """The refresh is an attempt to find the key, not a way to accept without one.""" + assert_rejected(id_token(entra_claims(), signing_keys, key="next", kid="never-published")) + + +def test_repeated_unknown_kids_do_not_refetch_the_key_set_each_time(entra, signing_keys): + """Otherwise a junk `kid` turns every request into a call to the provider. + + Two fetches, not one: the first request populates an empty cache, and its + unknown kid earns the one refresh that could have found a rotated key. The + four after it are inside the refresh floor and take no network at all. + """ + for _ in range(5): + assert_rejected(id_token(entra_claims(), signing_keys, kid="never-published")) + + assert entra.calls == 2 + + +def test_authentication_survives_a_provider_outage_once_keys_are_cached(entra, signing_keys): + """A login.microsoftonline.com blip must not log out every signed-in user.""" + token = id_token(entra_claims(), signing_keys) + PKCEAuthProviderPlugin().get_current_user(bearer(token)) + + entra.error = requests.ConnectionError("login.microsoftonline.com unreachable") + core_plugin.jwks_cache._entries[JWKS_URL]["fetched_at"] -= 10_000 + + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == UPN + + +def test_an_unreachable_key_endpoint_with_nothing_cached_is_rejected(entra, signing_keys): + """Fail closed, and as a 401 rather than an unhandled requests exception.""" + entra.error = requests.ConnectionError("login.microsoftonline.com unreachable") + + assert_rejected(id_token(entra_claims(), signing_keys)) + + +def test_a_key_endpoint_error_response_with_nothing_cached_is_rejected(entra, signing_keys): + entra.error = requests.HTTPError("503 Service Unavailable") + + assert_rejected(id_token(entra_claims(), signing_keys)) + + +# --- from an id token to a Dispatch user --------------------------------- + + +@pytest.fixture +def entra_login(entra, signing_keys, session, monkeypatch): + """Drive the whole chain: id token -> PKCE provider -> DispatchUser row. + + Uses the "default" organization init_database seeds, because auth.service + resolves the default project with one_or_none() -- a second default in the + same schema makes it raise rather than choose. + """ + from dispatch.auth import service as auth_service + from dispatch.plugins.base import register + + register(PKCEAuthProviderPlugin) + monkeypatch.setattr( + auth_service, "DISPATCH_AUTHENTICATION_PROVIDER_SLUG", "dispatch-auth-provider-pkce" + ) + + def _login(**claim_overrides): + request = bearer(id_token(entra_claims(**claim_overrides), signing_keys)) + request.state.db = session + request.state.organization = "default" + return auth_service.get_current_user(request) + + return _login + + +def test_signing_in_through_entra_provisions_a_dispatch_user(entra_login, session): + """First sight of a tenant member creates the account, with no admin step.""" + from dispatch.auth import service as auth_service + from dispatch.enums import UserRoles + + assert auth_service.get_by_email(db_session=session, email=UPN) is None + + user = entra_login() + + assert user.email == UPN + assert user.get_organization_role("default") == UserRoles.member + + +def test_a_second_sign_in_reuses_the_existing_account(entra_login, session): + """Identity is keyed on the email claim, so the same UPN is the same user.""" + first = entra_login() + session.commit() + + second = entra_login() + + assert second.id == first.id + + +def test_an_entra_user_matching_an_existing_dispatch_account_is_linked_to_it(entra_login, session): + """Account linking is by email, including for accounts created another way. + + Worth pinning because it is the security consequence of that choice: an + account that predates Entra -- a basic-auth owner, say -- is adopted by + whoever the directory hands that address to, with the role it already had. + Restricting the issuer to one tenant is what makes that safe, since only + that tenant can then assert the address. + """ + from dispatch.auth import service as auth_service + from dispatch.auth.models import UserRegister + from dispatch.enums import UserRoles + + existing = auth_service.create( + db_session=session, + organization="default", + user_in=UserRegister(email=UPN), + ) + auth_service.create_or_update_organization_role( + db_session=session, + user=existing, + role_in=_owner_of_default(session), + ) + session.commit() + + user = entra_login() + + assert user.id == existing.id + assert user.get_organization_role("default") == UserRoles.owner + + +def _owner_of_default(session): + from dispatch.auth.models import UserOrganization + from dispatch.enums import UserRoles + from dispatch.organization import service as organization_service + from dispatch.organization.models import OrganizationRead + + organization = organization_service.get_by_slug(db_session=session, slug="default") + return UserOrganization( + organization=OrganizationRead.from_orm(organization), role=UserRoles.owner + ) + + +def test_a_rejected_token_never_reaches_user_provisioning( + entra_login, entra, session, signing_keys +): + """A 401 has to stop before the row is created, not after. + + auth.service provisions on whatever email the provider returns, so a token + the provider should have rejected would otherwise become an account. + """ + from dispatch.auth import service as auth_service + + with pytest.raises(HTTPException) as exc: + entra_login(iss=f"https://login.microsoftonline.com/{OTHER_TENANT_ID}/v2.0") + + assert exc.value.status_code == 401 + assert auth_service.get_by_email(db_session=session, email=UPN) is None + + +# --- the at_hash opt-out ------------------------------------------------- + + +def test_a_token_carrying_an_at_hash_is_rejected_by_default(entra, signing_keys): + """Nothing here holds the access token, so an at_hash cannot be checked. + + Entra never sends one from /token, but providers that do would 401 -- which + is what DISPATCH_PKCE_DONT_VERIFY_AT_HASH exists to let an operator accept. + """ + assert_rejected(id_token(entra_claims(at_hash="uJiMMlv4pHhAJPGRKkPTUw"), signing_keys)) + + +def test_the_at_hash_check_can_be_turned_off(entra, signing_keys, monkeypatch): + monkeypatch.setattr(core_plugin, "DISPATCH_PKCE_DONT_VERIFY_AT_HASH", True) + token = id_token(entra_claims(at_hash="uJiMMlv4pHhAJPGRKkPTUw"), signing_keys) + + assert PKCEAuthProviderPlugin().get_current_user(bearer(token)) == UPN + + +def test_setting_the_at_hash_opt_out_to_false_does_not_turn_the_check_off(monkeypatch): + """It is read as a bool, not as a non-empty string. + + Uncast, `DISPATCH_PKCE_DONT_VERIFY_AT_HASH=false` is the string "false" -- + truthy -- so an operator writing the value that means "keep verifying" + switched verification off instead, with nothing to indicate it. + """ + import importlib + + from dispatch import config + + monkeypatch.setenv("DISPATCH_PKCE_DONT_VERIFY_AT_HASH", "false") + try: + reloaded = importlib.reload(config) + assert reloaded.DISPATCH_PKCE_DONT_VERIFY_AT_HASH is False + finally: + monkeypatch.undo() + importlib.reload(config) + + +def test_a_token_with_no_audience_is_rejected_when_one_is_expected(entra, signing_keys): + """An expected audience has to be a requirement, not a check that is skipped. + + python-jose passes a token that simply omits `aud`, so requiring the claim + has to be stated separately from requiring it to match. + """ + assert_rejected(id_token(entra_claims(aud=None), signing_keys)) + + +def test_an_identity_claim_that_is_not_a_string_is_rejected(entra, signing_keys): + """Whatever this claim holds becomes a Dispatch account, so it must be text.""" + token = id_token(entra_claims(preferred_username=["ada@contoso.com"]), signing_keys) + + assert_rejected(token) From 879d529f171828209cbd7e27905395a566b7de99 Mon Sep 17 00:00:00 2001 From: Jamyn Shanley <696574+Jamyn@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:20:06 +0000 Subject: [PATCH 2/2] docs: document Microsoft Entra ID authentication --- docker/.env.example | 14 + .../settings/authentication-entra-id.mdx | 247 ++++++++++++++++++ docs/docs/administration/settings/server.mdx | 61 ++++- 3 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 docs/docs/administration/settings/authentication-entra-id.mdx diff --git a/docker/.env.example b/docker/.env.example index 7d201ddfc..10e286252 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -20,6 +20,20 @@ LOG_LEVEL=INFO STATIC_DIR="" DISPATCH_AUTHENTICATION_PROVIDER_SLUG=dispatch-auth-provider-basic +# To develop against an OpenID Connect provider (Okta, Google, Keycloak, +# Microsoft Entra ID) instead, set the slug above to dispatch-auth-provider-pkce +# and fill these in. The VITE_-prefixed one is read when the frontend bundle is +# built, not when the server starts. See +# docs/docs/administration/settings/authentication-entra-id.mdx for a worked +# Entra example. None of these is a secret. +# DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS= +# DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER= +# DISPATCH_AUTHENTICATION_PROVIDER_PKCE_CLIENT_ID= +# DISPATCH_AUTHENTICATION_PROVIDER_USE_ID_TOKEN=true +# DISPATCH_JWT_AUDIENCE= +# DISPATCH_JWT_EMAIL_OVERRIDE= +# VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL= + # Postgres container. The image applies POSTGRES_PASSWORD only while it # initializes the data directory, so dev-setup.sh will not regenerate it once # the volume holds a cluster -- the server would keep the old password. diff --git a/docs/docs/administration/settings/authentication-entra-id.mdx b/docs/docs/administration/settings/authentication-entra-id.mdx new file mode 100644 index 000000000..e89bfdb88 --- /dev/null +++ b/docs/docs/administration/settings/authentication-entra-id.mdx @@ -0,0 +1,247 @@ +--- +description: >- + Configuring Dispatch to authenticate users against Microsoft Entra ID using + OpenID Connect. +--- + +# Authenticating with Microsoft Entra ID + +Dispatch authenticates against Microsoft Entra ID through its generic OpenID +Connect provider, `dispatch-auth-provider-pkce`. There is no Entra-specific +plugin and none is needed: the browser runs the OAuth 2.0 authorization code +flow with PKCE against your tenant, and the Dispatch API validates the resulting +**ID token** on every request. + +This page is the complete configuration for a single organization's internal +Dispatch deployment. For the meaning of each individual setting, see +[Server](server#authentication). + +:::warning +Everyone who can sign in gets an account. Dispatch creates a `Member` user the +first time an identity it accepts makes a request — there is no approval step and +no allow list. The tenant boundary *is* the authorization boundary, which is why +`DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER` below is not optional in practice. +See [Who can sign in](#who-can-sign-in). +::: + +## Create the app registration + +In the [Microsoft Entra admin center](https://entra.microsoft.com), go to +**Identity → Applications → App registrations → New registration**. + +| Field | Value | +| --- | --- | +| **Name** | `Dispatch` | +| **Supported account types** | **Accounts in this organizational directory only (Single tenant)** | +| **Redirect URI** | Platform **Single-page application (SPA)**, URI `https://dispatch.example.com/implicit/callback` | + +Record the **Application (client) ID** and the **Directory (tenant) ID** from the +registration's Overview page. + +### Supported account types + +Choose **single tenant**. Dispatch has no per-user approval step, so a multitenant +registration means any Microsoft work, school, or personal account in the world +can sign in and be provisioned. Only pick multitenant if you genuinely intend to +admit guests from other directories, and pair it with a separate authorization +control — the issuer setting below can only pin one tenant. + +### Redirect URI + +The redirect URI must be registered under the **Single-page application** +platform, not **Web**. That is what makes Entra: + +* serve CORS headers on the token endpoint, so the browser can redeem the code, and +* accept the code redemption without a client secret. + +Registering it as **Web** produces `AADSTS9002326: Cross-origin token redemption +is permitted only for the 'Single-Page Application' client-type` at the token +exchange. + +The path is fixed: Dispatch's frontend always redirects to `/implicit/callback` +on its own origin and restores the page you were on from browser storage, so only +this one URI needs registering however many routes the app has. Add +`http://localhost:8080/implicit/callback` as a second URI if you want the local +dev server to sign in against the same registration. + +### Client secret + +**Do not create one.** A single-page application is a public client; Microsoft +documents that SPAs must not use secrets or certificates when redeeming an +authorization code, and Entra refuses client credentials on any request carrying +an `Origin` header. Dispatch never sends one and has nowhere to put one that the +browser could not read. PKCE, not a secret, is what binds the code to this client. + +### Scopes and permissions + +`openid`, `profile` and `email` — the three OpenID Connect scopes the frontend +already requests. Nothing else. These are delegated permissions that need no +admin consent, and none of them is a Microsoft Graph permission: Dispatch reads +the user's identity out of the ID token and never calls Graph. + +Leave **API permissions** at the `User.Read` default Entra adds, or remove it. +Dispatch does not use it. + +### Token claims + +Dispatch needs exactly one claim: an email address to key the user on. By default +it reads `email`, but Entra emits `email` for a managed user only if you add it as +an optional claim or request the `email` scope, and Microsoft documents the value +as user-mutable and not guaranteed to be correct. + +Use `preferred_username` instead, which is the user principal name, is issued +with the `profile` scope, and is controlled by the directory: + +```text +DISPATCH_JWT_EMAIL_OVERRIDE=preferred_username +``` + +No optional claims configuration is required. If your UPNs are not routable email +addresses — Dispatch also uses this value to match people to incidents and to +address notifications — add `email` under **Token configuration → Add optional +claim → ID → email** and leave `DISPATCH_JWT_EMAIL_OVERRIDE` unset instead. + +## Configure Dispatch + +With `` and `` from the registration: + +```bash +# Select the OIDC provider +DISPATCH_AUTHENTICATION_PROVIDER_SLUG=dispatch-auth-provider-pkce + +# Backend: which tokens to accept +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS=https://login.microsoftonline.com//discovery/v2.0/keys +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER=https://login.microsoftonline.com//v2.0 +DISPATCH_JWT_AUDIENCE= +DISPATCH_JWT_EMAIL_OVERRIDE=preferred_username + +# Frontend: how to obtain one +VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL=https://login.microsoftonline.com//v2.0 +DISPATCH_AUTHENTICATION_PROVIDER_PKCE_CLIENT_ID= +DISPATCH_AUTHENTICATION_PROVIDER_USE_ID_TOKEN=true +``` + +Notes on the less obvious ones: + +* **`..._OPEN_ID_CONNECT_URL` is the issuer, with no trailing slash and no + `.well-known` suffix.** The frontend appends + `/.well-known/openid-configuration` itself. +* **`..._USE_ID_TOKEN=true` is required, not a preference.** Without it the + frontend sends Entra's *access* token to the Dispatch API. With only OIDC + scopes requested that token is issued for Microsoft Graph, not for Dispatch — + Microsoft's guidance is not to read or validate tokens for an API you do not + own, and its format is not guaranteed to be a validatable JWT. +* **`DISPATCH_JWT_AUDIENCE` is the client id.** An Entra ID token's `aud` is the + application it was issued for. If it is left unset, every token is rejected. +* Settings prefixed `VITE_` are read when the frontend bundle is **built**, not + when the server starts — see [Frontend build](#frontend-build). + +### Restricting sign-in to your tenant + +Two things do this, and both matter: + +1. **The registration is single tenant**, so Entra only ever mints a token with + your `aud` for your own directory's users. +2. **`DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER` pins the tenant.** Entra signs + every tenant's tokens from a shared key set — the same key ids are published at + `/common` and at an individual tenant's `discovery/v2.0/keys` — so a valid + signature only proves the token came from Microsoft. A tenant-scoped JWKS URL + does not narrow that. The issuer check is what ties a token to your directory, + and it is the only control that still holds if the registration is ever + switched to multitenant. + +Restrict *which* users in the tenant may sign in with Entra's own controls, not +with Dispatch's: set **Enterprise applications → Dispatch → Properties → +Assignment required** to **Yes** and assign the groups that should have access. +Dispatch has no equivalent. + +### Frontend build + +The three `VITE_`-prefixed values are compiled into the JavaScript bundle, so +changing them means rebuilding the frontend, not restarting the server. When +building the container image, pass them as build arguments: + +```bash +docker build \ + --build-arg VITE_DISPATCH_AUTHENTICATION_PROVIDER_SLUG=dispatch-auth-provider-pkce \ + --build-arg VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_CLIENT_ID= \ + --build-arg VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL=https://login.microsoftonline.com//v2.0 \ + --build-arg VITE_DISPATCH_AUTHENTICATION_PROVIDER_USE_ID_TOKEN=true \ + -f docker/Dockerfile . +``` + +The backend settings are ordinary environment variables and take effect on +restart. None of them is a secret, so they are safe in a Compose file or an image +build log — there is no client secret anywhere in this configuration. + +## Who can sign in + +Dispatch provisions on first sight. The first request carrying an ID token it +accepts creates a `DispatchUser` with the `Member` role in the organization being +addressed; no administrator action is involved. `Member` cannot administer the +deployment, but it can read and act on incidents and cases. + +Consequently: + +* **Restrict access in Entra, not in Dispatch.** Use *Assignment required* plus + group assignment on the enterprise application. +* **Promote deliberately.** Grant `Admin` or `Owner` from + **Settings → Users**, or with + `dispatch user update --role owner --organization `. + +### Existing Dispatch users + +Users are keyed by email address, and that does not change when you enable Entra. +An account that already exists — created by basic auth self-registration, by an +administrator, or by an earlier OIDC provider — is matched by email and keeps its +existing roles, projects and history the first time the same address signs in +through Entra. No migration or re-provisioning step is required. + +The flip side is that whoever the directory hands an address to inherits the +Dispatch account holding it. That is exactly why the issuer must be pinned to the +one tenant that controls those mailboxes. Before switching a deployment over, +review existing accounts for addresses your tenant does not own — external +contractors, shared mailboxes, accounts created for testing — and remove or +rename any that should not be claimable. + +:::info +Dispatch deliberately does not key users on Entra's immutable `oid`/`sub`. Email is +the join key throughout the product — for incident participants, contact +resolution, and every messaging plugin — so an opaque identifier would have to be +resolved back to an address anyway. The tenant restriction, not the claim's +immutability, is what makes the address trustworthy. +::: + +## Verifying it works + +1. Restart the server (and rebuild the frontend) with the settings above. +2. Open Dispatch. You should be redirected to `login.microsoftonline.com` and back. +3. Check the server log at startup. The PKCE provider warns about anything it + needs and does not have — a missing JWKS URL, issuer, or audience. + +### Troubleshooting + +| Symptom | Cause | +| --- | --- | +| `AADSTS9002326`, cross-origin token redemption | Redirect URI registered as **Web**; change it to **Single-page application**. | +| CORS error on `/token` | Same as above. | +| Redirect loop, or 401 on every API call after a successful sign-in | `DISPATCH_AUTHENTICATION_PROVIDER_USE_ID_TOKEN` is not `true`, so the Graph access token is being sent. | +| 401 with *Token rejected: Invalid audience* in the log | `DISPATCH_JWT_AUDIENCE` unset or not the client id. | +| 401 with *Token rejected: Invalid issuer* | Tenant id in `..._PKCE_ISSUER` does not match the tenant that signed in. | +| 401 with *Token carries no 'email' claim* | Set `DISPATCH_JWT_EMAIL_OVERRIDE=preferred_username`, or add the `email` optional claim. | +| `AADSTS50011`, redirect URI mismatch | The registered URI must be the deployment origin plus `/implicit/callback`, exactly. | + +## Reverting to another provider + +There is nothing to undo in the database — Entra sign-in creates ordinary +Dispatch users. To go back to basic authentication, set + +```bash +DISPATCH_AUTHENTICATION_PROVIDER_SLUG=dispatch-auth-provider-basic +DISPATCH_JWT_SECRET= +``` + +rebuild the frontend with the matching `VITE_DISPATCH_AUTHENTICATION_PROVIDER_SLUG`, +and restart. Accounts provisioned through Entra remain, but were created without a +usable password, so each user needs a password reset from an owner before they can +sign in again. Delete the app registration in Entra once you no longer need it. diff --git a/docs/docs/administration/settings/server.mdx b/docs/docs/administration/settings/server.mdx index 1e8a2daff..1e1ac8808 100644 --- a/docs/docs/administration/settings/server.mdx +++ b/docs/docs/administration/settings/server.mdx @@ -89,11 +89,14 @@ For this plugin to work, you need to set `DISPATCH_JWT_SECRET`. #### `DISPATCH_JWT_AUDIENCE` -> Override what the `Audience` is expected to be in the PKCE JWT decode +> The `aud` the PKCE provider requires an id token to carry — normally the OIDC +> client id. Tokens issued for a different application are rejected. If your +> provider sets `aud` and this is unset, every token is rejected. #### `DISPATCH_JWT_EMAIL_OVERRIDE` -> Override where Dispatch should find the user email in the idtoken. +> Override where Dispatch should find the user email in the idtoken. Defaults to +> the `email` claim. A token with no value for the configured claim is rejected. #### `DISPATCH_AUTHENTICATION_DEFAULT_USER` \['default': dispatch@example.com\] @@ -101,9 +104,18 @@ For this plugin to work, you need to set `DISPATCH_JWT_SECRET`. #### Configuration for `dispatch-auth-provider-pkce` +This is Dispatch's generic OpenID Connect provider. It authenticates the browser +with the authorization code flow plus PKCE, and validates the resulting token on +every API request. It works with any spec-compliant OIDC provider — Okta, Google, +Keycloak, Microsoft Entra ID. For a worked Entra example see +[Authenticating with Microsoft Entra ID](authentication-entra-id). + :::info -For this plugin to work with your OIDC setup, you may need to set -`DISPATCH_JWT_AUDIENCE` and `DISPATCH_PKCE_DONT_VERIFY_AT_HASH`. +For this plugin to work with your OIDC setup, you need to set +`DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS`, +`DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER` and `DISPATCH_JWT_AUDIENCE`. You +may additionally need `DISPATCH_JWT_EMAIL_OVERRIDE` and +`DISPATCH_PKCE_DONT_VERIFY_AT_HASH`, depending on what your provider issues. ::: #### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS` @@ -112,10 +124,49 @@ For this plugin to work with your OIDC setup, you may need to set > This will likely be the `jwks_uri` URL from your OIDC provider. > This is required when using the `dispatch-auth-provider-pkce` auth provider. +#### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER` + +> The exact `iss` value your provider mints, for example +> `https://login.microsoftonline.com//v2.0`. Tokens carrying any other +> issuer are rejected. + +:::warning +Leaving this unset accepts any issuer whose signature the configured JWKS +validates. Some providers — Microsoft Entra ID among them — sign every tenant's +tokens with a shared key set, so without an expected issuer a valid signature +does not establish which directory the user came from. Dispatch logs a warning at +startup when the PKCE provider is selected and this is unset. It has no default so +that existing deployments keep working, but a new deployment should always set it. +::: + +#### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_CACHE_SECONDS` \['default': 300\] + +> How long the provider's signing keys are cached. Keys are also refetched +> immediately when a token arrives with a key id Dispatch has not seen, so a +> rotated signing key is picked up without waiting for this to expire. + +#### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_JWKS_TIMEOUT_SECONDS` \['default': 5\] + +> Request timeout when fetching the JWKS. If the fetch fails and keys are already +> cached, the cached keys keep being used rather than failing every request for +> the duration of the provider's outage. + +#### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_LEEWAY_SECONDS` \['default': 60\] + +> Clock skew tolerated when checking `exp`, `nbf` and `iat`. Set to `0` to require +> exact agreement with the identity provider's clock. + #### `DISPATCH_PKCE_DONT_VERIFY_AT_HASH` \['default': false\] > Depending on what values your OIDC provider sends, you may need to set this to `true` for the Dispatch backend -> to be able to decode the JWT token. +> to be able to decode the JWT token. Accepts `true`/`false` (or `1`/`0`) only; +> any other value is rejected at startup. + +:::info +Microsoft Entra ID emits `at_hash` only from its `/authorize` endpoint, never from +the code-for-token exchange this flow uses, so an Entra deployment does not need +this setting. +::: #### `DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL`