diff --git a/README.md b/README.md index 13ea45db..acf835db 100644 --- a/README.md +++ b/README.md @@ -602,13 +602,15 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a - `GET /.well-known/openid-configuration` - OIDC discovery document - `GET /:tenant/v2.0/.well-known/openid-configuration` - tenant-scoped OIDC discovery -- `GET /discovery/v2.0/keys` - JSON Web Key Set (JWKS) -- `GET /oauth2/v2.0/authorize` - authorization endpoint (shows user picker) -- `POST /oauth2/v2.0/token` - token exchange (authorization code, refresh token, client credentials) +- `GET /:tenant/discovery/v2.0/keys` - tenant-scoped JSON Web Key Set (JWKS) +- `GET /:tenant/oauth2/v2.0/authorize` - tenant-scoped authorization endpoint (shows user picker) +- `POST /:tenant/oauth2/v2.0/token` - tenant-scoped token exchange (authorization code, refresh token, client credentials) - `GET /oidc/userinfo` - OpenID Connect user info - `GET /v1.0/me` - Microsoft Graph user profile -- `GET /oauth2/v2.0/logout` - end session / logout -- `POST /oauth2/v2.0/revoke` - token revocation +- `GET /:tenant/oauth2/v2.0/logout` - tenant-scoped end session / logout +- `POST /:tenant/oauth2/v2.0/revoke` - tenant-scoped token revocation + +Root-scoped aliases such as `/oauth2/v2.0/token` remain available for compatibility, but tenant-scoped discovery now returns tenant-scoped endpoint URLs to match Microsoft. ## AWS diff --git a/apps/web/app/microsoft/page.mdx b/apps/web/app/microsoft/page.mdx index 07b2a4f9..afd744c8 100644 --- a/apps/web/app/microsoft/page.mdx +++ b/apps/web/app/microsoft/page.mdx @@ -6,20 +6,22 @@ Microsoft Entra ID (Azure AD) v2.0 OAuth 2.0 and OpenID Connect emulation with a - `GET /.well-known/openid-configuration` - OIDC discovery document - `GET /:tenant/v2.0/.well-known/openid-configuration` - tenant-scoped OIDC discovery -- `GET /discovery/v2.0/keys` - JSON Web Key Set (JWKS) -- `GET /oauth2/v2.0/authorize` - authorization endpoint (shows user picker) -- `POST /oauth2/v2.0/token` - token exchange (authorization code, refresh token, and client credentials grants) +- `GET /:tenant/discovery/v2.0/keys` - tenant-scoped JSON Web Key Set (JWKS) +- `GET /:tenant/oauth2/v2.0/authorize` - tenant-scoped authorization endpoint (shows user picker) +- `POST /:tenant/oauth2/v2.0/token` - tenant-scoped token exchange (authorization code, refresh token, and client credentials grants) - `GET /oidc/userinfo` - OpenID Connect user info - `GET /v1.0/me` - Microsoft Graph user profile -- `GET /oauth2/v2.0/logout` - end session / logout -- `POST /oauth2/v2.0/revoke` - token revocation +- `GET /:tenant/oauth2/v2.0/logout` - tenant-scoped end session / logout +- `POST /:tenant/oauth2/v2.0/revoke` - tenant-scoped token revocation + +Root-scoped aliases such as `/oauth2/v2.0/token` remain available for compatibility. ## Authorization Code Flow -1. Redirect the user to `/oauth2/v2.0/authorize` with `client_id`, `redirect_uri`, `scope`, `state`, and optionally `nonce`, `response_mode`, `code_challenge`, and `code_challenge_method` +1. Redirect the user to `/:tenant/oauth2/v2.0/authorize` with `client_id`, `redirect_uri`, `scope`, `state`, and optionally `nonce`, `response_mode`, `code_challenge`, and `code_challenge_method` 2. The emulator renders a user picker page 3. On selection, the emulator redirects to `redirect_uri` with `code` and `state` -4. Exchange the code for tokens via `POST /oauth2/v2.0/token` +4. Exchange the code for tokens via `POST /:tenant/oauth2/v2.0/token` ## PKCE diff --git a/packages/@emulators/microsoft/README.md b/packages/@emulators/microsoft/README.md index 3289d8ba..70e1fd90 100644 --- a/packages/@emulators/microsoft/README.md +++ b/packages/@emulators/microsoft/README.md @@ -14,14 +14,16 @@ npm install @emulators/microsoft - `GET /.well-known/openid-configuration` — OIDC discovery document - `GET /:tenant/v2.0/.well-known/openid-configuration` — tenant-scoped OIDC discovery -- `GET /discovery/v2.0/keys` — JSON Web Key Set (JWKS) -- `GET /oauth2/v2.0/authorize` — authorization endpoint (shows user picker) -- `POST /oauth2/v2.0/token` — token exchange (authorization code, refresh token, client credentials) +- `GET /:tenant/discovery/v2.0/keys` — tenant-scoped JSON Web Key Set (JWKS) +- `GET /:tenant/oauth2/v2.0/authorize` — tenant-scoped authorization endpoint (shows user picker) +- `POST /:tenant/oauth2/v2.0/token` — tenant-scoped token exchange (authorization code, refresh token, client credentials) - `GET /oidc/userinfo` — OpenID Connect user info - `GET /v1.0/me` — Microsoft Graph user profile - `GET /v1.0/users/:id` — Microsoft Graph user by ID -- `GET /oauth2/v2.0/logout` — end session / logout -- `POST /oauth2/v2.0/revoke` — token revocation +- `GET /:tenant/oauth2/v2.0/logout` — tenant-scoped end session / logout +- `POST /:tenant/oauth2/v2.0/revoke` — tenant-scoped token revocation + +Root-scoped aliases remain available for compatibility. ## Auth diff --git a/packages/@emulators/microsoft/src/__tests__/microsoft.test.ts b/packages/@emulators/microsoft/src/__tests__/microsoft.test.ts index 1e30c915..0dd87fc3 100644 --- a/packages/@emulators/microsoft/src/__tests__/microsoft.test.ts +++ b/packages/@emulators/microsoft/src/__tests__/microsoft.test.ts @@ -40,6 +40,7 @@ async function getAuthCode( state?: string; nonce?: string; response_mode?: string; + tenant?: string; } = {}, ): Promise<{ code: string; state: string }> { const email = options.email ?? "testuser@example.com"; @@ -49,6 +50,7 @@ async function getAuthCode( const nonce = options.nonce ?? "test-nonce"; const client_id = options.client_id ?? "test-client"; const response_mode = options.response_mode ?? "query"; + const tenantPath = options.tenant ? `/${options.tenant}` : ""; const formData = new URLSearchParams({ email, @@ -62,7 +64,7 @@ async function getAuthCode( code_challenge_method: "", }); - const res = await app.request(`${base}/oauth2/v2.0/authorize/callback`, { + const res = await app.request(`${base}${tenantPath}/oauth2/v2.0/authorize/callback`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formData.toString(), @@ -93,8 +95,10 @@ async function exchangeCode( client_id?: string; client_secret?: string; redirect_uri?: string; + tenant?: string; } = {}, ): Promise { + const tenantPath = options.tenant ? `/${options.tenant}` : ""; const formData = new URLSearchParams({ grant_type: "authorization_code", code, @@ -103,7 +107,7 @@ async function exchangeCode( redirect_uri: options.redirect_uri ?? "http://localhost:3000/callback", }); - return app.request(`${base}/oauth2/v2.0/token`, { + return app.request(`${base}${tenantPath}/oauth2/v2.0/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: formData.toString(), @@ -152,6 +156,21 @@ describe("Microsoft plugin integration", () => { expect(res.status).toBe(200); const body = (await res.json()) as Record; expect(body.issuer).toBe(`${base}/${tenantId}/v2.0`); + expect(body.authorization_endpoint).toBe(`${base}/${tenantId}/oauth2/v2.0/authorize`); + expect(body.token_endpoint).toBe(`${base}/${tenantId}/oauth2/v2.0/token`); + expect(body.end_session_endpoint).toBe(`${base}/${tenantId}/oauth2/v2.0/logout`); + expect(body.jwks_uri).toBe(`${base}/${tenantId}/discovery/v2.0/keys`); + }); + + it("GET /common/v2.0/.well-known/openid-configuration keeps common endpoints and default issuer", async () => { + const res = await app.request(`${base}/common/v2.0/.well-known/openid-configuration`); + expect(res.status).toBe(200); + const body = await res.json() as Record; + expect(body.issuer).toBe(`${base}/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0`); + expect(body.authorization_endpoint).toBe(`${base}/common/oauth2/v2.0/authorize`); + expect(body.token_endpoint).toBe(`${base}/common/oauth2/v2.0/token`); + expect(body.end_session_endpoint).toBe(`${base}/common/oauth2/v2.0/logout`); + expect(body.jwks_uri).toBe(`${base}/common/discovery/v2.0/keys`); }); // --- JWKS --- @@ -168,6 +187,14 @@ describe("Microsoft plugin integration", () => { expect(key.alg).toBe("RS256"); }); + it("GET /:tenant/discovery/v2.0/keys returns JWKS alias", async () => { + const res = await app.request(`${base}/common/discovery/v2.0/keys`); + expect(res.status).toBe(200); + const body = await res.json() as { keys: Array> }; + expect(body.keys).toHaveLength(1); + expect(body.keys[0]?.kid).toBe("emulate-microsoft-1"); + }); + // --- Authorization page --- it("GET /oauth2/v2.0/authorize returns an HTML sign-in page", async () => { @@ -181,6 +208,14 @@ describe("Microsoft plugin integration", () => { expect(html).toMatch(/Microsoft/i); }); + it("GET /:tenant/oauth2/v2.0/authorize posts back to tenant callback", async () => { + const url = `${base}/common/oauth2/v2.0/authorize?client_id=test-client&redirect_uri=${encodeURIComponent("http://localhost:3000/callback")}&response_type=code&scope=openid%20email%20profile`; + const res = await app.request(url); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain('action="/common/oauth2/v2.0/authorize/callback"'); + }); + it("returns error for unknown client_id when clients are configured", async () => { const url = `${base}/oauth2/v2.0/authorize?client_id=unknown-client&redirect_uri=${encodeURIComponent("http://localhost:3000/callback")}`; const res = await app.request(url); @@ -245,6 +280,21 @@ describe("Microsoft plugin integration", () => { expect(claims.nonce).toBe("test-nonce"); }); + it("completes full OAuth authorization_code flow through tenant-scoped URLs", async () => { + const tenant = "common"; + const { code, state } = await getAuthCode(app, { tenant }); + expect(code).toBeTruthy(); + expect(state).toBe("test-state"); + + const tokenRes = await exchangeCode(app, code, { tenant }); + expect(tokenRes.status).toBe(200); + const tokenBody = await tokenRes.json() as Record; + expect((tokenBody.access_token as string).startsWith("microsoft_")).toBe(true); + + const claims = decodeJwt(tokenBody.id_token as string); + expect(claims.iss).toBe(`${base}/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0`); + }); + // --- Refresh token flow --- it("exchanges refresh_token for new access_token with rotated refresh_token", async () => { @@ -372,6 +422,13 @@ describe("Microsoft plugin integration", () => { expect(res.headers.get("location")).toBe(redirectUri); }); + it("GET /:tenant/oauth2/v2.0/logout redirects when post_logout_redirect_uri is registered", async () => { + const redirectUri = "http://localhost:3000/callback"; + const res = await app.request(`${base}/common/oauth2/v2.0/logout?post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(redirectUri); + }); + it("GET /oauth2/v2.0/logout rejects unregistered post_logout_redirect_uri", async () => { const redirectUri = "http://evil.example.com/phishing"; const res = await app.request( @@ -405,6 +462,20 @@ describe("Microsoft plugin integration", () => { expect(res.status).toBe(200); }); + it("POST /:tenant/oauth2/v2.0/revoke returns 200", async () => { + const formData = new URLSearchParams({ + token: "some-token", + }); + + const res = await app.request(`${base}/common/oauth2/v2.0/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formData.toString(), + }); + + expect(res.status).toBe(200); + }); + // --- Client secret validation --- it("rejects incorrect client_secret", async () => { diff --git a/packages/@emulators/microsoft/src/routes/oauth.ts b/packages/@emulators/microsoft/src/routes/oauth.ts index 5aeadc12..942f53b3 100644 --- a/packages/@emulators/microsoft/src/routes/oauth.ts +++ b/packages/@emulators/microsoft/src/routes/oauth.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "crypto"; import { SignJWT, exportJWK, generateKeyPair } from "jose"; -import type { RouteContext } from "@emulators/core"; +import type { Context } from "hono"; +import type { AppEnv, RouteContext } from "@emulators/core"; import { escapeHtml, escapeAttr, @@ -65,6 +66,22 @@ function isPendingCodeExpired(p: PendingCode): boolean { } const SERVICE_LABEL = "Microsoft"; +type TenantSegment = string | null; + +function normalizeTenantId(tenant: TenantSegment): string { + if (!tenant || tenant === "common" || tenant === "organizations" || tenant === "consumers") { + return DEFAULT_TENANT_ID; + } + return tenant; +} + +function buildTenantPath(tenant: TenantSegment, path: string): string { + return tenant ? `/${tenant}${path}` : path; +} + +function buildTenantUrl(baseUrl: string, tenant: TenantSegment, path: string): string { + return `${baseUrl}${buildTenantPath(tenant, path)}`; +} async function createIdToken( user: MicrosoftUser, @@ -103,13 +120,13 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo // Microsoft uses /{tenant}/v2.0/.well-known/openid-configuration // We also serve at /.well-known/openid-configuration for convenience. - const oidcConfig = (tenantId: string) => ({ - issuer: `${baseUrl}/${tenantId}/v2.0`, - authorization_endpoint: `${baseUrl}/oauth2/v2.0/authorize`, - token_endpoint: `${baseUrl}/oauth2/v2.0/token`, + const oidcConfig = (tenantId: TenantSegment) => ({ + issuer: `${baseUrl}/${normalizeTenantId(tenantId)}/v2.0`, + authorization_endpoint: buildTenantUrl(baseUrl, tenantId, "/oauth2/v2.0/authorize"), + token_endpoint: buildTenantUrl(baseUrl, tenantId, "/oauth2/v2.0/token"), userinfo_endpoint: `${baseUrl}/oidc/userinfo`, - end_session_endpoint: `${baseUrl}/oauth2/v2.0/logout`, - jwks_uri: `${baseUrl}/discovery/v2.0/keys`, + end_session_endpoint: buildTenantUrl(baseUrl, tenantId, "/oauth2/v2.0/logout"), + jwks_uri: buildTenantUrl(baseUrl, tenantId, "/discovery/v2.0/keys"), response_types_supported: ["code"], response_modes_supported: ["query", "fragment", "form_post"], subject_types_supported: ["pairwise"], @@ -141,17 +158,12 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }); app.get("/:tenant/v2.0/.well-known/openid-configuration", (c) => { - const tenant = c.req.param("tenant"); - return c.json( - oidcConfig( - tenant === "common" || tenant === "organizations" || tenant === "consumers" ? DEFAULT_TENANT_ID : tenant, - ), - ); + return c.json(oidcConfig(normalizeTenantId(c.req.param("tenant")))); }); // ---------- JWKS ---------- - app.get("/discovery/v2.0/keys", async (c) => { + const handleJwks = async (c: Context) => { const { publicKey } = await keyPairPromise; const jwk = await exportJWK(publicKey); return c.json({ @@ -164,11 +176,14 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }, ], }); - }); + }; + + app.get("/discovery/v2.0/keys", handleJwks); + app.get("/:tenant/discovery/v2.0/keys", handleJwks); // ---------- Authorization page ---------- - app.get("/oauth2/v2.0/authorize", (c) => { + const handleAuthorize = (c: Context, tenant: TenantSegment) => { const client_id = c.req.query("client_id") ?? ""; const redirect_uri = c.req.query("redirect_uri") ?? ""; const scope = c.req.query("scope") ?? ""; @@ -213,7 +228,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo login: user.email, name: user.name, email: user.email, - formAction: "/oauth2/v2.0/authorize/callback", + formAction: buildTenantPath(tenant, "/oauth2/v2.0/authorize/callback"), hiddenFields: { email: user.email, redirect_uri, @@ -232,11 +247,14 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo const body = users.length === 0 ? '

No users in the emulator store.

' : userButtons; return c.html(renderCardPage("Sign in with Microsoft", subtitleText, body, SERVICE_LABEL)); - }); + }; + + app.get("/oauth2/v2.0/authorize", (c) => handleAuthorize(c, null)); + app.get("/:tenant/oauth2/v2.0/authorize", (c) => handleAuthorize(c, c.req.param("tenant"))); // ---------- Authorization callback ---------- - app.post("/oauth2/v2.0/authorize/callback", async (c) => { + const handleAuthorizeCallback = async (c: Context) => { const body = await c.req.parseBody(); const email = bodyStr(body.email); const redirect_uri = bodyStr(body.redirect_uri); @@ -295,11 +313,14 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo if (state) url.searchParams.set("state", state); return c.redirect(url.toString(), 302); - }); + }; + + app.post("/oauth2/v2.0/authorize/callback", handleAuthorizeCallback); + app.post("/:tenant/oauth2/v2.0/authorize/callback", handleAuthorizeCallback); // ---------- Token exchange ---------- - app.post("/oauth2/v2.0/token", async (c) => { + const handleToken = async (c: Context) => { const contentType = c.req.header("Content-Type") ?? ""; const rawText = await c.req.text(); @@ -500,14 +521,11 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo }); } - return c.json( - { - error: "unsupported_grant_type", - error_description: "Only authorization_code, refresh_token, and client_credentials are supported.", - }, - 400, - ); - }); + return c.json({ error: "unsupported_grant_type", error_description: "Only authorization_code, refresh_token, and client_credentials are supported." }, 400); + }; + + app.post("/oauth2/v2.0/token", handleToken); + app.post("/:tenant/oauth2/v2.0/token", handleToken); // ---------- UserInfo (Microsoft Graph /oidc/userinfo) ---------- @@ -639,7 +657,7 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo // ---------- Logout ---------- - app.get("/oauth2/v2.0/logout", (c) => { + const handleLogout = (c: Context) => { const post_logout_redirect_uri = c.req.query("post_logout_redirect_uri"); if (post_logout_redirect_uri) { // Validate against registered client redirect URIs @@ -653,11 +671,14 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo return c.redirect(post_logout_redirect_uri, 302); } return c.text("Logged out", 200); - }); + }; + + app.get("/oauth2/v2.0/logout", handleLogout); + app.get("/:tenant/oauth2/v2.0/logout", handleLogout); // ---------- Token revocation ---------- - app.post("/oauth2/v2.0/revoke", async (c) => { + const handleRevoke = async (c: Context) => { const contentType = c.req.header("Content-Type") ?? ""; const rawText = await c.req.text(); @@ -684,5 +705,8 @@ export function oauthRoutes({ app, store, baseUrl, tokenMap }: RouteContext): vo } return c.body(null, 200); - }); + }; + + app.post("/oauth2/v2.0/revoke", handleRevoke); + app.post("/:tenant/oauth2/v2.0/revoke", handleRevoke); } diff --git a/skills/microsoft/SKILL.md b/skills/microsoft/SKILL.md index 1eb6d148..2a89f602 100644 --- a/skills/microsoft/SKILL.md +++ b/skills/microsoft/SKILL.md @@ -41,12 +41,14 @@ MICROSOFT_EMULATOR_URL=http://localhost:4005 |--------------------|-------------| | `https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration` | `$MICROSOFT_EMULATOR_URL/{tenant}/v2.0/.well-known/openid-configuration` | | `https://login.microsoftonline.com/.well-known/openid-configuration` | `$MICROSOFT_EMULATOR_URL/.well-known/openid-configuration` | -| `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` | `$MICROSOFT_EMULATOR_URL/oauth2/v2.0/authorize` | -| `https://login.microsoftonline.com/common/oauth2/v2.0/token` | `$MICROSOFT_EMULATOR_URL/oauth2/v2.0/token` | -| `https://login.microsoftonline.com/common/discovery/v2.0/keys` | `$MICROSOFT_EMULATOR_URL/discovery/v2.0/keys` | +| `https://login.microsoftonline.com/common/oauth2/v2.0/authorize` | `$MICROSOFT_EMULATOR_URL/common/oauth2/v2.0/authorize` | +| `https://login.microsoftonline.com/common/oauth2/v2.0/token` | `$MICROSOFT_EMULATOR_URL/common/oauth2/v2.0/token` | +| `https://login.microsoftonline.com/common/discovery/v2.0/keys` | `$MICROSOFT_EMULATOR_URL/common/discovery/v2.0/keys` | | `https://graph.microsoft.com/oidc/userinfo` | `$MICROSOFT_EMULATOR_URL/oidc/userinfo` | | `https://graph.microsoft.com/v1.0/me` | `$MICROSOFT_EMULATOR_URL/v1.0/me` | +The emulator also accepts the root-scoped aliases `/oauth2/v2.0/authorize`, `/oauth2/v2.0/token`, `/discovery/v2.0/keys`, `/oauth2/v2.0/logout`, and `/oauth2/v2.0/revoke` for compatibility. OIDC discovery now returns tenant-scoped URLs to match Microsoft. + ### Auth.js / NextAuth.js ```typescript @@ -56,16 +58,16 @@ MicrosoftEntraId({ clientId: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET, authorization: { - url: `${process.env.MICROSOFT_EMULATOR_URL}/oauth2/v2.0/authorize`, + url: `${process.env.MICROSOFT_EMULATOR_URL}/common/oauth2/v2.0/authorize`, params: { scope: 'openid email profile User.Read' }, }, token: { - url: `${process.env.MICROSOFT_EMULATOR_URL}/oauth2/v2.0/token`, + url: `${process.env.MICROSOFT_EMULATOR_URL}/common/oauth2/v2.0/token`, }, userinfo: { url: `${process.env.MICROSOFT_EMULATOR_URL}/oidc/userinfo`, }, - issuer: process.env.MICROSOFT_EMULATOR_URL, + issuer: `${process.env.MICROSOFT_EMULATOR_URL}/common/v2.0`, }) ``` @@ -77,7 +79,7 @@ import { OIDCStrategy } from 'passport-azure-ad' const MICROSOFT_URL = process.env.MICROSOFT_EMULATOR_URL ?? 'https://login.microsoftonline.com' new OIDCStrategy({ - identityMetadata: `${MICROSOFT_URL}/.well-known/openid-configuration`, + identityMetadata: `${MICROSOFT_URL}/common/v2.0/.well-known/openid-configuration`, clientID: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET, redirectUrl: 'http://localhost:3000/api/auth/callback/microsoft-entra-id', @@ -96,8 +98,8 @@ const msalConfig = { auth: { clientId: process.env.MICROSOFT_CLIENT_ID, clientSecret: process.env.MICROSOFT_CLIENT_SECRET, - authority: process.env.MICROSOFT_EMULATOR_URL, - knownAuthorities: [process.env.MICROSOFT_EMULATOR_URL], + authority: `${process.env.MICROSOFT_EMULATOR_URL}/common`, + knownAuthorities: [new URL(process.env.MICROSOFT_EMULATOR_URL!).host], }, } @@ -142,11 +144,11 @@ Returns the standard OIDC discovery document: ```json { "issuer": "http://localhost:4005/{tenant}/v2.0", - "authorization_endpoint": "http://localhost:4005/oauth2/v2.0/authorize", - "token_endpoint": "http://localhost:4005/oauth2/v2.0/token", + "authorization_endpoint": "http://localhost:4005/{tenant}/oauth2/v2.0/authorize", + "token_endpoint": "http://localhost:4005/{tenant}/oauth2/v2.0/token", "userinfo_endpoint": "http://localhost:4005/oidc/userinfo", - "end_session_endpoint": "http://localhost:4005/oauth2/v2.0/logout", - "jwks_uri": "http://localhost:4005/discovery/v2.0/keys", + "end_session_endpoint": "http://localhost:4005/{tenant}/oauth2/v2.0/logout", + "jwks_uri": "http://localhost:4005/{tenant}/discovery/v2.0/keys", "response_types_supported": ["code"], "subject_types_supported": ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], @@ -158,7 +160,7 @@ Returns the standard OIDC discovery document: ### JWKS ```bash -curl http://localhost:4005/discovery/v2.0/keys +curl http://localhost:4005/common/discovery/v2.0/keys ``` Returns an RSA public key (`kid`: `emulate-microsoft-1`) for verifying `id_token` signatures. @@ -167,7 +169,7 @@ Returns an RSA public key (`kid`: `emulate-microsoft-1`) for verifying `id_token ```bash # Browser flow: redirects to a user picker page -curl -v "http://localhost:4005/oauth2/v2.0/authorize?\ +curl -v "http://localhost:4005/common/oauth2/v2.0/authorize?\ client_id=example-client-id&\ redirect_uri=http://localhost:3000/api/auth/callback/microsoft-entra-id&\ scope=openid+email+profile&\ @@ -192,7 +194,7 @@ Query parameters: ### Token Exchange ```bash -curl -X POST http://localhost:4005/oauth2/v2.0/token \ +curl -X POST http://localhost:4005/common/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "code=&\ client_id=example-client-id&\ @@ -223,7 +225,7 @@ Supports `Authorization: Basic` header with base64-encoded `client_id:client_sec ### Client Credentials ```bash -curl -X POST http://localhost:4005/oauth2/v2.0/token \ +curl -X POST http://localhost:4005/common/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=example-client-id&\ client_secret=example-client-secret&\ @@ -236,7 +238,7 @@ Returns an `access_token` only (no `refresh_token` or `id_token`). ### Refresh Token ```bash -curl -X POST http://localhost:4005/oauth2/v2.0/token \ +curl -X POST http://localhost:4005/common/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "refresh_token=r_microsoft_...&\ client_id=example-client-id&\ @@ -287,7 +289,7 @@ Returns an OData-style response: ### Logout ```bash -curl "http://localhost:4005/oauth2/v2.0/logout?post_logout_redirect_uri=http://localhost:3000" +curl "http://localhost:4005/common/oauth2/v2.0/logout?post_logout_redirect_uri=http://localhost:3000" ``` Redirects to the `post_logout_redirect_uri` if provided and valid. @@ -295,7 +297,7 @@ Redirects to the `post_logout_redirect_uri` if provided and valid. ### Token Revocation ```bash -curl -X POST http://localhost:4005/oauth2/v2.0/revoke \ +curl -X POST http://localhost:4005/common/oauth2/v2.0/revoke \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "token=microsoft_..." ``` @@ -308,18 +310,19 @@ Returns `200 OK`. The token is removed from the emulator's token map. ```bash MICROSOFT_URL="http://localhost:4005" +TENANT="common" CLIENT_ID="example-client-id" CLIENT_SECRET="example-client-secret" REDIRECT_URI="http://localhost:3000/api/auth/callback/microsoft-entra-id" # 1. Open in browser (user picks a seeded account) -# $MICROSOFT_URL/oauth2/v2.0/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=openid+email+profile&response_type=code&state=abc +# $MICROSOFT_URL/$TENANT/oauth2/v2.0/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=openid+email+profile&response_type=code&state=abc # 2. After user selection, emulator redirects to: # $REDIRECT_URI?code=&state=abc # 3. Exchange code for tokens -curl -X POST $MICROSOFT_URL/oauth2/v2.0/token \ +curl -X POST $MICROSOFT_URL/$TENANT/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "code=&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&redirect_uri=$REDIRECT_URI&grant_type=authorization_code" @@ -335,10 +338,10 @@ CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43) CODE_CHALLENGE=$(echo -n $CODE_VERIFIER | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_') # 1. Authorize with challenge -# $MICROSOFT_URL/oauth2/v2.0/authorize?...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256 +# $MICROSOFT_URL/$TENANT/oauth2/v2.0/authorize?...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256 # 2. Token exchange with verifier -curl -X POST $MICROSOFT_URL/oauth2/v2.0/token \ +curl -X POST $MICROSOFT_URL/$TENANT/oauth2/v2.0/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "code=&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&redirect_uri=$REDIRECT_URI&grant_type=authorization_code&code_verifier=$CODE_VERIFIER" ```