diff --git a/docker/.env.example b/docker/.env.example index 7d201ddf..10e28625 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 00000000..e89bfdb8 --- /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 1e8a2daf..1e1ac880 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` diff --git a/src/dispatch/config.py b/src/dispatch/config.py index 754ae727..74d12d80 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 8bdd63e1..501a01e6 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 3d028523..5c3b89f7 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 486d0966..2be011ae 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 00000000..2ce95605 --- /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 00000000..f228eace --- /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)