Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 9 additions & 7 deletions apps/web/app/microsoft/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions packages/@emulators/microsoft/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
75 changes: 73 additions & 2 deletions packages/@emulators/microsoft/src/__tests__/microsoft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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(),
Expand Down Expand Up @@ -93,8 +95,10 @@ async function exchangeCode(
client_id?: string;
client_secret?: string;
redirect_uri?: string;
tenant?: string;
} = {},
): Promise<Response> {
const tenantPath = options.tenant ? `/${options.tenant}` : "";
const formData = new URLSearchParams({
grant_type: "authorization_code",
code,
Expand All @@ -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(),
Expand Down Expand Up @@ -152,6 +156,21 @@ describe("Microsoft plugin integration", () => {
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
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<string, unknown>;
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 ---
Expand All @@ -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<Record<string, unknown>> };
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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, unknown>;
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 () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading