Entra Emulator
v0.9.0 WS-Federation sign-out, and the last two rows without an outside witness →The OIDC and OAuth 2.0 v2.0 endpoints MSAL talks to, SAML 2.0 and WS-Federation, passkeys, Microsoft Graph and SCIM provisioning, in a single Go binary on your laptop. Point an unmodified MSAL client at it by changing the authority and nothing else.
Real RS256 signatures, real WebAuthn ceremonies, real directory state that survives a restart. Not a stub that returns a fixed token.
Every capability claim on this page names the CI job that proves it, and — things it does not do are listed just as plainly.
Identity is the first thing an application does and the last thing anyone can test locally. Three costs follow, and they compound.
Sign-in work needs app registrations, redirect URIs, users, groups, secrets and consent. Getting those provisioned is a ticket, and changing them is another one. So sign-in is the part of the stack nobody refactors.
Fixed GUIDs, seeded on first run, reset in one callA test that acquires a real token needs a real secret in the runner, a real tenant reachable from it, and tolerance for someone else's rate limits. Most teams answer by mocking the token, which stops testing the thing that breaks.
One binary, no network, no secret worth stealingA hand-rolled fake IdP agrees with the client you wrote against it. It agrees right up to the day a real library refuses the token, and your suite never had an opinion about that.
Microsoft's own clients are the oracle here, not oursOne HTTPS listener, routed by Host. Clients on the left are
unmodified and know nothing about this project; the emulators on the right verify the
tokens it issues against its published JWKS.
ORIGIN_MODE=compat serves everything
from https://localhost:8443 instead, which is what CI uses.A third of this project's parity claims were once witnessed by our own Go tests: our client, on both ends of our own implementation. Replacing that client with Microsoft's found seven interop defects and one data-loss bug. Every one of them had passing tests.
| What was green | What a real client found |
|---|---|
private_key_jwt |
MSAL signs client assertions with PS256; the emulator verified only RS256. Five Go tests passed while no Microsoft Go client could authenticate with a certificate at all. |
| Resource owner password | MSAL Go probes /common/UserRealm before it will send credentials, and
gives up on a non-200. The route did not exist, so ROPC was unreachable. |
| Custom API scopes | Clients send api://<app>/<scope>; the emulator echoed the
short name, and the narrowing check then compared two different vocabularies. |
| Group overage | The overage payload points a client at getMemberObjects to recover
groups the token could not carry. That route did not exist, and the URL was built
without the compat prefix. Dead twice over. |
| SCIM discovery | Every meta.location pointed at routes that were never mounted, and
/Schemas carried no attributes, so no client could learn that
userName is required. An independent RFC checker scored it
3 passed, 31 errored against a row already graded green. |
SCIM PATCH |
Silently did nothing for name, emails and
externalId; remove was a no-op returning
200 OK. The worst failure mode, because it looks like success. |
| Incremental provisioning | The watermark is Unix seconds compared with <=, so a change
committed inside a sync's own second was skipped by that sync and every one after
it. Not delayed. Lost. |
A claim is credited once, by its strongest evidence: a CI job driving a packaged external client beats a Microsoft library linked in-process, which beats our own Go tests. Counting citations instead would flatter every row, because one claim can carry four witnesses. The parity map names the witness for each, and CI fails when one goes missing.
Unmodified, as published, from the package manager. Two knobs make a custom authority work in every Microsoft SDK: disable instance discovery, and trust the certificate.
@azure/msal-node, @azure/msal-browser, the Microsoft Graph
JavaScript client, MSAL Go and azidentity, MSAL Python, MSAL.NET with the
Wilson token-validation stack, MSAL4J, and the az CLI registered as a
custom cloud.
node-saml as an unmodified service provider,
Microsoft.AspNetCore.Authentication.WsFederation as unmodified middleware,
and scim2-tester, an RFC 7643/7644 checker that knows nothing about this
project.
Outbound SCIM provisioning is proved against
AzureAD/SCIMReferenceCode, receiving the full Entra sequence:
filter probe, create, externalId correlation, and
active:false deprovision.
Chromium completes the redirect flow, the RP-initiated logout, and the implicit and
hybrid front-channel responses. Passkeys register and assert through a CDP virtual
authenticator, yielding amr: ["fido"].
An Android emulator and an iOS simulator run the device-code flow end to end from a
Flutter app. No official MSAL exists for Dart, so the automated path uses Dart
http and the flutter_appauth screen is manual.
Key Vault, ARM, Fabric, APIM and Databricks emulators all validate tokens this issuer mints. A change to the claim shape here fails there, which is a consumer test no amount of local testing substitutes for.
github.com/calvinchengx/azure-emulatorsAuthorization Code + PKCE (S256 and plain), Client Credentials with
.default and app-role auto-grant, rotating Refresh Tokens with
family revocation on reuse, Device Code (RFC 8628) with its human approval page,
on-behalf-of, implicit and hybrid, front-channel logout, and UserInfo.
SAML 2.0 SP-initiated SSO at /{tid}/saml2 and WS-Federation passive
sign-in and sign-out at /{tid}/wsfed, both advertised on the same
FederationMetadata URL real Entra uses. Repointing an SP means changing the host.
Real RS256 JWTs with Entra v2.0 claim shapes: tid, oid,
scp/roles, pairwise sub,
ver: "2.0", client_info. Optional and group claims per app
registration, including the Entra-style overage payload.
Account picker and password giving amr: ["pwd"], and passkeys
(FIDO2/WebAuthn) giving amr: ["fido"], with the relying party built
per request from the Host header so passkeys work on any origin.
A stateful Microsoft Graph: users, groups and membership, app registrations, role assignments, administrative units, invitations, audit and sign-in logs, and soft-delete through the recycle bin. SCIM 2.0 in both directions.
The emulator package runs the whole thing in-process, with no external
server and no fixed port, so a Go test can point MSAL Go or azidentity
straight at it and get a token.
The line is drawn at the policy engine. Everything below is what separates a development-loop emulator from an identity provider, and none of it is planned.
| Capability | Status | Why |
|---|---|---|
| Conditional Access | not implemented | Policies, named locations and authentication strength are the product, not a protocol surface. Emulating them convincingly would mislead. |
| MFA and step-up | not implemented | No second factor exists to challenge. amr reports honestly what did happen: pwd or fido. |
| Identity Protection, risky users | not implemented | Risk scoring has no meaning against seeded users on a laptop. |
| PIM, entitlement management, access reviews | not implemented | Governance surfaces, not sign-in surfaces. |
| B2C user flows, External ID, CIAM | not implemented | A separate product with a separate protocol surface. |
| Cross-tenant access policies | not implemented | Partner settings and inbound/outbound trust need more than one real tenant to mean anything. |
| Device registration, Intune compliance | not implemented | Device-bound tokens require a device identity story this does not have. |
| Sovereign clouds | not implemented | US Gov, China and Germany instance routing. The discovery document names this emulator's own coordinates and nothing else. |
Graph beta | not implemented | v1.0 only. A partial beta would be worse than its absence. |
| mTLS, PoP, certificate-bound tokens | not implemented | Bearer tokens only. |
| Multi-tenant | emulated | Additional tenants exist in the store and route correctly, but there is no cross-tenant consent or guest-object model behind them. |
| Provisioning scheduler | emulated | SCIM sync runs on demand. Entra's ~40-minute cycle is not simulated; nothing waits. |
| Fabric control plane | bring your own engine | Workspace identity provisioning is real when fabric-emulator is attached, and a toy stands in otherwise. |
| Externalized authorization | bring your own engine | Relationship-based authorization is delegated to a real PDP: OpenFGA, OPA, Cedar, SpiceDB, Keto, Casbin or Permify. |
Open admin API, publicly known seeded users and secrets, self-signed TLS, and a signing
key stored unencrypted on disk. It exists so a laptop can issue tokens that look real
enough to develop against. Run it on localhost. Never point real users or
real secrets at it.
A ~13 MB distroless image with a built-in healthcheck, or a static binary for six platform pairs. Pure Go, no cgo, no runtime dependency.
docker run -p 8443:8443 -v entra-emulator-data:/app/data \
ghcr.io/calvinchengx/entra-emulator:latest
brew install calvinchengx/tap/entra-emulator # macOS and Linux
winget install calvinchengx.entra-emulator # Windows
go install github.com/calvinchengx/entra-emulator/cmd/entra-emulator@latest
The image defaults to ORIGIN_MODE=compat
and binds 0.0.0.0; mount a volume at /app/data to keep the store,
the certificate and the signing key stable across restarts. Every install method is in
installation.
Three verbs, the same on Linux, macOS and Windows. run needs no
container runtime at all.
make doctor # toolchain check, run this first
make run # build and serve at https://localhost:8443
make status # is it actually serving? probes discovery, JWKS, a real token mint
Then point a client at it. Only the authority
changes; knownAuthorities is what tells MSAL to skip instance discovery
against Microsoft's cloud metadata.
{
"auth": {
"clientId": "189c7070-78a3-4c13-aa18-20a2ca5755ca",
"authority": "https://localhost:8443/6f89cf12-978b-4d23-ac18-9ef0c127cf87",
"knownAuthorities": ["localhost:8443"],
"redirectUri": "https://localhost:3000"
}
}
The seed is deterministic and its GUIDs are fixed, so
a CI fixture written today still resolves next year:
alice@entraemulator.dev and bob@entraemulator.dev with password
Password1!, a public SPA, and a confidential daemon holding the
Tasks.Read.All app role. Full detail in
data model and seed.