-
Notifications
You must be signed in to change notification settings - Fork 22
Add Microsoft Entra ID (Azure AD) OIDC simulator #373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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 | ||
| ``` | ||
|
|
||
| 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` | ||
|
|
||
|
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. | ||
| 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`, | ||
| ), | ||
| ); |
| 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`, | ||
| ), | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| { | ||
| "name": "@simulacrum/entra-simulator", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noting so we don't forget, we discuss a possible name change. |
||
| "version": "0.1.0", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Set this to v0 so the first bump is a minor to this version. |
||
| "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", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use the latest v6 here? It is on my list to update auth0 as well. (Maybe double check we can't update any of the others as well? Except |
||
| "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" | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.