# 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: -------------------------------------------------------------------------------- ``` 1 | .venv ``` -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- ``` 1 | bin 2 | obj 3 | csx 4 | .vs 5 | edge 6 | Publish 7 | 8 | *.user 9 | *.suo 10 | *.cscfg 11 | *.Cache 12 | project.lock.json 13 | 14 | /packages 15 | /TestResults 16 | 17 | /tools/NuGet.exe 18 | /App_Data 19 | /secrets 20 | /data 21 | .secrets 22 | appsettings.json 23 | 24 | node_modules 25 | dist 26 | 27 | # Local python packages 28 | .python_packages/ 29 | 30 | # Python Environments 31 | .env 32 | .venv 33 | env/ 34 | venv/ 35 | ENV/ 36 | env.bak/ 37 | venv.bak/ 38 | 39 | # Byte-compiled / optimized / DLL files 40 | __pycache__/ 41 | *.py[cod] 42 | *$py.class 43 | 44 | # Azurite artifacts 45 | __blobstorage__ 46 | __queuestorage__ 47 | __azurite_db*__.json ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | <!-- 2 | --- 3 | name: Remote MCP using Azure API Management 4 | description: Use Azure API Management as the AI Gateway for MCP Servers using Azure Functions 5 | page_type: sample 6 | languages: 7 | - python 8 | - bicep 9 | - azdeveloper 10 | products: 11 | - azure-api-management 12 | - azure-functions 13 | - azure 14 | urlFragment: remote-mcp-apim-functions-python 15 | --- 16 | --> 17 | 18 | # Secure Remote MCP Servers using Azure API Management (Experimental) 19 | 20 |  21 | 22 | Azure API Management acts as the [AI Gateway](https://github.com/Azure-Samples/AI-Gateway) for MCP servers. 23 | 24 | This sample implements the latest [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-10-third-party-authorization-flow) 25 | 26 | This is a [sequence diagram](infra/app/apim-oauth/diagrams/diagrams.md) to understand the flow. 27 | 28 | ## Deploy Remote MCP Server to Azure 29 | 30 | 1. Register `Microsoft.App` resource provider. 31 | * If you are using Azure CLI, run `az provider register --namespace Microsoft.App --wait`. 32 | * 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. 33 | 34 | 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 35 | 36 | ```shell 37 | azd up 38 | ``` 39 | 40 | ### Test with MCP Inspector 41 | 42 | 1. In a **new terminal window**, install and run MCP Inspector 43 | 44 | ```shell 45 | npx @modelcontextprotocol/inspector 46 | ``` 47 | 48 | 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) 49 | 1. Set the transport type to `SSE` 50 | 1. Set the URL to your running API Management SSE endpoint displayed after `azd up` and **Connect**: 51 | 52 | ```shell 53 | https://<apim-servicename-from-azd-output>.azure-api.net/mcp/sse 54 | ``` 55 | 56 | 5. **List Tools**. Click on a tool and **Run Tool**. 57 | 58 | 59 | ## Technical Architecture Overview 60 | 61 | 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. 62 | 63 |  64 | 65 | ### Deployed Azure Resources 66 | 67 | The infrastructure provisions the following Azure resources: 68 | 69 | #### Core Gateway Infrastructure 70 | - **Azure API Management (APIM)** - The central security gateway that exposes both OAuth and MCP APIs 71 | - **SKU**: BasicV2 (configurable) 72 | - **Identity**: System-assigned and user-assigned managed identities 73 | - **Purpose**: Handles authentication flows, request validation, and secure proxying to backend services 74 | 75 | #### Backend Compute 76 | - **Azure Function App** - Hosts the MCP server implementation 77 | - **Runtime**: Python 3.11 on Flex Consumption plan 78 | - **Authentication**: Function-level authentication with managed identity integration 79 | - **Purpose**: Executes MCP tools and operations (snippet management in this example) 80 | 81 | #### Storage and Data 82 | - **Azure Storage Account** - Provides multiple storage functions 83 | - **Function hosting**: Stores function app deployment packages 84 | - **Application data**: Blob container for snippet storage 85 | - **Security**: Configured with managed identity access and optional private endpoints 86 | 87 | #### Security and Identity 88 | - **User-Assigned Managed Identity** - Enables secure service-to-service authentication 89 | - **Purpose**: Allows Function App to access Storage and Application Insights without secrets 90 | - **Permissions**: Storage Blob Data Owner, Storage Queue Data Contributor, Monitoring Metrics Publisher 91 | 92 | - **Entra ID Application Registration** - OAuth2/OpenID Connect client for authentication 93 | - **Purpose**: Enables third-party authorization flow per MCP specification 94 | - **Configuration**: PKCE-enabled public client with custom redirect URIs 95 | 96 | #### Monitoring and Observability 97 | - **Application Insights** - Provides telemetry and monitoring 98 | - **Log Analytics Workspace** - Centralized logging and analytics 99 | 100 | #### Optional Network Security 101 | - **Virtual Network (VNet)** - When `vnetEnabled` is true 102 | - **Private Endpoints**: Secure connectivity to Storage Account 103 | - **Network Isolation**: Functions and storage communicate over private network 104 | 105 | ### Why These Resources? 106 | 107 | **Azure API Management** serves as the security perimeter, implementing: 108 | - OAuth 2.0/PKCE authentication flows per MCP specification 109 | - Session key encryption/decryption for secure API access 110 | - Request validation and header injection 111 | - Rate limiting and throttling capabilities 112 | - Centralized policy management 113 | 114 | **Azure Functions** provides: 115 | - Serverless, pay-per-use compute model 116 | - Native integration with Azure services 117 | - Automatic scaling based on demand 118 | - Built-in monitoring and diagnostics 119 | 120 | **Managed Identities** eliminate the need for: 121 | - Service credentials management 122 | - Secret rotation processes 123 | - Credential exposure risks 124 | 125 | ## Azure API Management Configuration Details 126 | 127 | The APIM instance is configured with two primary APIs that work together to implement the MCP authorization specification: 128 | 129 | ### OAuth API (`/oauth/*`) 130 | 131 | This API implements the complete OAuth 2.0 authorization server functionality required by the MCP specification: 132 | 133 | #### Endpoints and Operations 134 | 135 | **Authorization Endpoint** (`GET /authorize`) 136 | - **Purpose**: Initiates the OAuth 2.0/PKCE flow 137 | - **Policy Logic**: 138 | 1. Extracts PKCE parameters from MCP client request 139 | 2. Checks for existing user consent (via cookies) 140 | 3. Redirects to consent page if consent not granted 141 | 4. Generates new PKCE parameters for Entra ID communication 142 | 5. Stores authentication state in APIM cache 143 | 6. Redirects user to Entra ID for authentication 144 | 145 | **Consent Management** (`GET/POST /consent`) 146 | - **Purpose**: Handles user consent for MCP client access 147 | - **Features**: Consent persistence via secure cookies 148 | 149 | **OAuth Metadata Endpoint** (`GET /.well-known/oauth-authorization-server`) 150 | - **Purpose**: Publishes OAuth server configuration per RFC 8414 151 | - **Returns**: JSON metadata about supported endpoints, flows, and capabilities 152 | 153 | **Client Registration** (`POST /register`) 154 | - **Purpose**: Supports dynamic client registration per MCP specification 155 | 156 | **Token Endpoint** (`POST /token`) 157 | - **Purpose**: Exchanges authorization codes for access tokens 158 | - **Policy Logic**: 159 | 1. Validates authorization code and PKCE verifier from MCP client 160 | 2. Exchanges Entra ID authorization code for access tokens 161 | 3. Generates encrypted session key for MCP API access 162 | 4. Caches the access token with session key mapping 163 | 5. Returns encrypted session key to MCP client 164 | 165 | #### Named Values and Configuration 166 | 167 | The OAuth API uses several APIM Named Values for configuration: 168 | - `McpClientId` - The registered Entra ID application client ID 169 | - `EntraIDFicClientId` - Service identity client ID for token exchange 170 | - `APIMGatewayURL` - Base URL for callback and metadata endpoints 171 | - `OAuthScopes` - Requested OAuth scopes (`openid` + Microsoft Graph) 172 | - `EncryptionKey` / `EncryptionIV` - For session key encryption 173 | 174 | ### MCP API (`/mcp/*`) 175 | 176 | This API provides the actual MCP protocol endpoints with security enforcement: 177 | 178 | #### Endpoints and Operations 179 | 180 | **Server-Sent Events Endpoint** (`GET /sse`) 181 | - **Purpose**: Establishes real-time communication channel for MCP protocol 182 | - **Security**: Requires valid encrypted session token 183 | 184 | **Message Endpoint** (`POST /message`) 185 | - **Purpose**: Handles MCP protocol messages and tool invocations 186 | - **Security**: Requires valid encrypted session token 187 | 188 | #### Security Policy Implementation 189 | 190 | The MCP API applies a comprehensive security policy to all operations: 191 | 192 | 1. **Authorization Header Validation** 193 | ```xml 194 | <check-header name="Authorization" failed-check-httpcode="401" 195 | failed-check-error-message="Not authorized" ignore-case="false" /> 196 | ``` 197 | 198 | 2. **Session Key Decryption** 199 | - Extracts encrypted session key from Authorization header 200 | - Decrypts using AES with stored key and IV 201 | - Validates token format and structure 202 | 203 | 3. **Token Cache Lookup** 204 | ```xml 205 | <cache-lookup-value key="@($"EntraToken-{context.Variables.GetValueOrDefault("decryptedSessionKey")}")" 206 | variable-name="accessToken" /> 207 | ``` 208 | 209 | 4. **Access Token Validation** 210 | - Verifies cached access token exists and is valid 211 | - Returns 401 with proper WWW-Authenticate header if invalid 212 | 213 | 5. **Backend Authentication** 214 | ```xml 215 | <set-header name="x-functions-key" exists-action="override"> 216 | <value>{{function-host-key}}</value> 217 | </set-header> 218 | ``` 219 | 220 | ### Security Model 221 | 222 | The solution implements a sophisticated multi-layer security model: 223 | 224 | **Layer 1: OAuth 2.0/PKCE Authentication** 225 | - MCP clients must complete full OAuth flow with Entra ID 226 | - PKCE prevents authorization code interception attacks 227 | - User consent management with persistent preferences 228 | 229 | **Layer 2: Session Key Encryption** 230 | - Access tokens are never exposed to MCP clients 231 | - Encrypted session keys provide time-bounded access 232 | - AES encryption with secure key management in APIM 233 | 234 | **Layer 3: Function-Level Security** 235 | - Function host keys protect direct access to Azure Functions 236 | - Managed identity ensures secure service-to-service communication 237 | - Network isolation available via VNet integration 238 | 239 | **Layer 4: Azure Platform Security** 240 | - All traffic encrypted in transit (TLS) 241 | - Storage access via managed identities 242 | - Audit logging through Application Insights 243 | 244 | This layered approach ensures that even if one security boundary is compromised, multiple additional protections remain in place. 245 | 246 | 247 | 248 | ``` -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- ```markdown 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [[email protected]](mailto:[email protected]) with questions or concerns 10 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- ```markdown 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [[email protected]](mailto:[email protected]) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## <a name="coc"></a> Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## <a name="issue"></a> Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## <a name="feature"></a> Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## <a name="submit"></a> Submission Guidelines 36 | 37 | ### <a name="submit-issue"></a> Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | 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]. 55 | 56 | ### <a name="submit-pr"></a> Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | ``` -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-python.python" 5 | ] 6 | } ``` -------------------------------------------------------------------------------- /src/.vscode/extensions.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-python.python" 5 | ] 6 | } ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/diagrams/diagrams.md: -------------------------------------------------------------------------------- ```markdown 1 | # Sequence Diagrams 2 | 3 | ## MCP Client Auth Flow 4 | 5 |  6 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [tool.ruff] 2 | line-length = 120 3 | target-version = "py311" 4 | lint.select = ["E", "F", "I", "UP", "A"] 5 | lint.ignore = ["D203"] 6 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | ## [project-title] Changelog 2 | 3 | <a name="x.y.z"></a> 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | ``` -------------------------------------------------------------------------------- /src/local.settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "python", 5 | "AzureWebJobsStorage": "UseDevelopmentStorage=true" 6 | } 7 | } ``` -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- ``` 1 | # Do not include azure-functions-worker in this file 2 | # The Python Worker is managed by the Azure Functions platform 3 | # Manually managing azure-functions-worker may cause unexpected issues 4 | 5 | azure-functions 6 | ``` -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "label": "func: host start", 7 | "command": "host start", 8 | "problemMatcher": "$func-python-watch", 9 | "isBackground": true, 10 | "options": { 11 | "cwd": "${workspaceFolder}/src" 12 | } 13 | } 14 | ] 15 | } ``` -------------------------------------------------------------------------------- /src/.vscode/launch.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Python Functions", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "connect": { 9 | "host": "localhost", 10 | "port": 9091 11 | }, 12 | "preLaunchTask": "func: host start" 13 | } 14 | ] 15 | } ``` -------------------------------------------------------------------------------- /infra/bicepconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "experimentalFeaturesEnabled": { 3 | "extensibility": true 4 | }, 5 | // specify an alias for the version of the v1.0 dynamic types package you want to use 6 | "extensions": { 7 | "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.2.0-preview" 8 | } 9 | } 10 | ``` -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "azureFunctions.deploySubpath": "src", 3 | "azureFunctions.scmDoBuildDuringDeployment": true, 4 | "azureFunctions.projectLanguage": "Python", 5 | "azureFunctions.projectRuntime": "~4", 6 | "debug.internalConsoleOptions": "neverOpen", 7 | "azureFunctions.projectLanguageModel": 2 8 | } ``` -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: remote-mcp-apim-functions-python 4 | metadata: 5 | template: [email protected] 6 | services: 7 | api: 8 | project: ./src/ 9 | language: python 10 | host: function 11 | ``` -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", 13 | "version": "[4.*, 5.0.0)" 14 | } 15 | } ``` -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Python Functions", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "connect": { 9 | "host": "localhost", 10 | "port": 9091 11 | }, 12 | "preLaunchTask": "func: host start" 13 | } 14 | ] 15 | } ``` -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "vnetEnabled": { 12 | "value": "${VNET_ENABLED=true}" 13 | }, 14 | "apimSku": { 15 | "value": "Basicv2" 16 | } 17 | } 18 | } ``` -------------------------------------------------------------------------------- /src/.vscode/settings.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "files.exclude": { 3 | "obj": true, 4 | "bin": true 5 | }, 6 | "azureFunctions.deploySubpath": ".", 7 | "azureFunctions.scmDoBuildDuringDeployment": true, 8 | "azureFunctions.pythonVenv": ".venv", 9 | "azureFunctions.projectLanguage": "Python", 10 | "azureFunctions.projectRuntime": "~4", 11 | "debug.internalConsoleOptions": "neverOpen", 12 | "azureFunctions.projectLanguageModel": 2, 13 | "azureFunctions.preDeployTask": "func: extensions install" 14 | } ``` -------------------------------------------------------------------------------- /src/.vscode/tasks.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "pip install (functions)", 6 | "type": "shell", 7 | "osx": { 8 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" 9 | }, 10 | "windows": { 11 | "command": "${config:azureFunctions.pythonVenv}\\Scripts\\python -m pip install -r requirements.txt" 12 | }, 13 | "linux": { 14 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" 15 | }, 16 | "problemMatcher": [] 17 | }, 18 | { 19 | "type": "func", 20 | "label": "func: host start", 21 | "command": "host start", 22 | "problemMatcher": "$func-python-watch", 23 | "isBackground": true, 24 | "dependsOn": "func: extensions install" 25 | }, 26 | { 27 | "type": "func", 28 | "command": "extensions install", 29 | "dependsOn": "pip install (functions)", 30 | "problemMatcher": [] 31 | } 32 | ] 33 | } ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- ```markdown 1 | <!-- 2 | IF SUFFICIENT INFORMATION IS NOT PROVIDED VIA THE FOLLOWING TEMPLATE THE ISSUE MIGHT BE CLOSED WITHOUT FURTHER CONSIDERATION OR INVESTIGATION 3 | --> 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | ``` -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- ```markdown 1 | ## Purpose 2 | <!-- Describe the intention of the changes being proposed. What problem does it solve or functionality does it add? --> 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | <!-- Mark one with an "x". --> 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | <!-- Please check the one that applies to this PR using "x". --> 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | <!-- Add steps to run the tests suite and/or manually test --> 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | <!-- Add any other helpful information that may be needed here. --> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-options.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | OAUTH METADATA OPTIONS POLICY 3 | This policy handles OPTIONS requests to the OAuth metadata endpoint, implementing CORS support 4 | for cross-origin requests to the OAuth authorization server. 5 | --> 6 | <policies> 7 | <inbound> 8 | <!-- Return CORS headers for OPTIONS requests --> 9 | <return-response> 10 | <set-status code="200" reason="OK" /> 11 | <set-header name="Access-Control-Allow-Origin" exists-action="override"> 12 | <value>*</value> 13 | </set-header> 14 | <set-header name="Access-Control-Allow-Methods" exists-action="override"> 15 | <value>GET, OPTIONS</value> 16 | </set-header> 17 | <set-header name="Access-Control-Allow-Headers" exists-action="override"> 18 | <value>Content-Type, Authorization</value> 19 | </set-header> 20 | <set-header name="Access-Control-Max-Age" exists-action="override"> 21 | <value>86400</value> 22 | </set-header> 23 | <set-body /> 24 | </return-response> 25 | <base /> 26 | </inbound> 27 | <backend> 28 | <base /> 29 | </backend> 30 | <outbound> 31 | <base /> 32 | </outbound> 33 | <on-error> 34 | <base /> 35 | </on-error> 36 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/register-options.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | REGISTER OPTIONS POLICY 3 | This policy handles the OPTIONS pre-flight requests for the OAuth client registration endpoint. 4 | It returns the appropriate CORS headers to allow cross-origin requests. 5 | --> 6 | <policies> 7 | <inbound> 8 | <!-- Return a 200 OK response with appropriate CORS headers --> 9 | <return-response> 10 | <set-status code="200" reason="OK" /> 11 | <set-header name="Access-Control-Allow-Origin" exists-action="override"> 12 | <value>*</value> 13 | </set-header> 14 | <set-header name="Access-Control-Allow-Methods" exists-action="override"> 15 | <value>GET, OPTIONS</value> 16 | </set-header> 17 | <set-header name="Access-Control-Allow-Headers" exists-action="override"> 18 | <value>Content-Type, Authorization</value> 19 | </set-header> 20 | <set-header name="Access-Control-Max-Age" exists-action="override"> 21 | <value>86400</value> 22 | </set-header> 23 | <set-body /> 24 | </return-response> 25 | <base /> 26 | </inbound> 27 | <backend> 28 | <base /> 29 | </backend> 30 | <outbound> 31 | <base /> 32 | </outbound> 33 | <on-error> 34 | <base /> 35 | </on-error> 36 | </policies> 37 | ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-get.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | OAUTH METADATA POLICY 3 | This policy implements the OpenID Connect and OAuth 2.0 discovery endpoint (.well-known/oauth-authorization-server). 4 | --> 5 | <policies> 6 | <inbound> 7 | <!-- Return the OAuth metadata in JSON format --> 8 | <return-response> 9 | <set-status code="200" reason="OK" /> 10 | <set-header name="Content-Type" exists-action="override"> 11 | <value>application/json; charset=utf-8</value> 12 | </set-header> 13 | <set-header name="access-control-allow-origin" exists-action="override"> 14 | <value>*</value> 15 | </set-header> 16 | <set-body> 17 | { 18 | "issuer": "{{APIMGatewayURL}}", 19 | "service_documentation": "https://microsoft.com/", 20 | "authorization_endpoint": "{{APIMGatewayURL}}/authorize", 21 | "token_endpoint": "{{APIMGatewayURL}}/token", 22 | "revocation_endpoint": "{{APIMGatewayURL}}/revoke", 23 | "registration_endpoint": "{{APIMGatewayURL}}/register", 24 | "response_types_supported": [ 25 | "code" 26 | ], 27 | "code_challenge_methods_supported": [ 28 | "S256" 29 | ], 30 | "token_endpoint_auth_methods_supported": [ 31 | "none" 32 | ], 33 | "grant_types_supported": [ 34 | "authorization_code", 35 | "refresh_token" 36 | ], 37 | "revocation_endpoint_auth_methods_supported": [ 38 | "client_secret_post" 39 | ] 40 | } 41 | </set-body> 42 | </return-response> 43 | <base /> 44 | </inbound> 45 | <backend> 46 | <base /> 47 | </backend> 48 | <outbound> 49 | <base /> 50 | </outbound> 51 | <on-error> 52 | <base /> 53 | </on-error> 54 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-mcp/mcp-api.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | MCP API POLICY 3 | This policy applies to all operations in the MCP API. 4 | It adds authorization header check for security. 5 | --> 6 | <policies> 7 | <inbound> 8 | <base /> 9 | <check-header name="Authorization" failed-check-httpcode="401" failed-check-error-message="Not authorized" ignore-case="false" /> 10 | <set-variable name="IV" value="{{EncryptionIV}}" /> 11 | <set-variable name="key" value="{{EncryptionKey}}" /> 12 | <set-variable name="decryptedSessionKey" value="@{ 13 | // Retrieve the encrypted session key from the request header 14 | string authHeader = context.Request.Headers.GetValueOrDefault("Authorization"); 15 | 16 | string encryptedSessionKey = authHeader.StartsWith("Bearer ") ? authHeader.Substring(7) : authHeader; 17 | 18 | // Decrypt the session key using AES 19 | byte[] IV = Convert.FromBase64String((string)context.Variables["IV"]); 20 | byte[] key = Convert.FromBase64String((string)context.Variables["key"]); 21 | 22 | byte[] encryptedBytes = Convert.FromBase64String(encryptedSessionKey); 23 | byte[] decryptedBytes = encryptedBytes.Decrypt("Aes", key, IV); 24 | 25 | return Encoding.UTF8.GetString(decryptedBytes); 26 | }" /> 27 | <cache-lookup-value key="@($"EntraToken-{context.Variables.GetValueOrDefault("decryptedSessionKey")}")" variable-name="accessToken" /> 28 | 29 | <choose> 30 | <when condition="@(context.Variables.GetValueOrDefault("accessToken") == null)"> 31 | <return-response> 32 | <set-status code="401" reason="Unauthorized" /> 33 | <set-header name="WWW-Authenticate" exists-action="override"> 34 | <value>Bearer error="invalid_token"</value> 35 | </set-header> 36 | </return-response> 37 | </when> 38 | </choose> 39 | 40 | <set-header name="x-functions-key" exists-action="override"> 41 | <value>{{function-host-key}}</value> 42 | </set-header> 43 | </inbound> 44 | <backend> 45 | <base /> 46 | </backend> 47 | <outbound> 48 | <base /> 49 | </outbound> 50 | <on-error> 51 | <base /> 52 | </on-error> 53 | </policies> 54 | ``` -------------------------------------------------------------------------------- /src/function_app.py: -------------------------------------------------------------------------------- ```python 1 | from dataclasses import dataclass 2 | import json 3 | import logging 4 | 5 | import azure.functions as func 6 | 7 | app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) 8 | 9 | # Constants for the Azure Blob Storage container, file, and blob path 10 | _SNIPPET_NAME_PROPERTY_NAME = "snippetname" 11 | _SNIPPET_PROPERTY_NAME = "snippet" 12 | _BLOB_PATH = "snippets/{mcptoolargs." + _SNIPPET_NAME_PROPERTY_NAME + "}.json" 13 | 14 | 15 | @dataclass 16 | class ToolProperty: 17 | propertyName: str 18 | propertyType: str 19 | description: str 20 | 21 | 22 | # Define the tool properties using the ToolProperty class 23 | tool_properties_save_snippets_object = [ 24 | ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet."), 25 | ToolProperty(_SNIPPET_PROPERTY_NAME, "string", "The content of the snippet."), 26 | ] 27 | 28 | tool_properties_get_snippets_object = [ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet.")] 29 | 30 | # Convert the tool properties to JSON 31 | tool_properties_save_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_save_snippets_object]) 32 | tool_properties_get_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_get_snippets_object]) 33 | 34 | 35 | @app.generic_trigger( 36 | arg_name="context", 37 | type="mcpToolTrigger", 38 | toolName="hello_mcp", 39 | description="Hello world.", 40 | toolProperties="[]", 41 | ) 42 | def hello_mcp(context) -> str: 43 | """ 44 | A simple function that returns a greeting message. 45 | 46 | Args: 47 | context: The trigger context (not used in this function). 48 | 49 | Returns: 50 | str: A greeting message. 51 | """ 52 | return "Hello I am MCPTool!" 53 | 54 | 55 | @app.generic_trigger( 56 | arg_name="context", 57 | type="mcpToolTrigger", 58 | toolName="get_snippet", 59 | description="Retrieve a snippet by name.", 60 | toolProperties=tool_properties_get_snippets_json, 61 | ) 62 | @app.generic_input_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) 63 | def get_snippet(file: func.InputStream, context) -> str: 64 | """ 65 | Retrieves a snippet by name from Azure Blob Storage. 66 | 67 | Args: 68 | file (func.InputStream): The input binding to read the snippet from Azure Blob Storage. 69 | context: The trigger context containing the input arguments. 70 | 71 | Returns: 72 | str: The content of the snippet or an error message. 73 | """ 74 | snippet_content = file.read().decode("utf-8") 75 | logging.info("Retrieved snippet: %s", snippet_content) 76 | return snippet_content 77 | 78 | 79 | @app.generic_trigger( 80 | arg_name="context", 81 | type="mcpToolTrigger", 82 | toolName="save_snippet", 83 | description="Save a snippet with a name.", 84 | toolProperties=tool_properties_save_snippets_json, 85 | ) 86 | @app.generic_output_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) 87 | def save_snippet(file: func.Out[str], context) -> str: 88 | content = json.loads(context) 89 | if "arguments" not in content: 90 | return "No arguments provided" 91 | 92 | snippet_name_from_args = content["arguments"].get(_SNIPPET_NAME_PROPERTY_NAME) 93 | snippet_content_from_args = content["arguments"].get(_SNIPPET_PROPERTY_NAME) 94 | 95 | if not snippet_name_from_args: 96 | return "No snippet name provided" 97 | 98 | if not snippet_content_from_args: 99 | return "No snippet content provided" 100 | 101 | file.set(snippet_content_from_args) 102 | logging.info("Saved snippet: %s", snippet_content_from_args) 103 | return f"Snippet '{snippet_content_from_args}' saved successfully" 104 | ``` -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationConfigurationStores": "appcs-", 5 | "applications": "app-", 6 | "appManagedEnvironments": "cae-", 7 | "appContainerApps": "ca-", 8 | "authorizationPolicyDefinitions": "policy-", 9 | "automationAutomationAccounts": "aa-", 10 | "blueprintBlueprints": "bp-", 11 | "blueprintBlueprintsArtifacts": "bpa-", 12 | "cacheRedis": "redis-", 13 | "cdnProfiles": "cdnp-", 14 | "cdnProfilesEndpoints": "cdne-", 15 | "cognitiveServicesAccounts": "cog-", 16 | "cognitiveServicesFormRecognizer": "cog-fr-", 17 | "cognitiveServicesTextAnalytics": "cog-ta-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "logicIntegrationAccounts": "ia-", 60 | "logicWorkflows": "logic-", 61 | "machineLearningServicesWorkspaces": "mlw-", 62 | "managedIdentityUserAssignedIdentities": "id-", 63 | "managementManagementGroups": "mg-", 64 | "migrateAssessmentProjects": "migr-", 65 | "networkApplicationGateways": "agw-", 66 | "networkApplicationSecurityGroups": "asg-", 67 | "networkAzureFirewalls": "afw-", 68 | "networkBastionHosts": "bas-", 69 | "networkConnections": "con-", 70 | "networkDnsZones": "dnsz-", 71 | "networkExpressRouteCircuits": "erc-", 72 | "networkFirewallPolicies": "afwp-", 73 | "networkFirewallPoliciesWebApplication": "waf", 74 | "networkFirewallPoliciesRuleGroups": "wafrg", 75 | "networkFrontDoors": "fd-", 76 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 77 | "networkLoadBalancersExternal": "lbe-", 78 | "networkLoadBalancersInternal": "lbi-", 79 | "networkLoadBalancersInboundNatRules": "rule-", 80 | "networkLocalNetworkGateways": "lgw-", 81 | "networkNatGateways": "ng-", 82 | "networkNetworkInterfaces": "nic-", 83 | "networkNetworkSecurityGroups": "nsg-", 84 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 85 | "networkNetworkWatchers": "nw-", 86 | "networkPrivateDnsZones": "pdnsz-", 87 | "networkPrivateLinkServices": "pl-", 88 | "networkPublicIPAddresses": "pip-", 89 | "networkPublicIPPrefixes": "ippre-", 90 | "networkRouteFilters": "rf-", 91 | "networkRouteTables": "rt-", 92 | "networkRouteTablesRoutes": "udr-", 93 | "networkTrafficManagerProfiles": "traf-", 94 | "networkVirtualNetworkGateways": "vgw-", 95 | "networkVirtualNetworks": "vnet-", 96 | "networkVirtualNetworksSubnets": "snet-", 97 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 98 | "networkVirtualWans": "vwan-", 99 | "networkVpnGateways": "vpng-", 100 | "networkVpnGatewaysVpnConnections": "vcn-", 101 | "networkVpnGatewaysVpnSites": "vst-", 102 | "notificationHubsNamespaces": "ntfns-", 103 | "notificationHubsNamespacesNotificationHubs": "ntf-", 104 | "operationalInsightsWorkspaces": "log-", 105 | "portalDashboards": "dash-", 106 | "powerBIDedicatedCapacities": "pbi-", 107 | "purviewAccounts": "pview-", 108 | "recoveryServicesVaults": "rsv-", 109 | "resourcesResourceGroups": "rg-", 110 | "searchSearchServices": "srch-", 111 | "serviceBusNamespaces": "sb-", 112 | "serviceBusNamespacesQueues": "sbq-", 113 | "serviceBusNamespacesTopics": "sbt-", 114 | "serviceEndPointPolicies": "se-", 115 | "serviceFabricClusters": "sf-", 116 | "signalRServiceSignalR": "sigr", 117 | "sqlManagedInstances": "sqlmi-", 118 | "sqlServers": "sql-", 119 | "sqlServersDataWarehouse": "sqldw-", 120 | "sqlServersDatabases": "sqldb-", 121 | "sqlServersDatabasesStretch": "sqlstrdb-", 122 | "storageStorageAccounts": "st", 123 | "storageStorageAccountsVm": "stvm", 124 | "storSimpleManagers": "ssimp", 125 | "streamAnalyticsCluster": "asa-", 126 | "synapseWorkspaces": "syn", 127 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 128 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 129 | "synapseWorkspacesSqlPoolsSpark": "synsp", 130 | "timeSeriesInsightsEnvironments": "tsi-", 131 | "webServerFarms": "plan-", 132 | "webSitesAppService": "app-", 133 | "webSitesAppServiceEnvironment": "ase-", 134 | "webSitesFunctions": "func-", 135 | "webStaticSites": "stapp-" 136 | } ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/register.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | REGISTER POLICY 3 | This policy implements the dynamic client registration endpoint for OAuth2 flow. 4 | 5 | Flow: 6 | 1. MCP client sends a registration request with redirect URIs 7 | 2. We store the registration information in CosmosDB for persistence 8 | 3. We generate and return client credentials with the provided redirect URIs 9 | --> 10 | <policies> 11 | <inbound> 12 | <base /> 13 | <!-- STEP 1: Extract client registration data from request --> 14 | <set-variable name="requestBody" value="@(context.Request.Body.As<JObject>(preserveContent: true))" /> 15 | 16 | <!-- STEP 2: Generate a unique client ID (GUID) --> 17 | <set-variable name="uniqueClientId" value="@(Guid.NewGuid().ToString())" /> 18 | 19 | <!-- STEP 3: Prepare client info document for CosmosDB --> 20 | <set-variable name="clientDocument" value="@{ 21 | var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); 22 | var uniqueClientId = context.Variables.GetValueOrDefault<string>("uniqueClientId"); 23 | 24 | var document = new JObject(); 25 | document["id"] = uniqueClientId; 26 | document["clientId"] = uniqueClientId; 27 | document["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application"; 28 | document["client_uri"] = requestBody["client_uri"]?.ToString() ?? ""; 29 | document["redirect_uris"] = requestBody["redirect_uris"]; 30 | document["created_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); 31 | 32 | return document.ToString(); 33 | }" /> 34 | 35 | <!-- STEP 4: Get CosmosDB access token using managed identity --> 36 | <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> 37 | 38 | <!-- STEP 5: Store client registration in CosmosDB using AAD token --> 39 | <send-request mode="new" response-variable-name="cosmosResponse" timeout="30" ignore-error="false"> 40 | <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs")</set-url> 41 | <set-method>POST</set-method> 42 | <set-header name="Content-Type" exists-action="override"> 43 | <value>application/json</value> 44 | </set-header> 45 | <set-header name="x-ms-version" exists-action="override"> 46 | <value>2018-12-31</value> 47 | </set-header> 48 | <set-header name="x-ms-documentdb-partitionkey" exists-action="override"> 49 | <value>@($"[\"{context.Variables.GetValueOrDefault<string>("uniqueClientId")}\"]")</value> 50 | </set-header> 51 | <set-header name="Authorization" exists-action="override"> 52 | <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> 53 | </set-header> 54 | <set-body>@(context.Variables.GetValueOrDefault<string>("clientDocument"))</set-body> 55 | </send-request> 56 | 57 | <!-- STEP 6: Check if CosmosDB operation was successful --> 58 | <choose> 59 | <when condition="@(((IResponse)context.Variables["cosmosResponse"]).StatusCode >= 400)"> 60 | <return-response> 61 | <set-status code="500" reason="Internal Server Error" /> 62 | <set-header name="Content-Type" exists-action="override"> 63 | <value>application/json</value> 64 | </set-header> 65 | <set-body>@{ 66 | return new JObject 67 | { 68 | ["error"] = "server_error", 69 | ["error_description"] = "Failed to store client registration" 70 | }.ToString(); 71 | }</set-body> 72 | </return-response> 73 | </when> 74 | </choose> 75 | 76 | <!-- STEP 7: Cache the redirect URI for backward compatibility with other policies --> 77 | <cache-store-value duration="3600" 78 | key="ClientRedirectUri" 79 | value="@(context.Variables.GetValueOrDefault<JObject>("requestBody")["redirect_uris"][0].ToString())" /> 80 | 81 | <!-- Store client info by client ID for easy lookup during consent --> 82 | <cache-store-value duration="3600" 83 | key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("uniqueClientId")}")" 84 | value="@{ 85 | var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); 86 | var clientInfo = new JObject(); 87 | clientInfo["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application"; 88 | clientInfo["client_uri"] = requestBody["client_uri"]?.ToString() ?? ""; 89 | clientInfo["redirect_uris"] = requestBody["redirect_uris"]; 90 | return clientInfo.ToString(); 91 | }" /> 92 | 93 | <!-- STEP 8: Set response content type --> 94 | <set-header name="Content-Type" exists-action="override"> 95 | <value>application/json</value> 96 | </set-header> 97 | 98 | <!-- STEP 9: Return client credentials response --> 99 | <return-response> 100 | <set-status code="200" reason="OK" /> 101 | <set-header name="access-control-allow-origin" exists-action="override"> 102 | <value>*</value> 103 | </set-header> 104 | <set-body template="none">@{ 105 | var requestBody = context.Variables.GetValueOrDefault<JObject>("requestBody"); 106 | 107 | // Generate timestamps dynamically 108 | // Current time in seconds since epoch (Unix timestamp) 109 | long currentTimeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); 110 | 111 | // Client ID issued at current time 112 | long clientIdIssuedAt = currentTimeSeconds; 113 | 114 | // Client secret expires in 1 year (31536000 seconds = 365 days) 115 | long clientSecretExpiresAt = currentTimeSeconds + 31536000; 116 | 117 | // Use the generated client ID from earlier 118 | string uniqueClientId = context.Variables.GetValueOrDefault<string>("uniqueClientId", Guid.NewGuid().ToString()); 119 | 120 | return new JObject 121 | { 122 | ["client_id"] = uniqueClientId, 123 | ["client_id_issued_at"] = clientIdIssuedAt, 124 | ["client_secret_expires_at"] = clientSecretExpiresAt, 125 | ["redirect_uris"] = requestBody["redirect_uris"]?.ToObject<JArray>(), 126 | ["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application", 127 | ["client_uri"] = requestBody["client_uri"]?.ToString() ?? "" 128 | }.ToString(); 129 | }</set-body> 130 | </return-response> 131 | </inbound> 132 | <backend /> 133 | <outbound> 134 | <base /> 135 | </outbound> 136 | <on-error> 137 | <base /> 138 | </on-error> 139 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/authorize.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | AUTHORIZE POLICY 3 | OAuth 2.0 PKCE authorization endpoint with Entra ID integration. 4 | 5 | Flow: Client → Consent (if needed) → Entra ID → Callback → Client 6 | --> 7 | <policies> 8 | <inbound> 9 | <base /> 10 | <!-- Extract all OAuth parameters --> 11 | <set-variable name="clientId" value="@((string)context.Request.Url.Query.GetValueOrDefault("client_id", ""))" /> 12 | <set-variable name="redirect_uri" value="@((string)context.Request.Url.Query.GetValueOrDefault("redirect_uri", ""))" /> 13 | <set-variable name="currentState" value="@((string)context.Request.Url.Query.GetValueOrDefault("state", ""))" /> 14 | <set-variable name="mcpScope" value="@((string)context.Request.Url.Query.GetValueOrDefault("scope", ""))" /> 15 | <set-variable name="mcpClientCodeChallenge" value="@((string)context.Request.Url.Query.GetValueOrDefault("code_challenge", ""))" /> 16 | <set-variable name="mcpClientCodeChallengeMethod" value="@((string)context.Request.Url.Query.GetValueOrDefault("code_challenge_method", ""))" /> 17 | 18 | <!-- Validate required OAuth parameters --> 19 | <choose> 20 | <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientId")) || 21 | string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("redirect_uri")) || 22 | string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("currentState")))"> 23 | <return-response> 24 | <set-status code="400" reason="Bad Request" /> 25 | <set-header name="Content-Type" exists-action="override"> 26 | <value>application/json</value> 27 | </set-header> 28 | <set-header name="Cache-Control" exists-action="override"> 29 | <value>no-store, no-cache</value> 30 | </set-header> 31 | <set-body>@{ 32 | return new JObject { 33 | ["error"] = "invalid_request", 34 | ["error_description"] = "Missing required parameters: client_id, redirect_uri, and state are all required for OAuth authorization" 35 | }.ToString(); 36 | }</set-body> 37 | </return-response> 38 | </when> 39 | </choose> 40 | 41 | <!-- Validate required PKCE parameters --> 42 | <choose> 43 | <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("mcpClientCodeChallenge")) || 44 | string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("mcpClientCodeChallengeMethod")))"> 45 | <return-response> 46 | <set-status code="400" reason="Bad Request" /> 47 | <set-header name="Content-Type" exists-action="override"> 48 | <value>application/json</value> 49 | </set-header> 50 | <set-header name="Cache-Control" exists-action="override"> 51 | <value>no-store, no-cache</value> 52 | </set-header> 53 | <set-body>@{ 54 | return new JObject { 55 | ["error"] = "invalid_request", 56 | ["error_description"] = "Missing required PKCE parameters: code_challenge and code_challenge_method are required for secure authorization" 57 | }.ToString(); 58 | }</set-body> 59 | </return-response> 60 | </when> 61 | </choose> 62 | 63 | <!-- Normalize redirect URI --> 64 | <set-variable name="normalized_redirect_uri" value="@{ 65 | string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); 66 | if (string.IsNullOrEmpty(redirectUri)) { 67 | return ""; 68 | } 69 | 70 | try { 71 | string decodedUri = System.Net.WebUtility.UrlDecode(redirectUri); 72 | return decodedUri; 73 | } catch (Exception) { 74 | return redirectUri; 75 | } 76 | }" /> 77 | 78 | <!-- Check for existing approval cookie --> 79 | <set-variable name="has_approval_cookie" value="@{ 80 | try { 81 | if (string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientId", "")) || 82 | string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""))) { 83 | return false; 84 | } 85 | 86 | string clientId = context.Variables.GetValueOrDefault<string>("clientId", ""); 87 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 88 | 89 | string APPROVAL_COOKIE_NAME = "__Host-MCP_APPROVED_CLIENTS"; 90 | 91 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 92 | if (string.IsNullOrEmpty(cookieHeader)) { 93 | return false; 94 | } 95 | 96 | string[] cookies = cookieHeader.Split(';'); 97 | foreach (string cookie in cookies) { 98 | string trimmedCookie = cookie.Trim(); 99 | if (trimmedCookie.StartsWith(APPROVAL_COOKIE_NAME + "=")) { 100 | try { 101 | string cookieValue = trimmedCookie.Substring(APPROVAL_COOKIE_NAME.Length + 1); 102 | string decodedValue = System.Text.Encoding.UTF8.GetString( 103 | System.Convert.FromBase64String(cookieValue)); 104 | JArray approvedClients = JArray.Parse(decodedValue); 105 | 106 | string clientKey = $"{clientId}:{redirectUri}"; 107 | foreach (var item in approvedClients) { 108 | if (item.ToString() == clientKey) { 109 | return true; 110 | } 111 | } 112 | } catch (Exception ex) { 113 | // Error parsing approval cookie - ignore and continue 114 | } 115 | break; 116 | } 117 | } 118 | 119 | return false; 120 | } catch (Exception ex) { 121 | // Error checking approval cookie - return false 122 | return false; 123 | } 124 | }" /> 125 | 126 | <!-- Check if the client has been approved via secure cookie --> 127 | <choose> 128 | <when condition="@(context.Variables.GetValueOrDefault<bool>("has_approval_cookie"))"> 129 | <!-- Continue with normal flow - client is authorized via secure cookie --> 130 | </when> 131 | <otherwise> 132 | <!-- Redirect to consent page for user approval --> 133 | <return-response> 134 | <set-status code="302" reason="Found" /> 135 | <set-header name="Location" exists-action="override"> 136 | <value>@{ 137 | string basePath = context.Request.OriginalUrl.Scheme + "://" + context.Request.OriginalUrl.Host + (context.Request.OriginalUrl.Port == 80 || context.Request.OriginalUrl.Port == 443 ? "" : ":" + context.Request.OriginalUrl.Port); 138 | string clientId = context.Variables.GetValueOrDefault<string>("clientId"); 139 | // Use the normalized (already decoded) redirect_uri to avoid double-encoding 140 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri"); 141 | string state = context.Variables.GetValueOrDefault<string>("currentState"); 142 | string codeChallenge = context.Variables.GetValueOrDefault<string>("mcpClientCodeChallenge"); 143 | string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("mcpClientCodeChallengeMethod"); 144 | 145 | // URL encode parameters for the consent redirect URL 146 | string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); 147 | string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); 148 | 149 | // State parameter: use as-is without additional encoding 150 | // context.Request.Url.Query.GetValueOrDefault() preserves the original encoding 151 | string encodedState = state; 152 | 153 | // Code challenge parameters: use as-is since they typically don't need encoding 154 | string encodedCodeChallenge = codeChallenge; 155 | string encodedCodeChallengeMethod = codeChallengeMethod; 156 | 157 | return $"{basePath}/consent?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}&code_challenge={encodedCodeChallenge}&code_challenge_method={encodedCodeChallengeMethod}"; 158 | }</value> 159 | </set-header> 160 | </return-response> 161 | </otherwise> 162 | </choose> 163 | 164 | <set-variable name="codeVerifier" value="@((string)Guid.NewGuid().ToString().Replace("-", ""))" /> 165 | <set-variable name="codeChallenge" value="@{ 166 | using (var sha256 = System.Security.Cryptography.SHA256.Create()) 167 | { 168 | var bytes = System.Text.Encoding.UTF8.GetBytes((string)context.Variables.GetValueOrDefault("codeVerifier", "")); 169 | var hash = sha256.ComputeHash(bytes); 170 | return System.Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_'); 171 | } 172 | }" /> 173 | 174 | <!-- Build the complete Entra ID URL using client's original state --> 175 | <set-variable name="authUrl" value="@{ 176 | string baseUrl = "https://login.microsoftonline.com/{{EntraIDTenantId}}/oauth2/v2.0/authorize"; 177 | string codeChallenge = context.Variables.GetValueOrDefault("codeChallenge", ""); 178 | string clientState = context.Variables.GetValueOrDefault("currentState", ""); 179 | 180 | 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)}"; 181 | }" /> 182 | 183 | <!-- STEP 5: Store authentication data in cache for use in callback --> 184 | <!-- Generate a confirmation code to return to the MCP client --> 185 | <set-variable name="mcpConfirmConsentCode" value="@((string)Guid.NewGuid().ToString())" /> 186 | 187 | <!-- Store code verifier for token exchange using client state --> 188 | <cache-store-value duration="3600" 189 | key="@("CodeVerifier-"+context.Variables.GetValueOrDefault("currentState", ""))" 190 | value="@(context.Variables.GetValueOrDefault("codeVerifier", ""))" /> 191 | 192 | <!-- Map client state to MCP confirmation code for callback --> 193 | <cache-store-value duration="3600" 194 | key="@((string)context.Variables.GetValueOrDefault("currentState"))" 195 | value="@(context.Variables.GetValueOrDefault("mcpConfirmConsentCode", ""))" /> 196 | 197 | <!-- Store MCP client data --> 198 | <cache-store-value duration="3600" 199 | key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" 200 | value="@{ 201 | return new JObject{ 202 | ["mcpClientCodeChallenge"] = (string)context.Variables["mcpClientCodeChallenge"], 203 | ["mcpClientCodeChallengeMethod"] = (string)context.Variables["mcpClientCodeChallengeMethod"], 204 | ["mcpClientState"] = (string)context.Variables["currentState"], 205 | ["mcpClientScope"] = (string)context.Variables["mcpScope"], 206 | ["mcpCallbackRedirectUri"] = (string)context.Variables["normalized_redirect_uri"] 207 | }.ToString(); 208 | }" /> 209 | </inbound> 210 | <backend> 211 | <base /> 212 | </backend> 213 | <outbound> 214 | <base /> 215 | <!-- Return the response with a 302 status code for redirect --> 216 | <return-response> 217 | <set-status code="302" reason="Found" /> 218 | <set-header name="Location" exists-action="override"> 219 | <value>@(context.Variables.GetValueOrDefault("authUrl", ""))</value> 220 | </set-header> 221 | <!-- Add cache control headers to ensure browser follows redirect --> 222 | <set-header name="Cache-Control" exists-action="override"> 223 | <value>no-store, no-cache, must-revalidate</value> 224 | </set-header> 225 | <set-header name="Pragma" exists-action="override"> 226 | <value>no-cache</value> 227 | </set-header> 228 | <!-- Remove any content-type that might interfere --> 229 | <set-header name="Content-Type" exists-action="delete" /> 230 | </return-response> 231 | </outbound> 232 | <on-error> 233 | <base /> 234 | </on-error> 235 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauth-callback.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | OAUTH CALLBACK POLICY 3 | This policy implements the callback endpoint for PKCE OAuth2 flow with Entra ID. 4 | --> 5 | <policies> 6 | <inbound> 7 | <base /> 8 | <!-- STEP 1: Extract the authorization code and state from Entra ID callback --> 9 | <set-variable name="authCode" value="@((string)context.Request.Url.Query.GetValueOrDefault("code", ""))" /> 10 | <set-variable name="clientState" value="@{ 11 | string stateValue = (string)context.Request.Url.Query.GetValueOrDefault("state", ""); 12 | return !string.IsNullOrEmpty(stateValue) ? System.Net.WebUtility.UrlDecode(stateValue) : ""; 13 | }" /> 14 | <set-variable name="sessionState" value="@((string)context.Request.Url.Query.GetValueOrDefault("session_state", ""))" /> 15 | 16 | <!-- Validate required OAuth parameters --> 17 | <choose> 18 | <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("authCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("clientState", "")))"> 19 | <return-response> 20 | <set-status code="400" reason="Bad Request" /> 21 | <set-header name="Content-Type" exists-action="override"> 22 | <value>application/json</value> 23 | </set-header> 24 | <set-body>@{ 25 | var errorResponse = new JObject(); 26 | errorResponse["error"] = "invalid_request"; 27 | errorResponse["error_description"] = "Missing required OAuth callback parameters"; 28 | return errorResponse.ToString(); 29 | }</set-body> 30 | </return-response> 31 | </when> 32 | </choose> 33 | 34 | <!-- STEP 1.5: Validate that the state matches what the user consented to --> 35 | <set-variable name="consent_state_valid" value="@{ 36 | try { 37 | string returnedState = context.Variables.GetValueOrDefault<string>("clientState", ""); 38 | if (string.IsNullOrEmpty(returnedState)) { 39 | return false; 40 | } 41 | 42 | // Extract consent state from cookie 43 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 44 | if (string.IsNullOrEmpty(cookieHeader)) { 45 | return false; 46 | } 47 | 48 | string cookieName = "__Host-MCP_CONSENT_STATE"; 49 | string[] cookies = cookieHeader.Split(';'); 50 | foreach (string cookie in cookies) { 51 | string trimmedCookie = cookie.Trim(); 52 | if (trimmedCookie.StartsWith(cookieName + "=")) { 53 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 54 | string decodedValue = System.Text.Encoding.UTF8.GetString( 55 | System.Convert.FromBase64String(cookieValue)); 56 | JObject consentData = JObject.Parse(decodedValue); 57 | 58 | string consentedState = consentData["state"]?.ToString(); 59 | 60 | // Constant-time comparison to prevent timing attacks 61 | if (string.IsNullOrEmpty(consentedState) || returnedState.Length != consentedState.Length) { 62 | return false; 63 | } 64 | 65 | int result = 0; 66 | for (int i = 0; i < returnedState.Length; i++) { 67 | result |= returnedState[i] ^ consentedState[i]; 68 | } 69 | 70 | return (result == 0); 71 | } 72 | } 73 | 74 | return false; 75 | } catch (Exception ex) { 76 | return false; 77 | } 78 | }" /> 79 | 80 | <!-- Validate consent state cookie --> 81 | <choose> 82 | <when condition="@(!context.Variables.GetValueOrDefault<bool>("consent_state_valid"))"> 83 | <return-response> 84 | <set-status code="400" reason="Bad Request" /> 85 | <set-header name="Content-Type" exists-action="override"> 86 | <value>application/json</value> 87 | </set-header> 88 | <set-body>@{ 89 | var errorResponse = new JObject(); 90 | errorResponse["error"] = "invalid_state"; 91 | errorResponse["error_description"] = "State parameter does not match consented state."; 92 | return errorResponse.ToString(); 93 | }</set-body> 94 | </return-response> 95 | </when> 96 | </choose> 97 | 98 | <!-- Clear the consent state cookie since it's been validated --> 99 | <set-variable name="clear_consent_cookie" value="__Host-MCP_CONSENT_STATE=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" /> 100 | 101 | <!-- STEP 2: Retrieve stored PKCE code verifier using the client state parameter --> 102 | <cache-lookup-value key="@("CodeVerifier-"+context.Variables.GetValueOrDefault("clientState", ""))" variable-name="codeVerifier" /> 103 | 104 | <!-- Validate that code verifier was found in cache --> 105 | <choose> 106 | <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("codeVerifier", "")))"> 107 | <return-response> 108 | <set-status code="400" reason="Bad Request" /> 109 | <set-header name="Content-Type" exists-action="override"> 110 | <value>application/json</value> 111 | </set-header> 112 | <set-body>@{ 113 | var errorResponse = new JObject(); 114 | errorResponse["error"] = "invalid_request"; 115 | errorResponse["error_description"] = "Authorization session expired or invalid state parameter"; 116 | return errorResponse.ToString(); 117 | }</set-body> 118 | </return-response> 119 | </when> 120 | </choose> 121 | 122 | <!-- STEP 3: Set token request parameters --> 123 | <set-variable name="codeChallengeMethod" value="S256" /> 124 | <set-variable name="redirectUri" value="{{OAuthCallbackUri}}" /> 125 | <set-variable name="clientId" value="{{EntraIDClientId}}" /> 126 | <set-variable name="clientAssertionType" value="@(System.Net.WebUtility.UrlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"))" /> 127 | <authentication-managed-identity resource="api://AzureADTokenExchange" client-id="{{EntraIDFicClientId}}" output-token-variable-name="ficToken"/> 128 | 129 | <!-- STEP 4: Configure token request to Entra ID --> 130 | <set-method>POST</set-method> 131 | <set-header name="Content-Type" exists-action="override"> 132 | <value>application/x-www-form-urlencoded</value> 133 | </set-header> 134 | <set-body>@{ 135 | 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")}"; 136 | }</set-body> 137 | <rewrite-uri template="/token" /> 138 | </inbound> 139 | 140 | <backend> 141 | <base /> 142 | </backend> <outbound> 143 | <base /> 144 | <!-- STEP 5: Process the token response from Entra ID --> 145 | <trace source="apim-policy"> 146 | <message>@("Token response received: " + context.Response.Body.As<string>(preserveContent: true))</message> 147 | </trace> 148 | <!-- Check if the response is successful (200 OK) and contains a token --> 149 | <choose> 150 | <when condition="@(context.Response.StatusCode != 200 || string.IsNullOrEmpty(context.Response.Body.As<JObject>(preserveContent: true)["access_token"]?.ToString()))"> 151 | <return-response> 152 | <set-status code="@(context.Response.StatusCode)" reason="@(context.Response.StatusReason)" /> 153 | <set-header name="Content-Type" exists-action="override"> 154 | <value>application/json</value> 155 | </set-header> 156 | <set-body>@{ 157 | var errorResponse = new JObject(); 158 | errorResponse["error"] = "token_error"; 159 | errorResponse["error_description"] = "Failed to retrieve access token from Entra ID."; 160 | return errorResponse.ToString(); 161 | }</set-body> 162 | </return-response> 163 | </when> 164 | </choose> 165 | 166 | <!-- STEP 6: Generate secure session token for MCP client --> 167 | <set-variable name="IV" value="{{EncryptionIV}}" /> 168 | <set-variable name="key" value="{{EncryptionKey}}" /> 169 | <set-variable name="sessionId" value="@((string)Guid.NewGuid().ToString().Replace("-", ""))" /> 170 | <set-variable name="encryptedSessionKey" value="@{ 171 | // Generate a unique session ID 172 | string sessionId = (string)context.Variables.GetValueOrDefault("sessionId"); 173 | byte[] sessionIdBytes = Encoding.UTF8.GetBytes(sessionId); 174 | 175 | // Encrypt the session ID using AES 176 | byte[] IV = Convert.FromBase64String((string)context.Variables["IV"]); 177 | byte[] key = Convert.FromBase64String((string)context.Variables["key"]); 178 | 179 | byte[] encryptedBytes = sessionIdBytes.Encrypt("Aes", key, IV); 180 | 181 | return Convert.ToBase64String(encryptedBytes); 182 | }" /> 183 | 184 | <!-- STEP 6: Lookup MCP client redirect URI stored during authorization --> 185 | <cache-lookup-value key="@((string)context.Variables.GetValueOrDefault("clientState"))" variable-name="mcpConfirmConsentCode" /> 186 | <cache-lookup-value key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="mcpClientData" /> 187 | 188 | <!-- Validate that MCP client data was found in cache --> 189 | <choose> 190 | <when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpConfirmConsentCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpClientData", "")))"> 191 | <return-response> 192 | <set-status code="400" reason="Bad Request" /> 193 | <set-header name="Content-Type" exists-action="override"> 194 | <value>application/json</value> 195 | </set-header> 196 | <set-body>@{ 197 | var errorResponse = new JObject(); 198 | errorResponse["error"] = "invalid_request"; 199 | errorResponse["error_description"] = "MCP client authorization session expired or invalid"; 200 | return errorResponse.ToString(); 201 | }</set-body> 202 | </return-response> 203 | </when> 204 | </choose> 205 | 206 | <!-- STEP 8: Use the client's original state parameter directly --> 207 | <set-variable name="mcpState" value="@(context.Variables.GetValueOrDefault<string>("clientState"))" /> 208 | <!-- STEP 9: Extract the stored mcp client callback redirect uri from cache --> 209 | <set-variable name="callbackRedirectUri" value="@{ 210 | var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientData"]); 211 | return mcpAuthDataAsJObject["mcpCallbackRedirectUri"]; 212 | }" /> 213 | <!-- STEP 10: Store the encrypted session key and Entra token in cache --> 214 | <!-- Store the encrypted session key with the MCP confirmation code as key --> 215 | <cache-store-value duration="3600" 216 | key="@($"AccessToken-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" 217 | value="@($"{context.Variables.GetValueOrDefault("encryptedSessionKey")}")" /> 218 | 219 | <!-- Store the Entra token for later use --> 220 | <cache-store-value duration="3600" 221 | key="@($"EntraToken-{context.Variables.GetValueOrDefault("sessionId")}")" 222 | value="@(context.Response.Body.As<JObject>(preserveContent: true).ToString())" /> 223 | 224 | <!-- STEP 11: Redirect back to MCP client with confirmation code --> 225 | <return-response> 226 | <set-status code="302" reason="Found" /> 227 | <set-header name="Location" exists-action="override"> 228 | <value>@($"{context.Variables.GetValueOrDefault("callbackRedirectUri")}?code={context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}&state={System.Net.WebUtility.UrlEncode((string)context.Variables.GetValueOrDefault("mcpState"))}")</value> 229 | </set-header> 230 | <!-- Clear the consent state cookie --> 231 | <set-header name="Set-Cookie" exists-action="append"> 232 | <value>@(context.Variables.GetValueOrDefault<string>("clear_consent_cookie"))</value> 233 | </set-header> 234 | <set-body /> 235 | </return-response> 236 | </outbound> 237 | <on-error> 238 | <base /> 239 | </on-error> 240 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/token.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <!-- 2 | TOKEN POLICY 3 | This policy implements the token endpoint for PKCE OAuth2 flow. 4 | 5 | Flow: 6 | 1. MCP client sends token request with code and code_verifier 7 | 2. We validate the code_verifier against the stored code_challenge 8 | 3. We retrieve the cached access token and return it to the client 9 | --> 10 | <policies> 11 | <inbound> 12 | <base /> 13 | <!-- STEP 1: Extract parameters from token request --> 14 | <!-- Read the request body as a string while preserving it for later processing --> 15 | <set-variable name="tokenRequestBody" value="@((string)context.Request.Body.As<string>(preserveContent: true))" /> 16 | 17 | <!-- Extract the confirmation code from the request --> 18 | <set-variable name="mcpConfirmConsentCode" value="@{ 19 | // Retrieve the raw body string 20 | var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); 21 | if (!string.IsNullOrEmpty(body)) 22 | { 23 | // Split the body into name/value pairs 24 | var pairs = body.Split('&'); 25 | foreach (var pair in pairs) 26 | { 27 | var keyValue = pair.Split('='); 28 | if (keyValue.Length == 2) 29 | { 30 | if(keyValue[0] == "code") 31 | { 32 | return keyValue[1]; 33 | } 34 | } 35 | } 36 | } 37 | return ""; 38 | }" /> 39 | <!-- Extract the code_verifier from the request and URL-decode it --> 40 | <set-variable name="mcpClientCodeVerifier" value="@{ 41 | // Retrieve the raw body string 42 | var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); 43 | if (!string.IsNullOrEmpty(body)) 44 | { 45 | // Split the body into name/value pairs 46 | var pairs = body.Split('&'); 47 | foreach (var pair in pairs) 48 | { 49 | var keyValue = pair.Split('='); 50 | if (keyValue.Length == 2) 51 | { 52 | if(keyValue[0] == "code_verifier") 53 | { 54 | // URL-decode the code_verifier if needed 55 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 56 | } 57 | } 58 | } 59 | } 60 | return ""; 61 | }" /> 62 | 63 | <!-- STEP 2: Extract state parameters --> 64 | <set-variable name="mcpState" value="@((string)context.Request.Url.Query.GetValueOrDefault("state", ""))" /> 65 | <set-variable name="stateSession" value="@((string)context.Request.Url.Query.GetValueOrDefault("state_session", ""))" /> 66 | </inbound> 67 | <backend /> 68 | <outbound> 69 | <base /> 70 | <!-- STEP 3: Retrieve stored MCP client data --> 71 | <!-- Lookup the stored MCP client code challenge and challenge method from the cache --> 72 | <cache-lookup-value key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="mcpClientAuthData" /> 73 | 74 | <!-- Extract the stored code challenge from the cached data --> 75 | <set-variable name="storedMcpClientCodeChallenge" value="@{ 76 | var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientAuthData"]); 77 | return (string)mcpAuthDataAsJObject["mcpClientCodeChallenge"]; 78 | }" /> 79 | <!-- STEP 4: Compute and validate the code challenge --> 80 | <!-- Generate a challenge from the incoming code_verifier using the stored challenge method --> 81 | <set-variable name="mcpServerComputedCodeChallenge" value="@{ 82 | var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientAuthData"]); 83 | string codeVerifier = (string)context.Variables.GetValueOrDefault("mcpClientCodeVerifier", ""); 84 | string codeChallengeMethod = ((string)mcpAuthDataAsJObject["mcpClientCodeChallengeMethod"]).ToLower(); 85 | 86 | if(string.IsNullOrEmpty(codeVerifier)){ 87 | return string.Empty; 88 | } 89 | 90 | if(codeChallengeMethod == "plain"){ 91 | // For "plain", no transformation is applied 92 | return codeVerifier; 93 | } else if(codeChallengeMethod == "s256"){ 94 | // For S256, compute the SHA256 hash, Base64 encode it, and convert to URL-safe format 95 | using (var sha256 = System.Security.Cryptography.SHA256.Create()) 96 | { 97 | var bytes = System.Text.Encoding.UTF8.GetBytes(codeVerifier); 98 | var hash = sha256.ComputeHash(bytes); 99 | 100 | // Convert the hash to a Base64 string 101 | string base64 = Convert.ToBase64String(hash); 102 | 103 | // Convert Base64 string into a URL-safe variant 104 | // Replace '+' with '-', '/' with '_', and remove any '=' padding 105 | return base64.Replace("+", "-").Replace("/", "_").Replace("=", ""); 106 | } 107 | } else { 108 | // Unsupported method 109 | return string.Empty; 110 | } 111 | }" /> 112 | <!-- STEP 5: Verify code challenge matches --> 113 | <choose> 114 | <when condition="@(string.Compare((string)context.Variables.GetValueOrDefault("mcpServerComputedCodeChallenge", ""), (string)context.Variables.GetValueOrDefault("storedMcpClientCodeChallenge", "")) != 0)"> 115 | <!-- If they don't match, return an error --> 116 | <return-response> 117 | <set-status code="400" reason="Bad Request" /> 118 | <set-body>@("{\"error\": \"code_verifier does not match.\"}")</set-body> 119 | </return-response> 120 | </when> 121 | </choose> 122 | 123 | <!-- STEP 5.5: Verify client registration --> 124 | <!-- Extract client ID and redirect URI from the token request --> 125 | <set-variable name="client_id" value="@{ 126 | // Retrieve the raw body string 127 | var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); 128 | if (!string.IsNullOrEmpty(body)) 129 | { 130 | // Split the body into name/value pairs 131 | var pairs = body.Split('&'); 132 | foreach (var pair in pairs) 133 | { 134 | var keyValue = pair.Split('='); 135 | if (keyValue.Length == 2) 136 | { 137 | if(keyValue[0] == "client_id") 138 | { 139 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 140 | } 141 | } 142 | } 143 | } 144 | return ""; 145 | }" /> 146 | 147 | <set-variable name="redirect_uri" value="@{ 148 | // Retrieve the raw body string 149 | var body = context.Variables.GetValueOrDefault<string>("tokenRequestBody"); 150 | if (!string.IsNullOrEmpty(body)) 151 | { 152 | // Split the body into name/value pairs 153 | var pairs = body.Split('&'); 154 | foreach (var pair in pairs) 155 | { 156 | var keyValue = pair.Split('='); 157 | if (keyValue.Length == 2) 158 | { 159 | if(keyValue[0] == "redirect_uri") 160 | { 161 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 162 | } 163 | } 164 | } 165 | } 166 | return ""; 167 | }" /> 168 | 169 | <!-- Normalize the redirect URI --> 170 | <set-variable name="normalized_redirect_uri" value="@{ 171 | string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); 172 | return System.Net.WebUtility.UrlDecode(redirectUri); 173 | }" /> 174 | 175 | <!-- Look up client information from cache --> 176 | <cache-lookup-value key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" variable-name="clientInfoJson" /> 177 | 178 | <!-- If cache lookup failed, try to retrieve from CosmosDB --> 179 | <choose> 180 | <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientInfoJson")))"> 181 | <!-- Get CosmosDB access token using managed identity --> 182 | <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> 183 | 184 | <send-request mode="new" response-variable-name="cosmosClientResponse" timeout="30" ignore-error="true"> 185 | <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs/{context.Variables.GetValueOrDefault<string>("client_id")}")</set-url> 186 | <set-method>GET</set-method> 187 | <set-header name="Content-Type" exists-action="override"> 188 | <value>application/json</value> 189 | </set-header> 190 | <set-header name="x-ms-version" exists-action="override"> 191 | <value>2018-12-31</value> 192 | </set-header> 193 | <set-header name="x-ms-partitionkey" exists-action="override"> 194 | <value>@($"[\"{context.Variables.GetValueOrDefault<string>("client_id")}\"]")</value> 195 | </set-header> 196 | <set-header name="Authorization" exists-action="override"> 197 | <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> 198 | </set-header> 199 | </send-request> 200 | 201 | <!-- If CosmosDB request was successful, extract client info --> 202 | <choose> 203 | <when condition="@(((IResponse)context.Variables["cosmosClientResponse"]).StatusCode == 200)"> 204 | <set-variable name="clientInfoJson" value="@{ 205 | var cosmosResponse = (IResponse)context.Variables["cosmosClientResponse"]; 206 | var cosmosDocument = cosmosResponse.Body.As<JObject>(); 207 | 208 | // Extract the client info fields we need 209 | var clientInfo = new JObject(); 210 | clientInfo["client_name"] = cosmosDocument["client_name"]; 211 | clientInfo["client_uri"] = cosmosDocument["client_uri"]; 212 | clientInfo["redirect_uris"] = cosmosDocument["redirect_uris"]; 213 | 214 | return clientInfo.ToString(); 215 | }" /> 216 | 217 | <!-- Store in cache for future requests --> 218 | <cache-store-value duration="3600" 219 | key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" 220 | value="@(context.Variables.GetValueOrDefault<string>("clientInfoJson"))" /> 221 | </when> 222 | </choose> 223 | </when> 224 | </choose> 225 | 226 | <!-- Verify that the client exists and the redirect URI is valid --> 227 | <set-variable name="is_client_registered" value="@{ 228 | try { 229 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 230 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 231 | 232 | if (string.IsNullOrEmpty(clientId)) { 233 | return false; 234 | } 235 | 236 | // Get the client info from the variable set by cache-lookup-value 237 | string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); 238 | if (string.IsNullOrEmpty(clientInfoJson)) { 239 | context.Trace($"Client info not found in cache for client_id: {clientId}"); 240 | return false; 241 | } 242 | 243 | // Parse client info 244 | JObject clientInfo = JObject.Parse(clientInfoJson); 245 | JArray redirectUris = clientInfo["redirect_uris"]?.ToObject<JArray>(); 246 | 247 | // Check if the redirect URI is in the registered URIs 248 | if (redirectUris != null) { 249 | foreach (var uri in redirectUris) { 250 | // Normalize the URI from the cache for comparison 251 | string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); 252 | if (registeredUri == redirectUri) { 253 | return true; 254 | } 255 | } 256 | } 257 | 258 | context.Trace($"Redirect URI mismatch - URI: {redirectUri} not found in registered URIs"); 259 | return false; 260 | } 261 | catch (Exception ex) { 262 | context.Trace($"Error checking client registration: {ex.Message}"); 263 | return false; 264 | } 265 | }" /> 266 | 267 | <!-- Check if client is properly registered --> 268 | <choose> 269 | <when condition="@(!context.Variables.GetValueOrDefault<bool>("is_client_registered"))"> 270 | <!-- Client is not properly registered, return error --> 271 | <return-response> 272 | <set-status code="401" reason="Unauthorized" /> 273 | <set-header name="Content-Type" exists-action="override"> 274 | <value>application/json</value> 275 | </set-header> 276 | <set-body>@{ 277 | var errorResponse = new JObject(); 278 | errorResponse["error"] = "invalid_client"; 279 | errorResponse["error_description"] = "Client not found or redirect URI is invalid."; 280 | return errorResponse.ToString(); 281 | }</set-body> 282 | </return-response> 283 | </when> 284 | </choose> 285 | 286 | <!-- STEP 6: Retrieve cached tokens --> 287 | <!-- Get the access token stored during the authorization process --> 288 | <cache-lookup-value key="@($"AccessToken-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="cachedSessionToken" /> 289 | 290 | <!-- STEP 7: Generate token response --> 291 | <set-variable name="jsonPayload" value="@{ 292 | var accessToken = context.Variables.GetValueOrDefault<string>("cachedSessionToken"); 293 | var payloadObject = new 294 | { 295 | access_token = accessToken, 296 | token_type = "Bearer", 297 | expires_in = 3600, 298 | refresh_token = "", 299 | scope = "openid profile email" 300 | }; 301 | 302 | // Serialize the object to a JSON string. 303 | return Newtonsoft.Json.JsonConvert.SerializeObject(payloadObject); 304 | }" /> 305 | <set-body template="none">@{ 306 | return (string)context.Variables.GetValueOrDefault("jsonPayload", ""); 307 | }</set-body> 308 | <set-header name="access-control-allow-origin" exists-action="override"> 309 | <value>*</value> 310 | </set-header> 311 | </outbound> 312 | <on-error> 313 | <base /> 314 | </on-error> 315 | </policies> ``` -------------------------------------------------------------------------------- /infra/app/apim-oauth/consent.policy.xml: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8" standalone="yes"?> 2 | <!-- 3 | Consent Policy - Handles user consent for OAuth client applications 4 | --> 5 | <policies> 6 | <inbound> 7 | <base /> 8 | <!-- Extract form body once --> 9 | <set-variable name="form_body" value="@{ 10 | if (context.Request.Method == "POST") { 11 | string contentType = context.Request.Headers.GetValueOrDefault("Content-Type", ""); 12 | if (contentType.Contains("application/x-www-form-urlencoded")) { 13 | return context.Request.Body.As<string>(preserveContent: true); 14 | } 15 | } 16 | return ""; 17 | }" /> 18 | 19 | <!-- Extract individual parameters with consistent decoding --> 20 | <set-variable name="client_id" value="@{ 21 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 22 | 23 | // Check form data first (POST) 24 | if (!string.IsNullOrEmpty(formBody)) { 25 | string[] pairs = formBody.Split('&'); 26 | foreach (string pair in pairs) { 27 | string[] keyValue = pair.Split(new char[] {'='}, 2); 28 | if (keyValue.Length == 2 && keyValue[0] == "client_id") { 29 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 30 | } 31 | } 32 | } 33 | 34 | // Fallback to query string (GET) 35 | string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("client_id", ""); 36 | return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; 37 | }" /> 38 | 39 | <set-variable name="redirect_uri" value="@{ 40 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 41 | 42 | // Check form data first (POST) 43 | if (!string.IsNullOrEmpty(formBody)) { 44 | string[] pairs = formBody.Split('&'); 45 | foreach (string pair in pairs) { 46 | string[] keyValue = pair.Split(new char[] {'='}, 2); 47 | if (keyValue.Length == 2 && keyValue[0] == "redirect_uri") { 48 | return keyValue[1]; 49 | } 50 | } 51 | } 52 | 53 | // Fallback to query string (GET) 54 | return (string)context.Request.Url.Query.GetValueOrDefault("redirect_uri", ""); 55 | }" /> 56 | 57 | <set-variable name="state" value="@{ 58 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 59 | 60 | // Check form data first (POST) 61 | if (!string.IsNullOrEmpty(formBody)) { 62 | string[] pairs = formBody.Split('&'); 63 | foreach (string pair in pairs) { 64 | string[] keyValue = pair.Split(new char[] {'='}, 2); 65 | if (keyValue.Length == 2 && keyValue[0] == "state") { 66 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 67 | } 68 | } 69 | } 70 | 71 | // Fallback to query string (GET) 72 | string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("state", ""); 73 | return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; 74 | }" /> 75 | 76 | <set-variable name="code_challenge" value="@{ 77 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 78 | 79 | // Check form data first (POST) 80 | if (!string.IsNullOrEmpty(formBody)) { 81 | string[] pairs = formBody.Split('&'); 82 | foreach (string pair in pairs) { 83 | string[] keyValue = pair.Split(new char[] {'='}, 2); 84 | if (keyValue.Length == 2 && keyValue[0] == "code_challenge") { 85 | return keyValue[1]; 86 | } 87 | } 88 | } 89 | 90 | // Fallback to query string (GET) 91 | return (string)context.Request.Url.Query.GetValueOrDefault("code_challenge", ""); 92 | }" /> 93 | 94 | <set-variable name="code_challenge_method" value="@{ 95 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 96 | 97 | // Check form data first (POST) 98 | if (!string.IsNullOrEmpty(formBody)) { 99 | string[] pairs = formBody.Split('&'); 100 | foreach (string pair in pairs) { 101 | string[] keyValue = pair.Split(new char[] {'='}, 2); 102 | if (keyValue.Length == 2 && keyValue[0] == "code_challenge_method") { 103 | return keyValue[1]; 104 | } 105 | } 106 | } 107 | 108 | // Fallback to query string (GET) 109 | return (string)context.Request.Url.Query.GetValueOrDefault("code_challenge_method", ""); 110 | }" /> 111 | 112 | <set-variable name="access_denied_template" value="@{ 113 | return @"<html lang='en'> 114 | <head> <meta charset='UTF-8'> 115 | <meta name='viewport' content='width=device-width, initial-scale=1.0'> 116 | <title>Access Denied</title> 117 | <style> 118 | __COMMON_STYLES__ 119 | .error-details { 120 | background-color: #f8f9fa; 121 | border: 1px solid #dee2e6; 122 | border-radius: 8px; 123 | padding: 20px; 124 | margin: 20px 0; 125 | font-family: 'Courier New', Consolas, monospace; 126 | font-size: 14px; 127 | line-height: 1.6; 128 | white-space: pre-wrap; 129 | overflow-x: auto; 130 | } 131 | 132 | .error-title { 133 | color: #dc3545; 134 | font-weight: bold; 135 | margin-bottom: 10px; 136 | } 137 | 138 | .debug-section { 139 | margin-top: 15px; 140 | padding-top: 15px; 141 | border-top: 1px solid #dee2e6; 142 | } 143 | 144 | .debug-label { 145 | font-weight: bold; 146 | color: #495057; 147 | } 148 | </style> 149 | </head> 150 | <body> 151 | <div class='consent-container'> 152 | <h1 class='denial-heading'>Access Denied</h1> 153 | <div class='error-details'> 154 | <div class='error-title'>Error Details:</div> 155 | __DENIAL_MESSAGE__ 156 | </div> 157 | <p>The application will not be able to access your data.</p> 158 | <p>You can close this window safely.</p> 159 | </div> 160 | </body> 161 | </html>"; 162 | }" /> 163 | 164 | <!-- Reusable function to generate 403 error response --> 165 | <set-variable name="generate_403_response" value="@{ 166 | string errorTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); 167 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 168 | string message = "Access denied."; 169 | 170 | // Replace placeholders with actual content 171 | errorTemplate = errorTemplate.Replace("__COMMON_STYLES__", commonStyles); 172 | errorTemplate = errorTemplate.Replace("__DENIAL_MESSAGE__", message); 173 | 174 | return errorTemplate; 175 | }" /> 176 | <!-- Error page template --> 177 | <set-variable name="client_not_found_template" value="@{ 178 | return @"<html lang='en'> 179 | <head> 180 | <meta charset='UTF-8'> 181 | <meta name='viewport' content='width=device-width, initial-scale=1.0'> 182 | <title>Client Not Found</title> 183 | <style> 184 | __COMMON_STYLES__ 185 | </style> 186 | </head> 187 | <body> 188 | <div class='consent-container'> 189 | <h1 class='denial-heading'>Client Not Found</h1> 190 | <p>The client registration for the specified client was not found.</p> 191 | <div class='client-info'> 192 | <p><strong>Client ID:</strong> <code>__CLIENT_ID_DISPLAY__</code></p> 193 | <p><strong>Redirect URI:</strong> <code>__REDIRECT_URI__</code></p> 194 | </div> 195 | <p>Please ensure that you are using a properly registered client application.</p> 196 | <p>You can close this window safely.</p> 197 | </div> 198 | </body> 199 | </html>"; 200 | }" /> 201 | <!-- Normalize redirect URI by handling potential double-encoding --> 202 | <set-variable name="normalized_redirect_uri" value="@{ 203 | string redirectUri = context.Variables.GetValueOrDefault<string>("redirect_uri", ""); 204 | 205 | if (string.IsNullOrEmpty(redirectUri)) { 206 | return ""; 207 | } 208 | 209 | try { 210 | string firstDecode = System.Net.WebUtility.UrlDecode(redirectUri); 211 | 212 | // Check if still encoded (contains % followed by hex digits) 213 | if (firstDecode.Contains("%") && System.Text.RegularExpressions.Regex.IsMatch(firstDecode, @"%[0-9A-Fa-f]{2}")) { 214 | // Double-encoded, decode again 215 | string secondDecode = System.Net.WebUtility.UrlDecode(firstDecode); 216 | return secondDecode; 217 | } else { 218 | // Single encoding, first decode is sufficient 219 | return firstDecode; 220 | } 221 | } catch (Exception) { 222 | // If decoding fails, return original value 223 | return redirectUri; 224 | } 225 | }" /> 226 | 227 | <!-- Cache client information lookup --> 228 | <cache-lookup-value key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" variable-name="clientInfoJson" /> 229 | 230 | <!-- If cache lookup failed, try to retrieve from CosmosDB --> 231 | <choose> 232 | <when condition="@(string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("clientInfoJson")))"> 233 | <!-- Get CosmosDB access token using managed identity --> 234 | <authentication-managed-identity resource="https://cosmos.azure.com" output-token-variable-name="cosmosAccessToken" /> 235 | 236 | <send-request mode="new" response-variable-name="cosmosClientResponse" timeout="30" ignore-error="true"> 237 | <set-url>@($"{{CosmosDbEndpoint}}/dbs/{{CosmosDbDatabase}}/colls/{{CosmosDbContainer}}/docs/{context.Variables.GetValueOrDefault<string>("client_id")}")</set-url> 238 | <set-method>GET</set-method> 239 | <set-header name="Content-Type" exists-action="override"> 240 | <value>application/json</value> 241 | </set-header> 242 | <set-header name="x-ms-version" exists-action="override"> 243 | <value>2018-12-31</value> 244 | </set-header> 245 | <set-header name="x-ms-partitionkey" exists-action="override"> 246 | <value>@($"[\"{context.Variables.GetValueOrDefault<string>("client_id")}\"]")</value> 247 | </set-header> 248 | <set-header name="Authorization" exists-action="override"> 249 | <value>@($"type=aad&ver=1.0&sig={context.Variables.GetValueOrDefault<string>("cosmosAccessToken")}")</value> 250 | </set-header> 251 | </send-request> 252 | 253 | <!-- If CosmosDB request was successful, extract client info --> 254 | <choose> 255 | <when condition="@(((IResponse)context.Variables["cosmosClientResponse"]).StatusCode == 200)"> 256 | <set-variable name="clientInfoJson" value="@{ 257 | var cosmosResponse = (IResponse)context.Variables["cosmosClientResponse"]; 258 | var cosmosDocument = cosmosResponse.Body.As<JObject>(); 259 | 260 | // Extract the client info fields we need 261 | var clientInfo = new JObject(); 262 | clientInfo["client_name"] = cosmosDocument["client_name"]; 263 | clientInfo["client_uri"] = cosmosDocument["client_uri"]; 264 | clientInfo["redirect_uris"] = cosmosDocument["redirect_uris"]; 265 | 266 | return clientInfo.ToString(); 267 | }" /> 268 | 269 | <!-- Store in cache for future requests --> 270 | <cache-store-value duration="3600" 271 | key="@($"ClientInfo-{context.Variables.GetValueOrDefault<string>("client_id")}")" 272 | value="@(context.Variables.GetValueOrDefault<string>("clientInfoJson"))" /> 273 | </when> 274 | </choose> 275 | </when> 276 | </choose> 277 | 278 | <!-- Get OAuth scopes from configuration --> 279 | <set-variable name="oauth_scopes" value="{{OAuthScopes}}" /> 280 | 281 | <!-- Generate CSRF token for form protection (GET requests only) --> 282 | <set-variable name="csrf_token" value="@{ 283 | // Only generate tokens for GET requests (showing consent form) 284 | // POST requests validate existing tokens, not generate new ones 285 | if (context.Request.Method != "GET") { 286 | return ""; 287 | } 288 | 289 | // Generate random CSRF token using Guid and timestamp 290 | string guidPart = Guid.NewGuid().ToString("N"); 291 | string timestampPart = DateTime.UtcNow.Ticks.ToString(); 292 | string combinedString = guidPart + timestampPart; 293 | 294 | // Create URL-safe token by encoding combined string 295 | string token = System.Convert.ToBase64String( 296 | System.Text.Encoding.UTF8.GetBytes(combinedString) 297 | ).Replace("+", "-").Replace("/", "_").Replace("=", "").Substring(0, 32); 298 | 299 | return token; 300 | }" /> 301 | 302 | <!-- Cache CSRF token for validation (GET requests only) --> 303 | <choose> 304 | <when condition="@(context.Request.Method == "GET" && !string.IsNullOrEmpty(context.Variables.GetValueOrDefault<string>("csrf_token")))"> 305 | <cache-store-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token")}")" 306 | value="@{ 307 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 308 | string normalizedRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 309 | string timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); 310 | string tokenData = $"{clientId}:{normalizedRedirectUri}:{timestamp}"; 311 | 312 | // Add debugging metadata 313 | string debugInfo = $"CACHED_AT:{DateTime.UtcNow:yyyy-MM-ddTHH:mm:ss.fffZ}"; 314 | return $"{tokenData}|{debugInfo}"; 315 | }" 316 | duration="900" /> 317 | <!-- Track token caching for debugging --> 318 | <set-variable name="csrf_token_cached" value="true" /> 319 | </when> 320 | <otherwise> 321 | <set-variable name="csrf_token_cached" value="false" /> 322 | </otherwise> 323 | </choose> 324 | <!-- Validate client registration --> 325 | <set-variable name="is_client_registered" value="@{ 326 | try { 327 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 328 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 329 | 330 | if (string.IsNullOrEmpty(clientId)) { 331 | return false; 332 | } 333 | 334 | // Get client info from cache lookup 335 | string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); 336 | if (string.IsNullOrEmpty(clientInfoJson)) { 337 | return false; 338 | } 339 | 340 | // Parse client configuration 341 | JObject clientInfo = JObject.Parse(clientInfoJson); 342 | JArray redirectUris = clientInfo["redirect_uris"]?.ToObject<JArray>(); 343 | 344 | // Validate redirect URI is registered 345 | if (redirectUris != null) { 346 | foreach (var uri in redirectUris) { 347 | // Normalize registered URI for comparison 348 | string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); 349 | if (registeredUri == redirectUri) { 350 | return true; 351 | } 352 | } 353 | } 354 | 355 | return false; 356 | } 357 | catch (Exception ex) { 358 | return false; 359 | } 360 | }" /> 361 | 362 | <!-- Extract client name from cache --> 363 | <set-variable name="client_name" value="@{ 364 | try { 365 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 366 | 367 | if (string.IsNullOrEmpty(clientId)) { 368 | return "Unknown Application"; 369 | } 370 | 371 | // Get client info from cache lookup 372 | string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); 373 | 374 | if (string.IsNullOrEmpty(clientInfoJson)) { 375 | return clientId; 376 | } 377 | 378 | // Parse client configuration 379 | JObject clientInfo = JObject.Parse(clientInfoJson); 380 | string clientName = clientInfo["client_name"]?.ToString(); 381 | 382 | return string.IsNullOrEmpty(clientName) ? clientId : clientName; 383 | } 384 | catch (Exception ex) { 385 | return context.Variables.GetValueOrDefault<string>("client_id", "Unknown Application"); 386 | } 387 | }" /> 388 | 389 | <!-- Extract client URI from cache --> 390 | <set-variable name="client_uri" value="@{ 391 | try { 392 | // Get client info from cache lookup 393 | string clientInfoJson = context.Variables.GetValueOrDefault<string>("clientInfoJson"); 394 | 395 | if (string.IsNullOrEmpty(clientInfoJson)) { 396 | return "N/A"; 397 | } 398 | 399 | // Parse client configuration 400 | JObject clientInfo = JObject.Parse(clientInfoJson); 401 | string clientUri = clientInfo["client_uri"]?.ToString(); 402 | 403 | return string.IsNullOrEmpty(clientUri) ? "N/A" : clientUri; 404 | } 405 | catch (Exception ex) { 406 | return "N/A"; 407 | } 408 | }" /> 409 | 410 | <!-- Define common styles for consent and error pages --> 411 | <set-variable name="common_styles" value="@{ 412 | return @" body { 413 | font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 414 | max-width: 100%; 415 | margin: 0; padding: 0; 416 | line-height: 1.6; 417 | min-height: 100vh; 418 | background: linear-gradient(135deg, #1f1f1f, #333344, #3f4066); /* Modern dark gradient */ 419 | color: #333333; 420 | display: flex; 421 | justify-content: center; 422 | align-items: center; 423 | }.container, .consent-container { 424 | background-color: #ffffff; 425 | border-radius: 4px; /* Adding some subtle rounding */ 426 | padding: 30px; 427 | max-width: 600px; width: 90%; 428 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); 429 | border: none; 430 | } 431 | 432 | h1 { 433 | margin-bottom: 20px; 434 | border-bottom: 1px solid #EDEBE9; 435 | padding-bottom: 10px; 436 | font-weight: 500; 437 | } 438 | .consent-heading { 439 | color: #0078D4; /* Microsoft Blue */ 440 | } 441 | .denial-heading { 442 | color: #D83B01; /* Microsoft Attention color */ 443 | } 444 | 445 | p { 446 | margin: 15px 0; 447 | line-height: 1.7; 448 | color: #323130; /* Microsoft text color */ 449 | } .client-info { 450 | background-color: #F5F5F5; /* Light gray background for info boxes */ 451 | padding: 15px; 452 | border-radius: 4px; /* Adding some subtle rounding */ 453 | margin: 15px 0; 454 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); 455 | border: 1px solid #EDEBE9; 456 | } 457 | .client-info p { 458 | display: flex; 459 | align-items: flex-start; 460 | margin: 8px 0; 461 | } 462 | 463 | .client-info strong { 464 | min-width: 160px; 465 | flex-shrink: 0; 466 | text-align: left; 467 | padding-right: 15px; 468 | color: #0078D4; /* Microsoft Blue */ 469 | } 470 | .client-info code { 471 | font-family: 'Consolas', 'Monaco', 'Courier New', monospace; 472 | background-color: rgba(240, 240, 250, 0.5); 473 | padding: 2px 6px; 474 | border-radius: 4px; /* Adding some subtle rounding */ 475 | color: #0078D4; /* Microsoft Blue */ 476 | word-break: break-all; 477 | } 478 | .btn { 479 | display: inline-block; 480 | padding: 8px 16px; 481 | margin: 10px 0; 482 | border-radius: 4px; /* Adding some subtle rounding */ 483 | text-decoration: none; 484 | font-weight: 600; 485 | cursor: pointer; 486 | transition: all 0.2s ease; 487 | } 488 | 489 | .btn-primary { 490 | background-color: #0078D4; /* Microsoft Blue */ 491 | color: white; 492 | border: none; 493 | } 494 | .btn-primary:hover { 495 | background-color: #106EBE; /* Microsoft Blue hover */ 496 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 497 | } 498 | 499 | .btn-secondary { 500 | background-color: #D83B01; /* Microsoft Red */ 501 | color: white; /* White text */ 502 | border: none; 503 | } 504 | .btn-secondary:hover { 505 | background-color: #A80000; /* Darker red on hover */ 506 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 507 | } 508 | .buttons { 509 | margin-top: 20px; 510 | display: flex; 511 | gap: 10px; 512 | justify-content: flex-start; 513 | } 514 | 515 | a { 516 | color: #0078D4; /* Microsoft Blue */ 517 | text-decoration: none; 518 | font-weight: 600; 519 | } 520 | a:hover { 521 | text-decoration: underline; 522 | } 523 | strong { 524 | color: #0078D4; /* Microsoft Blue */ 525 | font-weight: 600; 526 | } .error-message { 527 | background-color: #FDE7E9; /* Light red background */ 528 | padding: 15px; 529 | margin: 15px 0; 530 | border-radius: 4px; /* Adding some subtle rounding */ 531 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); 532 | border-left: 3px solid #D83B01; /* Microsoft Attention color */ 533 | } 534 | 535 | .error-message p { 536 | margin: 8px 0; 537 | } 538 | 539 | .error-message p:first-child { 540 | font-weight: 500; 541 | color: #D83B01; /* Microsoft Attention color */ 542 | }"; 543 | }" /> 544 | 545 | <!-- Consent page HTML template --> 546 | <set-variable name="consent_page_template" value="@{ 547 | return @"<html lang='en'> 548 | <head> <meta charset='UTF-8'> 549 | <meta name='viewport' content='width=device-width, initial-scale=1.0'> 550 | <title>Application Consent</title> 551 | <style> 552 | __COMMON_STYLES__ /* Additional styles for scopes list */ 553 | .scopes-list { 554 | margin: 0; 555 | padding-left: 0; 556 | } 557 | .scopes-list li { 558 | list-style-type: none; 559 | padding: 4px 0; 560 | display: flex; 561 | } 562 | </style> 563 | </head> 564 | <body> 565 | <div class='consent-container'> 566 | <h1 class='consent-heading'>Application Access Request</h1> 567 | 568 | <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> 569 | 570 | <div class='client-info'> 571 | <p><strong>Application Name:</strong> <code>__CLIENT_NAME__</code></p> 572 | <p><strong>Application Website:</strong> <code>__CLIENT_URI__</code></p> 573 | <p><strong>Application ID:</strong> <code>__CLIENT_ID_DISPLAY__</code></p> 574 | <p><strong>Redirect URI:</strong> <code>__REDIRECT_URI__</code></p> 575 | </div> 576 | <p>The application will have access to the following scopes, used by <strong>{{MCPServerName}}</strong>:</p> 577 | <div class='client-info'> 578 | <ul class='scopes-list'> 579 | <li>__OAUTH_SCOPES__</li> 580 | </ul> 581 | </div> <div class='buttons'> 582 | <form method='post' action='__CONSENT_ACTION_URL__' style='display: inline-block;'> 583 | <input type='hidden' name='client_id' value='__CLIENT_ID_FORM__'> 584 | <input type='hidden' name='redirect_uri' value='__REDIRECT_URI__'> 585 | <input type='hidden' name='state' value='__STATE__'> 586 | <input type='hidden' name='code_challenge' value='__CODE_CHALLENGE__'> 587 | <input type='hidden' name='code_challenge_method' value='__CODE_CHALLENGE_METHOD__'> 588 | <input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'> 589 | <input type='hidden' name='consent_action' value='allow'> 590 | <button type='submit' class='btn btn-primary'>Allow</button> 591 | </form> 592 | 593 | <form method='post' action='__CONSENT_ACTION_URL__' style='display: inline-block;'> <input type='hidden' name='client_id' value='__CLIENT_ID_FORM__'> 594 | <input type='hidden' name='redirect_uri' value='__REDIRECT_URI__'> 595 | <input type='hidden' name='state' value='__STATE__'> 596 | <input type='hidden' name='code_challenge' value='__CODE_CHALLENGE__'> 597 | <input type='hidden' name='code_challenge_method' value='__CODE_CHALLENGE_METHOD__'> 598 | <input type='hidden' name='csrf_token' value='__CSRF_TOKEN__'> 599 | <input type='hidden' name='consent_action' value='deny'> 600 | <button type='submit' class='btn btn-secondary'>Deny</button> 601 | </form> 602 | </div> 603 | </div> 604 | </body> 605 | </html>"; 606 | }" /> 607 | 608 | <!-- Check for existing client denial cookie --> 609 | <set-variable name="has_denial_cookie" value="@{ 610 | try { 611 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 612 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 613 | 614 | if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(redirectUri)) { 615 | return false; 616 | } 617 | 618 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 619 | if (string.IsNullOrEmpty(cookieHeader)) { 620 | return false; 621 | } 622 | 623 | string cookieName = "__Host-MCP_DENIED_CLIENTS"; 624 | string[] cookies = cookieHeader.Split(';'); 625 | foreach (string cookie in cookies) { 626 | string trimmedCookie = cookie.Trim(); 627 | if (trimmedCookie.StartsWith(cookieName + "=")) { 628 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 629 | try { 630 | string decodedValue = System.Text.Encoding.UTF8.GetString( 631 | System.Convert.FromBase64String(cookieValue.Split('.')[0])); 632 | JArray clients = JArray.Parse(decodedValue); 633 | 634 | string clientKey = $"{clientId}:{redirectUri}"; 635 | 636 | foreach (var item in clients) { 637 | string itemString = item.ToString(); 638 | 639 | if (itemString == clientKey) { 640 | return true; 641 | } 642 | 643 | // Handle URL-encoded redirect URI in stored cookie 644 | try { 645 | if (itemString.Contains(':')) { 646 | string[] parts = itemString.Split(new char[] {':'}, 2); 647 | if (parts.Length == 2) { 648 | string storedClientId = parts[0]; 649 | string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); 650 | 651 | if (storedClientId == clientId && storedRedirectUri == redirectUri) { 652 | return true; 653 | } 654 | } 655 | } 656 | } catch (Exception ex) { 657 | // Ignore comparison errors and continue 658 | } 659 | } 660 | } catch (Exception ex) { 661 | // Ignore cookie parsing errors and continue 662 | } 663 | } 664 | } 665 | 666 | return false; 667 | } catch (Exception ex) { 668 | return false; 669 | } 670 | }" /> 671 | 672 | <!-- Check for existing client approval cookie --> 673 | <set-variable name="has_approval_cookie" value="@{ 674 | try { 675 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 676 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 677 | 678 | if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(redirectUri)) { 679 | return false; 680 | } 681 | 682 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 683 | if (string.IsNullOrEmpty(cookieHeader)) { 684 | return false; 685 | } 686 | 687 | string cookieName = "__Host-MCP_APPROVED_CLIENTS"; 688 | string[] cookies = cookieHeader.Split(';'); 689 | foreach (string cookie in cookies) { 690 | string trimmedCookie = cookie.Trim(); 691 | if (trimmedCookie.StartsWith(cookieName + "=")) { 692 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 693 | try { 694 | string decodedValue = System.Text.Encoding.UTF8.GetString( 695 | System.Convert.FromBase64String(cookieValue.Split('.')[0])); 696 | JArray clients = JArray.Parse(decodedValue); 697 | 698 | string clientKey = $"{clientId}:{redirectUri}"; 699 | 700 | foreach (var item in clients) { 701 | string itemString = item.ToString(); 702 | 703 | if (itemString == clientKey) { 704 | return true; 705 | } 706 | 707 | // Handle URL-encoded redirect URI in stored cookie 708 | try { 709 | if (itemString.Contains(':')) { 710 | string[] parts = itemString.Split(new char[] {':'}, 2); 711 | if (parts.Length == 2) { 712 | string storedClientId = parts[0]; 713 | string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); 714 | 715 | if (storedClientId == clientId && storedRedirectUri == redirectUri) { 716 | return true; 717 | } 718 | } 719 | } 720 | } catch (Exception ex) { 721 | // Ignore comparison errors and continue 722 | } 723 | } 724 | } catch (Exception ex) { 725 | // Ignore cookie parsing errors and continue 726 | } 727 | } 728 | } 729 | 730 | return false; 731 | } catch (Exception ex) { 732 | return false; 733 | } 734 | }" /> 735 | 736 | <set-variable name="consent_action" value="@{ 737 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 738 | 739 | // Check form data first (POST) 740 | if (!string.IsNullOrEmpty(formBody)) { 741 | string[] pairs = formBody.Split('&'); 742 | foreach (string pair in pairs) { 743 | string[] keyValue = pair.Split(new char[] {'='}, 2); 744 | if (keyValue.Length == 2 && keyValue[0] == "consent_action") { 745 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 746 | } 747 | } 748 | } 749 | 750 | // Fallback to query string (GET) 751 | string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("consent_action", ""); 752 | return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; 753 | }" /> 754 | 755 | <!-- Extract CSRF token from form data --> 756 | <set-variable name="csrf_token_from_form" value="@{ 757 | string formBody = context.Variables.GetValueOrDefault<string>("form_body", ""); 758 | 759 | // Check form data first (POST) 760 | if (!string.IsNullOrEmpty(formBody)) { 761 | string[] pairs = formBody.Split('&'); 762 | foreach (string pair in pairs) { 763 | string[] keyValue = pair.Split(new char[] {'='}, 2); 764 | if (keyValue.Length == 2 && keyValue[0] == "csrf_token") { 765 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 766 | } 767 | } 768 | } 769 | 770 | // Fallback to query string (GET) 771 | string queryValue = (string)context.Request.Url.Query.GetValueOrDefault("csrf_token", ""); 772 | return !string.IsNullOrEmpty(queryValue) ? System.Net.WebUtility.UrlDecode(queryValue) : ""; 773 | }" /> 774 | 775 | <!-- Validate CSRF token for POST requests --> 776 | <set-variable name="csrf_valid" value="@{ 777 | if (context.Request.Method != "POST") { 778 | return true; // Only validate POST requests 779 | } 780 | 781 | string submittedToken = context.Variables.GetValueOrDefault<string>("csrf_token_from_form", ""); 782 | if (string.IsNullOrEmpty(submittedToken)) { 783 | return false; 784 | } 785 | 786 | // Token cache lookup validation happens next 787 | string cacheKey = $"CSRF-{submittedToken}"; 788 | 789 | return true; // Initial validation passes, detailed validation follows 790 | }" /> 791 | 792 | <!-- Validate Origin/Referer headers for CSRF protection --> 793 | <set-variable name="origin_referer_valid" value="@{ 794 | if (context.Request.Method != "POST") { 795 | return true; // Only validate state-changing operations 796 | } 797 | 798 | // Get the target origin (expected origin) 799 | string targetOrigin = "{{APIMGatewayURL}}"; 800 | 801 | // Remove protocol and trailing slash for comparison 802 | if (targetOrigin.StartsWith("https://")) { 803 | targetOrigin = targetOrigin.Substring(8); 804 | } else if (targetOrigin.StartsWith("http://")) { 805 | targetOrigin = targetOrigin.Substring(7); 806 | } 807 | if (targetOrigin.EndsWith("/")) { 808 | targetOrigin = targetOrigin.TrimEnd('/'); 809 | } 810 | 811 | // First check Origin header (preferred) 812 | string originHeader = context.Request.Headers.GetValueOrDefault("Origin", ""); 813 | if (!string.IsNullOrEmpty(originHeader)) { 814 | try { 815 | Uri originUri = new Uri(originHeader); 816 | string sourceOrigin = originUri.Host; 817 | if (originUri.Port != 80 && originUri.Port != 443) { 818 | sourceOrigin += ":" + originUri.Port; 819 | } 820 | 821 | if (sourceOrigin.Equals(targetOrigin, StringComparison.OrdinalIgnoreCase)) { 822 | return true; 823 | } else { 824 | return false; 825 | } 826 | } catch (Exception ex) { 827 | return false; 828 | } 829 | } 830 | 831 | // Fallback to Referer header if Origin is not present 832 | string refererHeader = context.Request.Headers.GetValueOrDefault("Referer", ""); 833 | if (!string.IsNullOrEmpty(refererHeader)) { 834 | try { 835 | Uri refererUri = new Uri(refererHeader); 836 | string sourceOrigin = refererUri.Host; 837 | if (refererUri.Port != 80 && refererUri.Port != 443) { 838 | sourceOrigin += ":" + refererUri.Port; 839 | } 840 | 841 | if (sourceOrigin.Equals(targetOrigin, StringComparison.OrdinalIgnoreCase)) { 842 | return true; 843 | } else { 844 | return false; 845 | } 846 | } catch (Exception ex) { 847 | return false; 848 | } 849 | } 850 | 851 | // Neither Origin nor Referer header present - this is suspicious for POST requests 852 | // OWASP recommends blocking such requests for better security 853 | return false; // Block requests without proper origin validation 854 | }" /> 855 | 856 | <!-- Validate Fetch Metadata headers for CSRF protection --> 857 | <set-variable name="fetch_metadata_valid" value="@{ 858 | // Check Sec-Fetch-Site header for cross-site request detection 859 | string secFetchSite = context.Request.Headers.GetValueOrDefault("Sec-Fetch-Site", ""); 860 | 861 | // Allow same-origin, same-site, and direct navigation 862 | if (string.IsNullOrEmpty(secFetchSite) || 863 | secFetchSite == "same-origin" || 864 | secFetchSite == "same-site" || 865 | secFetchSite == "none") { 866 | return true; 867 | } 868 | 869 | // Block cross-site POST requests 870 | if (context.Request.Method == "POST" && secFetchSite == "cross-site") { 871 | return false; 872 | } 873 | 874 | // Allow other values for compatibility 875 | return true; 876 | }" /> 877 | 878 | <!-- Lookup CSRF token from cache --> 879 | <cache-lookup-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token_from_form")}")" variable-name="csrf_token_data" /> 880 | 881 | <!-- Validate CSRF token details --> 882 | <set-variable name="csrf_validation_result" value="@{ 883 | if (context.Request.Method != "POST") { 884 | return "valid"; // No validation needed for GET requests 885 | } 886 | 887 | string submittedToken = context.Variables.GetValueOrDefault<string>("csrf_token_from_form", ""); 888 | if (string.IsNullOrEmpty(submittedToken)) { 889 | return "missing_token"; 890 | } 891 | 892 | string tokenData = context.Variables.GetValueOrDefault<string>("csrf_token_data"); 893 | if (string.IsNullOrEmpty(tokenData)) { 894 | return "invalid_token"; 895 | } 896 | 897 | try { 898 | // Extract token data (before debug info separator) 899 | string actualTokenData = tokenData; 900 | if (tokenData.Contains("|")) { 901 | actualTokenData = tokenData.Split('|')[0]; 902 | } 903 | 904 | // Parse token data: client_id:redirect_uri:timestamp 905 | // Since both redirect_uri and timestamp can contain colons, we need to be very careful 906 | // The timestamp format is: YYYY-MM-DDTHH:mm:ssZ 907 | // So we look for the last occurrence of a timestamp pattern 908 | 909 | // Find the last occurrence of a timestamp pattern (YYYY-MM-DDTHH:mm:ssZ) 910 | var timestampPattern = @":\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"; 911 | var timestampMatch = System.Text.RegularExpressions.Regex.Match(actualTokenData, timestampPattern); 912 | 913 | if (!timestampMatch.Success) { 914 | return "malformed_token"; 915 | } 916 | 917 | // Extract the timestamp (without the leading colon) 918 | string timestampStr = timestampMatch.Value.Substring(1); 919 | 920 | // Extract everything before the timestamp match as the client_id:redirect_uri part 921 | string clientAndRedirect = actualTokenData.Substring(0, timestampMatch.Index); 922 | 923 | // Split client_id:redirect_uri on the first colon only 924 | int firstColonIndex = clientAndRedirect.IndexOf(':'); 925 | if (firstColonIndex == -1) { 926 | return "malformed_token"; 927 | } 928 | 929 | string tokenClientId = clientAndRedirect.Substring(0, firstColonIndex); 930 | string tokenRedirectUri = clientAndRedirect.Substring(firstColonIndex + 1); 931 | 932 | // Validate client_id and redirect_uri match using constant-time comparison 933 | string currentClientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 934 | string currentRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 935 | 936 | // Constant-time string comparison for client_id to prevent timing attacks 937 | bool clientIdMatches = true; 938 | if (tokenClientId == null || currentClientId == null) { 939 | clientIdMatches = (tokenClientId == currentClientId); 940 | } else if (tokenClientId.Length != currentClientId.Length) { 941 | clientIdMatches = false; 942 | } else { 943 | int result = 0; 944 | for (int i = 0; i < tokenClientId.Length; i++) { 945 | result |= tokenClientId[i] ^ currentClientId[i]; 946 | } 947 | clientIdMatches = (result == 0); 948 | } 949 | 950 | if (!clientIdMatches) { 951 | return "client_mismatch"; 952 | } 953 | 954 | // Constant-time string comparison for redirect_uri to prevent timing attacks 955 | bool redirectUriMatches = true; 956 | if (tokenRedirectUri == null || currentRedirectUri == null) { 957 | redirectUriMatches = (tokenRedirectUri == currentRedirectUri); 958 | } else if (tokenRedirectUri.Length != currentRedirectUri.Length) { 959 | redirectUriMatches = false; 960 | } else { 961 | int result = 0; 962 | for (int i = 0; i < tokenRedirectUri.Length; i++) { 963 | result |= tokenRedirectUri[i] ^ currentRedirectUri[i]; 964 | } 965 | redirectUriMatches = (result == 0); 966 | } 967 | 968 | if (!redirectUriMatches) { 969 | return "redirect_mismatch"; 970 | } 971 | 972 | // Validate timestamp (token should not be older than 15 minutes) 973 | DateTime tokenTime; 974 | try { 975 | tokenTime = DateTime.Parse(timestampStr); 976 | } catch (Exception) { 977 | return "invalid_timestamp"; 978 | } 979 | 980 | TimeSpan age = DateTime.UtcNow - tokenTime; 981 | if (age.TotalMinutes > 15) { 982 | return "expired_token"; 983 | } 984 | 985 | return "valid"; 986 | 987 | } catch (Exception ex) { 988 | return "validation_error"; 989 | } 990 | }" /> 991 | 992 | <!-- If this is a form submission, process the consent choice --> 993 | <choose> 994 | <when condition="@(context.Request.Method == "POST")"> 995 | <!-- Validate Origin/Referer headers --> 996 | <choose> 997 | <when condition="@(!context.Variables.GetValueOrDefault<bool>("origin_referer_valid"))"> 998 | <!-- Origin/Referer validation failed --> 999 | <return-response> 1000 | <set-status code="403" reason="Forbidden" /> 1001 | <set-header name="Content-Type" exists-action="override"> 1002 | <value>text/html</value> 1003 | </set-header> 1004 | <set-header name="Cache-Control" exists-action="override"> 1005 | <value>no-store, no-cache</value> 1006 | </set-header> 1007 | <set-header name="Pragma" exists-action="override"> 1008 | <value>no-cache</value> 1009 | </set-header> 1010 | <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> 1011 | </return-response> 1012 | </when> 1013 | <otherwise> 1014 | <!-- Origin/Referer validation passed --> 1015 | <!-- Validate Fetch Metadata headers --> 1016 | <choose> 1017 | <when condition="@(!context.Variables.GetValueOrDefault<bool>("fetch_metadata_valid"))"> 1018 | <!-- Fetch metadata validation failed --> 1019 | <return-response> 1020 | <set-status code="403" reason="Forbidden" /> 1021 | <set-header name="Content-Type" exists-action="override"> 1022 | <value>text/html</value> 1023 | </set-header> 1024 | <set-header name="Cache-Control" exists-action="override"> 1025 | <value>no-store, no-cache</value> 1026 | </set-header> 1027 | <set-header name="Pragma" exists-action="override"> 1028 | <value>no-cache</value> 1029 | </set-header> 1030 | <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> 1031 | </return-response> 1032 | </when> 1033 | <otherwise> 1034 | <!-- Fetch metadata validation passed --> 1035 | <!-- Validate CSRF token --> 1036 | <choose> 1037 | <when condition="@(context.Variables.GetValueOrDefault<string>("csrf_validation_result") != "valid")"> 1038 | <!-- CSRF validation failed --> 1039 | <return-response> 1040 | <set-status code="403" reason="Forbidden" /> 1041 | <set-header name="Content-Type" exists-action="override"> 1042 | <value>text/html</value> 1043 | </set-header> 1044 | <set-header name="Cache-Control" exists-action="override"> 1045 | <value>no-store, no-cache</value> 1046 | </set-header> 1047 | <set-header name="Pragma" exists-action="override"> 1048 | <value>no-cache</value> 1049 | </set-header> 1050 | <set-body>@(context.Variables.GetValueOrDefault<string>("generate_403_response"))</set-body> 1051 | </return-response> 1052 | </when> 1053 | <otherwise> 1054 | <!-- CSRF validation passed --> 1055 | <!-- Delete CSRF token from cache to prevent reuse --> 1056 | <cache-remove-value key="@($"CSRF-{context.Variables.GetValueOrDefault<string>("csrf_token_from_form")}")" /> 1057 | 1058 | <choose> 1059 | <when condition="@(context.Variables.GetValueOrDefault<string>("consent_action") == "allow")"> 1060 | <!-- Process consent approval --> 1061 | <set-variable name="response_status_code" value="302" /> 1062 | 1063 | <set-variable name="response_redirect_location" value="@{ 1064 | string baseUrl = "{{APIMGatewayURL}}"; 1065 | 1066 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1067 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1068 | string originalState = context.Variables.GetValueOrDefault<string>("state", ""); 1069 | 1070 | string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); 1071 | string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); 1072 | // State should be used as-is since it's already properly formatted from the original request 1073 | string encodedState = originalState; 1074 | 1075 | // Add PKCE parameters if they exist 1076 | string codeChallenge = context.Variables.GetValueOrDefault<string>("code_challenge", ""); 1077 | string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("code_challenge_method", ""); 1078 | 1079 | string url = $"{baseUrl}/authorize?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}"; 1080 | 1081 | if (!string.IsNullOrEmpty(codeChallenge)) { 1082 | url += $"&code_challenge={System.Net.WebUtility.UrlEncode(codeChallenge)}"; 1083 | } 1084 | 1085 | if (!string.IsNullOrEmpty(codeChallengeMethod)) { 1086 | url += $"&code_challenge_method={System.Net.WebUtility.UrlEncode(codeChallengeMethod)}"; 1087 | } 1088 | 1089 | return url; 1090 | }" /> 1091 | <!-- Calculate approval cookie value --> 1092 | <set-variable name="approval_cookie" value="@{ 1093 | string cookieName = "__Host-MCP_APPROVED_CLIENTS"; 1094 | 1095 | // Use already extracted parameters instead of re-parsing form data 1096 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1097 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1098 | 1099 | // Create a unique identifier for this client/redirect combination 1100 | string clientKey = $"{clientId}:{redirectUri}"; 1101 | 1102 | // Check for existing cookie 1103 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 1104 | JArray approvedClients = new JArray(); 1105 | 1106 | if (!string.IsNullOrEmpty(cookieHeader)) { 1107 | // Parse cookies to find our approval cookie 1108 | string[] cookies = cookieHeader.Split(';'); 1109 | foreach (string cookie in cookies) { 1110 | string trimmedCookie = cookie.Trim(); 1111 | if (trimmedCookie.StartsWith(cookieName + "=")) { 1112 | try { 1113 | // Extract and parse the cookie value 1114 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 1115 | // Get the payload part (before the first dot if cookie is signed) 1116 | string payload = cookieValue.Contains('.') ? 1117 | cookieValue.Split('.')[0] : cookieValue; 1118 | string decodedValue = System.Text.Encoding.UTF8.GetString( 1119 | System.Convert.FromBase64String(payload)); 1120 | approvedClients = JArray.Parse(decodedValue); 1121 | } catch (Exception) { 1122 | // If parsing fails, we'll just create a new cookie 1123 | approvedClients = new JArray(); 1124 | } 1125 | break; 1126 | } 1127 | } 1128 | } 1129 | 1130 | // Add the current client if not already in the list 1131 | bool clientExists = false; 1132 | foreach (var item in approvedClients) { 1133 | if (item.ToString() == clientKey) { 1134 | clientExists = true; 1135 | break; 1136 | } 1137 | } 1138 | 1139 | if (!clientExists) { 1140 | approvedClients.Add(clientKey); 1141 | } 1142 | 1143 | // Base64 encode the client list 1144 | string jsonClients = approvedClients.ToString(Newtonsoft.Json.Formatting.None); 1145 | string encodedClients = System.Convert.ToBase64String( 1146 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 1147 | 1148 | // Return the full cookie string with appropriate settings 1149 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 1150 | }" /> 1151 | 1152 | <!-- Set variables for outbound policy awareness --> 1153 | <set-variable name="consent_approved" value="true" /> 1154 | <set-variable name="cookie_name" value="__Host-MCP_APPROVED_CLIENTS" /> 1155 | 1156 | <!-- Return the response with the cookie already set --> 1157 | <return-response> 1158 | <set-status code="302" reason="Found" /> 1159 | <set-header name="Location" exists-action="override"> 1160 | <value>@(context.Variables.GetValueOrDefault<string>("response_redirect_location", ""))</value> 1161 | </set-header> 1162 | <set-header name="Set-Cookie" exists-action="append"> 1163 | <value>@(context.Variables.GetValueOrDefault<string>("approval_cookie"))</value> 1164 | </set-header> 1165 | </return-response> 1166 | </when> 1167 | <when condition="@(context.Variables.GetValueOrDefault<string>("consent_action") == "deny")"> 1168 | <!-- Process consent denial --> 1169 | <set-variable name="response_status_code" value="403" /> 1170 | <set-variable name="response_content_type" value="text/html" /> 1171 | <set-variable name="response_cache_control" value="no-store, no-cache" /> 1172 | <set-variable name="response_pragma" value="no-cache" /> 1173 | 1174 | <!-- Calculate the cookie value right here in inbound before returning response --> 1175 | <set-variable name="denial_cookie" value="@{ 1176 | string cookieName = "__Host-MCP_DENIED_CLIENTS"; 1177 | 1178 | // Use already extracted parameters instead of re-parsing form data 1179 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1180 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1181 | 1182 | // Create a unique identifier for this client/redirect combination 1183 | string clientKey = $"{clientId}:{redirectUri}"; 1184 | 1185 | // Check for existing cookie 1186 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 1187 | JArray deniedClients = new JArray(); 1188 | 1189 | if (!string.IsNullOrEmpty(cookieHeader)) { 1190 | // Parse cookies to find our denial cookie 1191 | string[] cookies = cookieHeader.Split(';'); 1192 | foreach (string cookie in cookies) { 1193 | string trimmedCookie = cookie.Trim(); 1194 | if (trimmedCookie.StartsWith(cookieName + "=")) { 1195 | try { 1196 | // Extract and parse the cookie value 1197 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 1198 | // Get the payload part (before the first dot if cookie is signed) 1199 | string payload = cookieValue.Contains('.') ? 1200 | cookieValue.Split('.')[0] : cookieValue; 1201 | string decodedValue = System.Text.Encoding.UTF8.GetString( 1202 | System.Convert.FromBase64String(payload)); 1203 | deniedClients = JArray.Parse(decodedValue); 1204 | } catch (Exception) { 1205 | // If parsing fails, we'll just create a new cookie 1206 | deniedClients = new JArray(); 1207 | } 1208 | break; 1209 | } 1210 | } 1211 | } 1212 | 1213 | // Add the current client if not already in the list 1214 | bool clientExists = false; 1215 | foreach (var item in deniedClients) { 1216 | if (item.ToString() == clientKey) { 1217 | clientExists = true; 1218 | break; 1219 | } 1220 | } 1221 | 1222 | if (!clientExists) { 1223 | deniedClients.Add(clientKey); 1224 | } 1225 | 1226 | // Base64 encode the client list 1227 | string jsonClients = deniedClients.ToString(Newtonsoft.Json.Formatting.None); 1228 | string encodedClients = System.Convert.ToBase64String( 1229 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 1230 | 1231 | // Return the full cookie string with appropriate settings 1232 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 1233 | }" /> <!-- Store the HTML content for the access denied page --> 1234 | <set-variable name="response_body" value="@{ 1235 | string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); 1236 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 1237 | 1238 | // Replace placeholders with actual content 1239 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 1240 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", 1241 | "You have denied authorization for this application against the MCP server."); 1242 | 1243 | return denialTemplate; 1244 | }" /> 1245 | 1246 | <!-- Set variables for outbound policy awareness --> 1247 | <set-variable name="consent_denied" value="true" /> 1248 | <set-variable name="cookie_name" value="__Host-MCP_DENIED_CLIENTS" /> 1249 | 1250 | <!-- Return the response with the cookie already set --> 1251 | <return-response> 1252 | <set-status code="403" reason="Forbidden" /> 1253 | <set-header name="Content-Type" exists-action="override"> 1254 | <value>text/html</value> 1255 | </set-header> 1256 | <set-header name="Cache-Control" exists-action="override"> 1257 | <value>no-store, no-cache</value> 1258 | </set-header> 1259 | <set-header name="Pragma" exists-action="override"> 1260 | <value>no-cache</value> 1261 | </set-header> 1262 | <set-header name="Set-Cookie" exists-action="append"> 1263 | <value>@(context.Variables.GetValueOrDefault<string>("denial_cookie"))</value> 1264 | </set-header> 1265 | <set-body>@(context.Variables.GetValueOrDefault<string>("response_body", ""))</set-body> 1266 | </return-response> 1267 | </when> 1268 | <otherwise> 1269 | <!-- Invalid consent action - return error --> 1270 | <return-response> 1271 | <set-status code="403" reason="Forbidden" /> 1272 | <set-header name="Content-Type" exists-action="override"> 1273 | <value>text/html</value> 1274 | </set-header> 1275 | <!-- Explicitly disable any redirects --> 1276 | <set-header name="Cache-Control" exists-action="override"> 1277 | <value>no-store, no-cache</value> 1278 | </set-header> 1279 | <set-header name="Pragma" exists-action="override"> 1280 | <value>no-cache</value> 1281 | </set-header> 1282 | <set-body>@{ 1283 | string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); 1284 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 1285 | string consentAction = context.Variables.GetValueOrDefault<string>("consent_action", ""); 1286 | 1287 | string detailedMessage = $"Invalid consent action '{consentAction}' received. Expected 'allow' or 'deny'. This may indicate a form tampering attempt or a browser compatibility issue."; 1288 | 1289 | // Replace placeholders with actual content 1290 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 1291 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", detailedMessage); 1292 | 1293 | return denialTemplate; 1294 | }</set-body> 1295 | </return-response> 1296 | </otherwise> 1297 | </choose> 1298 | </otherwise> 1299 | </choose> 1300 | </otherwise> 1301 | </choose> 1302 | </otherwise> 1303 | </choose> 1304 | </when> 1305 | <!-- For GET requests, check for cookies first, then display consent page if no cookie found --> 1306 | <otherwise> 1307 | <choose> 1308 | <!-- If there's an approval cookie, skip consent and redirect to authorization endpoint --> 1309 | <when condition="@(context.Variables.GetValueOrDefault<bool>("has_approval_cookie"))"> 1310 | <!-- Set redirect location to authorization endpoint --> 1311 | <set-variable name="response_redirect_location" value="@{ 1312 | string baseUrl = "{{APIMGatewayURL}}"; 1313 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1314 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1315 | string state = context.Variables.GetValueOrDefault<string>("state", ""); 1316 | 1317 | // URL encode parameters to prevent injection attacks 1318 | string encodedClientId = System.Net.WebUtility.UrlEncode(clientId); 1319 | string encodedRedirectUri = System.Net.WebUtility.UrlEncode(redirectUri); 1320 | // State is already properly encoded, don't double-encode 1321 | string encodedState = state; 1322 | 1323 | // Add PKCE parameters if they exist 1324 | string codeChallenge = context.Variables.GetValueOrDefault<string>("code_challenge", ""); 1325 | string codeChallengeMethod = context.Variables.GetValueOrDefault<string>("code_challenge_method", ""); 1326 | 1327 | string url = $"{baseUrl}/authorize?client_id={encodedClientId}&redirect_uri={encodedRedirectUri}&state={encodedState}"; 1328 | 1329 | if (!string.IsNullOrEmpty(codeChallenge)) { 1330 | url += $"&code_challenge={System.Net.WebUtility.UrlEncode(codeChallenge)}"; 1331 | } 1332 | 1333 | if (!string.IsNullOrEmpty(codeChallengeMethod)) { 1334 | url += $"&code_challenge_method={System.Net.WebUtility.UrlEncode(codeChallengeMethod)}"; 1335 | } 1336 | 1337 | return url; 1338 | }" /> 1339 | 1340 | <!-- Redirect to authorization endpoint --> 1341 | <return-response> 1342 | <set-status code="302" reason="Found" /> 1343 | <set-header name="Location" exists-action="override"> 1344 | <value>@(context.Variables.GetValueOrDefault<string>("response_redirect_location", ""))</value> 1345 | </set-header> 1346 | </return-response> 1347 | </when> 1348 | 1349 | <!-- If there's a denial cookie, return access denied page immediately --> 1350 | <when condition="@(context.Variables.GetValueOrDefault<bool>("has_denial_cookie"))"> 1351 | <return-response> 1352 | <set-status code="403" reason="Forbidden" /> 1353 | <set-header name="Content-Type" exists-action="override"> 1354 | <value>text/html</value> 1355 | </set-header> 1356 | <!-- Explicitly disable any redirects --> 1357 | <set-header name="Cache-Control" exists-action="override"> 1358 | <value>no-store, no-cache</value> 1359 | </set-header> 1360 | <set-header name="Pragma" exists-action="override"> 1361 | <value>no-cache</value> 1362 | </set-header> 1363 | <set-body>@{ 1364 | string denialTemplate = context.Variables.GetValueOrDefault<string>("access_denied_template"); 1365 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 1366 | 1367 | // Replace placeholders with actual content 1368 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 1369 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", 1370 | "You have previously denied access to this application."); 1371 | 1372 | return denialTemplate; 1373 | }</set-body> 1374 | </return-response> 1375 | </when> 1376 | <!-- If no cookies found, show the consent screen --> 1377 | <otherwise> 1378 | <!-- Check if client is registered first --> 1379 | <choose> 1380 | <when condition="@(!context.Variables.GetValueOrDefault<bool>("is_client_registered"))"> 1381 | <!-- Client is not registered, show error page --> 1382 | <return-response> 1383 | <set-status code="403" reason="Forbidden" /> 1384 | <set-header name="Content-Type" exists-action="override"> 1385 | <value>text/html</value> 1386 | </set-header> 1387 | <set-header name="Cache-Control" exists-action="override"> 1388 | <value>no-store, no-cache</value> 1389 | </set-header> 1390 | <set-header name="Pragma" exists-action="override"> 1391 | <value>no-cache</value> 1392 | </set-header> 1393 | <set-body>@{ 1394 | string template = context.Variables.GetValueOrDefault<string>("client_not_found_template"); 1395 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 1396 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1397 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1398 | 1399 | // Replace placeholders with HTML-encoded content to prevent XSS 1400 | template = template.Replace("__COMMON_STYLES__", commonStyles); 1401 | template = template.Replace("__CLIENT_ID_DISPLAY__", System.Net.WebUtility.HtmlEncode(clientId)); 1402 | template = template.Replace("__REDIRECT_URI__", System.Net.WebUtility.HtmlEncode(redirectUri)); 1403 | 1404 | return template; 1405 | }</set-body> 1406 | </return-response> 1407 | </when> 1408 | <otherwise> <!-- Client is registered, get client name from the cache --> 1409 | <!-- Build consent page using the standardized template --> 1410 | <set-variable name="consent_page" value="@{ 1411 | string template = context.Variables.GetValueOrDefault<string>("consent_page_template"); 1412 | string commonStyles = context.Variables.GetValueOrDefault<string>("common_styles"); 1413 | 1414 | // Use the service URL from APIM configuration 1415 | string basePath = "{{APIMGatewayURL}}"; 1416 | 1417 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1418 | string clientName = context.Variables.GetValueOrDefault<string>("client_name", "Unknown Application"); 1419 | string clientUri = context.Variables.GetValueOrDefault<string>("client_uri", "N/A"); 1420 | string oauthScopes = context.Variables.GetValueOrDefault<string>("oauth_scopes", ""); 1421 | 1422 | // Get the normalized (human-readable) redirect URI for display 1423 | string normalizedRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1424 | 1425 | // Use the normalized redirect URI for form submission to ensure consistency 1426 | string formRedirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1427 | string htmlEncodedFormUri = System.Net.WebUtility.HtmlEncode(formRedirectUri); 1428 | 1429 | string state = context.Variables.GetValueOrDefault<string>("state", ""); 1430 | string csrfToken = context.Variables.GetValueOrDefault<string>("csrf_token", ""); 1431 | 1432 | // Create a temporary placeholder for the form fields 1433 | string FORM_FIELD_PLACEHOLDER = "___ENCODED_REDIRECT_URI___"; 1434 | 1435 | // Replace the styles first 1436 | template = template.Replace("__COMMON_STYLES__", commonStyles); 1437 | 1438 | // First, create a temporary placeholder for the form fields 1439 | template = template.Replace("value='__REDIRECT_URI__'", "value='" + FORM_FIELD_PLACEHOLDER + "'"); 1440 | 1441 | // Replace template placeholders with properly encoded values 1442 | template = template.Replace("__CLIENT_NAME__", System.Net.WebUtility.HtmlEncode(clientName)); 1443 | template = template.Replace("__CLIENT_URI__", System.Net.WebUtility.HtmlEncode(clientUri)); 1444 | // For display purposes, use HtmlEncode for safety 1445 | template = template.Replace("__CLIENT_ID_DISPLAY__", System.Net.WebUtility.HtmlEncode(clientId)); 1446 | template = template.Replace("__REDIRECT_URI__", System.Net.WebUtility.HtmlEncode(normalizedRedirectUri)); 1447 | // For form field values, use HtmlEncode for XSS protection 1448 | template = template.Replace("__CLIENT_ID_FORM__", System.Net.WebUtility.HtmlEncode(clientId)); 1449 | // State should be HTML-encoded for form safety (don't URL-decode first as it may already be in correct format) 1450 | template = template.Replace("__STATE__", System.Net.WebUtility.HtmlEncode(state)); 1451 | template = template.Replace("__CODE_CHALLENGE__", System.Net.WebUtility.HtmlEncode(context.Variables.GetValueOrDefault<string>("code_challenge", ""))); 1452 | template = template.Replace("__CODE_CHALLENGE_METHOD__", System.Net.WebUtility.HtmlEncode(context.Variables.GetValueOrDefault<string>("code_challenge_method", ""))); 1453 | template = template.Replace("__CSRF_TOKEN__", System.Net.WebUtility.HtmlEncode(csrfToken)); 1454 | template = template.Replace("__CONSENT_ACTION_URL__", basePath + "/consent"); 1455 | // Handle space-separated OAuth scopes and create individual list items with HTML encoding 1456 | string[] scopeArray = oauthScopes.Split(new char[] {' '}, StringSplitOptions.RemoveEmptyEntries); 1457 | StringBuilder scopeList = new StringBuilder(); 1458 | 1459 | foreach (string scope in scopeArray) { 1460 | scopeList.AppendLine($"<li><code>{System.Net.WebUtility.HtmlEncode(scope)}</code></li>"); 1461 | } 1462 | 1463 | template = template.Replace("__OAUTH_SCOPES__", scopeList.ToString()); 1464 | 1465 | // Replace form field placeholder with encoded URI 1466 | template = template.Replace(FORM_FIELD_PLACEHOLDER, htmlEncodedFormUri); return template; 1467 | }" /> 1468 | 1469 | <!-- Return consent page --> 1470 | <return-response> 1471 | <set-status code="200" reason="OK" /> 1472 | <set-header name="Content-Type" exists-action="override"> 1473 | <value>text/html</value> 1474 | </set-header> 1475 | <!-- Security headers --> 1476 | <set-header name="X-Frame-Options" exists-action="override"> 1477 | <value>DENY</value> 1478 | </set-header> 1479 | <set-header name="X-Content-Type-Options" exists-action="override"> 1480 | <value>nosniff</value> 1481 | </set-header> 1482 | <set-header name="X-XSS-Protection" exists-action="override"> 1483 | <value>1; mode=block</value> 1484 | </set-header> 1485 | <set-header name="Referrer-Policy" exists-action="override"> 1486 | <value>strict-origin-when-cross-origin</value> 1487 | </set-header> 1488 | <set-header name="Content-Security-Policy" exists-action="override"> 1489 | <value>default-src 'self'; style-src 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https:</value> 1490 | </set-header> 1491 | <set-header name="Cache-Control" exists-action="override"> 1492 | <value>no-store, no-cache, must-revalidate</value> 1493 | </set-header> 1494 | <set-header name="Pragma" exists-action="override"> 1495 | <value>no-cache</value> 1496 | </set-header> 1497 | <!-- Store the state parameter in a secure cookie for validation --> 1498 | <set-header name="Set-Cookie" exists-action="append"> 1499 | <value>@{ 1500 | string state = context.Variables.GetValueOrDefault<string>("state", ""); 1501 | string clientId = context.Variables.GetValueOrDefault<string>("client_id", ""); 1502 | string redirectUri = context.Variables.GetValueOrDefault<string>("normalized_redirect_uri", ""); 1503 | 1504 | // Create consent context data 1505 | var consentData = new JObject { 1506 | ["state"] = state, 1507 | ["clientId"] = clientId, 1508 | ["redirectUri"] = redirectUri, 1509 | ["timestamp"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") 1510 | }; 1511 | 1512 | // Base64 encode the consent data 1513 | string consentDataJson = consentData.ToString(Newtonsoft.Json.Formatting.None); 1514 | string encodedConsentData = System.Convert.ToBase64String( 1515 | System.Text.Encoding.UTF8.GetBytes(consentDataJson)); 1516 | 1517 | return $"__Host-MCP_CONSENT_STATE={encodedConsentData}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax"; 1518 | }</value> 1519 | </set-header> 1520 | <set-body>@{ 1521 | return context.Variables.GetValueOrDefault<string>("consent_page", ""); 1522 | }</set-body> 1523 | </return-response> 1524 | </otherwise> 1525 | </choose> 1526 | </otherwise> 1527 | </choose> 1528 | </otherwise> 1529 | </choose> 1530 | </inbound> 1531 | <backend> 1532 | <base /> 1533 | </backend> 1534 | <outbound> 1535 | <base /> 1536 | </outbound> 1537 | <on-error> 1538 | <base /> 1539 | </on-error> 1540 | </policies> 1541 | ```