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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
247 changes: 247 additions & 0 deletions docs/docs/administration/settings/authentication-entra-id.mdx
Original file line number Diff line number Diff line change
@@ -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 `<tenant-id>` and `<client-id>` 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/<tenant-id>/discovery/v2.0/keys
DISPATCH_AUTHENTICATION_PROVIDER_PKCE_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
DISPATCH_JWT_AUDIENCE=<client-id>
DISPATCH_JWT_EMAIL_OVERRIDE=preferred_username

# Frontend: how to obtain one
VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL=https://login.microsoftonline.com/<tenant-id>/v2.0
DISPATCH_AUTHENTICATION_PROVIDER_PKCE_CLIENT_ID=<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=<client-id> \
--build-arg VITE_DISPATCH_AUTHENTICATION_PROVIDER_PKCE_OPEN_ID_CONNECT_URL=https://login.microsoftonline.com/<tenant-id>/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 <org> <email>`.

### 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=<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.
61 changes: 56 additions & 5 deletions docs/docs/administration/settings/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,33 @@ 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\]

> Used as the default anonymous user when authentication is disabled.

#### 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`
Expand All @@ -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/<tenant-id>/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`

Expand Down
Loading
Loading