From 4f39690714ccacc48dfaf3a62f5d562cecdca741 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:09:17 -0400 Subject: [PATCH 1/3] Add Microsoft Entra ID (Azure AD) OIDC simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drop-in replacement for core Entra authentication user flows, built on @simulacrum/foundation-simulator and modeled on the auth0 simulator. Supports the OpenID discovery document, JWKS, AAD instance discovery, the authorization-code + PKCE flow, refresh-token, client-credentials and ROPC grants, userinfo and logout — all returning Entra v2.0 shaped tokens and metadata, so applications point their authority at it with no source changes. --- .changes/config.json | 5 + .changes/entra-simulator.md | 10 + README.md | 1 + packages/entra/README.md | 202 +++++++++ packages/entra/bin/start.mjs | 19 + packages/entra/example/index.mts | 24 + packages/entra/package.json | 90 ++++ packages/entra/src/auth/constants.ts | 27 ++ packages/entra/src/auth/date.ts | 11 + packages/entra/src/config/get-config.ts | 51 +++ packages/entra/src/handlers/entra-handlers.ts | 234 ++++++++++ packages/entra/src/handlers/index.ts | 54 +++ .../entra/src/handlers/openid-handlers.ts | 109 +++++ packages/entra/src/handlers/token.ts | 299 +++++++++++++ packages/entra/src/handlers/url.ts | 1 + packages/entra/src/handlers/utils.ts | 59 +++ packages/entra/src/index.ts | 39 ++ packages/entra/src/middleware/create-cors.ts | 14 + .../entra/src/middleware/error-handling.ts | 36 ++ packages/entra/src/middleware/no-cache.ts | 7 + packages/entra/src/middleware/session.ts | 14 + packages/entra/src/store/entities.ts | 56 +++ packages/entra/src/store/index.ts | 100 +++++ packages/entra/src/types.ts | 115 +++++ packages/entra/src/views/login.ts | 80 ++++ packages/entra/test/entra.test.ts | 421 ++++++++++++++++++ packages/entra/test/helpers.ts | 8 + packages/entra/test/openid-handlers.test.ts | 53 +++ packages/entra/tsconfig.json | 8 + packages/entra/tsdown.config.ts | 20 + pnpm-lock.yaml | 61 +++ 31 files changed, 2228 insertions(+) create mode 100644 .changes/entra-simulator.md create mode 100644 packages/entra/README.md create mode 100644 packages/entra/bin/start.mjs create mode 100644 packages/entra/example/index.mts create mode 100644 packages/entra/package.json create mode 100644 packages/entra/src/auth/constants.ts create mode 100644 packages/entra/src/auth/date.ts create mode 100644 packages/entra/src/config/get-config.ts create mode 100644 packages/entra/src/handlers/entra-handlers.ts create mode 100644 packages/entra/src/handlers/index.ts create mode 100644 packages/entra/src/handlers/openid-handlers.ts create mode 100644 packages/entra/src/handlers/token.ts create mode 100644 packages/entra/src/handlers/url.ts create mode 100644 packages/entra/src/handlers/utils.ts create mode 100644 packages/entra/src/index.ts create mode 100644 packages/entra/src/middleware/create-cors.ts create mode 100644 packages/entra/src/middleware/error-handling.ts create mode 100644 packages/entra/src/middleware/no-cache.ts create mode 100644 packages/entra/src/middleware/session.ts create mode 100644 packages/entra/src/store/entities.ts create mode 100644 packages/entra/src/store/index.ts create mode 100644 packages/entra/src/types.ts create mode 100644 packages/entra/src/views/login.ts create mode 100644 packages/entra/test/entra.test.ts create mode 100644 packages/entra/test/helpers.ts create mode 100644 packages/entra/test/openid-handlers.test.ts create mode 100644 packages/entra/tsconfig.json create mode 100644 packages/entra/tsdown.config.ts diff --git a/.changes/config.json b/.changes/config.json index 25abd514..efb0f80a 100644 --- a/.changes/config.json +++ b/.changes/config.json @@ -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", diff --git a/.changes/entra-simulator.md b/.changes/entra-simulator.md new file mode 100644 index 00000000..26448284 --- /dev/null +++ b/.changes/entra-simulator.md @@ -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. diff --git a/README.md b/README.md index 96138c15..77d131cc 100644 --- a/README.md +++ b/README.md @@ -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] diff --git a/packages/entra/README.md b/packages/entra/README.md new file mode 100644 index 00000000..c0f9b03a --- /dev/null +++ b/packages/entra/README.md @@ -0,0 +1,202 @@ +# 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: "", + }, +}); +``` + +## 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/ +``` + +and the discovery document (which drives every other endpoint) is at: + +``` +https://localhost:4400//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: "", + 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/`. +- `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: "", + 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) + +## 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. diff --git a/packages/entra/bin/start.mjs b/packages/entra/bin/start.mjs new file mode 100644 index 00000000..ae811968 --- /dev/null +++ b/packages/entra/bin/start.mjs @@ -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`, + ), +); diff --git a/packages/entra/example/index.mts b/packages/entra/example/index.mts new file mode 100644 index 00000000..665e9d24 --- /dev/null +++ b/packages/entra/example/index.mts @@ -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`, + ), +); diff --git a/packages/entra/package.json b/packages/entra/package.json new file mode 100644 index 00000000..5b241c50 --- /dev/null +++ b/packages/entra/package.json @@ -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": [ + "entra", + "azure-ad", + "azuread", + "microsoft", + "oidc", + "authentication", + "emulation", + "integration testing", + "mock", + "mocking", + "simulation", + "stubbing" + ], + "homepage": "https://github.com/thefrontside/simulacrum#readme", + "bugs": { + "url": "https://github.com/thefrontside/simulacrum/issues" + }, + "license": "MIT", + "author": "Frontside Engineering ", + "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.cts", + "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" + } +} diff --git a/packages/entra/src/auth/constants.ts b/packages/entra/src/auth/constants.ts new file mode 100644 index 00000000..cabf69c5 --- /dev/null +++ b/packages/entra/src/auth/constants.ts @@ -0,0 +1,27 @@ +import { createPrivateKey } from "node:crypto"; + +// A stable, well-known development RSA keypair. The corresponding public key is +// published at the JWKS endpoint so tokens issued here can be verified locally. +// Do NOT use these keys for anything other than local simulation. +export const PRIVATE_KEY = + "-----BEGIN RSA PRIVATE KEY-----~~MIIEpAIBAAKCAQEAwzwwEqR5p7a6CaG61i3od+GLTyype3t/f0pwwtoA9NsZANcj~~HaAUR/qzqlNRQlLI687vF1OfbETYLeHIT5V36QGrMfrYR/tYAoaKFixC/wOjt8EB~~PoeHgaTOAyGf3V3YNwocNU+StyV1X4hPmDjCrapNYpbKIcAJwq5Ij0WBKGNXBcUU~~bAceRtLqgzf/6x2vcQJeE5nJK5gd41f/jtlK9Xge0Ig6CcdKOI7U3agyI/iNF3SL~~0bnCfvtDRIkgAzzeN5Yj4S3Z4rJVQ1RgzZmqBmnjw8h06G8wDyaQPf19u4F///gF~~+dL1md/fVRKL5UL7OZg42hIZzwzdhrxOGcXUFQIDAQABAoIBAQCSwHUqLjO722Av~~yT/VqqBpLEI4+0tSJFyL4/qqnI/HfcFnnk8o/6D/EfVm/EXCYtPgXKXflN3q1jzh~~ECwvlhySKszyPqnAQa/ABj1ZuV+KrMOtZgh3Zgx3aNfqBqZSES5rANB/ShbwT9nQ~~O3gI5fF/9NlCWDIL+HvduH+WIhqZrfHbTQrntr0sMvYTNE8PkAsB1GGZ5VO8gGGT~~Tg3kKOpNGWAJUYape/1Mb0Z1W8E1YDEaQKCnVY+Whr3+ZuLveDmyTuvLrUi+MAA4~~EaISkBZQo3ehUMjXm/0PdG4U9f2otAOhdwa2eT9irTkNS0TU+bi7VDEEhTmRKyfO~~yyVAGzTpAoGBAOmPbnMBAJYLk0SiQVspxIT+LW3tDWc7aFr/TtqAXkEVxI2xG6wA~~dEyRasBbPJDSWEljMre4g/6GG1fsPj0l5n2S8azy6jIu49ANvZPrrrBsuVYStR18~~MibzxjWWUht3vOCZBKtbqgHbC/oc49jMLPO5XrDFMTJPU5GkWRRLRZArAoGBANX+~~H6NQ7fUg8TUerXEB1WqA+FH4zVqJ/NKjbqaj9FPFWG59AjBVqQ335QzDWNCs/LjD~~REF/6qMeP2adehNhEVv2nK3rS+po9b4yLkiSysLivFPry9AxMGzFr3XzgoDb+y+y~~T02zj2k7nkGlNSSSanIxsp+TzTKsIY7ZbGx6d8y/AoGAS/kSDmq3DBe70cmNxN+z~~QyeDE4zWnUvfyCngNocnIbi49PY1cB+9tOJgfS2wZ9NkUIrqBoUIupRY9KKuJCnd~~7d8MqhtiPuytwhGWJzW030KejvcK3wp1LeKCCRBaqQCr+csMj8kDZhMgtD0NiInx~~3V3hBVM/i4PuRSPWrhlGCX0CgYEAq/xR8TBaD2kqc0b0np6ap75/1WHhqaK9T42K~~oOOkuq8hI6vU1oQCGvfhXyChgRWHB/foI7xrGC53RkHKm0ioawEJa75whTVWTEaI~~bEuOKpOQSOJ6LBlckg9PtbzAZlBm0S6+DfUCjdEcoCXnUD1cz+qhZR+lC9TMI8Mb~~IRRMtIECgYBrVlenf/rHK1UMC3FDmkOzH7agShcDHqzFMR1/NcMDolZNXXFy/u4t~~nDxci1smAUQr4oNs1lk9UzCdt0+pVCjmhbriBngwsS6sazOesDft53w6RTczsRpa~~09YDfcXhnI5yT+vd5r4xA5HhyniY7W1ahSzGDYIGlVm5IReC3P6Caw==~~-----END RSA PRIVATE KEY-----~~" as const; + +// The kid that ties tokens issued here to the public key advertised in the JWKS. +// Entra libraries (MSAL, passport-azure-ad) look up the signing key by this kid. +export const KEY_ID = "NkRGQjI5N0RBNUUwMTYwOEMxQUVGQkJBQTJBODBGNTE2MDA5NDM5RA"; + +export const JWKS = { + keys: [ + { + kty: "RSA", + use: "sig", + kid: KEY_ID, + x5t: KEY_ID, + n: "wzwwEqR5p7a6CaG61i3od-GLTyype3t_f0pwwtoA9NsZANcjHaAUR_qzqlNRQlLI687vF1OfbETYLeHIT5V36QGrMfrYR_tYAoaKFixC_wOjt8EBPoeHgaTOAyGf3V3YNwocNU-StyV1X4hPmDjCrapNYpbKIcAJwq5Ij0WBKGNXBcUUbAceRtLqgzf_6x2vcQJeE5nJK5gd41f_jtlK9Xge0Ig6CcdKOI7U3agyI_iNF3SL0bnCfvtDRIkgAzzeN5Yj4S3Z4rJVQ1RgzZmqBmnjw8h06G8wDyaQPf19u4F___gF-dL1md_fVRKL5UL7OZg42hIZzwzdhrxOGcXUFQ", + e: "AQAB", + }, + ], +} as const; + +const parseKey = (key: string): string => key.split("~~").join("\n"); +export const signingKey = createPrivateKey(parseKey(PRIVATE_KEY)); diff --git a/packages/entra/src/auth/date.ts b/packages/entra/src/auth/date.ts new file mode 100644 index 00000000..28d40865 --- /dev/null +++ b/packages/entra/src/auth/date.ts @@ -0,0 +1,11 @@ +// returns the current time in seconds since the epoch +export const epochTime = (date = Date.now()): number => Math.floor(date / 1000); + +// returns the time in seconds since the epoch for a date that is hours from now +export const expiresAt = (hours = 1): number => epochTime() + hours * 60 * 60; + +export const epochTimeToLocalDate = (epoch: number): Date => { + let date = new Date(0); + date.setUTCSeconds(epoch); + return date; +}; diff --git a/packages/entra/src/config/get-config.ts b/packages/entra/src/config/get-config.ts new file mode 100644 index 00000000..ae2dfc53 --- /dev/null +++ b/packages/entra/src/config/get-config.ts @@ -0,0 +1,51 @@ +import { cosmiconfigSync } from "cosmiconfig"; +import type { EntraConfiguration, ConfigSchema } from "../types.ts"; +import { configurationSchema } from "../types.ts"; + +const DefaultEntraPort = 4400; + +export const DefaultArgs: ConfigSchema = { + // a stable, obviously-fake tenant GUID for local development + tenant: "0e8a3b8a-0000-4000-a000-0000000000ab", + clientId: "00000000-0000-0000-0000-000000000000", + audience: "00000000-0000-0000-0000-000000000000", + scope: "openid profile email offline_access", +}; + +type Explorer = ReturnType; + +function getPort({ port }: EntraConfiguration): number { + if (typeof port === "number") { + return port; + } + + return DefaultEntraPort; +} + +// This higher order function would only be used for testing and +// allows different cosmiconfig instances to be used for testing +export function getConfigCreator(explorer: Explorer) { + return function getConfig(options?: Partial): EntraConfiguration { + let searchResult = explorer.search(); + + let config: ConfigSchema = searchResult === null ? DefaultArgs : searchResult.config; + + let strippedOptions = options ?? {}; + + let configuration = { + ...DefaultArgs, + ...config, + ...strippedOptions, + } as EntraConfiguration; + + configuration.port = getPort(configuration); + + configurationSchema.parse(configuration); + + return configuration; + }; +} + +const explorer = cosmiconfigSync("entraSimulator"); + +export const getConfig = getConfigCreator(explorer); diff --git a/packages/entra/src/handlers/entra-handlers.ts b/packages/entra/src/handlers/entra-handlers.ts new file mode 100644 index 00000000..308ab2cc --- /dev/null +++ b/packages/entra/src/handlers/entra-handlers.ts @@ -0,0 +1,234 @@ +import { assert } from "assert-ts"; +import { stringify } from "querystring"; +import { decodeJwt } from "jose"; +import type { Request, RequestHandler, Response } from "express"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import type { EntraUser } from "../store/entities.ts"; +import type { AuthorizeQuery, EntraConfiguration, ResponseMode } from "../types.ts"; +import { epochTime } from "../auth/date.ts"; +import { loginView } from "../views/login.ts"; +import { createTokens } from "./token.ts"; +import { createUserQuery, encodeAuthorizationCode } from "./utils.ts"; +import { issuerFor, tenantParam } from "./openid-handlers.ts"; + +export type Routes = "/authorize" | "/login" | "/token" | "/logout" | "/userinfo" | "/heartbeat"; + +type LoggerArgs = Parameters; + +const createLogger = (debug: boolean) => ({ + log: (...args: LoggerArgs): void => { + if (!debug) return; + console.dir(...args); + }, +}); + +// A stable, fake session_state value; Entra returns one but its contents are opaque. +const SESSION_STATE = "00000000-0000-0000-0000-000000000000"; + +const parseAuthorizeQuery = (source: Record): AuthorizeQuery => ({ + client_id: source.client_id as string, + redirect_uri: source.redirect_uri as string, + response_type: (source.response_type as string) ?? "code", + response_mode: source.response_mode as ResponseMode | undefined, + scope: (source.scope as string) ?? "openid profile email", + state: source.state as string | undefined, + nonce: source.nonce as string | undefined, + code_challenge: source.code_challenge as string | undefined, + code_challenge_method: source.code_challenge_method as string | undefined, + prompt: source.prompt as string | undefined, + login_hint: source.login_hint as string | undefined, + domain_hint: source.domain_hint as string | undefined, +}); + +const defaultResponseMode = (query: AuthorizeQuery): ResponseMode => { + if (query.response_mode) return query.response_mode; + // Entra defaults to `query` for the code flow and `fragment` otherwise. + return query.response_type === "code" ? "query" : "fragment"; +}; + +const redirectWithCode = (res: Response, query: AuthorizeQuery, user: EntraUser): void => { + let code = encodeAuthorizationCode({ + sub: user.id, + oid: user.id, + nonce: query.nonce, + scope: query.scope, + client_id: query.client_id, + code_challenge: query.code_challenge, + code_challenge_method: query.code_challenge_method, + auth_time: epochTime(), + iat: epochTime(), + }); + + let params: Record = { code, session_state: SESSION_STATE }; + if (typeof query.state !== "undefined") params.state = query.state; + + let mode = defaultResponseMode(query); + + if (mode === "form_post") { + res.set("Content-Type", "text/html"); + res.status(200).send(autoPostForm(query.redirect_uri, params)); + return; + } + + let separator = mode === "fragment" ? "#" : "?"; + res.redirect(302, `${query.redirect_uri}${separator}${stringify(params)}`); +}; + +const redirectWithError = ( + res: Response, + query: AuthorizeQuery, + error: string, + description: string, +): void => { + let params: Record = { error, error_description: description }; + if (typeof query.state !== "undefined") params.state = query.state; + let mode = defaultResponseMode(query); + let separator = mode === "fragment" ? "#" : "?"; + res.redirect(302, `${query.redirect_uri}${separator}${stringify(params)}`); +}; + +const autoPostForm = (action: string, fields: Record): string => { + let inputs = Object.entries(fields) + .map(([name, value]) => ``) + .join("\n"); + return /*html*/ `Working... + +
+ ${inputs} + +
+ `; +}; + +const loginPath = (req: Request): string => `/${tenantParam(req)}/login`; + +export const createEntraHandlers = ( + simulationStore: ExtendedSimulationStore, + config: EntraConfiguration, + debug: boolean, +): Record => { + let logger = createLogger(debug); + let userQuery = createUserQuery(simulationStore); + + return { + ["/heartbeat"]: function (_req, res) { + res.status(200).json({ ok: true }); + }, + + ["/authorize"]: function (req, res) { + logger.log({ "/authorize": { query: req.query, session: req.session } }); + let query = parseAuthorizeQuery(req.query as Record); + + assert(!!query.client_id, "400::client_id is required"); + assert(!!query.redirect_uri, "400::redirect_uri is required"); + + let sessionUser = req.session?.username as string | undefined; + + if (sessionUser) { + let user = userQuery( + (u) => + u.email?.toLowerCase() === sessionUser.toLowerCase() || + u.preferredUsername?.toLowerCase() === sessionUser.toLowerCase(), + ); + if (user) { + redirectWithCode(res, query, user); + return; + } + } + + if (query.prompt === "none") { + // silent auth with no established session -> spec-compliant error redirect + redirectWithError(res, query, "login_required", "The user must sign in."); + return; + } + + res.set("Content-Type", "text/html"); + res.status(200).send(loginView({ actionUrl: loginPath(req), query, loginFailed: false })); + }, + + ["/login"]: function (req, res) { + logger.log({ "/login": { body: { ...req.body, password: "***" } } }); + let query = parseAuthorizeQuery(req.body as Record); + let { username, password } = req.body as { username?: string; password?: string }; + + assert(!!username, "400::username is required"); + assert(!!query.redirect_uri, "400::redirect_uri is required"); + + let user = userQuery( + (u) => + (u.email?.toLowerCase() === username!.toLowerCase() || + u.preferredUsername?.toLowerCase() === username!.toLowerCase()) && + u.password === password, + ); + + if (!user) { + res.set("Content-Type", "text/html"); + res.status(401).send(loginView({ actionUrl: loginPath(req), query, loginFailed: true })); + return; + } + + if (req.session) { + req.session.username = username; + } + + redirectWithCode(res, query, user); + }, + + ["/token"]: async function (req, res, next) { + logger.log({ "/token": { body: { ...req.body, client_secret: "***" } } }); + try { + let tokens = await createTokens({ + simulationStore, + config, + issuer: issuerFor(req), + tenant: tenantParam(req), + body: req.body ?? {}, + }); + res.status(200).json(tokens); + } catch (error) { + next(error); + } + }, + + ["/userinfo"]: function (req, res) { + let token: string | undefined; + if (req.headers.authorization) { + token = req.headers.authorization.split(" ")?.[1]; + } else { + token = req.query?.access_token as string | undefined; + } + + assert(!!token, "401::no bearer token or access_token"); + let { oid, sub } = decodeJwt(token); + let subject = (oid as string) ?? (sub as string); + + let user = userQuery((u) => u.id === subject); + assert(!!user, "404::user not found"); + + res.status(200).json({ + sub: user.id, + oid: user.id, + name: user.name, + given_name: user.name.split(" ")[0], + family_name: user.name.split(" ").slice(1).join(" ") || user.name, + preferred_username: user.preferredUsername, + email: user.email, + }); + }, + + ["/logout"]: function (req, res) { + req.session = null; + + let returnTo = + (req.query.post_logout_redirect_uri as string | undefined) ?? + (req.headers.referer as string | undefined); + + if (!returnTo) { + res.status(200).send("Logged out"); + return; + } + + res.redirect(302, returnTo); + }, + }; +}; diff --git a/packages/entra/src/handlers/index.ts b/packages/entra/src/handlers/index.ts new file mode 100644 index 00000000..d10f97f5 --- /dev/null +++ b/packages/entra/src/handlers/index.ts @@ -0,0 +1,54 @@ +import express, { type Express, type Router } from "express"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import { createCors } from "../middleware/create-cors.ts"; +import { noCache } from "../middleware/no-cache.ts"; +import { createSession } from "../middleware/session.ts"; +import { defaultErrorHandler } from "../middleware/error-handling.ts"; +import { createEntraHandlers } from "./entra-handlers.ts"; +import { createOpenIdHandlers } from "./openid-handlers.ts"; +import type { EntraConfiguration } from "../types.ts"; + +export const extendRouter = + ( + config: EntraConfiguration, + extend: ((router: Router, simulationStore: ExtendedSimulationStore) => void) | undefined, + debug = false, + ) => + (router: Express, simulationStore: ExtendedSimulationStore) => { + let entra = createEntraHandlers(simulationStore, config, debug); + let openid = createOpenIdHandlers(); + + router + .use(express.json()) + .use(express.urlencoded({ extended: true })) + .use(createSession(config.cookieSecret)) + .use(createCors()) + .use(noCache()); + + if (extend) { + extend(router, simulationStore); + } + + router + .get("/health", (_req, res) => { + res.send({ status: "ok" }); + }) + // OpenID discovery + keys (tenant scoped, as with real Entra) + .get("/:tenant/v2.0/.well-known/openid-configuration", openid.openidConfiguration) + .get("/:tenant/discovery/v2.0/keys", openid.jwks) + // AAD instance discovery — MSAL calls `/common/discovery/instance` + .get("/:tenant/discovery/instance", openid.instanceDiscovery) + // core OAuth2 v2.0 endpoints + .get("/:tenant/oauth2/v2.0/authorize", entra["/authorize"]) + .post("/:tenant/oauth2/v2.0/authorize", entra["/authorize"]) + .post("/:tenant/login", entra["/login"]) + .post("/:tenant/oauth2/v2.0/token", entra["/token"]) + .get("/:tenant/oauth2/v2.0/logout", entra["/logout"]) + .get("/:tenant/oauth2/v2.0/heartbeat", entra["/heartbeat"]) + // Microsoft Graph style userinfo endpoint + .get("/oidc/userinfo", entra["/userinfo"]) + .post("/oidc/userinfo", entra["/userinfo"]); + + // needs to be the last middleware added + router.use(defaultErrorHandler); + }; diff --git a/packages/entra/src/handlers/openid-handlers.ts b/packages/entra/src/handlers/openid-handlers.ts new file mode 100644 index 00000000..1119bc7f --- /dev/null +++ b/packages/entra/src/handlers/openid-handlers.ts @@ -0,0 +1,109 @@ +import type { Request, RequestHandler } from "express"; +import { JWKS } from "../auth/constants.ts"; +import { removeTrailingSlash } from "./url.ts"; + +// Builds `https://host` (no trailing slash) from the incoming request so the +// discovery document always points back at wherever the simulator is served. +export const baseUrl = (req: Request): string => + removeTrailingSlash(`${req.protocol}://${req.get("Host")}`); + +export const tenantParam = (req: Request): string => + (req.params.tenant as string | undefined) ?? "common"; + +export const issuerFor = (req: Request): string => `${baseUrl(req)}/${tenantParam(req)}/v2.0`; + +export const createOpenIdHandlers = (): { + openidConfiguration: RequestHandler; + jwks: RequestHandler; + instanceDiscovery: RequestHandler; +} => { + return { + openidConfiguration(req, res) { + let base = baseUrl(req); + let tenant = tenantParam(req); + let authority = `${base}/${tenant}`; + + res.status(200).json({ + token_endpoint: `${authority}/oauth2/v2.0/token`, + token_endpoint_auth_methods_supported: [ + "client_secret_post", + "private_key_jwt", + "client_secret_basic", + ], + jwks_uri: `${authority}/discovery/v2.0/keys`, + response_modes_supported: ["query", "fragment", "form_post"], + subject_types_supported: ["pairwise"], + id_token_signing_alg_values_supported: ["RS256"], + response_types_supported: ["code", "id_token", "code id_token", "id_token token"], + scopes_supported: ["openid", "profile", "email", "offline_access"], + issuer: `${authority}/v2.0`, + request_uri_parameter_supported: false, + userinfo_endpoint: `${base}/oidc/userinfo`, + authorization_endpoint: `${authority}/oauth2/v2.0/authorize`, + device_authorization_endpoint: `${authority}/oauth2/v2.0/devicecode`, + http_logout_supported: true, + frontchannel_logout_supported: true, + end_session_endpoint: `${authority}/oauth2/v2.0/logout`, + claims_supported: [ + "sub", + "iss", + "cloud_instance_name", + "cloud_instance_host_name", + "cloud_graph_host_name", + "msgraph_host", + "aud", + "exp", + "iat", + "auth_time", + "acr", + "nonce", + "preferred_username", + "name", + "tid", + "ver", + "at_hash", + "c_hash", + "email", + ], + kerberos_endpoint: `${authority}/kerberos`, + tenant_region_scope: "NA", + cloud_instance_name: "microsoftonline.com", + cloud_graph_host_name: "graph.windows.net", + msgraph_host: "graph.microsoft.com", + rbac_url: "https://pas.windows.net", + grant_types_supported: [ + "authorization_code", + "refresh_token", + "client_credentials", + "urn:ietf:params:oauth:grant-type:jwt-bearer", + "implicit", + "password", + ], + }); + }, + + jwks(_req, res) { + res.status(200).json(JWKS); + }, + + instanceDiscovery(req, res) { + let base = baseUrl(req); + let tenant = tenantParam(req); + let host = new URL(base).host; + + // Lets MSAL treat this non-Microsoft host as a valid authority instance + // instead of rejecting it during AAD instance discovery. + res.status(200).json({ + tenant_discovery_endpoint: `${base}/${tenant}/v2.0/.well-known/openid-configuration`, + "api-version": "1.1", + metadata: [ + { + preferred_network: host, + preferred_cache: host, + aliases: [host], + }, + ], + }); + }, + }; +}; diff --git a/packages/entra/src/handlers/token.ts b/packages/entra/src/handlers/token.ts new file mode 100644 index 00000000..9ee9cae6 --- /dev/null +++ b/packages/entra/src/handlers/token.ts @@ -0,0 +1,299 @@ +import { assert } from "assert-ts"; +import { SignJWT } from "jose"; +import { KEY_ID, signingKey } from "../auth/constants.ts"; +import { epochTime } from "../auth/date.ts"; +import { + createUserQuery, + decodeAuthorizationCode, + decodeRefreshToken, + encodeRefreshToken, + scopeIncludes, + verifyPkce, +} from "./utils.ts"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import type { EntraUser } from "../store/entities.ts"; +import type { + AccessTokenClaims, + EntraConfiguration, + GrantType, + IdTokenClaims, + RefreshTokenPayload, + TokenResponse, +} from "../types.ts"; + +const EXPIRES_IN_SECONDS = 3600; + +const header = { alg: "RS256", typ: "JWT", kid: KEY_ID }; + +const sign = (claims: Record): Promise => + new SignJWT(claims).setProtectedHeader(header).sign(signingKey); + +interface TokenContext { + simulationStore: ExtendedSimulationStore; + config: EntraConfiguration; + issuer: string; + tenant: string; + body: Record; +} + +export const createTokens = async (ctx: TokenContext): Promise => { + let grantType = (ctx.body.grant_type ?? "authorization_code") as GrantType; + + switch (grantType) { + case "client_credentials": + return clientCredentialsTokens(ctx); + case "refresh_token": + return refreshTokenTokens(ctx); + case "authorization_code": + return authorizationCodeTokens(ctx); + case "password": + return passwordTokens(ctx); + default: + assert(false, `400::unsupported grant_type ${grantType}`); + } +}; + +const resolveClientId = (ctx: TokenContext): string => ctx.body.client_id ?? ctx.config.clientId; + +const resolveAudience = (ctx: TokenContext, clientId: string): string => { + // Entra scopes look like `api:///` or `/.default`. + // The access token audience is the resource. When the caller passes a + // `resource` we honor it; otherwise the audience is the client itself + // (id-token-style), which is what SPAs calling their own API expect. + if (ctx.body.resource) return ctx.body.resource; + return clientId; +}; + +const buildIdToken = async ({ + ctx, + user, + clientId, + nonce, + authTime, +}: { + ctx: TokenContext; + user: EntraUser; + clientId: string; + nonce: string | undefined; + authTime: number; +}): Promise => { + let iat = epochTime(); + let claims: IdTokenClaims = { + ver: "2.0", + iss: ctx.issuer, + sub: user.id, + aud: clientId, + exp: iat + EXPIRES_IN_SECONDS, + iat, + nbf: iat, + name: user.name, + preferred_username: user.preferredUsername, + email: user.email, + oid: user.id, + tid: ctx.tenant, + auth_time: authTime, + }; + if (typeof nonce !== "undefined") { + claims.nonce = nonce; + } + return sign(claims); +}; + +const buildAccessToken = async ({ + ctx, + user, + clientId, + scope, +}: { + ctx: TokenContext; + user: EntraUser; + clientId: string; + scope: string; +}): Promise => { + let iat = epochTime(); + let audience = resolveAudience(ctx, clientId); + // Entra strips the reserved OIDC scopes from the `scp` claim on access tokens. + let scp = scope + .split(" ") + .filter((s) => s && !["openid", "profile", "offline_access"].includes(s)) + .join(" "); + + let claims: AccessTokenClaims = { + ver: "2.0", + iss: ctx.issuer, + sub: user.id, + aud: audience, + exp: iat + EXPIRES_IN_SECONDS, + iat, + nbf: iat, + oid: user.id, + tid: ctx.tenant, + azp: clientId, + scp, + name: user.name, + preferred_username: user.preferredUsername, + }; + return sign(claims); +}; + +const buildRefreshToken = (payload: Omit): string => { + let iat = epochTime(); + return encodeRefreshToken({ + ...payload, + iat, + // 90 day sliding window, as Entra defaults to + exp: iat + 90 * 24 * 60 * 60, + }); +}; + +const tokenResponse = ({ + accessToken, + idToken, + refreshToken, + scope, +}: { + accessToken: string; + idToken?: string | undefined; + refreshToken?: string | undefined; + scope: string; +}): TokenResponse => ({ + token_type: "Bearer", + scope, + expires_in: EXPIRES_IN_SECONDS, + ext_expires_in: EXPIRES_IN_SECONDS, + access_token: accessToken, + ...(idToken ? { id_token: idToken } : {}), + ...(refreshToken ? { refresh_token: refreshToken } : {}), +}); + +const findUserById = (ctx: TokenContext, id: string): EntraUser => { + let user = createUserQuery(ctx.simulationStore)((u) => u.id === id); + assert(!!user, "401::invalid_grant"); + return user; +}; + +const authorizationCodeTokens = async (ctx: TokenContext): Promise => { + let { code, code_verifier } = ctx.body; + assert(typeof code !== "undefined", "400::no code in token request"); + + let decoded = decodeAuthorizationCode(code); + + assert( + verifyPkce({ + codeVerifier: code_verifier, + codeChallenge: decoded.code_challenge, + codeChallengeMethod: decoded.code_challenge_method, + }), + "400::invalid_grant: PKCE verification failed", + ); + + let clientId = ctx.body.client_id ?? decoded.client_id; + let user = findUserById(ctx, decoded.sub); + let scope = ctx.body.scope ?? decoded.scope; + + return finishUserTokens({ + ctx, + user, + clientId, + scope, + nonce: decoded.nonce, + authTime: decoded.auth_time, + }); +}; + +const refreshTokenTokens = async (ctx: TokenContext): Promise => { + let { refresh_token } = ctx.body; + assert(typeof refresh_token !== "undefined", "400::no refresh_token in token request"); + + let decoded = decodeRefreshToken(refresh_token); + let clientId = ctx.body.client_id ?? decoded.client_id; + let user = findUserById(ctx, decoded.sub); + let scope = ctx.body.scope ?? decoded.scope; + + return finishUserTokens({ + ctx, + user, + clientId, + scope, + nonce: decoded.nonce, + authTime: epochTime(), + }); +}; + +const passwordTokens = async (ctx: TokenContext): Promise => { + // ROPC grant. Not recommended by Microsoft, but supported by Entra and handy + // for non-interactive integration tests. + let { username, password } = ctx.body; + assert(!!username, "400::username is required"); + + let user = createUserQuery(ctx.simulationStore)( + (u) => + u.email?.toLowerCase() === username!.toLowerCase() || + u.preferredUsername?.toLowerCase() === username!.toLowerCase(), + ); + assert(!!user, "401::invalid_grant"); + assert(user.password === password, "401::invalid_grant"); + + let clientId = resolveClientId(ctx); + let scope = ctx.body.scope ?? ctx.config.scope; + return finishUserTokens({ ctx, user, clientId, scope, nonce: undefined, authTime: epochTime() }); +}; + +const finishUserTokens = async ({ + ctx, + user, + clientId, + scope, + nonce, + authTime, +}: { + ctx: TokenContext; + user: EntraUser; + clientId: string; + scope: string; + nonce: string | undefined; + authTime: number; +}): Promise => { + let accessToken = await buildAccessToken({ ctx, user, clientId, scope }); + + let idToken = scopeIncludes(scope, "openid") + ? await buildIdToken({ ctx, user, clientId, nonce, authTime }) + : undefined; + + let refreshToken = scopeIncludes(scope, "offline_access") + ? buildRefreshToken({ sub: user.id, oid: user.id, nonce, scope, client_id: clientId }) + : undefined; + + return tokenResponse({ accessToken, idToken, refreshToken, scope }); +}; + +const clientCredentialsTokens = async (ctx: TokenContext): Promise => { + let clientId = resolveClientId(ctx); + let audience = ctx.body.resource ?? ctx.config.audience; + let iat = epochTime(); + + // In the client_credentials flow there is no user; the subject is the app's + // service principal. Entra emits `roles` (app roles) rather than `scp`. + let claims: AccessTokenClaims = { + ver: "2.0", + iss: ctx.issuer, + sub: clientId, + aud: audience, + exp: iat + EXPIRES_IN_SECONDS, + iat, + nbf: iat, + oid: clientId, + tid: ctx.tenant, + azp: clientId, + roles: [], + }; + + let accessToken = await sign(claims); + + return { + token_type: "Bearer", + expires_in: EXPIRES_IN_SECONDS, + ext_expires_in: EXPIRES_IN_SECONDS, + access_token: accessToken, + }; +}; diff --git a/packages/entra/src/handlers/url.ts b/packages/entra/src/handlers/url.ts new file mode 100644 index 00000000..26d00278 --- /dev/null +++ b/packages/entra/src/handlers/url.ts @@ -0,0 +1 @@ +export const removeTrailingSlash = (url: string): string => url.replace(/\/$/, ""); diff --git a/packages/entra/src/handlers/utils.ts b/packages/entra/src/handlers/utils.ts new file mode 100644 index 00000000..69fbc441 --- /dev/null +++ b/packages/entra/src/handlers/utils.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; +import { encode, decode } from "base64-url"; +import type { ExtendedSimulationStore } from "../store/index.ts"; +import type { EntraUser } from "../store/entities.ts"; +import type { AuthorizationCode, RefreshTokenPayload } from "../types.ts"; + +type Predicate = (this: void, value: T, index: number, obj: T[]) => boolean; + +export const createUserQuery = + (store: ExtendedSimulationStore) => (predicate: Predicate) => { + const users = store.schema.users.selectTableAsList(store.store.getState()); + return users.find(predicate); + }; + +// Authorization codes and refresh tokens are encoded statelessly as base64url +// JSON so the token endpoint can be served without any server side session for +// the code exchange, mirroring how the auth0 simulator encodes refresh tokens. +export const encodeAuthorizationCode = (code: AuthorizationCode): string => + encode(JSON.stringify(code)); + +export const decodeAuthorizationCode = (value: string): AuthorizationCode => + JSON.parse(decode(value)); + +export const encodeRefreshToken = (token: RefreshTokenPayload): string => + encode(JSON.stringify(token)); + +export const decodeRefreshToken = (value: string): RefreshTokenPayload => JSON.parse(decode(value)); + +// PKCE verification (RFC 7636). Returns true when the presented verifier +// satisfies the challenge that was captured at /authorize time. When no +// challenge was captured (non-PKCE flow) verification is a no-op. +export const verifyPkce = ({ + codeVerifier, + codeChallenge, + codeChallengeMethod, +}: { + codeVerifier: string | undefined; + codeChallenge: string | undefined; + codeChallengeMethod: string | undefined; +}): boolean => { + if (!codeChallenge) { + return true; + } + + if (!codeVerifier) { + return false; + } + + if (!codeChallengeMethod || codeChallengeMethod.toUpperCase() === "PLAIN") { + return codeVerifier === codeChallenge; + } + + // S256: BASE64URL(SHA256(ASCII(code_verifier))) + let hashed = createHash("sha256").update(codeVerifier).digest("base64url"); + return hashed === codeChallenge; +}; + +export const scopeIncludes = (scope: string, value: string): boolean => + scope.split(" ").filter(Boolean).includes(value); diff --git a/packages/entra/src/index.ts b/packages/entra/src/index.ts new file mode 100644 index 00000000..81ebd3b2 --- /dev/null +++ b/packages/entra/src/index.ts @@ -0,0 +1,39 @@ +import { + createFoundationSimulationServer, + type FoundationSimulator, +} from "@simulacrum/foundation-simulator"; +import type { ExtendedSimulationStore, EntraExtendStoreInput } from "./store/index.ts"; +import { extendStore } from "./store/index.ts"; +import type { Router } from "express"; +import { extendRouter } from "./handlers/index.ts"; +import { type EntraInitialStore, entraInitialStoreSchema } from "./store/entities.ts"; +import { getConfig } from "./config/get-config.ts"; +import { type EntraConfiguration } from "./types.ts"; + +export type EntraSimulator = (args?: { + debug?: boolean; + initialState?: EntraInitialStore; + extend?: { + extendStore?: EntraExtendStoreInput; + extendRouter?: (router: Router, simulationStore: ExtendedSimulationStore) => void; + }; + options?: Partial; +}) => FoundationSimulator; + +export const simulation: EntraSimulator = (args = {}) => { + const config = getConfig(args.options); + const parsedInitialState = !args?.initialState + ? undefined + : entraInitialStoreSchema.parse(args?.initialState); + return createFoundationSimulationServer({ + port: config.port ?? 4400, // default port + protocol: "https", + extendStore: extendStore(parsedInitialState, args?.extend?.extendStore), + extendRouter: extendRouter(config, args.extend?.extendRouter, args.debug), + })(); +}; + +export { entraUserSchema, defaultUser } from "./store/entities.ts"; +export { getConfig } from "./config/get-config.ts"; +export type { EntraConfiguration } from "./types.ts"; +export type { EntraInitialStore } from "./store/entities.ts"; diff --git a/packages/entra/src/middleware/create-cors.ts b/packages/entra/src/middleware/create-cors.ts new file mode 100644 index 00000000..66138768 --- /dev/null +++ b/packages/entra/src/middleware/create-cors.ts @@ -0,0 +1,14 @@ +import type { RequestHandler } from "express"; +import cors from "cors"; + +export const createCors = (): RequestHandler => + cors({ + origin: (origin, cb) => { + if (typeof origin === "string") { + return cb(null, [origin]); + } + + cb(null, "*"); + }, + credentials: true, + }); diff --git a/packages/entra/src/middleware/error-handling.ts b/packages/entra/src/middleware/error-handling.ts new file mode 100644 index 00000000..56321c6f --- /dev/null +++ b/packages/entra/src/middleware/error-handling.ts @@ -0,0 +1,36 @@ +import type { Request, Response, NextFunction } from "express"; + +export function defaultErrorHandler( + error: Error, + _req: Request, + res: Response, + next: NextFunction, +) { + if (res.headersSent) { + return next(error); + } + + let assertCondition = "Assert condition failed: "; + + if (error?.message?.startsWith(assertCondition)) { + let errorCode = 500; + let errorResponse = error.message; + + if (error.message.includes("::")) { + let errorMessage = error.message.slice(assertCondition.length); + errorCode = parseInt(errorMessage.slice(0, 3)); + errorResponse = errorMessage.slice(5); + } + + res.status(errorCode).send(errorResponse); + } else { + console.error(error); + res.status(500).json({ + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + } +} diff --git a/packages/entra/src/middleware/no-cache.ts b/packages/entra/src/middleware/no-cache.ts new file mode 100644 index 00000000..b1ded26b --- /dev/null +++ b/packages/entra/src/middleware/no-cache.ts @@ -0,0 +1,7 @@ +import type { RequestHandler } from "express"; + +export const noCache: () => RequestHandler = () => (_, res, next) => { + res.set("Pragma", "no-cache"); + res.set("Cache-Control", "no-cache, no-store"); + next(); +}; diff --git a/packages/entra/src/middleware/session.ts b/packages/entra/src/middleware/session.ts new file mode 100644 index 00000000..06e96160 --- /dev/null +++ b/packages/entra/src/middleware/session.ts @@ -0,0 +1,14 @@ +import type { RequestHandler } from "express"; +import cookieSession from "cookie-session"; +const twentyFourHours = 24 * 60 * 60 * 1000; + +export const createSession = (secret = "shhh"): RequestHandler => { + return cookieSession({ + name: "session", + keys: [secret], + secure: true, + httpOnly: false, + maxAge: twentyFourHours, + sameSite: "none", + }); +}; diff --git a/packages/entra/src/store/entities.ts b/packages/entra/src/store/entities.ts new file mode 100644 index 00000000..458d989c --- /dev/null +++ b/packages/entra/src/store/entities.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; +import { type IdProp } from "@simulacrum/foundation-simulator"; +import { faker } from "@faker-js/faker"; + +// A GUID-shaped identifier, mirroring Entra's `oid`/`sub` claims. +const guid = () => faker.string.uuid(); + +export const entraUserSchema = z + .object({ + // `oid` in Entra: the immutable object id for the user in the directory. + id: z.string().default(guid), + name: z.string(), + password: z.string().optional().default("12345"), + email: z.string().email().optional(), + // `preferred_username` in the id token; defaults to the email. + preferredUsername: z.string().optional(), + }) + .transform((user) => { + if (!user.email) user.email = faker.internet.email({ firstName: user.name }); + if (!user.preferredUsername) user.preferredUsername = user.email; + return user; + }); +export type EntraUser = z.infer; + +export const defaultUser = entraUserSchema.parse({ + id: "0e8a3b8a-1111-4000-a000-0000000000cd", + name: "Default User", + email: "default@example.com", +}); + +export const entraInitialStoreSchema = z.object({ + users: z.array(entraUserSchema), +}); +export type EntraStore = z.output; +export type EntraInitialStore = z.input; + +export const convertToObj = ( + arrayOfObjects: T[], + key: IdProp = "id", +): Record => + arrayOfObjects.reduce( + (final, obj: T) => { + final[obj[key]] = obj; + return final; + }, + {} as Record, + ); + +export const convertInitialStateToStoreState = (initialState: EntraInitialStore | undefined) => { + if (!initialState) return undefined; + const storeObject = { + users: convertToObj(initialState.users as EntraStore["users"], "id"), + }; + + return storeObject; +}; diff --git a/packages/entra/src/store/index.ts b/packages/entra/src/store/index.ts new file mode 100644 index 00000000..a720167d --- /dev/null +++ b/packages/entra/src/store/index.ts @@ -0,0 +1,100 @@ +import type { + SimulationStore, + ExtendSimulationSchema, + ExtendSimulationSchemaInput, + ExtendSimulationActions, + ExtendSimulationActionsInput, + ExtendSimulationSelectors, + ExtendSimulationSelectorsInput, + TableOutput, + AnyState, + ExtendSimulationActionsInputLoose, + ExtendSimulationSelectorsInputLoose, + ExtendStoreConfig, +} from "@simulacrum/foundation-simulator"; +import { + convertInitialStateToStoreState, + defaultUser, + type EntraUser, + type EntraInitialStore, +} from "./entities.ts"; + +export type ExtendedSchema = ({ slice }: ExtendSimulationSchema) => { + users: (n: string) => TableOutput; +}; +type ExtendActions = typeof inputActions; +type ExtendSelectors = typeof inputSelectors; +export type EntraSchema = ReturnType; +export type EntraActions = ReturnType; +export type EntraSelectors = ReturnType; + +export type ExtendedSimulationStore = SimulationStore; + +const inputSchema = + (initialState?: EntraInitialStore, extendedSchema?: ExtendSimulationSchemaInput) => + ({ slice }: ExtendSimulationSchema) => { + const storeInitialState = convertInitialStateToStoreState(initialState); + + const extended = extendedSchema ? extendedSchema({ slice }) : {}; + let slices = { + users: slice.table( + !storeInitialState + ? { + initialState: { + [defaultUser.id]: defaultUser, + }, + } + : { initialState: storeInitialState.users }, + ), + ...extended, + }; + return slices; + }; + +const inputActions = (_args: ExtendSimulationActions) => { + return {} as ExtendSimulationActions; +}; + +const extendActions = + (extendedActions?: ExtendSimulationActionsInputLoose) => + (args: ExtendSimulationActions) => { + const base = inputActions(args); + if (!extendedActions) return base; + const extResult = extendedActions(args); + return { + ...(base as object), + ...(extResult as object), + } as EntraActions; + }; + +const inputSelectors = (_args: ExtendSimulationSelectors) => { + return {} as ExtendSimulationSelectors; +}; + +const extendSelectors = + (extendedSelectors?: ExtendSimulationSelectorsInputLoose) => + (args: ExtendSimulationSelectors) => { + const base = inputSelectors(args); + if (!extendedSelectors) return base; + const extResult = extendedSelectors(args); + return { + ...(base as object), + ...(extResult as object), + } as EntraSelectors; + }; + +export type EntraExtendStoreInput = ExtendStoreConfig; + +export const extendStore = ( + initialState: EntraInitialStore | undefined, + extended?: EntraExtendStoreInput, +): { + schema: ExtendSimulationSchemaInput; + actions?: ExtendSimulationActionsInput; + selectors?: ExtendSimulationSelectorsInput; + logs?: boolean; +} => ({ + actions: extendActions(extended?.actions), + selectors: extendSelectors(extended?.selectors), + schema: inputSchema(initialState, extended?.schema), +}); diff --git a/packages/entra/src/types.ts b/packages/entra/src/types.ts new file mode 100644 index 00000000..5fa40880 --- /dev/null +++ b/packages/entra/src/types.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; + +export const configurationSchema = z.object({ + port: z.optional( + z.number().gt(2999, "port must be greater than 2999").lt(10000, "must be less than 10000"), + ), + // The default tenant used when constructing the bin/example issuer. Requests + // may target any tenant in the path; whatever tenant is used becomes the + // `tid` claim and the issuer tenant so tokens stay internally consistent. + tenant: z.optional(z.string().min(1, "tenant is required")), + // Entra application (client) id. GUID in real Entra, but any string works here. + clientId: z.optional(z.string().min(1, "clientId is required")), + // Default audience for access tokens when a request does not specify a resource. + audience: z.optional(z.string().min(1, "audience is required")), + // Space delimited scopes the simulator advertises/echoes by default. + scope: z.optional(z.string().min(1, "scope is required")), + cookieSecret: z.optional(z.string()), +}); + +export type ConfigSchema = z.infer; + +type ReadonlyFields = "tenant" | "clientId" | "audience" | "scope" | "port"; + +export type EntraConfiguration = { + [K in ReadonlyFields]-?: NonNullable; +} & Omit; + +// grant types Entra's v2.0 token endpoint accepts that we simulate +export type GrantType = "authorization_code" | "refresh_token" | "client_credentials" | "password"; + +export type ResponseMode = "query" | "fragment" | "form_post"; + +export type AuthorizeQuery = { + client_id: string; + redirect_uri: string; + response_type: string; + response_mode?: ResponseMode | undefined; + scope: string; + state?: string | undefined; + nonce?: string | undefined; + code_challenge?: string | undefined; + code_challenge_method?: string | undefined; + prompt?: string | undefined; + login_hint?: string | undefined; + domain_hint?: string | undefined; +}; + +// Payload we encode into an authorization code so the token endpoint is stateless. +export interface AuthorizationCode { + sub: string; + oid: string; + nonce?: string | undefined; + scope: string; + client_id: string; + code_challenge?: string | undefined; + code_challenge_method?: string | undefined; + auth_time: number; + iat: number; +} + +export interface RefreshTokenPayload { + sub: string; + oid: string; + nonce?: string | undefined; + scope: string; + client_id: string; + iat: number; + exp: number; +} + +export interface IdTokenClaims { + ver: "2.0"; + iss: string; + sub: string; + aud: string; + exp: number; + iat: number; + nbf: number; + name?: string | undefined; + preferred_username?: string | undefined; + oid: string; + tid: string; + email?: string | undefined; + nonce?: string | undefined; + auth_time?: number | undefined; + [key: string]: unknown; +} + +export interface AccessTokenClaims { + ver: "2.0"; + iss: string; + sub: string; + aud: string; + exp: number; + iat: number; + nbf: number; + oid: string; + tid: string; + azp: string; + scp?: string | undefined; + roles?: string[] | undefined; + name?: string | undefined; + preferred_username?: string | undefined; + [key: string]: unknown; +} + +export interface TokenResponse { + token_type: "Bearer"; + scope?: string | undefined; + expires_in: number; + ext_expires_in: number; + access_token: string; + refresh_token?: string | undefined; + id_token?: string | undefined; +} diff --git a/packages/entra/src/views/login.ts b/packages/entra/src/views/login.ts new file mode 100644 index 00000000..1c6c1aee --- /dev/null +++ b/packages/entra/src/views/login.ts @@ -0,0 +1,80 @@ +import { encode } from "html-entities"; +import type { AuthorizeQuery } from "../types.ts"; + +interface LoginViewProps { + // where the form posts back to (the simulator's login handler) + actionUrl: string; + query: AuthorizeQuery; + loginFailed: boolean; +} + +const hidden = (name: string, value: string | undefined): string => + typeof value === "undefined" + ? "" + : ``; + +export const loginView = ({ actionUrl, query, loginFailed }: LoginViewProps): string => { + return /*html*/ ` + + + + + Sign in + + + +
+
+
Microsoft Entra (Simulacrum)
+

Sign in

+

to continue to your application

+
Your account or password is incorrect.
+ + + ${hidden("client_id", query.client_id)} + ${hidden("redirect_uri", query.redirect_uri)} + ${hidden("response_type", query.response_type)} + ${hidden("response_mode", query.response_mode)} + ${hidden("scope", query.scope)} + ${hidden("state", query.state)} + ${hidden("nonce", query.nonce)} + ${hidden("code_challenge", query.code_challenge)} + ${hidden("code_challenge_method", query.code_challenge_method)} + +
+
+ + + `; +}; diff --git a/packages/entra/test/entra.test.ts b/packages/entra/test/entra.test.ts new file mode 100644 index 00000000..4fa68c72 --- /dev/null +++ b/packages/entra/test/entra.test.ts @@ -0,0 +1,421 @@ +import { describe, it, beforeAll, afterAll, beforeEach, expect } from "vitest"; +import { simulation, defaultUser } from "../src/index.ts"; +import type { FoundationSimulatorListening } from "@simulacrum/foundation-simulator"; +import { decodeJwt, type JWTPayload } from "jose"; +import { stringify } from "querystring"; +import { createPkcePair } from "./helpers.ts"; + +let basePort = 4411; +let host = "https://localhost"; +let tenant = "0e8a3b8a-0000-4000-a000-0000000000ab"; +let baseUrl = `${host}:${basePort}`; +let authority = `${baseUrl}/${tenant}`; +let clientId = "00000000-0000-0000-0000-000000000000"; +let redirectUri = "http://localhost:3000/auth/callback"; +let person = defaultUser; + +// A cookie jar just rich enough to carry the simulator's session cookie between +// the login POST and a follow-up silent /authorize request. +const cookieHeader = (res: Response): string => + (res.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); + +describe("Entra ID simulator", () => { + let server: FoundationSimulatorListening; + beforeAll(async () => { + const app = simulation(); + server = await app.listen(basePort); + }); + afterAll(async () => { + await server.ensureClose(); + }); + + it("has a heartbeat", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/heartbeat`); + expect(res.ok).toBe(true); + }); + + describe("/authorize", () => { + it("renders a login page for an interactive request", async () => { + let { challenge } = createPkcePair(); + let res = await fetch( + `${authority}/oauth2/v2.0/authorize?${stringify({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + response_mode: "query", + scope: "openid profile email offline_access", + state: "state-123", + nonce: "nonce-abc", + code_challenge: challenge, + code_challenge_method: "S256", + })}`, + ); + + expect(res.ok).toBe(true); + expect(res.headers.get("content-type")).toContain("text/html"); + let body = await res.text(); + expect(body).toContain("Sign in"); + // PKCE challenge is carried through the login form as a hidden field + expect(body).toContain(`name="code_challenge"`); + expect(body).toContain(challenge); + }); + + it("returns login_required for prompt=none without a session", async () => { + let res = await fetch( + `${authority}/oauth2/v2.0/authorize?${stringify({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + prompt: "none", + state: "st", + })}`, + { redirect: "manual" }, + ); + + expect(res.status).toBe(302); + let location = res.headers.get("location")!; + expect(location).toContain("error=login_required"); + expect(location).toContain("state=st"); + }); + }); + + describe("authorization code + PKCE flow", () => { + let pkce = createPkcePair(); + + const login = (extra: Record = {}) => + fetch(`${authority}/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: stringify({ + username: person.email, + password: person.password, + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + response_mode: "query", + scope: "openid profile email offline_access", + state: "state-123", + nonce: "nonce-abc", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + ...extra, + }), + }); + + it("redirects back to redirect_uri with a code and state on valid login", async () => { + let res = await login(); + expect(res.status).toBe(302); + let location = new URL(res.headers.get("location")!); + expect(`${location.origin}${location.pathname}`).toBe(redirectUri); + expect(location.searchParams.get("code")).toBeTruthy(); + expect(location.searchParams.get("state")).toBe("state-123"); + }); + + it("re-renders the login page with a 401 on invalid credentials", async () => { + let res = await login({ password: "wrong" }); + expect(res.status).toBe(401); + let body = await res.text(); + expect(body).toContain("incorrect"); + }); + + it("exchanges the code for id, access and refresh tokens", async () => { + let loginRes = await login(); + let code = new URL(loginRes.headers.get("location")!).searchParams.get("code")!; + + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "authorization_code", + code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: pkce.verifier, + }), + }); + + expect(res.ok).toBe(true); + let json = (await res.json()) as { + token_type: string; + id_token: string; + access_token: string; + refresh_token: string; + expires_in: number; + ext_expires_in: number; + }; + + expect(json.token_type).toBe("Bearer"); + expect(json.expires_in).toBe(3600); + expect(json.ext_expires_in).toBe(3600); + expect(json.refresh_token).toBeTruthy(); + + let idToken = decodeJwt(json.id_token); + expect(idToken.iss).toBe(`${authority}/v2.0`); + expect(idToken.aud).toBe(clientId); + expect(idToken.tid).toBe(tenant); + expect(idToken.ver).toBe("2.0"); + expect(idToken.oid).toBe(person.id); + expect(idToken.sub).toBe(person.id); + expect(idToken.preferred_username).toBe(person.email); + expect(idToken.email).toBe(person.email); + expect(idToken.nonce).toBe("nonce-abc"); + + let accessToken = decodeJwt(json.access_token); + expect(accessToken.iss).toBe(`${authority}/v2.0`); + expect(accessToken.tid).toBe(tenant); + expect(accessToken.azp).toBe(clientId); + // reserved OIDC scopes are stripped from scp + expect(accessToken.scp).toBe("email"); + }); + + it("verifies the id_token header carries the JWKS kid", async () => { + let loginRes = await login(); + let code = new URL(loginRes.headers.get("location")!).searchParams.get("code")!; + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "authorization_code", + code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: pkce.verifier, + }), + }); + let json = (await res.json()) as { id_token: string }; + let header = JSON.parse( + Buffer.from(json.id_token.split(".")[0]!, "base64url").toString("utf8"), + ); + expect(header.alg).toBe("RS256"); + expect(header.typ).toBe("JWT"); + expect(header.kid).toBeTruthy(); + }); + + it("rejects the exchange when the PKCE verifier does not match", async () => { + let loginRes = await login(); + let code = new URL(loginRes.headers.get("location")!).searchParams.get("code")!; + + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "authorization_code", + code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: "not-the-right-verifier", + }), + }); + + expect(res.ok).toBe(false); + expect(res.status).toBe(400); + }); + }); + + describe("refresh_token grant", () => { + let pkce = createPkcePair(); + let refreshToken: string; + + beforeEach(async () => { + let loginRes = await fetch(`${authority}/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: stringify({ + username: person.email, + password: person.password, + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + }), + }); + let code = new URL(loginRes.headers.get("location")!).searchParams.get("code")!; + let tokenRes = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "authorization_code", + code, + client_id: clientId, + code_verifier: pkce.verifier, + }), + }); + refreshToken = ((await tokenRes.json()) as { refresh_token: string }).refresh_token; + }); + + it("issues fresh tokens from a refresh_token", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }), + }); + + expect(res.ok).toBe(true); + let json = (await res.json()) as { access_token: string; id_token: string }; + let idToken = decodeJwt(json.id_token); + expect(idToken.sub).toBe(person.id); + }); + }); + + describe("client_credentials grant", () => { + it("issues an app-only access token with no id_token", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "client_credentials", + client_id: "api-client", + client_secret: "secret", + scope: "https://graph.microsoft.com/.default", + resource: "https://graph.microsoft.com", + }), + }); + + expect(res.ok).toBe(true); + let json = (await res.json()) as { + access_token: string; + id_token?: string; + refresh_token?: string; + }; + expect(json.id_token).toBeUndefined(); + expect(json.refresh_token).toBeUndefined(); + + let accessToken = decodeJwt(json.access_token); + expect(accessToken.aud).toBe("https://graph.microsoft.com"); + expect(accessToken.azp).toBe("api-client"); + expect(accessToken.sub).toBe("api-client"); + expect(Array.isArray((accessToken as JWTPayload & { roles: unknown }).roles)).toBe(true); + }); + }); + + describe("password (ROPC) grant", () => { + it("issues tokens for valid username/password", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "password", + username: person.email, + password: person.password, + client_id: clientId, + scope: "openid profile email", + }), + }); + + expect(res.ok).toBe(true); + let json = (await res.json()) as { id_token: string }; + expect(decodeJwt(json.id_token).email).toBe(person.email); + }); + + it("rejects invalid credentials with 401", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "password", + username: person.email, + password: "nope", + client_id: clientId, + scope: "openid", + }), + }); + expect(res.status).toBe(401); + }); + }); + + describe("/oidc/userinfo", () => { + let accessToken: string; + beforeEach(async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "password", + username: person.email, + password: person.password, + client_id: clientId, + scope: "openid profile email", + }), + }); + accessToken = ((await res.json()) as { access_token: string }).access_token; + }); + + it("returns the user's profile from a bearer token", async () => { + let res = await fetch(`${baseUrl}/oidc/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(res.ok).toBe(true); + let user = (await res.json()) as { name: string; email: string; sub: string }; + expect(user.name).toBe(person.name); + expect(user.email).toBe(person.email); + expect(user.sub).toBe(person.id); + }); + + it("401s without a token", async () => { + let res = await fetch(`${baseUrl}/oidc/userinfo`); + expect(res.status).toBe(401); + }); + }); + + describe("silent authentication via session cookie", () => { + it("issues a code without re-prompting once a session exists", async () => { + let pkce = createPkcePair(); + let loginRes = await fetch(`${authority}/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: stringify({ + username: person.email, + password: person.password, + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + }), + }); + let cookie = cookieHeader(loginRes); + expect(cookie).toContain("session"); + + let silent = await fetch( + `${authority}/oauth2/v2.0/authorize?${stringify({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + prompt: "none", + state: "silent-state", + })}`, + { headers: { cookie }, redirect: "manual" }, + ); + + expect(silent.status).toBe(302); + let location = new URL(silent.headers.get("location")!); + expect(location.searchParams.get("code")).toBeTruthy(); + expect(location.searchParams.get("state")).toBe("silent-state"); + }); + }); + + describe("/logout", () => { + it("redirects to post_logout_redirect_uri", async () => { + let res = await fetch( + `${authority}/oauth2/v2.0/logout?${stringify({ + post_logout_redirect_uri: "http://localhost:3000/", + })}`, + { redirect: "manual" }, + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("http://localhost:3000/"); + }); + }); +}); diff --git a/packages/entra/test/helpers.ts b/packages/entra/test/helpers.ts new file mode 100644 index 00000000..da1ffd9c --- /dev/null +++ b/packages/entra/test/helpers.ts @@ -0,0 +1,8 @@ +import { createHash, randomBytes } from "node:crypto"; + +// Generate a PKCE verifier/challenge pair the same way MSAL does. +export const createPkcePair = (): { verifier: string; challenge: string } => { + let verifier = randomBytes(32).toString("base64url"); + let challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +}; diff --git a/packages/entra/test/openid-handlers.test.ts b/packages/entra/test/openid-handlers.test.ts new file mode 100644 index 00000000..21ecfa07 --- /dev/null +++ b/packages/entra/test/openid-handlers.test.ts @@ -0,0 +1,53 @@ +import { describe, it, beforeAll, afterAll, expect } from "vitest"; +import { simulation } from "../src/index.ts"; +import type { FoundationSimulatorListening } from "@simulacrum/foundation-simulator"; +import { JWKS } from "../src/auth/constants.ts"; + +let basePort = 4410; +let host = "https://localhost"; +let tenant = "0e8a3b8a-0000-4000-a000-0000000000ab"; +let baseUrl = `${host}:${basePort}`; +let authority = `${baseUrl}/${tenant}`; + +describe("entra openid metadata", () => { + let server: FoundationSimulatorListening; + beforeAll(async () => { + const app = simulation(); + server = await app.listen(basePort); + }); + afterAll(async () => { + await server.ensureClose(); + }); + + it("serves the JWKS keys", async () => { + let res = await fetch(`${authority}/discovery/v2.0/keys`); + let json = await res.json(); + expect(res.ok).toBe(true); + expect(json).toEqual(JWKS); + }); + + it("serves the openid-configuration with a consistent, self-referential issuer", async () => { + let res = await fetch(`${authority}/v2.0/.well-known/openid-configuration`); + let json = (await res.json()) as Record; + + expect(res.ok).toBe(true); + expect(json.issuer).toBe(`${authority}/v2.0`); + expect(json.authorization_endpoint).toBe(`${authority}/oauth2/v2.0/authorize`); + expect(json.token_endpoint).toBe(`${authority}/oauth2/v2.0/token`); + expect(json.jwks_uri).toBe(`${authority}/discovery/v2.0/keys`); + expect(json.end_session_endpoint).toBe(`${authority}/oauth2/v2.0/logout`); + expect(json.userinfo_endpoint).toBe(`${baseUrl}/oidc/userinfo`); + }); + + it("serves AAD instance discovery so MSAL accepts the custom authority", async () => { + let res = await fetch(`${baseUrl}/common/discovery/instance`); + let json = (await res.json()) as { + tenant_discovery_endpoint: string; + metadata: { aliases: string[] }[]; + }; + + expect(res.ok).toBe(true); + expect(json.tenant_discovery_endpoint).toContain("/.well-known/openid-configuration"); + expect(json.metadata[0]?.aliases).toContain(`localhost:${basePort}`); + }); +}); diff --git a/packages/entra/tsconfig.json b/packages/entra/tsconfig.json new file mode 100644 index 00000000..f338bcfd --- /dev/null +++ b/packages/entra/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts", "example/**/*.ts"] +} diff --git a/packages/entra/tsdown.config.ts b/packages/entra/tsdown.config.ts new file mode 100644 index 00000000..2ca1768a --- /dev/null +++ b/packages/entra/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + name: "entra", + entry: "./src/index.ts", + deps: { + // if we unbundle, we want to skip this as well + skipNodeModulesBundle: true, + }, + exports: { devExports: "development" }, + format: ["esm"], + // not really required and can mangle things + minify: false, + // don't bundle up as have some relative path imports for static assets + unbundle: true, + unused: true, + // runs with @arethetypeswrong/core which checks types + attw: { profile: "esm-only" }, + publint: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25573c1e..a0b2a8e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,67 @@ importers: packages/client: {} + packages/entra: + dependencies: + '@faker-js/faker': + specifier: ^9.3.0 + version: 9.9.0 + '@simulacrum/foundation-simulator': + specifier: ^0.8.0 + version: link:../foundation + assert-ts: + specifier: ^0.3.4 + version: 0.3.4 + base64-url: + specifier: ^2.3.3 + version: 2.3.3 + cookie-session: + specifier: ^2.1.0 + version: 2.1.1 + cors: + specifier: ^2.8.6 + version: 2.8.6 + cosmiconfig: + specifier: ^9.0.0 + version: 9.0.1(typescript@5.8.3) + express: + specifier: ^5.2.1 + version: 5.2.1 + html-entities: + specifier: ^2.5.2 + version: 2.6.0 + jose: + specifier: ^5.9.6 + version: 5.10.0 + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + '@simulacrum/server': + specifier: workspace:^ + version: link:../server + '@types/base64-url': + specifier: ^2.2.2 + version: 2.2.2 + '@types/cookie-session': + specifier: ^2.0.49 + version: 2.0.49 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.3 + version: 5.0.6 + '@types/keygrip': + specifier: ^1.0.4 + version: 1.0.6 + effection: + specifier: 'catalog:' + version: 4.0.3 + keygrip: + specifier: ^1.1.0 + version: 1.1.0 + packages/foundation: dependencies: ajv-formats: From e86d78ef082484b4c727b74683b4025364f214a4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:16:15 -0400 Subject: [PATCH 2/3] Format entra README and package.json with oxfmt --- packages/entra/README.md | 25 ++++++++++++++----------- packages/entra/package.json | 8 ++++---- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/entra/README.md b/packages/entra/README.md index c0f9b03a..999844c7 100644 --- a/packages/entra/README.md +++ b/packages/entra/README.md @@ -43,9 +43,7 @@ Or run it from code: import { simulation } from "@simulacrum/entra-simulator"; const app = simulation(); -app.listen(4400, () => - console.log("Entra simulation server started at https://localhost:4400"), -); +app.listen(4400, () => console.log("Entra simulation server started at https://localhost:4400")); ``` Seed your own users with `initialState`: @@ -54,7 +52,12 @@ Seed your own users with `initialState`: const app = simulation({ initialState: { users: [ - { id: "11111111-1111-1111-1111-111111111111", name: "Ada Lovelace", email: "ada@example.com", password: "hunter2" }, + { + id: "11111111-1111-1111-1111-111111111111", + name: "Ada Lovelace", + email: "ada@example.com", + password: "hunter2", + }, ], }, options: { @@ -142,13 +145,13 @@ Configuration is loaded with [cosmiconfig](https://github.com/cosmiconfig/cosmic 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. | +| 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 diff --git a/packages/entra/package.json b/packages/entra/package.json index 5b241c50..d90c2167 100644 --- a/packages/entra/package.json +++ b/packages/entra/package.json @@ -3,16 +3,16 @@ "version": "0.1.0", "description": "Run local instance of Microsoft Entra ID (Azure AD) OIDC for local development and integration testing", "keywords": [ - "entra", + "authentication", "azure-ad", "azuread", - "microsoft", - "oidc", - "authentication", "emulation", + "entra", "integration testing", + "microsoft", "mock", "mocking", + "oidc", "simulation", "stubbing" ], From 56105c89b1b7288bb2f5c52ddca3e699d486ea13 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:13:09 -0400 Subject: [PATCH 3/3] Address CodeRabbit feedback (DX-focused) on entra simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a local dev tool, so security-only findings are ignored; the focus is developer experience — avoiding opaque failures and confusing behavior. - Return 400 invalid_grant (not an opaque 500 + stack) for malformed authorization codes and refresh tokens, matching real Entra - Escape reflected values in the form_post auto-submit page so a legitimate state/redirect_uri containing " & < survives intact instead of corrupting the posted state (which would make the app reject the callback) - Use & instead of ? when the redirect_uri already carries a query string - Forward extendStore `logs` so `logs: true` actually enables action logging - Point package "types" at the emitted ./dist/index.d.mts (the .d.cts path did not exist, breaking types for classic-resolution consumers) - Drop grant types the token endpoint does not implement (jwt-bearer, implicit) from the discovery document - Preserve auth_time across refresh so refreshed id tokens report the original sign-in time - Clarify in the README that /oidc/userinfo is mounted globally, not tenant-scoped - Add tests for fragment and form_post response modes, HTML escaping, the redirect-separator fix, and malformed-grant 400s Deliberately not changed: session httpOnly and error stack-trace exposure are security-only concerns that do not apply to a localhost dev tool (and the stack trace aids debugging). --- packages/entra/README.md | 3 +- packages/entra/package.json | 2 +- packages/entra/src/handlers/entra-handlers.ts | 29 +++-- .../entra/src/handlers/openid-handlers.ts | 4 +- packages/entra/src/handlers/token.ts | 12 ++- packages/entra/src/handlers/utils.ts | 18 +++- packages/entra/src/store/index.ts | 4 + packages/entra/src/types.ts | 3 + packages/entra/test/entra.test.ts | 102 ++++++++++++++++++ 9 files changed, 163 insertions(+), 14 deletions(-) diff --git a/packages/entra/README.md b/packages/entra/README.md index 999844c7..c967e129 100644 --- a/packages/entra/README.md +++ b/packages/entra/README.md @@ -191,7 +191,8 @@ Endpoints (tenant-scoped, mirroring real Entra): - `POST /:tenant/login` (login form submission) - `POST /:tenant/oauth2/v2.0/token` - `GET /:tenant/oauth2/v2.0/logout` -- `GET /oidc/userinfo` (Microsoft Graph style) +- `GET /oidc/userinfo` — Microsoft Graph style, mounted **globally** (not under + `/:tenant`), matching the real `https://graph.microsoft.com/oidc/userinfo` ## What is (and isn't) simulated diff --git a/packages/entra/package.json b/packages/entra/package.json index d90c2167..70e1ced5 100644 --- a/packages/entra/package.json +++ b/packages/entra/package.json @@ -33,7 +33,7 @@ "dist/**/*" ], "type": "module", - "types": "./dist/index.d.cts", + "types": "./dist/index.d.mts", "typesVersions": { "*": { "*": [ diff --git a/packages/entra/src/handlers/entra-handlers.ts b/packages/entra/src/handlers/entra-handlers.ts index 308ab2cc..6d41b90d 100644 --- a/packages/entra/src/handlers/entra-handlers.ts +++ b/packages/entra/src/handlers/entra-handlers.ts @@ -1,5 +1,6 @@ import { assert } from "assert-ts"; import { stringify } from "querystring"; +import { encode } from "html-entities"; import { decodeJwt } from "jose"; import type { Request, RequestHandler, Response } from "express"; import type { ExtendedSimulationStore } from "../store/index.ts"; @@ -70,8 +71,7 @@ const redirectWithCode = (res: Response, query: AuthorizeQuery, user: EntraUser) return; } - let separator = mode === "fragment" ? "#" : "?"; - res.redirect(302, `${query.redirect_uri}${separator}${stringify(params)}`); + res.redirect(302, appendParams(query.redirect_uri, mode, params)); }; const redirectWithError = ( @@ -83,17 +83,34 @@ const redirectWithError = ( let params: Record = { error, error_description: description }; if (typeof query.state !== "undefined") params.state = query.state; let mode = defaultResponseMode(query); - let separator = mode === "fragment" ? "#" : "?"; - res.redirect(302, `${query.redirect_uri}${separator}${stringify(params)}`); + res.redirect(302, appendParams(query.redirect_uri, mode, params)); +}; + +// Append the response params to the redirect_uri. `fragment` uses `#`; `query` +// uses `?` — unless the redirect_uri already carries a query string, in which +// case `&` keeps the URL well-formed instead of producing `...?a=1?code=...`. +const appendParams = ( + redirectUri: string, + mode: ResponseMode, + params: Record, +): string => { + let qs = stringify(params); + if (mode === "fragment") return `${redirectUri}#${qs}`; + let separator = redirectUri.includes("?") ? "&" : "?"; + return `${redirectUri}${separator}${qs}`; }; const autoPostForm = (action: string, fields: Record): string => { + // Escape the action (redirect_uri) and every value. Beyond the obvious markup + // safety, a legitimate `state`/`redirect_uri` containing `"`, `&`, or `<` would + // otherwise break out of the attribute and corrupt the posted value, making the + // developer's app reject the callback with a state mismatch. let inputs = Object.entries(fields) - .map(([name, value]) => ``) + .map(([name, value]) => ``) .join("\n"); return /*html*/ `Working... -
+ ${inputs}
diff --git a/packages/entra/src/handlers/openid-handlers.ts b/packages/entra/src/handlers/openid-handlers.ts index 1119bc7f..b5348bde 100644 --- a/packages/entra/src/handlers/openid-handlers.ts +++ b/packages/entra/src/handlers/openid-handlers.ts @@ -71,12 +71,12 @@ export const createOpenIdHandlers = (): { cloud_graph_host_name: "graph.windows.net", msgraph_host: "graph.microsoft.com", rbac_url: "https://pas.windows.net", + // only advertise grants the token endpoint actually implements — a + // library that trusts this list should never receive a surprise 400 grant_types_supported: [ "authorization_code", "refresh_token", "client_credentials", - "urn:ietf:params:oauth:grant-type:jwt-bearer", - "implicit", "password", ], }); diff --git a/packages/entra/src/handlers/token.ts b/packages/entra/src/handlers/token.ts index 9ee9cae6..a72889cf 100644 --- a/packages/entra/src/handlers/token.ts +++ b/packages/entra/src/handlers/token.ts @@ -216,7 +216,8 @@ const refreshTokenTokens = async (ctx: TokenContext): Promise => clientId, scope, nonce: decoded.nonce, - authTime: epochTime(), + // preserve the original sign-in time across the refresh (real Entra behavior) + authTime: decoded.auth_time, }); }; @@ -261,7 +262,14 @@ const finishUserTokens = async ({ : undefined; let refreshToken = scopeIncludes(scope, "offline_access") - ? buildRefreshToken({ sub: user.id, oid: user.id, nonce, scope, client_id: clientId }) + ? buildRefreshToken({ + sub: user.id, + oid: user.id, + nonce, + scope, + client_id: clientId, + auth_time: authTime, + }) : undefined; return tokenResponse({ accessToken, idToken, refreshToken, scope }); diff --git a/packages/entra/src/handlers/utils.ts b/packages/entra/src/handlers/utils.ts index 69fbc441..3a0e8f36 100644 --- a/packages/entra/src/handlers/utils.ts +++ b/packages/entra/src/handlers/utils.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { encode, decode } from "base64-url"; +import { assert } from "assert-ts"; import type { ExtendedSimulationStore } from "../store/index.ts"; import type { EntraUser } from "../store/entities.ts"; import type { AuthorizationCode, RefreshTokenPayload } from "../types.ts"; @@ -18,13 +19,26 @@ export const createUserQuery = export const encodeAuthorizationCode = (code: AuthorizationCode): string => encode(JSON.stringify(code)); +// A malformed (truncated, tampered, or non-base64url) code/token would otherwise +// throw a raw SyntaxError and surface as an opaque 500. Real Entra answers a bad +// grant with `400 invalid_grant`, so mirror that — it keeps a developer's OAuth +// library and their debugging on the happy path. +const decodeStatelessToken = (value: string): T => { + try { + return JSON.parse(decode(value)) as T; + } catch { + assert(false, "400::invalid_grant"); + } +}; + export const decodeAuthorizationCode = (value: string): AuthorizationCode => - JSON.parse(decode(value)); + decodeStatelessToken(value); export const encodeRefreshToken = (token: RefreshTokenPayload): string => encode(JSON.stringify(token)); -export const decodeRefreshToken = (value: string): RefreshTokenPayload => JSON.parse(decode(value)); +export const decodeRefreshToken = (value: string): RefreshTokenPayload => + decodeStatelessToken(value); // PKCE verification (RFC 7636). Returns true when the presented verifier // satisfies the challenge that was captured at /authorize time. When no diff --git a/packages/entra/src/store/index.ts b/packages/entra/src/store/index.ts index a720167d..53e6cc8f 100644 --- a/packages/entra/src/store/index.ts +++ b/packages/entra/src/store/index.ts @@ -97,4 +97,8 @@ export const extendStore = ( actions: extendActions(extended?.actions), selectors: extendSelectors(extended?.selectors), schema: inputSchema(initialState, extended?.schema), + // forward the flag through so `extend.extendStore.logs = true` actually turns + // on action logging instead of being silently dropped (only set the key when + // provided, to satisfy exactOptionalPropertyTypes downstream) + ...(typeof extended?.logs !== "undefined" ? { logs: extended.logs } : {}), }); diff --git a/packages/entra/src/types.ts b/packages/entra/src/types.ts index 5fa40880..8a981f9a 100644 --- a/packages/entra/src/types.ts +++ b/packages/entra/src/types.ts @@ -64,6 +64,9 @@ export interface RefreshTokenPayload { nonce?: string | undefined; scope: string; client_id: string; + // original authentication time, preserved so refreshed id tokens report when + // the user actually signed in (as real Entra does) rather than "just now" + auth_time: number; iat: number; exp: number; } diff --git a/packages/entra/test/entra.test.ts b/packages/entra/test/entra.test.ts index 4fa68c72..0e7296c2 100644 --- a/packages/entra/test/entra.test.ts +++ b/packages/entra/test/entra.test.ts @@ -214,6 +214,108 @@ describe("Entra ID simulator", () => { }); }); + describe("response modes and redirect robustness", () => { + const loginWith = (extra: Record) => { + let pkce = createPkcePair(); + return fetch(`${authority}/login`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + redirect: "manual", + body: stringify({ + username: person.email, + password: person.password, + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + state: "state-123", + nonce: "nonce-abc", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + ...extra, + }), + }); + }; + + it("returns the code in the fragment for response_mode=fragment", async () => { + let res = await loginWith({ response_mode: "fragment" }); + expect(res.status).toBe(302); + let location = res.headers.get("location")!; + // fragment, not query — the params live after `#` + expect(location.startsWith(`${redirectUri}#`)).toBe(true); + let fragment = new URLSearchParams(location.split("#")[1]); + expect(fragment.get("code")).toBeTruthy(); + expect(fragment.get("state")).toBe("state-123"); + }); + + it("returns an auto-submitting form for response_mode=form_post", async () => { + let res = await loginWith({ response_mode: "form_post" }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + let body = await res.text(); + expect(body).toContain(`method="post"`); + expect(body).toContain(`action="${redirectUri}"`); + expect(body).toContain(`name="code"`); + expect(body).toContain(`name="state"`); + }); + + it("html-escapes reflected values in the form_post body", async () => { + // a state a real app might use (JSON/quoted) must survive intact and must + // not break out of the HTML attribute + let res = await loginWith({ + response_mode: "form_post", + state: `a">&b`, + }); + let body = await res.text(); + expect(body).not.toContain(""); + expect(body).toContain("<script>"); + }); + + it("uses `&` when the redirect_uri already has a query string", async () => { + let res = await loginWith({ + response_mode: "query", + redirect_uri: "http://localhost:3000/auth/callback?rt=1", + }); + expect(res.status).toBe(302); + let location = res.headers.get("location")!; + // exactly one `?`, and the OAuth params were appended with `&` + expect(location.split("?").length).toBe(2); + expect(location).toContain("rt=1&code="); + let parsed = new URL(location); + expect(parsed.searchParams.get("rt")).toBe("1"); + expect(parsed.searchParams.get("code")).toBeTruthy(); + }); + + it("answers a malformed authorization code with 400, not 500", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "authorization_code", + code: "this-is-not-a-valid-code", + client_id: clientId, + redirect_uri: redirectUri, + }), + }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("invalid_grant"); + }); + + it("answers a malformed refresh_token with 400, not 500", async () => { + let res = await fetch(`${authority}/oauth2/v2.0/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: stringify({ + grant_type: "refresh_token", + refresh_token: "garbage-token", + client_id: clientId, + }), + }); + expect(res.status).toBe(400); + expect(await res.text()).toContain("invalid_grant"); + }); + }); + describe("refresh_token grant", () => { let pkce = createPkcePair(); let refreshToken: string;