# Directory Structure ``` ├── .github │ ├── CODE_OF_CONDUCT.md │ ├── ISSUE_TEMPLATE.md │ └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── azure.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── infra │ ├── abbreviations.json │ ├── app │ │ ├── api.bicep │ │ ├── apim-mcp │ │ │ ├── mcp-api.bicep │ │ │ └── mcp-api.policy.xml │ │ ├── apim-oauth │ │ │ ├── authorize.policy.xml │ │ │ ├── consent.policy.xml │ │ │ ├── diagrams │ │ │ │ ├── diagrams.md │ │ │ │ ├── images │ │ │ │ │ └── mcp-client-auth.png │ │ │ │ └── mcp_client_auth.mmd │ │ │ ├── entra-app.bicep │ │ │ ├── oauth-callback.policy.xml │ │ │ ├── oauth.bicep │ │ │ ├── oauthmetadata-get.policy.xml │ │ │ ├── oauthmetadata-options.policy.xml │ │ │ ├── register-options.policy.xml │ │ │ ├── register.policy.xml │ │ │ └── token.policy.xml │ │ ├── storage-Access.bicep │ │ ├── storage-PrivateEndpoint.bicep │ │ └── vnet.bicep │ ├── bicepconfig.json │ ├── core │ │ ├── apim │ │ │ └── apim.bicep │ │ ├── database │ │ │ ├── cosmosdb-rbac.bicep │ │ │ └── cosmosdb.bicep │ │ ├── host │ │ │ ├── appserviceplan.bicep │ │ │ └── functions-flexconsumption.bicep │ │ ├── identity │ │ │ └── userAssignedIdentity.bicep │ │ ├── monitor │ │ │ ├── appinsights-access.bicep │ │ │ ├── applicationinsights.bicep │ │ │ ├── loganalytics.bicep │ │ │ └── monitoring.bicep │ │ └── storage │ │ └── storage-account.bicep │ ├── main.bicep │ └── main.parameters.json ├── LICENSE.md ├── mcp-client-authorization.gif ├── overview.png ├── pyproject.toml ├── README.md └── src ├── .funcignore ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── function_app.py ├── host.json ├── local.settings.json └── requirements.txt ``` # Files -------------------------------------------------------------------------------- /src/.funcignore: -------------------------------------------------------------------------------- ``` .venv ``` -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- ``` bin obj csx .vs edge Publish *.user *.suo *.cscfg *.Cache project.lock.json /packages /TestResults /tools/NuGet.exe /App_Data /secrets /data .secrets appsettings.json node_modules dist # Local python packages .python_packages/ # Python Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Azurite artifacts __blobstorage__ __queuestorage__ __azurite_db*__.json ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # Ruff stuff: .ruff_cache/ # PyPI configuration file .pypirc ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown <!-- --- name: Remote MCP using Azure API Management description: Use Azure API Management as the AI Gateway for MCP Servers using Azure Functions page_type: sample languages: - python - bicep - azdeveloper products: - azure-api-management - azure-functions - azure urlFragment: remote-mcp-apim-functions-python --- --> # Secure Remote MCP Servers using Azure API Management (Experimental)  Azure API Management acts as the [AI Gateway](https://github.com/Azure-Samples/AI-Gateway) for MCP servers. This sample implements the latest [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-10-third-party-authorization-flow) This is a [sequence diagram](infra/app/apim-oauth/diagrams/diagrams.md) to understand the flow. ## Deploy Remote MCP Server to Azure 1. Register `Microsoft.App` resource provider. * If you are using Azure CLI, run `az provider register --namespace Microsoft.App --wait`. * If you are using Azure PowerShell, run `Register-AzResourceProvider -ProviderNamespace Microsoft.App`. Then run `(Get-AzResourceProvider -ProviderNamespace Microsoft.App).RegistrationState` after some time to check if the registration is complete. 2. Run this [azd](https://aka.ms/azd) command to provision the api management service, function app(with code) and all other required Azure resources ```shell azd up ``` ### Test with MCP Inspector 1. In a **new terminal window**, install and run MCP Inspector ```shell npx @modelcontextprotocol/inspector ``` 1. CTRL click to load the MCP Inspector web app from the URL displayed by the app (e.g. http://127.0.0.1:6274/#resources) 1. Set the transport type to `SSE` 1. Set the URL to your running API Management SSE endpoint displayed after `azd up` and **Connect**: ```shell https://<apim-servicename-from-azd-output>.azure-api.net/mcp/sse ``` 5. **List Tools**. Click on a tool and **Run Tool**. ## Technical Architecture Overview This solution deploys a secure MCP (Model Context Protocol) server infrastructure on Azure. The architecture implements a multi-layered security model with Azure API Management serving as an intelligent gateway that handles authentication, authorization, and request routing.  ### Deployed Azure Resources The infrastructure provisions the following Azure resources: #### Core Gateway Infrastructure - **Azure API Management (APIM)** - The central security gateway that exposes both OAuth and MCP APIs - **SKU**: BasicV2 (configurable) - **Identity**: System-assigned and user-assigned managed identities - **Purpose**: Handles authentication flows, request validation, and secure proxying to backend services #### Backend Compute - **Azure Function App** - Hosts the MCP server implementation - **Runtime**: Python 3.11 on Flex Consumption plan - **Authentication**: Function-level authentication with managed identity integration - **Purpose**: Executes MCP tools and operations (snippet management in this example) #### Storage and Data - **Azure Storage Account** - Provides multiple storage functions - **Function hosting**: Stores function app deployment packages - **Application data**: Blob container for snippet storage - **Security**: Configured with managed identity access and optional private endpoints #### Security and Identity - **User-Assigned Managed Identity** - Enables secure service-to-service authentication - **Purpose**: Allows Function App to access Storage and Application Insights without secrets - **Permissions**: Storage Blob Data Owner, Storage Queue Data Contributor, Monitoring Metrics Publisher - **Entra ID Application Registration** - OAuth2/OpenID Connect client for authentication - **Purpose**: Enables third-party authorization flow per MCP specification - **Configuration**: PKCE-enabled public client with custom redirect URIs #### Monitoring and Observability - **Application Insights** - Provides telemetry and monitoring - **Log Analytics Workspace** - Centralized logging and analytics #### Optional Network Security - **Virtual Network (VNet)** - When `vnetEnabled` is true - **Private Endpoints**: Secure connectivity to Storage Account - **Network Isolation**: Functions and storage communicate over private network ### Why These Resources? **Azure API Management** serves as the security perimeter, implementing: - OAuth 2.0/PKCE authentication flows per MCP specification - Session key encryption/decryption for secure API access - Request validation and header injection - Rate limiting and throttling capabilities - Centralized policy management **Azure Functions** provides: - Serverless, pay-per-use compute model - Native integration with Azure services - Automatic scaling based on demand - Built-in monitoring and diagnostics **Managed Identities** eliminate the need for: - Service credentials management - Secret rotation processes - Credential exposure risks ## Azure API Management Configuration Details The APIM instance is configured with two primary APIs that work together to implement the MCP authorization specification: ### OAuth API (`/oauth/*`) This API implements the complete OAuth 2.0 authorization server functionality required by the MCP specification: #### Endpoints and Operations **Authorization Endpoint** (`GET /authorize`) - **Purpose**: Initiates the OAuth 2.0/PKCE flow - **Policy Logic**: 1. Extracts PKCE parameters from MCP client request 2. Checks for existing user consent (via cookies) 3. Redirects to consent page if consent not granted 4. Generates new PKCE parameters for Entra ID communication 5. Stores authentication state in APIM cache 6. Redirects user to Entra ID for authentication **Consent Management** (`GET/POST /consent`) - **Purpose**: Handles user consent for MCP client access - **Features**: Consent persistence via secure cookies **OAuth Metadata Endpoint** (`GET /.well-known/oauth-authorization-server`) - **Purpose**: Publishes OAuth server configuration per RFC 8414 - **Returns**: JSON metadata about supported endpoints, flows, and capabilities **Client Registration** (`POST /register`) - **Purpose**: Supports dynamic client registration per MCP specification **Token Endpoint** (`POST /token`) - **Purpose**: Exchanges authorization codes for access tokens - **Policy Logic**: 1. Validates authorization code and PKCE verifier from MCP client 2. Exchanges Entra ID authorization code for access tokens 3. Generates encrypted session key for MCP API access 4. Caches the access token with session key mapping 5. Returns encrypted session key to MCP client #### Named Values and Configuration The OAuth API uses several APIM Named Values for configuration: - `McpClientId` - The registered Entra ID application client ID - `EntraIDFicClientId` - Service identity client ID for token exchange - `APIMGatewayURL` - Base URL for callback and metadata endpoints - `OAuthScopes` - Requested OAuth scopes (`openid` + Microsoft Graph) - `EncryptionKey` / `EncryptionIV` - For session key encryption ### MCP API (`/mcp/*`) This API provides the actual MCP protocol endpoints with security enforcement: #### Endpoints and Operations **Server-Sent Events Endpoint** (`GET /sse`) - **Purpose**: Establishes real-time communication channel for MCP protocol - **Security**: Requires valid encrypted session token **Message Endpoint** (`POST /message`) - **Purpose**: Handles MCP protocol messages and tool invocations - **Security**: Requires valid encrypted session token #### Security Policy Implementation The MCP API applies a comprehensive security policy to all operations: 1. **Authorization Header Validation** ```xml <check-header name="Authorization" failed-check-httpcode="401" failed-check-error-message="Not authorized" ignore-case="false" /> ``` 2. **Session Key Decryption** - Extracts encrypted session key from Authorization header - Decrypts using AES with stored key and IV - Validates token format and structure 3. **Token Cache Lookup** ```xml <cache-lookup-value key="@($"EntraToken-{context.Variables.GetValueOrDefault("decryptedSessionKey")}")" variable-name="accessToken" /> ``` 4. **Access Token Validation** - Verifies cached access token exists and is valid - Returns 401 with proper WWW-Authenticate header if invalid 5. **Backend Authentication** ```xml <set-header name="x-functions-key" exists-action="override"> <value>{{function-host-key}}</value> </set-header> ``` ### Security Model The solution implements a sophisticated multi-layer security model: **Layer 1: OAuth 2.0/PKCE Authentication** - MCP clients must complete full OAuth flow with Entra ID - PKCE prevents authorization code interception attacks - User consent management with persistent preferences **Layer 2: Session Key Encryption** - Access tokens are never exposed to MCP clients - Encrypted session keys provide time-bounded access - AES encryption with secure key management in APIM **Layer 3: Function-Level Security** - Function host keys protect direct access to Azure Functions - Managed identity ensures secure service-to-service communication - Network isolation available via VNet integration **Layer 4: Azure Platform Security** - All traffic encrypted in transit (TLS) - Storage access via managed identities - Audit logging through Application Insights This layered approach ensures that even if one security boundary is compromised, multiple additional protections remain in place. ``` -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- ```markdown # Microsoft Open Source Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). Resources: - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) - Contact [[email protected]](mailto:[email protected]) with questions or concerns ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- ```markdown MIT License Copyright (c) Microsoft Corporation. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown # Contributing to [project-title] This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [[email protected]](mailto:[email protected]) with any additional questions or comments. - [Code of Conduct](#coc) - [Issues and Bugs](#issue) - [Feature Requests](#feature) - [Submission Guidelines](#submit) ## <a name="coc"></a> Code of Conduct Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). ## <a name="issue"></a> Found an Issue? If you find a bug in the source code or a mistake in the documentation, you can help us by [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can [submit a Pull Request](#submit-pr) with a fix. ## <a name="feature"></a> Want a Feature? You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub Repository. If you would like to *implement* a new feature, please submit an issue with a proposal for your work first, to be sure that we can use it. * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). ## <a name="submit"></a> Submission Guidelines ### <a name="submit-issue"></a> Submitting an Issue Before you submit an issue, search the archive, maybe your question was already answered. If your issue appears to be a bug, and hasn't been reported, open a new issue. Help us to maximize the effort we can spend fixing issues and adding new features, by not reporting duplicate issues. Providing the following information will increase the chances of your issue being dealt with quickly: * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps * **Version** - what version is affected (e.g. 0.1.2) * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you * **Browsers and Operating System** - is this a problem with all browsers? * **Reproduce the Error** - provide a live example or a unambiguous set of steps * **Related Issues** - has a similar issue been reported before? * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be causing the problem (line of code or commit) You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. ### <a name="submit-pr"></a> Submitting a Pull Request (PR) Before you submit your Pull Request (PR) consider the following guidelines: * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR that relates to your submission. You don't want to duplicate effort. * Make your changes in a new git fork: * Commit your changes using a descriptive commit message * Push your fork to GitHub: * In GitHub, create a pull request * If we suggest changes then: * Make the required updates. * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): ```shell git rebase master -i git push -f ``` That's it! Thank you for your contribution! ``` -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- ```json { "recommendations": [ "ms-azuretools.vscode-azurefunctions", "ms-python.python" ] } ``` -------------------------------------------------------------------------------- /src/.vscode/extensions.json: -------------------------------------------------------------------------------- ```json { "recommendations": [ "ms-azuretools.vscode-azurefunctions", "ms-python.python" ] } ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/diagrams/diagrams.md: -------------------------------------------------------------------------------- ```markdown # Sequence Diagrams ## MCP Client Auth Flow  ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [tool.ruff] line-length = 120 target-version = "py311" lint.select = ["E", "F", "I", "UP", "A"] lint.ignore = ["D203"] ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown ## [project-title] Changelog <a name="x.y.z"></a> # x.y.z (yyyy-mm-dd) *Features* * ... *Bug Fixes* * ... *Breaking Changes* * ... ``` -------------------------------------------------------------------------------- /src/local.settings.json: -------------------------------------------------------------------------------- ```json { "IsEncrypted": false, "Values": { "FUNCTIONS_WORKER_RUNTIME": "python", "AzureWebJobsStorage": "UseDevelopmentStorage=true" } } ``` -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- ``` # Do not include azure-functions-worker in this file # The Python Worker is managed by the Azure Functions platform # Manually managing azure-functions-worker may cause unexpected issues azure-functions ``` -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- ```json { "version": "2.0.0", "tasks": [ { "type": "func", "label": "func: host start", "command": "host start", "problemMatcher": "$func-python-watch", "isBackground": true, "options": { "cwd": "${workspaceFolder}/src" } } ] } ``` -------------------------------------------------------------------------------- /src/.vscode/launch.json: -------------------------------------------------------------------------------- ```json { "version": "0.2.0", "configurations": [ { "name": "Attach to Python Functions", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 9091 }, "preLaunchTask": "func: host start" } ] } ``` -------------------------------------------------------------------------------- /infra/bicepconfig.json: -------------------------------------------------------------------------------- ```json { "experimentalFeaturesEnabled": { "extensibility": true }, // specify an alias for the version of the v1.0 dynamic types package you want to use "extensions": { "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.2.0-preview" } } ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json { "azureFunctions.deploySubpath": "src", "azureFunctions.scmDoBuildDuringDeployment": true, "azureFunctions.projectLanguage": "Python", "azureFunctions.projectRuntime": "~4", "debug.internalConsoleOptions": "neverOpen", "azureFunctions.projectLanguageModel": 2 } ``` -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- ```yaml # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json name: remote-mcp-apim-functions-python metadata: template: [email protected] services: api: project: ./src/ language: python host: function ``` -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- ```json { "version": "2.0", "logging": { "applicationInsights": { "samplingSettings": { "isEnabled": true, "excludedTypes": "Request" } } }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", "version": "[4.*, 5.0.0)" } } ``` -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- ```json { "version": "0.2.0", "configurations": [ { "name": "Attach to Python Functions", "type": "debugpy", "request": "attach", "connect": { "host": "localhost", "port": 9091 }, "preLaunchTask": "func: host start" } ] } ``` -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- ```json { "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", "contentVersion": "1.0.0.0", "parameters": { "environmentName": { "value": "${AZURE_ENV_NAME}" }, "location": { "value": "${AZURE_LOCATION}" }, "vnetEnabled": { "value": "${VNET_ENABLED=true}" }, "apimSku": { "value": "Basicv2" } } } ``` -------------------------------------------------------------------------------- /src/.vscode/settings.json: -------------------------------------------------------------------------------- ```json { "files.exclude": { "obj": true, "bin": true }, "azureFunctions.deploySubpath": ".", "azureFunctions.scmDoBuildDuringDeployment": true, "azureFunctions.pythonVenv": ".venv", "azureFunctions.projectLanguage": "Python", "azureFunctions.projectRuntime": "~4", "debug.internalConsoleOptions": "neverOpen", "azureFunctions.projectLanguageModel": 2, "azureFunctions.preDeployTask": "func: extensions install" } ``` -------------------------------------------------------------------------------- /src/.vscode/tasks.json: -------------------------------------------------------------------------------- ```json { "version": "2.0.0", "tasks": [ { "label": "pip install (functions)", "type": "shell", "osx": { "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" }, "windows": { "command": "${config:azureFunctions.pythonVenv}\\Scripts\\python -m pip install -r requirements.txt" }, "linux": { "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" }, "problemMatcher": [] }, { "type": "func", "label": "func: host start", "command": "host start", "problemMatcher": "$func-python-watch", "isBackground": true, "dependsOn": "func: extensions install" }, { "type": "func", "command": "extensions install", "dependsOn": "pip install (functions)", "problemMatcher": [] } ] } ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- ```markdown <!-- IF SUFFICIENT INFORMATION IS NOT PROVIDED VIA THE FOLLOWING TEMPLATE THE ISSUE MIGHT BE CLOSED WITHOUT FURTHER CONSIDERATION OR INVESTIGATION --> > Please provide us with the following information: > --------------------------------------------------------------- ### This issue is for a: (mark with an `x`) ``` - [ ] bug report -> please search issues before submitting - [ ] feature request - [ ] documentation issue or request - [ ] regression (a behavior that used to work and stopped in a new release) ``` ### Minimal steps to reproduce > ### Any log messages given by the failure > ### Expected/desired behavior > ### OS and Version? > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) ### Versions > ### Mention any other details that might be useful > --------------------------------------------------------------- > Thanks! We'll be in touch soon. ``` -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- ```markdown ## Purpose <!-- Describe the intention of the changes being proposed. What problem does it solve or functionality does it add? --> * ... ## Does this introduce a breaking change? <!-- Mark one with an "x". --> ``` [ ] Yes [ ] No ``` ## Pull Request Type What kind of change does this Pull Request introduce? <!-- Please check the one that applies to this PR using "x". --> ``` [ ] Bugfix [ ] Feature [ ] Code style update (formatting, local variables) [ ] Refactoring (no functional changes, no api changes) [ ] Documentation content changes [ ] Other... Please describe: ``` ## How to Test * Get the code ``` git clone [repo-address] cd [repo-name] git checkout [branch-name] npm install ``` * Test the code <!-- Add steps to run the tests suite and/or manually test --> ``` ``` ## What to Check Verify that the following are valid * ... ## Other Information <!-- Add any other helpful information that may be needed here. --> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-options.policy.xml: -------------------------------------------------------------------------------- ``` <!-- OAUTH METADATA OPTIONS POLICY This policy handles OPTIONS requests to the OAuth metadata endpoint, implementing CORS support for cross-origin requests to the OAuth authorization server. --> <policies> <inbound> <!-- Return CORS headers for OPTIONS requests --> <return-response> <set-status code="200" reason="OK" /> <set-header name="Access-Control-Allow-Origin" exists-action="override"> <value>*</value> </set-header> <set-header name="Access-Control-Allow-Methods" exists-action="override"> <value>GET, OPTIONS</value> </set-header> <set-header name="Access-Control-Allow-Headers" exists-action="override"> <value>Content-Type, Authorization</value> </set-header> <set-header name="Access-Control-Max-Age" exists-action="override"> <value>86400</value> </set-header> <set-body /> </return-response> <base /> </inbound> <backend> <base /> </backend> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/register-options.policy.xml: -------------------------------------------------------------------------------- ``` <!-- REGISTER OPTIONS POLICY This policy handles the OPTIONS pre-flight requests for the OAuth client registration endpoint. It returns the appropriate CORS headers to allow cross-origin requests. --> <policies> <inbound> <!-- Return a 200 OK response with appropriate CORS headers --> <return-response> <set-status code="200" reason="OK" /> <set-header name="Access-Control-Allow-Origin" exists-action="override"> <value>*</value> </set-header> <set-header name="Access-Control-Allow-Methods" exists-action="override"> <value>GET, OPTIONS</value> </set-header> <set-header name="Access-Control-Allow-Headers" exists-action="override"> <value>Content-Type, Authorization</value> </set-header> <set-header name="Access-Control-Max-Age" exists-action="override"> <value>86400</value> </set-header> <set-body /> </return-response> <base /> </inbound> <backend> <base /> </backend> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-get.policy.xml: -------------------------------------------------------------------------------- ``` <!-- OAUTH METADATA POLICY This policy implements the OpenID Connect and OAuth 2.0 discovery endpoint (.well-known/oauth-authorization-server). --> <policies> <inbound> <!-- Return the OAuth metadata in JSON format --> <return-response> <set-status code="200" reason="OK" /> <set-header name="Content-Type" exists-action="override"> <value>application/json; charset=utf-8</value> </set-header> <set-header name="access-control-allow-origin" exists-action="override"> <value>*</value> </set-header> <set-body> { "issuer": "{{APIMGatewayURL}}", "service_documentation": "https://microsoft.com/", "authorization_endpoint": "{{APIMGatewayURL}}/authorize", "token_endpoint": "{{APIMGatewayURL}}/token", "revocation_endpoint": "{{APIMGatewayURL}}/revoke", "registration_endpoint": "{{APIMGatewayURL}}/register", "response_types_supported": [ "code" ], "code_challenge_methods_supported": [ "S256" ], "token_endpoint_auth_methods_supported": [ "none" ], "grant_types_supported": [ "authorization_code", "refresh_token" ], "revocation_endpoint_auth_methods_supported": [ "client_secret_post" ] } </set-body> </return-response> <base /> </inbound> <backend> <base /> </backend> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-mcp/mcp-api.policy.xml: -------------------------------------------------------------------------------- ``` <!-- MCP API POLICY This policy applies to all operations in the MCP API. It adds authorization header check for security. --> <policies> <inbound> <base /> <check-header name="Authorization" failed-check-httpcode="401" failed-check-error-message="Not authorized" ignore-case="false" /> <set-variable name="IV" value="{{EncryptionIV}}" /> <set-variable name="key" value="{{EncryptionKey}}" /> <set-variable name="decryptedSessionKey" value="@{ // Retrieve the encrypted session key from the request header string authHeader = context.Request.Headers.GetValueOrDefault("Authorization"); string encryptedSessionKey = authHeader.StartsWith("Bearer ") ? authHeader.Substring(7) : authHeader; // Decrypt the session key using AES byte[] IV = Convert.FromBase64String((string)context.Variables["IV"]); byte[] key = Convert.FromBase64String((string)context.Variables["key"]); byte[] encryptedBytes = Convert.FromBase64String(encryptedSessionKey); byte[] decryptedBytes = encryptedBytes.Decrypt("Aes", key, IV); return Encoding.UTF8.GetString(decryptedBytes); }" /> <cache-lookup-value key="@($"EntraToken-{context.Variables.GetValueOrDefault("decryptedSessionKey")}")" variable-name="accessToken" /> <choose> <when condition="@(context.Variables.GetValueOrDefault("accessToken") == null)"> <return-response> <set-status code="401" reason="Unauthorized" /> <set-header name="WWW-Authenticate" exists-action="override"> <value>Bearer error="invalid_token"</value> </set-header> </return-response> </when> </choose> <set-header name="x-functions-key" exists-action="override"> <value>{{function-host-key}}</value> </set-header> </inbound> <backend> <base /> </backend> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /src/function_app.py: -------------------------------------------------------------------------------- ```python from dataclasses import dataclass import json import logging import azure.functions as func app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) # Constants for the Azure Blob Storage container, file, and blob path _SNIPPET_NAME_PROPERTY_NAME = "snippetname" _SNIPPET_PROPERTY_NAME = "snippet" _BLOB_PATH = "snippets/{mcptoolargs." + _SNIPPET_NAME_PROPERTY_NAME + "}.json" @dataclass class ToolProperty: propertyName: str propertyType: str description: str # Define the tool properties using the ToolProperty class tool_properties_save_snippets_object = [ ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet."), ToolProperty(_SNIPPET_PROPERTY_NAME, "string", "The content of the snippet."), ] tool_properties_get_snippets_object = [ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet.")] # Convert the tool properties to JSON tool_properties_save_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_save_snippets_object]) tool_properties_get_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_get_snippets_object]) @app.generic_trigger( arg_name="context", type="mcpToolTrigger", toolName="hello_mcp", description="Hello world.", toolProperties="[]", ) def hello_mcp(context) -> str: """ A simple function that returns a greeting message. Args: context: The trigger context (not used in this function). Returns: str: A greeting message. """ return "Hello I am MCPTool!" @app.generic_trigger( arg_name="context", type="mcpToolTrigger", toolName="get_snippet", description="Retrieve a snippet by name.", toolProperties=tool_properties_get_snippets_json, ) @app.generic_input_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) def get_snippet(file: func.InputStream, context) -> str: """ Retrieves a snippet by name from Azure Blob Storage. Args: file (func.InputStream): The input binding to read the snippet from Azure Blob Storage. context: The trigger context containing the input arguments. Returns: str: The content of the snippet or an error message. """ snippet_content = file.read().decode("utf-8") logging.info("Retrieved snippet: %s", snippet_content) return snippet_content @app.generic_trigger( arg_name="context", type="mcpToolTrigger", toolName="save_snippet", description="Save a snippet with a name.", toolProperties=tool_properties_save_snippets_json, ) @app.generic_output_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) def save_snippet(file: func.Out[str], context) -> str: content = json.loads(context) if "arguments" not in content: return "No arguments provided" snippet_name_from_args = content["arguments"].get(_SNIPPET_NAME_PROPERTY_NAME) snippet_content_from_args = content["arguments"].get(_SNIPPET_PROPERTY_NAME) if not snippet_name_from_args: return "No snippet name provided" if not snippet_content_from_args: return "No snippet content provided" file.set(snippet_content_from_args) logging.info("Saved snippet: %s", snippet_content_from_args) return f"Snippet '{snippet_content_from_args}' saved successfully" ``` -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- ```json { "analysisServicesServers": "as", "apiManagementService": "apim-", "appConfigurationConfigurationStores": "appcs-", "applications": "app-", "appManagedEnvironments": "cae-", "appContainerApps": "ca-", "authorizationPolicyDefinitions": "policy-", "automationAutomationAccounts": "aa-", "blueprintBlueprints": "bp-", "blueprintBlueprintsArtifacts": "bpa-", "cacheRedis": "redis-", "cdnProfiles": "cdnp-", "cdnProfilesEndpoints": "cdne-", "cognitiveServicesAccounts": "cog-", "cognitiveServicesFormRecognizer": "cog-fr-", "cognitiveServicesTextAnalytics": "cog-ta-", "computeAvailabilitySets": "avail-", "computeCloudServices": "cld-", "computeDiskEncryptionSets": "des", "computeDisks": "disk", "computeDisksOs": "osdisk", "computeGalleries": "gal", "computeSnapshots": "snap-", "computeVirtualMachines": "vm", "computeVirtualMachineScaleSets": "vmss-", "containerInstanceContainerGroups": "ci", "containerRegistryRegistries": "cr", "containerServiceManagedClusters": "aks-", "databricksWorkspaces": "dbw-", "dataFactoryFactories": "adf-", "dataLakeAnalyticsAccounts": "dla", "dataLakeStoreAccounts": "dls", "dataMigrationServices": "dms-", "dBforMySQLServers": "mysql-", "dBforPostgreSQLServers": "psql-", "devicesIotHubs": "iot-", "devicesProvisioningServices": "provs-", "devicesProvisioningServicesCertificates": "pcert-", "documentDBDatabaseAccounts": "cosmos-", "eventGridDomains": "evgd-", "eventGridDomainsTopics": "evgt-", "eventGridEventSubscriptions": "evgs-", "eventHubNamespaces": "evhns-", "eventHubNamespacesEventHubs": "evh-", "hdInsightClustersHadoop": "hadoop-", "hdInsightClustersHbase": "hbase-", "hdInsightClustersKafka": "kafka-", "hdInsightClustersMl": "mls-", "hdInsightClustersSpark": "spark-", "hdInsightClustersStorm": "storm-", "hybridComputeMachines": "arcs-", "insightsActionGroups": "ag-", "insightsComponents": "appi-", "keyVaultVaults": "kv-", "kubernetesConnectedClusters": "arck", "kustoClusters": "dec", "kustoClustersDatabases": "dedb", "logicIntegrationAccounts": "ia-", "logicWorkflows": "logic-", "machineLearningServicesWorkspaces": "mlw-", "managedIdentityUserAssignedIdentities": "id-", "managementManagementGroups": "mg-", "migrateAssessmentProjects": "migr-", "networkApplicationGateways": "agw-", "networkApplicationSecurityGroups": "asg-", "networkAzureFirewalls": "afw-", "networkBastionHosts": "bas-", "networkConnections": "con-", "networkDnsZones": "dnsz-", "networkExpressRouteCircuits": "erc-", "networkFirewallPolicies": "afwp-", "networkFirewallPoliciesWebApplication": "waf", "networkFirewallPoliciesRuleGroups": "wafrg", "networkFrontDoors": "fd-", "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", "networkLoadBalancersExternal": "lbe-", "networkLoadBalancersInternal": "lbi-", "networkLoadBalancersInboundNatRules": "rule-", "networkLocalNetworkGateways": "lgw-", "networkNatGateways": "ng-", "networkNetworkInterfaces": "nic-", "networkNetworkSecurityGroups": "nsg-", "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", "networkNetworkWatchers": "nw-", "networkPrivateDnsZones": "pdnsz-", "networkPrivateLinkServices": "pl-", "networkPublicIPAddresses": "pip-", "networkPublicIPPrefixes": "ippre-", "networkRouteFilters": "rf-", "networkRouteTables": "rt-", "networkRouteTablesRoutes": "udr-", "networkTrafficManagerProfiles": "traf-", "networkVirtualNetworkGateways": "vgw-", "networkVirtualNetworks": "vnet-", "networkVirtualNetworksSubnets": "snet-", "networkVirtualNetworksVirtualNetworkPeerings": "peer-", "networkVirtualWans": "vwan-", "networkVpnGateways": "vpng-", "networkVpnGatewaysVpnConnections": "vcn-", "networkVpnGatewaysVpnSites": "vst-", "notificationHubsNamespaces": "ntfns-", "notificationHubsNamespacesNotificationHubs": "ntf-", "operationalInsightsWorkspaces": "log-", "portalDashboards": "dash-", "powerBIDedicatedCapacities": "pbi-", "purviewAccounts": "pview-", "recoveryServicesVaults": "rsv-", "resourcesResourceGroups": "rg-", "searchSearchServices": "srch-", "serviceBusNamespaces": "sb-", "serviceBusNamespacesQueues": "sbq-", "serviceBusNamespacesTopics": "sbt-", "serviceEndPointPolicies": "se-", "serviceFabricClusters": "sf-", "signalRServiceSignalR": "sigr", "sqlManagedInstances": "sqlmi-", "sqlServers": "sql-", "sqlServersDataWarehouse": "sqldw-", "sqlServersDatabases": "sqldb-", "sqlServersDatabasesStretch": "sqlstrdb-", "storageStorageAccounts": "st", "storageStorageAccountsVm": "stvm", "storSimpleManagers": "ssimp", "streamAnalyticsCluster": "asa-", "synapseWorkspaces": "syn", "synapseWorkspacesAnalyticsWorkspaces": "synw", "synapseWorkspacesSqlPoolsDedicated": "syndp", "synapseWorkspacesSqlPoolsSpark": "synsp", "timeSeriesInsightsEnvironments": "tsi-", "webServerFarms": "plan-", "webSitesAppService": "app-", "webSitesAppServiceEnvironment": "ase-", "webSitesFunctions": "func-", "webStaticSites": "stapp-" } ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/register.policy.xml: -------------------------------------------------------------------------------- ``` <!-- REGISTER POLICY This policy implements the dynamic client registration endpoint for OAuth2 flow. Flow: 1. MCP client sends a registration request with redirect URIs 2. We store the registration information in CosmosDB for persistence 3. We generate and return client credentials with the provided redirect URIs --> <policies> <inbound> <base /> <!-- STEP 1: Extract client registration data from request --> <set-variable name="requestBody" value="@(context.Request.Body.As<JObject>(preserveContent: true))" /> <!-- STEP 2: Generate a unique client ID (GUID) --> <set-variable name="uniqueClientId" value="@(Guid.NewGuid().ToString())" /> <!-- STEP 3: Prepare client info document for CosmosDB --> <set-variable name="clientDocument" value="@{ var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); var uniqueClientId = context.Variables.GetValueOrDefault<string>("uniqueClientId"); var document = new JObject(); document["id"] = uniqueClientId; document["clientId"] = uniqueClientId; document["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application"; document["client_uri"] = requestBody["client_uri"]?.ToString() ?? ""; document["redirect_uris"] = requestBody["redirect_uris"]; document["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); return document.ToString(); }" /> <!-- STEP 4: Get CosmosDB access token using managed identity --> <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> <!-- STEP 5: Store client registration in CosmosDB using AAD token --> <send-request mode="new" response-variable-name="cosmosResponse" timeout="30" ignore-error="false"> <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs")</set-url> <set-method>POST</set-method> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-ms-version" exists-action="override"> <value>2018-12-31</value> </set-header> <set-header name="x-ms-documentdb-partitionkey" exists-action="override"> <value>@($"[\"{context.Variables.GetValueOrDefault<string>("uniqueClientId")}\"]")</value> </set-header> <set-header name="Authorization" exists-action="override"> <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> </set-header> <set-body>@(context.Variables.GetValueOrDefault<string>("clientDocument"))</set-body> </send-request> <!-- STEP 6: Check if CosmosDB operation was successful --> <choose> <when condition="@(((IResponse)context.Variables["cosmosResponse"]).StatusCode >= 400)"> <return-response> <set-status code="500" reason="Internal Server Error" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ return new JObject { ["error"] = "server_error", ["error_description"] = "Failed to store client registration" }.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 7: Cache the redirect URI for backward compatibility with other policies --> <cache-store-value duration="3600" key="ClientRedirectUri" value="@(context.Variables.GetValueOrDefault<JObject>("requestBody")["redirect_uris"][0].ToString())" /> <!-- Store client info by client ID for easy lookup during consent --> <cache-store-value duration="3600" key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("uniqueClientId")}")" value="@{ var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); var clientInfo = new JObject(); clientInfo["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application"; clientInfo["client_uri"] = requestBody["client_uri"]?.ToString() ?? ""; clientInfo["redirect_uris"] = requestBody["redirect_uris"]; return clientInfo.ToString(); }" /> <!-- STEP 8: Set response content type --> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <!-- STEP 9: Return client credentials response --> <return-response> <set-status code="200" reason="OK" /> <set-header name="access-control-allow-origin" exists-action="override"> <value>*</value> </set-header> <set-body template="none">@{ var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); // Generate timestamps dynamically // Current time in seconds since epoch (Unix timestamp) long currentTimeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Client ID issued at current time long clientIdIssuedAt = currentTimeSeconds; // Client secret expires in 1 year (31536000 seconds = 365 days) long clientSecretExpiresAt = currentTimeSeconds + 31536000; // Use the generated client ID from earlier string uniqueClientId = context.Variables.GetValueOrDefault<string>("uniqueClientId", Guid.NewGuid().ToString()); return new JObject { ["client_id"] = uniqueClientId, ["client_id_issued_at"] = clientIdIssuedAt, ["client_secret_expires_at"] = clientSecretExpiresAt, ["redirect_uris"] = requestBody["redirect_uris"]?.ToObject<JArray>(), ["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application", ["client_uri"] = requestBody["client_uri"]?.ToString() ?? "" }.ToString(); }</set-body> </return-response> </inbound> <backend /> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/authorize.policy.xml: -------------------------------------------------------------------------------- ``` <!-- AUTHORIZE POLICY OAuth 2.0 PKCE authorization endpoint with Entra ID integration. Flow: Client → Consent (if needed) → Entra ID → Callback → Client --> <policies> <inbound> <base /> <!-- Extract all OAuth parameters --> <set-variable name="clientId" value="@((string)context.Request.Url.Query.GetValueOrDefault("client_id", ""))" /> <set-variable name="redirect_uri" value="@((string)context.Request.Url.Query.GetValueOrDefault("redirect_uri", ""))" /> <set-variable name="currentState" value="@((string)context.Request.Url.Query.GetValueOrDefault("state", ""))" /> <set-variable name="mcpScope" value="@((string)context.Request.Url.Query.GetValueOrDefault("scope", ""))" /> <set-variable name="mcpClientCodeChallenge" value="@((string)context.Request.Url.Query.GetValueOrDefault("code_challenge", ""))" /> <set-variable name="mcpClientCodeChallengeMethod" value="@((string)context.Request.Url.Query.GetValueOrDefault("code_challenge_method", ""))" /> <!-- Validate required OAuth parameters --> <choose> <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientId")) || string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("redirect_uri")) || string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("currentState")))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-body>@{ return new JObject { ["error"] = "invalid_request", ["error_description"] = "Missing required parameters: client_id, redirect_uri, and state are all required for OAuth authorization" }.ToString(); }</set-body> </return-response> </when> </choose> <!-- Validate required PKCE parameters --> <choose> <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("mcpClientCodeChallenge")) || string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("mcpClientCodeChallengeMethod")))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-body>@{ return new JObject { ["error"] = "invalid_request", ["error_description"] = "Missing required PKCE parameters: code_challenge and code_challenge_method are required for secure authorization" }.ToString(); }</set-body> </return-response> </when> </choose> <!-- Normalize redirect URI --> <set-variable name="normalized_redirect_uri" value="@{ string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); if (string.IsNullOrEmpty(redirectUri)) { return ""; } try { string decodedUri = System.Net.WebUtility.UrlDecode(redirectUri); return decodedUri; } catch (Exception) { return redirectUri; } }" /> <!-- Check for existing approval cookie --> <set-variable name="has_approval_cookie" value="@{ try { if (string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientId", "")) || string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""))) { return false; } string clientId = context.Variables.GetValueOrDefault<string>("clientId", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); string APPROVAL_COOKIE_NAME = "__Host-MCP_APPROVED_CLIENTS"; var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); if (string.IsNullOrEmpty(cookieHeader)) { return false; } string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(APPROVAL_COOKIE_NAME + "=")) { try { string cookieValue = trimmedCookie.Substring(APPROVAL_COOKIE_NAME.Length + 1); string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(cookieValue)); JArray approvedClients = JArray.Parse(decodedValue); string clientKey = $"{clientId}:{redirectUri}"; foreach (var item in approvedClients) { if (item.ToString() == clientKey) { return true; } } } catch (Exception ex) { // Error parsing approval cookie - ignore and continue } break; } } return false; } catch (Exception ex) { // Error checking approval cookie - return false return false; } }" /> <!-- Check if the client has been approved via secure cookie --> <choose> <when condition="@(context.Variables.GetValueOrDefault<bool>("has_approval_cookie"))"> <!-- Continue with normal flow - client is authorized via secure cookie --> </when> <otherwise> <!-- Redirect to consent page for user approval --> <return-response> <set-status code="302" reason="Found" /> <set-header name="Location" exists-action="override"> <value>@{ string basePath = context.Request.OriginalUrl.Scheme + "://" + context.Request.OriginalUrl.Host + (context.Request.OriginalUrl.Port == 80 || context.Request.OriginalUrl.Port == 443 ? "" : ":" + context.Request.OriginalUrl.Port); string clientId = context.Variables.GetValueOrDefault<string>("clientId"); // Use the normalized (already decoded) redirect_uri to avoid double-encoding string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri"); string state = context.Variables.GetValueOrDefault<string>("currentState"); string codeChallenge = context.Variables.GetValueOrDefault<string>("mcpClientCodeChallenge"); string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("mcpClientCodeChallengeMethod"); // URL encode parameters for the consent redirect URL string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); // State parameter: use as-is without additional encoding // context.Request.Url.Query.GetValueOrDefault() preserves the original encoding string encodedState = state; // Code challenge parameters: use as-is since they typically don't need encoding string encodedCodeChallenge = codeChallenge; string encodedCodeChallengeMethod = codeChallengeMethod; return $"{basePath}/consent?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}&code_challenge={encodedCodeChallenge}&code_challenge_method={encodedCodeChallengeMethod}"; }</value> </set-header> </return-response> </otherwise> </choose> <set-variable name="codeVerifier" value="@((string)Guid.NewGuid().ToString().Replace("-", ""))" /> <set-variable name="codeChallenge" value="@{ using (var sha256 = System.Security.Cryptography.SHA256.Create()) { var bytes = System.Text.Encoding.UTF8.GetBytes((string)context.Variables.GetValueOrDefault("codeVerifier", "")); var hash = sha256.ComputeHash(bytes); return System.Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } }" /> <!-- Build the complete Entra ID URL using client's original state --> <set-variable name="authUrl" value="@{ string baseUrl = "https://login.microsoftonline.com/{{EntraIDTenantId}}/oauth2/v2.0/authorize"; string codeChallenge = context.Variables.GetValueOrDefault("codeChallenge", ""); string clientState = context.Variables.GetValueOrDefault("currentState", ""); return $"{baseUrl}?response_type=code&client_id={{EntraIDClientId}}&redirect_uri={{OAuthCallbackUri}}&scope={{OAuthScopes}}&code_challenge={codeChallenge}&code_challenge_method=S256&state={System.Net.WebUtility.UrlEncode(clientState)}"; }" /> <!-- STEP 5: Store authentication data in cache for use in callback --> <!-- Generate a confirmation code to return to the MCP client --> <set-variable name="mcpConfirmConsentCode" value="@((string)Guid.NewGuid().ToString())" /> <!-- Store code verifier for token exchange using client state --> <cache-store-value duration="3600" key="@("CodeVerifier-"+context.Variables.GetValueOrDefault("currentState", ""))" value="@(context.Variables.GetValueOrDefault("codeVerifier", ""))" /> <!-- Map client state to MCP confirmation code for callback --> <cache-store-value duration="3600" key="@((string)context.Variables.GetValueOrDefault("currentState"))" value="@(context.Variables.GetValueOrDefault("mcpConfirmConsentCode", ""))" /> <!-- Store MCP client data --> <cache-store-value duration="3600" key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" value="@{ return new JObject{ ["mcpClientCodeChallenge"] = (string)context.Variables["mcpClientCodeChallenge"], ["mcpClientCodeChallengeMethod"] = (string)context.Variables["mcpClientCodeChallengeMethod"], ["mcpClientState"] = (string)context.Variables["currentState"], ["mcpClientScope"] = (string)context.Variables["mcpScope"], ["mcpCallbackRedirectUri"] = (string)context.Variables["normalized_redirect_uri"] }.ToString(); }" /> </inbound> <backend> <base /> </backend> <outbound> <base /> <!-- Return the response with a 302 status code for redirect --> <return-response> <set-status code="302" reason="Found" /> <set-header name="Location" exists-action="override"> <value>@(context.Variables.GetValueOrDefault("authUrl", ""))</value> </set-header> <!-- Add cache control headers to ensure browser follows redirect --> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache, must-revalidate</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <!-- Remove any content-type that might interfere --> <set-header name="Content-Type" exists-action="delete" /> </return-response> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauth-callback.policy.xml: -------------------------------------------------------------------------------- ``` <!-- OAUTH CALLBACK POLICY This policy implements the callback endpoint for PKCE OAuth2 flow with Entra ID. --> <policies> <inbound> <base /> <!-- STEP 1: Extract the authorization code and state from Entra ID callback --> <set-variable name="authCode" value="@((string)context.Request.Url.Query.GetValueOrDefault("code", ""))" /> <set-variable name="clientState" value="@{ string stateValue = (string)context.Request.Url.Query.GetValueOrDefault("state", ""); return !string.IsNullOrEmpty(stateValue) ? System.Net.WebUtility.UrlDecode(stateValue) : ""; }" /> <set-variable name="sessionState" value="@((string)context.Request.Url.Query.GetValueOrDefault("session_state", ""))" /> <!-- Validate required OAuth parameters --> <choose> <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("authCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("clientState", "")))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "invalid_request"; errorResponse["error_description"] = "Missing required OAuth callback parameters"; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 1.5: Validate that the state matches what the user consented to --> <set-variable name="consent_state_valid" value="@{ try { string returnedState = context.Variables.GetValueOrDefault<string>("clientState", ""); if (string.IsNullOrEmpty(returnedState)) { return false; } // Extract consent state from cookie var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); if (string.IsNullOrEmpty(cookieHeader)) { return false; } string cookieName = "__Host-MCP_CONSENT_STATE"; string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(cookieName + "=")) { string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(cookieValue)); JObject consentData = JObject.Parse(decodedValue); string consentedState = consentData["state"]?.ToString(); // Constant-time comparison to prevent timing attacks if (string.IsNullOrEmpty(consentedState) || returnedState.Length != consentedState.Length) { return false; } int result = 0; for (int i = 0; i < returnedState.Length; i++) { result |= returnedState[i] ^ consentedState[i]; } return (result == 0); } } return false; } catch (Exception ex) { return false; } }" /> <!-- Validate consent state cookie --> <choose> <when condition="@(!context.Variables.GetValueOrDefault<bool>("consent_state_valid"))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "invalid_state"; errorResponse["error_description"] = "State parameter does not match consented state."; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- Clear the consent state cookie since it's been validated --> <set-variable name="clear_consent_cookie" value="__Host-MCP_CONSENT_STATE=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" /> <!-- STEP 2: Retrieve stored PKCE code verifier using the client state parameter --> <cache-lookup-value key="@("CodeVerifier-"+context.Variables.GetValueOrDefault("clientState", ""))" variable-name="codeVerifier" /> <!-- Validate that code verifier was found in cache --> <choose> <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("codeVerifier", "")))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "invalid_request"; errorResponse["error_description"] = "Authorization session expired or invalid state parameter"; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 3: Set token request parameters --> <set-variable name="codeChallengeMethod" value="S256" /> <set-variable name="redirectUri" value="{{OAuthCallbackUri}}" /> <set-variable name="clientId" value="{{EntraIDClientId}}" /> <set-variable name="clientAssertionType" value="@(System.Net.WebUtility.UrlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"))" /> <authentication-managed-identity resource="api://AzureADTokenExchange" client-id="{{EntraIDFicClientId}}" output-token-variable-name="ficToken"/> <!-- STEP 4: Configure token request to Entra ID --> <set-method>POST</set-method> <set-header name="Content-Type" exists-action="override"> <value>application/x-www-form-urlencoded</value> </set-header> <set-body>@{ return $"client_id={context.Variables.GetValueOrDefault("clientId")}&grant_type=authorization_code&code={context.Variables.GetValueOrDefault("authCode")}&redirect_uri={context.Variables.GetValueOrDefault("redirectUri")}&scope=User.Read&code_verifier={context.Variables.GetValueOrDefault("codeVerifier")}&client_assertion_type={context.Variables.GetValueOrDefault("clientAssertionType")}&client_assertion={context.Variables.GetValueOrDefault("ficToken")}"; }</set-body> <rewrite-uri template="/token" /> </inbound> <backend> <base /> </backend> <outbound> <base /> <!-- STEP 5: Process the token response from Entra ID --> <trace source="apim-policy"> <message>@("Token response received: " + context.Response.Body.As<string>(preserveContent: true))</message> </trace> <!-- Check if the response is successful (200 OK) and contains a token --> <choose> <when condition="@(context.Response.StatusCode != 200 || string.IsNullOrEmpty(context.Response.Body.As<JObject>(preserveContent: true)["access_token"]?.ToString()))"> <return-response> <set-status code="@(context.Response.StatusCode)" reason="@(context.Response.StatusReason)" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "token_error"; errorResponse["error_description"] = "Failed to retrieve access token from Entra ID."; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 6: Generate secure session token for MCP client --> <set-variable name="IV" value="{{EncryptionIV}}" /> <set-variable name="key" value="{{EncryptionKey}}" /> <set-variable name="sessionId" value="@((string)Guid.NewGuid().ToString().Replace("-", ""))" /> <set-variable name="encryptedSessionKey" value="@{ // Generate a unique session ID string sessionId = (string)context.Variables.GetValueOrDefault("sessionId"); byte[] sessionIdBytes = Encoding.UTF8.GetBytes(sessionId); // Encrypt the session ID using AES byte[] IV = Convert.FromBase64String((string)context.Variables["IV"]); byte[] key = Convert.FromBase64String((string)context.Variables["key"]); byte[] encryptedBytes = sessionIdBytes.Encrypt("Aes", key, IV); return Convert.ToBase64String(encryptedBytes); }" /> <!-- STEP 6: Lookup MCP client redirect URI stored during authorization --> <cache-lookup-value key="@((string)context.Variables.GetValueOrDefault("clientState"))" variable-name="mcpConfirmConsentCode" /> <cache-lookup-value key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="mcpClientData" /> <!-- Validate that MCP client data was found in cache --> <choose> <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpConfirmConsentCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpClientData", "")))"> <return-response> <set-status code="400" reason="Bad Request" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "invalid_request"; errorResponse["error_description"] = "MCP client authorization session expired or invalid"; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 8: Use the client's original state parameter directly --> <set-variable name="mcpState" value="@(context.Variables.GetValueOrDefault<string>("clientState"))" /> <!-- STEP 9: Extract the stored mcp client callback redirect uri from cache --> <set-variable name="callbackRedirectUri" value="@{ var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientData"]); return mcpAuthDataAsJObject["mcpCallbackRedirectUri"]; }" /> <!-- STEP 10: Store the encrypted session key and Entra token in cache --> <!-- Store the encrypted session key with the MCP confirmation code as key --> <cache-store-value duration="3600" key="@($"AccessToken-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" value="@($"{context.Variables.GetValueOrDefault("encryptedSessionKey")}")" /> <!-- Store the Entra token for later use --> <cache-store-value duration="3600" key="@($"EntraToken-{context.Variables.GetValueOrDefault("sessionId")}")" value="@(context.Response.Body.As<JObject>(preserveContent: true).ToString())" /> <!-- STEP 11: Redirect back to MCP client with confirmation code --> <return-response> <set-status code="302" reason="Found" /> <set-header name="Location" exists-action="override"> <value>@($"{context.Variables.GetValueOrDefault("callbackRedirectUri")}?code={context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}&state={System.Net.WebUtility.UrlEncode((string)context.Variables.GetValueOrDefault("mcpState"))}")</value> </set-header> <!-- Clear the consent state cookie --> <set-header name="Set-Cookie" exists-action="append"> <value>@(context.Variables.GetValueOrDefault<string>("clear_consent_cookie"))</value> </set-header> <set-body /> </return-response> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/token.policy.xml: -------------------------------------------------------------------------------- ``` <!-- TOKEN POLICY This policy implements the token endpoint for PKCE OAuth2 flow. Flow: 1. MCP client sends token request with code and code_verifier 2. We validate the code_verifier against the stored code_challenge 3. We retrieve the cached access token and return it to the client --> <policies> <inbound> <base /> <!-- STEP 1: Extract parameters from token request --> <!-- Read the request body as a string while preserving it for later processing --> <set-variable name="tokenRequestBody" value="@((string)context.Request.Body.As<string>(preserveContent: true))" /> <!-- Extract the confirmation code from the request --> <set-variable name="mcpConfirmConsentCode" value="@{ // Retrieve the raw body string var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); if (!string.IsNullOrEmpty(body)) { // Split the body into name/value pairs var pairs = body.Split('&'); foreach (var pair in pairs) { var keyValue = pair.Split('='); if (keyValue.Length == 2) { if(keyValue[0] == "code") { return keyValue[1]; } } } } return ""; }" /> <!-- Extract the code_verifier from the request and URL-decode it --> <set-variable name="mcpClientCodeVerifier" value="@{ // Retrieve the raw body string var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); if (!string.IsNullOrEmpty(body)) { // Split the body into name/value pairs var pairs = body.Split('&'); foreach (var pair in pairs) { var keyValue = pair.Split('='); if (keyValue.Length == 2) { if(keyValue[0] == "code_verifier") { // URL-decode the code_verifier if needed return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } } return ""; }" /> <!-- STEP 2: Extract state parameters --> <set-variable name="mcpState" value="@((string)context.Request.Url.Query.GetValueOrDefault("state", ""))" /> <set-variable name="stateSession" value="@((string)context.Request.Url.Query.GetValueOrDefault("state_session", ""))" /> </inbound> <backend /> <outbound> <base /> <!-- STEP 3: Retrieve stored MCP client data --> <!-- Lookup the stored MCP client code challenge and challenge method from the cache --> <cache-lookup-value key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="mcpClientAuthData" /> <!-- Extract the stored code challenge from the cached data --> <set-variable name="storedMcpClientCodeChallenge" value="@{ var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientAuthData"]); return (string)mcpAuthDataAsJObject["mcpClientCodeChallenge"]; }" /> <!-- STEP 4: Compute and validate the code challenge --> <!-- Generate a challenge from the incoming code_verifier using the stored challenge method --> <set-variable name="mcpServerComputedCodeChallenge" value="@{ var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientAuthData"]); string codeVerifier = (string)context.Variables.GetValueOrDefault("mcpClientCodeVerifier", ""); string codeChallengeMethod = ((string)mcpAuthDataAsJObject["mcpClientCodeChallengeMethod"]).ToLower(); if(string.IsNullOrEmpty(codeVerifier)){ return string.Empty; } if(codeChallengeMethod == "plain"){ // For "plain", no transformation is applied return codeVerifier; } else if(codeChallengeMethod == "s256"){ // For S256, compute the SHA256 hash, Base64 encode it, and convert to URL-safe format using (var sha256 = System.Security.Cryptography.SHA256.Create()) { var bytes = System.Text.Encoding.UTF8.GetBytes(codeVerifier); var hash = sha256.ComputeHash(bytes); // Convert the hash to a Base64 string string base64 = Convert.ToBase64String(hash); // Convert Base64 string into a URL-safe variant // Replace '+' with '-', '/' with '_', and remove any '=' padding return base64.Replace("+", "-").Replace("/", "_").Replace("=", ""); } } else { // Unsupported method return string.Empty; } }" /> <!-- STEP 5: Verify code challenge matches --> <choose> <when condition="@(string.Compare((string)context.Variables.GetValueOrDefault("mcpServerComputedCodeChallenge", ""), (string)context.Variables.GetValueOrDefault("storedMcpClientCodeChallenge", "")) != 0)"> <!-- If they don't match, return an error --> <return-response> <set-status code="400" reason="Bad Request" /> <set-body>@("{\"error\": \"code_verifier does not match.\"}")</set-body> </return-response> </when> </choose> <!-- STEP 5.5: Verify client registration --> <!-- Extract client ID and redirect URI from the token request --> <set-variable name="client_id" value="@{ // Retrieve the raw body string var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); if (!string.IsNullOrEmpty(body)) { // Split the body into name/value pairs var pairs = body.Split('&'); foreach (var pair in pairs) { var keyValue = pair.Split('='); if (keyValue.Length == 2) { if(keyValue[0] == "client_id") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } } return ""; }" /> <set-variable name="redirect_uri" value="@{ // Retrieve the raw body string var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); if (!string.IsNullOrEmpty(body)) { // Split the body into name/value pairs var pairs = body.Split('&'); foreach (var pair in pairs) { var keyValue = pair.Split('='); if (keyValue.Length == 2) { if(keyValue[0] == "redirect_uri") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } } return ""; }" /> <!-- Normalize the redirect URI --> <set-variable name="normalized_redirect_uri" value="@{ string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); return System.Net.WebUtility.UrlDecode(redirectUri); }" /> <!-- Look up client information from cache --> <cache-lookup-value key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" variable-name="clientInfoJson" /> <!-- If cache lookup failed, try to retrieve from CosmosDB --> <choose> <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientInfoJson")))"> <!-- Get CosmosDB access token using managed identity --> <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> <send-request mode="new" response-variable-name="cosmosClientResponse" timeout="30" ignore-error="true"> <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs/{context.Variables.GetValueOrDefault<string>("client_id")}")</set-url> <set-method>GET</set-method> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-ms-version" exists-action="override"> <value>2018-12-31</value> </set-header> <set-header name="x-ms-partitionkey" exists-action="override"> <value>@($"[\"{context.Variables.GetValueOrDefault<string>("client_id")}\"]")</value> </set-header> <set-header name="Authorization" exists-action="override"> <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> </set-header> </send-request> <!-- If CosmosDB request was successful, extract client info --> <choose> <when condition="@(((IResponse)context.Variables["cosmosClientResponse"]).StatusCode == 200)"> <set-variable name="clientInfoJson" value="@{ var cosmosResponse = (IResponse)context.Variables["cosmosClientResponse"]; var cosmosDocument = cosmosResponse.Body.As<JObject>(); // Extract the client info fields we need var clientInfo = new JObject(); clientInfo["client_name"] = cosmosDocument["client_name"]; clientInfo["client_uri"] = cosmosDocument["client_uri"]; clientInfo["redirect_uris"] = cosmosDocument["redirect_uris"]; return clientInfo.ToString(); }" /> <!-- Store in cache for future requests --> <cache-store-value duration="3600" key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" value="@(context.Variables.GetValueOrDefault<string>("clientInfoJson"))" /> </when> </choose> </when> </choose> <!-- Verify that the client exists and the redirect URI is valid --> <set-variable name="is_client_registered" value="@{ try { string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); if (string.IsNullOrEmpty(clientId)) { return false; } // Get the client info from the variable set by cache-lookup-value string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); if (string.IsNullOrEmpty(clientInfoJson)) { context.Trace($"Client info not found in cache for client_id: {clientId}"); return false; } // Parse client info JObject clientInfo = JObject.Parse(clientInfoJson); JArray redirectUris = clientInfo["redirect_uris"]?.ToObject<JArray>(); // Check if the redirect URI is in the registered URIs if (redirectUris != null) { foreach (var uri in redirectUris) { // Normalize the URI from the cache for comparison string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); if (registeredUri == redirectUri) { return true; } } } context.Trace($"Redirect URI mismatch - URI: {redirectUri} not found in registered URIs"); return false; } catch (Exception ex) { context.Trace($"Error checking client registration: {ex.Message}"); return false; } }" /> <!-- Check if client is properly registered --> <choose> <when condition="@(!context.Variables.GetValueOrDefault<bool>("is_client_registered"))"> <!-- Client is not properly registered, return error --> <return-response> <set-status code="401" reason="Unauthorized" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-body>@{ var errorResponse = new JObject(); errorResponse["error"] = "invalid_client"; errorResponse["error_description"] = "Client not found or redirect URI is invalid."; return errorResponse.ToString(); }</set-body> </return-response> </when> </choose> <!-- STEP 6: Retrieve cached tokens --> <!-- Get the access token stored during the authorization process --> <cache-lookup-value key="@($"AccessToken-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="cachedSessionToken" /> <!-- STEP 7: Generate token response --> <set-variable name="jsonPayload" value="@{ var accessToken = context.Variables.GetValueOrDefault<string>("cachedSessionToken"); var payloadObject = new { access_token = accessToken, token_type = "Bearer", expires_in = 3600, refresh_token = "", scope = "openid profile email" }; // Serialize the object to a JSON string. return Newtonsoft.Json.JsonConvert.SerializeObject(payloadObject); }" /> <set-body template="none">@{ return (string)context.Variables.GetValueOrDefault("jsonPayload", ""); }</set-body> <set-header name="access-control-allow-origin" exists-action="override"> <value>*</value> </set-header> </outbound> <on-error> <base /> </on-error> </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/consent.policy.xml: -------------------------------------------------------------------------------- ``` <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <!-- Consent Policy - Handles user consent for OAuth client applications --> <policies> <inbound> <base /> <!-- Extract form body once --> <set-variable name="form_body" value="@{ if (context.Request.Method == "POST") { string contentType = context.Request.Headers.GetValueOrDefault("Content-Type", ""); if (contentType.Contains("application/x-www-form-urlencoded")) { return context.Request.Body.As<string>(preserveContent: true); } } return ""; }" /> <!-- Extract individual parameters with consistent decoding --> <set-variable name="client_id" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "client_id") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } // Fallback to query string (GET) string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("client_id", ""); return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; }" /> <set-variable name="redirect_uri" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "redirect_uri") { return keyValue[1]; } } } // Fallback to query string (GET) return (string)context.Request.Url.Query.GetValueOrDefault("redirect_uri", ""); }" /> <set-variable name="state" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "state") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } // Fallback to query string (GET) string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("state", ""); return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; }" /> <set-variable name="code_challenge" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "code_challenge") { return keyValue[1]; } } } // Fallback to query string (GET) return (string)context.Request.Url.Query.GetValueOrDefault("code_challenge", ""); }" /> <set-variable name="code_challenge_method" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "code_challenge_method") { return keyValue[1]; } } } // Fallback to query string (GET) return (string)context.Request.Url.Query.GetValueOrDefault("code_challenge_method", ""); }" /> <set-variable name="access_denied_template" value="@{ return @"<html lang='en'> <head> <meta charset='UTF-8'> <meta name='viewport' content='width=device-width, initial-scale=1.0'> <title>Access Denied</title> <style> __COMMON_STYLES__ .error-details { background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 20px; margin: 20px 0; font-family: 'Courier New', Consolas, monospace; font-size: 14px; line-height: 1.6; white-space: pre-wrap; overflow-x: auto; } .error-title { color: #dc3545; font-weight: bold; margin-bottom: 10px; } .debug-section { margin-top: 15px; padding-top: 15px; border-top: 1px solid #dee2e6; } .debug-label { font-weight: bold; color: #495057; } </style> </head> <body> <div class='consent-container'> <h1 class='denial-heading'>Access Denied</h1> <div class='error-details'> <div class='error-title'>Error Details:</div> __DENIAL_MESSAGE__ </div> <p>The application will not be able to access your data.</p> <p>You can close this window safely.</p> </div> </body> </html>"; }" /> <!-- Reusable function to generate 403 error response --> <set-variable name="generate_403_response" value="@{ string errorTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); string message = "Access denied."; // Replace placeholders with actual content errorTemplate = errorTemplate.Replace("__COMMON_STYLES__", commonStyles); errorTemplate = errorTemplate.Replace("__DENIAL_MESSAGE__", message); return errorTemplate; }" /> <!-- Error page template --> <set-variable name="client_not_found_template" value="@{ return @"<html lang='en'> <head> <meta charset='UTF-8'> <meta name='viewport' content='width=device-width, initial-scale=1.0'> <title>Client Not Found</title> <style> __COMMON_STYLES__ </style> </head> <body> <div class='consent-container'> <h1 class='denial-heading'>Client Not Found</h1> <p>The client registration for the specified client was not found.</p> <div class='client-info'> <p><strong>Client ID:</strong> <code>__CLIENT_ID_DISPLAY__</code></p> <p><strong>Redirect URI:</strong> <code>__REDIRECT_URI__</code></p> </div> <p>Please ensure that you are using a properly registered client application.</p> <p>You can close this window safely.</p> </div> </body> </html>"; }" /> <!-- Normalize redirect URI by handling potential double-encoding --> <set-variable name="normalized_redirect_uri" value="@{ string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); if (string.IsNullOrEmpty(redirectUri)) { return ""; } try { string firstDecode = System.Net.WebUtility.UrlDecode(redirectUri); // Check if still encoded (contains % followed by hex digits) if (firstDecode.Contains("%") && System.Text.RegularExpressions.Regex.IsMatch(firstDecode, @"%[0-9A-Fa-f]{2}")) { // Double-encoded, decode again string secondDecode = System.Net.WebUtility.UrlDecode(firstDecode); return secondDecode; } else { // Single encoding, first decode is sufficient return firstDecode; } } catch (Exception) { // If decoding fails, return original value return redirectUri; } }" /> <!-- Cache client information lookup --> <cache-lookup-value key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" variable-name="clientInfoJson" /> <!-- If cache lookup failed, try to retrieve from CosmosDB --> <choose> <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientInfoJson")))"> <!-- Get CosmosDB access token using managed identity --> <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> <send-request mode="new" response-variable-name="cosmosClientResponse" timeout="30" ignore-error="true"> <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs/{context.Variables.GetValueOrDefault<string>("client_id")}")</set-url> <set-method>GET</set-method> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-ms-version" exists-action="override"> <value>2018-12-31</value> </set-header> <set-header name="x-ms-partitionkey" exists-action="override"> <value>@($"[\"{context.Variables.GetValueOrDefault<string>("client_id")}\"]")</value> </set-header> <set-header name="Authorization" exists-action="override"> <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> </set-header> </send-request> <!-- If CosmosDB request was successful, extract client info --> <choose> <when condition="@(((IResponse)context.Variables["cosmosClientResponse"]).StatusCode == 200)"> <set-variable name="clientInfoJson" value="@{ var cosmosResponse = (IResponse)context.Variables["cosmosClientResponse"]; var cosmosDocument = cosmosResponse.Body.As<JObject>(); // Extract the client info fields we need var clientInfo = new JObject(); clientInfo["client_name"] = cosmosDocument["client_name"]; clientInfo["client_uri"] = cosmosDocument["client_uri"]; clientInfo["redirect_uris"] = cosmosDocument["redirect_uris"]; return clientInfo.ToString(); }" /> <!-- Store in cache for future requests --> <cache-store-value duration="3600" key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" value="@(context.Variables.GetValueOrDefault<string>("clientInfoJson"))" /> </when> </choose> </when> </choose> <!-- Get OAuth scopes from configuration --> <set-variable name="oauth_scopes" value="{{OAuthScopes}}" /> <!-- Generate CSRF token for form protection (GET requests only) --> <set-variable name="csrf_token" value="@{ // Only generate tokens for GET requests (showing consent form) // POST requests validate existing tokens, not generate new ones if (context.Request.Method != "GET") { return ""; } // Generate random CSRF token using Guid and timestamp string guidPart = Guid.NewGuid().ToString("N"); string timestampPart = DateTime.UtcNow.Ticks.ToString(); string combinedString = guidPart + timestampPart; // Create URL-safe token by encoding combined string string token = System.Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(combinedString) ).Replace("+", "-").Replace("/", "_").Replace("=", "").Substring(0, 32); return token; }" /> <!-- Cache CSRF token for validation (GET requests only) --> <choose> <when condition="@(context.Request.Method == "GET" && !string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("csrf_token")))"> <cache-store-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token")}")" value="@{ string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string normalizedRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); string tokenData = $"{clientId}:{normalizedRedirectUri}:{timestamp}"; // Add debugging metadata string debugInfo = $"CACHED_AT:{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"; return $"{tokenData}|{debugInfo}"; }" duration="900" /> <!-- Track token caching for debugging --> <set-variable name="csrf_token_cached" value="true" /> </when> <otherwise> <set-variable name="csrf_token_cached" value="false" /> </otherwise> </choose> <!-- Validate client registration --> <set-variable name="is_client_registered" value="@{ try { string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); if (string.IsNullOrEmpty(clientId)) { return false; } // Get client info from cache lookup string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); if (string.IsNullOrEmpty(clientInfoJson)) { return false; } // Parse client configuration JObject clientInfo = JObject.Parse(clientInfoJson); JArray redirectUris = clientInfo["redirect_uris"]?.ToObject<JArray>(); // Validate redirect URI is registered if (redirectUris != null) { foreach (var uri in redirectUris) { // Normalize registered URI for comparison string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); if (registeredUri == redirectUri) { return true; } } } return false; } catch (Exception ex) { return false; } }" /> <!-- Extract client name from cache --> <set-variable name="client_name" value="@{ try { string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); if (string.IsNullOrEmpty(clientId)) { return "Unknown Application"; } // Get client info from cache lookup string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); if (string.IsNullOrEmpty(clientInfoJson)) { return clientId; } // Parse client configuration JObject clientInfo = JObject.Parse(clientInfoJson); string clientName = clientInfo["client_name"]?.ToString(); return string.IsNullOrEmpty(clientName) ? clientId : clientName; } catch (Exception ex) { return context.Variables.GetValueOrDefault<string>("client_id", "Unknown Application"); } }" /> <!-- Extract client URI from cache --> <set-variable name="client_uri" value="@{ try { // Get client info from cache lookup string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); if (string.IsNullOrEmpty(clientInfoJson)) { return "N/A"; } // Parse client configuration JObject clientInfo = JObject.Parse(clientInfoJson); string clientUri = clientInfo["client_uri"]?.ToString(); return string.IsNullOrEmpty(clientUri) ? "N/A" : clientUri; } catch (Exception ex) { return "N/A"; } }" /> <!-- Define common styles for consent and error pages --> <set-variable name="common_styles" value="@{ return @" body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; max-width: 100%; margin: 0; padding: 0; line-height: 1.6; min-height: 100vh; background: linear-gradient(135deg, #1f1f1f, #333344, #3f4066); /* Modern dark gradient */ color: #333333; display: flex; justify-content: center; align-items: center; }.container, .consent-container { background-color: #ffffff; border-radius: 4px; /* Adding some subtle rounding */ padding: 30px; max-width: 600px; width: 90%; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); border: none; } h1 { margin-bottom: 20px; border-bottom: 1px solid #EDEBE9; padding-bottom: 10px; font-weight: 500; } .consent-heading { color: #0078D4; /* Microsoft Blue */ } .denial-heading { color: #D83B01; /* Microsoft Attention color */ } p { margin: 15px 0; line-height: 1.7; color: #323130; /* Microsoft text color */ } .client-info { background-color: #F5F5F5; /* Light gray background for info boxes */ padding: 15px; border-radius: 4px; /* Adding some subtle rounding */ margin: 15px 0; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); border: 1px solid #EDEBE9; } .client-info p { display: flex; align-items: flex-start; margin: 8px 0; } .client-info strong { min-width: 160px; flex-shrink: 0; text-align: left; padding-right: 15px; color: #0078D4; /* Microsoft Blue */ } .client-info code { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; background-color: rgba(240, 240, 250, 0.5); padding: 2px 6px; border-radius: 4px; /* Adding some subtle rounding */ color: #0078D4; /* Microsoft Blue */ word-break: break-all; } .btn { display: inline-block; padding: 8px 16px; margin: 10px 0; border-radius: 4px; /* Adding some subtle rounding */ text-decoration: none; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } .btn-primary { background-color: #0078D4; /* Microsoft Blue */ color: white; border: none; } .btn-primary:hover { background-color: #106EBE; /* Microsoft Blue hover */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .btn-secondary { background-color: #D83B01; /* Microsoft Red */ color: white; /* White text */ border: none; } .btn-secondary:hover { background-color: #A80000; /* Darker red on hover */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .buttons { margin-top: 20px; display: flex; gap: 10px; justify-content: flex-start; } a { color: #0078D4; /* Microsoft Blue */ text-decoration: none; font-weight: 600; } a:hover { text-decoration: underline; } strong { color: #0078D4; /* Microsoft Blue */ font-weight: 600; } .error-message { background-color: #FDE7E9; /* Light red background */ padding: 15px; margin: 15px 0; border-radius: 4px; /* Adding some subtle rounding */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); border-left: 3px solid #D83B01; /* Microsoft Attention color */ } .error-message p { margin: 8px 0; } .error-message p:first-child { font-weight: 500; color: #D83B01; /* Microsoft Attention color */ }"; }" /> <!-- Consent page HTML template --> <set-variable name="consent_page_template" value="@{ return @"<html lang='en'> <head> <meta charset='UTF-8'> <meta name='viewport' content='width=device-width, initial-scale=1.0'> <title>Application Consent</title> <style> __COMMON_STYLES__ /* Additional styles for scopes list */ .scopes-list { margin: 0; padding-left: 0; } .scopes-list li { list-style-type: none; padding: 4px 0; display: flex; } </style> </head> <body> <div class='consent-container'> <h1 class='consent-heading'>Application Access Request</h1> <p>The following application is requesting access to <strong>{{MCPServerName}}</strong>, which might include access to everything <strong>{{MCPServerName}}</strong> has been and will be granted access to.</p> <div class='client-info'> <p><strong>Application Name:</strong> <code>__CLIENT_NAME__</code></p> <p><strong>Application Website:</strong> <code>__CLIENT_URI__</code></p> <p><strong>Application ID:</strong> <code>__CLIENT_ID_DISPLAY__</code></p> <p><strong>Redirect URI:</strong> <code>__REDIRECT_URI__</code></p> </div> <p>The application will have access to the following scopes, used by <strong>{{MCPServerName}}</strong>:</p> <div class='client-info'> <ul class='scopes-list'> <li>__OAUTH_SCOPES__</li> </ul> </div> <div class='buttons'> <form method='post' action='__CONSENT_ACTION_URL__' style='display: inline-block;'> <input type='hidden' name='client_id' value='__CLIENT_ID_FORM__'> <input type='hidden' name='redirect_uri' value='__REDIRECT_URI__'> <input type='hidden' name='state' value='__STATE__'> <input type='hidden' name='code_challenge' value='__CODE_CHALLENGE__'> <input type='hidden' name='code_challenge_method' value='__CODE_CHALLENGE_METHOD__'> <input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'> <input type='hidden' name='consent_action' value='allow'> <button type='submit' class='btn btn-primary'>Allow</button> </form> <form method='post' action='__CONSENT_ACTION_URL__' style='display: inline-block;'> <input type='hidden' name='client_id' value='__CLIENT_ID_FORM__'> <input type='hidden' name='redirect_uri' value='__REDIRECT_URI__'> <input type='hidden' name='state' value='__STATE__'> <input type='hidden' name='code_challenge' value='__CODE_CHALLENGE__'> <input type='hidden' name='code_challenge_method' value='__CODE_CHALLENGE_METHOD__'> <input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'> <input type='hidden' name='consent_action' value='deny'> <button type='submit' class='btn btn-secondary'>Deny</button> </form> </div> </div> </body> </html>"; }" /> <!-- Check for existing client denial cookie --> <set-variable name="has_denial_cookie" value="@{ try { string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(redirectUri)) { return false; } var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); if (string.IsNullOrEmpty(cookieHeader)) { return false; } string cookieName = "__Host-MCP_DENIED_CLIENTS"; string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(cookieName + "=")) { string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); try { string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(cookieValue.Split('.')[0])); JArray clients = JArray.Parse(decodedValue); string clientKey = $"{clientId}:{redirectUri}"; foreach (var item in clients) { string itemString = item.ToString(); if (itemString == clientKey) { return true; } // Handle URL-encoded redirect URI in stored cookie try { if (itemString.Contains(':')) { string[] parts = itemString.Split(new char[] {':'}, 2); if (parts.Length == 2) { string storedClientId = parts[0]; string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); if (storedClientId == clientId && storedRedirectUri == redirectUri) { return true; } } } } catch (Exception ex) { // Ignore comparison errors and continue } } } catch (Exception ex) { // Ignore cookie parsing errors and continue } } } return false; } catch (Exception ex) { return false; } }" /> <!-- Check for existing client approval cookie --> <set-variable name="has_approval_cookie" value="@{ try { string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(redirectUri)) { return false; } var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); if (string.IsNullOrEmpty(cookieHeader)) { return false; } string cookieName = "__Host-MCP_APPROVED_CLIENTS"; string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(cookieName + "=")) { string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); try { string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(cookieValue.Split('.')[0])); JArray clients = JArray.Parse(decodedValue); string clientKey = $"{clientId}:{redirectUri}"; foreach (var item in clients) { string itemString = item.ToString(); if (itemString == clientKey) { return true; } // Handle URL-encoded redirect URI in stored cookie try { if (itemString.Contains(':')) { string[] parts = itemString.Split(new char[] {':'}, 2); if (parts.Length == 2) { string storedClientId = parts[0]; string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); if (storedClientId == clientId && storedRedirectUri == redirectUri) { return true; } } } } catch (Exception ex) { // Ignore comparison errors and continue } } } catch (Exception ex) { // Ignore cookie parsing errors and continue } } } return false; } catch (Exception ex) { return false; } }" /> <set-variable name="consent_action" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "consent_action") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } // Fallback to query string (GET) string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("consent_action", ""); return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; }" /> <!-- Extract CSRF token from form data --> <set-variable name="csrf_token_from_form" value="@{ string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); // Check form data first (POST) if (!string.IsNullOrEmpty(formBody)) { string[] pairs = formBody.Split('&'); foreach (string pair in pairs) { string[] keyValue = pair.Split(new char[] {'='}, 2); if (keyValue.Length == 2 && keyValue[0] == "csrf_token") { return System.Net.WebUtility.UrlDecode(keyValue[1]); } } } // Fallback to query string (GET) string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("csrf_token", ""); return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; }" /> <!-- Validate CSRF token for POST requests --> <set-variable name="csrf_valid" value="@{ if (context.Request.Method != "POST") { return true; // Only validate POST requests } string submittedToken = context.Variables.GetValueOrDefault<string>("csrf_token_from_form", ""); if (string.IsNullOrEmpty(submittedToken)) { return false; } // Token cache lookup validation happens next string cacheKey = $"CSRF-{submittedToken}"; return true; // Initial validation passes, detailed validation follows }" /> <!-- Validate Origin/Referer headers for CSRF protection --> <set-variable name="origin_referer_valid" value="@{ if (context.Request.Method != "POST") { return true; // Only validate state-changing operations } // Get the target origin (expected origin) string targetOrigin = "{{APIMGatewayURL}}"; // Remove protocol and trailing slash for comparison if (targetOrigin.StartsWith("https://")) { targetOrigin = targetOrigin.Substring(8); } else if (targetOrigin.StartsWith("http://")) { targetOrigin = targetOrigin.Substring(7); } if (targetOrigin.EndsWith("/")) { targetOrigin = targetOrigin.TrimEnd('/'); } // First check Origin header (preferred) string originHeader = context.Request.Headers.GetValueOrDefault("Origin", ""); if (!string.IsNullOrEmpty(originHeader)) { try { Uri originUri = new Uri(originHeader); string sourceOrigin = originUri.Host; if (originUri.Port != 80 && originUri.Port != 443) { sourceOrigin += ":" + originUri.Port; } if (sourceOrigin.Equals(targetOrigin, StringComparison.OrdinalIgnoreCase)) { return true; } else { return false; } } catch (Exception ex) { return false; } } // Fallback to Referer header if Origin is not present string refererHeader = context.Request.Headers.GetValueOrDefault("Referer", ""); if (!string.IsNullOrEmpty(refererHeader)) { try { Uri refererUri = new Uri(refererHeader); string sourceOrigin = refererUri.Host; if (refererUri.Port != 80 && refererUri.Port != 443) { sourceOrigin += ":" + refererUri.Port; } if (sourceOrigin.Equals(targetOrigin, StringComparison.OrdinalIgnoreCase)) { return true; } else { return false; } } catch (Exception ex) { return false; } } // Neither Origin nor Referer header present - this is suspicious for POST requests // OWASP recommends blocking such requests for better security return false; // Block requests without proper origin validation }" /> <!-- Validate Fetch Metadata headers for CSRF protection --> <set-variable name="fetch_metadata_valid" value="@{ // Check Sec-Fetch-Site header for cross-site request detection string secFetchSite = context.Request.Headers.GetValueOrDefault("Sec-Fetch-Site", ""); // Allow same-origin, same-site, and direct navigation if (string.IsNullOrEmpty(secFetchSite) || secFetchSite == "same-origin" || secFetchSite == "same-site" || secFetchSite == "none") { return true; } // Block cross-site POST requests if (context.Request.Method == "POST" && secFetchSite == "cross-site") { return false; } // Allow other values for compatibility return true; }" /> <!-- Lookup CSRF token from cache --> <cache-lookup-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token_from_form")}")" variable-name="csrf_token_data" /> <!-- Validate CSRF token details --> <set-variable name="csrf_validation_result" value="@{ if (context.Request.Method != "POST") { return "valid"; // No validation needed for GET requests } string submittedToken = context.Variables.GetValueOrDefault<string>("csrf_token_from_form", ""); if (string.IsNullOrEmpty(submittedToken)) { return "missing_token"; } string tokenData = context.Variables.GetValueOrDefault<string>("csrf_token_data"); if (string.IsNullOrEmpty(tokenData)) { return "invalid_token"; } try { // Extract token data (before debug info separator) string actualTokenData = tokenData; if (tokenData.Contains("|")) { actualTokenData = tokenData.Split('|')[0]; } // Parse token data: client_id:redirect_uri:timestamp // Since both redirect_uri and timestamp can contain colons, we need to be very careful // The timestamp format is: YYYY-MM-DDTHH:mm:ssZ // So we look for the last occurrence of a timestamp pattern // Find the last occurrence of a timestamp pattern (YYYY-MM-DDTHH:mm:ssZ) var timestampPattern = @":\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"; var timestampMatch = System.Text.RegularExpressions.Regex.Match(actualTokenData, timestampPattern); if (!timestampMatch.Success) { return "malformed_token"; } // Extract the timestamp (without the leading colon) string timestampStr = timestampMatch.Value.Substring(1); // Extract everything before the timestamp match as the client_id:redirect_uri part string clientAndRedirect = actualTokenData.Substring(0, timestampMatch.Index); // Split client_id:redirect_uri on the first colon only int firstColonIndex = clientAndRedirect.IndexOf(':'); if (firstColonIndex == -1) { return "malformed_token"; } string tokenClientId = clientAndRedirect.Substring(0, firstColonIndex); string tokenRedirectUri = clientAndRedirect.Substring(firstColonIndex + 1); // Validate client_id and redirect_uri match using constant-time comparison string currentClientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string currentRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Constant-time string comparison for client_id to prevent timing attacks bool clientIdMatches = true; if (tokenClientId == null || currentClientId == null) { clientIdMatches = (tokenClientId == currentClientId); } else if (tokenClientId.Length != currentClientId.Length) { clientIdMatches = false; } else { int result = 0; for (int i = 0; i < tokenClientId.Length; i++) { result |= tokenClientId[i] ^ currentClientId[i]; } clientIdMatches = (result == 0); } if (!clientIdMatches) { return "client_mismatch"; } // Constant-time string comparison for redirect_uri to prevent timing attacks bool redirectUriMatches = true; if (tokenRedirectUri == null || currentRedirectUri == null) { redirectUriMatches = (tokenRedirectUri == currentRedirectUri); } else if (tokenRedirectUri.Length != currentRedirectUri.Length) { redirectUriMatches = false; } else { int result = 0; for (int i = 0; i < tokenRedirectUri.Length; i++) { result |= tokenRedirectUri[i] ^ currentRedirectUri[i]; } redirectUriMatches = (result == 0); } if (!redirectUriMatches) { return "redirect_mismatch"; } // Validate timestamp (token should not be older than 15 minutes) DateTime tokenTime; try { tokenTime = DateTime.Parse(timestampStr); } catch (Exception) { return "invalid_timestamp"; } TimeSpan age = DateTime.UtcNow - tokenTime; if (age.TotalMinutes > 15) { return "expired_token"; } return "valid"; } catch (Exception ex) { return "validation_error"; } }" /> <!-- If this is a form submission, process the consent choice --> <choose> <when condition="@(context.Request.Method == "POST")"> <!-- Validate Origin/Referer headers --> <choose> <when condition="@(!context.Variables.GetValueOrDefault<bool>("origin_referer_valid"))"> <!-- Origin/Referer validation failed --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> </return-response> </when> <otherwise> <!-- Origin/Referer validation passed --> <!-- Validate Fetch Metadata headers --> <choose> <when condition="@(!context.Variables.GetValueOrDefault<bool>("fetch_metadata_valid"))"> <!-- Fetch metadata validation failed --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> </return-response> </when> <otherwise> <!-- Fetch metadata validation passed --> <!-- Validate CSRF token --> <choose> <when condition="@(context.Variables.GetValueOrDefault<string>("csrf_validation_result") != "valid")"> <!-- CSRF validation failed --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> </return-response> </when> <otherwise> <!-- CSRF validation passed --> <!-- Delete CSRF token from cache to prevent reuse --> <cache-remove-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token_from_form")}")" /> <choose> <when condition="@(context.Variables.GetValueOrDefault<string>("consent_action") == "allow")"> <!-- Process consent approval --> <set-variable name="response_status_code" value="302" /> <set-variable name="response_redirect_location" value="@{ string baseUrl = "{{APIMGatewayURL}}"; string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); string originalState = context.Variables.GetValueOrDefault<string>("state", ""); string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); // State should be used as-is since it's already properly formatted from the original request string encodedState = originalState; // Add PKCE parameters if they exist string codeChallenge = context.Variables.GetValueOrDefault<string>("code_challenge", ""); string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("code_challenge_method", ""); string url = $"{baseUrl}/authorize?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}"; if (!string.IsNullOrEmpty(codeChallenge)) { url += $"&code_challenge={System.Net.WebUtility.UrlEncode(codeChallenge)}"; } if (!string.IsNullOrEmpty(codeChallengeMethod)) { url += $"&code_challenge_method={System.Net.WebUtility.UrlEncode(codeChallengeMethod)}"; } return url; }" /> <!-- Calculate approval cookie value --> <set-variable name="approval_cookie" value="@{ string cookieName = "__Host-MCP_APPROVED_CLIENTS"; // Use already extracted parameters instead of re-parsing form data string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Create a unique identifier for this client/redirect combination string clientKey = $"{clientId}:{redirectUri}"; // Check for existing cookie var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); JArray approvedClients = new JArray(); if (!string.IsNullOrEmpty(cookieHeader)) { // Parse cookies to find our approval cookie string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(cookieName + "=")) { try { // Extract and parse the cookie value string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); // Get the payload part (before the first dot if cookie is signed) string payload = cookieValue.Contains('.') ? cookieValue.Split('.')[0] : cookieValue; string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(payload)); approvedClients = JArray.Parse(decodedValue); } catch (Exception) { // If parsing fails, we'll just create a new cookie approvedClients = new JArray(); } break; } } } // Add the current client if not already in the list bool clientExists = false; foreach (var item in approvedClients) { if (item.ToString() == clientKey) { clientExists = true; break; } } if (!clientExists) { approvedClients.Add(clientKey); } // Base64 encode the client list string jsonClients = approvedClients.ToString(Newtonsoft.Json.Formatting.None); string encodedClients = System.Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(jsonClients)); // Return the full cookie string with appropriate settings return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; }" /> <!-- Set variables for outbound policy awareness --> <set-variable name="consent_approved" value="true" /> <set-variable name="cookie_name" value="__Host-MCP_APPROVED_CLIENTS" /> <!-- Return the response with the cookie already set --> <return-response> <set-status code="302" reason="Found" /> <set-header name="Location" exists-action="override"> <value>@(context.Variables.GetValueOrDefault<string>("response_redirect_location", ""))</value> </set-header> <set-header name="Set-Cookie" exists-action="append"> <value>@(context.Variables.GetValueOrDefault<string>("approval_cookie"))</value> </set-header> </return-response> </when> <when condition="@(context.Variables.GetValueOrDefault<string>("consent_action") == "deny")"> <!-- Process consent denial --> <set-variable name="response_status_code" value="403" /> <set-variable name="response_content_type" value="text/html" /> <set-variable name="response_cache_control" value="no-store, no-cache" /> <set-variable name="response_pragma" value="no-cache" /> <!-- Calculate the cookie value right here in inbound before returning response --> <set-variable name="denial_cookie" value="@{ string cookieName = "__Host-MCP_DENIED_CLIENTS"; // Use already extracted parameters instead of re-parsing form data string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Create a unique identifier for this client/redirect combination string clientKey = $"{clientId}:{redirectUri}"; // Check for existing cookie var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); JArray deniedClients = new JArray(); if (!string.IsNullOrEmpty(cookieHeader)) { // Parse cookies to find our denial cookie string[] cookies = cookieHeader.Split(';'); foreach (string cookie in cookies) { string trimmedCookie = cookie.Trim(); if (trimmedCookie.StartsWith(cookieName + "=")) { try { // Extract and parse the cookie value string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); // Get the payload part (before the first dot if cookie is signed) string payload = cookieValue.Contains('.') ? cookieValue.Split('.')[0] : cookieValue; string decodedValue = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String(payload)); deniedClients = JArray.Parse(decodedValue); } catch (Exception) { // If parsing fails, we'll just create a new cookie deniedClients = new JArray(); } break; } } } // Add the current client if not already in the list bool clientExists = false; foreach (var item in deniedClients) { if (item.ToString() == clientKey) { clientExists = true; break; } } if (!clientExists) { deniedClients.Add(clientKey); } // Base64 encode the client list string jsonClients = deniedClients.ToString(Newtonsoft.Json.Formatting.None); string encodedClients = System.Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(jsonClients)); // Return the full cookie string with appropriate settings return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; }" /> <!-- Store the HTML content for the access denied page --> <set-variable name="response_body" value="@{ string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); // Replace placeholders with actual content denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", "You have denied authorization for this application against the MCP server."); return denialTemplate; }" /> <!-- Set variables for outbound policy awareness --> <set-variable name="consent_denied" value="true" /> <set-variable name="cookie_name" value="__Host-MCP_DENIED_CLIENTS" /> <!-- Return the response with the cookie already set --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-header name="Set-Cookie" exists-action="append"> <value>@(context.Variables.GetValueOrDefault<string>("denial_cookie"))</value> </set-header> <set-body>@(context.Variables.GetValueOrDefault<string>("response_body", ""))</set-body> </return-response> </when> <otherwise> <!-- Invalid consent action - return error --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <!-- Explicitly disable any redirects --> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@{ string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); string consentAction = context.Variables.GetValueOrDefault<string>("consent_action", ""); string detailedMessage = $"Invalid consent action '{consentAction}' received. Expected 'allow' or 'deny'. This may indicate a form tampering attempt or a browser compatibility issue."; // Replace placeholders with actual content denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", detailedMessage); return denialTemplate; }</set-body> </return-response> </otherwise> </choose> </otherwise> </choose> </otherwise> </choose> </otherwise> </choose> </when> <!-- For GET requests, check for cookies first, then display consent page if no cookie found --> <otherwise> <choose> <!-- If there's an approval cookie, skip consent and redirect to authorization endpoint --> <when condition="@(context.Variables.GetValueOrDefault<bool>("has_approval_cookie"))"> <!-- Set redirect location to authorization endpoint --> <set-variable name="response_redirect_location" value="@{ string baseUrl = "{{APIMGatewayURL}}"; string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); string state = context.Variables.GetValueOrDefault<string>("state", ""); // URL encode parameters to prevent injection attacks string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); // State is already properly encoded, don't double-encode string encodedState = state; // Add PKCE parameters if they exist string codeChallenge = context.Variables.GetValueOrDefault<string>("code_challenge", ""); string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("code_challenge_method", ""); string url = $"{baseUrl}/authorize?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}"; if (!string.IsNullOrEmpty(codeChallenge)) { url += $"&code_challenge={System.Net.WebUtility.UrlEncode(codeChallenge)}"; } if (!string.IsNullOrEmpty(codeChallengeMethod)) { url += $"&code_challenge_method={System.Net.WebUtility.UrlEncode(codeChallengeMethod)}"; } return url; }" /> <!-- Redirect to authorization endpoint --> <return-response> <set-status code="302" reason="Found" /> <set-header name="Location" exists-action="override"> <value>@(context.Variables.GetValueOrDefault<string>("response_redirect_location", ""))</value> </set-header> </return-response> </when> <!-- If there's a denial cookie, return access denied page immediately --> <when condition="@(context.Variables.GetValueOrDefault<bool>("has_denial_cookie"))"> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <!-- Explicitly disable any redirects --> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@{ string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); // Replace placeholders with actual content denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", "You have previously denied access to this application."); return denialTemplate; }</set-body> </return-response> </when> <!-- If no cookies found, show the consent screen --> <otherwise> <!-- Check if client is registered first --> <choose> <when condition="@(!context.Variables.GetValueOrDefault<bool>("is_client_registered"))"> <!-- Client is not registered, show error page --> <return-response> <set-status code="403" reason="Forbidden" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <set-body>@{ string template = context.Variables.GetValueOrDefault<string>("client_not_found_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Replace placeholders with HTML-encoded content to prevent XSS template = template.Replace("__COMMON_STYLES__", commonStyles); template = template.Replace("__CLIENT_ID_DISPLAY__", System.Net.WebUtility.HtmlEncode(clientId)); template = template.Replace("__REDIRECT_URI__", System.Net.WebUtility.HtmlEncode(redirectUri)); return template; }</set-body> </return-response> </when> <otherwise> <!-- Client is registered, get client name from the cache --> <!-- Build consent page using the standardized template --> <set-variable name="consent_page" value="@{ string template = context.Variables.GetValueOrDefault<string>("consent_page_template"); string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); // Use the service URL from APIM configuration string basePath = "{{APIMGatewayURL}}"; string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string clientName = context.Variables.GetValueOrDefault<string>("client_name", "Unknown Application"); string clientUri = context.Variables.GetValueOrDefault<string>("client_uri", "N/A"); string oauthScopes = context.Variables.GetValueOrDefault<string>("oauth_scopes", ""); // Get the normalized (human-readable) redirect URI for display string normalizedRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Use the normalized redirect URI for form submission to ensure consistency string formRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); string htmlEncodedFormUri = System.Net.WebUtility.HtmlEncode(formRedirectUri); string state = context.Variables.GetValueOrDefault<string>("state", ""); string csrfToken = context.Variables.GetValueOrDefault<string>("csrf_token", ""); // Create a temporary placeholder for the form fields string FORM_FIELD_PLACEHOLDER = "___ENCODED_REDIRECT_URI___"; // Replace the styles first template = template.Replace("__COMMON_STYLES__", commonStyles); // First, create a temporary placeholder for the form fields template = template.Replace("value='__REDIRECT_URI__'", "value='" + FORM_FIELD_PLACEHOLDER + "'"); // Replace template placeholders with properly encoded values template = template.Replace("__CLIENT_NAME__", System.Net.WebUtility.HtmlEncode(clientName)); template = template.Replace("__CLIENT_URI__", System.Net.WebUtility.HtmlEncode(clientUri)); // For display purposes, use HtmlEncode for safety template = template.Replace("__CLIENT_ID_DISPLAY__", System.Net.WebUtility.HtmlEncode(clientId)); template = template.Replace("__REDIRECT_URI__", System.Net.WebUtility.HtmlEncode(normalizedRedirectUri)); // For form field values, use HtmlEncode for XSS protection template = template.Replace("__CLIENT_ID_FORM__", System.Net.WebUtility.HtmlEncode(clientId)); // State should be HTML-encoded for form safety (don't URL-decode first as it may already be in correct format) template = template.Replace("__STATE__", System.Net.WebUtility.HtmlEncode(state)); template = template.Replace("__CODE_CHALLENGE__", System.Net.WebUtility.HtmlEncode(context.Variables.GetValueOrDefault<string>("code_challenge", ""))); template = template.Replace("__CODE_CHALLENGE_METHOD__", System.Net.WebUtility.HtmlEncode(context.Variables.GetValueOrDefault<string>("code_challenge_method", ""))); template = template.Replace("__CSRF_TOKEN__", System.Net.WebUtility.HtmlEncode(csrfToken)); template = template.Replace("__CONSENT_ACTION_URL__", basePath + "/consent"); // Handle space-separated OAuth scopes and create individual list items with HTML encoding string[] scopeArray = oauthScopes.Split(new char[] {' '}, StringSplitOptions.RemoveEmptyEntries); StringBuilder scopeList = new StringBuilder(); foreach (string scope in scopeArray) { scopeList.AppendLine($"<li><code>{System.Net.WebUtility.HtmlEncode(scope)}</code></li>"); } template = template.Replace("__OAUTH_SCOPES__", scopeList.ToString()); // Replace form field placeholder with encoded URI template = template.Replace(FORM_FIELD_PLACEHOLDER, htmlEncodedFormUri); return template; }" /> <!-- Return consent page --> <return-response> <set-status code="200" reason="OK" /> <set-header name="Content-Type" exists-action="override"> <value>text/html</value> </set-header> <!-- Security headers --> <set-header name="X-Frame-Options" exists-action="override"> <value>DENY</value> </set-header> <set-header name="X-Content-Type-Options" exists-action="override"> <value>nosniff</value> </set-header> <set-header name="X-XSS-Protection" exists-action="override"> <value>1; mode=block</value> </set-header> <set-header name="Referrer-Policy" exists-action="override"> <value>strict-origin-when-cross-origin</value> </set-header> <set-header name="Content-Security-Policy" exists-action="override"> <value>default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https:</value> </set-header> <set-header name="Cache-Control" exists-action="override"> <value>no-store, no-cache, must-revalidate</value> </set-header> <set-header name="Pragma" exists-action="override"> <value>no-cache</value> </set-header> <!-- Store the state parameter in a secure cookie for validation --> <set-header name="Set-Cookie" exists-action="append"> <value>@{ string state = context.Variables.GetValueOrDefault<string>("state", ""); string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); // Create consent context data var consentData = new JObject { ["state"] = state, ["clientId"] = clientId, ["redirectUri"] = redirectUri, ["timestamp"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }; // Base64 encode the consent data string consentDataJson = consentData.ToString(Newtonsoft.Json.Formatting.None); string encodedConsentData = System.Convert.ToBase64String( System.Text.Encoding.UTF8.GetBytes(consentDataJson)); return $"__Host-MCP_CONSENT_STATE={encodedConsentData}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax"; }</value> </set-header> <set-body>@{ return context.Variables.GetValueOrDefault<string>("consent_page", ""); }</set-body> </return-response> </otherwise> </choose> </otherwise> </choose> </otherwise> </choose> </inbound> <backend> <base /> </backend> <outbound> <base /> </outbound> <on-error> <base /> </on-error> </policies> ```