Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changes/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
"manager": "javascript",
"dependencies": ["@simulacrum/foundation-simulator"]
},
"@simulacrum/entra-simulator": {
"path": "./packages/entra",
"manager": "javascript",
"dependencies": ["@simulacrum/foundation-simulator"]
},
"@simulacrum/github-api-simulator": {
"path": "./packages/github-api",
"manager": "javascript",
Expand Down
10 changes: 10 additions & 0 deletions .changes/entra-simulator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@simulacrum/entra-simulator": minor
---

Add a Microsoft Entra ID (Azure AD) OIDC simulator. It is a drop-in replacement
for the core Entra authentication user flows — point an application's authority
at the simulator and the OpenID discovery document, JWKS, AAD instance
discovery, authorization-code + PKCE flow, refresh-token, client-credentials and
ROPC grants, userinfo and logout endpoints all respond with Entra v2.0 shaped
tokens and metadata, with no application source changes required.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Simulacrum removes these constraints from your process by allowing you to simula

- [github-api](packages/github-api) - [@simulacrum/github-api-simulator](https://www.npmjs.com/package/@simulacrum/github-api-simulator)
- [auth0](packages/auth0) - [@simulacrum/auth0-simulator](https://www.npmjs.com/package/@simulacrum/auth0-simulator)
- [entra](packages/entra) - [@simulacrum/entra-simulator](https://www.npmjs.com/package/@simulacrum/entra-simulator)
- [ldap](packages/ldap) - [@simulacrum/ldap-simulator](https://www.npmjs.com/package/@simulacrum/ldap-simulator)

> [!WARNING]
Expand Down
206 changes: 206 additions & 0 deletions packages/entra/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Microsoft Entra ID (Azure AD) simulator

A [Simulacrum](../../README.md) simulator that stands in for **Microsoft Entra ID**
(formerly Azure Active Directory) OpenID Connect. Point an application that
authenticates with Entra at this server and run the core sign-in flows locally —
no mock data, no changes to your application's authentication source code.

It is the Entra counterpart to the [`@simulacrum/auth0-simulator`](../auth0) and
is built on top of the [`@simulacrum/foundation-simulator`](../foundation).

## Table of Contents

- [Quick Start](#quick-start)
- [Pointing your application at the simulator](#pointing-your-application-at-the-simulator)
- [MSAL (msal-node / msal-browser / msal-react)](#msal-msal-node--msal-browser--msal-react)
- [passport-azure-ad / NestJS](#passport-azure-ad--nestjs)
- [next-auth Azure AD / Microsoft Entra ID provider](#next-auth-azure-ad--microsoft-entra-id-provider)
- [Configuration](#configuration)
- [Users](#users)
- [Supported flows & endpoints](#supported-flows--endpoints)
- [What is (and isn't) simulated](#what-is-and-isnt-simulated)

> [!IMPORTANT]
> Entra client libraries require the identity provider to be served over `https`.
> This simulator serves `https` using a locally-trusted certificate. On first run
> you will be shown instructions to create one with
> [`mkcert`](https://github.com/FiloSottile/mkcert).

## Quick Start

Start a server directly from the command line:

```bash
npx @simulacrum/entra-simulator # starts an https server on https://localhost:4400
```

It prints the authority and discovery URL to point your application at, along
with the default user's credentials.

Or run it from code:

```js
import { simulation } from "@simulacrum/entra-simulator";

const app = simulation();
app.listen(4400, () => console.log("Entra simulation server started at https://localhost:4400"));
```

Seed your own users with `initialState`:

```js
const app = simulation({
initialState: {
users: [
{
id: "11111111-1111-1111-1111-111111111111",
name: "Ada Lovelace",
email: "ada@example.com",
password: "hunter2",
},
],
},
options: {
tenant: "0e8a3b8a-0000-4000-a000-0000000000ab",
clientId: "<your-app-registration-client-id>",
},
});
```

## Pointing your application at the simulator

The only application change required is **where the identity provider lives** —
the authority/issuer URL. Every core authentication flow then behaves as it would
against real Entra.

The authority the simulator serves is:

```
https://localhost:4400/<tenant>
```

and the discovery document (which drives every other endpoint) is at:

```
https://localhost:4400/<tenant>/v2.0/.well-known/openid-configuration
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Because a non-`login.microsoftonline.com` host is being used, disable AAD
instance validation (or rely on the simulator's built-in instance-discovery
endpoint) as shown below. Set `NODE_EXTRA_CA_CERTS` to the mkcert root CA so your
runtime trusts the simulator's certificate.

### MSAL (msal-node / msal-browser / msal-react)

```js
const config = {
auth: {
clientId: "<your-client-id>",
authority: "https://localhost:4400/0e8a3b8a-0000-4000-a000-0000000000ab",
knownAuthorities: ["localhost:4400"],
// treat this as a generic OIDC authority rather than a public AAD cloud
protocolMode: "OIDC",
},
};
```

- `authority` points at the simulator instead of `https://login.microsoftonline.com/<tenant>`.
- `knownAuthorities` / `protocolMode: "OIDC"` let MSAL accept the custom host.
(The simulator also implements the AAD `/common/discovery/instance` endpoint,
so default instance discovery succeeds too.)

### passport-azure-ad / NestJS

```js
new OIDCStrategy({
identityMetadata:
"https://localhost:4400/0e8a3b8a-0000-4000-a000-0000000000ab/v2.0/.well-known/openid-configuration",
clientID: "<your-client-id>",
responseType: "code",
responseMode: "query",
redirectUrl: "http://localhost:3000/auth/callback",
scope: ["openid", "profile", "email", "offline_access"],
validateIssuer: true,
});
```

`issuer` in the tokens matches the discovery document's `issuer`, so
`validateIssuer` can stay on.

### next-auth Azure AD / Microsoft Entra ID provider

```js
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: "unused-by-the-simulator",
issuer: "https://localhost:4400/0e8a3b8a-0000-4000-a000-0000000000ab/v2.0",
wellKnown:
"https://localhost:4400/0e8a3b8a-0000-4000-a000-0000000000ab/v2.0/.well-known/openid-configuration",
});
```

## Configuration

Configuration is loaded with [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig)
under the module name `entraSimulator` (e.g. a `.entraSimulatorrc.json` file), and
can be overridden with the `options` argument to `simulation()`.

| Option | Default | Description |
| ---------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `port` | `4400` | Port the https server listens on. |
| `tenant` | `0e8a3b8a-0000-4000-a000-0000000000ab` | Default tenant used for the bin/example authority. Any tenant used in the path is honored and becomes the token `tid`/issuer tenant. |
| `clientId` | `00000000-0000-0000-0000-000000000000` | Default application (client) id used when a request does not supply one. |
| `audience` | `00000000-0000-0000-0000-000000000000` | Default access-token audience for `client_credentials` when no `resource` is passed. |
| `scope` | `openid profile email offline_access` | Default scope echoed when a request does not supply one. |

The `tenant` segment in the authority path is authoritative: whatever tenant an
application uses becomes the `tid` claim and the issuer tenant, keeping the
discovery `issuer` and issued tokens internally consistent.

## Users

With no `initialState` the store is seeded with a single default user:

```
Email: default@example.com
Password: 12345
```

Each user has an `id` (used as the `oid` and `sub` claims), `name`, `email`,
optional `password` (default `12345`) and optional `preferredUsername`
(defaults to the email).

## Supported flows & endpoints

Core Entra v2.0 authentication flows, all returning v2.0-shaped tokens signed
with a key published at the JWKS endpoint:

- **Authorization code flow with PKCE** (`response_type=code`, `S256`/`plain`)
- **Refresh token** grant
- **Client credentials** grant (app-only token with `roles`)
- **Resource Owner Password Credentials (ROPC)** grant — handy for headless tests
- `response_mode` of `query`, `fragment`, and `form_post`
- Silent authentication (`prompt=none`) via the session cookie

Endpoints (tenant-scoped, mirroring real Entra):

- `GET /:tenant/v2.0/.well-known/openid-configuration`
- `GET /:tenant/discovery/v2.0/keys` (JWKS)
- `GET /:tenant/discovery/instance` (AAD instance discovery, incl. `/common/...`)
- `GET /:tenant/oauth2/v2.0/authorize`
- `POST /:tenant/login` (login form submission)
- `POST /:tenant/oauth2/v2.0/token`
- `GET /:tenant/oauth2/v2.0/logout`
- `GET /oidc/userinfo` — Microsoft Graph style, mounted **globally** (not under
`/:tenant`), matching the real `https://graph.microsoft.com/oidc/userinfo`

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## What is (and isn't) simulated

The goal is a faithful stand-in for **core authentication user flows**, not the
entire Entra/Graph surface. ID and access tokens carry the standard v2.0 claims
(`ver`, `iss`, `sub`, `aud`, `oid`, `tid`, `preferred_username`, `email`,
`nonce`, `scp`/`roles`, `azp`, …) and validate against the JWKS with correct
issuer and audience. Not simulated: conditional access, MFA, consent screens,
app-role/group assignment logic, and the Microsoft Graph data API beyond the
OIDC `userinfo` endpoint. If you need one of these, open an issue to discuss
extending the simulator.
19 changes: 19 additions & 0 deletions packages/entra/bin/start.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env node
import { simulation, defaultUser, getConfig } from "../dist/index.mjs";

const config = getConfig();
const port = config.port ?? 4400;
const authority = `https://localhost:${port}/${config.tenant}`;

const app = simulation();
app.listen(port, () =>
console.log(
`Entra ID simulation server started at https://localhost:${port}\n\n` +
`Point your application's authority at:\n ${authority}\n\n` +
`Discovery document:\n ${authority}/v2.0/.well-known/openid-configuration\n\n` +
`Sign in with the default user:\n` +
` Email: ${defaultUser.email}\n` +
` Password: ${defaultUser.password}\n\n` +
`Press Ctrl+C to stop the server`,
),
);
24 changes: 24 additions & 0 deletions packages/entra/example/index.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { simulation, defaultUser, getConfig } from "../src/index.ts";

let config = getConfig();
let port = config.port ?? 4400;
let authority = `https://localhost:${port}/${config.tenant}`;

let app = simulation({
extend: {
extendRouter: (router, _simulationStore) => {
router.get("/hello", (_req, res) => {
res.status(200).json({ message: "Hello from the Entra simulator!" });
});
},
},
});

app.listen(port, () =>
console.log(
`Entra simulation server started at https://localhost:${port}\n` +
`authority: ${authority}\n` +
`username: ${defaultUser.email}\n` +
`password: ${defaultUser.password}\n`,
),
);
90 changes: 90 additions & 0 deletions packages/entra/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{
"name": "@simulacrum/entra-simulator",
"version": "0.1.0",
"description": "Run local instance of Microsoft Entra ID (Azure AD) OIDC for local development and integration testing",
"keywords": [
"authentication",
"azure-ad",
"azuread",
"emulation",
"entra",
"integration testing",
"microsoft",
"mock",
"mocking",
"oidc",
"simulation",
"stubbing"
],
"homepage": "https://github.com/thefrontside/simulacrum#readme",
"bugs": {
"url": "https://github.com/thefrontside/simulacrum/issues"
},
"license": "MIT",
"author": "Frontside Engineering <engineering@frontside.com>",
"repository": {
"type": "git",
"url": "git+https://github.com/thefrontside/simulacrum.git",
"directory": "packages/entra"
},
"bin": "bin/start.mjs",
"files": [
"bin/**/*",
"dist/**/*"
],
"type": "module",
"types": "./dist/index.d.mts",
"typesVersions": {
"*": {
"*": [
"./dist/*",
"./*"
]
}
},
"exports": {
".": {
"development": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"publishConfig": {
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
}
},
"scripts": {
"build": "tsdown",
"lint": "oxlint",
"prepack": "pnpm run build",
"start": "node --experimental-transform-types ./example/index.mts",
"test": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest run --fileParallelism=false",
"test:watch": "NODE_EXTRA_CA_CERTS=\"$(mkcert -CAROOT)/rootCA.pem\" vitest watch --fileParallelism=false",
"tsc": "tsc --noEmit"
},
"dependencies": {
"@faker-js/faker": "^9.3.0",
"@simulacrum/foundation-simulator": "^0.8.0",
"assert-ts": "^0.3.4",
"base64-url": "^2.3.3",
"cookie-session": "^2.1.0",
"cors": "^2.8.6",
"cosmiconfig": "^9.0.0",
"express": "^5.2.1",
"html-entities": "^2.5.2",
"jose": "^5.9.6",
"zod": "^3.24.1"
},
"devDependencies": {
"@simulacrum/server": "workspace:^",
"@types/base64-url": "^2.2.2",
"@types/cookie-session": "^2.0.49",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/keygrip": "^1.0.4",
"effection": "catalog:",
"keygrip": "^1.1.0"
}
}
Loading
Loading