php-sdk/examples/server/oauth-microsoft
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Volodymyr Panivko 293a5880e4
[Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307)
* [Server] Add CORS, DNS rebinding and protocol version middleware

Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed
through a public `StreamableHttpTransport::defaultMiddleware()` factory
composed automatically when no middleware is passed.

- `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`),
  configurable allowlist, reflects matching origin with `Vary: Origin`
  to protect shared caches.
- `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against
  a hostname allowlist (localhost-only by default).
- `ProtocolVersionMiddleware`: rejects requests carrying an unsupported
  `Mcp-Protocol-Version` header with `400 Bad Request`.

The transport no longer applies CORS via an `instanceof + array_unshift`
post-hook; the middleware parameter is nullable — `null` installs the
secure defaults, `[]` disables them, and users compose by spreading
`StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and
`PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware
can reuse them.

BC breaks:
- The `corsHeaders` constructor parameter is removed; the `middleware`
  parameter shifts one position. Positional callers passing the old
  `corsHeaders` argument must switch to named arguments or drop it.
- Default `Access-Control-Allow-Origin` is no longer `*`.

Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306
(protocol version validation).

* [Server] Address review feedback on middleware defaults

- CorsMiddleware: emit Access-Control-Allow-Methods/Headers only on
  preflight per CORS spec; tokenize Vary header to avoid substring
  false-positives; add allowCredentials flag that emits
  Access-Control-Allow-Credentials and throws when combined with
  wildcard origin.
- DnsRebindingProtectionMiddleware: return plain-text 403 body
  (HTTP-level rejection, not JSON-RPC); use parse_url(..., PHP_URL_HOST)
  for origin parsing; drop redundant unbracketed '::1' from default
  allowlist.
- ProtocolVersionMiddleware: use Error::forInvalidParams instead of
  forInvalidRequest -- JSON-RPC -32602 fits header-value rejection
  better.
- JsonRpcErrorResponse::create now accepts a JsonRpc\Error so callers
  choose the error code.
- StreamableHttpTransport: emit a warning log when an explicit empty
  middleware list is passed, so operators can spot accidental bypass
  of the default security stack.

* [Server] Fix CI: switch warning-log tests to PHPUnit mocks

Replace the anonymous AbstractLogger fixture in
StreamableHttpTransportTest with `$this->createMock(LoggerInterface::class)`
so the test does not declare a `log()` signature at all. That sidesteps
both the psr/log v1 LSP incompatibility (untyped parameter) and the
phpstan `missingType.parameter` complaint in one move, and matches how
the rest of the suite (CallToolHandlerTest etc.) fakes loggers.

* Update src/Server/Transport/Http/Middleware/CorsMiddleware.php

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>

---------

Co-authored-by: Antoine Bluchet <soyuka@users.noreply.github.com>
2026-05-25 13:49:43 +02:00
..
nginx [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
tests/Unit [Server] feat: relax StrictOidcDiscoveryMetadataPolicy and add Dynamic Client Registration middleware (RFC 7591) (#269) 2026-04-11 17:27:27 +02:00
.env.dist [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
docker-compose.yml [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
Dockerfile [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
env.example [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
McpElements.php CS update after release of v3.95 (#282) 2026-04-26 12:39:27 +02:00
MicrosoftJwtTokenValidator.php [Server] Oauth2 based on middleware (#221) 2026-03-10 17:10:07 +01:00
README.md [Server] feat: relax StrictOidcDiscoveryMetadataPolicy and add Dynamic Client Registration middleware (RFC 7591) (#269) 2026-04-11 17:27:27 +02:00
server.php [Server] Compose StreamableHttpTransport security middleware via defaultMiddleware() factory (#307) 2026-05-25 13:49:43 +02:00

OAuth Microsoft Entra ID Example

This example demonstrates MCP server authorization using Microsoft Entra ID (formerly Azure AD) as the OAuth 2.0 / OpenID Connect provider.

Features

  • JWT token validation with Microsoft Entra ID
  • Microsoft-specific validator/discovery overrides for Entra quirks
  • Protected Resource Metadata (RFC 9728)
  • MCP tools that access Microsoft claims
  • Optional Microsoft Graph API integration

Prerequisites

  1. Azure Subscription with access to Entra ID
  2. App Registration in Azure Portal

Azure Setup

1. Create App Registration

  1. Go to Azure Portal > Entra ID > App registrations
  2. Click New registration
  3. Configure:
    • Name: MCP Server
    • Supported account types: Choose based on your needs
    • Redirect URI: Leave empty for now (this is a resource server)
  4. Click Register

2. Configure the App

After registration:

  1. Copy values for .env:

    • Application (client) ID → AZURE_CLIENT_ID
    • Directory (tenant) ID → AZURE_TENANT_ID
  2. Expose an API (optional, for custom scopes):

    • Go to Expose an API
    • Set Application ID URI (e.g., api://your-client-id)
    • Add scopes like mcp.read, mcp.write
  3. Create client secret (for Graph API calls):

    • Go to Certificates & secrets
    • Click New client secret
    • Copy the secret value → AZURE_CLIENT_SECRET
  4. API Permissions (for Graph API):

    • Go to API permissions
    • Add Microsoft Graph > Delegated permissions:
      • User.Read (for profile)
      • Mail.Read (for emails, optional)
    • Grant admin consent if required

3. Create a Client App (for testing)

Create a separate app registration for the client:

  1. New registration:

    • Name: MCP Client
    • Redirect URI: http://localhost (Public client/native)
  2. Authentication:

    • Enable Allow public client flows for PKCE
  3. API permissions:

    • Add permission to your MCP Server app's exposed API

Quick Start

  1. Copy environment file:
cp env.example .env
  1. Edit .env with your Azure values:
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret  # Optional, for Graph API
  1. Start the services:
docker compose up -d
  1. Get an access token:

Using Azure CLI:

# Login
az login

# Get token for your app
TOKEN=$(az account get-access-token \
  --resource api://your-client-id \
  --query accessToken -o tsv)

Or using MSAL / OAuth flow in your client application.

  1. Test the MCP server:
# Get Protected Resource Metadata
curl http://localhost:8000/.well-known/oauth-protected-resource

# Call MCP endpoint without token (should get 401)
curl -i http://localhost:8000/mcp

# Call MCP endpoint with token
curl -X POST http://localhost:8000/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

Architecture

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   MCP Client    │────▶│     Nginx       │────▶│    PHP-FPM      │
│                 │     │   (port 8000)   │     │   MCP Server    │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                                               │
        │ Get Token                                     │ Validate JWT
        ▼                                               ▼
┌─────────────────┐                            ┌─────────────────┐
│  Microsoft      │◀───────────────────────────│   JWKS Fetch    │
│  Entra ID       │                            │                 │
└─────────────────┘                            └─────────────────┘
        │
        │ (Optional) Graph API
        ▼
┌─────────────────┐
│   Microsoft     │
│   Graph API     │
└─────────────────┘

Files

  • docker-compose.yml - Docker Compose configuration
  • Dockerfile - PHP-FPM container
  • nginx/default.conf - Nginx configuration
  • env.example - Environment variables template
  • server.php - MCP server with OAuth middleware (uses built-in LenientOidcDiscoveryMetadataPolicy for metadata validation)
  • MicrosoftJwtTokenValidator.php - Example-specific validator for Graph/non-Graph tokens
  • McpElements.php - MCP tools including Graph API integration

Environment Variables

Variable Required Description
AZURE_TENANT_ID Yes Azure AD tenant ID
AZURE_CLIENT_ID Yes Application (client) ID
AZURE_CLIENT_SECRET No Client secret for Graph API calls

Microsoft Token Structure

Microsoft Entra ID tokens include these common claims:

Claim Description
oid Object ID (unique user identifier in tenant)
tid Tenant ID
sub Subject (unique user identifier)
name Display name
preferred_username Usually the UPN
email Email address (if available)
upn User Principal Name

Troubleshooting

"Invalid issuer" error

Microsoft uses different issuer URLs depending on the token flow:

  • v2.0 endpoint (user/delegated flows): https://login.microsoftonline.com/{tenant}/v2.0
  • v1.0 endpoint (client credentials flow): https://sts.windows.net/{tenant}/

This example automatically accepts both formats by configuring multiple issuers in the MicrosoftJwtTokenValidator. Check your token's iss claim to verify which format is being used.

"Invalid audience" error

The aud claim must match AZURE_CLIENT_ID. For v2.0 tokens with custom scopes, the audience might be api://your-client-id.

JWKS fetch fails

Microsoft's JWKS endpoint is public. Ensure your container can reach: https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys

code_challenge_methods_supported missing in discovery metadata

The default StrictOidcDiscoveryMetadataPolicy requires code_challenge_methods_supported. Microsoft Entra ID omits this field despite supporting PKCE with S256. This example uses the built-in LenientOidcDiscoveryMetadataPolicy which accepts missing code_challenge_methods_supported (defaults to S256 downstream).

Graph API errors

  1. Ensure AZURE_CLIENT_SECRET is set
  2. Verify API permissions have admin consent
  3. Check that the user exists in your tenant

Security Notes

  1. Never commit .env files - they contain secrets
  2. Use managed identities in Azure deployments instead of client secrets
  3. Implement proper token refresh in production clients
  4. Validate scopes for sensitive operations
  5. Important: MicrosoftJwtTokenValidator in this example accepts nonce Graph-style tokens via claim checks only (iss/exp/nbf) without signature verification. Treat this as demo-only behavior and replace it with full signature validation for production.

Cleanup

docker compose down -v