diff --git a/composer.json b/composer.json index c94820f3..89ccf06c 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-client": "^1.0", "psr/http-factory": "^1.1", "psr/http-message": "^1.1 || ^2.0", "psr/http-server-handler": "^1.0", @@ -35,6 +36,8 @@ "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "require-dev": { + "ext-openssl": "*", + "firebase/php-jwt": "^6.10 || ^7.0", "laminas/laminas-httphandlerrunner": "^2.12", "nyholm/psr7": "^1.8", "nyholm/psr7-server": "^1.1", @@ -46,6 +49,7 @@ "psr/simple-cache": "^2.0 || ^3.0", "symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/http-client": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "autoload": { @@ -67,6 +71,8 @@ "Mcp\\Example\\Server\\DiscoveryUserProfile\\": "examples/server/discovery-userprofile/", "Mcp\\Example\\Server\\EnvVariables\\": "examples/server/env-variables/", "Mcp\\Example\\Server\\ExplicitRegistration\\": "examples/server/explicit-registration/", + "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", + "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", "Mcp\\Tests\\": "tests/" } diff --git a/examples/server/oauth-keycloak/Dockerfile b/examples/server/oauth-keycloak/Dockerfile new file mode 100644 index 00000000..f877c73a --- /dev/null +++ b/examples/server/oauth-keycloak/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.1-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-keycloak/McpElements.php b/examples/server/oauth-keycloak/McpElements.php new file mode 100644 index 00000000..6825f198 --- /dev/null +++ b/examples/server/oauth-keycloak/McpElements.php @@ -0,0 +1,134 @@ + + */ + #[McpTool( + name: 'get_auth_status', + description: 'Confirm authentication status - only accessible with valid OAuth token' + )] + public function getAuthStatus(RequestContext $context): array + { + $meta = $context->getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + + return [ + 'authenticated' => true, + 'provider' => 'Keycloak', + 'message' => 'You have successfully authenticated with OAuth!', + 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'username' => $claims['preferred_username'] ?? null, + 'name' => $claims['name'] ?? null, + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], + 'note' => 'This endpoint is protected by JWT validation. If you see this, your token was valid.', + ]; + } + + /** + * Simulates calling a protected external API. + * + * @return array + */ + #[McpTool( + name: 'call_protected_api', + description: 'Simulate calling a protected external API endpoint' + )] + public function callProtectedApi( + string $endpoint, + string $method = 'GET', + ): array { + // In a real implementation, you would: + // 1. Use token exchange to get a token for the downstream API + // 2. Or use client credentials with the user's context + // 3. Make the actual HTTP call to the protected API + + return [ + 'status' => 'success', + 'message' => \sprintf('Simulated %s request to %s', $method, $endpoint), + 'simulated_response' => [ + 'data' => 'This is simulated data from the protected API', + 'timestamp' => date('c'), + ], + ]; + } + + /** + * Returns the current server time and status. + * + * @return array + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status (protected resource)', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'php_version' => \PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + 'protected' => true, + ]; + } + + /** + * A greeting prompt. + */ + #[McpPrompt( + name: 'greeting', + description: 'Generate a greeting message' + )] + public function greeting(string $style = 'formal'): string + { + return match ($style) { + 'casual' => 'Hey there! Welcome to the protected MCP server!', + 'formal' => 'Good day. Welcome to the OAuth-protected MCP server.', + 'friendly' => 'Hello! Great to have you here!', + default => 'Welcome to the MCP server!', + }; + } +} diff --git a/examples/server/oauth-keycloak/README.md b/examples/server/oauth-keycloak/README.md new file mode 100644 index 00000000..d1929318 --- /dev/null +++ b/examples/server/oauth-keycloak/README.md @@ -0,0 +1,135 @@ +# OAuth Keycloak Example + +This example demonstrates MCP server authorization using Keycloak as the OAuth 2.0 / OpenID Connect provider. + +## Features + +- JWT token validation with automatic JWKS discovery +- Protected Resource Metadata (RFC 9728) at `/.well-known/oauth-protected-resource` +- MCP tools protected by OAuth authentication +- Pre-configured Keycloak realm with test user + +## Quick Start + +1. **Start the services:** + +```bash +docker compose up -d +``` + +2. **Wait for Keycloak to be ready** (may take 30-60 seconds): + +```bash +docker compose logs -f keycloak +# Wait until you see "Running the server in development mode" +``` + +3. **Get an access token:** + +```bash +# Using Resource Owner Password Credentials (for testing only) +TOKEN=$(curl -s -X POST "http://localhost:8180/realms/mcp/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "client_id=mcp-client" \ + -d "username=demo" \ + -d "password=demo123" \ + -d "grant_type=password" \ + -d "scope=openid mcp" | jq -r '.access_token') + +echo $TOKEN +``` + +4. **Test the MCP server:** + +```bash +# 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"}}}' +``` + +5. **Use with MCP Inspector:** + +MCP Inspector can call this server if you provide a valid Bearer token manually (Authorization header). It does not run the OAuth login flow automatically. + +## Keycloak Configuration + +The realm is pre-configured with: + +| Item | Value | +|------|-------| +| Realm | `mcp` | +| Client (public) | `mcp-client` | +| Client (resource) | `mcp-server` | +| Test User | `demo` / `demo123` | +| Scopes | `mcp:read`, `mcp:write` | + +### Keycloak Admin Console + +Access at http://localhost:8180/admin with: +- Username: `admin` +- Password: `admin` + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ MCP Client │────▶│ Nginx │────▶│ PHP-FPM │ +│ │ │ (port 8000) │ │ MCP Server │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ Get Token │ Validate JWT + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ Keycloak │◀───────────────────────────│ JWKS Fetch │ +│ (port 8180) │ │ │ +└─────────────────┘ └─────────────────┘ +``` + +## Files + +- `docker-compose.yml` - Docker Compose configuration +- `Dockerfile` - PHP-FPM container with dependencies +- `nginx/default.conf` - Nginx configuration for MCP endpoint +- `keycloak/mcp-realm.json` - Pre-configured Keycloak realm +- `server.php` - MCP server with OAuth middleware +- `McpElements.php` - MCP tools and resources + +## Configuration + +This example uses hard-coded values in `server.php` for consistency with other examples: +- Keycloak external URL: `http://localhost:8180` +- Keycloak internal URL: `http://keycloak:8080` +- Realm: `mcp` +- Audience: `mcp-server` + +## Troubleshooting + +### Token validation fails + +1. Ensure Keycloak is fully started (check health endpoint) +2. Verify the token hasn't expired (default: 5 minutes) +3. Check that the audience claim matches `mcp-server` + +### Connection refused + +1. Wait for Keycloak health check to pass +2. Check Docker network connectivity: `docker compose logs` + +### JWKS fetch fails + +The MCP server needs to reach Keycloak at `http://keycloak:8080` (Docker network). +For local development outside Docker, use `http://localhost:8180`. + +## Cleanup + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-keycloak/docker-compose.yml b/examples/server/oauth-keycloak/docker-compose.yml new file mode 100644 index 00000000..6d188040 --- /dev/null +++ b/examples/server/oauth-keycloak/docker-compose.yml @@ -0,0 +1,68 @@ +services: + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: mcp-keycloak + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ./keycloak/mcp-realm.json:/opt/keycloak/data/import/mcp-realm.json:ro + command: + - start-dev + - --import-realm + ports: + - "8180:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8080;echo -e 'GET /health/ready HTTP/1.1\r\nhost: localhost\r\nConnection: close\r\n\r\n' >&3;if [ $? -eq 0 ]; then echo 'Healthcheck Successful';exit 0;else echo 'Healthcheck Failed';exit 1;fi;"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 30s + networks: + - mcp-network + + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php + volumes: + - ../../../:/app + working_dir: /app + environment: + KEYCLOAK_EXTERNAL_URL: http://localhost:8180 + KEYCLOAK_INTERNAL_URL: http://keycloak:8080 + KEYCLOAK_REALM: mcp + MCP_AUDIENCE: mcp-server + depends_on: + keycloak: + condition: service_healthy + command: > + sh -c "mkdir -p /app/examples/server/oauth-keycloak/sessions; + chmod -R 0777 /app/examples/server/oauth-keycloak/sessions; + touch /app/examples/server/oauth-keycloak/dev.log; + chmod 0666 /app/examples/server/oauth-keycloak/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx + ports: + - "8000:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-keycloak/keycloak/mcp-realm.json b/examples/server/oauth-keycloak/keycloak/mcp-realm.json new file mode 100644 index 00000000..55d28751 --- /dev/null +++ b/examples/server/oauth-keycloak/keycloak/mcp-realm.json @@ -0,0 +1,128 @@ +{ + "realm": "mcp", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "accessTokenLifespan": 300, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "clients": [ + { + "clientId": "mcp-client", + "name": "MCP Client Application", + "description": "Public client for MCP client applications", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false, + "fullScopeAllowed": true, + "redirectUris": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "webOrigins": [ + "http://localhost:*", + "http://127.0.0.1:*" + ], + "defaultClientScopes": [ + "openid", + "profile", + "email", + "mcp" + ], + "optionalClientScopes": [], + "attributes": { + "pkce.code.challenge.method": "S256" + } + }, + { + "clientId": "mcp-server", + "name": "MCP Server Resource", + "description": "Resource server representing the MCP server", + "enabled": true, + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "authorizationServicesEnabled": false + } + ], + "clientScopes": [ + { + "name": "mcp", + "description": "MCP access scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "Access to MCP server resources" + }, + "protocolMappers": [ + { + "name": "mcp-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "mcp-scopes", + "protocol": "openid-connect", + "protocolMapper": "oidc-hardcoded-claim-mapper", + "consentRequired": false, + "config": { + "claim.name": "scope", + "claim.value": "mcp:read mcp:write", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false" + } + } + ] + } + ], + "users": [ + { + "username": "demo", + "email": "demo@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "Demo", + "lastName": "User", + "credentials": [ + { + "type": "password", + "value": "demo123", + "temporary": false + } + ], + "realmRoles": ["default-roles-mcp"] + } + ], + "defaultDefaultClientScopes": [ + "openid", + "profile", + "email" + ], + "roles": { + "realm": [ + { + "name": "default-roles-mcp", + "description": "Default roles for MCP realm", + "composite": false + } + ] + } +} diff --git a/examples/server/oauth-keycloak/nginx/default.conf b/examples/server/oauth-keycloak/nginx/default.conf new file mode 100644 index 00000000..f7a265ad --- /dev/null +++ b/examples/server/oauth-keycloak/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-keycloak; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-keycloak/server.php b/examples/server/oauth-keycloak/server.php new file mode 100644 index 00000000..b9e99425 --- /dev/null +++ b/examples/server/oauth-keycloak/server.php @@ -0,0 +1,100 @@ +createServerRequestFromGlobals(); +$discovery = new OidcDiscovery(); + +// Create JWT validator +// - issuer: accepts both external and internal issuer forms +// - jwksUri: where to fetch keys (internal URL) +$validator = new JwtTokenValidator( + issuer: [$externalIssuer, $internalIssuer], + audience: $mcpAudience, + jwksProvider: new JwksProvider(discovery: $discovery), + jwksUri: $jwksUri, +); + +// Create a shared Protected Resource Metadata object (RFC 9728). +// It is used both for the metadata endpoint and for WWW-Authenticate hints. +$protectedResourceMetadata = new ProtectedResourceMetadata( + authorizationServers: [$externalIssuer], + scopesSupported: ['openid'], + resource: 'http://localhost:8000/mcp', + resourceName: 'OAuth Keycloak Example MCP Server', +); + +// Create middleware serving Protected Resource Metadata (RFC 9728). +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $protectedResourceMetadata, +); + +// Create authorization middleware. +$authMiddleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $protectedResourceMetadata, +); +$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Keycloak Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with authorization middleware +$transport = new StreamableHttpTransport( + $request, + logger: logger(), + middleware: [$metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/.env.dist b/examples/server/oauth-microsoft/.env.dist new file mode 100644 index 00000000..de4376e9 --- /dev/null +++ b/examples/server/oauth-microsoft/.env.dist @@ -0,0 +1,3 @@ +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= \ No newline at end of file diff --git a/examples/server/oauth-microsoft/Dockerfile b/examples/server/oauth-microsoft/Dockerfile new file mode 100644 index 00000000..f877c73a --- /dev/null +++ b/examples/server/oauth-microsoft/Dockerfile @@ -0,0 +1,23 @@ +FROM php:8.1-fpm-alpine + +# Install dependencies +RUN apk add --no-cache \ + curl \ + git \ + unzip + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +# Set working directory +WORKDIR /app + +# Install PHP extensions +RUN docker-php-ext-install opcache + +# Configure PHP-FPM to listen on TCP +RUN sed -i 's/listen = .*/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf + +EXPOSE 9000 + +CMD ["php-fpm"] diff --git a/examples/server/oauth-microsoft/McpElements.php b/examples/server/oauth-microsoft/McpElements.php new file mode 100644 index 00000000..06eeaa03 --- /dev/null +++ b/examples/server/oauth-microsoft/McpElements.php @@ -0,0 +1,154 @@ + + */ + #[McpTool( + name: 'get_auth_status', + description: 'Confirm Microsoft Entra ID authentication status' + )] + public function getAuthStatus(RequestContext $context): array + { + $meta = $context->getRequest()->getMeta() ?? []; + $oauth = isset($meta['oauth']) && \is_array($meta['oauth']) ? $meta['oauth'] : []; + $claims = isset($oauth['oauth.claims']) && \is_array($oauth['oauth.claims']) ? $oauth['oauth.claims'] : []; + $scopes = isset($oauth['oauth.scopes']) && \is_array($oauth['oauth.scopes']) ? $oauth['oauth.scopes'] : []; + + return [ + 'authenticated' => true, + 'provider' => 'Microsoft Entra ID', + 'message' => 'You have successfully authenticated with Microsoft!', + 'timestamp' => date('c'), + 'user' => [ + 'subject' => $oauth['oauth.subject'] ?? ($claims['sub'] ?? null), + 'object_id' => $oauth['oauth.object_id'] ?? ($claims['oid'] ?? null), + 'username' => $claims['preferred_username'] ?? ($claims['upn'] ?? null), + 'name' => $oauth['oauth.name'] ?? ($claims['name'] ?? null), + 'email' => $claims['email'] ?? null, + 'issuer' => $claims['iss'] ?? null, + 'audience' => $claims['aud'] ?? null, + 'tenant_id' => $claims['tid'] ?? null, + 'scopes' => $scopes, + 'expires_at' => isset($claims['exp']) && is_numeric($claims['exp']) + ? date('c', (int) $claims['exp']) + : null, + ], + ]; + } + + /** + * Simulates calling Microsoft Graph API. + * + * @return array + */ + #[McpTool( + name: 'call_graph_api', + description: 'Simulate calling Microsoft Graph API' + )] + public function callGraphApi( + string $endpoint = '/me', + ): array { + // In a real implementation, you would: + // 1. Use the On-Behalf-Of flow to exchange tokens + // 2. Call Microsoft Graph with the new token + + return [ + 'status' => 'simulated', + 'endpoint' => "https://graph.microsoft.com/v1.0{$endpoint}", + 'message' => 'Configure AZURE_CLIENT_SECRET for actual Graph API calls', + 'simulated_response' => [ + 'displayName' => 'Demo User', + 'mail' => 'demo@example.com', + ], + ]; + } + + /** + * Lists simulated emails. + * + * @return array + */ + #[McpTool( + name: 'list_emails', + description: 'List recent emails (simulated)' + )] + public function listEmails(int $count = 5): array + { + return [ + 'note' => 'Simulated data. Implement Graph API call with Mail.Read scope for real emails.', + 'emails' => array_map(static fn ($i) => [ + 'id' => 'msg_'.uniqid(), + 'subject' => "Sample Email #{$i}", + 'from' => "sender{$i}@example.com", + 'receivedDateTime' => date('c', strtotime("-{$i} hours")), + ], range(1, $count)), + ]; + } + + /** + * Returns the current server status. + * + * @return array + */ + #[McpResource( + uri: 'server://status', + name: 'server_status', + description: 'Current server status with Microsoft auth info', + mimeType: 'application/json' + )] + public function getServerStatus(): array + { + return [ + 'status' => 'healthy', + 'timestamp' => date('c'), + 'auth_provider' => 'Microsoft Entra ID', + 'php_version' => \PHP_VERSION, + 'memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2), + ]; + } + + /** + * A Microsoft Teams-style message prompt. + */ + #[McpPrompt( + name: 'teams_message', + description: 'Generate a Microsoft Teams-style message' + )] + public function teamsMessage(string $messageType = 'announcement'): string + { + return match ($messageType) { + 'announcement' => "📢 **Announcement**\n\nPlease add your announcement content here.", + 'question' => "❓ **Question**\n\nType your question here.", + 'update' => "📋 **Status Update**\n\n**Progress:**\n- Item 1\n- Item 2", + default => "💬 **Message**\n\nYour message content here.", + }; + } +} diff --git a/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php new file mode 100644 index 00000000..d4cb00a4 --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftJwtTokenValidator.php @@ -0,0 +1,191 @@ + + */ +class MicrosoftJwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param JwtTokenValidator $jwtTokenValidator Base JWT validator used for non-Graph tokens + * @param string $scopeClaim Claim name for scopes in Graph tokens + * @param list $trustedGraphIssuers Allowed Graph issuer host markers + * @param int $notBeforeLeewaySeconds Allowed clock skew for "nbf" claim + */ + public function __construct( + private readonly JwtTokenValidator $jwtTokenValidator, + private readonly string $scopeClaim = 'scp', + private readonly array $trustedGraphIssuers = ['sts.windows.net', 'login.microsoftonline.com'], + private readonly int $notBeforeLeewaySeconds = 60, + ) { + } + + public function validate(string $accessToken): AuthorizationResult + { + $parts = explode('.', $accessToken); + if (!$this->isGraphToken($parts)) { + return $this->jwtTokenValidator->validate($accessToken); + } + + return $this->validateGraphToken($parts); + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + return $this->jwtTokenValidator->requireScopes($result, $requiredScopes); + } + + /** + * @param array $parts + */ + private function isGraphToken(array $parts): bool + { + if ([] === $parts) { + return false; + } + + $header = $this->decodePartToArray($parts[0]); + if (null === $header) { + return false; + } + + return isset($header['nonce']); + } + + /** + * @param array $parts + */ + private function validateGraphToken(array $parts): AuthorizationResult + { + // Intentionally claim-based only for example Graph token compatibility. + if (\count($parts) < 2) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token format.'); + } + + $payload = $this->decodePartToArray($parts[1]); + if (null === $payload) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token payload.'); + } + + if (isset($payload['exp']) && is_numeric($payload['exp']) && (int) $payload['exp'] < time()) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } + + if (isset($payload['nbf']) && is_numeric($payload['nbf']) && (int) $payload['nbf'] > time() + $this->notBeforeLeewaySeconds) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } + + $issuer = $payload['iss'] ?? ''; + if (!\is_string($issuer) || !$this->isTrustedGraphIssuer($issuer)) { + return AuthorizationResult::unauthorized('invalid_token', 'Invalid token issuer for Graph token.'); + } + + $scopes = $this->extractScopes($payload); + + $attributes = [ + 'oauth.claims' => $payload, + 'oauth.scopes' => $scopes, + 'oauth.graph_token' => true, + ]; + + if (isset($payload['sub'])) { + $attributes['oauth.subject'] = $payload['sub']; + } + + if (isset($payload['oid'])) { + $attributes['oauth.object_id'] = $payload['oid']; + } + + if (isset($payload['name'])) { + $attributes['oauth.name'] = $payload['name']; + } + + return AuthorizationResult::allow($attributes); + } + + private function isTrustedGraphIssuer(string $issuer): bool + { + foreach ($this->trustedGraphIssuers as $marker) { + if (str_contains($issuer, $marker)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } + + /** + * @return array|null + */ + private function decodePartToArray(string $part): ?array + { + $decoded = base64_decode(strtr($part, '-_', '+/')); + if (false === $decoded) { + return null; + } + + $data = json_decode($decoded, true); + + return \is_array($data) ? $data : null; + } +} diff --git a/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php new file mode 100644 index 00000000..fb16cda8 --- /dev/null +++ b/examples/server/oauth-microsoft/MicrosoftOidcMetadataPolicy.php @@ -0,0 +1,38 @@ + + */ +final class MicrosoftOidcMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + return \is_array($metadata) + && isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + && \is_string($metadata['authorization_endpoint']) + && '' !== trim($metadata['authorization_endpoint']) + && \is_string($metadata['token_endpoint']) + && '' !== trim($metadata['token_endpoint']) + && \is_string($metadata['jwks_uri']) + && '' !== trim($metadata['jwks_uri']); + } +} diff --git a/examples/server/oauth-microsoft/README.md b/examples/server/oauth-microsoft/README.md new file mode 100644 index 00000000..74239498 --- /dev/null +++ b/examples/server/oauth-microsoft/README.md @@ -0,0 +1,222 @@ +# 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](https://portal.azure.com) > **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:** + +```bash +cp env.example .env +``` + +2. **Edit `.env` with your Azure values:** + +```bash +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-client-id +AZURE_CLIENT_SECRET=your-client-secret # Optional, for Graph API +``` + +3. **Start the services:** + +```bash +docker compose up -d +``` + +4. **Get an access token:** + +Using Azure CLI: +```bash +# 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. + +5. **Test the MCP server:** + +```bash +# 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 +- `MicrosoftJwtTokenValidator.php` - Example-specific validator for Graph/non-Graph tokens +- `MicrosoftOidcMetadataPolicy.php` - Lenient metadata validation policy +- `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 + +This example configures `OidcDiscovery` with `MicrosoftOidcMetadataPolicy`, so this +field can be missing or malformed and will not fail discovery. + +### 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 + +```bash +docker compose down -v +``` diff --git a/examples/server/oauth-microsoft/docker-compose.yml b/examples/server/oauth-microsoft/docker-compose.yml new file mode 100644 index 00000000..4b02d65b --- /dev/null +++ b/examples/server/oauth-microsoft/docker-compose.yml @@ -0,0 +1,43 @@ +services: + php: + build: + context: . + dockerfile: Dockerfile + container_name: mcp-php-microsoft + volumes: + - ../../../:/app + working_dir: /app + env_file: + - .env + environment: + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-} + AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET:-} + command: > + sh -c "mkdir -p /app/examples/server/oauth-microsoft/sessions; + chmod -R 0777 /app/examples/server/oauth-microsoft/sessions; + touch /app/examples/server/oauth-microsoft/dev.log; + chmod 0666 /app/examples/server/oauth-microsoft/dev.log; + touch /app/examples/server/dev.log; + chmod 0666 /app/examples/server/dev.log; + composer install --no-interaction --quiet 2>/dev/null || true; + php-fpm" + networks: + - mcp-network + + nginx: + image: nginx:alpine + container_name: mcp-nginx-microsoft + ports: + - "${MCP_HTTP_PORT:-8000}:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../../../:/app:ro + depends_on: + - php + networks: + - mcp-network + +networks: + mcp-network: + driver: bridge diff --git a/examples/server/oauth-microsoft/env.example b/examples/server/oauth-microsoft/env.example new file mode 100644 index 00000000..7ce041f4 --- /dev/null +++ b/examples/server/oauth-microsoft/env.example @@ -0,0 +1,18 @@ +# Microsoft Entra ID (Azure AD) Configuration +# Copy this file to .env and fill in your values + +# Your Azure AD tenant ID +# Find at: Azure Portal > Entra ID > Overview > Tenant ID +AZURE_TENANT_ID=your-tenant-id-here + +# Application (client) ID for the MCP server app registration +# This is the audience that tokens must be issued for +AZURE_CLIENT_ID=your-client-id-here + +# Client secret for calling Microsoft Graph API (optional) +# Only needed if your MCP tools call Graph API on behalf of users +AZURE_CLIENT_SECRET=your-client-secret-here + +# Optional: Specific API permissions/scopes your MCP server accepts +# Comma-separated list of custom scopes defined in your app registration +# MCP_SCOPES=api://your-client-id/mcp.read,api://your-client-id/mcp.write diff --git a/examples/server/oauth-microsoft/nginx/default.conf b/examples/server/oauth-microsoft/nginx/default.conf new file mode 100644 index 00000000..ad990152 --- /dev/null +++ b/examples/server/oauth-microsoft/nginx/default.conf @@ -0,0 +1,25 @@ +server { + listen 80; + server_name localhost; + root /app/examples/server/oauth-microsoft; + + # Route all requests through PHP + location / { + try_files $uri /server.php$is_args$args; + } + + # PHP processing + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index server.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + + # Pass all request info + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + } +} diff --git a/examples/server/oauth-microsoft/server.php b/examples/server/oauth-microsoft/server.php new file mode 100644 index 00000000..eb1e7585 --- /dev/null +++ b/examples/server/oauth-microsoft/server.php @@ -0,0 +1,124 @@ +createServerRequestFromGlobals(); +$discovery = new OidcDiscovery( + metadataPolicy: new MicrosoftOidcMetadataPolicy(), +); + +// Create base JWT validator for Microsoft Entra ID +// Microsoft uses the client ID as the audience for access tokens +// Accept both v1.0 and v2.0 issuers to support various token flows +$jwtTokenValidator = new JwtTokenValidator( + issuer: $issuers, + audience: $clientId, + jwksProvider: new JwksProvider(discovery: $discovery), + // Microsoft's JWKS endpoint - use common endpoint for all Microsoft signing keys + jwksUri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys', + scopeClaim: 'scp', +); + +// Decorate base validator with Graph-token handling. +$validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, +); + +// Create a shared Protected Resource Metadata object (RFC 9728). +// It is used both for the metadata endpoint and for WWW-Authenticate hints. +$protectedResourceMetadata = new ProtectedResourceMetadata( + authorizationServers: [$localBaseUrl], + scopesSupported: ['openid', 'profile', 'email'], + resourceName: 'OAuth Microsoft Example MCP Server', + resourceDocumentation: $localBaseUrl, +); + +// Create middleware serving Protected Resource Metadata (RFC 9728). +$metadataMiddleware = new ProtectedResourceMetadataMiddleware( + metadata: $protectedResourceMetadata, +); + +// Get client secret for confidential client flow +$clientSecret = getenv('AZURE_CLIENT_SECRET') ?: null; + +// Create OAuth proxy middleware to handle /authorize and /token endpoints +// This proxies OAuth requests to Microsoft Entra ID +// The clientSecret is injected server-side since mcp-remote doesn't have access to it +$oauthProxyMiddleware = new OAuthProxyMiddleware( + upstreamIssuer: $issuerV2, + localBaseUrl: $localBaseUrl, + discovery: $discovery, + clientSecret: $clientSecret, +); + +// Create authorization middleware +$authMiddleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $protectedResourceMetadata, +); +$oauthRequestMetaMiddleware = new OAuthRequestMetaMiddleware(); + +// Build MCP server +$server = Server::builder() + ->setServerInfo('OAuth Microsoft Example', '1.0.0') + ->setLogger(logger()) + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + ->setDiscovery(__DIR__) + ->build(); + +// Create transport with OAuth proxy and authorization middlewares +// Order matters: first matching middleware handles the request. +$transport = new StreamableHttpTransport( + $request, + logger: logger(), + middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, $oauthRequestMetaMiddleware], +); + +// Run server +$response = $server->run($transport); + +// Emit response +(new SapiEmitter())->emit($response); diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php new file mode 100644 index 00000000..b8ae5112 --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftJwtTokenValidatorTest.php @@ -0,0 +1,300 @@ + + */ +class MicrosoftJwtTokenValidatorTest extends TestCase +{ + #[TestDox('non-Graph Microsoft token is validated via JWKS')] + public function testNonGraphTokenUsesStandardJwtValidation(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://login.microsoftonline.com/common/discovery/v2.0/keys'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', + audience: 'mcp-api', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + jwksUri: $jwksUri, + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator(jwtTokenValidator: $jwtTokenValidator); + + $token = JWT::encode( + [ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-123', $result->getAttributes()['oauth.subject']); + $this->assertArrayNotHasKey('oauth.graph_token', $result->getAttributes()); + } + + #[TestDox('Graph token with nonce header is validated by claims only')] + public function testGraphTokenWithNonceHeaderIsAllowed(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://login.microsoftonline.com/tenant-id/v2.0', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read files.write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $this->assertTrue($result->getAttributes()['oauth.graph_token']); + $this->assertSame(['files.read', 'files.write'], $result->getAttributes()['oauth.scopes']); + $this->assertSame('user-graph', $result->getAttributes()['oauth.subject']); + } + + #[TestDox('Graph token with invalid payload is unauthorized')] + public function testGraphTokenInvalidPayloadIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + $token = $header.'..'; + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token payload.', $result->getErrorDescription()); + } + + #[TestDox('Graph token with invalid issuer is unauthorized')] + public function testGraphTokenInvalidIssuerIsUnauthorized(): void + { + $factory = new Psr17Factory(); + $token = $this->buildGraphToken([ + 'iss' => 'https://evil.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-graph', + 'scp' => 'files.read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ]); + + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([$factory->createResponse(500)], 0), + requestFactory: $factory + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Invalid token issuer for Graph token.', $result->getErrorDescription()); + } + + #[TestDox('scope checks are delegated to base JwtTokenValidator')] + public function testRequireScopesDelegatesToJwtTokenValidator(): void + { + $factory = new Psr17Factory(); + $jwtTokenValidator = new JwtTokenValidator( + issuer: ['https://auth.example.com'], + audience: ['mcp-api'], + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), + httpClient: $this->createHttpClientMock([], 0), + requestFactory: $factory, + ), + jwksUri: 'https://unused.example.com/jwks', + scopeClaim: 'scp', + ); + $validator = new MicrosoftJwtTokenValidator( + jwtTokenValidator: $jwtTokenValidator, + scopeClaim: 'scp', + ); + + $result = AuthorizationResult::allow([ + 'oauth.scopes' => ['files.read'], + ]); + $scoped = $validator->requireScopes($result, ['files.read', 'files.write']); + + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + } + + /** + * @param array $claims + */ + private function buildGraphToken(array $claims): string + { + $header = $this->b64urlEncode(json_encode([ + 'alg' => 'none', + 'typ' => 'JWT', + 'nonce' => 'abc', + ], \JSON_THROW_ON_ERROR)); + + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header.'.'.$payload.'.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + $this->fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + $this->fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + $this->fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= \count($responses); + + $client = $this->createMock(ClientInterface::class); + $client + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)) + ->willReturnCallback(static function () use (&$responses): ResponseInterface { + if ([] === $responses) { + throw new \RuntimeException('No more mocked responses available.'); + } + + return array_shift($responses); + }); + + return $client; + } +} diff --git a/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php new file mode 100644 index 00000000..218e0fbd --- /dev/null +++ b/examples/server/oauth-microsoft/tests/Unit/MicrosoftOidcMetadataPolicyTest.php @@ -0,0 +1,64 @@ + + */ +class MicrosoftOidcMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is accepted')] + public function testMissingCodeChallengeMethodsIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('malformed code challenge methods are ignored for validity')] + public function testMalformedCodeChallengeMethodsSupportedIsAccepted(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => 'S256', + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('required endpoint fields still enforce validity')] + public function testIsValidRequiresCoreEndpoints(): void + { + $policy = new MicrosoftOidcMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + // token_endpoint missing + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d5adf9e1..54c2a8e1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,6 +12,9 @@ tests/Unit + + examples/server/oauth-microsoft/tests + tests/Inspector diff --git a/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php new file mode 100644 index 00000000..45c997e4 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/AuthorizationMiddleware.php @@ -0,0 +1,182 @@ + + */ +final class AuthorizationMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + + /** + * @param AuthorizationTokenValidatorInterface $validator Token validator implementation + * @param ProtectedResourceMetadata $resourceMetadata Protected resource metadata object used for challenge hints + * @param ResponseFactoryInterface|null $responseFactory PSR-17 response factory (auto-discovered if null) + */ + public function __construct( + private AuthorizationTokenValidatorInterface $validator, + private ProtectedResourceMetadata $resourceMetadata, + ?ResponseFactoryInterface $responseFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $authorization = $request->getHeaderLine('Authorization'); + if ('' === $authorization) { + return $this->buildErrorResponse($request, AuthorizationResult::unauthorized()); + } + + $accessToken = $this->parseBearerToken($authorization); + if (null === $accessToken) { + return $this->buildErrorResponse( + $request, + AuthorizationResult::badRequest('invalid_request', 'Malformed Authorization header.'), + ); + } + + $result = $this->validator->validate($accessToken); + if (!$result->isAllowed()) { + return $this->buildErrorResponse($request, $result); + } + + return $handler->handle($this->applyAttributes($request, $result->getAttributes())); + } + + private function buildErrorResponse(ServerRequestInterface $request, AuthorizationResult $result): ResponseInterface + { + $response = $this->responseFactory->createResponse($result->getStatusCode()); + $header = $this->buildAuthenticateHeader($request, $result); + + $response = $response->withHeader('WWW-Authenticate', $header); + + return $response; + } + + private function buildAuthenticateHeader(ServerRequestInterface $request, AuthorizationResult $result): string + { + $parts = []; + + $parts[] = 'resource_metadata="'.$this->escapeHeaderValue($this->resolveResourceMetadataUrl($request)).'"'; + + $scopes = $this->resolveScopes($result); + if (null !== $scopes) { + $parts[] = 'scope="'.$this->escapeHeaderValue(implode(' ', $scopes)).'"'; + } + + if (null !== $result->getError()) { + $parts[] = 'error="'.$this->escapeHeaderValue($result->getError()).'"'; + } + + if (null !== $result->getErrorDescription()) { + $parts[] = 'error_description="'.$this->escapeHeaderValue($result->getErrorDescription()).'"'; + } + + return 'Bearer '.implode(', ', $parts); + } + + /** + * @return list|null + */ + private function resolveScopes(AuthorizationResult $result): ?array + { + $scopes = $this->normalizeScopes($result->getScopes()); + if (null !== $scopes) { + return $scopes; + } + + return $this->normalizeScopes($this->resourceMetadata->getScopesSupported()); + } + + /** + * @param list|null $scopes + * + * @return list|null + */ + private function normalizeScopes(?array $scopes): ?array + { + if (null === $scopes) { + return null; + } + + $normalized = array_values(array_filter(array_map('trim', $scopes), static function (string $scope): bool { + return '' !== $scope; + })); + + return [] === $normalized ? null : $normalized; + } + + private function resolveResourceMetadataUrl(ServerRequestInterface $request): string + { + $metadataPath = $this->resourceMetadata->getPrimaryMetadataPath(); + + $uri = $request->getUri(); + $scheme = $uri->getScheme(); + $authority = $uri->getAuthority(); + + if ('' === $scheme || '' === $authority) { + throw new RuntimeException('Cannot resolve resource metadata URL: request URI must have scheme and authority'); + } + + return $scheme.'://'.$authority.$metadataPath; + } + + /** + * @param array $attributes + */ + private function applyAttributes(ServerRequestInterface $request, array $attributes): ServerRequestInterface + { + foreach ($attributes as $name => $value) { + $request = $request->withAttribute($name, $value); + } + + return $request; + } + + private function parseBearerToken(string $authorization): ?string + { + if (!preg_match('/^Bearer\\s+(.+)$/', $authorization, $matches)) { + return null; + } + + $token = trim($matches[1]); + + return '' === $token ? null : $token; + } + + private function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } +} diff --git a/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php new file mode 100644 index 00000000..b3e7fb48 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/OAuthProxyMiddleware.php @@ -0,0 +1,271 @@ + + */ +final class OAuthProxyMiddleware implements MiddlewareInterface +{ + private const CLIENT_SECRET_BASIC = 'client_secret_basic'; + private const CLIENT_SECRET_POST = 'client_secret_post'; + + private ?ClientInterface $httpClient; + private ?RequestFactoryInterface $requestFactory; + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + /** + * @param string $upstreamIssuer The issuer URL of the upstream OAuth provider + * @param string $localBaseUrl The base URL of this MCP server (e.g., http://localhost:8000) + * @param string|null $clientSecret Optional client secret for confidential clients + * @param OidcDiscoveryInterface $discovery OIDC discovery provider for upstream metadata + */ + public function __construct( + private readonly string $upstreamIssuer, + private readonly string $localBaseUrl, + private readonly OidcDiscoveryInterface $discovery, + private readonly ?string $clientSecret = null, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->httpClient = $httpClient; + $this->requestFactory = $requestFactory; + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $path = $request->getUri()->getPath(); + + if ('GET' === $request->getMethod() && '/.well-known/oauth-authorization-server' === $path) { + return $this->createAuthServerMetadataResponse(); + } + + if ('GET' === $request->getMethod() && '/authorize' === $path) { + return $this->handleAuthorize($request); + } + + if ('POST' === $request->getMethod() && '/token' === $path) { + return $this->handleToken($request); + } + + return $handler->handle($request); + } + + private function handleAuthorize(ServerRequestInterface $request): ResponseInterface + { + try { + $authorizationEndpoint = $this->discovery->getAuthorizationEndpoint($this->upstreamIssuer); + } catch (\Throwable) { + return $this->createErrorResponse(500, 'Upstream authorization endpoint not found'); + } + + $rawQueryString = $request->getUri()->getQuery(); + $upstreamUrl = $authorizationEndpoint; + if ('' !== $rawQueryString) { + $upstreamUrl .= '?'.$rawQueryString; + } + + return $this->responseFactory + ->createResponse(302) + ->withHeader('Location', $upstreamUrl) + ->withHeader('Cache-Control', 'no-store'); + } + + private function handleToken(ServerRequestInterface $request): ResponseInterface + { + try { + $tokenEndpoint = $this->discovery->getTokenEndpoint($this->upstreamIssuer); + } catch (\Throwable) { + return $this->createErrorResponse(500, 'Upstream token endpoint not found'); + } + + $body = $request->getBody()->__toString(); + parse_str($body, $params); + + $upstreamAuthorization = trim($request->getHeaderLine('Authorization')); + if ('' === $upstreamAuthorization) { + $upstreamAuthorization = null; + } + + if (null !== $this->clientSecret && !isset($params['client_secret']) && null === $upstreamAuthorization) { + $authMethod = $this->resolveTokenEndpointAuthMethod(); + + if (self::CLIENT_SECRET_BASIC === $authMethod) { + $clientId = $params['client_id'] ?? null; + + if (\is_string($clientId) && '' !== trim($clientId)) { + $upstreamAuthorization = 'Basic '.base64_encode(trim($clientId).':'.$this->clientSecret); + } else { + $params['client_secret'] = $this->clientSecret; + } + } else { + $params['client_secret'] = $this->clientSecret; + } + } + + $body = http_build_query($params); + + $upstreamRequest = $this->getRequestFactory() + ->createRequest('POST', $tokenEndpoint) + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($this->streamFactory->createStream($body)); + + if (null !== $upstreamAuthorization) { + $upstreamRequest = $upstreamRequest->withHeader('Authorization', $upstreamAuthorization); + } + + try { + $upstreamResponse = $this->getHttpClient()->sendRequest($upstreamRequest); + $responseBody = $upstreamResponse->getBody()->__toString(); + + return $this->responseFactory + ->createResponse($upstreamResponse->getStatusCode()) + ->withHeader('Content-Type', $upstreamResponse->getHeaderLine('Content-Type')) + ->withHeader('Cache-Control', 'no-store') + ->withBody($this->streamFactory->createStream($responseBody)); + } catch (\Throwable $e) { + return $this->createErrorResponse(502, 'Failed to contact upstream token endpoint: '.$e->getMessage()); + } + } + + private function createAuthServerMetadataResponse(): ResponseInterface + { + try { + $upstreamMetadata = $this->discovery->discover($this->upstreamIssuer); + } catch (\Throwable) { + return $this->createErrorResponse(500, 'Failed to discover upstream server metadata'); + } + + $localBaseUrl = rtrim($this->localBaseUrl, '/'); + $localMetadata = [ + 'issuer' => $localBaseUrl, + 'authorization_endpoint' => $localBaseUrl.'/authorize', + 'token_endpoint' => $localBaseUrl.'/token', + 'response_types_supported' => $upstreamMetadata['response_types_supported'] ?? ['code'], + 'grant_types_supported' => $upstreamMetadata['grant_types_supported'] ?? ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => $upstreamMetadata['code_challenge_methods_supported'] ?? ['S256'], + ]; + + $copyFields = [ + 'scopes_supported', + 'token_endpoint_auth_methods_supported', + 'jwks_uri', + ]; + + foreach ($copyFields as $field) { + if (isset($upstreamMetadata[$field])) { + $localMetadata[$field] = $upstreamMetadata[$field]; + } + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Cache-Control', 'max-age=3600') + ->withBody($this->streamFactory->createStream(json_encode($localMetadata, \JSON_UNESCAPED_SLASHES))); + } + + private function createErrorResponse(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => 'server_error', 'error_description' => $message]); + + return $this->responseFactory + ->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } + + private function resolveTokenEndpointAuthMethod(): string + { + $supportedMethods = $this->getTokenEndpointAuthMethods(); + + if (\in_array(self::CLIENT_SECRET_BASIC, $supportedMethods, true)) { + return self::CLIENT_SECRET_BASIC; + } + + if (\in_array(self::CLIENT_SECRET_POST, $supportedMethods, true)) { + return self::CLIENT_SECRET_POST; + } + + return self::CLIENT_SECRET_POST; + } + + /** + * @return list + */ + private function getTokenEndpointAuthMethods(): array + { + try { + $metadata = $this->discovery->discover($this->upstreamIssuer); + } catch (\Throwable) { + return []; + } + + $methods = $metadata['token_endpoint_auth_methods_supported'] ?? null; + if (!\is_array($methods)) { + return []; + } + + $normalized = []; + foreach ($methods as $method) { + if (!\is_string($method)) { + continue; + } + + $method = trim($method); + if ('' === $method) { + continue; + } + + $normalized[] = $method; + } + + return array_values(array_unique($normalized)); + } + + private function getHttpClient(): ClientInterface + { + return $this->httpClient ??= Psr18ClientDiscovery::find(); + } + + private function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); + } +} diff --git a/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php new file mode 100644 index 00000000..9b940dab --- /dev/null +++ b/src/Server/Transport/Http/Middleware/OAuthRequestMetaMiddleware.php @@ -0,0 +1,149 @@ + + */ +final class OAuthRequestMetaMiddleware implements MiddlewareInterface +{ + public function __construct( + private ?StreamFactoryInterface $streamFactory = null, + ) { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ('POST' !== $request->getMethod()) { + return $handler->handle($request); + } + + $oauthMeta = $this->extractOAuthAttributes($request); + if ([] === $oauthMeta) { + return $handler->handle($request); + } + + $body = (string) $request->getBody(); + if ('' === trim($body)) { + return $handler->handle($request); + } + + try { + $payload = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $handler->handle($request); + } + + $updatedPayload = $this->injectOauthMeta($payload, $oauthMeta); + if (null === $updatedPayload) { + return $handler->handle($request); + } + + try { + $updatedBody = json_encode($updatedPayload, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES); + } catch (\JsonException) { + return $handler->handle($request); + } + + $request = $request->withBody($this->getStreamFactory()->createStream($updatedBody)); + + return $handler->handle($request); + } + + /** + * @return array + */ + private function extractOAuthAttributes(ServerRequestInterface $request): array + { + $result = []; + foreach ($request->getAttributes() as $key => $value) { + if (\is_string($key) && str_starts_with($key, 'oauth.')) { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * @param array $oauthMeta + */ + private function injectOauthMeta(mixed $payload, array $oauthMeta): mixed + { + if (!\is_array($payload)) { + return null; + } + + if (array_is_list($payload)) { + $updated = []; + foreach ($payload as $entry) { + if (!\is_array($entry)) { + $updated[] = $entry; + continue; + } + + $updated[] = $this->injectIntoMessage($entry, $oauthMeta); + } + + return $updated; + } + + return $this->injectIntoMessage($payload, $oauthMeta); + } + + /** + * @param array $message + * @param array $oauthMeta + * + * @return array + */ + private function injectIntoMessage(array $message, array $oauthMeta): array + { + $params = $message['params'] ?? []; + if (!\is_array($params)) { + return $message; + } + + $meta = $params['_meta'] ?? []; + if (!\is_array($meta)) { + $meta = []; + } + + $existingOAuth = $meta['oauth'] ?? []; + if (!\is_array($existingOAuth)) { + $existingOAuth = []; + } + + $meta['oauth'] = array_merge($existingOAuth, $oauthMeta); + $params['_meta'] = $meta; + $message['params'] = $params; + + return $message; + } + + private function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); + } +} diff --git a/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php new file mode 100644 index 00000000..315f585f --- /dev/null +++ b/src/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddleware.php @@ -0,0 +1,64 @@ + + */ +final class ProtectedResourceMetadataMiddleware implements MiddlewareInterface +{ + private ResponseFactoryInterface $responseFactory; + private StreamFactoryInterface $streamFactory; + + public function __construct( + private readonly ProtectedResourceMetadata $metadata, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ) { + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!$this->isMetadataRequest($request)) { + return $handler->handle($request); + } + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream(json_encode($this->metadata, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES))); + } + + private function isMetadataRequest(ServerRequestInterface $request): bool + { + if ('GET' !== $request->getMethod()) { + return false; + } + + return \in_array($request->getUri()->getPath(), $this->metadata->getMetadataPaths(), true); + } +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationResult.php b/src/Server/Transport/Http/OAuth/AuthorizationResult.php new file mode 100644 index 00000000..4db13f4c --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationResult.php @@ -0,0 +1,135 @@ + + */ +final class AuthorizationResult +{ + /** + * @param list|null $scopes Scopes to include in WWW-Authenticate challenge + * @param array $attributes Attributes to attach to the request on success + */ + private function __construct( + private readonly bool $allowed, + private readonly int $statusCode, + private readonly ?string $error, + private readonly ?string $errorDescription, + private readonly ?array $scopes, + private readonly array $attributes, + ) { + } + + /** + * Creates a result indicating access is allowed. + * + * @param array $attributes Attributes to attach to the request (e.g., user_id, scopes) + */ + public static function allow(array $attributes = []): self + { + return new self(true, 200, null, null, null, $attributes); + } + + /** + * Creates a result indicating the request is unauthorized (401). + * + * Use when no valid credentials are provided or the token is invalid. + * + * @param string|null $error OAuth error code (e.g., "invalid_token") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function unauthorized( + ?string $error = null, + ?string $errorDescription = null, + ?array $scopes = null, + ): self { + return new self(false, 401, $error, $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating the request is forbidden (403). + * + * Use when the token is valid but lacks required permissions/scopes. + * + * @param string|null $error OAuth error code (defaults to "insufficient_scope") + * @param string|null $errorDescription Human-readable error description + * @param list|null $scopes Required scopes to include in challenge + */ + public static function forbidden( + ?string $error = 'insufficient_scope', + ?string $errorDescription = null, + ?array $scopes = null, + ): self { + return new self(false, 403, $error ?? 'insufficient_scope', $errorDescription, $scopes, []); + } + + /** + * Creates a result indicating a bad request (400). + * + * Use when the Authorization header is malformed. + * + * @param string|null $error OAuth error code (defaults to "invalid_request") + * @param string|null $errorDescription Human-readable error description + */ + public static function badRequest( + ?string $error = 'invalid_request', + ?string $errorDescription = null, + ): self { + return new self(false, 400, $error ?? 'invalid_request', $errorDescription, null, []); + } + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getError(): ?string + { + return $this->error; + } + + public function getErrorDescription(): ?string + { + return $this->errorDescription; + } + + /** + * @return list|null + */ + public function getScopes(): ?array + { + return $this->scopes; + } + + /** + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php new file mode 100644 index 00000000..78b849eb --- /dev/null +++ b/src/Server/Transport/Http/OAuth/AuthorizationTokenValidatorInterface.php @@ -0,0 +1,32 @@ + + */ +interface AuthorizationTokenValidatorInterface +{ + /** + * Validates an access token extracted from the Authorization header. + * + * @param string $accessToken The bearer token (without "Bearer " prefix) + * + * @return AuthorizationResult The result of the validation + */ + public function validate(string $accessToken): AuthorizationResult; +} diff --git a/src/Server/Transport/Http/OAuth/JwksProvider.php b/src/Server/Transport/Http/OAuth/JwksProvider.php new file mode 100644 index 00000000..50145d92 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProvider.php @@ -0,0 +1,128 @@ + + */ +class JwksProvider implements JwksProviderInterface +{ + private const CACHE_KEY_PREFIX = 'mcp_jwks_'; + + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + + /** + * @param OidcDiscoveryInterface $discovery OIDC discovery provider (required for JWKS URI resolution when $jwksUri is not explicit) + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache Optional PSR-16 cache + * @param int $cacheTtl JWKS cache TTL in seconds + */ + public function __construct( + private readonly OidcDiscoveryInterface $discovery, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + /** + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array + { + $jwksUri ??= $this->resolveJwksUri($issuer); + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $jwksUri); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if ($this->isJwksValid($cached)) { + /* @var array $cached */ + return $cached; + } + } + + $jwks = $this->fetchJwks($jwksUri); + + if (!$this->isJwksValid($jwks)) { + throw new RuntimeException(\sprintf('JWKS response from %s has invalid format: expected non-empty "keys" array.', $jwksUri)); + } + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $jwks, $this->cacheTtl); + } + + return $jwks; + } + + private function resolveJwksUri(string $issuer): string + { + return $this->discovery->getJwksUri($issuer); + } + + /** + * @return array + */ + private function fetchJwks(string $jwksUri): array + { + $request = $this->requestFactory->createRequest('GET', $jwksUri) + ->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: %s', $jwksUri, $e->getMessage()), 0, $e); + } + + if (200 !== $response->getStatusCode()) { + throw new RuntimeException(\sprintf('Failed to fetch JWKS from %s: HTTP %d', $jwksUri, $response->getStatusCode())); + } + + $body = $response->getBody()->__toString(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to decode JWKS: %s', $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new RuntimeException('Invalid JWKS format: expected JSON object.'); + } + + return $data; + } + + private function isJwksValid(mixed $jwks): bool + { + if (!\is_array($jwks) || !isset($jwks['keys']) || !\is_array($jwks['keys'])) { + return false; + } + + $nonEmptyKeys = array_filter($jwks['keys'], static fn (mixed $key): bool => \is_array($key) && [] !== $key); + + return [] !== $nonEmptyKeys; + } +} diff --git a/src/Server/Transport/Http/OAuth/JwksProviderInterface.php b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php new file mode 100644 index 00000000..f33ac658 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwksProviderInterface.php @@ -0,0 +1,28 @@ + + */ +interface JwksProviderInterface +{ + /** + * @param string $issuer authorization server issuer URL + * @param string|null $jwksUri Optional explicit JWKS URI. If null, implementation may resolve via discovery. + * + * @return array + */ + public function getJwks(string $issuer, ?string $jwksUri = null): array; +} diff --git a/src/Server/Transport/Http/OAuth/JwtTokenValidator.php b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php new file mode 100644 index 00000000..6291314f --- /dev/null +++ b/src/Server/Transport/Http/OAuth/JwtTokenValidator.php @@ -0,0 +1,218 @@ + + */ +class JwtTokenValidator implements AuthorizationTokenValidatorInterface +{ + /** + * @param string|list $issuer Expected token issuer(s) (e.g., "https://auth.example.com/realms/mcp") + * @param string|list $audience Expected audience(s) for the token + * @param JwksProviderInterface $jwksProvider JWKS provider + * @param string|null $jwksUri Explicit JWKS URI (auto-discovered from first issuer if null) + * @param list $algorithms Allowed JWT algorithms (default: RS256, RS384, RS512) + * @param string $scopeClaim Claim name for scopes (default: "scope") + */ + public function __construct( + private readonly string|array $issuer, + private readonly string|array $audience, + private readonly JwksProviderInterface $jwksProvider, + private readonly ?string $jwksUri = null, + private readonly array $algorithms = ['RS256', 'RS384', 'RS512'], + private readonly string $scopeClaim = 'scope', + ) { + } + + public function validate(string $accessToken): AuthorizationResult + { + try { + $keys = $this->getJwks(); + $decoded = JWT::decode($accessToken, $keys); + /** @var array $claims */ + $claims = (array) $decoded; + + // Validate issuer + if (!$this->validateIssuer($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token issuer mismatch.' + ); + } + + // Validate audience + if (!$this->validateAudience($claims)) { + return AuthorizationResult::unauthorized( + 'invalid_token', + 'Token audience mismatch.' + ); + } + + // Extract scopes + $scopes = $this->extractScopes($claims); + + // Build attributes to attach to request + $attributes = [ + 'oauth.claims' => $claims, + 'oauth.scopes' => $scopes, + ]; + + // Add common claims as individual attributes + if (isset($claims['sub'])) { + $attributes['oauth.subject'] = $claims['sub']; + } + + if (isset($claims['client_id'])) { + $attributes['oauth.client_id'] = $claims['client_id']; + } + + // Add azp (authorized party) for OIDC tokens + if (isset($claims['azp'])) { + $attributes['oauth.authorized_party'] = $claims['azp']; + } + + return AuthorizationResult::allow($attributes); + } catch (ExpiredException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token has expired.'); + } catch (SignatureInvalidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token signature verification failed.'); + } catch (BeforeValidException) { + return AuthorizationResult::unauthorized('invalid_token', 'Token is not yet valid.'); + } catch (\UnexpectedValueException|\DomainException $e) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation failed: '.$e->getMessage()); + } catch (\Throwable) { + return AuthorizationResult::unauthorized('invalid_token', 'Token validation error.'); + } + } + + /** + * Validates a token has the required scopes. + * + * Use this after validation to check specific scope requirements. + * + * @param AuthorizationResult $result The result from validate() + * @param list $requiredScopes Scopes required for this operation + * + * @return AuthorizationResult The original result if scopes are sufficient, forbidden otherwise + */ + public function requireScopes(AuthorizationResult $result, array $requiredScopes): AuthorizationResult + { + if (!$result->isAllowed()) { + return $result; + } + + $tokenScopes = $result->getAttributes()['oauth.scopes'] ?? []; + + if (!\is_array($tokenScopes)) { + $tokenScopes = []; + } + + foreach ($requiredScopes as $required) { + if (!\in_array($required, $tokenScopes, true)) { + return AuthorizationResult::forbidden( + 'insufficient_scope', + \sprintf('Required scope: %s', $required), + $requiredScopes + ); + } + } + + return $result; + } + + /** + * @return array + */ + private function getJwks(): array + { + $issuer = \is_array($this->issuer) ? $this->issuer[0] : $this->issuer; + $jwksData = $this->jwksProvider->getJwks($issuer, $this->jwksUri); + + /* @var array */ + return JWK::parseKeySet($jwksData, $this->algorithms[0]); + } + + /** + * @param array $claims + */ + private function validateAudience(array $claims): bool + { + if (!isset($claims['aud'])) { + return false; + } + + $tokenAudiences = \is_array($claims['aud']) ? $claims['aud'] : [$claims['aud']]; + $expectedAudiences = \is_array($this->audience) ? $this->audience : [$this->audience]; + + foreach ($expectedAudiences as $expected) { + if (\in_array($expected, $tokenAudiences, true)) { + return true; + } + } + + return false; + } + + /** + * @param array $claims + */ + private function validateIssuer(array $claims): bool + { + if (!isset($claims['iss'])) { + return false; + } + + $expectedIssuers = \is_array($this->issuer) ? $this->issuer : [$this->issuer]; + + return \in_array($claims['iss'], $expectedIssuers, true); + } + + /** + * @param array $claims + * + * @return list + */ + private function extractScopes(array $claims): array + { + if (!isset($claims[$this->scopeClaim])) { + return []; + } + + $scopeValue = $claims[$this->scopeClaim]; + + if (\is_array($scopeValue)) { + return array_values(array_filter($scopeValue, 'is_string')); + } + + if (\is_string($scopeValue)) { + return array_values(array_filter(explode(' ', $scopeValue))); + } + + return []; + } +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscovery.php b/src/Server/Transport/Http/OAuth/OidcDiscovery.php new file mode 100644 index 00000000..dbff831c --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscovery.php @@ -0,0 +1,235 @@ + + */ +class OidcDiscovery implements OidcDiscoveryInterface +{ + private ClientInterface $httpClient; + private RequestFactoryInterface $requestFactory; + private OidcDiscoveryMetadataPolicyInterface $metadataPolicy; + + private const CACHE_KEY_PREFIX = 'mcp_oidc_discovery_'; + + /** + * @param ClientInterface|null $httpClient PSR-18 HTTP client (auto-discovered if null) + * @param RequestFactoryInterface|null $requestFactory PSR-17 request factory (auto-discovered if null) + * @param CacheInterface|null $cache PSR-16 cache for metadata (optional) + * @param int $cacheTtl Cache TTL in seconds (default: 1 hour) + * @param OidcDiscoveryMetadataPolicyInterface|null $metadataPolicy Metadata validation policy + */ + public function __construct( + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + private readonly ?CacheInterface $cache = null, + private readonly int $cacheTtl = 3600, + ?OidcDiscoveryMetadataPolicyInterface $metadataPolicy = null, + ) { + $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); + $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + $this->metadataPolicy = $metadataPolicy ?? new StrictOidcDiscoveryMetadataPolicy(); + } + + /** + * Gets the JWKS URI from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The JWKS URI + * + * @throws RuntimeException If discover fails + */ + public function getJwksUri(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['jwks_uri']; + } + + /** + * Gets the token endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The token endpoint URL + * + * @throws RuntimeException If discover fails + */ + public function getTokenEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['token_endpoint']; + } + + /** + * Gets the authorization endpoint from the authorization server metadata. + * + * @param string $issuer The issuer URL + * + * @return string The authorization endpoint URL + * + * @throws RuntimeException If discover fails + */ + public function getAuthorizationEndpoint(string $issuer): string + { + $metadata = $this->discover($issuer); + + return $metadata['authorization_endpoint']; + } + + /** + * Discovers authorization server metadata from the issuer URL. + * + * Tries endpoints in priority order per RFC 8414 and OpenID Connect Discovery: + * 1. OAuth 2.0 path insertion: /.well-known/oauth-authorization-server/{path} + * 2. OIDC path insertion: /.well-known/openid-configuration/{path} + * 3. OIDC path appending: {path}/.well-known/openid-configuration + * + * @param string $issuer The issuer URL (e.g., "https://auth.example.com/realms/mcp") + * + * @return array The authorization server metadata + * + * @throws RuntimeException If discovery fails + */ + public function discover(string $issuer): array + { + $cacheKey = self::CACHE_KEY_PREFIX.hash('sha256', $issuer); + + if (null !== $this->cache) { + $cached = $this->cache->get($cacheKey); + if ($this->metadataPolicy->isValid($cached)) { + /* @var array $cached */ + return $cached; + } + } + + $metadata = $this->fetchMetadata($issuer); + + if (null !== $this->cache) { + $this->cache->set($cacheKey, $metadata, $this->cacheTtl); + } + + return $metadata; + } + + /** + * @return array + */ + private function fetchMetadata(string $issuer): array + { + $issuer = rtrim($issuer, '/'); + $parsed = parse_url($issuer); + + if (false === $parsed || !isset($parsed['scheme'], $parsed['host'])) { + throw new RuntimeException(\sprintf('Invalid issuer URL: %s', $issuer)); + } + + $scheme = $parsed['scheme']; + $host = $parsed['host']; + $port = isset($parsed['port']) ? ':'.$parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + + $baseUrl = $scheme.'://'.$host.$port; + + // Build discovery URLs in priority order per RFC 8414 Section 3.1 + $discoveryUrls = []; + + if ('' !== $path && '/' !== $path) { + // For issuer URLs with path components + // 1. OAuth 2.0 path insertion + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'.$path; + // 2. OIDC path insertion + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'.$path; + // 3. OIDC path appending + $discoveryUrls[] = $issuer.'/.well-known/openid-configuration'; + } else { + // For issuer URLs without path components + $discoveryUrls[] = $baseUrl.'/.well-known/oauth-authorization-server'; + $discoveryUrls[] = $baseUrl.'/.well-known/openid-configuration'; + } + + $lastException = null; + + foreach ($discoveryUrls as $url) { + try { + $metadata = $this->fetchJson($url); + if (!$this->metadataPolicy->isValid($metadata)) { + throw new RuntimeException(\sprintf('OIDC discovery response from %s has invalid format.', $url)); + } + + // Validate issuer claim matches + if (isset($metadata['issuer']) && $metadata['issuer'] !== $issuer) { + continue; + } + + return $metadata; + } catch (RuntimeException $e) { + $lastException = $e; + continue; + } + } + + throw new RuntimeException(\sprintf('Failed to discover authorization server metadata for issuer: %s', $issuer), 0, $lastException); + } + + /** + * @return array + */ + private function fetchJson(string $url): array + { + $request = $this->requestFactory->createRequest('GET', $url) + ->withHeader('Accept', 'application/json'); + + try { + $response = $this->httpClient->sendRequest($request); + } catch (\Throwable $e) { + throw new RuntimeException(\sprintf('HTTP request to %s failed: %s', $url, $e->getMessage()), 0, $e); + } + + if ($response->getStatusCode() >= 400) { + throw new RuntimeException(\sprintf('HTTP request to %s failed with status %d', $url, $response->getStatusCode())); + } + + $body = $response->getBody()->__toString(); + + try { + $data = json_decode($body, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new RuntimeException(\sprintf('Failed to decode JSON from %s: %s', $url, $e->getMessage()), 0, $e); + } + + if (!\is_array($data)) { + throw new RuntimeException(\sprintf('Expected JSON object from %s, got %s', $url, \gettype($data))); + } + + return $data; + } +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php new file mode 100644 index 00000000..aeabf0bb --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryInterface.php @@ -0,0 +1,31 @@ + + */ +interface OidcDiscoveryInterface +{ + /** + * @return array + */ + public function discover(string $issuer): array; + + public function getAuthorizationEndpoint(string $issuer): string; + + public function getTokenEndpoint(string $issuer): string; + + public function getJwksUri(string $issuer): string; +} diff --git a/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php new file mode 100644 index 00000000..edf94c48 --- /dev/null +++ b/src/Server/Transport/Http/OAuth/OidcDiscoveryMetadataPolicyInterface.php @@ -0,0 +1,22 @@ + + */ +interface OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool; +} diff --git a/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php new file mode 100644 index 00000000..dd96982b --- /dev/null +++ b/src/Server/Transport/Http/OAuth/ProtectedResourceMetadata.php @@ -0,0 +1,245 @@ + + */ +final class ProtectedResourceMetadata implements \JsonSerializable +{ + public const DEFAULT_METADATA_PATH = '/.well-known/oauth-protected-resource'; + + private const LOCALIZED_HUMAN_READABLE_FIELD_PATTERN = '/^(resource_name|resource_documentation|resource_policy_uri|resource_tos_uri)#[A-Za-z0-9-]+$/'; + + /** @var list */ + private array $authorizationServers; + + /** @var list|null */ + private ?array $scopesSupported; + + /** @var list */ + private array $metadataPaths; + + /** @var array */ + private array $localizedHumanReadable; + + /** @var array */ + private array $extra; + + private ?string $resource; + private ?string $resourceName; + private ?string $resourceDocumentation; + private ?string $resourcePolicyUri; + private ?string $resourceTosUri; + + /** + * @param list $authorizationServers + * @param list|null $scopesSupported + * @param array $localizedHumanReadable Locale-specific values, e.g. resource_name#en => "My Resource" + * @param array $extra Additional RFC 9728 metadata fields + * @param list $metadataPaths + */ + public function __construct( + array $authorizationServers, + ?array $scopesSupported = null, + ?string $resource = null, + ?string $resourceName = null, + ?string $resourceDocumentation = null, + ?string $resourcePolicyUri = null, + ?string $resourceTosUri = null, + array $localizedHumanReadable = [], + array $extra = [], + array $metadataPaths = [self::DEFAULT_METADATA_PATH], + ) { + $this->authorizationServers = $this->normalizeStringList($authorizationServers, 'authorizationServers'); + if ([] === $this->authorizationServers) { + throw new InvalidArgumentException('Protected resource metadata requires at least one authorization server.'); + } + + $normalizedScopes = $this->normalizeStringList($scopesSupported ?? [], 'scopesSupported'); + $this->scopesSupported = [] === $normalizedScopes ? null : $normalizedScopes; + + $this->resource = $this->normalizeNullableString($resource); + $this->resourceName = $this->normalizeNullableString($resourceName); + $this->resourceDocumentation = $this->normalizeNullableString($resourceDocumentation); + $this->resourcePolicyUri = $this->normalizeNullableString($resourcePolicyUri); + $this->resourceTosUri = $this->normalizeNullableString($resourceTosUri); + $this->localizedHumanReadable = $this->normalizeLocalizedHumanReadable($localizedHumanReadable); + $this->extra = $extra; + + $this->metadataPaths = $this->normalizePaths($metadataPaths); + if ([] === $this->metadataPaths) { + throw new InvalidArgumentException('Protected resource metadata requires at least one metadata path.'); + } + } + + /** + * @return list + */ + public function getMetadataPaths(): array + { + return $this->metadataPaths; + } + + public function getPrimaryMetadataPath(): string + { + return $this->metadataPaths[0]; + } + + /** + * @return list|null + */ + public function getScopesSupported(): ?array + { + return $this->scopesSupported; + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = [ + 'authorization_servers' => $this->authorizationServers, + ]; + + if (null !== $this->scopesSupported) { + $data['scopes_supported'] = $this->scopesSupported; + } + + if (null !== $this->resource) { + $data['resource'] = $this->resource; + } + + if (null !== $this->resourceName) { + $data['resource_name'] = $this->resourceName; + } + + if (null !== $this->resourceDocumentation) { + $data['resource_documentation'] = $this->resourceDocumentation; + } + + if (null !== $this->resourcePolicyUri) { + $data['resource_policy_uri'] = $this->resourcePolicyUri; + } + + if (null !== $this->resourceTosUri) { + $data['resource_tos_uri'] = $this->resourceTosUri; + } + + foreach ($this->localizedHumanReadable as $key => $value) { + $data[$key] = $value; + } + + return array_merge($this->extra, $data); + } + + /** + * @param list $values + * + * @return list + */ + private function normalizeStringList(array $values, string $parameterName): array + { + $normalized = []; + + foreach ($values as $value) { + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Protected resource metadata parameter "%s" must contain strings.', $parameterName)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[] = $value; + } + + return array_values(array_unique($normalized)); + } + + private function normalizeNullableString(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : $value; + } + + /** + * @param list $paths + * + * @return list + */ + private function normalizePaths(array $paths): array + { + $normalized = []; + + foreach ($paths as $path) { + if (!\is_string($path)) { + throw new InvalidArgumentException('Protected resource metadata paths must be strings.'); + } + + $path = trim($path); + if ('' === $path) { + continue; + } + + if ('/' !== $path[0]) { + $path = '/'.$path; + } + + $normalized[] = $path; + } + + return array_values(array_unique($normalized)); + } + + /** + * @param array $localizedHumanReadable + * + * @return array + */ + private function normalizeLocalizedHumanReadable(array $localizedHumanReadable): array + { + $normalized = []; + + foreach ($localizedHumanReadable as $field => $value) { + if (!\is_string($field) || !preg_match(self::LOCALIZED_HUMAN_READABLE_FIELD_PATTERN, $field)) { + throw new InvalidArgumentException(\sprintf('Invalid localized human-readable field: "%s".', (string) $field)); + } + + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Localized human-readable value for "%s" must be a string.', $field)); + } + + $value = trim($value); + if ('' === $value) { + continue; + } + + $normalized[$field] = $value; + } + + return $normalized; + } +} diff --git a/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php new file mode 100644 index 00000000..b89a3f8a --- /dev/null +++ b/src/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicy.php @@ -0,0 +1,48 @@ + + */ +final class StrictOidcDiscoveryMetadataPolicy implements OidcDiscoveryMetadataPolicyInterface +{ + public function isValid(mixed $metadata): bool + { + if (!\is_array($metadata) + || !isset($metadata['authorization_endpoint'], $metadata['token_endpoint'], $metadata['jwks_uri']) + || !\is_string($metadata['authorization_endpoint']) + || '' === trim($metadata['authorization_endpoint']) + || !\is_string($metadata['token_endpoint']) + || '' === trim($metadata['token_endpoint']) + || !\is_string($metadata['jwks_uri']) + || '' === trim($metadata['jwks_uri']) + || !isset($metadata['code_challenge_methods_supported']) + ) { + return false; + } + + if (!\is_array($metadata['code_challenge_methods_supported']) || [] === $metadata['code_challenge_methods_supported']) { + return false; + } + + foreach ($metadata['code_challenge_methods_supported'] as $method) { + if (!\is_string($method) || '' === trim($method)) { + return false; + } + } + + return true; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php new file mode 100644 index 00000000..bd05af13 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/AuthorizationMiddlewareTest.php @@ -0,0 +1,284 @@ + + */ +class AuthorizationMiddlewareTest extends TestCase +{ + #[TestDox('missing Authorization header returns 401 with metadata and scope guidance')] + public function testMissingAuthorizationReturns401(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(401, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('Bearer', $header); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="mcp:read"', $header); + } + + #[TestDox('malformed Authorization header returns 400 with invalid_request')] + public function testMalformedAuthorizationReturns400(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Basic abc'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(400, $response->getStatusCode()); + $this->assertStringContainsString('error="invalid_request"', $response->getHeaderLine('WWW-Authenticate')); + } + + #[TestDox('insufficient scopes return 403 with scope challenge')] + public function testInsufficientScopeReturns403(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::forbidden('insufficient_scope', 'Need more scopes.', ['mcp:write']); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(403, $response->getStatusCode()); + $header = $response->getHeaderLine('WWW-Authenticate'); + $this->assertStringContainsString('error="insufficient_scope"', $header); + $this->assertStringContainsString('scope="mcp:write"', $header); + } + + #[TestDox('metadata scopes are used in challenge when result has no scopes')] + public function testMetadataScopesAreUsedWhenResultHasNoScopes(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + ); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); + + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); + } + + #[TestDox('resource metadata object path and scopes are reflected in challenge')] + public function testResourceMetadataObjectProvidesMetadataAndScopes(): void + { + $factory = new Psr17Factory(); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + throw new RuntimeException('Validator should not be called without a token.'); + } + }; + + $resourceMetadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + metadataPaths: ['/oauth/resource-meta'], + ); + + $middleware = new AuthorizationMiddleware( + validator: $validator, + responseFactory: $factory, + resourceMetadata: $resourceMetadata, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200); + } + }; + + $response = $middleware->process($request, $handler); + $header = $response->getHeaderLine('WWW-Authenticate'); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertStringContainsString( + 'resource_metadata="https://mcp.example.com/oauth/resource-meta"', + $header, + ); + $this->assertStringContainsString('scope="openid profile"', $header); + } + + #[TestDox('authorized requests reach the handler with attributes applied')] + public function testAllowedRequestPassesAttributes(): void + { + $factory = new Psr17Factory(); + $resourceMetadata = new ProtectedResourceMetadata(['https://auth.example.com']); + $validator = new class implements AuthorizationTokenValidatorInterface { + public function validate(string $accessToken): AuthorizationResult + { + return AuthorizationResult::allow(['subject' => 'user-1']); + } + }; + + $middleware = new AuthorizationMiddleware( + validator: $validator, + resourceMetadata: $resourceMetadata, + responseFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withHeader('Authorization', 'Bearer token'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(200) + ->withHeader('X-Subject', (string) $request->getAttribute('subject')); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('user-1', $response->getHeaderLine('X-Subject')); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php new file mode 100644 index 00000000..4ab36616 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthProxyMiddlewareTest.php @@ -0,0 +1,299 @@ + + */ +class OAuthProxyMiddlewareTest extends TestCase +{ + #[TestDox('metadata endpoint returns local oauth metadata with upstream capabilities')] + public function testMetadataEndpointReturnsLocalMetadata(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'response_types_supported' => ['code'], + 'grant_types_supported' => ['authorization_code', 'refresh_token'], + 'code_challenge_methods_supported' => ['S256'], + 'scopes_supported' => ['openid', 'profile'], + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/.well-known/oauth-authorization-server'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame('http://localhost:8000', $payload['issuer']); + $this->assertSame('http://localhost:8000/authorize', $payload['authorization_endpoint']); + $this->assertSame('http://localhost:8000/token', $payload['token_endpoint']); + $this->assertSame(['openid', 'profile'], $payload['scopes_supported']); + $this->assertSame('https://login.example.com/discovery/v2.0/keys', $payload['jwks_uri']); + } + + #[TestDox('authorize endpoint redirects to upstream authorization endpoint preserving query')] + public function testAuthorizeEndpointRedirectsToUpstream(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getAuthorizationEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/authorize'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'http://localhost:8000/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(302, $response->getStatusCode()); + $this->assertSame( + 'https://login.example.com/oauth2/v2.0/authorize?client_id=test-client&scope=openid%20profile&code_challenge=abc', + $response->getHeaderLine('Location'), + ); + } + + #[TestDox('token endpoint proxies request and injects client secret')] + public function testTokenEndpointProxiesRequestAndInjectsClientSecret(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_post'], + ]); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertSame('secret-value', $params['client_secret'] ?? null); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + + #[TestDox('token endpoint uses client_secret_basic when supported by upstream metadata')] + public function testTokenEndpointUsesClientSecretBasicWhenSupported(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getTokenEndpoint') + ->with('https://login.example.com/tenant') + ->willReturn('https://login.example.com/oauth2/v2.0/token'); + $discovery->expects($this->once()) + ->method('discover') + ->with('https://login.example.com/tenant') + ->willReturn([ + 'authorization_endpoint' => 'https://login.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://login.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://login.example.com/discovery/v2.0/keys', + 'token_endpoint_auth_methods_supported' => ['client_secret_basic'], + ]); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturnCallback(function (RequestInterface $request) use ($factory): ResponseInterface { + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://login.example.com/oauth2/v2.0/token', (string) $request->getUri()); + $this->assertSame('Basic ZGVtby1jbGllbnQ6c2VjcmV0LXZhbHVl', $request->getHeaderLine('Authorization')); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + + parse_str($request->getBody()->__toString(), $params); + $this->assertSame('authorization_code', $params['grant_type'] ?? null); + $this->assertSame('abc123', $params['code'] ?? null); + $this->assertArrayNotHasKey('client_secret', $params); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{"access_token":"token-1"}')); + }); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + clientSecret: 'secret-value', + discovery: $discovery, + httpClient: $httpClient, + requestFactory: $factory, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('POST', 'http://localhost:8000/token') + ->withHeader('Content-Type', 'application/x-www-form-urlencoded') + ->withBody($factory->createStream('grant_type=authorization_code&client_id=demo-client&code=abc123')); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $this->assertSame('{"access_token":"token-1"}', $response->getBody()->__toString()); + } + + #[TestDox('non oauth proxy requests are delegated to next middleware')] + public function testNonOAuthRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->never())->method('discover'); + + $middleware = new OAuthProxyMiddleware( + upstreamIssuer: 'https://login.example.com/tenant', + localBaseUrl: 'http://localhost:8000', + discovery: $discovery, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'http://localhost:8000/mcp'); + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php new file mode 100644 index 00000000..c66dfb4e --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/OAuthRequestMetaMiddlewareTest.php @@ -0,0 +1,179 @@ + + */ +class OAuthRequestMetaMiddlewareTest extends TestCase +{ + #[TestDox('oauth request attributes are copied to json-rpc params _meta')] + public function testInjectsOauthAttributesIntoSingleRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.claims', ['sub' => 'user-1']) + ->withAttribute('oauth.scopes', ['openid', 'profile']) + ->withAttribute('oauth.subject', 'user-1') + ->withAttribute('not_oauth', 'ignored'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame(['sub' => 'user-1'], $decoded['params']['_meta']['oauth']['oauth.claims']); + $this->assertSame(['openid', 'profile'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + $this->assertSame('user-1', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertArrayNotHasKey('not_oauth', $decoded['params']['_meta']['oauth']); + } + + #[TestDox('existing _meta is preserved and oauth keys are merged')] + public function testMergesWithExistingMeta(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/list', + 'params' => [ + '_meta' => [ + 'trace_id' => 'trace-1', + 'oauth' => [ + 'client_hint' => 'web', + 'oauth.subject' => 'spoofed', + ], + ], + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'trusted-user') + ->withAttribute('oauth.scopes', ['mcp.read']); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('trace-1', $decoded['params']['_meta']['trace_id']); + $this->assertSame('web', $decoded['params']['_meta']['oauth']['client_hint']); + $this->assertSame('trusted-user', $decoded['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame(['mcp.read'], $decoded['params']['_meta']['oauth']['oauth.scopes']); + } + + #[TestDox('oauth request attributes are copied for each batch entry')] + public function testInjectsOauthAttributesIntoBatchRequest(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $payload = [ + [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ], + [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], + ]; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR))) + ->withAttribute('oauth.subject', 'batch-user'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + $decoded = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('batch-user', $decoded[0]['params']['_meta']['oauth']['oauth.subject']); + $this->assertSame('batch-user', $decoded[1]['params']['_meta']['oauth']['oauth.subject']); + } + + #[TestDox('request without oauth attributes passes through unchanged')] + public function testNoOauthAttributesPassThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'; + + $request = $factory + ->createServerRequest('POST', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + #[TestDox('non post requests pass through unchanged')] + public function testNonPostPassesThrough(): void + { + $factory = new Psr17Factory(); + $middleware = new OAuthRequestMetaMiddleware($factory); + + $body = '{"jsonrpc":"2.0","id":1,"method":"ping"}'; + + $request = $factory + ->createServerRequest('GET', 'https://mcp.example.com/mcp') + ->withBody($factory->createStream($body)) + ->withAttribute('oauth.subject', 'user-1'); + + $response = $middleware->process($request, $this->createEchoHandler($factory)); + + $this->assertSame($body, $response->getBody()->__toString()); + } + + private function createEchoHandler(Psr17Factory $factory): RequestHandlerInterface + { + return new class($factory) implements RequestHandlerInterface { + public function __construct(private readonly Psr17Factory $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory + ->createResponse(200) + ->withBody($this->factory->createStream($request->getBody()->__toString())); + } + }; + } +} diff --git a/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php new file mode 100644 index 00000000..66e57e04 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/Middleware/ProtectedResourceMetadataMiddlewareTest.php @@ -0,0 +1,125 @@ + + */ +class ProtectedResourceMetadataMiddlewareTest extends TestCase +{ + #[TestDox('default metadata endpoint returns protected resource metadata JSON')] + public function testDefaultMetadataEndpointReturnsJson(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['mcp:read', 'mcp:write'], + resource: 'https://mcp.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://mcp.example.com/docs', + localizedHumanReadable: [ + 'resource_name#uk' => 'Pryklad MCP API', + ], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest( + 'GET', + 'https://mcp.example.com/.well-known/oauth-protected-resource', + ); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(404); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + + $payload = json_decode($response->getBody()->__toString(), true, 512, \JSON_THROW_ON_ERROR); + $this->assertSame(['https://auth.example.com'], $payload['authorization_servers']); + $this->assertSame(['mcp:read', 'mcp:write'], $payload['scopes_supported']); + $this->assertSame('https://mcp.example.com/mcp', $payload['resource']); + $this->assertSame('Example MCP API', $payload['resource_name']); + $this->assertSame('https://mcp.example.com/docs', $payload['resource_documentation']); + $this->assertSame('Pryklad MCP API', $payload['resource_name#uk']); + } + + #[TestDox('non metadata request passes to next middleware')] + public function testNonMetadataRequestPassesThrough(): void + { + $factory = new Psr17Factory(); + + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + ); + + $middleware = new ProtectedResourceMetadataMiddleware( + metadata: $metadata, + responseFactory: $factory, + streamFactory: $factory, + ); + + $request = $factory->createServerRequest('GET', 'https://mcp.example.com/mcp'); + + $handler = new class($factory) implements RequestHandlerInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->factory->createResponse(204); + } + }; + + $response = $middleware->process($request, $handler); + + $this->assertSame(204, $response->getStatusCode()); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php new file mode 100644 index 00000000..78c1572e --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/JwksProviderTest.php @@ -0,0 +1,168 @@ + + */ +class JwksProviderTest extends TestCase +{ + #[TestDox('JWKS are loaded from explicit URI')] + public function testGetJwksFromExplicitUri(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('invalid cached JWKS are ignored and replaced by fetched values')] + public function testInvalidCachedJwksAreIgnored(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn(['keys' => []]); + $cache->expects($this->once()) + ->method('set'); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $result = $provider->getJwks('https://auth.example.com', $jwksUri); + + $this->assertSame($jwks, $result); + } + + #[TestDox('discovery is used when explicit JWKS URI is not provided')] + public function testDiscoveryIsUsedWhenUriIsMissing(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + $jwks = [ + 'keys' => [ + ['kty' => 'RSA', 'kid' => 'kid-1', 'n' => 'abc', 'e' => 'AQAB'], + ], + ]; + + $discovery = $this->createMock(OidcDiscoveryInterface::class); + $discovery->expects($this->once()) + ->method('getJwksUri') + ->with('https://auth.example.com') + ->willReturn($jwksUri); + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($jwks, \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + httpClient: $httpClient, + requestFactory: $factory, + discovery: $discovery, + ); + + $result = $provider->getJwks('https://auth.example.com'); + + $this->assertSame($jwks, $result); + } + + #[TestDox('empty keys in fetched JWKS throw RuntimeException')] + public function testEmptyKeysThrow(): void + { + $factory = new Psr17Factory(); + $jwksUri = 'https://auth.example.com/jwks'; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->once()) + ->method('sendRequest') + ->willReturn( + $factory->createResponse(200)->withBody( + $factory->createStream(json_encode(['keys' => []], \JSON_THROW_ON_ERROR)), + ), + ); + + $provider = new JwksProvider( + discovery: $this->createDiscoveryStub(), + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('expected non-empty "keys" array'); + + $provider->getJwks('https://auth.example.com', $jwksUri); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php new file mode 100644 index 00000000..493a1520 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/JwtTokenValidatorTest.php @@ -0,0 +1,598 @@ + + */ +class JwtTokenValidatorTest extends TestCase +{ + #[TestDox('valid JWT is allowed and claims/scopes are exposed as request attributes')] + public function testValidJwtAllowsAndExposesAttributes(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'client_id' => 'client-abc', + 'azp' => 'client-abc', + 'scope' => 'mcp:read mcp:write', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertTrue($result->isAllowed()); + $attributes = $result->getAttributes(); + + $this->assertArrayHasKey('oauth.claims', $attributes); + $this->assertArrayHasKey('oauth.scopes', $attributes); + $this->assertSame(['mcp:read', 'mcp:write'], $attributes['oauth.scopes']); + $this->assertSame('user-123', $attributes['oauth.subject']); + $this->assertSame('client-abc', $attributes['oauth.client_id']); + $this->assertSame('client-abc', $attributes['oauth.authorized_party']); + } + + #[TestDox('issuer mismatch yields unauthorized result')] + public function testIssuerMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://other-issuer.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token issuer mismatch.', $result->getErrorDescription()); + } + + #[TestDox('audience mismatch yields unauthorized result')] + public function testAudienceMismatchIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksUri = 'https://auth.example.com/.well-known/jwks.json'; + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: ['mcp-api'], + jwksUri: $jwksUri, + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'different-aud', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token audience mismatch.', $result->getErrorDescription()); + } + + #[TestDox('expired token yields unauthorized invalid_token with expired message')] + public function testExpiredTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 7200, + 'exp' => time() - 10, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token has expired.', $result->getErrorDescription()); + } + + #[TestDox('token with future nbf yields unauthorized invalid_token with not-yet-valid message')] + public function testBeforeValidTokenIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time(), + 'nbf' => time() + 3600, + 'exp' => time() + 7200, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token is not yet valid.', $result->getErrorDescription()); + } + + #[TestDox('signature verification failure yields unauthorized invalid_token with signature message')] + public function testSignatureInvalidIsUnauthorized(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + // Create a mismatched JWK with the same kid so the key lookup succeeds but signature verification fails. + [, $mismatchedJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + $mismatchedJwk['kid'] = $publicJwk['kid']; + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$mismatchedJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame(401, $result->getStatusCode()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token signature verification failed.', $result->getErrorDescription()); + } + + #[TestDox('JWKS HTTP error results in unauthorized token validation error')] + public function testJwksHttpErrorResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $this->createHttpClientMock([$factory->createResponse(500)]), requestFactory: $factory), + ); + + // Unsigned token forces the validator to load JWKS and fail on HTTP 500. + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('Invalid JWKS JSON results in unauthorized token validation error')] + public function testInvalidJwksJsonResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream('{not-json')), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('JWKS without keys array results in unauthorized token validation error')] + public function testJwksMissingKeysResultsInUnauthorized(): void + { + $factory = new Psr17Factory(); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['nope' => []], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = $this->unsignedJwt(['iss' => 'https://auth.example.com', 'aud' => 'mcp-api']); + + $result = $validator->validate($token); + + $this->assertFalse($result->isAllowed()); + $this->assertSame('invalid_token', $result->getError()); + $this->assertSame('Token validation error.', $result->getErrorDescription()); + } + + #[TestDox('requireScopes returns forbidden when any required scope is missing')] + public function testRequireScopesForbiddenWhenMissing(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 'mcp:read', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read', 'mcp:write']); + $this->assertFalse($scoped->isAllowed()); + $this->assertSame(403, $scoped->getStatusCode()); + $this->assertSame('insufficient_scope', $scoped->getError()); + $this->assertSame(['mcp:read', 'mcp:write'], $scoped->getScopes()); + } + + #[TestDox('requireScopes passes through when all required scopes are present')] + public function testRequireScopesPassesWhenPresent(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $httpClient = $this->createHttpClientMock([ + $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))), + ]); + + $validator = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $token = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => ['mcp:read', 'mcp:write'], + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $result = $validator->validate($token); + $this->assertTrue($result->isAllowed()); + + $scoped = $validator->requireScopes($result, ['mcp:read']); + $this->assertTrue($scoped->isAllowed()); + } + + #[TestDox('extractScopes returns empty array when scope claim is missing or invalid type')] + public function testExtractScopesEdgeCases(): void + { + $factory = new Psr17Factory(); + [$privateKeyPem, $publicJwk] = $this->generateRsaKeypairAsJwk('test-kid'); + + $jwksResponse = $factory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($factory->createStream(json_encode(['keys' => [$publicJwk]], \JSON_THROW_ON_ERROR))); + + $httpClient = $this->createHttpClientMock([$jwksResponse]); + + // missing scope + $validatorMissing = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient, requestFactory: $factory), + ); + + $tokenMissing = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultMissing = $validatorMissing->validate($tokenMissing); + $this->assertTrue($resultMissing->isAllowed()); + $this->assertSame([], $resultMissing->getAttributes()['oauth.scopes']); + + // invalid scope type + $httpClient2 = $this->createHttpClientMock([$jwksResponse]); + + $validatorInvalid = new JwtTokenValidator( + issuer: 'https://auth.example.com', + audience: 'mcp-api', + jwksUri: 'https://auth.example.com/jwks', + jwksProvider: new JwksProvider(discovery: $this->createDiscoveryStub(), httpClient: $httpClient2, requestFactory: $factory), + ); + + $tokenInvalid = JWT::encode( + [ + 'iss' => 'https://auth.example.com', + 'aud' => 'mcp-api', + 'sub' => 'user-123', + 'scope' => 123, + 'iat' => time() - 10, + 'exp' => time() + 600, + ], + $privateKeyPem, + 'RS256', + keyId: 'test-kid', + ); + + $resultInvalid = $validatorInvalid->validate($tokenInvalid); + $this->assertTrue($resultInvalid->isAllowed()); + $this->assertSame([], $resultInvalid->getAttributes()['oauth.scopes']); + } + + private function unsignedJwt(array $claims): string + { + $header = $this->b64urlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT'], \JSON_THROW_ON_ERROR)); + $payload = $this->b64urlEncode(json_encode($claims, \JSON_THROW_ON_ERROR)); + + return $header.'.'.$payload.'.'; + } + + /** + * @return array{0: string, 1: array} + */ + private function generateRsaKeypairAsJwk(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 2048, + ]); + + if (false === $key) { + $this->fail('Failed to generate RSA keypair via OpenSSL.'); + } + + $privateKeyPem = ''; + if (!openssl_pkey_export($key, $privateKeyPem)) { + $this->fail('Failed to export RSA private key.'); + } + + $details = openssl_pkey_get_details($key); + if (false === $details || !isset($details['rsa']['n'], $details['rsa']['e'])) { + $this->fail('Failed to read RSA key details.'); + } + + $n = $this->b64urlEncode($details['rsa']['n']); + $e = $this->b64urlEncode($details['rsa']['e']); + + $publicJwk = [ + 'kty' => 'RSA', + 'kid' => $kid, + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $n, + 'e' => $e, + ]; + + return [$privateKeyPem, $publicJwk]; + } + + private function b64urlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function createDiscoveryStub(): OidcDiscoveryInterface + { + return $this->createStub(OidcDiscoveryInterface::class); + } + + /** + * @param list $responses + */ + private function createHttpClientMock(array $responses, ?int $expectedCalls = null): ClientInterface + { + $expectedCalls ??= \count($responses); + + $httpClient = $this->createMock(ClientInterface::class); + $expectation = $httpClient + ->expects($this->exactly($expectedCalls)) + ->method('sendRequest') + ->with($this->isInstanceOf(RequestInterface::class)); + + if (1 === $expectedCalls) { + $expectation->willReturn($responses[0]); + } else { + // If expectedCalls > count(responses), keep returning the last response. + $sequence = $responses; + while (\count($sequence) < $expectedCalls) { + $sequence[] = $responses[array_key_last($responses)]; + } + $expectation->willReturnOnConsecutiveCalls(...$sequence); + } + + return $httpClient; + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php new file mode 100644 index 00000000..14eb54f1 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/OidcDiscoveryTest.php @@ -0,0 +1,268 @@ + + */ +class OidcDiscoveryTest extends TestCase +{ + #[TestDox('invalid issuer URL throws RuntimeException')] + public function testInvalidIssuerUrlThrows(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $discovery = new OidcDiscovery( + httpClient: $this->createMock(ClientInterface::class), + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid issuer URL'); + $discovery->discover('invalid-issuer'); + } + + #[TestDox('strict discovery rejects metadata without code challenge methods')] + public function testDiscoverRejectsMetadataWithoutCodeChallengeMethodsSupported(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $issuer = 'https://auth.example.com'; + $metadataWithoutCodeChallengeMethods = [ + 'issuer' => $issuer, + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturn($factory->createResponse(200)->withBody( + $factory->createStream(json_encode($metadataWithoutCodeChallengeMethods, \JSON_THROW_ON_ERROR)), + )); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to discover authorization server metadata'); + $discovery->discover($issuer); + } + + #[TestDox('discover falls back to the next metadata URL when first response is invalid')] + public function testDiscoverFallsBackOnInvalidMetadataResponse(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $invalidMetadata = [ + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + // token_endpoint is intentionally missing + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $invalidMetadata, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $invalidMetadata : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['authorization_endpoint'], $metadata['authorization_endpoint']); + $this->assertSame($validMetadata['token_endpoint'], $metadata['token_endpoint']); + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('valid metadata from cache is returned without HTTP call')] + public function testDiscoverUsesValidCacheWithoutHttpCall(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $cachedMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->never())->method('sendRequest'); + + $cache = $this->createMock(CacheInterface::class); + $cache->expects($this->once()) + ->method('get') + ->willReturn($cachedMetadata); + $cache->expects($this->never())->method('set'); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + cache: $cache, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($cachedMetadata, $metadata); + } + + #[TestDox('discover skips metadata when issuer claim does not match requested issuer')] + public function testDiscoverSkipsIssuerMismatch(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + + $issuerMismatch = [ + 'issuer' => 'https://auth.example.com/other-tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + $validMetadata = [ + 'issuer' => 'https://auth.example.com/tenant', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $issuerMismatch, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + $payload = 1 === \count($requestedUrls) ? $issuerMismatch : $validMetadata; + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($payload, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com/tenant'); + + $this->assertSame($validMetadata['issuer'], $metadata['issuer']); + $this->assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server/tenant', + $requestedUrls[0], + ); + $this->assertSame( + 'https://auth.example.com/.well-known/openid-configuration/tenant', + $requestedUrls[1], + ); + } + + #[TestDox('issuer without path uses standard well-known endpoints')] + public function testIssuerWithoutPathUsesStandardWellKnownEndpoints(): void + { + $this->skipIfPsrHttpClientIsMissing(); + + $factory = new Psr17Factory(); + $requestedUrls = []; + $validMetadata = [ + 'issuer' => 'https://auth.example.com', + 'authorization_endpoint' => 'https://auth.example.com/oauth2/v2.0/authorize', + 'token_endpoint' => 'https://auth.example.com/oauth2/v2.0/token', + 'jwks_uri' => 'https://auth.example.com/discovery/v2.0/keys', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->expects($this->exactly(2)) + ->method('sendRequest') + ->willReturnCallback(static function (RequestInterface $request) use ($factory, &$requestedUrls, $validMetadata): ResponseInterface { + $requestedUrls[] = (string) $request->getUri(); + + if (1 === \count($requestedUrls)) { + return $factory->createResponse(404); + } + + return $factory->createResponse(200)->withBody( + $factory->createStream(json_encode($validMetadata, \JSON_THROW_ON_ERROR)), + ); + }); + + $discovery = new OidcDiscovery( + httpClient: $httpClient, + requestFactory: $factory, + ); + + $metadata = $discovery->discover('https://auth.example.com'); + + $this->assertSame($validMetadata['jwks_uri'], $metadata['jwks_uri']); + $this->assertSame('https://auth.example.com/.well-known/oauth-authorization-server', $requestedUrls[0]); + $this->assertSame('https://auth.example.com/.well-known/openid-configuration', $requestedUrls[1]); + } + + private function skipIfPsrHttpClientIsMissing(): void + { + if (!interface_exists(ClientInterface::class)) { + $this->markTestSkipped('psr/http-client is not available in this runtime.'); + } + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php new file mode 100644 index 00000000..fbb51a91 --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/ProtectedResourceMetadataTest.php @@ -0,0 +1,86 @@ + + */ +class ProtectedResourceMetadataTest extends TestCase +{ + #[TestDox('serializes RFC 9728 metadata including human-readable fields')] + public function testJsonSerializeIncludesHumanReadableFields(): void + { + $metadata = new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + scopesSupported: ['openid', 'profile'], + resource: 'https://api.example.com/mcp', + resourceName: 'Example MCP API', + resourceDocumentation: 'https://api.example.com/docs', + resourcePolicyUri: 'https://api.example.com/policy', + resourceTosUri: 'https://api.example.com/tos', + localizedHumanReadable: [ + 'resource_name#en' => 'Example MCP API', + ], + extra: [ + 'bearer_methods_supported' => ['header'], + ], + metadataPaths: ['.well-known/oauth-protected-resource'], + ); + + $this->assertSame( + [ + 'bearer_methods_supported' => ['header'], + 'authorization_servers' => ['https://auth.example.com'], + 'scopes_supported' => ['openid', 'profile'], + 'resource' => 'https://api.example.com/mcp', + 'resource_name' => 'Example MCP API', + 'resource_documentation' => 'https://api.example.com/docs', + 'resource_policy_uri' => 'https://api.example.com/policy', + 'resource_tos_uri' => 'https://api.example.com/tos', + 'resource_name#en' => 'Example MCP API', + ], + $metadata->jsonSerialize(), + ); + $this->assertSame('/.well-known/oauth-protected-resource', $metadata->getPrimaryMetadataPath()); + $this->assertSame(['openid', 'profile'], $metadata->getScopesSupported()); + } + + #[TestDox('invalid localized human-readable field is rejected')] + public function testInvalidLocalizedHumanReadableFieldThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid localized human-readable field'); + + new ProtectedResourceMetadata( + authorizationServers: ['https://auth.example.com'], + localizedHumanReadable: [ + 'invalid#en' => 'value', + ], + ); + } + + #[TestDox('empty authorization servers are rejected')] + public function testEmptyAuthorizationServersThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one authorization server'); + + new ProtectedResourceMetadata([]); + } +} diff --git a/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php new file mode 100644 index 00000000..3d1e8a7d --- /dev/null +++ b/tests/Unit/Server/Transport/Http/OAuth/StrictOidcDiscoveryMetadataPolicyTest.php @@ -0,0 +1,79 @@ + + */ +class StrictOidcDiscoveryMetadataPolicyTest extends TestCase +{ + #[TestDox('metadata without code challenge methods is invalid in strict mode')] + public function testMissingCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('valid code challenge methods list is accepted in strict mode')] + public function testValidCodeChallengeMethodsIsAccepted(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256'], + ]; + + $this->assertTrue($policy->isValid($metadata)); + } + + #[TestDox('empty code challenge methods list is invalid in strict mode')] + public function testEmptyCodeChallengeMethodsIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => [], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } + + #[TestDox('non string code challenge method is invalid in strict mode')] + public function testNonStringCodeChallengeMethodIsInvalid(): void + { + $policy = new StrictOidcDiscoveryMetadataPolicy(); + $metadata = [ + 'authorization_endpoint' => 'https://auth.example.com/authorize', + 'token_endpoint' => 'https://auth.example.com/token', + 'jwks_uri' => 'https://auth.example.com/jwks', + 'code_challenge_methods_supported' => ['S256', 123], + ]; + + $this->assertFalse($policy->isValid($metadata)); + } +}