-
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
Open
taras
wants to merge
3
commits into
main
Choose a base branch
from
entra-simulator
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,380
−0
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`, | ||
| ), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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`, | ||
| ), | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.