# Entra External ID

> Set up Microsoft Entra External ID for AppointMe on Azure, covering the tenant, app registration, redirect URIs, API scope, user flow and Graph access, plus the config keys the .NET API reads in place of Keycloak.

Source page: https://appointme.dev/docs/deploy/entra-external-id/

* **You'll build:** An External ID tenant the API trusts
* **Time:** About 30 minutes in the portal
* **Touches:** `appsettings.Devtest.json`, Key Vault
* **Before you start:** The `appServiceUrl` output from Bicep

## How sign-in works on Azure

**The API is a confidential OIDC client. Browsers get a cookie through the authorization code flow, API callers send a bearer token, and both trust the same External ID authority.**

*Figure 1. Browser sign-in. A bearer token skips the redirect and is validated directly.*

1. **SPA** — redirects to `GET /api/v1/login`
2. **External ID** — runs the user flow at `<tenant>.ciamlogin.com`
3. **/signin-oidc** — the API redeems the code with its client secret
4. **Cookie** — `appointme.auth`, 7 days, sliding

The API talks to External ID in four ways, and each needs something in the tenant:

| What                                | Used for                                                                               | Needs in the tenant                                     | Config                               |
| ----------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------ |
| OIDC code flow                      | Browser sign-in. Tokens never reach the SPA.                                           | A Web redirect URI and a client secret                  | `Authentication:EntraExternalId`     |
| JWT bearer                          | Calls that send `Authorization: Bearer`                                                | An exposed API (the audience)                           | `Authority`, `ApiAudience`           |
| Microsoft Graph, client credentials | Sign-up and team invitations: `POST /invitations`, plus a `GET /users` lookup by email | Graph application permissions, with admin consent       | `EntraIdentity`                      |
| The native authentication API       | The demo login at `/api/v1/login/demo`                                                 | Public client flows and native authentication turned on | `Authority`, `ClientId`, `Demo:User` |

One setting picks the provider. `Authentication:Provider` defaults to `Keycloak`, and the API rejects any value other than `Keycloak` or `EntraExternalId`:

src/AppointMe.Api/Authentication/AuthenticationExtensions.cs

```cs
            var providerName = configuration["Authentication:Provider"] ?? "Keycloak";
            switch (providerName)
            {
                case "Keycloak":
                    services.AddKeycloakAuthOptions(configuration);
                    break;
                case "EntraExternalId":
                    services.AddEntraExternalIdAuthOptions(configuration);
                    break;
                default:
                    throw new InvalidOperationException(
                        $"Unknown Authentication:Provider '{providerName}'. Expected 'Keycloak' or 'EntraExternalId'.");
            }
```

## Set up the tenant and the app

**You do all of this in the Microsoft Entra admin center. None of it is in Bicep, because you can only create the tenant in the portal.**

1. **Create the External ID tenant.** In the Azure portal go to **Microsoft Entra ID → External ID → Create a new tenant**, and pick a subdomain for `<tenant>.ciamlogin.com`. Note the tenant ID, which goes into the authority URL and `EntraIdentity:TenantId`.

2. **Register the app.** In the new tenant open **App registrations → New registration**. One registration serves both the OIDC client and the API.

   | Field                   | Value                                                                                                         |
   | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
   | Name                    | For example `appointme-api-devtest`                                                                           |
   | Supported account types | Accounts in any organizational directory and personal Microsoft accounts, or narrower if your policy requires |
   | Redirect URI (Web)      | `https://<web-app-name>.azurewebsites.net/signin-oidc`                                                        |

   If you add a [custom domain](/docs/deploy/azure/#optional-custom-domain), also register `https://<your-host>/signin-oidc`. The callback path is fixed in code (`oidc.CallbackPath = "/signin-oidc"`).

3. **Leave implicit grant off.** Under **Authentication → Implicit grant and hybrid flows**, leave both boxes unticked. The API uses the authorization code flow (`OpenIdConnectResponseType.Code`).

4. **Create a client secret.** Under **Certificates & secrets → New client secret**, create one and copy the value immediately. It goes into Key Vault as `EntraExternalIdClientSecret`, never into a file.

5. **Expose an API.** Under **Expose an API → Add a scope**, define a scope such as `access_as_user`. The Application ID URI, for example `api://<client-id>`, is your `ApiAudience`.

6. **Create a user flow.** Under **User flows → New user flow**, create a sign-up and sign-in flow and add the app registration to it. For the demo login, the flow must sign users in with email and password.

7. **Grant Graph permissions.** The Identity module creates users by sending Graph invitations. It calls Graph with the app’s own client ID and secret, using the `https://graph.microsoft.com/.default` scope. Under **API permissions**, add the Microsoft Graph application permissions [`User.Invite.All`](https://learn.microsoft.com/graph/api/invitation-post) and [`User.Read.All`](https://learn.microsoft.com/graph/api/user-list), then grant admin consent.

8. **Turn on native authentication (demo login only).** External ID doesn’t support the password grant, so the demo login uses the native authentication API instead. Under **Authentication**, set **Enable the following mobile and desktop flows** to *Yes* and **Enable native authentication** to *Yes* ([Microsoft’s steps](https://learn.microsoft.com/entra/identity-platform/concept-native-authentication)). Then create the demo user in the tenant with the email and password you’ll put in `Demo:User`.

9. **Fill in `appsettings.Devtest.json`.** Copy the example file, then replace the placeholders with your tenant’s values.

   ```bash
   cp src/AppointMe.Api/appsettings.Devtest.example.json src/AppointMe.Api/appsettings.Devtest.json
   ```

   src/AppointMe.Api/appsettings.Devtest.example.json

   ```json
     "Authentication": {
       "Provider": "EntraExternalId",
       "RequireHttpsMetadata": true,
       "SuperAdmins": [ "<super-admin-email>" ],
       "EntraExternalId": {
         "Authority": "https://<your-tenant>.ciamlogin.com/<your-tenant-id>/v2.0",
         "ClientId": "<your-api-app-client-id>",
         "ApiAudience": "api://<your-api-app-client-id>"
       }
     },
     "EntraIdentity": {
       "TenantId": "<your-tenant-id>",
       "ClientId": "<your-api-app-client-id>"
     },
   ```

   Also set `Frontend:BaseUrl` to the public URL and fill in the `Demo` block, or set `Demo:Enabled` to `false`. Leave out both client secrets: the [Bicep](/docs/deploy/azure/) app settings fill them from Key Vault.

Gotcha

In the repo, `appsettings.Devtest.json` is committed and holds BravoDev’s live tenant IDs and demo password. A project generated from the template doesn’t ship that file, so you create it from the `.example.json`. If you forked the repo instead, overwrite every value in it.

## Config keys the API reads

**Ten keys configure External ID. Two of them are secrets that come from Key Vault, and the rest are in `appsettings.Devtest.json`.**

| Key                                           | Value                                                       | Set in                                   |
| --------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------- |
| `Authentication:Provider`                     | `EntraExternalId`                                           | `appsettings.Devtest.json`               |
| `Authentication:RequireHttpsMetadata`         | `true` (also the default in code)                           | `appsettings.Devtest.json`               |
| `Authentication:EntraExternalId:Authority`    | `https://<tenant>.ciamlogin.com/<tenant-id>/v2.0`           | `appsettings.Devtest.json`               |
| `Authentication:EntraExternalId:ClientId`     | Application (client) ID                                     | `appsettings.Devtest.json`               |
| `Authentication:EntraExternalId:ClientSecret` | The client secret                                           | Key Vault: `EntraExternalIdClientSecret` |
| `Authentication:EntraExternalId:ApiAudience`  | Application ID URI, `api://<client-id>`                     | `appsettings.Devtest.json`               |
| `EntraIdentity:TenantId`                      | Tenant ID                                                   | `appsettings.Devtest.json`               |
| `EntraIdentity:ClientId`                      | The same client ID                                          | `appsettings.Devtest.json`               |
| `EntraIdentity:ClientSecret`                  | The same client secret                                      | Key Vault: `EntraExternalIdClientSecret` |
| `Frontend:BaseUrl`                            | The public URL, where users land after sign-in and sign-out | `appsettings.Devtest.json`               |

`EntraExternalIdOptions` and `EntraIdentityOptions` mark every property `[Required]` and are validated at startup. An empty value stops the API before it serves a request, rather than failing at the first sign-in. [Configuration](/docs/reference/configuration/) lists every other key.

## Keycloak locally, External ID on Azure

**The same switch, `Authentication:Provider`, is read in three places. The rest of the app sees the same normalized claims from either provider.**

| Concern                                    | Keycloak (local)                                                                                      | Entra External ID (Azure)                                                                          |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| Selected when `Authentication:Provider` is | `Keycloak`, the default                                                                               | `EntraExternalId`                                                                                  |
| Sign-in config                             | `Authentication:Keycloak`: `Authority`, `FrontendClientId`, `FrontendClientSecret`, `ApiAudience`     | `Authentication:EntraExternalId`: `Authority`, `ClientId`, `ClientSecret`, `ApiAudience`           |
| Creating users                             | `KeycloakAdmin` → Keycloak admin API. New users get the `VERIFY_EMAIL` and `UPDATE_PASSWORD` actions. | `EntraIdentity` → a Graph invitation email                                                         |
| Demo login                                 | Password grant (ROPC) against the realm client                                                        | Native auth: `initiate` → `challenge` → `token`                                                    |
| Claims transformer                         | `KeycloakClaimsTransformer`: `sub`, `name`, `email`                                                   | `EntraExternalIdClaimsTransformer`: `sub` or `oid`, then `email`, `emails` or `preferred_username` |
| `?provider=google` on login                | Sent as `kc_idp_hint`, which skips straight to Google                                                 | Ignored. The user flow shows whatever identity providers you’ve added to it.                       |
| Where it runs                              | An Aspire container with the `appointme` realm imported                                               | Your tenant, managed in the portal                                                                 |

The three places are `AddAppointMeAuthentication` (OIDC and JWT), `AddDemoMode` (the demo authenticator) and `AddIdentityModule` (`IIdentityProvider`). [Identity & principal](/docs/concepts/identity-and-principal/) explains what happens after the claims are normalized.

### Why does the demo login use native authentication?

The demo button signs a fixed user in without sending the browser to the identity provider: the API authenticates on the server, then sets the cookie itself. Keycloak supports this through the password grant. External ID doesn’t support ROPC at all.

The native authentication API is External ID’s way to sign in with an email and password without a redirect, so `EntraExternalIdDemoUserAuthenticator` calls its `initiate`, `challenge` and `token` endpoints in turn. It insists on a `password` challenge, and treats anything else, such as a browser fallback, as a failure.

## Verify it works

**Sign-in round-trips through your tenant, and sign-up sends an invitation email.**

| Check                                          | Expected                                                                                                 |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Open the app and choose **Sign in**            | A redirect to `<tenant>.ciamlogin.com`, then back to `Frontend:BaseUrl`, signed in                       |
| Sign up with a new email                       | An invitation email. After redeeming it, the user lands on `<BaseUrl>/auth/login?returnUrl=/onboarding`. |
| `GET /api/v1/login/demo` (with `Demo:Enabled`) | A redirect to `Frontend:BaseUrl`, signed in as the demo user                                             |
| `GET /api/v1/login/demo` (demo off)            | `404`                                                                                                    |

## Troubleshooting

**Most failures are a redirect URI, a missing Graph consent, or native auth left switched off.**

| Symptom                                                                                                 | Cause                                                                                                                                                          | Fix                                                                                                                                                                                |
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `AADSTS50011` after you click Sign in                                                                   | The redirect URI the API sent isn’t registered                                                                                                                 | Register `https://<host>/signin-oidc` for every host you serve, including the custom domain                                                                                        |
| The redirect URI uses the `azurewebsites.net` host behind the Worker                                    | `X-Original-Host` isn’t arriving                                                                                                                               | Check `PUBLIC_HOST` and the `routes` pattern in the Worker, and that the request came through the Worker’s route. `Program.cs` reads the header through `ForwardedHostHeaderName`. |
| The API stops at startup with a validation error for `EntraExternalIdOptions` or `EntraIdentityOptions` | A required value is empty or missing in `appsettings.Devtest.json`                                                                                             | Fill in every key in the table above                                                                                                                                               |
| Sign-in fails with `AADSTS7000215` (invalid client secret)                                              | The secret in Key Vault is wrong or expired, or its Key Vault reference didn’t resolve. App Service then passes the literal `@Microsoft.KeyVault(...)` string. | Check the reference status on the Web App’s **Environment variables** page, fix the secret, and restart                                                                            |
| Sign-up fails, and the logs show `Unhandled Graph error in POST /invitations`                           | No Graph application permission or no admin consent                                                                                                            | Add `User.Invite.All` and `User.Read.All`, then grant consent                                                                                                                      |
| Sign-up returns `invalid_email_for_idp`                                                                 | Graph rejected the address, often a `+` alias                                                                                                                  | Use a different email. The API tells the user the same thing.                                                                                                                      |
| The demo login returns `502 Demo login failed.`                                                         | Native auth is off, the user flow isn’t email and password, or the demo user doesn’t exist                                                                     | The log line `Entra demo login: expected a 'password' challenge` points at native auth                                                                                             |
| API calls with a bearer token return `401`                                                              | The token’s audience isn’t `ApiAudience`                                                                                                                       | Request a token for your `api://<client-id>` scope                                                                                                                                 |

## Next

* [Deploy to Azure with Bicep](/docs/deploy/azure/) (Deploy)
* [Identity](/docs/modules/identity/) (Module)
* [Configuration](/docs/reference/configuration/) (Reference)
