#
tokens: 44278/50000 37/37 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | ![Diagram](mcp-client-authorization.gif)
 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 | ![overview diagram](overview.png)
 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 | ![MCP Client Authorization Flow](images/mcp-client-auth.png)
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 | 
```