How the emulator’s surface maps to real Entra ID (as documented at
learn.microsoft.com/entra), and —
the point of this table — whether real work happens or just the API shape.
The design bet is that the durable, testable surface is protocol + real
cryptography + directory state, and those are done for real: real RS256 JWTs
that third-party validators accept, every OAuth2 grant a real MSAL speaks, real
WebAuthn ceremonies, a real SQLite directory. What is deliberately left out is
the policy engine — Conditional Access, MFA, Identity Protection — which is
what would turn a dev-loop emulator into an IdP.
“Real via our own wire-protocol implementation.” A row is 🟢 Real not
only when real cryptography does the work, but also when the emulator itself
implements Entra’s wire protocol and the logic behind it — so a real,
unmodified client (MSAL in five languages, the Graph SDK, a SCIM connector)
gets byte- and behaviour-identical responses.
Optional claims + group overage (_claim_names / _claim_sources)
Real Entra overage payload above the limit; protocol claims non-overridable
🟢 Real
Token signing algorithm
RS256 only — which is exactly what real Entra v2.0 advertises (id_token_signing_alg_values_supported: ["RS256"], captured in e2e/golden/). ES256/PS256 are absent from Entra too, so there is no gap to close: adding them would diverge, not converge
Device code (spec form and the bare device_code msal-node sends)
Real, with an atomic approve→mint step that closes the double-mint window
🟢 Real
private_key_jwt client assertion
Real: assertion verified against the app’s registered certificate, and now advertised in token_endpoint_auth_methods_supported so a spec-driven client actually attempts it
🟢 Real
RP-initiated logout (end_session_endpoint)
Real: clears the SSO session and honours a validatedpost_logout_redirect_uri + state; advertised via http_logout_supported
🟢 Real
Front-channel logout (OP calls each RP’s frontchannel_logout_uri)
Real: apps register a logout URI, the emulator records which apps each SSO session signed into, and logout renders one hidden iframe per signed-into RP carrying iss and sid. Apps the session never used are deliberately not notified. Now advertised, because it now happens
🟢 Real
Implicit / hybrid flow
Real: response_type=id_token and code id_token mint a genuine signed ID token at the authorize endpoint, delivered by fragment or form_post with the nonce echoed. OIDC’s rules are enforced — a nonce is required, response_mode=query is refused for an id_token, and PKCE is demanded only when a code is actually issued. id_token token is not implemented and so is not advertised
🟢 Real
mTLS / PoP / certificate-bound tokens
—
🔴 Not implemented
JAR by reference (request_uri, RFC 9101)
Real: the signed request object is fetched, verified against the app’s registered keys, and its parameters override the query — and the fetch is SSRF-guarded, reaching only origins the tenant already trusted as this app’s redirect URIs, with no redirects followed and the body size- and time-capped
🟢 Real
Inline request parameter
Refused — and not an Entra feature either: the real discovery document leaves request_parameter_supported absent, which per OIDC means false. Implementing it would diverge, not converge
🟢 Real
PAR (pushed authorization requests)
Not implemented — and not an Entra feature either: the real discovery document advertises no pushed_authorization_request_endpoint, so this is parity, not a gap
🟢 Real
CAE (continuous access evaluation)
—
🔴 Not implemented
Token lifetime policies
Real and load-bearing: policies/tokenLifetimePolicies plus the $ref assignment onto an application, parsed from Entra’s own JSON-inside-a-string definition with .NET [d.]hh:mm:ss durations. An assigned policy (or isOrganizationDefault) changes the exp of the tokens actually minted, and a definition that would be silently inert is refused
🟢 Real
Claims-mapping policies
Not implemented as a policy resource. Per-app claim shaping is available instead through optionalClaims, groupMembershipClaims, and custom authentication extensions
Real gate behind GRAPH_PERMISSIONS: delegated calls need the scope in scp, app-only calls the role in roles, Directory.* acts as the superset, denials are 403 Authorization_RequestDenied. Off by default — the emulator has always accepted any valid Graph-audience token, so enabling it is opt-in
🟢 Real
Separate servicePrincipal store
An app registration is its own SP; object id and appId are conflated
🟡 Emulated
Custom role definitions
Real CRUD over roleManagement/directory/roleDefinitions: tenant-authored roles list beside the built-ins, are assignable, and deleting one cascades to its assignments. Built-ins are protected from modification, and custom roles are excluded from wids — real Entra emits built-in role template GUIDs there only
🟢 Real
Administrative units
Real CRUD over directory/administrativeUnits plus membership of both users and groups (each returned with its own @odata.type), Public/HiddenMembership visibility, a dangling member refused, and FK-cascade so deleting a unit takes its memberships with it
🟢 Real
Custom security attributes
Real: attribute sets and String/Integer/Boolean definitions (id is Entra’s {set}_{name} composite), assigned onto users with the declared type enforced — an Integer attribute refuses a string and a scalar refuses a collection slot. Returned only on explicit $select, exactly as Graph does
🟢 Real
Graph beta endpoint
v1.0 only
🔴 Not implemented
Sign-in logs (Graph auditLogs/signIns)
Real: served over the flow recorder, so every row is an exchange that actually happened. The recorder now carries the user each exchange resolved, so a delegated row names userId/userPrincipalName while an app-only row is userless (correct, not missing); failures carry their concrete reason and every row has a stable id to de-duplicate on. conditionalAccessStatus is always notApplied — there is no CA engine, by design
Real: every mutation through the Graph write surface is journaled with its activity, category, target resource, and the caller it is attributed to (an app-only caller reports no user, which is correct rather than missing). The emulator’s own admin API is a control surface with no Entra equivalent, so its mutations are deliberately not journaled
Real ceremonies (real assertion verification, real CBOR/COSE); RP derived per-request from the Host, so passkeys work on any origin; drives amr:["fido"]
Real: the new password is scrypt-hashed into the directory, so the old credential immediately stops signing in and the new one works. Omitting newPassword returns a system-generated one in Entra’s passwordResetResponse shape, with 202 + Location as Graph answers this long-running operation
🟢 Real
Interactive SSPR (verify by email / SMS / security questions at passwordreset.microsoftonline.com)
Not implemented — it is a first-party web flow, not a documented protocol, so emulating it would mean inventing a wire format rather than reproducing one
🔴 Not implemented
SAML / WS-Federation
— stated non-goal
🔴 Not implemented
B2C user flows / External ID / CIAM
— stated non-goal
🔴 Not implemented
B2B guest invitations
Real: POST /invitations creates an actual directory user with Entra’s external shape — #EXT# UPN, userType: Guest, externalUserState: PendingAcceptance — and the returned redeem link flips that state to Accepted and redirects to the inviting app. Members keep userType: Member with a null external state
Real token exchange: an external workload presents ITS OWN OIDC token as the client_assertion and the emulator matches a registered issuer/subject/audience trust, then verifies the signature against keys fetched from that issuer’s published JWKS — no secret exists anywhere, which is the whole point. Expiry, wrong subject, wrong audience, forged signature and revoked credential are each refused. Managed through the admin API (/admin/api/apps/{id}/federated-credentials) rather than the Graph federatedIdentityCredentials route
🟢 Real
Graph route for federatedIdentityCredentials
Real CRUD on applications/{id}/federatedIdentityCredentials, writing the same rows the token endpoint matches — a trust created here immediately authenticates an external workload, PATCHing its subject changes who can, and DELETE revokes it. (Applications are addressed by the conflated object id / appId, as on every /applications route)
A PDP port: real engines attach — OpenFGA, SpiceDB, Keto, Permify, Casbin, OPA, Cedar, all exercised in CI. A ~50-line InMemoryPDP ships so the sample runs with nothing attached; it is explicitly not a real engine
TLS with a wildcard cert over the emulator’s origins
Real self-signed X.509, regenerated on SAN drift, stable fingerprint otherwise
🟢 Real
CORS on the OIDC surface
Real: discovery, JWKS and instance discovery reflect the caller’s Origin and Vary on it; the token endpoint is gated exactly as Entra gates it — CORS only for an origin the application registered as an spa redirect URI, so an app that works here will not fail against real Entra. Preflight answers with the telemetry headers MSAL.js sends. Without any of this, no browser SPA can authenticate at all
Real clients prove the emulator works; golden references prove its wire
contracts haven’t drifted. Three canonical references — the real Entra OIDC
discovery document, the official Microsoft Graph OpenAPI, and the SCIM 2.0 RFCs
— are committed under e2e/golden/ and diffed against the live emulator on
every push. Several 🟢 rows above name those tests as their witness
(TestGoldenParityOIDCDiscovery, TestGoldenParityGraph,
TestGoldenParitySCIM). See
golden-reference parity for what each asserts
and the documented divergences it reports.
The stated non-goals — SAML/WS-Fed, B2C user flows, MFA/Conditional Access,
production hardening — are a deliberate line. Crossing it changes the project’s
character from “the identity provider your tests run against” to “an identity
provider”, which is a different product with a different duty of care.
What that buys: everything above the line can be real, because none of it
needs a policy engine, a risk model, or a tenant’s compliance posture. A token
this emulator signs is a real token; a passkey it verifies is really verified.