# 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 |
```