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.
- 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
appServiceUrloutput from Bicep
How sign-in works on Azure
Section titled “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.
GET /api/v1/login<tenant>.ciamlogin.comappointme.auth, 7 days, slidingThe 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:
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
Section titled “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.
-
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 andEntraIdentity:TenantId. -
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-devtestSupported 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-oidcIf you add a custom domain, also register
https://<your-host>/signin-oidc. The callback path is fixed in code (oidc.CallbackPath = "/signin-oidc"). -
Leave implicit grant off. Under Authentication → Implicit grant and hybrid flows, leave both boxes unticked. The API uses the authorization code flow (
OpenIdConnectResponseType.Code). -
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. -
Expose an API. Under Expose an API → Add a scope, define a scope such as
access_as_user. The Application ID URI, for exampleapi://<client-id>, is yourApiAudience. -
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.
-
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/.defaultscope. Under API permissions, add the Microsoft Graph application permissionsUser.Invite.AllandUser.Read.All, then grant admin consent. -
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). Then create the demo user in the tenant with the email and password you’ll put in
Demo:User. -
Fill in
appsettings.Devtest.json. Copy the example file, then replace the placeholders with your tenant’s values.Terminal window cp src/AppointMe.Api/appsettings.Devtest.example.json src/AppointMe.Api/appsettings.Devtest.jsonsrc/AppointMe.Api/appsettings.Devtest.example.jsonView source ↗ "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:BaseUrlto the public URL and fill in theDemoblock, or setDemo:Enabledtofalse. Leave out both client secrets: the Bicep app settings fill them from Key Vault.
Config keys the API reads
Section titled “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 lists every other key.
Keycloak locally, External ID on Azure
Section titled “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 explains what happens after the claims are normalized.
Why does the demo login use native authentication?1 min · optional
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
Section titled “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
Section titled “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 |