diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7e28424 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/bin +**/obj +.azure +.git +.github +.serena +.claude +tools diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 317e0c5..9fe1dde 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' @@ -27,3 +27,23 @@ jobs: - name: Test run: dotnet test --no-build --verbosity normal if: hashFiles('**/*Tests*.csproj') != '' + + - name: Build infrastructure template + run: az bicep build --file infra/main.bicep + + - name: Validate provisioning hook syntax + shell: pwsh + run: | + $tokens = $null + $errors = $null + [Management.Automation.Language.Parser]::ParseFile( + "$PWD/scripts/complete-container-app-provision.ps1", + [ref]$tokens, + [ref]$errors) | Out-Null + if ($errors.Count -gt 0) { + $errors | ForEach-Object { Write-Error $_ } + exit 1 + } + + - name: Build container image + run: docker build --file src/DevBrain.Server/Dockerfile . diff --git a/.gitignore b/.gitignore index a2fc0e8..a153d51 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,6 @@ bld/ [Ll]og/ [Ll]ogs/ -# Azure Functions -local.settings.json - # Azure Developer CLI .azure/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed9249..d34915a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,27 +4,61 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman ## [Unreleased] +## [2.0.0] — 2026-09-29 + +DevBrain 2.0 replaces the Azure Functions host with a standalone ASP.NET Core host on Azure Container Apps. The MCP endpoint moves from `/runtime/webhooks/mcp` on the Function App to `/mcp` on the Container App. Existing documents carry over unchanged, and each client signs in once against the new endpoint. See "Upgrading from 1.x" in the README for the cutover and cleanup steps. + +### Added + +- Added `DevBrain.Server`, a .NET 10 Minimal API host using `ModelContextProtocol.AspNetCore` and the 2026-07-28 MCP specification revision. Its Streamable HTTP transport is stateless and exposed at `/mcp`. +- Added a shared `DevBrain.Core` library for the Cosmos document services and the OAuth/DCR implementation. +- Added application-owned ASP.NET Core authentication for the complete MCP protocol surface. Unauthenticated MCP requests receive `401` with a `WWW-Authenticate: Bearer resource_metadata="..."` challenge, which removes the Functions host-layer blocker for clients such as VS Code and GitHub Copilot. VS Code / GitHub Copilot now completes OAuth and works end to end. +- Added single-tenant `DevBrain.User` app-role enforcement. Validated Entra role claims are persisted with the local OAuth session and rehydrated into the caller principal. +- Added an anonymous `/healthz` process/readiness endpoint, configurable per-caller rate limiting, an explicit request-body limit, and optional explicit-origin CORS configuration. +- Added Azure Container Apps infrastructure with native HTTPS ingress, a Basic Azure Container Registry, managed identity, Key Vault secret references, Application Insights through Azure Monitor OpenTelemetry, and `0`–`3` replica defaults at `0.5` vCPU / `1 GiB`. +- Added optional secure `ENTRA_CLIENT_SECRET` and `JWT_SIGNING_SECRET` azd values that seed the two Key Vault secrets on first provision, so a new deployment needs no separate Key Vault data-plane step. +- Added an `OAUTH_REDIRECT_URI` provisioning output with the exact `/callback` URL to register on the Entra app. +- Added server integration tests for `/healthz`, protected-resource metadata, the MCP authentication challenge, app-role authorization, and all 12 published tool contracts. The RFC 6749/7591/8414/9728 wire-format guards now run against the server's OAuth response types. + +### Removed + +- **Removed the Azure Functions host.** Deleted the `DevBrain.Functions` project and its Function App, Flex Consumption plan, managed-identity role assignments, `deploymentpackage` and 1.x `dataprotection-keys` blob containers, the azd `api` service, and the post-provision Functions warm-up wait. `azd provision` is incremental, so existing 1.x environments must delete the old Function App and plan manually (see the README). +- Removed the Functions-only packages, including `Microsoft.ApplicationInsights.WorkerService`, `Microsoft.Azure.Functions.Worker.*`, `Microsoft.Azure.Functions.Worker.Extensions.Mcp`, and `Microsoft.Extensions.Azure`. +- Removed the Functions-vs-server tool contract parity test and the Functions-only `McpToolContextAccessor` tests. +- Removed the one-shot `/ops/touch` maintenance endpoint and its `TouchAllAsync` store operation. +- Removed the legacy function-key/SSE seed script; seed documents through any authenticated MCP client with `UpsertDocument`. + ### Fixed + +- Hardened OAuth diagnostics against log forging and accidental secret disclosure. Caller-controlled identifiers are now represented by fixed-length SHA-256 fingerprints or bounded structural flags, and Cosmos cleanup warnings no longer include raw OAuth keys or exception messages. Regression tests cover authorization, callback, registration, and token requests containing injected line breaks. - Hardened the OAuth `refresh_token` grant for MCP clients that retry or restart while their local credential cache is catching up to token rotation. A successful refresh now leaves a short replay marker for the old refresh token, so an immediate retry returns the same replacement refresh token instead of forcing a reconnect. Wrong-client refresh attempts are rejected without consuming the legitimate client's token, and successful refresh/replay calls slide the upstream token vault TTL forward with the local refresh window. +- Restored upstream Entra refresh on the first use of each rotated local refresh token. Tenant ID, object ID, UPN, and `roles` claims are revalidated before the next local access token is accepted; a failed or identity-changing refresh revokes the local upstream session. +- Added the 2026-07-28 authorization hardening used by the DCR flow: `application_type` registration metadata, loopback-only HTTP redirects, RFC 8707 `resource` binding across authorization/token/refresh requests, and the RFC 9207 `iss` authorization-response parameter. - Added Bicep validation for the required Entra tenant/client parameters so `azd provision` fails fast instead of blanking `OAuth__EntraTenantId` or `OAuth__EntraClientId`. +- Configured the Container App to use its system-assigned identity when pulling private images from ACR, so `azd deploy` no longer fails after a successful remote image build. +- Added a bounded, idempotent post-provision retry for the Container App's Cosmos DB data-role assignment. This handles the short Entra replication window that can otherwise reject a newly created managed identity during its first deployment. +- Preserved azd's last deployed Container App image during later infrastructure-only provisions instead of resetting a healthy revision to the public first-provision placeholder. ### Changed -- Added reason-specific server-side diagnostics for OAuth refresh failures. `TokenHandler/refresh` now logs a stable rejection reason (`missing`, `expired`, `replay_window_expired`, `wrong_client`, `upstream_missing_or_expired`, etc.) plus short SHA-256 refresh-token fingerprints so stale per-session client credential generations can be correlated without logging token material. -- Updated the compatibility notes for the modern unified ChatGPT/Codex Windows app, which is currently working well with DevBrain OAuth but remains under monitoring rather than being marked fully resolved. -- Added optional OAuth token-window settings for deployments that need a different refresh cadence or replay tolerance: `OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES` and `OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES` flow through Bicep to `OAuth__AccessTokenLifetimeMinutes` and `OAuth__RefreshReplayLifetimeMinutes`. Values must be whole minutes from 1 through 1,440. If left unset, DevBrain uses its built-in defaults: 10 minutes for access tokens and 5 minutes for refresh replay markers. -- Refreshed the deployed runtime dependency stack to current compatible NuGet releases, including `Microsoft.Azure.Functions.Worker` 2.52.0, `Microsoft.Azure.Functions.Worker.Extensions.Mcp` 1.6.0, `Microsoft.ApplicationInsights.WorkerService` 2.23.0, `Microsoft.Azure.Cosmos` 3.60.0, `Microsoft.Extensions.Azure` 1.14.0, IdentityModel 8.18.0, `Microsoft.AspNetCore.DataProtection` 10.0.8, and `System.Security.Cryptography.Xml` 10.0.10. Removed the unused direct `ModelContextProtocol` package reference. -- Kept Application Insights on the direct Azure Functions isolated worker integration path (`AddApplicationInsightsTelemetryWorkerService` + `ConfigureFunctionsApplicationInsights`) instead of moving to the newer OpenTelemetry telemetry wiring. -- Refreshed test tooling to `Microsoft.NET.Test.Sdk` 18.6.0, `xunit.runner.visualstudio` 3.1.5, and `Microsoft.Extensions.TimeProvider.Testing` 10.6.0. -- Synced the release notes with merged Dependabot PR #19, which already moved `Microsoft.AspNetCore.DataProtection` and `System.Security.Cryptography.Xml` to 10.0.7. -- Patched the remaining Azure Data Protection helper packages to `Azure.Extensions.AspNetCore.DataProtection.Blobs` 1.5.2 and `Azure.Extensions.AspNetCore.DataProtection.Keys` 1.6.2, then replaced the stale 10.0.6 workaround comment in the project file. + +- The release version now lives only in `Directory.Build.props`. The MCP `serverInfo` version and startup log read it from the assembly instead of a hard-coded string. +- Kept the `dataprotection-keys-v2` key ring container and the `v2:` OAuth-state key prefix names, so existing deployments keep their live sessions across the Functions retirement. +- Rewrote the README deploy guide as a step-by-step new-tenant runbook: Entra app registration (portal and CLI) including the `DevBrain.User` app role, user assignment, provision, redirect URI, deploy, verify, first-run seeding, upgrading from 1.x, and operating notes (secret rotation, cold starts, cost, purge-protected Key Vault on teardown). Local development now documents every required setting as .NET user secrets. +- Added reason-specific server-side diagnostics for OAuth refresh failures. The refresh handler logs a stable rejection reason (`missing`, `expired`, `replay_window_expired`, `wrong_client`, `upstream_missing_or_expired`, etc.) plus short SHA-256 refresh-token fingerprints, so stale per-session client credential generations can be correlated without logging token material. +- Added optional OAuth token-window settings for deployments that need a different refresh cadence or replay tolerance. `OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES` and `OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES` flow through Bicep to `OAuth__AccessTokenLifetimeMinutes` and `OAuth__RefreshReplayLifetimeMinutes`. Values must be whole minutes from 1 through 1,440. If they're unset, DevBrain uses its built-in defaults: 10 minutes for access tokens and 5 minutes for refresh replay markers. +- Updated the compatibility notes for the modern unified ChatGPT/Codex Windows app, which works well with DevBrain OAuth but remains under monitoring. +- Refreshed the runtime dependency stack to current releases: `ModelContextProtocol.AspNetCore` 2.2.0, `Microsoft.Azure.Cosmos` 3.63.1, IdentityModel 8.23.0, `Microsoft.AspNetCore.DataProtection` 10.0.12, `System.Security.Cryptography.Xml` 10.0.12, `Azure.Extensions.AspNetCore.DataProtection.Blobs` 1.5.4, and `Azure.Extensions.AspNetCore.DataProtection.Keys` 1.6.4. +- Migrated both test projects from the deprecated `xunit` 2.9.3 to `xunit.v3` 4.0.1 on Microsoft.Testing.Platform, opted in via a new root `global.json`. `Microsoft.NET.Test.Sdk` and `xunit.runner.visualstudio` are no longer referenced. Renamed `DevBrain.Functions.Tests` to `DevBrain.Core.Tests`. +- Bumped CI to `actions/checkout@v7` and `actions/setup-dotnet@v6` (Node 24 runtime). - Added `.serena/` to `.gitignore` so local Serena workspace metadata stays out of the public repository. ### Validation -- `dotnet list devbrain.slnx package --vulnerable --include-transitive` reports no vulnerable packages. -- `dotnet list devbrain.slnx package --outdated --highest-patch` reports no patch-level updates for direct package references. -- `dotnet list devbrain.slnx package --outdated --include-transitive` was checked; direct package references are current except the intentional `Microsoft.ApplicationInsights.WorkerService` 2.x hold for the existing Functions Application Insights integration path, with upstream-owned transitive package updates still reported. -- `dotnet list devbrain.slnx package --deprecated` reports no deprecated packages in `DevBrain.Functions`; the remaining deprecation is the test-only `xunit` 2.9.3 package, which requires a separate xUnit v3 migration. -- `dotnet test devbrain.slnx` passes with 148 tests. + +- `dotnet test --solution devbrain.slnx` passes 157 tests across `DevBrain.Core.Tests` and `DevBrain.Server.Tests`. +- `dotnet list devbrain.slnx package --outdated` reports every direct reference current. +- `dotnet list devbrain.slnx package --vulnerable --include-transitive` and `--deprecated --include-transitive` report no vulnerable or deprecated packages in any project. +- `az bicep build --file infra/main.bicep` succeeds, the post-provision hook parses cleanly, and the container image builds from `src/DevBrain.Server/Dockerfile`. +- The Container Apps host is deployed and serving the production environment. The unified ChatGPT/Codex Windows app completed OAuth with `DevBrain.User`, discovered all 12 tools, and read existing shared Cosmos documents. VS Code / GitHub Copilot completes OAuth and works end to end. ## [1.9.0] — 2026-04-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65876d9..530389f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,8 @@ Thanks for your interest in contributing to DevBrain! 1. **Prerequisites** - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) - - [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (logged in via `az login`) - - A Cosmos DB account (or the [Cosmos DB Emulator](https://learn.microsoft.com/azure/cosmos-db/local-emulator)) + - A dev DevBrain environment to point at (Cosmos DB, storage, and Key Vault). The server authenticates to them with `DefaultAzureCredential`, so the key-based Cosmos DB Emulator isn't supported. 2. **Clone and build** ```bash @@ -18,31 +17,30 @@ Thanks for your interest in contributing to DevBrain! ``` 3. **Configure local settings** - ```bash - cp src/DevBrain.Functions/local.settings.json.example src/DevBrain.Functions/local.settings.json - ``` - Edit `local.settings.json` with your Cosmos DB account endpoint. + + Configure the required settings with .NET user secrets or environment variables. The server fails fast when any required value is missing. [Local Development](README.md#local-development) in the README lists every setting and the roles your `az login` identity needs. 4. **Run locally** ```bash - cd src/DevBrain.Functions - func start + dotnet run --project src/DevBrain.Server ``` + The MCP endpoint is `/mcp` and the anonymous health endpoint is `/healthz`. + ## Pull Request Process 1. Fork the repository and create a feature branch from `main`. 2. Make your changes. Keep commits focused and atomic. -3. Ensure `dotnet build` completes with no warnings (warnings are treated as errors). +3. Ensure `dotnet build` completes with no warnings (warnings are treated as errors) and `dotnet test --solution devbrain.slnx` passes. Tests run on xUnit v3 through Microsoft.Testing.Platform, which `global.json` opts into. 4. For dependency changes, check the whole solution from the repository root before opening the PR: ```bash dotnet list devbrain.slnx package --vulnerable --include-transitive - dotnet list devbrain.slnx package --outdated --highest-patch - dotnet list devbrain.slnx package --outdated --include-transitive - dotnet list devbrain.slnx package --deprecated + dotnet list devbrain.slnx package --outdated + dotnet list devbrain.slnx package --deprecated --include-transitive ``` -5. Open a pull request against `main` with a clear description of the change. -6. A maintainer will review and merge once CI passes. +5. For a release, bump `` in `Directory.Build.props`. It's the single source for assembly versions and the MCP `serverInfo` version. Then add the matching `CHANGELOG.md` entry. +6. Open a pull request against `main` with a clear description of the change. +7. A maintainer will review and merge once CI passes. ## Code Style diff --git a/Directory.Build.props b/Directory.Build.props index 04538d7..47f4bd8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,7 @@ - 1.0.0 + + 2.0.0 Ignite Solutions Group Ignite Solutions Group DevBrain diff --git a/README.md b/README.md index e4622e5..4122163 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # DevBrain -An Azure-native remote MCP server — built on Azure Functions and Cosmos DB — that gives any AI tool persistent, shared access to developer knowledge. One brain, zero upload tax, for teams already living in the Microsoft Azure dev ecosystem. +An Azure-native remote MCP server built on ASP.NET Core, Azure Container Apps, and Cosmos DB. DevBrain gives AI tools persistent, shared access to developer knowledge across projects and clients. + +DevBrain 2.0 uses the official [Model Context Protocol (MCP) C# SDK](https://github.com/modelcontextprotocol/csharp-sdk) and implements the [2026-07-28 revision of the MCP specification](https://modelcontextprotocol.io/specification/2026-07-28). The protocol revision is intentionally pinned here: informal references to “MCP v2” can otherwise be confused with the C# SDK’s own 2.x package version. ## The Problem @@ -39,8 +41,8 @@ DevBrain is the only approach that gives every AI tool (Claude, Copilot, Codex, └──────────────────────┼──────────────────────┘ │ MCP (Streamable HTTP + OAuth 2.0) ┌────────▼─────────┐ - │ Azure Functions │ ← DCR OAuth facade - │ (DevBrain) │ (Entra-backed) + │ Container Apps │ ← ASP.NET Core + MCP SDK + │ (DevBrain 2.0) │ OAuth facade (Entra-backed) └────────┬─────────┘ │ Managed Identity ┌────────▼─────────┐ @@ -49,47 +51,170 @@ DevBrain is the only approach that gives every AI tool (Claude, Copilot, Codex, └──────────────────┘ ``` +### Hosting defaults + +The MCP transport is stateless, so requests do not require session affinity or a distributed protocol-state cache. The template therefore does not provision Redis. It exposes a separate anonymous `/healthz` process/readiness endpoint rather than treating MCP JSON-RPC traffic as a health probe. + +| Setting | Default | +|---------|---------| +| Container Apps replicas | Minimum `0`, maximum `3` | +| Container resources | `0.5` vCPU, `1 GiB` memory | +| Public endpoint rate limit | `120` requests per `60` seconds per replica and authenticated object ID (IP fallback) | +| Request body limit | `4 MiB` | +| CORS | Disabled; configure explicit origins only when a browser client requires them | +| Public edge | Native Container Apps HTTPS FQDN; no Front Door dependency | + +Minimum replicas are a latency/cost choice. This repository defaults to zero; latency-sensitive interactive deployments should consider one or more warm replicas, consistent with [Microsoft’s stateless MCP hosting guidance](https://techcommunity.microsoft.com/blog/appsonazureblog/mcp-just-went-stateless-%E2%80%94-what-the-2026-spec-changes-about-scaling-on-app-servic/4530222). + ## Prerequisites -- Azure subscription +- An Azure subscription where you can create resources and role assignments (Owner, or Contributor + User Access Administrator) +- Permission to create an app registration in the Entra tenant whose users will sign in (Application Developer or higher), and to assign users to its enterprise application - [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/install-azure-cli), used for the Entra and verification commands below +- [PowerShell 7 (`pwsh`)](https://learn.microsoft.com/powershell/scripting/install/installing-powershell), which runs the cross-platform post-provision hook - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +Docker isn't required locally, because `azd` builds the container image remotely in Azure Container Registry. + ## Deploy +DevBrain is single-tenant: one deployment serves one Entra tenant. These steps take a fresh tenant from nothing to a working MCP endpoint. + +### 1. Create the Entra app registration + +DevBrain signs users in through one pre-registered Entra app. It asks Entra only for `openid profile offline_access`, so the app doesn't need an exposed API scope or extra API permissions. + +**Portal:** Entra ID → App registrations → New registration + +| Setting | Value | +|---------|-------| +| Name | `DevBrain` (any name works) | +| Supported account types | **Accounts in this organizational directory only** (single tenant). Don't pick a multi-tenant option. | +| Redirect URI | Leave blank for now. You'll add it in step 4, after the Container App host name exists. | + +Then, on the new registration: + +1. Copy the **Application (client) ID** and **Directory (tenant) ID** from the Overview page. +2. **Certificates & secrets** → **New client secret**. Copy the secret **Value** now; Entra shows it only once. Note the expiry date (see [Operating notes](#operating-notes)). +3. **App roles** → **Create app role**: display name `DevBrain User`, allowed member types **Users/Groups**, value **`DevBrain.User`**, enabled. `/mcp` rejects any caller without this role. +4. If your tenant blocks user consent, open **API permissions** and select **Grant admin consent**. Otherwise, users consent to the basic sign-in scopes on first login. + +**Azure CLI equivalent:** + +```powershell +az login --tenant +$app = az ad app create --display-name DevBrain --sign-in-audience AzureADMyOrg | ConvertFrom-Json +az ad sp create --id $app.appId | Out-Null # creates the enterprise application used for role assignment +$clientSecret = az ad app credential reset --id $app.appId --display-name devbrain --years 1 --query password -o tsv + +$roles = ConvertTo-Json -AsArray @(@{ + allowedMemberTypes = @('User'); isEnabled = $true; value = 'DevBrain.User' + displayName = 'DevBrain User'; description = 'Can connect to DevBrain'; id = [guid]::NewGuid().ToString() +}) +Set-Content -Path approles.json -Value $roles +az ad app update --id $app.appId --app-roles approles.json +Remove-Item approles.json + +$app.appId # this is ENTRA_CLIENT_ID +``` + +### 2. Choose who can connect + +Entra ID → **Enterprise applications** → `DevBrain`: + +1. **Users and groups** → **Add user/group** → select the people or security groups (group assignment requires Entra ID P1 or higher) → role **DevBrain User**. +2. Recommended: **Properties** → set **Assignment required?** to **Yes**. Entra then stops unassigned users at sign-in, instead of letting them sign in and get rejected by DevBrain. + +Role changes take effect the next time a client's short-lived access token refreshes, not immediately. + +### 3. Provision the Azure resources + ```powershell azd init -t Ignite-Solutions-Group/devbrain -azd env set ENTRA_TENANT_ID -azd env set ENTRA_CLIENT_ID -azd up +azd auth login --tenant-id +azd env new # e.g. devbrain-prod; drives resource naming +azd env set ENTRA_TENANT_ID +azd env set ENTRA_CLIENT_ID +azd env set ENTRA_CLIENT_SECRET +azd env set JWT_SIGNING_SECRET ([Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))) +azd provision ``` -**Before `azd up`**, create a single Entra app registration in your tenant (see CHANGELOG v1.6.0 for the full prerequisite checklist). After deployment, populate the two Key Vault secrets: +`azd provision` creates the Container App and its environment, Azure Container Registry, Cosmos DB, Storage, Key Vault, and Log Analytics with Application Insights. It also creates the managed-identity role assignments. The two secret values seed Key Vault through secure deployment parameters, so they never appear in plain deployment history. + +The post-provision hook finalizes the Container App's Cosmos DB data-role assignment. It retries while a newly created managed identity propagates through Entra, so the first deployment doesn't need a manual second provision. It's safe to rerun. -`ENTRA_TENANT_ID` and `ENTRA_CLIENT_ID` are Bicep-owned Function App settings. Keep them in the azd environment before running `azd provision` or `azd up`; `azd deploy` alone does not modify them. +`ENTRA_TENANT_ID` and `ENTRA_CLIENT_ID` stay in the azd environment, and every later `azd provision` or `azd up` re-applies them. After the first successful provision, you can clear the two bootstrap secrets from the local environment. Later provisions leave the existing Key Vault secrets unchanged: ```powershell -az keyvault secret set --vault-name --name jwt-signing-secret --value $(openssl rand -base64 32) -az keyvault secret set --vault-name --name entra-client-secret --value +azd env set ENTRA_CLIENT_SECRET "" +azd env set JWT_SIGNING_SECRET "" ``` -Restart the Function App to pick up the Key Vault references, then connect any MCP client. +### 4. Add the redirect URI -## First Run +```powershell +azd env get-value OAUTH_REDIRECT_URI # https://ca-devbrain-..azurecontainerapps.io/callback +``` -After a fresh deployment, seed the default reference documents so any AI tool connecting to your new instance immediately has usage guidance available. Connect any authenticated MCP client and call: +In the portal, open the app registration → **Authentication** → **Add a platform** → **Web**, and paste that value exactly. Or use the CLI: +```powershell +az ad app update --id --web-redirect-uris (azd env get-value OAUTH_REDIRECT_URI) ``` -UpsertDocument(key="ref:devbrain-usage", project="default", content=) + +### 5. Deploy the server + +```powershell +azd deploy ``` -Or use the seed script (requires a valid MCP connection): +After the first deployment, `azd up` (provision + deploy) is fine for later updates. + +### 6. Verify ```powershell -./scripts/seed-devbrain.ps1 +$url = azd env get-value AZURE_CONTAINER_APP_URL +Invoke-RestMethod "$url/healthz" # status: healthy +Invoke-RestMethod "$url/.well-known/oauth-authorization-server" # DevBrain's own issuer metadata ``` -Re-running is safe — every upsert is a full overwrite. Source content for the seed lives under [`docs/seed/`](docs/seed/). +Then connect a client (see [Configure Your MCP Client](#configure-your-mcp-client)) at `$url/mcp`. The first tool call opens an Entra sign-in, and every write records the signed-in user's UPN in `updatedBy`. + +The Container App uses the platform-provided HTTPS host name. Front Door and a custom domain are optional additions, not requirements. If you add a custom domain, update `OAuth__BaseUrl` and `AllowedHosts` in `infra/main.bicep`, and the Entra redirect URI, to match. + +## First Run + +After a fresh deployment, seed the shared usage guide so any AI tool connecting to the new instance knows how to use DevBrain. + +1. Edit the **Known Projects** list in [`docs/seed/ref-devbrain-usage.md`](docs/seed/ref-devbrain-usage.md) to name your organization's projects. +2. From any connected MCP client (for example, Claude Code running in a clone of this repo), ask: + + > Read `docs/seed/ref-devbrain-usage.md` and store it in DevBrain with `UpsertDocument`, key `ref:devbrain-usage`, project `default`, tags `meta`, `instructions`, `usage`. + +Re-running is safe because every upsert is a full overwrite. Seeding goes through a signed-in client because every DevBrain tool call requires a per-user OAuth token with the `DevBrain.User` role. + +## Upgrading from 1.x (Azure Functions) + +DevBrain 2.0 removes the Azure Functions host. On an existing 1.x environment: + +1. On the existing Entra app, add the `DevBrain.User` app role and assign it (step 1, item 3, and step 2). Then, in the existing azd environment, run `azd provision`, add the new redirect URI (step 4), and run `azd deploy`. The Container App reads and writes the same Cosmos `documents` container, so no data migration is needed. +2. Point every MCP client at `https:///mcp` in place of `https:///runtime/webhooks/mcp`. Each client signs in once against the new endpoint. +3. Once no client uses the old endpoint, delete the resources that `azd provision` no longer manages. Provisioning is incremental, so it doesn't remove them for you: + - the `func-devbrain-` Function App and its `plan-devbrain-` Flex Consumption plan. Deleting the Function App also removes its system-assigned identity; Azure then shows that identity's old role assignments as "Identity not found", and you can remove them. + - the `deploymentpackage` and `dataprotection-keys` blob containers in the storage account +4. Remove the old Function App URL from the Entra app's redirect URIs. + +1.x OAuth records left in the `oauth_state` container expire on their own through Cosmos TTL. + +## Operating notes + +- **Client secret expiry.** When the Entra client secret expires, sign-ins and token refreshes fail. Before it expires, create a new secret and write it to the `entra-client-secret` Key Vault secret, either with `az keyvault secret set` or by setting `ENTRA_CLIENT_SECRET` and re-running `azd provision`. Container Apps picks up the new version within 30 minutes. To apply it immediately, restart the active revision with `az containerapp revision restart`. +- **JWT signing secret rotation.** Replacing `jwt-signing-secret` invalidates every DevBrain access token already issued, so clients re-authenticate on their next call. It's the "sign everyone out" lever. +- **Cold starts.** The template defaults to zero minimum replicas, so the first request after an idle period waits for a container to start. Set `containerAppMinReplicas` to `1` in `infra/main.bicep` if interactive latency matters more than idle cost. +- **Cost profile.** The main fixed costs are Cosmos DB, which uses standard provisioned throughput rather than serverless, and the Basic Azure Container Registry. Container Apps compute scales to zero by default. Check the Cosmos throughput settings against your budget before a long-running trial. +- **Tearing down a trial.** The Key Vault has purge protection on, so `azd down` leaves it soft-deleted for 90 days and its name can't be reused in that time. For another trial, create a new azd environment name (`azd env new`) rather than re-provisioning the old one. ## Configure Your MCP Client @@ -98,7 +223,7 @@ DevBrain uses OAuth 2.0 with Dynamic Client Registration (DCR). Clients that sup ### Claude Code CLI ```bash -claude mcp add devbrain --transport http https:///runtime/webhooks/mcp +claude mcp add devbrain --transport http https:///mcp ``` On first use, Claude Code opens a browser for Entra login. Subsequent sessions re-use the stored token. @@ -108,7 +233,7 @@ On first use, Claude Code opens a browser for Entra login. Subsequent sessions r Add as a custom MCP connector pointing at: ``` -https:///runtime/webhooks/mcp +https:///mcp ``` OAuth completes automatically — no proxy, no function key, no manual headers. @@ -118,7 +243,7 @@ OAuth completes automatically — no proxy, no function key, no manual headers. The modern unified ChatGPT/Codex app for Windows is currently working well with DevBrain OAuth. This is treated as operationally healthy but still under monitoring, rather than a permanent compatibility guarantee. ```bash -codex mcp add devbrain --transport http https:///runtime/webhooks/mcp +codex mcp add devbrain --transport http https:///mcp ``` ### OAuth token windows @@ -131,29 +256,34 @@ Deployments can tune the access-token lifetime and refresh replay window when th azd env set OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES 45 azd env set OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES 5 azd provision -azd deploy ``` -`azd provision` applies the Bicep app settings. `azd deploy` only deploys application code, so existing Function App settings persist across code-only updates. If the `OAUTH_*` values are not set in the azd environment, Bicep creates blank settings and DevBrain uses its built-in defaults. +`azd provision` applies the settings to the Container App and rolls a new revision. `azd deploy` only deploys a new application image, so existing settings persist across code-only updates. If the `OAUTH_*` values are not set, DevBrain uses its built-in defaults. -These `azd` values provision the Function App settings: +These `azd` values provision the equivalent application settings: ```text OAuth__AccessTokenLifetimeMinutes=45 OAuth__RefreshReplayLifetimeMinutes=5 ``` -For a one-off test on an already-provisioned Function App, set the same `OAuth__*` app settings directly with Azure CLI or the portal, then restart the app. Both values must be whole minutes from 1 through 1,440. Defaults are 10 minutes for access tokens and 5 minutes for refresh replay markers. +For a one-off test on an already-provisioned app, set the same `OAuth__*` environment variables on the Container App directly, which creates a new revision. Both values must be whole minutes from 1 through 1,440. Defaults are 10 minutes for access tokens and 5 minutes for refresh replay markers. Keep both windows as short as the client population allows. A longer access-token lifetime reduces refresh frequency but extends the useful lifetime of a stolen bearer token. A longer replay window makes an old refresh token reusable for longer and should only be used to accommodate a measured client retry interval. ### VS Code / GitHub Copilot -⚠️ **Known issue:** The VS Code MCP extension connects successfully and discovers all tools, but does not trigger the OAuth flow. See [Known Limitations](#vs-code--github-copilot-mcp-extension--oauth-not-triggered) below for the full explanation and fix paths. +DevBrain 2.0 owns the `/mcp` protocol surface directly and returns the specification-required `401` plus `WWW-Authenticate: Bearer resource_metadata="..."` challenge. That removes the Azure Functions host-layer limitation that stopped VS Code and GitHub Copilot from starting OAuth in 1.x. VS Code and GitHub Copilot now complete OAuth and use DevBrain's tools end to end. + +``` +https:///mcp +``` + +Add it as an HTTP MCP server. VS Code starts the Entra sign-in on first use. ### Cursor -Not yet tested with v1.6 OAuth. Expected to work if the client supports MCP OAuth with DCR. +Expected to work, because Cursor supports current MCP OAuth with DCR, but not yet validated. Connect it to `https:///mcp`. ## Session Startup / AGENTS.md @@ -281,37 +411,52 @@ Keys use colon as the separator (e.g. `sprint:license-sync`). **Writes** (`Upser ## Local Development -1. Install prerequisites: .NET 10 SDK, [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local), Azure CLI. +1. Install the .NET 10 SDK and Azure CLI. -2. Log in to Azure (for Cosmos access via `DefaultAzureCredential`): +2. Sign in to Azure so `DefaultAzureCredential` can reach the data services: ```powershell az login ``` + Point local runs at a **dev** DevBrain environment, not production. Your identity needs **Cosmos DB Built-in Data Contributor** (a Cosmos SQL role assignment), **Storage Blob Data Contributor** on the storage account, and **Key Vault Crypto User** on the vault. -3. Copy and configure local settings: +3. Configure the required settings with .NET user secrets. The server fails fast at startup if any of these is missing: ```powershell - Copy-Item src/DevBrain.Functions/local.settings.json.example src/DevBrain.Functions/local.settings.json - # Edit with your Cosmos DB account endpoint + $p = 'src/DevBrain.Server' + dotnet user-secrets --project $p set CosmosDb:AccountEndpoint 'https://.documents.azure.com:443/' + dotnet user-secrets --project $p set OAuth:BaseUrl 'http://localhost:5000' + dotnet user-secrets --project $p set OAuth:EntraTenantId '' + dotnet user-secrets --project $p set OAuth:EntraClientId '' + dotnet user-secrets --project $p set OAuth:EntraClientSecret '' + dotnet user-secrets --project $p set OAuth:JwtSigningSecret ([Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))) + dotnet user-secrets --project $p set DataProtection:BlobUri 'https://.blob.core.windows.net/dataprotection-keys-v2/keys.xml' + dotnet user-secrets --project $p set DataProtection:KeyVaultKeyUri 'https://.vault.azure.net/keys/data-protection-key' ``` + Add `http://localhost:5000/callback` as a second **Web** redirect URI on the Entra app to sign in against the local server. -4. Run: +4. Run the server: ```powershell - cd src/DevBrain.Functions - func start + dotnet run --project src/DevBrain.Server ``` + The local MCP endpoint is `http://localhost:5000/mcp`, and `/healthz` is anonymous. -5. Optional dependency health checks from the repository root: +5. Run the tests. They use xUnit v3 on Microsoft.Testing.Platform, which `global.json` opts into: + ```powershell + dotnet test --solution devbrain.slnx + ``` + +6. Optional dependency health checks from the repository root: ```powershell dotnet list devbrain.slnx package --vulnerable --include-transitive - dotnet list devbrain.slnx package --outdated --highest-patch - dotnet list devbrain.slnx package --outdated --include-transitive - dotnet list devbrain.slnx package --deprecated + dotnet list devbrain.slnx package --outdated + dotnet list devbrain.slnx package --deprecated --include-transitive ``` ## Authentication DevBrain implements RFC 7591 Dynamic Client Registration (DCR) with an in-process OAuth proxy that brokers a single pre-registered Entra app. From the client's perspective, DevBrain *is* the authorization server. Internally it delegates to your tenant's Entra ID for user authentication. +The 2026-07-28 specification deprecates DCR in favor of Client ID Metadata Documents but retains it for backward compatibility. DevBrain keeps DCR for the clients in its compatibility matrix while honoring the revision's authorization hardening: `application_type` metadata, RFC 8707 resource binding, and RFC 9207 issuer identification on authorization responses. + This solves two problems that previously blocked MCP OAuth: 1. **Entra doesn't support DCR** — DevBrain's facade implements it, issuing opaque `client_id` handles that all map to the same upstream Entra app. @@ -319,28 +464,17 @@ This solves two problems that previously blocked MCP OAuth: Every write operation records the authenticated user's Entra UPN in the `updatedBy` field. +The deployment is intentionally single-tenant. Validated Entra `roles` claims are carried into the local DevBrain session, and `/mcp` requires the `DevBrain.User` app role. There is no application-level administrator role or maintenance endpoint; administration is performed through Azure and Entra control planes. + ### Refresh Token Rotation Access tokens are short-lived and DevBrain refresh tokens rotate on every refresh. By default, the old refresh token becomes a five-minute replay marker that points at the replacement token, which makes immediate MCP client retries idempotent without reopening the OAuth flow. Replays outside the configured window still fail with `invalid_grant`, and every successful refresh or replay extends the upstream token vault record for the same local refresh window. See [OAuth token windows](#oauth-token-windows) for configuration and security tradeoffs. -## Known Limitations - -### VS Code / GitHub Copilot MCP extension — OAuth not triggered - -VS Code connects to the MCP endpoint, gets a 200 OK on `tools/list`, discovers all 7 tools, and proceeds as if no auth is required. Tool calls then fail with a missing Bearer token. **VS Code's behavior is correct per the MCP authorization spec** — the spec requires the server to challenge unauthenticated requests with `401 + WWW-Authenticate: Bearer resource_metadata="..."`, at which point the client reads PRM and starts OAuth. - -**Why DevBrain returns 200 here:** `initialize` and `tools/list` are handled by the Azure Functions MCP extension at the host process layer and never dispatch a function, so DevBrain's JWT middleware (which runs in the isolated worker) never sees them. The extension assumes Microsoft's documented deployment pattern — App Service Auth in front of the extension, owning the 401 challenge. DevBrain can't use that pattern because enabling App Service Auth with Entra would make the PRM advertise `login.microsoftonline.com` as the authorization server, which Claude.ai web ignores ([anthropics/claude-ai-mcp#82](https://github.com/anthropics/claude-ai-mcp/issues/82)), breaking a client that currently works. - -Other clients work because they probe PRM proactively rather than waiting to be challenged. VS Code follows the spec strictly. - -**Workaround:** None currently. - -**Fix paths (future DevBrain versions):** +The first use of each rotated refresh token also refreshes the upstream Entra session and revalidates tenant, user identity, and app-role claims. Assignment changes therefore take effect when the current short-lived access token expires rather than remaining cached for the full local refresh-token lifetime. -1. File a feature request against [`Azure/azure-functions-mcp-extension`](https://github.com/Azure/azure-functions-mcp-extension) for a pluggable auth hook at the host layer so custom OAuth servers can gate the MCP protocol surface. -2. Replace the extension's webhook handler with a custom anonymous HTTP trigger implementing `initialize`, `tools/list`, and `tools/call` directly, under DevBrain's JWT middleware. +## Client compatibility -### Client compatibility (v1.6.0) +The 2.0 host keeps the same OAuth DCR flow as 1.x and fixes the VS Code/Copilot challenge blocker. "Working" means the client works with DevBrain's OAuth flow in production use. | Client | Platform | Auth | Status | |--------|----------|------|--------| @@ -349,11 +483,11 @@ Other clients work because they probe PRM proactively rather than waiting to be | Claude Code | claude.ai web | OAuth (DCR) | ✅ Working | | Claude Desktop | Windows | OAuth (DCR) | ✅ Working | | Claude Mobile | Android | OAuth (DCR) | ✅ Working | -| ChatGPT / Codex unified app | Windows | OAuth (DCR) | ✅ Working; monitoring | +| ChatGPT / Codex unified app | Windows | OAuth (DCR) | ✅ Working; monitoring continues | | Codex CLI | Windows Terminal | OAuth (DCR) | ✅ Working | | Codex CLI | WSL | OAuth (DCR) | ✅ Working | -| VS Code / GitHub Copilot | Windows | OAuth (DCR) | ⚠️ [See above](#vs-code--github-copilot-mcp-extension--oauth-not-triggered) | -| Cursor | — | OAuth (DCR) | Not tested | +| VS Code / GitHub Copilot | Windows | OAuth (DCR) | ✅ Working (new in 2.0) | +| Cursor | — | OAuth (DCR) | Expected to work; not yet validated | ## Contributing diff --git a/azure.yaml b/azure.yaml index 42411ed..727641f 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,12 +2,16 @@ name: devbrain hooks: postprovision: shell: pwsh - run: | - Write-Host "Waiting 30 seconds for Function App to warm up..." - Start-Sleep -Seconds 30 - Write-Host "Done. Starting deployment." + run: ./scripts/complete-container-app-provision.ps1 + interactive: false + continueOnError: false services: - api: - project: src/DevBrain.Functions - language: dotnet - host: function \ No newline at end of file + server: + project: . + language: docker + host: containerapp + apiVersion: 2024-03-01 + docker: + path: src/DevBrain.Server/Dockerfile + context: . + remoteBuild: true diff --git a/devbrain.slnx b/devbrain.slnx index f1bcfc3..c8b8282 100644 --- a/devbrain.slnx +++ b/devbrain.slnx @@ -1,8 +1,10 @@ - + + - + + diff --git a/docs/devbrain-spec-v1.md b/docs/devbrain-spec-v1.md index e1bc01e..d1d374b 100644 --- a/docs/devbrain-spec-v1.md +++ b/docs/devbrain-spec-v1.md @@ -1,5 +1,7 @@ # DevBrain v1 — Sprint Spec +> **Historical document.** This is the original v1 sprint spec, kept for context. It describes the retired Azure Functions host, a four-tool surface, and a pre-OAuth auth model. For current setup and usage, see the [README](../README.md) and [CHANGELOG](../CHANGELOG.md). + **Repository:** `github.com/Ignite-Solutions-Group/devbrain` **Purpose:** A remote MCP server that gives any AI tool (Claude web, Claude Code CLI, GitHub Copilot, Cursor) persistent, shared access to developer knowledge — sprint docs, architecture decisions, project state. One brain. Zero upload tax. **Stack:** .NET 10 · C# 14 · Azure Functions (isolated worker) · Cosmos DB NoSQL · Entra ID · Bicep · `azd` diff --git a/global.json b/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/infra/main.bicep b/infra/main.bicep index ab7f844..b85c567 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -4,11 +4,10 @@ param location string = 'eastus' @description('Environment name used for resource naming (set by azd).') param environmentName string -// ─── v1.6 OAuth DCR facade parameters ─────────────────────────────────────── +// ─── OAuth DCR facade parameters ──────────────────────────────────────────── // -// These must be set by the deployer (via azd env set) BEFORE first deploy. See CHANGELOG v1.6.0 -// and the sprint doc "Deploy prerequisite" callout. Neither value is sensitive — the client secret -// lives in Key Vault, not here. +// Tenant and client IDs must be set by the deployer before first provision. Secret bootstrap values +// use separate secure parameters below and are stored in Key Vault. @description('Entra tenant GUID (single-tenant only — not "common" or "organizations").') @minLength(1) @@ -18,15 +17,34 @@ param entraTenantId string @minLength(1) param entraClientId string +@secure() +@description('Optional Entra client secret value to seed into Key Vault on first provision. Leave empty when the secret already exists.') +param entraClientSecretValue string = '' + +@secure() +@description('Optional base64-encoded 32-byte JWT signing secret to seed into Key Vault on first provision. Leave empty when the secret already exists.') +param jwtSigningSecretValue string = '' + @description('Optional local DevBrain access-token lifetime in whole minutes. Leave empty for the application default.') param oauthAccessTokenLifetimeMinutes string = '' @description('Optional rotated-refresh-token replay marker lifetime in whole minutes. Leave empty for the application default.') param oauthRefreshReplayLifetimeMinutes string = '' +@description('Container image used when provisioning the Container App. azd replaces it with the built DevBrain image during deployment.') +param containerAppImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + +@description('Minimum Container App replica count. Set to 1 or higher when interactive cold-start latency is important.') +@minValue(0) +param containerAppMinReplicas int = 0 + +@description('Maximum Container App replica count.') +@minValue(1) +param containerAppMaxReplicas int = 3 + var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) -// ─── Storage Account (required by Azure Functions) ─────────────────────────── +// ─── Storage Account (Data Protection key ring) ───────────────────────────── resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { name: 'sadevbrain${substring(resourceToken, 0, 6)}' @@ -46,23 +64,15 @@ resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' name: 'default' } -resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { - parent: blobService - name: 'deploymentpackage' - properties: { - publicAccess: 'None' - } -} - -// ─── Data Protection key ring blob container (v1.6 upstream token encryption) ── +// ─── Data Protection key ring blob container ──────────────────────────────── // -// Holds the ASP.NET Core Data Protection key ring (single keys.xml blob). The Function MI -// already has Storage Blob Data Owner at the storage account scope, which covers this container, -// so no new role assignment is required. +// Holds the ASP.NET Core Data Protection key ring (single keys.xml blob) that encrypts upstream +// Entra tokens at rest. The name keeps its original `-v2` suffix so existing deployments keep +// their key ring (and live OAuth sessions) across the Functions host retirement. -resource dataProtectionKeysContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { +resource dataProtectionV2KeysContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { parent: blobService - name: 'dataprotection-keys' + name: 'dataprotection-keys-v2' properties: { publicAccess: 'None' } @@ -77,6 +87,12 @@ resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { properties: { databaseAccountOfferType: 'Standard' enableFreeTier: false + enableAutomaticFailover: true + minimalTlsVersion: 'Tls12' + defaultIdentity: 'FirstPartyIdentity' + analyticalStorageConfiguration: { + schemaType: 'WellDefined' + } consistencyPolicy: { defaultConsistencyLevel: 'Session' } @@ -119,7 +135,7 @@ resource cosmosContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/con } } -// ─── OAuth state container (v1.6 DCR facade) ──────────────────────────────── +// ─── OAuth state container (DCR facade) ───────────────────────────────────── // // Holds the five DCR-facade record kinds: client:{id}, txn:{state}, code:{code}, // upstream:{jti}, refresh:{token}. Every record sets `ttl` explicitly via the @@ -164,16 +180,61 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { } } -// ─── Key Vault (v1.6 DCR facade) ──────────────────────────────────────────── +// ─── Container Apps hosting ───────────────────────────────────────────────── + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: 'acrdevbrain${substring(resourceToken, 0, 6)}' + location: location + sku: { + name: 'Basic' + } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + policies: { + quarantinePolicy: { + status: 'disabled' + } + retentionPolicy: { + days: 7 + status: 'disabled' + } + trustPolicy: { + type: 'Notary' + status: 'disabled' + } + } + } +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2024-03-01' = { + name: 'cae-devbrain-${substring(resourceToken, 0, 6)}' + location: location + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + } +} + +resource containerAppIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'id-devbrain-${substring(resourceToken, 0, 6)}' + location: location +} + +// ─── Key Vault (DCR facade) ───────────────────────────────────────────────── // -// Holds two secrets set manually post-deploy (see CHANGELOG v1.6.0 and the sprint doc's -// deploy prerequisite callout): +// Holds two secrets managed outside the application runtime: // - jwt-signing-secret: base64-encoded 32-byte HMAC key for DevBrain JWT signing. // Generate with: `openssl rand -base64 32` // - entra-client-secret: the client secret from the tenant-admin-created Entra app registration. // -// Bicep does NOT create the secret resources themselves — their values come from outside and -// populating them via Bicep parameters would put sensitive material in deployment state. +// Existing deployments leave the secure seed parameters empty so Bicep does not replace either +// secret. A new deployment may supply them once through secure azd environment values. resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { // Compressed form of the {type}devbrain{resourceToken} naming convention. The hyphenated form @@ -195,10 +256,10 @@ resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { } } -// ─── Data Protection master key (v1.6 upstream token encryption) ──────────── +// ─── Data Protection master key (upstream token encryption) ───────────────── // // ASP.NET Core Data Protection protects its key ring with this key (via wrapKey/unwrapKey). -// The Function MI's Key Vault Crypto User role (granted below) covers the required operations. +// The Container App identity's Key Vault Crypto User role (granted below) covers the required operations. // Key rotation here rotates the KEK, not the data keys; DP handles data-key rotation internally. resource dataProtectionKey 'Microsoft.KeyVault/vaults/keys@2023-07-01' = { @@ -214,226 +275,232 @@ resource dataProtectionKey 'Microsoft.KeyVault/vaults/keys@2023-07-01' = { } } -// ─── Flex Consumption App Service Plan ─────────────────────────────────────── - -resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { - name: 'plan-devbrain-${resourceToken}' - location: location - kind: 'functionapp' - sku: { - name: 'FC1' - tier: 'FlexConsumption' +resource entraClientSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(entraClientSecretValue)) { + parent: keyVault + name: 'entra-client-secret' + properties: { + value: entraClientSecretValue } +} + +resource jwtSigningSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(jwtSigningSecretValue)) { + parent: keyVault + name: 'jwt-signing-secret' properties: { - reserved: true + value: jwtSigningSecretValue } } -// ─── Function App (Flex Consumption) ───────────────────────────────────────── +var containerAppName = 'ca-devbrain-${substring(resourceToken, 0, 6)}' +var containerAppHostName = '${containerAppName}.${containerAppsEnvironment.properties.defaultDomain}' +var containerAppBaseUrl = 'https://${containerAppHostName}' -resource functionApp 'Microsoft.Web/sites@2024-04-01' = { - name: 'func-devbrain-${resourceToken}' +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName location: location - kind: 'functionapp,linux' tags: { - 'azd-service-name': 'api' + 'azd-service-name': 'server' } identity: { - type: 'SystemAssigned' + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${containerAppIdentity.id}': {} + } } properties: { - serverFarmId: appServicePlan.id - reserved: true - functionAppConfig: { - deployment: { - storage: { - type: 'blobContainer' - value: '${storageAccount.properties.primaryEndpoints.blob}deploymentpackage' - authentication: { - type: 'SystemAssignedIdentity' - } + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: containerRegistry.properties.loginServer + identity: 'system' } + ] + ingress: { + external: true + allowInsecure: false + targetPort: 8080 + transport: 'auto' } - runtime: { - name: 'dotnet-isolated' - version: '10.0' - } - scaleAndConcurrency: { - maximumInstanceCount: 10 - instanceMemoryMB: 2048 - } + // The public placeholder image keeps initial provisioning independent of ACR. The + // explicit registry identity lets azd switch to the private built image during deploy. + secrets: [ + { + name: 'entra-client-secret' + keyVaultUrl: '${keyVault.properties.vaultUri}secrets/entra-client-secret' + identity: containerAppIdentity.id + } + { + name: 'jwt-signing-secret' + keyVaultUrl: '${keyVault.properties.vaultUri}secrets/jwt-signing-secret' + identity: containerAppIdentity.id + } + ] } - siteConfig: { - appSettings: [ - { name: 'AzureWebJobsStorage__accountName', value: storageAccount.name } - { name: 'AzureWebJobsStorage__credential', value: 'managedidentity' } - { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } - { name: 'CosmosDb__AccountEndpoint', value: cosmosAccount.properties.documentEndpoint } - { name: 'CosmosDb__DatabaseName', value: 'devbrain' } - { name: 'CosmosDb__ContainerName', value: 'documents' } - { name: 'CosmosDb__OAuthContainerName', value: 'oauth_state' } - { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: applicationInsights.properties.ConnectionString } - // Worker-side log level filters. host.json sets host-process levels; these control the - // isolated worker's ILogger pipeline (which is what DevBrain code actually writes to). - // Keep DevBrain at Debug while we're diagnosing the v1.6 post-deploy issues; dial back - // once the shape of OAuth traffic stabilizes. - { name: 'Logging__LogLevel__Default', value: 'Information' } - { name: 'Logging__LogLevel__DevBrain', value: 'Debug' } - { name: 'Logging__LogLevel__Microsoft', value: 'Warning' } - { name: 'Logging__LogLevel__Microsoft.Hosting.Lifetime', value: 'Information' } - // Disable the Application Insights SDK's default adaptive sampling while investigating — - // sampling can drop the exact invocations we're trying to catch. Re-enable post-stabilization. - { name: 'APPLICATIONINSIGHTS_SAMPLING_PERCENTAGE', value: '100' } - // v1.6 OAuth DCR facade. Secrets are Key Vault references — the secret values are set - // manually post-deploy (see CHANGELOG v1.6.0 and the sprint doc deploy prereqs). - { name: 'OAuth__BaseUrl', value: 'https://func-devbrain-${resourceToken}.azurewebsites.net' } - { name: 'OAuth__EntraTenantId', value: entraTenantId } - { name: 'OAuth__EntraClientId', value: entraClientId } - { name: 'OAuth__EntraClientSecret', value: '@Microsoft.KeyVault(SecretUri=https://${keyVault.name}${environment().suffixes.keyvaultDns}/secrets/entra-client-secret/)' } - { name: 'OAuth__JwtSigningSecret', value: '@Microsoft.KeyVault(SecretUri=https://${keyVault.name}${environment().suffixes.keyvaultDns}/secrets/jwt-signing-secret/)' } - { name: 'OAuth__AccessTokenLifetimeMinutes', value: oauthAccessTokenLifetimeMinutes } - { name: 'OAuth__RefreshReplayLifetimeMinutes', value: oauthRefreshReplayLifetimeMinutes } - { name: 'KeyVault__Name', value: keyVault.name } - // v1.6 Data Protection: key ring persisted in blob storage, protected by the KV key above. - // Used exclusively by IUpstreamTokenProtector (purpose: DevBrain.OAuth.UpstreamToken). - { name: 'DataProtection__BlobUri', value: '${storageAccount.properties.primaryEndpoints.blob}dataprotection-keys/keys.xml' } - { name: 'DataProtection__KeyVaultKeyUri', value: '${keyVault.properties.vaultUri}keys/data-protection-key' } + template: { + containers: [ + { + name: 'server' + image: containerAppImage + resources: { + cpu: json('0.5') + memory: '1Gi' + } + env: [ + { name: 'ASPNETCORE_HTTP_PORTS', value: '8080' } + { name: 'AZURE_CLIENT_ID', value: containerAppIdentity.properties.clientId } + { name: 'AllowedHosts', value: containerAppHostName } + { name: 'CosmosDb__AccountEndpoint', value: cosmosAccount.properties.documentEndpoint } + { name: 'CosmosDb__DatabaseName', value: 'devbrain' } + { name: 'CosmosDb__ContainerName', value: 'documents' } + { name: 'CosmosDb__OAuthContainerName', value: 'oauth_state' } + // Namespaces OAuth records in the shared oauth_state container. Kept as `v2:` so existing + // deployments keep their live sessions; changing it signs every client out. + { name: 'CosmosDb__OAuthKeyPrefix', value: 'v2:' } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: applicationInsights.properties.ConnectionString } + { name: 'OAuth__BaseUrl', value: containerAppBaseUrl } + { name: 'OAuth__EntraTenantId', value: entraTenantId } + { name: 'OAuth__EntraClientId', value: entraClientId } + { name: 'OAuth__EntraClientSecret', secretRef: 'entra-client-secret' } + { name: 'OAuth__JwtSigningSecret', secretRef: 'jwt-signing-secret' } + { name: 'OAuth__AccessTokenLifetimeMinutes', value: oauthAccessTokenLifetimeMinutes } + { name: 'OAuth__RefreshReplayLifetimeMinutes', value: oauthRefreshReplayLifetimeMinutes } + { name: 'DataProtection__BlobUri', value: '${storageAccount.properties.primaryEndpoints.blob}dataprotection-keys-v2/keys.xml' } + { name: 'DataProtection__KeyVaultKeyUri', value: '${keyVault.properties.vaultUri}keys/data-protection-key' } + { name: 'RateLimit__PermitLimit', value: '120' } + { name: 'RateLimit__WindowSeconds', value: '60' } + { name: 'Server__MaxRequestBodySizeBytes', value: '4194304' } + ] + probes: [ + { + type: 'Startup' + httpGet: { + path: '/healthz' + port: 8080 + scheme: 'HTTP' + httpHeaders: [ + { name: 'Host', value: containerAppHostName } + ] + } + initialDelaySeconds: 1 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 30 + } + { + type: 'Readiness' + httpGet: { + path: '/healthz' + port: 8080 + scheme: 'HTTP' + httpHeaders: [ + { name: 'Host', value: containerAppHostName } + ] + } + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + successThreshold: 1 + } + { + type: 'Liveness' + httpGet: { + path: '/healthz' + port: 8080 + scheme: 'HTTP' + httpHeaders: [ + { name: 'Host', value: containerAppHostName } + ] + } + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + } + ] + } ] + scale: { + minReplicas: containerAppMinReplicas + maxReplicas: containerAppMaxReplicas + } } } + dependsOn: [ + containerAppStorageBlobDataOwnerRole + containerAppKeyVaultCryptoUserRole + containerAppKeyVaultSecretsUserRole + entraClientSecret + jwtSigningSecret + ] } -// ─── App Service Authentication: EXPLICITLY DISABLED (v1.6 DCR facade) ────── -// -// DevBrain's DCR facade includes its own JWT validation middleware -// (McpJwtValidationMiddleware) that is the sole gate on MCP tool invocations. -// App Service Authentication (Easy Auth) must be OFF — if it's enabled, it -// intercepts Bearer tokens at the platform layer BEFORE the worker sees them -// and validates against the Entra identity provider's audience/issuer/JWKS, -// which doesn't match DevBrain's own HS256 JWTs. Result: 401 with -// "The audience 'X' is invalid" from Easy Auth, zero logs in App Insights -// (our code never ran), and a hard-to-diagnose silent rejection. -// -// This resource pins Easy Auth OFF in Bicep so it survives `azd up` and -// portal-click accidents. If you need App Service Auth for a different -// purpose in the future, coordinate with the DCR middleware first. - -// resource functionAppAuth 'Microsoft.Web/sites/config@2024-04-01' = { -// parent: functionApp -// name: 'authsettingsV2' -// properties: { -// platform: { -// enabled: false -// } -// globalValidation: { -// requireAuthentication: false -// unauthenticatedClientAction: 'AllowAnonymous' -// } -// // Empty identityProviders block CLEARS all configured providers. This is load-bearing — -// // even with platform.enabled = false, the MCP extension's "built-in MCP server authorization" -// // feature reads identity provider configs and validates Bearer tokens at the webhook level -// // if any provider is enabled. Our DCR facade has its own JWT validation middleware -// // (McpJwtValidationMiddleware) that is the sole gate; no App Service-level validation -// // should be active. See: https://learn.microsoft.com/azure/app-service/configure-authentication-mcp -// identityProviders: {} -// } -// } - -// ─── Storage Blob Data Owner (for Flex Consumption managed identity) ──────── - -resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, functionApp.id, 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') - scope: storageAccount - properties: { - principalId: functionApp.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') - } -} - -// ─── Storage Queue Data Contributor (for Flex Consumption managed identity) ─ +// ─── Cosmos DB RBAC (Managed Identity) ─────────────────────────────────────── -resource storageQueueDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, functionApp.id, '974c5e8b-45b9-4653-ba55-5f855dd0fb88') - scope: storageAccount - properties: { - principalId: functionApp.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '974c5e8b-45b9-4653-ba55-5f855dd0fb88') - } -} +// Cosmos DB Built-in Data Contributor role +var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002' +var containerAppCosmosRoleAssignmentName = guid(cosmosAccount.id, containerAppIdentity.id, cosmosDataContributorRoleId) -// ─── Storage Queue Data Message Processor (for Flex Consumption managed identity) ─ +// ─── Container App managed-identity access ────────────────────────────────── -resource storageQueueDataMessageProcessorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, functionApp.id, '8a0f0c08-91a1-4084-bc3d-661d67233fed') - scope: storageAccount +resource containerAppAcrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, containerApp.id, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + scope: containerRegistry properties: { - principalId: functionApp.identity.principalId + principalId: containerApp.identity.principalId principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8a0f0c08-91a1-4084-bc3d-661d67233fed') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') } } -// ─── Monitoring Metrics Publisher (for Function App → App Insights) ──────── - -resource monitoringMetricsPublisherRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(applicationInsights.id, functionApp.id, '3913510d-42f4-4e42-8a64-420c390055eb') - scope: applicationInsights +resource containerAppStorageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, containerAppIdentity.id, 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + scope: storageAccount properties: { - principalId: functionApp.identity.principalId + principalId: containerAppIdentity.properties.principalId principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '3913510d-42f4-4e42-8a64-420c390055eb') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') } } -// ─── Cosmos DB RBAC (Managed Identity) ─────────────────────────────────────── - -// Cosmos DB Built-in Data Contributor role -var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002' - -resource cosmosRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-05-15' = { - parent: cosmosAccount - name: guid(cosmosAccount.id, functionApp.id, cosmosDataContributorRoleId) - properties: { - principalId: functionApp.identity.principalId - roleDefinitionId: '${cosmosAccount.id}/sqlRoleDefinitions/${cosmosDataContributorRoleId}' - scope: cosmosAccount.id - } -} +// Cosmos native SQL role assignments do not accept principalType. A newly-created managed +// identity can therefore be rejected while it is still replicating through Entra. The azd +// postprovision hook creates this deterministic assignment with a bounded, idempotent retry. -// ─── Key Vault RBAC (v1.6 — BOTH planes required) ──────────────────────────── -// -// Data Protection's secret-backed key ring (used for future upstream-token encryption) needs -// Secrets Officer to create and update secrets, and Crypto User to wrap/unwrap via KEK operations. -// Single-role configurations fail silently until the first key rotation — bake both in on day one. -// See sprint doc "Files to modify → infra/main.bicep". - -// Key Vault Crypto User: 12338af0-0e69-4776-bea7-57ae8d297424 -resource keyVaultCryptoUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, functionApp.id, '12338af0-0e69-4776-bea7-57ae8d297424') +resource containerAppKeyVaultCryptoUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, containerAppIdentity.id, '12338af0-0e69-4776-bea7-57ae8d297424') scope: keyVault properties: { - principalId: functionApp.identity.principalId + principalId: containerAppIdentity.properties.principalId principalType: 'ServicePrincipal' roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '12338af0-0e69-4776-bea7-57ae8d297424') } } -// Key Vault Secrets Officer: b86a8fe4-44ce-4948-aee5-eccb2c155cd7 -resource keyVaultSecretsOfficerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, functionApp.id, 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') +resource containerAppKeyVaultSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, containerAppIdentity.id, '4633458b-17de-408a-b874-0445c86b69e6') scope: keyVault properties: { - principalId: functionApp.identity.principalId + principalId: containerAppIdentity.properties.principalId principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') } } // ─── Outputs ───────────────────────────────────────────────────────────────── -output AZURE_FUNCTION_URL string = 'https://${functionApp.properties.defaultHostName}' +output AZURE_CONTAINER_APP_URL string = containerAppBaseUrl +output OAUTH_REDIRECT_URI string = '${containerAppBaseUrl}/callback' +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.name +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer +output AZURE_CONTAINER_APP_IDENTITY_PRINCIPAL_ID string = containerAppIdentity.properties.principalId +output AZURE_CONTAINER_APP_COSMOS_ROLE_ASSIGNMENT_ID string = containerAppCosmosRoleAssignmentName +output AZURE_COSMOS_ACCOUNT_ID string = cosmosAccount.id +output AZURE_COSMOS_ACCOUNT_NAME string = cosmosAccount.name +output AZURE_KEY_VAULT_NAME string = keyVault.name +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id +output AZURE_RESOURCE_GROUP string = resourceGroup().name output COSMOS_ACCOUNT_ENDPOINT string = cosmosAccount.properties.documentEndpoint output KEY_VAULT_NAME string = keyVault.name output KEY_VAULT_URI string = keyVault.properties.vaultUri diff --git a/infra/main.parameters.json b/infra/main.parameters.json index fc25df0..d225e36 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -14,11 +14,20 @@ "entraClientId": { "value": "${ENTRA_CLIENT_ID}" }, + "entraClientSecretValue": { + "value": "${ENTRA_CLIENT_SECRET=}" + }, + "jwtSigningSecretValue": { + "value": "${JWT_SIGNING_SECRET=}" + }, "oauthAccessTokenLifetimeMinutes": { "value": "${OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES=}" }, "oauthRefreshReplayLifetimeMinutes": { "value": "${OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES=}" + }, + "containerAppImage": { + "value": "${SERVICE_SERVER_IMAGE_NAME=mcr.microsoft.com/azuredocs/containerapps-helloworld:latest}" } } } diff --git a/scripts/complete-container-app-provision.ps1 b/scripts/complete-container-app-provision.ps1 new file mode 100644 index 0000000..6c7d2e1 --- /dev/null +++ b/scripts/complete-container-app-provision.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param( + [string]$SubscriptionId, + [string]$ResourceGroup, + [string]$CosmosAccountId, + [string]$CosmosAccountName, + [string]$PrincipalId, + [string]$RoleAssignmentId, + [ValidateRange(1, 60)] + [int]$MaximumAttempts = 12, + [ValidateRange(1, 60)] + [int]$RetryDelaySeconds = 10 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-AzdValue { + param( + [Parameter(Mandatory)] + [string]$Name, + [string]$ExplicitValue + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitValue)) { + return $ExplicitValue.Trim() + } + + $environmentValue = [Environment]::GetEnvironmentVariable($Name) + if (-not [string]::IsNullOrWhiteSpace($environmentValue)) { + return $environmentValue.Trim() + } + + $azdValue = & azd env get-value $Name 2>$null + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($azdValue)) { + throw "Required azd value '$Name' is unavailable. Run this script from a provisioned azd environment or pass the corresponding parameter explicitly." + } + + return $azdValue.Trim() +} + +$SubscriptionId = Resolve-AzdValue -Name 'AZURE_SUBSCRIPTION_ID' -ExplicitValue $SubscriptionId +$ResourceGroup = Resolve-AzdValue -Name 'AZURE_RESOURCE_GROUP' -ExplicitValue $ResourceGroup +$CosmosAccountId = Resolve-AzdValue -Name 'AZURE_COSMOS_ACCOUNT_ID' -ExplicitValue $CosmosAccountId +$CosmosAccountName = Resolve-AzdValue -Name 'AZURE_COSMOS_ACCOUNT_NAME' -ExplicitValue $CosmosAccountName +$PrincipalId = Resolve-AzdValue -Name 'AZURE_CONTAINER_APP_IDENTITY_PRINCIPAL_ID' -ExplicitValue $PrincipalId +$RoleAssignmentId = Resolve-AzdValue -Name 'AZURE_CONTAINER_APP_COSMOS_ROLE_ASSIGNMENT_ID' -ExplicitValue $RoleAssignmentId + +if (-not [guid]::TryParse($SubscriptionId, [ref]([guid]::Empty))) { + throw 'AZURE_SUBSCRIPTION_ID must be a GUID.' +} + +if (-not [guid]::TryParse($PrincipalId, [ref]([guid]::Empty))) { + throw 'AZURE_CONTAINER_APP_IDENTITY_PRINCIPAL_ID must be a GUID.' +} + +if (-not [guid]::TryParse($RoleAssignmentId, [ref]([guid]::Empty))) { + throw 'AZURE_CONTAINER_APP_COSMOS_ROLE_ASSIGNMENT_ID must be a GUID.' +} + +$expectedAccountId = "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroup/providers/Microsoft.DocumentDB/databaseAccounts/$CosmosAccountName" +if (-not $CosmosAccountId.Equals($expectedAccountId, [StringComparison]::OrdinalIgnoreCase)) { + throw 'AZURE_COSMOS_ACCOUNT_ID does not match the selected subscription, resource group, and account name.' +} + +$roleDefinitionId = "$CosmosAccountId/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002" +$requestUri = "https://management.azure.com$CosmosAccountId/sqlRoleAssignments/$RoleAssignmentId`?api-version=2024-05-15" +$requestBody = @{ + properties = @{ + principalId = $PrincipalId + roleDefinitionId = $roleDefinitionId + scope = $CosmosAccountId + } +} | ConvertTo-Json -Depth 4 -Compress + +$managementToken = (& azd auth token --scope 'https://management.azure.com/.default').Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($managementToken)) { + throw 'Unable to obtain an Azure Resource Manager token from azd.' +} + +$headers = @{ + Authorization = "Bearer $managementToken" + 'Content-Type' = 'application/json' +} + +for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt++) { + try { + Invoke-RestMethod -Method Put -Uri $requestUri -Headers $headers -Body $requestBody | Out-Null + Write-Host "Cosmos DB data-role assignment is ready for managed identity $PrincipalId." + break + } + catch { + $details = @($_.ErrorDetails.Message, $_.Exception.Message) -join ' ' + $isPropagationDelay = $details -match '(?i)principal.*(not found|does not exist)' + + if (-not $isPropagationDelay -or $attempt -eq $MaximumAttempts) { + throw + } + + Write-Warning "Managed identity is not visible to Cosmos DB yet (attempt $attempt of $MaximumAttempts). Retrying in $RetryDelaySeconds seconds." + Start-Sleep -Seconds $RetryDelaySeconds + } +} diff --git a/scripts/seed-devbrain.ps1 b/scripts/seed-devbrain.ps1 deleted file mode 100644 index 25e02b9..0000000 --- a/scripts/seed-devbrain.ps1 +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Seed a freshly deployed DevBrain instance with baseline reference documents. - -.DESCRIPTION - Reads the Function App URL from the current azd environment and the MCP - extension system key from the DEVBRAIN_KEY environment variable (or prompts - for either if not set), then calls the DevBrain UpsertDocument MCP tool - over the SSE transport to seed a set of default documents. - - Run this after `azd up` on a brand new deployment. Safe to re-run — every - upsert is a full overwrite. - -.PARAMETER FunctionUrl - Base URL of the deployed Function App (e.g. https://devbrain-xyz.azurewebsites.net). - Defaults to the AZURE_FUNCTION_URL output from the current azd environment. - -.PARAMETER FunctionKey - The MCP extension system key (Azure Portal > Function App > App keys > - System keys > mcp_extension). Defaults to $env:DEVBRAIN_KEY. - -.EXAMPLE - ./scripts/seed-devbrain.ps1 - -.EXAMPLE - ./scripts/seed-devbrain.ps1 -FunctionUrl https://devbrain-xyz.azurewebsites.net -FunctionKey abc123 -#> - -[CmdletBinding()] -param( - [string]$FunctionUrl, - [string]$FunctionKey -) - -$ErrorActionPreference = 'Stop' - -# ---------- resolve settings ---------- - -function Get-AzdEnvValue { - param([string]$Name) - try { - $values = azd env get-values 2>$null - foreach ($line in $values) { - if ($line -match "^$Name=`"?([^`"]+)`"?$") { - return $matches[1] - } - } - } catch { - # azd not installed or no env initialized — fall through - } - return $null -} - -if (-not $FunctionUrl) { - $FunctionUrl = Get-AzdEnvValue -Name 'AZURE_FUNCTION_URL' -} -if (-not $FunctionUrl) { - $FunctionUrl = Read-Host -Prompt 'Function App URL (e.g. https://devbrain-xyz.azurewebsites.net)' -} - -if (-not $FunctionKey) { - $FunctionKey = $env:DEVBRAIN_KEY -} -if (-not $FunctionKey) { - $secure = Read-Host -Prompt 'DevBrain MCP function key (mcp_extension system key)' -AsSecureString - $FunctionKey = [System.Net.NetworkCredential]::new('', $secure).Password -} - -if (-not $FunctionUrl -or -not $FunctionKey) { - throw 'FunctionUrl and FunctionKey are required.' -} - -$FunctionUrl = $FunctionUrl.TrimEnd('/') -$SseUrl = "$FunctionUrl/runtime/webhooks/mcp/sse" - -Write-Host "Seeding DevBrain at $FunctionUrl" -ForegroundColor Cyan - -# ---------- minimal MCP client over SSE ---------- - -Add-Type -AssemblyName System.Net.Http - -function Invoke-McpTool { - param( - [Parameter(Mandatory)][string]$ToolName, - [Parameter(Mandatory)][hashtable]$Arguments - ) - - $client = [System.Net.Http.HttpClient]::new() - $client.Timeout = [TimeSpan]::FromSeconds(60) - $client.DefaultRequestHeaders.Add('x-functions-key', $FunctionKey) - - try { - # 1. Open SSE stream to get the session-scoped message endpoint. - $sseReq = [System.Net.Http.HttpRequestMessage]::new('GET', $SseUrl) - $sseReq.Headers.Accept.Add( - [System.Net.Http.Headers.MediaTypeWithQualityHeaderValue]::new('text/event-stream')) - $sseResp = $client.SendAsync( - $sseReq, - [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).Result - if (-not $sseResp.IsSuccessStatusCode) { - throw "SSE connect failed: HTTP $([int]$sseResp.StatusCode) $($sseResp.ReasonPhrase)" - } - $stream = $sseResp.Content.ReadAsStreamAsync().Result - $reader = [System.IO.StreamReader]::new($stream) - - # 2. Read `event: endpoint` / `data: ` frame. - $messageUrl = $null - $pendingEvent = $null - while ($null -eq $messageUrl) { - $line = $reader.ReadLine() - if ($null -eq $line) { throw 'SSE stream closed before endpoint event was received.' } - if ($line.StartsWith('event: ')) { - $pendingEvent = $line.Substring(7).Trim() - } - elseif ($line.StartsWith('data: ') -and $pendingEvent -eq 'endpoint') { - $path = $line.Substring(6).Trim() - $messageUrl = if ($path -match '^https?://') { $path } else { "$FunctionUrl$path" } - } - } - - $headers = @{ - 'x-functions-key' = $FunctionKey - 'Content-Type' = 'application/json' - } - - # 3. initialize - $initBody = @{ - jsonrpc = '2.0' - id = 1 - method = 'initialize' - params = @{ - protocolVersion = '2024-11-05' - capabilities = @{} - clientInfo = @{ name = 'seed-devbrain'; version = '1.0' } - } - } | ConvertTo-Json -Depth 10 -Compress - Invoke-RestMethod -Uri $messageUrl -Method POST -Headers $headers -Body $initBody | Out-Null - - # 4. initialized notification - $initializedBody = @{ - jsonrpc = '2.0' - method = 'notifications/initialized' - } | ConvertTo-Json -Compress - Invoke-RestMethod -Uri $messageUrl -Method POST -Headers $headers -Body $initializedBody | Out-Null - - # 5. tools/call - $callBody = @{ - jsonrpc = '2.0' - id = 2 - method = 'tools/call' - params = @{ - name = $ToolName - arguments = $Arguments - } - } | ConvertTo-Json -Depth 20 -Compress - Invoke-RestMethod -Uri $messageUrl -Method POST -Headers $headers -Body $callBody | Out-Null - - # 6. Read the tool response off the SSE stream (matches id=2). - $deadline = [DateTime]::UtcNow.AddSeconds(30) - $pendingEvent = $null - while ([DateTime]::UtcNow -lt $deadline) { - $line = $reader.ReadLine() - if ($null -eq $line) { Start-Sleep -Milliseconds 50; continue } - if ($line.StartsWith('event: ')) { - $pendingEvent = $line.Substring(7).Trim() - continue - } - if ($line.StartsWith('data: ')) { - $payload = $line.Substring(6) - try { - $parsed = $payload | ConvertFrom-Json -ErrorAction Stop - if ($parsed.id -eq 2) { return $parsed } - } catch { - # not a JSON-RPC frame — skip - } - } - } - throw 'Timed out waiting for tool response on SSE stream.' - } - finally { - if ($reader) { $reader.Dispose() } - if ($client) { $client.Dispose() } - } -} - -# ---------- seed documents ---------- - -$repoRoot = Split-Path -Parent $PSScriptRoot -$seedDir = Join-Path $repoRoot 'docs/seed' - -$documents = @( - @{ - Key = 'ref:devbrain-usage' - Project = 'default' - File = Join-Path $seedDir 'ref-devbrain-usage.md' - Tags = @('meta', 'instructions', 'usage') - } -) - -$failures = 0 -foreach ($doc in $documents) { - if (-not (Test-Path $doc.File)) { - Write-Host " [FAIL] $($doc.Key) — seed file not found: $($doc.File)" -ForegroundColor Red - $failures++ - continue - } - - $content = Get-Content -Path $doc.File -Raw - $args = @{ - key = $doc.Key - content = $content - tags = $doc.Tags - project = $doc.Project - } - - try { - $result = Invoke-McpTool -ToolName 'UpsertDocument' -Arguments $args - if ($result.error) { - Write-Host " [FAIL] $($doc.Key) (project=$($doc.Project)) — $($result.error.message)" -ForegroundColor Red - $failures++ - } else { - Write-Host " [ OK ] $($doc.Key) (project=$($doc.Project))" -ForegroundColor Green - } - } - catch { - Write-Host " [FAIL] $($doc.Key) (project=$($doc.Project)) — $($_.Exception.Message)" -ForegroundColor Red - $failures++ - } -} - -Write-Host "" -if ($failures -gt 0) { - Write-Host "Seeding completed with $failures failure(s)." -ForegroundColor Yellow - exit 1 -} -Write-Host "Seeding complete." -ForegroundColor Green diff --git a/src/DevBrain.Functions/Auth/Crypto/Pkce.cs b/src/DevBrain.Core/Auth/Crypto/Pkce.cs similarity index 98% rename from src/DevBrain.Functions/Auth/Crypto/Pkce.cs rename to src/DevBrain.Core/Auth/Crypto/Pkce.cs index 4fa5291..52cc4e6 100644 --- a/src/DevBrain.Functions/Auth/Crypto/Pkce.cs +++ b/src/DevBrain.Core/Auth/Crypto/Pkce.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace DevBrain.Functions.Auth.Crypto; +namespace DevBrain.Core.Auth.Crypto; /// /// RFC 7636 PKCE primitives. Used in two independent flows inside DevBrain: diff --git a/src/DevBrain.Functions/Auth/DcrFacade/AuthorizationHandler.cs b/src/DevBrain.Core/Auth/DcrFacade/AuthorizationHandler.cs similarity index 69% rename from src/DevBrain.Functions/Auth/DcrFacade/AuthorizationHandler.cs rename to src/DevBrain.Core/Auth/DcrFacade/AuthorizationHandler.cs index ca3719b..4a4d8f5 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/AuthorizationHandler.cs +++ b/src/DevBrain.Core/Auth/DcrFacade/AuthorizationHandler.cs @@ -1,9 +1,10 @@ -using DevBrain.Functions.Auth.Crypto; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Crypto; +using DevBrain.Core.Auth.Logging; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Logging; -namespace DevBrain.Functions.Auth.DcrFacade; +namespace DevBrain.Core.Auth.DcrFacade; /// /// Service layer for GET /authorize. Validates the client, generates the upstream-side @@ -45,11 +46,18 @@ public AuthorizationHandler( public async Task HandleAsync(AuthorizationRequest request) { _logger?.LogInformation( - "AuthorizationHandler: request received clientId={ClientId} responseType={ResponseType} redirectUri={RedirectUri} hasState={HasState} codeChallengeMethod={CodeChallengeMethod}", - request.ClientId, request.ResponseType, request.RedirectUri, !string.IsNullOrEmpty(request.State), request.CodeChallengeMethod); + "AuthorizationHandler: request received clientIdFingerprint={ClientIdFingerprint} responseTypeCode={ResponseTypeCode} redirectUriFingerprint={RedirectUriFingerprint} hasState={HasState} codeChallengeMethodS256={CodeChallengeMethodS256}", + OAuthLogValue.Fingerprint(request.ClientId), + string.Equals(request.ResponseType, "code", StringComparison.Ordinal), + OAuthLogValue.Fingerprint(request.RedirectUri), + !string.IsNullOrEmpty(request.State), + string.Equals(request.CodeChallengeMethod, "S256", StringComparison.Ordinal)); // RFC 6749 §4.1.1: response_type, client_id, redirect_uri are the structural inputs. // RFC 7636 §4.3: code_challenge + code_challenge_method are PKCE. + // These exact allowlist checks are security gates, not caller-controlled authentication + // bypasses. BuildAuthorizeUri is a pure constructor that later receives only configured and + // server-generated values; no caller-provided response type or PKCE method reaches it. if (string.IsNullOrEmpty(request.ClientId)) { _logger?.LogWarning("AuthorizationHandler: rejected — missing client_id"); @@ -57,7 +65,7 @@ public async Task HandleAsync(AuthorizationRequest request) } if (request.ResponseType != "code") { - _logger?.LogWarning("AuthorizationHandler: rejected — unsupported response_type={ResponseType}", request.ResponseType); + _logger?.LogWarning("AuthorizationHandler: rejected — unsupported response_type"); return AuthorizationResult.Error("unsupported_response_type", "Only response_type=code is supported."); } if (string.IsNullOrEmpty(request.RedirectUri)) @@ -72,14 +80,23 @@ public async Task HandleAsync(AuthorizationRequest request) } if (request.CodeChallengeMethod != "S256") { - _logger?.LogWarning("AuthorizationHandler: rejected — code_challenge_method={Method} (only S256)", request.CodeChallengeMethod); + _logger?.LogWarning("AuthorizationHandler: rejected — unsupported code_challenge_method (only S256)"); return AuthorizationResult.Error("invalid_request", "code_challenge_method must be S256."); } + if (!string.IsNullOrEmpty(request.Resource) + && !string.IsNullOrEmpty(request.CanonicalResource) + && !string.Equals(request.Resource, request.CanonicalResource, StringComparison.Ordinal)) + { + _logger?.LogWarning("AuthorizationHandler: rejected — resource does not match canonical MCP resource"); + return AuthorizationResult.Error("invalid_target", "resource must identify this DevBrain MCP endpoint."); + } var client = await _store.GetClientAsync(request.ClientId); if (client is null) { - _logger?.LogWarning("AuthorizationHandler: rejected — unknown or expired clientId={ClientId}", request.ClientId); + _logger?.LogWarning( + "AuthorizationHandler: rejected — unknown or expired clientIdFingerprint={ClientIdFingerprint}", + OAuthLogValue.Fingerprint(request.ClientId)); return AuthorizationResult.Error("invalid_client", "Unknown or expired client_id."); } @@ -88,8 +105,9 @@ public async Task HandleAsync(AuthorizationRequest request) if (!client.RedirectUris.Any(u => string.Equals(u, request.RedirectUri, StringComparison.Ordinal))) { _logger?.LogWarning( - "AuthorizationHandler: rejected — redirect_uri {RedirectUri} not registered for clientId={ClientId}", - request.RedirectUri, request.ClientId); + "AuthorizationHandler: rejected — redirectUriFingerprint={RedirectUriFingerprint} not registered for clientIdFingerprint={ClientIdFingerprint}", + OAuthLogValue.Fingerprint(request.RedirectUri), + OAuthLogValue.Fingerprint(request.ClientId)); return AuthorizationResult.Error("invalid_redirect_uri", "redirect_uri is not registered for this client_id."); } @@ -104,6 +122,8 @@ public async Task HandleAsync(AuthorizationRequest request) ClientId = request.ClientId, ClientRedirectUri = request.RedirectUri, ClientState = request.State, + Resource = request.CanonicalResource ?? request.Resource ?? string.Empty, + Issuer = request.Issuer ?? string.Empty, ClientCodeChallenge = request.CodeChallenge, ClientCodeChallengeMethod = request.CodeChallengeMethod, UpstreamState = upstreamState, @@ -117,21 +137,25 @@ public async Task HandleAsync(AuthorizationRequest request) var upstreamAuthorizeUri = _upstream.BuildAuthorizeUri(upstreamState, upstreamChallenge); _logger?.LogInformation( - "AuthorizationHandler: transaction persisted clientId={ClientId} upstreamState={UpstreamState} redirecting to Entra", - request.ClientId, upstreamState); + "AuthorizationHandler: transaction persisted clientIdFingerprint={ClientIdFingerprint} upstreamStateFingerprint={UpstreamStateFingerprint} redirecting to Entra", + OAuthLogValue.Fingerprint(request.ClientId), + OAuthLogValue.Fingerprint(upstreamState)); return AuthorizationResult.Success(upstreamAuthorizeUri); } } -/// Query-string inputs to /authorize, flattened into a record. The endpoint class extracts these from . +/// Query-string inputs to /authorize, flattened into a host-neutral record. public sealed record AuthorizationRequest( string ClientId, string ResponseType, string RedirectUri, string? State, string CodeChallenge, - string CodeChallengeMethod); + string CodeChallengeMethod, + string? Resource = null, + string? Issuer = null, + string? CanonicalResource = null); /// /// Either "redirect the user here" (success) or "show this error" (failure). RFC 6749 §4.1.2.1 says diff --git a/src/DevBrain.Functions/Auth/DcrFacade/CallbackHandler.cs b/src/DevBrain.Core/Auth/DcrFacade/CallbackHandler.cs similarity index 86% rename from src/DevBrain.Functions/Auth/DcrFacade/CallbackHandler.cs rename to src/DevBrain.Core/Auth/DcrFacade/CallbackHandler.cs index 212fa38..9ab3987 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/CallbackHandler.cs +++ b/src/DevBrain.Core/Auth/DcrFacade/CallbackHandler.cs @@ -1,9 +1,10 @@ using System.Security.Cryptography; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Logging; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Logging; -namespace DevBrain.Functions.Auth.DcrFacade; +namespace DevBrain.Core.Auth.DcrFacade; /// /// Service layer for GET /callback. This is the integration choke point — the only endpoint @@ -68,8 +69,9 @@ public async Task HandleAsync(CallbackRequest request) if (!string.IsNullOrEmpty(request.Error)) { _logger?.LogWarning( - "CallbackHandler: upstream returned error={UpstreamError} description={UpstreamErrorDescription}", - request.Error, request.ErrorDescription); + "CallbackHandler: upstream returned errorFingerprint={UpstreamErrorFingerprint} hasDescription={HasDescription}", + OAuthLogValue.Fingerprint(request.Error), + !string.IsNullOrEmpty(request.ErrorDescription)); var errorTxn = await _store.GetTransactionAsync(request.State ?? string.Empty); if (errorTxn is null) { @@ -97,8 +99,8 @@ public async Task HandleAsync(CallbackRequest request) if (transaction is null) { _logger?.LogWarning( - "CallbackHandler: rejected — transaction not found or expired state={State}", - request.State); + "CallbackHandler: rejected — transaction not found or expired stateFingerprint={StateFingerprint}", + OAuthLogValue.Fingerprint(request.State)); return CallbackResult.LocalError("invalid_state", "Unknown or expired transaction state."); } @@ -116,8 +118,9 @@ public async Task HandleAsync(CallbackRequest request) // surfaces in logs and error reporting. The transaction is still consumed so an // attacker can't replay the same state. _logger?.LogError(ex, - "CallbackHandler: id_token validation failed clientId={ClientId} upstreamState={UpstreamState}", - transaction.ClientId, transaction.UpstreamState); + "CallbackHandler: id_token validation failed clientIdFingerprint={ClientIdFingerprint} upstreamStateFingerprint={UpstreamStateFingerprint}", + OAuthLogValue.Fingerprint(transaction.ClientId), + OAuthLogValue.Fingerprint(transaction.UpstreamState)); await _store.DeleteTransactionAsync(transaction.UpstreamState); return CallbackResult.LocalError("invalid_grant", $"id_token validation failed: {ex.Message}"); } @@ -126,8 +129,9 @@ public async Task HandleAsync(CallbackRequest request) // Forward upstream transport/grant failure to the client with a generic error. We don't // leak the upstream error body — it could reveal tenant internals. _logger?.LogError(ex, - "CallbackHandler: upstream token exchange failed clientId={ClientId} upstreamState={UpstreamState}", - transaction.ClientId, transaction.UpstreamState); + "CallbackHandler: upstream token exchange failed clientIdFingerprint={ClientIdFingerprint} upstreamStateFingerprint={UpstreamStateFingerprint}", + OAuthLogValue.Fingerprint(transaction.ClientId), + OAuthLogValue.Fingerprint(transaction.UpstreamState)); await _store.DeleteTransactionAsync(transaction.UpstreamState); return CallbackResult.RedirectToClient(BuildClientErrorRedirect( transaction, @@ -154,6 +158,7 @@ await _store.SaveUpstreamTokenAsync(new UpstreamTokenRecord UserPrincipalName = upstreamTokens.UserPrincipalName, ObjectId = upstreamTokens.ObjectId, TenantId = upstreamTokens.TenantId, + Roles = upstreamTokens.Roles ?? [], CreatedAt = now, ExpiresAt = now + UpstreamVaultTtl, Ttl = (int)UpstreamVaultTtl.TotalSeconds, @@ -167,6 +172,7 @@ await _store.SaveAuthCodeAsync(new DevBrainAuthCode Code = devbrainCode, ClientId = transaction.ClientId, ClientRedirectUri = transaction.ClientRedirectUri, + Resource = transaction.Resource, ClientCodeChallenge = transaction.ClientCodeChallenge, ClientCodeChallengeMethod = transaction.ClientCodeChallengeMethod, UpstreamJti = jti, @@ -178,8 +184,10 @@ await _store.SaveAuthCodeAsync(new DevBrainAuthCode await _store.DeleteTransactionAsync(transaction.UpstreamState); _logger?.LogInformation( - "CallbackHandler: success clientId={ClientId} upstreamJti={Jti} upn={Upn} — minted devbrain auth code, redirecting to client", - transaction.ClientId, jti, upstreamTokens.UserPrincipalName); + "CallbackHandler: success clientIdFingerprint={ClientIdFingerprint} upstreamJtiFingerprint={JtiFingerprint} upnFingerprint={UpnFingerprint} — minted devbrain auth code, redirecting to client", + OAuthLogValue.Fingerprint(transaction.ClientId), + OAuthLogValue.Fingerprint(jti), + OAuthLogValue.Fingerprint(upstreamTokens.UserPrincipalName)); var redirect = BuildClientSuccessRedirect(transaction, devbrainCode); return CallbackResult.RedirectToClient(redirect); @@ -196,6 +204,10 @@ private static Uri BuildClientSuccessRedirect(AuthTransaction transaction, strin { pairs.Add($"state={Uri.EscapeDataString(transaction.ClientState)}"); } + if (!string.IsNullOrEmpty(transaction.Issuer)) + { + pairs.Add($"iss={Uri.EscapeDataString(transaction.Issuer)}"); + } builder.Query = AppendToQuery(builder.Query, pairs); return builder.Uri; } @@ -215,6 +227,10 @@ private static Uri BuildClientErrorRedirect(AuthTransaction transaction, string { pairs.Add($"state={Uri.EscapeDataString(transaction.ClientState)}"); } + if (!string.IsNullOrEmpty(transaction.Issuer)) + { + pairs.Add($"iss={Uri.EscapeDataString(transaction.Issuer)}"); + } builder.Query = AppendToQuery(builder.Query, pairs); return builder.Uri; } diff --git a/src/DevBrain.Functions/Auth/DcrFacade/RegistrationHandler.cs b/src/DevBrain.Core/Auth/DcrFacade/RegistrationHandler.cs similarity index 68% rename from src/DevBrain.Functions/Auth/DcrFacade/RegistrationHandler.cs rename to src/DevBrain.Core/Auth/DcrFacade/RegistrationHandler.cs index a3921ad..ec53a73 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/RegistrationHandler.cs +++ b/src/DevBrain.Core/Auth/DcrFacade/RegistrationHandler.cs @@ -1,13 +1,14 @@ using System.Text.Json.Serialization; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Logging; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Logging; -namespace DevBrain.Functions.Auth.DcrFacade; +namespace DevBrain.Core.Auth.DcrFacade; /// /// Service layer for POST /register. Held separate from the HTTP adapter so it can be -/// unit-tested without standing up the Functions runtime — the endpoint class is a thin wrapper +/// unit-tested without standing up either hosting runtime — each endpoint adapter is a thin wrapper /// that parses the JSON body, calls , and formats the response. /// public sealed class RegistrationHandler @@ -35,8 +36,9 @@ public RegistrationHandler(IOAuthStateStore store, TimeProvider timeProvider, IL public async Task HandleAsync(RegistrationRequest request) { _logger?.LogInformation( - "RegistrationHandler: request received clientName={ClientName} redirectUriCount={RedirectUriCount}", - request.ClientName, request.RedirectUris?.Length ?? 0); + "RegistrationHandler: request received clientNameFingerprint={ClientNameFingerprint} redirectUriCount={RedirectUriCount}", + OAuthLogValue.Fingerprint(request.ClientName), + request.RedirectUris?.Length ?? 0); // RFC 7591 §2: redirect_uris is REQUIRED and MUST contain at least one value. if (request.RedirectUris is null || request.RedirectUris.Length == 0) @@ -45,20 +47,38 @@ public async Task HandleAsync(RegistrationRequest request) return RegistrationResult.Error("invalid_redirect_uri", "redirect_uris is required and must contain at least one entry."); } + var applicationType = string.IsNullOrEmpty(request.ApplicationType) ? "web" : request.ApplicationType; + if (applicationType is not ("native" or "web")) + { + _logger?.LogWarning("RegistrationHandler: rejected — unsupported application_type"); + return RegistrationResult.Error("invalid_client_metadata", "application_type must be 'native' or 'web'."); + } + foreach (var uri in request.RedirectUris) { if (string.IsNullOrWhiteSpace(uri) || !Uri.TryCreate(uri, UriKind.Absolute, out var parsed)) { - _logger?.LogWarning("RegistrationHandler: rejected — redirect_uri {Uri} is not a valid absolute URI", uri); + _logger?.LogWarning( + "RegistrationHandler: rejected — redirectUriFingerprint={RedirectUriFingerprint} is not a valid absolute URI", + OAuthLogValue.Fingerprint(uri)); return RegistrationResult.Error("invalid_redirect_uri", $"redirect_uri '{uri}' is not a valid absolute URI."); } // Only http/https are acceptable. We don't accept custom URI schemes, mailto:, etc. if (parsed.Scheme is not ("http" or "https")) { - _logger?.LogWarning("RegistrationHandler: rejected — redirect_uri scheme {Scheme} not allowed", parsed.Scheme); + _logger?.LogWarning( + "RegistrationHandler: rejected — redirect URI scheme fingerprint={SchemeFingerprint} not allowed", + OAuthLogValue.Fingerprint(parsed.Scheme)); return RegistrationResult.Error("invalid_redirect_uri", $"redirect_uri scheme '{parsed.Scheme}' is not allowed. Use http or https."); } + if (parsed.Scheme == "http" && !parsed.IsLoopback) + { + _logger?.LogWarning( + "RegistrationHandler: rejected — non-loopback HTTP redirectUriFingerprint={RedirectUriFingerprint}", + OAuthLogValue.Fingerprint(uri)); + return RegistrationResult.Error("invalid_redirect_uri", "HTTP redirect_uris are allowed only for loopback clients."); + } } // Opaque GUID as client_id. FastMCP-style: this is a handle onto the client's declared @@ -72,6 +92,7 @@ public async Task HandleAsync(RegistrationRequest request) ClientId = clientId, ClientName = request.ClientName, RedirectUris = request.RedirectUris, + ApplicationType = applicationType, CreatedAt = now, ExpiresAt = now + ClientTtl, Ttl = (int)ClientTtl.TotalSeconds, @@ -79,14 +100,16 @@ public async Task HandleAsync(RegistrationRequest request) await _store.SaveClientAsync(record); _logger?.LogInformation( - "RegistrationHandler: client registered clientId={ClientId} clientName={ClientName}", - clientId, request.ClientName); + "RegistrationHandler: client registered clientIdFingerprint={ClientIdFingerprint} clientNameFingerprint={ClientNameFingerprint}", + OAuthLogValue.Fingerprint(clientId), + OAuthLogValue.Fingerprint(request.ClientName)); return RegistrationResult.Success(new RegistrationResponse( ClientId: clientId, ClientIdIssuedAt: now.ToUnixTimeSeconds(), ClientName: request.ClientName, RedirectUris: request.RedirectUris, + ApplicationType: applicationType, // Public clients only — DevBrain doesn't issue client secrets because the client_id // is a handle, not an Entra app, and there's no upstream secret to protect. TokenEndpointAuthMethod: "none")); @@ -103,14 +126,13 @@ public async Task HandleAsync(RegistrationRequest request) /// , which applies /// and would otherwise look for redirectUris/clientName — no match, no exception, /// silently null fields, and the handler rejecting "redirect_uris missing or empty" for every -/// real client. The response-side .RegistrationResponseDto -/// has always had these annotations; the request side was missed until Claude Desktop hit it -/// in the v1.6 post-deploy window. +/// real client. Response adapters use explicit wire-format annotations for the same reason. /// /// public sealed record RegistrationRequest( [property: JsonPropertyName("redirect_uris")] string[]? RedirectUris, - [property: JsonPropertyName("client_name")] string? ClientName); + [property: JsonPropertyName("client_name")] string? ClientName, + [property: JsonPropertyName("application_type")] string? ApplicationType = null); /// Result envelope — either a success (to be serialized as RFC 7591 §3.2.1 response) or an error (RFC 7591 §3.2.2). public sealed record RegistrationResult(bool IsSuccess, RegistrationResponse? Response, string? ErrorCode, string? ErrorDescription) @@ -124,4 +146,5 @@ public sealed record RegistrationResponse( long ClientIdIssuedAt, string? ClientName, string[] RedirectUris, + string ApplicationType, string TokenEndpointAuthMethod); diff --git a/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs b/src/DevBrain.Core/Auth/DcrFacade/TokenHandler.cs similarity index 62% rename from src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs rename to src/DevBrain.Core/Auth/DcrFacade/TokenHandler.cs index 61da71b..56519bf 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs +++ b/src/DevBrain.Core/Auth/DcrFacade/TokenHandler.cs @@ -1,10 +1,11 @@ using System.Security.Cryptography; -using DevBrain.Functions.Auth.Crypto; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Crypto; +using DevBrain.Core.Auth.Logging; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Logging; -namespace DevBrain.Functions.Auth.DcrFacade; +namespace DevBrain.Core.Auth.DcrFacade; /// /// Service layer for POST /token. Handles both the authorization_code and @@ -30,12 +31,13 @@ public sealed class TokenHandler private readonly IOAuthStateStore _store; private readonly DevBrainJwtIssuer _jwtIssuer; + private readonly IUpstreamOAuthClient? _upstreamOAuthClient; private readonly TimeProvider _timeProvider; private readonly TokenHandlerOptions _options; private readonly ILogger? _logger; public TokenHandler(IOAuthStateStore store, DevBrainJwtIssuer jwtIssuer, TimeProvider timeProvider) - : this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger: null) + : this(store, jwtIssuer, upstreamOAuthClient: null, timeProvider, TokenHandlerOptions.Default, logger: null) { } @@ -44,7 +46,7 @@ public TokenHandler( DevBrainJwtIssuer jwtIssuer, TimeProvider timeProvider, ILogger? logger) - : this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger) + : this(store, jwtIssuer, upstreamOAuthClient: null, timeProvider, TokenHandlerOptions.Default, logger) { } @@ -54,10 +56,22 @@ public TokenHandler( TimeProvider timeProvider, TokenHandlerOptions options, ILogger? logger) + : this(store, jwtIssuer, upstreamOAuthClient: null, timeProvider, options, logger) + { + } + + public TokenHandler( + IOAuthStateStore store, + DevBrainJwtIssuer jwtIssuer, + IUpstreamOAuthClient? upstreamOAuthClient, + TimeProvider timeProvider, + TokenHandlerOptions options, + ILogger? logger) { options.Validate(); _store = store; _jwtIssuer = jwtIssuer; + _upstreamOAuthClient = upstreamOAuthClient; _timeProvider = timeProvider; _options = options; _logger = logger; @@ -66,8 +80,12 @@ public TokenHandler( public Task HandleAsync(TokenRequest request) { _logger?.LogInformation( - "TokenHandler: request received grantType={GrantType} clientId={ClientId} hasCode={HasCode} hasRefreshToken={HasRefreshToken}", - request.GrantType, request.ClientId, !string.IsNullOrEmpty(request.Code), !string.IsNullOrEmpty(request.RefreshToken)); + "TokenHandler: request received authorizationCodeGrant={AuthorizationCodeGrant} refreshTokenGrant={RefreshTokenGrant} clientIdFingerprint={ClientIdFingerprint} hasCode={HasCode} hasRefreshToken={HasRefreshToken}", + string.Equals(request.GrantType, "authorization_code", StringComparison.Ordinal), + string.Equals(request.GrantType, "refresh_token", StringComparison.Ordinal), + OAuthLogValue.Fingerprint(request.ClientId), + !string.IsNullOrEmpty(request.Code), + !string.IsNullOrEmpty(request.RefreshToken)); return request.GrantType switch { @@ -79,7 +97,7 @@ public Task HandleAsync(TokenRequest request) private Task LogAndReturnUnsupported(string grantType) { - _logger?.LogWarning("TokenHandler: rejected — unsupported grant_type={GrantType}", grantType); + _logger?.LogWarning("TokenHandler: rejected — unsupported grant_type"); return Task.FromResult(TokenResult.Error("unsupported_grant_type", $"grant_type '{grantType}' is not supported.")); } @@ -113,8 +131,9 @@ private async Task HandleAuthorizationCodeAsync(TokenRequest reques if (!string.Equals(code.ClientId, request.ClientId, StringComparison.Ordinal)) { _logger?.LogWarning( - "TokenHandler/authcode: rejected — client binding mismatch codeClientId={CodeClientId} requestClientId={RequestClientId}", - code.ClientId, request.ClientId); + "TokenHandler/authcode: rejected — client binding mismatch codeClientIdFingerprint={CodeClientIdFingerprint} requestClientIdFingerprint={RequestClientIdFingerprint}", + OAuthLogValue.Fingerprint(code.ClientId), + OAuthLogValue.Fingerprint(request.ClientId)); return TokenResult.Error("invalid_grant", "Authorization code was issued to a different client."); } @@ -124,6 +143,13 @@ private async Task HandleAuthorizationCodeAsync(TokenRequest reques _logger?.LogWarning("TokenHandler/authcode: rejected — redirect_uri mismatch"); return TokenResult.Error("invalid_grant", "redirect_uri does not match the value used at /authorize."); } + if (!string.IsNullOrEmpty(request.Resource) + && !string.IsNullOrEmpty(code.Resource) + && !string.Equals(request.Resource, code.Resource, StringComparison.Ordinal)) + { + _logger?.LogWarning("TokenHandler/authcode: rejected — resource mismatch"); + return TokenResult.Error("invalid_target", "resource does not match the authorization request."); + } if (!Pkce.VerifyChallenge(request.CodeVerifier, code.ClientCodeChallenge)) { @@ -133,11 +159,12 @@ private async Task HandleAuthorizationCodeAsync(TokenRequest reques var upstreamJti = code.UpstreamJti; var (jwt, _) = IssueJwtForUpstream(upstreamJti); - var refresh = await MintAndStoreRefreshAsync(code.ClientId, upstreamJti); + var refresh = await MintAndStoreRefreshAsync(code.ClientId, upstreamJti, code.Resource); _logger?.LogInformation( - "TokenHandler/authcode: issued access+refresh clientId={ClientId} upstreamJti={Jti}", - code.ClientId, upstreamJti); + "TokenHandler/authcode: issued access+refresh clientIdFingerprint={ClientIdFingerprint} upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(code.ClientId), + OAuthLogValue.Fingerprint(upstreamJti)); return TokenResult.Success(new TokenResponse( AccessToken: jwt, @@ -167,26 +194,37 @@ private async Task HandleRefreshAsync(TokenRequest request) replacementRefresh, RefreshTokenLifetime, _options.RefreshReplayLifetime, - UpstreamVaultTtl); + UpstreamVaultTtl, + request.Resource); if (!rotation.Succeeded) { _logger?.LogWarning( - "TokenHandler/refresh: rejected reason={Reason} clientId={ClientId} refreshTokenFingerprint={RefreshTokenFingerprint}", - rotation.LogCode, request.ClientId, FingerprintToken(request.RefreshToken)); + "TokenHandler/refresh: rejected reason={Reason} clientIdFingerprint={ClientIdFingerprint} refreshTokenFingerprint={RefreshTokenFingerprint}", + rotation.LogCode, + OAuthLogValue.Fingerprint(request.ClientId), + OAuthLogValue.Fingerprint(request.RefreshToken)); return TokenResult.Error("invalid_grant", "refresh_token is invalid, expired, already rotated outside the replay window, or bound to a different client."); } var upstreamJti = rotation.UpstreamJti!; + if (!rotation.IsReplay && _upstreamOAuthClient is not null + && !await RefreshUpstreamSessionAsync(upstreamJti)) + { + return TokenResult.Error( + "invalid_grant", + "The upstream Entra session could not be refreshed or no longer matches the signed-in user."); + } + var (jwt, _) = IssueJwtForUpstream(upstreamJti); _logger?.LogInformation( - "TokenHandler/refresh: {RotationKind} refresh clientId={ClientId} upstreamJti={Jti} refreshTokenFingerprint={RefreshTokenFingerprint} returnedRefreshTokenFingerprint={ReturnedRefreshTokenFingerprint}", + "TokenHandler/refresh: {RotationKind} refresh clientIdFingerprint={ClientIdFingerprint} upstreamJtiFingerprint={JtiFingerprint} refreshTokenFingerprint={RefreshTokenFingerprint} returnedRefreshTokenFingerprint={ReturnedRefreshTokenFingerprint}", rotation.IsReplay ? "replayed" : "rotated", - request.ClientId, - upstreamJti, - FingerprintToken(request.RefreshToken), - FingerprintToken(rotation.RefreshToken!)); + OAuthLogValue.Fingerprint(request.ClientId), + OAuthLogValue.Fingerprint(upstreamJti), + OAuthLogValue.Fingerprint(request.RefreshToken), + OAuthLogValue.Fingerprint(rotation.RefreshToken)); return TokenResult.Success(new TokenResponse( AccessToken: jwt, @@ -196,6 +234,85 @@ private async Task HandleRefreshAsync(TokenRequest request) Scope: "documents.readwrite")); } + private async Task RefreshUpstreamSessionAsync(string upstreamJti) + { + var existing = await _store.GetUpstreamTokenAsync(upstreamJti); + if (existing is null || string.IsNullOrEmpty(existing.Envelope.RefreshToken)) + { + _logger?.LogWarning( + "TokenHandler/refresh: rejected reason=upstream_refresh_missing upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(upstreamJti)); + await RevokeUpstreamSessionAsync(upstreamJti); + return false; + } + + UpstreamTokenResponse refreshed; + try + { + refreshed = await _upstreamOAuthClient!.RefreshTokenAsync(existing.Envelope.RefreshToken); + } + catch (Exception ex) + { + _logger?.LogWarning( + ex, + "TokenHandler/refresh: rejected reason=upstream_refresh_failed upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(upstreamJti)); + await RevokeUpstreamSessionAsync(upstreamJti); + return false; + } + + if (!string.Equals(refreshed.TenantId, existing.TenantId, StringComparison.OrdinalIgnoreCase) + || !string.Equals(refreshed.ObjectId, existing.ObjectId, StringComparison.OrdinalIgnoreCase)) + { + _logger?.LogWarning( + "TokenHandler/refresh: rejected reason=upstream_identity_changed upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(upstreamJti)); + await RevokeUpstreamSessionAsync(upstreamJti); + return false; + } + + var now = _timeProvider.GetUtcNow(); + existing.Envelope = new UpstreamTokenEnvelope( + AccessToken: refreshed.AccessToken, + RefreshToken: string.IsNullOrEmpty(refreshed.RefreshToken) + ? existing.Envelope.RefreshToken + : refreshed.RefreshToken, + ExpiresAtUnixSeconds: (now + refreshed.ExpiresIn).ToUnixTimeSeconds()); + existing.UserPrincipalName = refreshed.UserPrincipalName; + existing.Roles = refreshed.Roles ?? []; + existing.ExpiresAt = now + UpstreamVaultTtl; + existing.Ttl = (int)UpstreamVaultTtl.TotalSeconds; + try + { + await _store.SaveUpstreamTokenAsync(existing); + return true; + } + catch (Exception ex) + { + _logger?.LogWarning( + ex, + "TokenHandler/refresh: rejected reason=upstream_refresh_persist_failed upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(upstreamJti)); + await RevokeUpstreamSessionAsync(upstreamJti); + return false; + } + } + + private async Task RevokeUpstreamSessionAsync(string upstreamJti) + { + try + { + await _store.DeleteUpstreamTokenAsync(upstreamJti); + } + catch (Exception ex) + { + _logger?.LogError( + ex, + "TokenHandler/refresh: failed to revoke upstream session upstreamJtiFingerprint={JtiFingerprint}", + OAuthLogValue.Fingerprint(upstreamJti)); + } + } + /// /// Mints a DevBrain JWT whose jti is the provided upstream JTI. The subject is synthetic /// (upstream-{jti}) — the real user identity is carried in the @@ -214,7 +331,7 @@ private async Task HandleRefreshAsync(TokenRequest request) return _jwtIssuer.IssueWithJti(subject: $"upstream-{upstreamJti}", jti: upstreamJti, lifetime: _options.AccessTokenLifetime); } - private async Task MintAndStoreRefreshAsync(string clientId, string upstreamJti) + private async Task MintAndStoreRefreshAsync(string clientId, string upstreamJti, string resource) { var token = GenerateOpaqueToken(); var now = _timeProvider.GetUtcNow(); @@ -223,6 +340,7 @@ await _store.SaveRefreshAsync(new DevBrainRefreshRecord RefreshToken = token, ClientId = clientId, UpstreamJti = upstreamJti, + Resource = resource, CreatedAt = now, ExpiresAt = now + RefreshTokenLifetime, Ttl = (int)RefreshTokenLifetime.TotalSeconds, @@ -237,12 +355,6 @@ private static string GenerateOpaqueToken() return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } - private static string FingerprintToken(string token) - { - Span hash = stackalloc byte[32]; - SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token), hash); - return Convert.ToHexString(hash[..6]).ToLowerInvariant(); - } } public sealed record TokenHandlerOptions(TimeSpan AccessTokenLifetime, TimeSpan RefreshReplayLifetime) @@ -273,7 +385,8 @@ public sealed record TokenRequest( string? Code, string? CodeVerifier, string? RedirectUri, - string? RefreshToken); + string? RefreshToken, + string? Resource = null); public sealed record TokenResult(bool IsSuccess, TokenResponse? Response, string? ErrorCode, string? ErrorDescription) { diff --git a/src/DevBrain.Core/Auth/Logging/OAuthLogValue.cs b/src/DevBrain.Core/Auth/Logging/OAuthLogValue.cs new file mode 100644 index 0000000..6332d0a --- /dev/null +++ b/src/DevBrain.Core/Auth/Logging/OAuthLogValue.cs @@ -0,0 +1,21 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DevBrain.Core.Auth.Logging; + +internal static class OAuthLogValue +{ + private const string Missing = "none"; + + public static string Fingerprint(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return Missing; + } + + Span hash = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(value), hash); + return Convert.ToHexString(hash[..6]).ToLowerInvariant(); + } +} diff --git a/src/DevBrain.Functions/Auth/Middleware/JwtAuthenticator.cs b/src/DevBrain.Core/Auth/Middleware/JwtAuthenticator.cs similarity index 95% rename from src/DevBrain.Functions/Auth/Middleware/JwtAuthenticator.cs rename to src/DevBrain.Core/Auth/Middleware/JwtAuthenticator.cs index 5dbe1e4..6a29035 100644 --- a/src/DevBrain.Functions/Auth/Middleware/JwtAuthenticator.cs +++ b/src/DevBrain.Core/Auth/Middleware/JwtAuthenticator.cs @@ -1,9 +1,9 @@ using System.Security.Claims; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Logging; using Microsoft.IdentityModel.JsonWebTokens; -namespace DevBrain.Functions.Auth.Middleware; +namespace DevBrain.Core.Auth.Middleware; /// /// Configuration for . The is the @@ -21,9 +21,8 @@ public sealed class JwtAuthenticatorOptions /// value and returns either a rehydrated or a reason for rejection. /// /// -/// Held separate from so the acceptance gates can be -/// covered by unit tests without constructing a . -/// The middleware is a thin HTTP-to-authenticator adapter. +/// Held separate from host-specific authentication middleware so the acceptance gates can be +/// covered by unit tests without constructing a Functions or ASP.NET Core request context. /// /// /// Acceptance gates proven at this layer: @@ -145,6 +144,10 @@ public async Task AuthenticateAsync(string? authorizationH identity.AddClaim(new Claim("oid", upstreamRecord.ObjectId)); identity.AddClaim(new Claim("tid", upstreamRecord.TenantId)); identity.AddClaim(new Claim("jti", jti)); + foreach (var role in upstreamRecord.Roles) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } var principal = new ClaimsPrincipal(identity); _logger?.LogInformation( diff --git a/src/DevBrain.Functions/Auth/Models/AuthTransaction.cs b/src/DevBrain.Core/Auth/Models/AuthTransaction.cs similarity index 90% rename from src/DevBrain.Functions/Auth/Models/AuthTransaction.cs rename to src/DevBrain.Core/Auth/Models/AuthTransaction.cs index fd1d7e0..f08cfaa 100644 --- a/src/DevBrain.Functions/Auth/Models/AuthTransaction.cs +++ b/src/DevBrain.Core/Auth/Models/AuthTransaction.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// A pending authorization transaction created at /authorize and consumed at @@ -27,6 +27,12 @@ public sealed class AuthTransaction [JsonPropertyName("clientState")] public string? ClientState { get; set; } + [JsonPropertyName("resource")] + public string Resource { get; set; } = string.Empty; + + [JsonPropertyName("issuer")] + public string Issuer { get; set; } = string.Empty; + /// The client's PKCE code_challenge. Validated at /token against the client's code_verifier. [JsonPropertyName("clientCodeChallenge")] public string ClientCodeChallenge { get; set; } = string.Empty; diff --git a/src/DevBrain.Functions/Auth/Models/DevBrainAuthCode.cs b/src/DevBrain.Core/Auth/Models/DevBrainAuthCode.cs similarity index 93% rename from src/DevBrain.Functions/Auth/Models/DevBrainAuthCode.cs rename to src/DevBrain.Core/Auth/Models/DevBrainAuthCode.cs index 88612a0..65a5eb0 100644 --- a/src/DevBrain.Functions/Auth/Models/DevBrainAuthCode.cs +++ b/src/DevBrain.Core/Auth/Models/DevBrainAuthCode.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// A single-use authorization code issued by DevBrain at /callback and redeemed at /token. @@ -26,6 +26,9 @@ public sealed class DevBrainAuthCode [JsonPropertyName("clientRedirectUri")] public string ClientRedirectUri { get; set; } = string.Empty; + [JsonPropertyName("resource")] + public string Resource { get; set; } = string.Empty; + /// Mirror of the client's original PKCE challenge, copied from the transaction at /callback time. [JsonPropertyName("clientCodeChallenge")] public string ClientCodeChallenge { get; set; } = string.Empty; diff --git a/src/DevBrain.Functions/Auth/Models/DevBrainRefreshRecord.cs b/src/DevBrain.Core/Auth/Models/DevBrainRefreshRecord.cs similarity index 92% rename from src/DevBrain.Functions/Auth/Models/DevBrainRefreshRecord.cs rename to src/DevBrain.Core/Auth/Models/DevBrainRefreshRecord.cs index 6685907..37fd94d 100644 --- a/src/DevBrain.Functions/Auth/Models/DevBrainRefreshRecord.cs +++ b/src/DevBrain.Core/Auth/Models/DevBrainRefreshRecord.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// A DevBrain refresh token. Rotated on every use (see @@ -25,6 +25,9 @@ public sealed class DevBrainRefreshRecord [JsonPropertyName("upstreamJti")] public string UpstreamJti { get; set; } = string.Empty; + [JsonPropertyName("resource")] + public string Resource { get; set; } = string.Empty; + [JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; set; } diff --git a/src/DevBrain.Functions/Auth/Models/RegisteredClient.cs b/src/DevBrain.Core/Auth/Models/RegisteredClient.cs similarity index 89% rename from src/DevBrain.Functions/Auth/Models/RegisteredClient.cs rename to src/DevBrain.Core/Auth/Models/RegisteredClient.cs index 2b34acf..14ec4f0 100644 --- a/src/DevBrain.Functions/Auth/Models/RegisteredClient.cs +++ b/src/DevBrain.Core/Auth/Models/RegisteredClient.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// A client registered via RFC 7591 Dynamic Client Registration. @@ -27,6 +27,9 @@ public sealed class RegisteredClient [JsonPropertyName("redirectUris")] public string[] RedirectUris { get; set; } = []; + [JsonPropertyName("applicationType")] + public string ApplicationType { get; set; } = "web"; + [JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; set; } diff --git a/src/DevBrain.Functions/Auth/Models/UpstreamTokenEnvelope.cs b/src/DevBrain.Core/Auth/Models/UpstreamTokenEnvelope.cs similarity index 95% rename from src/DevBrain.Functions/Auth/Models/UpstreamTokenEnvelope.cs rename to src/DevBrain.Core/Auth/Models/UpstreamTokenEnvelope.cs index 08304a4..b0c5a64 100644 --- a/src/DevBrain.Functions/Auth/Models/UpstreamTokenEnvelope.cs +++ b/src/DevBrain.Core/Auth/Models/UpstreamTokenEnvelope.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// The plaintext payload that gets encrypted into 's diff --git a/src/DevBrain.Functions/Auth/Models/UpstreamTokenRecord.cs b/src/DevBrain.Core/Auth/Models/UpstreamTokenRecord.cs similarity index 95% rename from src/DevBrain.Functions/Auth/Models/UpstreamTokenRecord.cs rename to src/DevBrain.Core/Auth/Models/UpstreamTokenRecord.cs index c356753..16e2c49 100644 --- a/src/DevBrain.Functions/Auth/Models/UpstreamTokenRecord.cs +++ b/src/DevBrain.Core/Auth/Models/UpstreamTokenRecord.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Auth.Models; +namespace DevBrain.Core.Auth.Models; /// /// In-memory representation of an upstream token vault entry. Holds the plaintext @@ -39,6 +39,8 @@ public sealed class UpstreamTokenRecord public string TenantId { get; set; } = string.Empty; + public IReadOnlyList Roles { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } diff --git a/src/DevBrain.Functions/Auth/Services/CosmosOAuthStateStore.cs b/src/DevBrain.Core/Auth/Services/CosmosOAuthStateStore.cs similarity index 91% rename from src/DevBrain.Functions/Auth/Services/CosmosOAuthStateStore.cs rename to src/DevBrain.Core/Auth/Services/CosmosOAuthStateStore.cs index 86ea968..c6a915c 100644 --- a/src/DevBrain.Functions/Auth/Services/CosmosOAuthStateStore.cs +++ b/src/DevBrain.Core/Auth/Services/CosmosOAuthStateStore.cs @@ -1,10 +1,10 @@ using System.Net; using System.Text.Json.Serialization; -using DevBrain.Functions.Auth.Models; +using DevBrain.Core.Auth.Models; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Cosmos-backed . Uses the dedicated oauth_state container @@ -22,6 +22,7 @@ public sealed class CosmosOAuthStateStore : IOAuthStateStore private readonly Container _container; private readonly TimeProvider _timeProvider; private readonly IUpstreamTokenProtector _protector; + private readonly string _keyPrefix; public CosmosOAuthStateStore( CosmosClient cosmosClient, @@ -31,6 +32,7 @@ public CosmosOAuthStateStore( { var databaseName = configuration["CosmosDb:DatabaseName"] ?? "devbrain"; var containerName = configuration["CosmosDb:OAuthContainerName"] ?? "oauth_state"; + _keyPrefix = configuration["CosmosDb:OAuthKeyPrefix"] ?? string.Empty; _container = cosmosClient.GetContainer(databaseName, containerName); _timeProvider = timeProvider; _protector = protector; @@ -141,6 +143,7 @@ public async Task SaveUpstreamTokenAsync(UpstreamTokenRecord token) UserPrincipalName = token.UserPrincipalName, ObjectId = token.ObjectId, TenantId = token.TenantId, + Roles = token.Roles.ToArray(), CreatedAt = token.CreatedAt, ExpiresAt = token.ExpiresAt, Ttl = token.Ttl, @@ -170,6 +173,7 @@ public async Task SaveUpstreamTokenAsync(UpstreamTokenRecord token) UserPrincipalName = dto.UserPrincipalName, ObjectId = dto.ObjectId, TenantId = dto.TenantId, + Roles = dto.Roles, CreatedAt = dto.CreatedAt, ExpiresAt = dto.ExpiresAt, Ttl = dto.Ttl, @@ -200,7 +204,8 @@ public async Task RotateRefreshAsync( string replacementRefreshToken, TimeSpan replacementLifetime, TimeSpan replayLifetime, - TimeSpan upstreamVaultLifetime) + TimeSpan upstreamVaultLifetime, + string? resource = null) { var key = RefreshKey(refreshToken); var partition = new PartitionKey(key); @@ -232,6 +237,12 @@ record = response.Resource; { return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient); } + if (!string.IsNullOrEmpty(resource) + && !string.IsNullOrEmpty(record.Resource) + && !string.Equals(record.Resource, resource, StringComparison.Ordinal)) + { + return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongResource); + } if (record.IsReplayMarker) { @@ -258,6 +269,7 @@ record = response.Resource; RefreshToken = replacementRefreshToken, ClientId = record.ClientId, UpstreamJti = record.UpstreamJti, + Resource = record.Resource, CreatedAt = now, ExpiresAt = now + replacementLifetime, Ttl = (int)replacementLifetime.TotalSeconds, @@ -271,6 +283,7 @@ record = response.Resource; RefreshToken = refreshToken, ClientId = record.ClientId, UpstreamJti = record.UpstreamJti, + Resource = record.Resource, CreatedAt = record.CreatedAt, ExpiresAt = now + replayLifetime, RotatedAt = now, @@ -295,7 +308,7 @@ await _container.ReplaceItemAsync( // Another request won the rotation. Re-read once and, if it left a replay marker, // return that winning replacement instead of surfacing a spurious invalid_grant. await DeleteAsync(RefreshKey(replacementRefreshToken)); - var replay = await ReadRefreshReplayAsync(refreshToken, clientId, upstreamVaultLifetime); + var replay = await ReadRefreshReplayAsync(refreshToken, clientId, resource, upstreamVaultLifetime); return replay.Succeeded ? replay : RefreshRotationResult.Rejected(RefreshRotationOutcome.ConcurrentReplayUnavailable); @@ -347,6 +360,7 @@ await _container.DeleteItemAsync( private async Task ReadRefreshReplayAsync( string refreshToken, string clientId, + string? resource, TimeSpan upstreamVaultLifetime) { var key = RefreshKey(refreshToken); @@ -366,6 +380,12 @@ private async Task ReadRefreshReplayAsync( { return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient); } + if (!string.IsNullOrEmpty(resource) + && !string.IsNullOrEmpty(record.Resource) + && !string.Equals(record.Resource, resource, StringComparison.Ordinal)) + { + return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongResource); + } if (!record.IsReplayMarker) { @@ -499,7 +519,7 @@ await _container.DeleteItemAsync( && ex.StatusCode != System.Net.HttpStatusCode.PreconditionFailed) { System.Diagnostics.Trace.TraceWarning( - $"CosmosOAuthStateStore.TryDeleteAsync: unexpected CosmosException {ex.StatusCode} for key '{key}': {ex.Message}"); + $"CosmosOAuthStateStore.TryDeleteAsync: unexpected CosmosException status={ex.StatusCode}"); } } } @@ -509,11 +529,14 @@ await _container.DeleteItemAsync( // Kept as private static methods rather than a public constants class so callers never // construct raw Cosmos keys — all access goes through the typed interface methods. - private static string ClientKey(string clientId) => $"client:{clientId}"; - private static string TransactionKey(string upstreamState) => $"txn:{upstreamState}"; - private static string AuthCodeKey(string code) => $"code:{code}"; - private static string UpstreamKey(string jti) => $"upstream:{jti}"; - private static string RefreshKey(string refreshToken) => $"refresh:{refreshToken}"; + private string ClientKey(string clientId) => ComposeKey(_keyPrefix, "client", clientId); + private string TransactionKey(string upstreamState) => ComposeKey(_keyPrefix, "txn", upstreamState); + private string AuthCodeKey(string code) => ComposeKey(_keyPrefix, "code", code); + private string UpstreamKey(string jti) => ComposeKey(_keyPrefix, "upstream", jti); + private string RefreshKey(string refreshToken) => ComposeKey(_keyPrefix, "refresh", refreshToken); + + internal static string ComposeKey(string prefix, string recordKind, string identifier) => + $"{prefix}{recordKind}:{identifier}"; /// /// Cosmos wire shape for upstream token vault entries. The encryptedPayload field holds @@ -544,6 +567,9 @@ private sealed class UpstreamCosmosDto [JsonPropertyName("tenantId")] public string TenantId { get; set; } = string.Empty; + [JsonPropertyName("roles")] + public string[] Roles { get; set; } = []; + [JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; set; } diff --git a/src/DevBrain.Functions/Auth/Services/DataProtectionUpstreamTokenProtector.cs b/src/DevBrain.Core/Auth/Services/DataProtectionUpstreamTokenProtector.cs similarity index 96% rename from src/DevBrain.Functions/Auth/Services/DataProtectionUpstreamTokenProtector.cs rename to src/DevBrain.Core/Auth/Services/DataProtectionUpstreamTokenProtector.cs index cfed5eb..2f25dfc 100644 --- a/src/DevBrain.Functions/Auth/Services/DataProtectionUpstreamTokenProtector.cs +++ b/src/DevBrain.Core/Auth/Services/DataProtectionUpstreamTokenProtector.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using DevBrain.Functions.Auth.Models; +using DevBrain.Core.Auth.Models; using Microsoft.AspNetCore.DataProtection; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Production implementation of . Backed by an diff --git a/src/DevBrain.Functions/Auth/Services/DevBrainJwtIssuer.cs b/src/DevBrain.Core/Auth/Services/DevBrainJwtIssuer.cs similarity index 97% rename from src/DevBrain.Functions/Auth/Services/DevBrainJwtIssuer.cs rename to src/DevBrain.Core/Auth/Services/DevBrainJwtIssuer.cs index 5ca8688..cf68461 100644 --- a/src/DevBrain.Functions/Auth/Services/DevBrainJwtIssuer.cs +++ b/src/DevBrain.Core/Auth/Services/DevBrainJwtIssuer.cs @@ -2,7 +2,7 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Options for . Read from configuration under the OAuth section. @@ -17,7 +17,8 @@ public sealed class DevBrainJwtIssuerOptions public string Issuer { get; set; } = string.Empty; /// - /// JWT aud claim. Must be the MCP webhook URL ({base_url}/runtime/webhooks/mcp), not the base URL. + /// JWT aud claim. Must be the host's complete MCP resource URL + /// (for example, {base_url}/mcp), not the base URL. /// This is the CVE-2025-69196 guard — a bad aud here allows token reuse across servers. /// public string Audience { get; set; } = string.Empty; @@ -32,7 +33,7 @@ public sealed class DevBrainJwtIssuerOptions /// /// Issues and validates DevBrain's own HS256 JWTs. The client-facing OAuth flow mints these at -/// /token; validates them on every tool call. +/// /token; host-specific authentication middleware validates them on every tool call. /// /// Design: /// diff --git a/src/DevBrain.Functions/Auth/Services/EntraOAuthClient.cs b/src/DevBrain.Core/Auth/Services/EntraOAuthClient.cs similarity index 95% rename from src/DevBrain.Functions/Auth/Services/EntraOAuthClient.cs rename to src/DevBrain.Core/Auth/Services/EntraOAuthClient.cs index 2f95c00..b093268 100644 --- a/src/DevBrain.Functions/Auth/Services/EntraOAuthClient.cs +++ b/src/DevBrain.Core/Auth/Services/EntraOAuthClient.cs @@ -8,7 +8,7 @@ using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Options for . Bound from configuration under the OAuth section. @@ -180,7 +180,7 @@ private async Task PostTokenAsync(Dictionary PostTokenAsync(Dictionary @@ -207,7 +208,7 @@ private async Task PostTokenAsync(Dictionary /// - private async Task<(string Upn, string Oid, string Tid)> ValidateAndExtractIdTokenClaimsAsync(string idToken) + private async Task<(string Upn, string Oid, string Tid, IReadOnlyList Roles)> ValidateAndExtractIdTokenClaimsAsync(string idToken) { OpenIdConnectConfiguration openIdConfig; try @@ -266,8 +267,14 @@ private async Task PostTokenAsync(Dictionary string.Equals(claim.Type, "roles", StringComparison.Ordinal)) + .Select(claim => claim.Value) + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return (upn, oid, tid, roles); } private static string? FindClaim(JsonWebToken jwt, string type) => diff --git a/src/DevBrain.Functions/Auth/Services/IOAuthStateStore.cs b/src/DevBrain.Core/Auth/Services/IOAuthStateStore.cs similarity index 96% rename from src/DevBrain.Functions/Auth/Services/IOAuthStateStore.cs rename to src/DevBrain.Core/Auth/Services/IOAuthStateStore.cs index e301b0b..81203a7 100644 --- a/src/DevBrain.Functions/Auth/Services/IOAuthStateStore.cs +++ b/src/DevBrain.Core/Auth/Services/IOAuthStateStore.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.Models; +using DevBrain.Core.Auth.Models; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Persistence for the DCR OAuth facade's five record kinds. Backed by a dedicated Cosmos @@ -71,7 +71,8 @@ Task RotateRefreshAsync( string replacementRefreshToken, TimeSpan replacementLifetime, TimeSpan replayLifetime, - TimeSpan upstreamVaultLifetime); + TimeSpan upstreamVaultLifetime, + string? resource = null); /// /// Atomically consumes a refresh token. Returns the stored record on success. diff --git a/src/DevBrain.Functions/Auth/Services/IUpstreamOAuthClient.cs b/src/DevBrain.Core/Auth/Services/IUpstreamOAuthClient.cs similarity index 94% rename from src/DevBrain.Functions/Auth/Services/IUpstreamOAuthClient.cs rename to src/DevBrain.Core/Auth/Services/IUpstreamOAuthClient.cs index 8790dcb..93b83fd 100644 --- a/src/DevBrain.Functions/Auth/Services/IUpstreamOAuthClient.cs +++ b/src/DevBrain.Core/Auth/Services/IUpstreamOAuthClient.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Thin abstraction over DevBrain's single upstream Entra app. Three operations: @@ -39,7 +39,7 @@ public interface IUpstreamOAuthClient /// /// Upstream Entra token response. Fields mirror the subset we care about from the Entra v2.0 token -/// response. // are +/// response. /// are /// extracted from the id_token claims by the client so callers don't have to re-parse it. /// public sealed record UpstreamTokenResponse( @@ -49,4 +49,5 @@ public sealed record UpstreamTokenResponse( TimeSpan ExpiresIn, string UserPrincipalName, string ObjectId, - string TenantId); + string TenantId, + IReadOnlyList? Roles = null); diff --git a/src/DevBrain.Functions/Auth/Services/IUpstreamTokenProtector.cs b/src/DevBrain.Core/Auth/Services/IUpstreamTokenProtector.cs similarity index 94% rename from src/DevBrain.Functions/Auth/Services/IUpstreamTokenProtector.cs rename to src/DevBrain.Core/Auth/Services/IUpstreamTokenProtector.cs index af9b3df..dd157a5 100644 --- a/src/DevBrain.Functions/Auth/Services/IUpstreamTokenProtector.cs +++ b/src/DevBrain.Core/Auth/Services/IUpstreamTokenProtector.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.Models; +using DevBrain.Core.Auth.Models; -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Single-responsibility abstraction: turn an into opaque bytes diff --git a/src/DevBrain.Functions/Auth/Services/IdTokenValidationException.cs b/src/DevBrain.Core/Auth/Services/IdTokenValidationException.cs similarity index 95% rename from src/DevBrain.Functions/Auth/Services/IdTokenValidationException.cs rename to src/DevBrain.Core/Auth/Services/IdTokenValidationException.cs index c57fc01..5d00a8f 100644 --- a/src/DevBrain.Functions/Auth/Services/IdTokenValidationException.cs +++ b/src/DevBrain.Core/Auth/Services/IdTokenValidationException.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Thrown by when an Entra id_token fails signature, issuer, diff --git a/src/DevBrain.Functions/Auth/Services/RefreshRotationResult.cs b/src/DevBrain.Core/Auth/Services/RefreshRotationResult.cs similarity index 94% rename from src/DevBrain.Functions/Auth/Services/RefreshRotationResult.cs rename to src/DevBrain.Core/Auth/Services/RefreshRotationResult.cs index 090d6bc..abc2957 100644 --- a/src/DevBrain.Functions/Auth/Services/RefreshRotationResult.cs +++ b/src/DevBrain.Core/Auth/Services/RefreshRotationResult.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Auth.Services; +namespace DevBrain.Core.Auth.Services; /// /// Outcome of rotating a DevBrain refresh token. @@ -11,6 +11,7 @@ public enum RefreshRotationOutcome Expired, ReplayWindowExpired, WrongClient, + WrongResource, ReplayMarkerMissingReplacement, UpstreamMissingOrExpired, ConcurrentReplayUnavailable, @@ -39,6 +40,7 @@ public sealed record RefreshRotationResult( RefreshRotationOutcome.Expired => "expired", RefreshRotationOutcome.ReplayWindowExpired => "replay_window_expired", RefreshRotationOutcome.WrongClient => "wrong_client", + RefreshRotationOutcome.WrongResource => "wrong_resource", RefreshRotationOutcome.ReplayMarkerMissingReplacement => "replay_marker_missing_replacement", RefreshRotationOutcome.UpstreamMissingOrExpired => "upstream_missing_or_expired", RefreshRotationOutcome.ConcurrentReplayUnavailable => "concurrent_replay_unavailable", diff --git a/src/DevBrain.Core/DevBrain.Core.csproj b/src/DevBrain.Core/DevBrain.Core.csproj new file mode 100644 index 0000000..a2f845e --- /dev/null +++ b/src/DevBrain.Core/DevBrain.Core.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/DevBrain.Functions/Models/BrainDocument.cs b/src/DevBrain.Core/Models/BrainDocument.cs similarity index 97% rename from src/DevBrain.Functions/Models/BrainDocument.cs rename to src/DevBrain.Core/Models/BrainDocument.cs index 4ff53df..958545c 100644 --- a/src/DevBrain.Functions/Models/BrainDocument.cs +++ b/src/DevBrain.Core/Models/BrainDocument.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace DevBrain.Functions.Models; +namespace DevBrain.Core.Models; public sealed class BrainDocument { diff --git a/src/DevBrain.Functions/Models/ConditionalWriteResult.cs b/src/DevBrain.Core/Models/ConditionalWriteResult.cs similarity index 79% rename from src/DevBrain.Functions/Models/ConditionalWriteResult.cs rename to src/DevBrain.Core/Models/ConditionalWriteResult.cs index b75522c..bb5a186 100644 --- a/src/DevBrain.Functions/Models/ConditionalWriteResult.cs +++ b/src/DevBrain.Core/Models/ConditionalWriteResult.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Models; +namespace DevBrain.Core.Models; public sealed record ConditionalWriteResult( bool Applied, diff --git a/src/DevBrain.Functions/Models/EditApplyResult.cs b/src/DevBrain.Core/Models/EditApplyResult.cs similarity index 93% rename from src/DevBrain.Functions/Models/EditApplyResult.cs rename to src/DevBrain.Core/Models/EditApplyResult.cs index ccfde63..bd910db 100644 --- a/src/DevBrain.Functions/Models/EditApplyResult.cs +++ b/src/DevBrain.Core/Models/EditApplyResult.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Models; +namespace DevBrain.Core.Models; public sealed class EditApplyResult { diff --git a/src/DevBrain.Functions/Models/EditPreviewResult.cs b/src/DevBrain.Core/Models/EditPreviewResult.cs similarity index 94% rename from src/DevBrain.Functions/Models/EditPreviewResult.cs rename to src/DevBrain.Core/Models/EditPreviewResult.cs index 05058d3..f08b865 100644 --- a/src/DevBrain.Functions/Models/EditPreviewResult.cs +++ b/src/DevBrain.Core/Models/EditPreviewResult.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Models; +namespace DevBrain.Core.Models; public sealed class EditPreviewResult { diff --git a/src/DevBrain.Functions/Services/ChunkedStaging.cs b/src/DevBrain.Core/Services/ChunkedStaging.cs similarity index 98% rename from src/DevBrain.Functions/Services/ChunkedStaging.cs rename to src/DevBrain.Core/Services/ChunkedStaging.cs index 2b164c9..beb8f39 100644 --- a/src/DevBrain.Functions/Services/ChunkedStaging.cs +++ b/src/DevBrain.Core/Services/ChunkedStaging.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; /// /// In-memory model of a chunked-upload staging document's payload. The payload is diff --git a/src/DevBrain.Functions/Services/ContentHashing.cs b/src/DevBrain.Core/Services/ContentHashing.cs similarity index 94% rename from src/DevBrain.Functions/Services/ContentHashing.cs rename to src/DevBrain.Core/Services/ContentHashing.cs index ded3b0f..dacfc07 100644 --- a/src/DevBrain.Functions/Services/ContentHashing.cs +++ b/src/DevBrain.Core/Services/ContentHashing.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Text; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; internal static class ContentHashing { diff --git a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs b/src/DevBrain.Core/Services/CosmosDocumentStore.cs similarity index 96% rename from src/DevBrain.Functions/Services/CosmosDocumentStore.cs rename to src/DevBrain.Core/Services/CosmosDocumentStore.cs index 73f6187..3abcfaf 100644 --- a/src/DevBrain.Functions/Services/CosmosDocumentStore.cs +++ b/src/DevBrain.Core/Services/CosmosDocumentStore.cs @@ -1,9 +1,9 @@ using System.Net; -using DevBrain.Functions.Models; +using DevBrain.Core.Models; using Microsoft.Azure.Cosmos; using Microsoft.Extensions.Configuration; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public sealed class CosmosDocumentStore : IDocumentStore { @@ -163,25 +163,6 @@ public async Task ReplaceIfHashMatchesAsync(BrainDocumen return null; } - public async Task TouchAllAsync() - { - var queryDefinition = new QueryDefinition("SELECT * FROM c"); - var touched = 0; - - using var iterator = _container.GetItemQueryIterator(queryDefinition); - while (iterator.HasMoreResults) - { - var response = await iterator.ReadNextAsync(); - foreach (var document in response) - { - await UpsertAsync(document); - touched++; - } - } - - return touched; - } - public async Task> ListAsync(string project, string? prefix = null) { var queryText = prefix is not null diff --git a/src/DevBrain.Functions/Services/DocumentEditService.cs b/src/DevBrain.Core/Services/DocumentEditService.cs similarity index 99% rename from src/DevBrain.Functions/Services/DocumentEditService.cs rename to src/DevBrain.Core/Services/DocumentEditService.cs index 2f35321..a058087 100644 --- a/src/DevBrain.Functions/Services/DocumentEditService.cs +++ b/src/DevBrain.Core/Services/DocumentEditService.cs @@ -1,7 +1,7 @@ using System.Text; -using DevBrain.Functions.Models; +using DevBrain.Core.Models; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public sealed class DocumentEditService : IDocumentEditService { diff --git a/src/DevBrain.Functions/Services/IDocumentEditService.cs b/src/DevBrain.Core/Services/IDocumentEditService.cs similarity index 87% rename from src/DevBrain.Functions/Services/IDocumentEditService.cs rename to src/DevBrain.Core/Services/IDocumentEditService.cs index 3621736..abafe15 100644 --- a/src/DevBrain.Functions/Services/IDocumentEditService.cs +++ b/src/DevBrain.Core/Services/IDocumentEditService.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Models; +using DevBrain.Core.Models; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public interface IDocumentEditService { diff --git a/src/DevBrain.Functions/Services/IDocumentStore.cs b/src/DevBrain.Core/Services/IDocumentStore.cs similarity index 85% rename from src/DevBrain.Functions/Services/IDocumentStore.cs rename to src/DevBrain.Core/Services/IDocumentStore.cs index 7f95d7d..2891d70 100644 --- a/src/DevBrain.Functions/Services/IDocumentStore.cs +++ b/src/DevBrain.Core/Services/IDocumentStore.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Models; +using DevBrain.Core.Models; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public interface IDocumentStore { @@ -17,13 +17,6 @@ public interface IDocumentStore /// Task GetMetadataAsync(string key, string project); - /// - /// Re-upserts every document in the store, triggering server-side metadata - /// recomputation (contentHash, contentLength). Returns the number of documents - /// touched. Intended as a one-shot backfill after adding new computed fields. - /// - Task TouchAllAsync(); - /// /// Deletes a single document by key within a project. Idempotent: returns false /// when the document does not exist. Accepts both colon and slash keys to support diff --git a/src/DevBrain.Functions/Services/ITagEditService.cs b/src/DevBrain.Core/Services/ITagEditService.cs similarity index 83% rename from src/DevBrain.Functions/Services/ITagEditService.cs rename to src/DevBrain.Core/Services/ITagEditService.cs index bc14369..149d406 100644 --- a/src/DevBrain.Functions/Services/ITagEditService.cs +++ b/src/DevBrain.Core/Services/ITagEditService.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public interface ITagEditService { diff --git a/src/DevBrain.Functions/Services/TagEditResult.cs b/src/DevBrain.Core/Services/TagEditResult.cs similarity index 93% rename from src/DevBrain.Functions/Services/TagEditResult.cs rename to src/DevBrain.Core/Services/TagEditResult.cs index aaf23bf..57cda34 100644 --- a/src/DevBrain.Functions/Services/TagEditResult.cs +++ b/src/DevBrain.Core/Services/TagEditResult.cs @@ -1,4 +1,4 @@ -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public sealed class TagEditResult { diff --git a/src/DevBrain.Functions/Services/TagEditService.cs b/src/DevBrain.Core/Services/TagEditService.cs similarity index 98% rename from src/DevBrain.Functions/Services/TagEditService.cs rename to src/DevBrain.Core/Services/TagEditService.cs index f66d09f..67aeedb 100644 --- a/src/DevBrain.Functions/Services/TagEditService.cs +++ b/src/DevBrain.Core/Services/TagEditService.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Models; +using DevBrain.Core.Models; -namespace DevBrain.Functions.Services; +namespace DevBrain.Core.Services; public sealed class TagEditService : ITagEditService { diff --git a/src/DevBrain.Functions/Auth/DcrFacade/AuthorizeEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/AuthorizeEndpoint.cs deleted file mode 100644 index 241a6d3..0000000 --- a/src/DevBrain.Functions/Auth/DcrFacade/AuthorizeEndpoint.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Web; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.DcrFacade; - -/// -/// HTTP adapter for GET /authorize. Parses the query string, calls the handler, returns a 302 -/// to the upstream Entra /authorize URL on success or a 400 on structural errors. -/// -public sealed class AuthorizeEndpoint -{ - private readonly AuthorizationHandler _handler; - private readonly ILogger _logger; - - public AuthorizeEndpoint(AuthorizationHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - [Function("AuthorizeEndpoint")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "authorize")] HttpRequestData req) - { - _logger.LogInformation("GET /authorize received url={Url}", req.Url); - - var query = HttpUtility.ParseQueryString(req.Url.Query); - var request = new AuthorizationRequest( - ClientId: query["client_id"] ?? string.Empty, - ResponseType: query["response_type"] ?? string.Empty, - RedirectUri: query["redirect_uri"] ?? string.Empty, - State: query["state"], - CodeChallenge: query["code_challenge"] ?? string.Empty, - CodeChallengeMethod: query["code_challenge_method"] ?? "plain"); - - var result = await _handler.HandleAsync(request); - - if (!result.IsSuccess) - { - _logger.LogWarning("GET /authorize: rejected error={Error}", result.ErrorCode); - var error = req.CreateResponse(HttpStatusCode.BadRequest); - error.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await JsonSerializer.SerializeAsync(error.Body, new { error = result.ErrorCode, error_description = result.ErrorDescription }); - return error; - } - - _logger.LogInformation("GET /authorize: 302 Found redirecting to upstream"); - var redirect = req.CreateResponse(HttpStatusCode.Found); - redirect.Headers.Add("Location", result.RedirectTo!.ToString()); - return redirect; - } -} diff --git a/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs deleted file mode 100644 index 80cb56c..0000000 --- a/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Web; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.DcrFacade; - -/// -/// HTTP adapter for GET /callback. Parses the Entra redirect's query string and delegates to -/// . Returns a 302 on success (both happy path and upstream-error pass-through -/// redirect back to the client) or a 400 on local structural errors (unknown state, missing code). -/// -public sealed class CallbackEndpoint -{ - private readonly CallbackHandler _handler; - private readonly ILogger _logger; - - public CallbackEndpoint(CallbackHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - [Function("CallbackEndpoint")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "callback")] HttpRequestData req) - { - _logger.LogInformation("GET /callback received url={Url}", req.Url); - - var query = HttpUtility.ParseQueryString(req.Url.Query); - var request = new CallbackRequest( - Code: query["code"], - State: query["state"], - Error: query["error"], - ErrorDescription: query["error_description"]); - - var result = await _handler.HandleAsync(request); - - if (result.Kind == CallbackResultKind.Redirect) - { - _logger.LogInformation("GET /callback: 302 Found redirecting to client"); - var redirect = req.CreateResponse(HttpStatusCode.Found); - redirect.Headers.Add("Location", result.RedirectTo!.ToString()); - return redirect; - } - - _logger.LogWarning("GET /callback: 400 local error={Error}", result.ErrorCode); - var error = req.CreateResponse(HttpStatusCode.BadRequest); - error.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await JsonSerializer.SerializeAsync(error.Body, new { error = result.ErrorCode, error_description = result.ErrorDescription }); - return error; - } -} diff --git a/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs b/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs deleted file mode 100644 index 01f5db2..0000000 --- a/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.DcrFacade; - -/// -/// Options for the DCR facade's discovery documents. Bound from OAuth:BaseUrl in -/// configuration. The base URL is the public-facing root — e.g. https://devbrain.example.com. -/// All discovery endpoint URLs are derived from this base. -/// -public sealed class DcrFacadeDiscoveryOptions -{ - public string BaseUrl { get; set; } = string.Empty; -} - -/// -/// Serves the two .well-known discovery documents that let MCP clients learn where to -/// register, authorize, and exchange tokens. -/// -/// -/// Why these live in DevBrain at all (rather than pointing at Entra): Claude.ai web ignores -/// discovery endpoints that don't live under the MCP server's own domain root -/// (claude-ai-mcp issue #82). DevBrain hosting its own .well-known URLs and proxying to -/// Entra internally is the fix. -/// -/// -public sealed class DiscoveryEndpoints -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly string _baseUrl; - private readonly ILogger _logger; - - public DiscoveryEndpoints(IConfiguration configuration, ILogger logger) - { - _baseUrl = (configuration["OAuth:BaseUrl"] ?? string.Empty).TrimEnd('/'); - _logger = logger; - } - - /// - /// RFC 8414 — OAuth 2.0 Authorization Server Metadata. Clients POST to /register, GET - /// /authorize, POST /token. All endpoints live on DevBrain itself; Entra is never - /// referenced in this document. - /// - [Function("DiscoveryAuthorizationServer")] - public async Task AuthorizationServer( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = ".well-known/oauth-authorization-server")] HttpRequestData req) - { - _logger.LogInformation("GET /.well-known/oauth-authorization-server received url={Url} baseUrl={BaseUrl}", req.Url, _baseUrl); - - var metadata = new AuthorizationServerMetadata( - Issuer: _baseUrl, - RegistrationEndpoint: $"{_baseUrl}/register", - AuthorizationEndpoint: $"{_baseUrl}/authorize", - TokenEndpoint: $"{_baseUrl}/token", - ResponseTypesSupported: ["code"], - GrantTypesSupported: ["authorization_code", "refresh_token"], - CodeChallengeMethodsSupported: ["S256"], - TokenEndpointAuthMethodsSupported: ["none"], - ScopesSupported: ["documents.readwrite"]); - - return await WriteJsonAsync(req, metadata); - } - - /// - /// RFC 9728 — OAuth 2.0 Protected Resource Metadata. Tells clients which authorization server - /// to use for this resource. Points at DevBrain's own /authorize, NOT Entra's. - /// - [Function("DiscoveryProtectedResource")] - public async Task ProtectedResource( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = ".well-known/oauth-protected-resource")] HttpRequestData req) - { - _logger.LogInformation("GET /.well-known/oauth-protected-resource received url={Url} baseUrl={BaseUrl}", req.Url, _baseUrl); - - var metadata = new ProtectedResourceMetadata( - Resource: $"{_baseUrl}/runtime/webhooks/mcp", - AuthorizationServers: [_baseUrl], - BearerMethodsSupported: ["header"], - ScopesSupported: ["documents.readwrite"]); - - return await WriteJsonAsync(req, metadata); - } - - private static async Task WriteJsonAsync(HttpRequestData req, T body) - { - var response = req.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - response.Headers.Add("Cache-Control", "public, max-age=3600"); - await JsonSerializer.SerializeAsync(response.Body, body, JsonOptions); - return response; - } - - // Wire-format DTOs are internal (not private) so OAuthWireFormatTests can reference them - // directly and pin the emitted JSON field names against the governing RFCs. - - internal sealed record AuthorizationServerMetadata( - [property: JsonPropertyName("issuer")] string Issuer, - [property: JsonPropertyName("registration_endpoint")] string RegistrationEndpoint, - [property: JsonPropertyName("authorization_endpoint")] string AuthorizationEndpoint, - [property: JsonPropertyName("token_endpoint")] string TokenEndpoint, - [property: JsonPropertyName("response_types_supported")] string[] ResponseTypesSupported, - [property: JsonPropertyName("grant_types_supported")] string[] GrantTypesSupported, - [property: JsonPropertyName("code_challenge_methods_supported")] string[] CodeChallengeMethodsSupported, - [property: JsonPropertyName("token_endpoint_auth_methods_supported")] string[] TokenEndpointAuthMethodsSupported, - [property: JsonPropertyName("scopes_supported")] string[] ScopesSupported); - - internal sealed record ProtectedResourceMetadata( - [property: JsonPropertyName("resource")] string Resource, - [property: JsonPropertyName("authorization_servers")] string[] AuthorizationServers, - [property: JsonPropertyName("bearer_methods_supported")] string[] BearerMethodsSupported, - [property: JsonPropertyName("scopes_supported")] string[] ScopesSupported); -} diff --git a/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs deleted file mode 100644 index 7ed0a1d..0000000 --- a/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.DcrFacade; - -/// -/// HTTP adapter for POST /register. Parses the RFC 7591 body, delegates to -/// , serializes the response. -/// -/// -/// Auth: . The entire DCR facade is anonymous — the proxy -/// IS the gate, and clients need to be able to register before they have any credentials to present. -/// -/// -public sealed class RegisterEndpoint -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - private readonly RegistrationHandler _handler; - private readonly ILogger _logger; - - public RegisterEndpoint(RegistrationHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - [Function("RegisterEndpoint")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "register")] HttpRequestData req) - { - _logger.LogInformation("POST /register received"); - - // TODO(v1.6 Claude Desktop DCR parsing investigation): temporary diagnostic logging. - // Clients are hitting "empty redirect_uris" rejection somewhere between the wire and - // RegistrationHandler.HandleAsync; we need the raw body + Content-Type to know whether - // the problem is a missing field, a wrong content type (e.g., form-urlencoded instead - // of JSON), a casing mismatch (`redirectUris` vs `redirect_uris`), or something else - // entirely. Warning level so App Insights doesn't sample it out. Remove before v1.7. - var contentType = req.Headers.TryGetValues("Content-Type", out var ctValues) - ? string.Join(",", ctValues) - : "(none)"; - - string rawBody; - using (var reader = new StreamReader(req.Body)) - { - rawBody = await reader.ReadToEndAsync(); - } - - const int MaxBodyLogLength = 2000; - var loggedBody = rawBody.Length <= MaxBodyLogLength - ? rawBody - : rawBody[..MaxBodyLogLength] + "... (truncated)"; - - _logger.LogWarning( - "RegistrationHandler: raw body content-type={ContentType} body={Body}", - contentType, loggedBody); - - RegistrationRequest? body; - try - { - body = JsonSerializer.Deserialize(rawBody, JsonOptions); - } - catch (JsonException ex) - { - _logger.LogWarning(ex, "POST /register: request body is not valid JSON"); - return await WriteErrorAsync(req, HttpStatusCode.BadRequest, "invalid_client_metadata", "Request body is not valid JSON."); - } - - if (body is null) - { - _logger.LogWarning("POST /register: request body is null"); - return await WriteErrorAsync(req, HttpStatusCode.BadRequest, "invalid_client_metadata", "Request body is required."); - } - - var result = await _handler.HandleAsync(body); - if (!result.IsSuccess) - { - _logger.LogWarning("POST /register: handler rejected error={Error}", result.ErrorCode); - return await WriteErrorAsync(req, HttpStatusCode.BadRequest, result.ErrorCode!, result.ErrorDescription!); - } - - _logger.LogInformation("POST /register: 201 Created clientId={ClientId}", result.Response!.ClientId); - var response = req.CreateResponse(HttpStatusCode.Created); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await JsonSerializer.SerializeAsync(response.Body, new RegistrationResponseDto(result.Response!), JsonOptions); - return response; - } - - private static async Task WriteErrorAsync(HttpRequestData req, HttpStatusCode status, string code, string description) - { - var response = req.CreateResponse(status); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await JsonSerializer.SerializeAsync(response.Body, new { error = code, error_description = description }, JsonOptions); - return response; - } - - /// - /// Wire-format DTO for the RFC 7591 §3.2.1 registration response. Separated from the internal - /// record so the JSON property names can use snake_case - /// without polluting the domain type. internal (not private) so - /// OAuthWireFormatTests can reference it directly without widening the public API. - /// - internal sealed record RegistrationResponseDto( - [property: JsonPropertyName("client_id")] string ClientId, - [property: JsonPropertyName("client_id_issued_at")] long ClientIdIssuedAt, - [property: JsonPropertyName("client_name")] string? ClientName, - [property: JsonPropertyName("redirect_uris")] string[] RedirectUris, - [property: JsonPropertyName("token_endpoint_auth_method")] string TokenEndpointAuthMethod) - { - public RegistrationResponseDto(RegistrationResponse response) - : this(response.ClientId, response.ClientIdIssuedAt, response.ClientName, response.RedirectUris, response.TokenEndpointAuthMethod) { } - } -} diff --git a/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs deleted file mode 100644 index 8600d2e..0000000 --- a/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Net; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Web; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.DcrFacade; - -/// -/// HTTP adapter for POST /token. Handles both authorization_code and refresh_token -/// grants via form-urlencoded bodies. Returns a JSON access_token/refresh_token response -/// per RFC 6749 §5.1 on success, or a {"error":..., "error_description":...} shape per §5.2 -/// on failure. -/// -public sealed class TokenEndpoint -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - private readonly TokenHandler _handler; - private readonly ILogger _logger; - - public TokenEndpoint(TokenHandler handler, ILogger logger) - { - _handler = handler; - _logger = logger; - } - - [Function("TokenEndpoint")] - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "token")] HttpRequestData req) - { - _logger.LogInformation("POST /token received"); - - using var reader = new StreamReader(req.Body); - var formBody = await reader.ReadToEndAsync(); - var form = HttpUtility.ParseQueryString(formBody); - - var request = new TokenRequest( - GrantType: form["grant_type"] ?? string.Empty, - ClientId: form["client_id"], - Code: form["code"], - CodeVerifier: form["code_verifier"], - RedirectUri: form["redirect_uri"], - RefreshToken: form["refresh_token"]); - - var result = await _handler.HandleAsync(request); - - if (!result.IsSuccess) - { - _logger.LogWarning("POST /token: 400 error={Error}", result.ErrorCode); - var error = req.CreateResponse(HttpStatusCode.BadRequest); - error.Headers.Add("Content-Type", "application/json; charset=utf-8"); - error.Headers.Add("Cache-Control", "no-store"); - await JsonSerializer.SerializeAsync(error.Body, new { error = result.ErrorCode, error_description = result.ErrorDescription }, JsonOptions); - return error; - } - - _logger.LogInformation("POST /token: 200 OK"); - var response = req.CreateResponse(HttpStatusCode.OK); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - response.Headers.Add("Cache-Control", "no-store"); - await JsonSerializer.SerializeAsync(response.Body, new TokenResponseDto(result.Response!), JsonOptions); - return response; - } - - /// - /// RFC 6749 §5.1 wire format — snake_case property names on the JSON envelope. - /// internal (not private) so the wire-format regression tests in - /// OAuthWireFormatTests can reference it directly without widening the public API. - /// - internal sealed record TokenResponseDto( - [property: JsonPropertyName("access_token")] string AccessToken, - [property: JsonPropertyName("token_type")] string TokenType, - [property: JsonPropertyName("expires_in")] int ExpiresIn, - [property: JsonPropertyName("refresh_token")] string RefreshToken, - [property: JsonPropertyName("scope")] string Scope) - { - public TokenResponseDto(TokenResponse response) - : this(response.AccessToken, response.TokenType, response.ExpiresIn, response.RefreshToken, response.Scope) { } - } -} diff --git a/src/DevBrain.Functions/Auth/Middleware/InvocationDiagnosticMiddleware.cs b/src/DevBrain.Functions/Auth/Middleware/InvocationDiagnosticMiddleware.cs deleted file mode 100644 index ad4ddb1..0000000 --- a/src/DevBrain.Functions/Auth/Middleware/InvocationDiagnosticMiddleware.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Middleware; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.Middleware; - -/// -/// Unconditional diagnostic middleware that logs every function invocation's name, binding types, -/// and outcome. Runs for every function — MCP tool triggers, HTTP-trigger OAuth endpoints, and -/// any future additions. Not registered behind a UseWhen predicate. -/// -/// -/// Why this exists: the v1.6 post-deploy investigation revealed that -/// was not firing for tool calls, and the working theory -/// was a string/case mismatch in the UseWhen binding-type predicate. This middleware runs -/// before any conditional middleware and independently of whether the gate is working — so -/// on the next deploy its output will definitively show the actual binding type string emitted -/// by the MCP extension at runtime, the order of binding values, and whether invocations that -/// look like tool calls from the client side are reaching the worker at all. -/// -/// -/// -/// Logging shape: -/// -/// -/// Entry: function name, binding types joined by |, invocation id. Information level. -/// Success: function name, invocation id, duration. Information level. -/// Exception: function name, invocation id, duration, exception (full stack trace). Error level, then re-throws. -/// -/// -/// -/// Cost: one log call per invocation. Negligible for a DCR facade + MCP tool surface, expensive -/// for hot-path RPC workloads (not applicable here). Removable once diagnosis is complete. -/// -/// -public sealed class InvocationDiagnosticMiddleware : IFunctionsWorkerMiddleware -{ - private readonly ILogger _logger; - - public InvocationDiagnosticMiddleware(ILogger logger) - { - _logger = logger; - } - - public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) - { - var name = context.FunctionDefinition.Name; - var inputBindingTypes = string.Join('|', context.FunctionDefinition.InputBindings.Values.Select(b => $"{b.Name}:{b.Type}")); - var outputBindingTypes = string.Join('|', context.FunctionDefinition.OutputBindings.Values.Select(b => $"{b.Name}:{b.Type}")); - - _logger.LogInformation( - "Invocation start function={Function} invocationId={InvocationId} inputBindings=[{InputBindings}] outputBindings=[{OutputBindings}]", - name, context.InvocationId, inputBindingTypes, outputBindingTypes); - - var start = TimeProvider.System.GetTimestamp(); - - try - { - await next(context); - - var elapsedMs = TimeProvider.System.GetElapsedTime(start).TotalMilliseconds; - _logger.LogInformation( - "Invocation success function={Function} invocationId={InvocationId} durationMs={DurationMs}", - name, context.InvocationId, elapsedMs); - } - catch (Exception ex) - { - var elapsedMs = TimeProvider.System.GetElapsedTime(start).TotalMilliseconds; - _logger.LogError(ex, - "Invocation failed function={Function} invocationId={InvocationId} durationMs={DurationMs} exceptionType={ExceptionType}", - name, context.InvocationId, elapsedMs, ex.GetType().FullName); - throw; - } - } -} diff --git a/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs b/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs deleted file mode 100644 index d654515..0000000 --- a/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Net; -using System.Security.Claims; -using System.Text.Json; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Azure.Functions.Worker.Middleware; -using Microsoft.Extensions.Logging; - -namespace DevBrain.Functions.Auth.Middleware; - -/// -/// Thin worker-middleware adapter over . Only registered for MCP tool -/// trigger invocations (via UseWhen in Program.cs) so the DCR facade HTTP endpoints don't -/// try to gate themselves. -/// -/// -/// The middleware: -/// -/// -/// Pulls the HTTP request out of (MCP tool triggers carry their webhook HTTP request through the worker). -/// Reads the Authorization header and hands it to . -/// On success: writes the into so DocumentTools.GetCallerIdentity sees it unchanged. -/// On failure: short-circuits with a 401 response carrying an RFC 6750 WWW-Authenticate header. -/// -/// -public sealed class McpJwtValidationMiddleware : IFunctionsWorkerMiddleware -{ - private readonly JwtAuthenticator _authenticator; - private readonly ILogger _logger; - - public McpJwtValidationMiddleware(JwtAuthenticator authenticator, ILogger logger) - { - _authenticator = authenticator; - _logger = logger; - } - - public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) - { - var fn = context.FunctionDefinition.Name; - _logger.LogDebug("MCP JWT middleware entering function={Function} invocationId={InvocationId}", fn, context.InvocationId); - - // For MCP tool trigger invocations the standard HTTP accessor returns null — the worker - // sees MCP as a custom trigger type, not an HTTP trigger, so IHttpRequestDataFeature is - // never populated. The MCP extension surfaces the webhook's original HTTP headers via - // FunctionContext.Items["ToolInvocationContext"] → HttpTransport.Headers instead. See - // McpToolContextAccessor remarks for the full chain. - // - // We still try GetHttpRequestDataAsync() first as a defensive fallback: if a future - // version of the MCP extension ever does expose an HttpRequestData, or if this middleware - // somehow runs for a non-MCP HTTP trigger, we want to use that path. - string? authHeader = null; - string headerSource = "none"; - string? accessorFailureReason = null; - - var httpRequest = await TryGetHttpRequestDataAsync(context, fn); - if (httpRequest is not null) - { - authHeader = httpRequest.Headers.TryGetValues("Authorization", out var values) - ? values.FirstOrDefault() - : null; - headerSource = "HttpRequestData"; - } - else - { - // MCP tool trigger path — extract from the tool invocation context. - var mcpHeader = McpToolContextAccessor.TryGetAuthorizationHeader(context.Items, out var failureReason); - if (mcpHeader is not null) - { - authHeader = mcpHeader; - headerSource = "McpToolContext"; - } - else - { - // Capture the reason so we can include it in the downstream rejection log — - // otherwise the accessor's diagnostic signal ("non_http_transport", "tool_context_missing", - // etc.) is lost when the authenticator fires "missing_authorization". - accessorFailureReason = failureReason; - } - } - - _logger.LogDebug( - "MCP JWT middleware resolved auth header function={Function} invocationId={InvocationId} source={Source} hasAuthHeader={HasAuthHeader}", - fn, context.InvocationId, headerSource, !string.IsNullOrEmpty(authHeader)); - - var result = await _authenticator.AuthenticateAsync(authHeader); - if (!result.IsAuthenticated) - { - _logger.LogWarning( - "MCP JWT rejected function={Function} invocationId={InvocationId} error={Error} description={Description} accessorFailureReason={AccessorFailureReason}", - fn, context.InvocationId, result.ErrorCode, result.ErrorDescription, accessorFailureReason ?? "(n/a)"); - - if (httpRequest is not null) - { - // HTTP-backed path: return a proper RFC 6750 bearer challenge. - await WriteUnauthorizedResponseAsync(context, httpRequest, result.ErrorCode!, result.ErrorDescription!); - return; - } - - // MCP tool trigger path: no HttpRequestData to build a 401 from. Throw so the MCP - // extension's executor surfaces the rejection as a JSON-RPC tool-invocation error. - // This is the best available signal at the worker layer — a spec-compliant 401 + - // WWW-Authenticate challenge would require interception at the webhook level, which - // is out of scope for this worker middleware. - throw new UnauthorizedAccessException( - $"MCP JWT validation failed: {result.ErrorCode} — {result.ErrorDescription}"); - } - - // Populate the ClaimsPrincipal feature so DocumentTools.GetCallerIdentity continues to work - // unchanged — it already reads from FunctionContext.Features.Get(). - context.Features.Set(result.Principal); - - var upn = result.Principal?.FindFirst("preferred_username")?.Value; - _logger.LogInformation( - "MCP JWT accepted function={Function} invocationId={InvocationId} upn={Upn}", - fn, context.InvocationId, upn); - - await next(context); - } - - private async Task TryGetHttpRequestDataAsync(FunctionContext context, string functionName) - { - try - { - return await context.GetHttpRequestDataAsync(); - } - catch (OperationCanceledException) - { - // Never swallow cancellation — let it propagate so the host can shut the invocation - // down cleanly. Debug-level swallowing of this would delay timeout/shutdown handling. - throw; - } - catch (Exception ex) - { - // Non-HTTP-backed invocations (including MCP tool triggers) may throw or return null - // here depending on the SDK version. Log at Debug because this is the expected path - // for MCP triggers, not an error. - _logger.LogDebug(ex, - "MCP JWT middleware: GetHttpRequestDataAsync threw function={Function} — expected for MCP tool triggers, falling back to MCP tool context accessor", - functionName); - return null; - } - } - - private static async Task WriteUnauthorizedResponseAsync(FunctionContext context, HttpRequestData request, string error, string description) - { - var response = request.CreateResponse(HttpStatusCode.Unauthorized); - // RFC 6750 §3 — Bearer challenge with error details. - response.Headers.Add("WWW-Authenticate", $"Bearer error=\"{error}\", error_description=\"{description.Replace("\"", "'")}\""); - response.Headers.Add("Content-Type", "application/json; charset=utf-8"); - await JsonSerializer.SerializeAsync(response.Body, new { error, error_description = description }); - - // Short-circuit the pipeline by setting the invocation result directly. - var invocationResult = context.GetInvocationResult(); - invocationResult.Value = response; - } -} diff --git a/src/DevBrain.Functions/Auth/Middleware/McpToolContextAccessor.cs b/src/DevBrain.Functions/Auth/Middleware/McpToolContextAccessor.cs deleted file mode 100644 index 553583c..0000000 --- a/src/DevBrain.Functions/Auth/Middleware/McpToolContextAccessor.cs +++ /dev/null @@ -1,125 +0,0 @@ -using Microsoft.Azure.Functions.Worker.Extensions.Mcp; - -namespace DevBrain.Functions.Auth.Middleware; - -/// -/// Pure helper that walks the MCP extension's worker-side context surface to extract HTTP headers -/// from a tool trigger invocation. Held separate from so -/// the extraction logic can be unit-tested without constructing a . -/// -/// -/// Why this exists: for MCP tool trigger invocations on the isolated worker, the standard -/// FunctionContext.GetHttpRequestDataAsync() extension method returns null — the -/// worker sees MCP as a custom trigger type, not an HTTP trigger, so IHttpRequestDataFeature -/// is never populated. The HTTP request that backed the webhook invocation at -/// /runtime/webhooks/mcp reaches the worker via the MCP-specific channel described below, -/// not via the HTTP feature set. This was the root cause of the v1.6 post-deploy bug where -/// threw on every tool call. -/// -/// -/// How the MCP extension exposes HTTP headers (from reading Azure/azure-functions-mcp-extension): -/// -/// The host-side extension (Microsoft.Azure.Functions.Extensions.Mcp) captures -/// HttpContext.Request.Headers at webhook receive time and stores them in a -/// Transport object on the ToolInvocationContext, then serializes the whole -/// thing into BindingContext.BindingData. -/// The worker-side extension (Microsoft.Azure.Functions.Worker.Extensions.Mcp) -/// registers FunctionsMcpContextMiddleware automatically via its extension startup. -/// That middleware deserializes the tool invocation context and places it in -/// under the literal -/// key "ToolInvocationContext". -/// The deserialized is polymorphic; for -/// HTTP-originated invocations it's an subtype carrying a -/// case-insensitive Headers dictionary with the original webhook headers. -/// -/// -/// -/// The worker SDK exposes as -/// the public API for step 3. Step 2 uses an lookup against -/// the literal key "ToolInvocationContext"; the matching Constants class in the -/// extension is internal, so we repeat the literal here with a comment. The key string is -/// effectively part of the public contract between -/// FunctionsMcpContextMiddleware (writer) and -/// ToolInvocationContextConverter (the extension's own reader) — changing it would break -/// the extension's own parameter binding path. -/// -/// -/// -/// Ordering: FunctionsMcpContextMiddleware runs before any user-registered -/// middleware, so by the time executes the item is -/// already present. If it's ever absent (e.g., the MCP extension doesn't run for a specific -/// invocation), the helper returns null and the caller decides how to fail. -/// -/// -internal static class McpToolContextAccessor -{ - /// - /// Well-known key under which FunctionsMcpContextMiddleware stores the deserialized - /// . Mirrors - /// Microsoft.Azure.Functions.Worker.Extensions.Mcp.Constants.ToolInvocationContextKey, - /// which is internal in the extension — the literal is duplicated here intentionally. - /// See class remarks. - /// - public const string ToolInvocationContextItemKey = "ToolInvocationContext"; - - /// - /// Attempts to read the Authorization header from the HTTP transport attached to the - /// current MCP tool invocation. - /// - /// - /// The dictionary. The Worker SDK types this as - /// of to , - /// so this parameter matches that shape exactly. Taken as a generic dictionary so the method - /// is unit-testable without constructing a real . - /// - /// - /// On null return, a short machine-parsable reason string indicating which step of the walk - /// failed. Intended for middleware logging, not for end users. - /// - /// - /// The full Authorization header value (e.g., "Bearer eyJ...") if all four - /// steps succeed: - /// - /// The items dictionary has the "ToolInvocationContext" key. - /// The value is a . - /// Its is an . - /// The transport's dictionary contains the - /// Authorization key (case-insensitive per 's - /// comparer). - /// - /// Returns null if any step fails; indicates which. - /// - public static string? TryGetAuthorizationHeader( - IDictionary items, - out string failureReason) - { - ArgumentNullException.ThrowIfNull(items); - - if (!items.TryGetValue(ToolInvocationContextItemKey, out var raw)) - { - failureReason = "tool_context_missing"; - return null; - } - - if (raw is not ToolInvocationContext toolContext) - { - failureReason = $"tool_context_wrong_type:{raw?.GetType().FullName ?? "null"}"; - return null; - } - - if (!toolContext.TryGetHttpTransport(out var httpTransport) || httpTransport is null) - { - failureReason = $"non_http_transport:{toolContext.Transport?.Name ?? "null"}"; - return null; - } - - if (!httpTransport.Headers.TryGetValue("Authorization", out var authHeader)) - { - failureReason = "authorization_header_missing"; - return null; - } - - failureReason = string.Empty; - return authHeader; - } -} diff --git a/src/DevBrain.Functions/DevBrain.Functions.csproj b/src/DevBrain.Functions/DevBrain.Functions.csproj deleted file mode 100644 index bdbd7e5..0000000 --- a/src/DevBrain.Functions/DevBrain.Functions.csproj +++ /dev/null @@ -1,43 +0,0 @@ - - - - Exe - v4 - 6221c643-6ba8-4163-98a2-2241c8ddd211 - 1.9.0 - - - - - - - - - - - - - - - - - - - - - - - - - PreserveNewest - - - - - - - - - diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs deleted file mode 100644 index d842967..0000000 --- a/src/DevBrain.Functions/Program.cs +++ /dev/null @@ -1,227 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using Azure.Core; -using Azure.Identity; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Middleware; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Services; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Azure.Cosmos; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; - -var builder = FunctionsApplication.CreateBuilder(args); - -// ---------------- Application Insights (v1.6 post-deploy logging work) ---------------- -// -// The isolated worker does NOT wire AI telemetry by default, even when -// APPLICATIONINSIGHTS_CONNECTION_STRING is in app settings. Without these two calls the worker's -// ILogger writes go to stdout (which Flex Consumption doesn't surface) and the Functions host -// sees nothing. See: -// https://learn.microsoft.com/azure/azure-functions/dotnet-isolated-process-guide#application-insights -// -// AddApplicationInsightsTelemetryWorkerService() stands up the TelemetryClient and the default -// logger provider. ConfigureFunctionsApplicationInsights() adds the Functions-specific filter -// that attaches function-execution activity as AI request telemetry. -builder.Services - .AddApplicationInsightsTelemetryWorkerService() - .ConfigureFunctionsApplicationInsights(); - -// The AI SDK installs a LoggerFilterRule that silently drops anything below Warning for its -// own provider, regardless of the appsettings/host.json config. This is a well-known gotcha — -// if we don't remove it, DevBrain's Information/Debug logs never reach AI. See: -// https://learn.microsoft.com/azure/azure-monitor/app/worker-service#logging -builder.Logging.Services.Configure(options => -{ - var defaultRule = options.Rules.FirstOrDefault(r => - r.ProviderName == "Microsoft.Extensions.Logging.ApplicationInsights.ApplicationInsightsLoggerProvider"); - if (defaultRule is not null) - { - options.Rules.Remove(defaultRule); - } -}); - -// Startup fail-fast: every OAuth config value is required. Missing values would surface as 500s -// at first traffic; prefer a loud refusal to start. Single-tenant tenant GUID enforcement lives -// inside DevBrainJwtIssuer and EntraOAuthClient — those throw if TenantId isn't a GUID. -var startupConfig = builder.Configuration; -EnsureConfig(startupConfig, "CosmosDb:AccountEndpoint"); -EnsureConfig(startupConfig, "OAuth:BaseUrl"); -EnsureConfig(startupConfig, "OAuth:JwtSigningSecret"); -EnsureConfig(startupConfig, "OAuth:EntraTenantId"); -EnsureConfig(startupConfig, "OAuth:EntraClientId"); -EnsureConfig(startupConfig, "OAuth:EntraClientSecret"); -EnsureConfig(startupConfig, "DataProtection:BlobUri"); -EnsureConfig(startupConfig, "DataProtection:KeyVaultKeyUri"); - -var tokenHandlerOptions = BuildTokenHandlerOptions(startupConfig); -builder.Services.AddSingleton(tokenHandlerOptions); - -builder.Services.AddSingleton(sp => -{ - var configuration = sp.GetRequiredService(); - var endpoint = configuration["CosmosDb:AccountEndpoint"]!; - return new CosmosClient(endpoint, (TokenCredential)new DefaultAzureCredential(), new CosmosClientOptions - { - UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions() - }); -}); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// ---------------- OAuth DCR facade (v1.6) ---------------- - -builder.Services.AddSingleton(TimeProvider.System); - -// Data Protection — key ring persisted to the existing storage account, protected by a key in -// the existing Key Vault. The Function MI already has Storage Blob Data Owner on the storage -// account and Key Vault Crypto User on the vault (both from v1.6 step 7 infra), so no new role -// assignments are required beyond what the Bicep already provisions. -// -// Used exclusively by the upstream token protector (DevBrain.OAuth.UpstreamToken purpose). JWT -// signing intentionally does NOT go through Data Protection — it uses a plain Key Vault HMAC -// secret (OAuth:JwtSigningSecret), because Data Protection's API is Protect/Unprotect and is not -// appropriate for HMAC signing key derivation. See sprint §"Scope decisions". -builder.Services - .AddDataProtection() - .SetApplicationName("DevBrain") - .PersistKeysToAzureBlobStorage(new Uri(builder.Configuration["DataProtection:BlobUri"]!), new DefaultAzureCredential()) - .ProtectKeysWithAzureKeyVault(new Uri(builder.Configuration["DataProtection:KeyVaultKeyUri"]!), new DefaultAzureCredential()); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// JWT issuer — signing secret + issuer + audience + tenant baked at construction. -builder.Services.AddSingleton(sp => -{ - var config = sp.GetRequiredService(); - var baseUrl = config["OAuth:BaseUrl"]!.TrimEnd('/'); - var options = new DevBrainJwtIssuerOptions - { - SigningSecret = config["OAuth:JwtSigningSecret"]!, - Issuer = baseUrl, - // CVE-2025-69196 guard: audience MUST be the webhook URL, not the base URL. - // The MCP extension's built-in webhook auth is disabled via - // host.json extensions.mcp.system.webhookAuthorizationLevel = "anonymous" — - // our worker middleware (McpJwtValidationMiddleware) is the sole gate. - Audience = $"{baseUrl}/runtime/webhooks/mcp", - TenantId = config["OAuth:EntraTenantId"]!, - }; - return new DevBrainJwtIssuer(options, sp.GetRequiredService()); -}); - -// Upstream Entra client — typed HttpClient. -builder.Services.AddHttpClient().ConfigureHttpClient((sp, _) => { /* HttpClient config set inside the client */ }); -builder.Services.AddSingleton(sp => -{ - var config = sp.GetRequiredService(); - return new EntraOAuthClientOptions - { - TenantId = config["OAuth:EntraTenantId"]!, - ClientId = config["OAuth:EntraClientId"]!, - ClientSecret = config["OAuth:EntraClientSecret"]!, - RedirectUri = $"{config["OAuth:BaseUrl"]!.TrimEnd('/')}/callback", - Scope = config["OAuth:EntraScope"] ?? "openid profile offline_access", - }; -}); - -// OpenID Connect discovery for id_token signature validation. ConfigurationManager defaults: -// 24-hour auto-refresh, 5-minute last-known-good retention, 30-second minimum refresh interval. -// Caching lives inside the manager; we treat it as a singleton so all EntraOAuthClient instances -// share the same JWKS cache. -builder.Services.AddSingleton>(sp => -{ - var config = sp.GetRequiredService(); - var tenantId = config["OAuth:EntraTenantId"]!; - var metadataAddress = $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration"; - return new ConfigurationManager( - metadataAddress, - new OpenIdConnectConfigurationRetriever(), - new HttpDocumentRetriever { RequireHttps = true }); -}); - -// DCR facade handlers. -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// JWT authenticator + middleware. The middleware is registered on MCP tool triggers only — the -// DCR facade HTTP endpoints are anonymous so clients can register/authorize/token without -// presenting a credential they don't yet have. -builder.Services.AddSingleton(sp => -{ - var config = sp.GetRequiredService(); - return new JwtAuthenticatorOptions { ExpectedTenantId = config["OAuth:EntraTenantId"]! }; -}); -builder.Services.AddSingleton(); - -// Diagnostic pass-through middleware — runs unconditionally for every invocation, logs function -// name + binding types + outcome. This is how we surface the actual runtime binding-type string -// emitted by the MCP extension (v1.6 post-deploy investigation). Registered FIRST so it sees -// every invocation before any conditional middleware runs. -builder.UseMiddleware(); - -builder.UseWhen(ctx => - ctx.FunctionDefinition.InputBindings.Values.Any(b => b.Type == "mcpToolTrigger")); - -var host = builder.Build(); -var startupLogger = host.Services.GetRequiredService().CreateLogger("DevBrain.Startup"); -startupLogger.LogInformation( - "OAuth token windows configured accessTokenLifetimeMinutes={AccessTokenLifetimeMinutes} refreshReplayLifetimeMinutes={RefreshReplayLifetimeMinutes}", - (int)tokenHandlerOptions.AccessTokenLifetime.TotalMinutes, - (int)tokenHandlerOptions.RefreshReplayLifetime.TotalMinutes); - -host.Run(); - -static void EnsureConfig(IConfiguration config, string key) -{ - if (string.IsNullOrWhiteSpace(config[key])) - { - throw new InvalidOperationException($"Required configuration value '{key}' is missing or empty. " + - "Set it via app settings, environment variable (double-underscore form), or local.settings.json."); - } -} - -static TokenHandlerOptions BuildTokenHandlerOptions(IConfiguration config) -{ - var options = new TokenHandlerOptions( - AccessTokenLifetime: TimeSpan.FromMinutes(ReadOptionalWholeMinutes( - config, - "OAuth:AccessTokenLifetimeMinutes", - (int)TokenHandlerOptions.Default.AccessTokenLifetime.TotalMinutes)), - RefreshReplayLifetime: TimeSpan.FromMinutes(ReadOptionalWholeMinutes( - config, - "OAuth:RefreshReplayLifetimeMinutes", - (int)TokenHandlerOptions.Default.RefreshReplayLifetime.TotalMinutes))); - - options.Validate(); - return options; -} - -static int ReadOptionalWholeMinutes(IConfiguration config, string key, int defaultValue) -{ - var value = config[key]; - if (string.IsNullOrWhiteSpace(value)) - { - return defaultValue; - } - - if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) - { - throw new InvalidOperationException( - $"Configuration value '{key}' must be a whole number of minutes."); - } - - return minutes; -} diff --git a/src/DevBrain.Functions/Tools/AdminFunctions.cs b/src/DevBrain.Functions/Tools/AdminFunctions.cs deleted file mode 100644 index 97fd473..0000000 --- a/src/DevBrain.Functions/Tools/AdminFunctions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Net; -using DevBrain.Functions.Services; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; - -namespace DevBrain.Functions.Tools; - -/// -/// Administrative HTTP endpoints that are not exposed as MCP tools. -/// Gated by (requires the Function App master key). -/// -public sealed class AdminFunctions -{ - private readonly IDocumentStore _store; - - public AdminFunctions(IDocumentStore store) - { - _store = store; - } - - /// - /// Re-upserts every document to backfill computed metadata fields (contentHash, - /// contentLength). Idempotent — safe to run multiple times. Not exposed as an - /// MCP tool; invoke via HTTP with the Function App master key. - /// - [Function(nameof(TouchAllDocuments))] - public async Task TouchAllDocuments( - [HttpTrigger(AuthorizationLevel.Function, "post", Route = "ops/touch")] HttpRequestData req) - { - var touched = await _store.TouchAllAsync(); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new - { - touched, - message = $"Re-upserted {touched} document(s). contentHash and contentLength are now populated." - }); - return response; - } -} diff --git a/src/DevBrain.Functions/host.json b/src/DevBrain.Functions/host.json deleted file mode 100644 index a8d63d7..0000000 --- a/src/DevBrain.Functions/host.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "2.0", - "logging": { - "logLevel": { - "default": "Information", - "Host.Results": "Information", - "Function": "Information", - "Host.Aggregator": "Information", - "DevBrain": "Debug", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - }, - "applicationInsights": { - "samplingSettings": { - "isEnabled": false - }, - "enableDependencyTracking": true, - "enableLiveMetrics": false - } - }, - "extensions": { - "http": { - "routePrefix": "" - }, - "mcp": { - "instructions": "DevBrain is a developer knowledge store. Use UpsertDocument to save sprint docs, architecture notes, or project state. Use GetDocument to retrieve by key. Use ListDocuments to browse what's stored. Use SearchDocuments to find content by keyword. Use DeleteDocument to remove a document. Use AppendDocument for append-only logs (session history, decision logs) where re-emitting the whole body is wasteful. Use UpsertDocumentChunked when a document is too large to emit in a single turn. Use GetDocumentMetadata to check if a document exists and inspect its size, hash, and timestamps without reading the full content. Use CompareDocument to check whether candidate content matches a stored document (by raw content or SHA-256 hash) before deciding whether to import or update. Use PreviewEditDocument and ApplyEditDocument to make safe exact-text edits without re-emitting the whole document. Use EditTags to add and/or remove tags on a document without re-emitting its content. Keys use colon as separator: state:current, sprint:my-feature, ref:notes. Writes require colon keys.", - "serverName": "DevBrain", - "serverVersion": "1.9.0", - "system": { - "webhookAuthorizationLevel": "anonymous" - } - } - } -} diff --git a/src/DevBrain.Functions/local.settings.json.example b/src/DevBrain.Functions/local.settings.json.example deleted file mode 100644 index 841b213..0000000 --- a/src/DevBrain.Functions/local.settings.json.example +++ /dev/null @@ -1,10 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", - "CosmosDb__AccountEndpoint": "https://your-cosmos-account.documents.azure.com:443/", - "CosmosDb__DatabaseName": "devbrain", - "CosmosDb__ContainerName": "documents" - } -} diff --git a/src/DevBrain.Server/Auth/OAuthEndpoints.cs b/src/DevBrain.Server/Auth/OAuthEndpoints.cs new file mode 100644 index 0000000..dee41c4 --- /dev/null +++ b/src/DevBrain.Server/Auth/OAuthEndpoints.cs @@ -0,0 +1,201 @@ +using System.Text.Json.Serialization; +using DevBrain.Core.Auth.DcrFacade; + +namespace DevBrain.Server.Auth; + +public static class OAuthEndpoints +{ + public static IEndpointRouteBuilder MapDevBrainOAuth( + this IEndpointRouteBuilder endpoints, + string rateLimitPolicy) + { + endpoints.MapGet("/.well-known/oauth-authorization-server", AuthorizationServerMetadata) + .RequireRateLimiting(rateLimitPolicy); + endpoints.MapGet("/.well-known/oauth-protected-resource", ProtectedResourceMetadata) + .RequireRateLimiting(rateLimitPolicy); + endpoints.MapPost("/register", RegisterAsync) + .RequireRateLimiting(rateLimitPolicy); + endpoints.MapGet("/authorize", AuthorizeAsync) + .RequireRateLimiting(rateLimitPolicy); + endpoints.MapGet("/callback", CallbackAsync) + .RequireRateLimiting(rateLimitPolicy); + endpoints.MapPost("/token", TokenAsync) + .RequireRateLimiting(rateLimitPolicy); + return endpoints; + } + + private static IResult AuthorizationServerMetadata(IConfiguration configuration) + { + var baseUrl = BaseUrl(configuration); + return CacheableJson(new AuthorizationServerMetadataResponse( + Issuer: baseUrl, + RegistrationEndpoint: $"{baseUrl}/register", + AuthorizationEndpoint: $"{baseUrl}/authorize", + TokenEndpoint: $"{baseUrl}/token", + ResponseTypesSupported: ["code"], + GrantTypesSupported: ["authorization_code", "refresh_token"], + CodeChallengeMethodsSupported: ["S256"], + TokenEndpointAuthMethodsSupported: ["none"], + AuthorizationResponseIssParameterSupported: true, + ScopesSupported: ["documents.readwrite"])); + } + + private static IResult ProtectedResourceMetadata(IConfiguration configuration) + { + var baseUrl = BaseUrl(configuration); + return CacheableJson(new ProtectedResourceMetadataResponse( + Resource: $"{baseUrl}/mcp", + AuthorizationServers: [baseUrl], + BearerMethodsSupported: ["header"], + ScopesSupported: ["documents.readwrite"])); + } + + private static async Task RegisterAsync( + RegistrationRequest request, + RegistrationHandler handler) + { + var result = await handler.HandleAsync(request); + return result.IsSuccess + ? Results.Json(new RegistrationResponseDto(result.Response!), statusCode: StatusCodes.Status201Created) + : OAuthError(StatusCodes.Status400BadRequest, result.ErrorCode!, result.ErrorDescription!); + } + + private static async Task AuthorizeAsync( + HttpRequest httpRequest, + AuthorizationHandler handler, + IConfiguration configuration) + { + var query = httpRequest.Query; + var baseUrl = BaseUrl(configuration); + var request = new AuthorizationRequest( + ClientId: query["client_id"].ToString(), + ResponseType: query["response_type"].ToString(), + RedirectUri: query["redirect_uri"].ToString(), + State: NullIfEmpty(query["state"].ToString()), + CodeChallenge: query["code_challenge"].ToString(), + CodeChallengeMethod: NullIfEmpty(query["code_challenge_method"].ToString()) ?? "plain", + Resource: NullIfEmpty(query["resource"].ToString()), + Issuer: baseUrl, + CanonicalResource: $"{baseUrl}/mcp"); + + var result = await handler.HandleAsync(request); + return result.IsSuccess + ? Results.Redirect(result.RedirectTo!.ToString()) + : OAuthError(StatusCodes.Status400BadRequest, result.ErrorCode!, result.ErrorDescription!); + } + + private static async Task CallbackAsync( + HttpRequest httpRequest, + CallbackHandler handler) + { + var query = httpRequest.Query; + var result = await handler.HandleAsync(new CallbackRequest( + Code: NullIfEmpty(query["code"].ToString()), + State: NullIfEmpty(query["state"].ToString()), + Error: NullIfEmpty(query["error"].ToString()), + ErrorDescription: NullIfEmpty(query["error_description"].ToString()))); + + return result.Kind == CallbackResultKind.Redirect + ? Results.Redirect(result.RedirectTo!.ToString()) + : OAuthError(StatusCodes.Status400BadRequest, result.ErrorCode!, result.ErrorDescription!); + } + + private static async Task TokenAsync( + HttpRequest httpRequest, + TokenHandler handler) + { + if (!httpRequest.HasFormContentType) + { + return OAuthError(StatusCodes.Status400BadRequest, "invalid_request", "Content-Type must be application/x-www-form-urlencoded."); + } + + var form = await httpRequest.ReadFormAsync(); + var result = await handler.HandleAsync(new TokenRequest( + GrantType: form["grant_type"].ToString(), + ClientId: NullIfEmpty(form["client_id"].ToString()), + Code: NullIfEmpty(form["code"].ToString()), + CodeVerifier: NullIfEmpty(form["code_verifier"].ToString()), + RedirectUri: NullIfEmpty(form["redirect_uri"].ToString()), + RefreshToken: NullIfEmpty(form["refresh_token"].ToString()), + Resource: NullIfEmpty(form["resource"].ToString()))); + + return result.IsSuccess + ? NoStoreJson(new TokenResponseDto(result.Response!)) + : OAuthError(StatusCodes.Status400BadRequest, result.ErrorCode!, result.ErrorDescription!); + } + + private static IResult CacheableJson(T value) => + new HeaderResult(Results.Json(value), "Cache-Control", "public, max-age=3600"); + + private static IResult NoStoreJson(T value) => + new HeaderResult(Results.Json(value), "Cache-Control", "no-store"); + + private static IResult OAuthError(int statusCode, string code, string description) => + new HeaderResult( + Results.Json(new OAuthErrorResponse(code, description), statusCode: statusCode), + "Cache-Control", + "no-store"); + + private static string BaseUrl(IConfiguration configuration) => + configuration["OAuth:BaseUrl"]!.TrimEnd('/'); + + private static string? NullIfEmpty(string value) => string.IsNullOrEmpty(value) ? null : value; + + private sealed class HeaderResult(IResult inner, string name, string value) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.Headers[name] = value; + await inner.ExecuteAsync(httpContext); + } + } + + internal sealed record AuthorizationServerMetadataResponse( + [property: JsonPropertyName("issuer")] string Issuer, + [property: JsonPropertyName("registration_endpoint")] string RegistrationEndpoint, + [property: JsonPropertyName("authorization_endpoint")] string AuthorizationEndpoint, + [property: JsonPropertyName("token_endpoint")] string TokenEndpoint, + [property: JsonPropertyName("response_types_supported")] string[] ResponseTypesSupported, + [property: JsonPropertyName("grant_types_supported")] string[] GrantTypesSupported, + [property: JsonPropertyName("code_challenge_methods_supported")] string[] CodeChallengeMethodsSupported, + [property: JsonPropertyName("token_endpoint_auth_methods_supported")] string[] TokenEndpointAuthMethodsSupported, + [property: JsonPropertyName("authorization_response_iss_parameter_supported")] bool AuthorizationResponseIssParameterSupported, + [property: JsonPropertyName("scopes_supported")] string[] ScopesSupported); + + internal sealed record ProtectedResourceMetadataResponse( + [property: JsonPropertyName("resource")] string Resource, + [property: JsonPropertyName("authorization_servers")] string[] AuthorizationServers, + [property: JsonPropertyName("bearer_methods_supported")] string[] BearerMethodsSupported, + [property: JsonPropertyName("scopes_supported")] string[] ScopesSupported); + + internal sealed record RegistrationResponseDto( + [property: JsonPropertyName("client_id")] string ClientId, + [property: JsonPropertyName("client_id_issued_at")] long ClientIdIssuedAt, + [property: JsonPropertyName("client_name")] string? ClientName, + [property: JsonPropertyName("redirect_uris")] string[] RedirectUris, + [property: JsonPropertyName("application_type")] string ApplicationType, + [property: JsonPropertyName("token_endpoint_auth_method")] string TokenEndpointAuthMethod) + { + public RegistrationResponseDto(RegistrationResponse response) + : this(response.ClientId, response.ClientIdIssuedAt, response.ClientName, response.RedirectUris, response.ApplicationType, response.TokenEndpointAuthMethod) + { + } + } + + internal sealed record TokenResponseDto( + [property: JsonPropertyName("access_token")] string AccessToken, + [property: JsonPropertyName("token_type")] string TokenType, + [property: JsonPropertyName("expires_in")] int ExpiresIn, + [property: JsonPropertyName("refresh_token")] string RefreshToken, + [property: JsonPropertyName("scope")] string Scope) + { + public TokenResponseDto(TokenResponse response) + : this(response.AccessToken, response.TokenType, response.ExpiresIn, response.RefreshToken, response.Scope) + { + } + } + + internal sealed record OAuthErrorResponse( + [property: JsonPropertyName("error")] string Error, + [property: JsonPropertyName("error_description")] string ErrorDescription); +} diff --git a/src/DevBrain.Server/Authentication/DevBrainAuthenticationHandler.cs b/src/DevBrain.Server/Authentication/DevBrainAuthenticationHandler.cs new file mode 100644 index 0000000..1d1b7ca --- /dev/null +++ b/src/DevBrain.Server/Authentication/DevBrainAuthenticationHandler.cs @@ -0,0 +1,57 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Encodings.Web; +using DevBrain.Core.Auth.Middleware; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Options; + +namespace DevBrain.Server.Authentication; + +public static class DevBrainAuthenticationDefaults +{ + public const string Scheme = "DevBrainBearer"; + public const string UserPolicy = "DevBrain.User"; +} + +public sealed class DevBrainAuthenticationHandler : AuthenticationHandler +{ + private readonly string _resourceMetadataUri; + + public DevBrainAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IConfiguration configuration) + : base(options, logger, encoder) + { + var baseUrl = (configuration["OAuth:BaseUrl"] ?? string.Empty).TrimEnd('/'); + _resourceMetadataUri = $"{baseUrl}/.well-known/oauth-protected-resource"; + } + + protected override async Task HandleAuthenticateAsync() + { + var authorization = Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authorization)) + { + return AuthenticateResult.NoResult(); + } + + var authenticator = Context.RequestServices.GetRequiredService(); + var result = await authenticator.AuthenticateAsync(authorization); + if (!result.IsAuthenticated || result.Principal is null) + { + return AuthenticateResult.Fail(result.ErrorDescription ?? "Invalid bearer token."); + } + + return AuthenticateResult.Success( + new AuthenticationTicket(result.Principal, DevBrainAuthenticationDefaults.Scheme)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.WWWAuthenticate = + new AuthenticationHeaderValue("Bearer", $"resource_metadata=\"{_resourceMetadataUri}\"").ToString(); + return Task.CompletedTask; + } +} diff --git a/src/DevBrain.Server/DevBrain.Server.csproj b/src/DevBrain.Server/DevBrain.Server.csproj new file mode 100644 index 0000000..6a9d87e --- /dev/null +++ b/src/DevBrain.Server/DevBrain.Server.csproj @@ -0,0 +1,23 @@ + + + + f6dfa005-c972-4c49-bca0-8c9c30e598a8 + + + + + + + + + + + + + + + + + + + diff --git a/src/DevBrain.Server/Dockerfile b/src/DevBrain.Server/Dockerfile new file mode 100644 index 0000000..e285c0c --- /dev/null +++ b/src/DevBrain.Server/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +EXPOSE 8080 +ENV ASPNETCORE_HTTP_PORTS=8080 +USER $APP_UID + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["Directory.Build.props", "."] +COPY ["NuGet.Config", "."] +COPY ["src/DevBrain.Core/DevBrain.Core.csproj", "src/DevBrain.Core/"] +COPY ["src/DevBrain.Server/DevBrain.Server.csproj", "src/DevBrain.Server/"] +RUN dotnet restore "src/DevBrain.Server/DevBrain.Server.csproj" +COPY ["src/DevBrain.Core/", "src/DevBrain.Core/"] +COPY ["src/DevBrain.Server/", "src/DevBrain.Server/"] +RUN dotnet publish "src/DevBrain.Server/DevBrain.Server.csproj" -c Release -o /app/publish --no-restore /p:UseAppHost=false + +FROM runtime AS final +WORKDIR /app +COPY --from=build /app/publish . +ENTRYPOINT ["dotnet", "DevBrain.Server.dll"] diff --git a/src/DevBrain.Server/Program.cs b/src/DevBrain.Server/Program.cs new file mode 100644 index 0000000..7571143 --- /dev/null +++ b/src/DevBrain.Server/Program.cs @@ -0,0 +1,266 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Threading.RateLimiting; +using Azure.Core; +using Azure.Identity; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Middleware; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Services; +using DevBrain.Server.Auth; +using DevBrain.Server.Authentication; +using DevBrain.Server.Tools; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Azure.Cosmos; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using ModelContextProtocol.AspNetCore; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); +var configuration = builder.Configuration; + +EnsureConfig(configuration, "CosmosDb:AccountEndpoint"); +EnsureConfig(configuration, "OAuth:BaseUrl"); +EnsureConfig(configuration, "OAuth:JwtSigningSecret"); +EnsureConfig(configuration, "OAuth:EntraTenantId"); +EnsureConfig(configuration, "OAuth:EntraClientId"); +EnsureConfig(configuration, "OAuth:EntraClientSecret"); +EnsureConfig(configuration, "DataProtection:BlobUri"); +EnsureConfig(configuration, "DataProtection:KeyVaultKeyUri"); + +var maxRequestBodySize = ReadPositiveInt(configuration, "Server:MaxRequestBodySizeBytes", 4 * 1024 * 1024); +builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = maxRequestBodySize); + +var applicationInsightsConnectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; +if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) +{ + builder.Services + .AddOpenTelemetry() + .UseAzureMonitor(options => options.ConnectionString = applicationInsightsConnectionString); +} + +builder.Services.AddProblemDetails(); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddSingleton(TimeProvider.System); + +builder.Services.AddSingleton(sp => +{ + var endpoint = sp.GetRequiredService()["CosmosDb:AccountEndpoint"]!; + return new CosmosClient(endpoint, (TokenCredential)new DefaultAzureCredential(), new CosmosClientOptions + { + UseSystemTextJsonSerializerWithOptions = new JsonSerializerOptions(), + }); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +builder.Services + .AddDataProtection() + .SetApplicationName("DevBrain.v2") + .PersistKeysToAzureBlobStorage(new Uri(configuration["DataProtection:BlobUri"]!), new DefaultAzureCredential()) + .ProtectKeysWithAzureKeyVault(new Uri(configuration["DataProtection:KeyVaultKeyUri"]!), new DefaultAzureCredential()); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var tokenHandlerOptions = new TokenHandlerOptions( + AccessTokenLifetime: TimeSpan.FromMinutes(ReadWholeMinutes( + configuration, + "OAuth:AccessTokenLifetimeMinutes", + (int)TokenHandlerOptions.Default.AccessTokenLifetime.TotalMinutes)), + RefreshReplayLifetime: TimeSpan.FromMinutes(ReadWholeMinutes( + configuration, + "OAuth:RefreshReplayLifetimeMinutes", + (int)TokenHandlerOptions.Default.RefreshReplayLifetime.TotalMinutes))); +tokenHandlerOptions.Validate(); +builder.Services.AddSingleton(tokenHandlerOptions); + +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + var baseUrl = config["OAuth:BaseUrl"]!.TrimEnd('/'); + return new DevBrainJwtIssuer( + new DevBrainJwtIssuerOptions + { + SigningSecret = config["OAuth:JwtSigningSecret"]!, + Issuer = baseUrl, + Audience = $"{baseUrl}/mcp", + TenantId = config["OAuth:EntraTenantId"]!, + }, + sp.GetRequiredService()); +}); + +builder.Services.AddSingleton(sp => +{ + var config = sp.GetRequiredService(); + return new EntraOAuthClientOptions + { + TenantId = config["OAuth:EntraTenantId"]!, + ClientId = config["OAuth:EntraClientId"]!, + ClientSecret = config["OAuth:EntraClientSecret"]!, + RedirectUri = $"{config["OAuth:BaseUrl"]!.TrimEnd('/')}/callback", + Scope = config["OAuth:EntraScope"] ?? "openid profile offline_access", + }; +}); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton>(sp => +{ + var tenantId = sp.GetRequiredService()["OAuth:EntraTenantId"]!; + return new ConfigurationManager( + $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), + new HttpDocumentRetriever { RequireHttps = true }); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => new JwtAuthenticatorOptions +{ + ExpectedTenantId = sp.GetRequiredService()["OAuth:EntraTenantId"]!, +}); +builder.Services.AddSingleton(); + +builder.Services + .AddAuthentication(DevBrainAuthenticationDefaults.Scheme) + .AddScheme( + DevBrainAuthenticationDefaults.Scheme, + _ => { }); +builder.Services.AddAuthorization(options => +{ + options.AddPolicy( + DevBrainAuthenticationDefaults.UserPolicy, + policy => policy + .AddAuthenticationSchemes(DevBrainAuthenticationDefaults.Scheme) + .RequireAuthenticatedUser() + .RequireRole(DevBrainAuthenticationDefaults.UserPolicy)); +}); + +var rateLimitPermitCount = ReadPositiveInt(configuration, "RateLimit:PermitLimit", 120); +var rateLimitWindowSeconds = ReadPositiveInt(configuration, "RateLimit:WindowSeconds", 60); +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy("public", httpContext => RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.User.FindFirst("oid")?.Value + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitPermitCount, + Window = TimeSpan.FromSeconds(rateLimitWindowSeconds), + QueueLimit = 0, + AutoReplenishment = true, + })); +}); + +var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +if (allowedOrigins.Length > 0) +{ + builder.Services.AddCors(options => options.AddPolicy( + "ConfiguredOrigins", + policy => policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod())); +} + +builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation + { + Name = "DevBrain", + Title = "DevBrain", + Version = ServerVersion(), + Description = "Persistent developer knowledge shared across MCP clients.", + }; + options.ServerInstructions = + "Use colon-separated document keys. Use preview/apply for safe exact-text edits and metadata/compare before retrieving or rewriting large documents."; + }) + .WithHttpTransport(options => options.Stateless = true) + .WithTools(); + +var app = builder.Build(); + +app.UseExceptionHandler(); +if (allowedOrigins.Length > 0) +{ + app.UseCors(); +} +app.UseAuthentication(); +app.UseRateLimiter(); +app.UseAuthorization(); + +app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })) + .AllowAnonymous(); +app.MapDevBrainOAuth("public"); + +var mcpEndpoint = app.MapMcp("/mcp") + .RequireAuthorization(DevBrainAuthenticationDefaults.UserPolicy) + .RequireRateLimiting("public"); +if (allowedOrigins.Length > 0) +{ + mcpEndpoint.RequireCors("ConfiguredOrigins"); +} + +app.Logger.LogInformation( + "DevBrain {Version} configured MCP stateless=true accessTokenLifetimeMinutes={AccessTokenLifetimeMinutes} refreshReplayLifetimeMinutes={RefreshReplayLifetimeMinutes}", + ServerVersion(), + (int)tokenHandlerOptions.AccessTokenLifetime.TotalMinutes, + (int)tokenHandlerOptions.RefreshReplayLifetime.TotalMinutes); + +app.Run(); + +static void EnsureConfig(IConfiguration config, string key) +{ + if (string.IsNullOrWhiteSpace(config[key])) + { + throw new InvalidOperationException($"Required configuration value '{key}' is missing or empty."); + } +} + +static int ReadWholeMinutes(IConfiguration config, string key, int defaultValue) +{ + var value = config[key]; + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var minutes)) + { + throw new InvalidOperationException($"Configuration value '{key}' must be a whole number of minutes."); + } + + return minutes; +} + +static int ReadPositiveInt(IConfiguration config, string key, int defaultValue) +{ + var value = config[key]; + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || parsed <= 0) + { + throw new InvalidOperationException($"Configuration value '{key}' must be a positive whole number."); + } + + return parsed; +} + +// The release version lives in Directory.Build.props. InformationalVersion carries a "+" +// suffix when source revision stamping is on, which serverInfo and logs don't need. +static string ServerVersion() => + typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] + ?? "0.0.0"; + +public partial class Program; diff --git a/src/DevBrain.Functions/Tools/DocumentTools.cs b/src/DevBrain.Server/Tools/ServerDocumentTools.cs similarity index 52% rename from src/DevBrain.Functions/Tools/DocumentTools.cs rename to src/DevBrain.Server/Tools/ServerDocumentTools.cs index eacf999..c58349c 100644 --- a/src/DevBrain.Functions/Tools/DocumentTools.cs +++ b/src/DevBrain.Server/Tools/ServerDocumentTools.cs @@ -1,37 +1,38 @@ using System.Text.Json; -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Extensions.Mcp; +using DevBrain.Core.Models; +using DevBrain.Core.Services; +using System.ComponentModel; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; -namespace DevBrain.Functions.Tools; +namespace DevBrain.Server.Tools; -public sealed class DocumentTools +[McpServerToolType] +public sealed class ServerDocumentTools { private readonly IDocumentStore _store; private readonly IDocumentEditService _editService; private readonly ITagEditService _tagEditService; + private readonly IHttpContextAccessor _httpContextAccessor; - public DocumentTools(IDocumentStore store, IDocumentEditService editService, ITagEditService tagEditService) + public ServerDocumentTools( + IDocumentStore store, + IDocumentEditService editService, + ITagEditService tagEditService, + IHttpContextAccessor httpContextAccessor) { _store = store; _editService = editService; _tagEditService = tagEditService; + _httpContextAccessor = httpContextAccessor; } - [Function(nameof(UpsertDocument))] + [McpServerTool(Name = "UpsertDocument", Destructive = true, Idempotent = true), Description("Create or replace a document by key.")] public async Task UpsertDocument( - [McpToolTrigger("UpsertDocument", "Create or replace a document by key.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key (e.g. sprint:license-sync).", isRequired: true)] - string key, - [McpToolProperty("content", "Raw text content of the document.", isRequired: true)] - string content, - [McpToolProperty("tags", "Optional tags for the document.")] - string[]? tags, - [McpToolProperty("project", "Project scope (default: \"default\"). Isolates documents by project.")] - string? project, - FunctionContext functionContext) + [Description("Document key (e.g. sprint:license-sync).")] string key, + [Description("Raw text content of the document.")] string content, + [Description("Optional tags for the document.")] string[]? tags, + [Description("Project scope (default: \"default\"). Isolates documents by project.")] string? project) { var keyError = ValidateWriteKey(key); if (keyError is not null) @@ -41,7 +42,7 @@ public async Task UpsertDocument( try { - var updatedBy = GetCallerIdentity(functionContext); + var updatedBy = GetCallerIdentity(); var resolvedProject = project ?? "default"; var document = new BrainDocument @@ -64,14 +65,10 @@ public async Task UpsertDocument( } } - [Function(nameof(GetDocument))] + [McpServerTool(Name = "GetDocument", ReadOnly = true, Idempotent = true), Description("Retrieve a document by key.")] public async Task GetDocument( - [McpToolTrigger("GetDocument", "Retrieve a document by key.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to retrieve.", isRequired: true)] - string key, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Document key to retrieve.")] string key, + [Description("Project scope (default: \"default\").")] string? project) { var document = await _store.GetAsync(key, project ?? "default"); if (document is null) @@ -82,14 +79,10 @@ public async Task GetDocument( return JsonSerializer.Serialize(document); } - [Function(nameof(GetDocumentMetadata))] + [McpServerTool(Name = "GetDocumentMetadata", ReadOnly = true, Idempotent = true), Description("Retrieve document metadata (key, project, tags, updatedAt, updatedBy, contentHash, contentLength) without the content body. Use to check whether a document exists, its size, and whether it has changed — without consuming tokens on the full content.")] public async Task GetDocumentMetadata( - [McpToolTrigger("GetDocumentMetadata", "Retrieve document metadata (key, project, tags, updatedAt, updatedBy, contentHash, contentLength) without the content body. Use to check whether a document exists, its size, and whether it has changed — without consuming tokens on the full content.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to retrieve metadata for.", isRequired: true)] - string key, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Document key to retrieve metadata for.")] string key, + [Description("Project scope (default: \"default\").")] string? project) { var document = await _store.GetMetadataAsync(key, project ?? "default"); if (document is null) @@ -109,18 +102,12 @@ public async Task GetDocumentMetadata( }); } - [Function(nameof(CompareDocument))] + [McpServerTool(Name = "CompareDocument", ReadOnly = true, Idempotent = true), Description("Compare candidate content against a stored document without retrieving the full body. Accepts either raw content (hashed server-side) or a precomputed SHA-256 hex hash. Returns whether the stored document matches, plus metadata. Use to decide whether an import or sync is needed.")] public async Task CompareDocument( - [McpToolTrigger("CompareDocument", "Compare candidate content against a stored document without retrieving the full body. Accepts either raw content (hashed server-side) or a precomputed SHA-256 hex hash. Returns whether the stored document matches, plus metadata. Use to decide whether an import or sync is needed.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to compare against.", isRequired: true)] - string key, - [McpToolProperty("content", "Candidate content to compare. Server computes its SHA-256 hash. Provide this OR contentHash, not both.")] - string? content, - [McpToolProperty("contentHash", "Precomputed SHA-256 hex hash of the candidate content. Provide this OR content, not both.")] - string? contentHash, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Document key to compare against.")] string key, + [Description("Candidate content to compare. Server computes its SHA-256 hash. Provide this OR contentHash, not both.")] string? content, + [Description("Precomputed SHA-256 hex hash of the candidate content. Provide this OR content, not both.")] string? contentHash, + [Description("Project scope (default: \"default\").")] string? project) { if (content is null && contentHash is null) { @@ -161,22 +148,14 @@ public async Task CompareDocument( }); } - [Function(nameof(PreviewEditDocument))] + [McpServerTool(Name = "PreviewEditDocument", ReadOnly = true, Idempotent = true), Description("Preview an exact text edit without writing. Matches literal text only. Returns match count, preview snippets, and the current content hash to pass into ApplyEditDocument.")] public async Task PreviewEditDocument( - [McpToolTrigger("PreviewEditDocument", "Preview an exact text edit without writing. Matches literal text only. Returns match count, preview snippets, and the current content hash to pass into ApplyEditDocument.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to edit.", isRequired: true)] - string key, - [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)] - string oldText, - [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)] - string newText, - [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; preview refuses ambiguous edits when the actual count differs.")] - int? expectedOccurrences, - [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")] - bool? caseSensitive, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Document key to edit.")] string key, + [Description("Exact literal text to find in the document.")] string oldText, + [Description("Replacement text to substitute for oldText. May be empty to delete the match.")] string newText, + [Description("Expected number of literal matches. Defaults to 1; preview refuses ambiguous edits when the actual count differs.")] int? expectedOccurrences, + [Description("When true, matching is case-sensitive. Defaults to false.")] bool? caseSensitive, + [Description("Project scope (default: \"default\").")] string? project) { if (string.IsNullOrEmpty(oldText)) { @@ -201,25 +180,15 @@ public async Task PreviewEditDocument( return JsonSerializer.Serialize(result); } - [Function(nameof(ApplyEditDocument))] + [McpServerTool(Name = "ApplyEditDocument", Destructive = true, Idempotent = true), Description("Apply an exact text edit after preview. Fails if the document changed since preview, if the match count differs, or if the edit is ambiguous. Matches literal text only.")] public async Task ApplyEditDocument( - [McpToolTrigger("ApplyEditDocument", "Apply an exact text edit after preview. Fails if the document changed since preview, if the match count differs, or if the edit is ambiguous. Matches literal text only.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to edit.", isRequired: true)] - string key, - [McpToolProperty("oldText", "Exact literal text to find in the document.", isRequired: true)] - string oldText, - [McpToolProperty("newText", "Replacement text to substitute for oldText. May be empty to delete the match.", isRequired: true)] - string newText, - [McpToolProperty("expectedContentHash", "Content hash returned by PreviewEditDocument. Apply fails if the stored document no longer matches this hash.", isRequired: true)] - string expectedContentHash, - [McpToolProperty("expectedOccurrences", "Expected number of literal matches. Defaults to 1; apply refuses ambiguous edits when the actual count differs.")] - int? expectedOccurrences, - [McpToolProperty("caseSensitive", "When true, matching is case-sensitive. Defaults to false.")] - bool? caseSensitive, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project, - FunctionContext functionContext) + [Description("Document key to edit.")] string key, + [Description("Exact literal text to find in the document.")] string oldText, + [Description("Replacement text to substitute for oldText. May be empty to delete the match.")] string newText, + [Description("Content hash returned by PreviewEditDocument. Apply fails if the stored document no longer matches this hash.")] string expectedContentHash, + [Description("Expected number of literal matches. Defaults to 1; apply refuses ambiguous edits when the actual count differs.")] int? expectedOccurrences, + [Description("When true, matching is case-sensitive. Defaults to false.")] bool? caseSensitive, + [Description("Project scope (default: \"default\").")] string? project) { if (string.IsNullOrEmpty(oldText)) { @@ -251,19 +220,15 @@ public async Task ApplyEditDocument( expectedOccurrences ?? 1, caseSensitive ?? false, expectedContentHash, - GetCallerIdentity(functionContext)); + GetCallerIdentity()); return JsonSerializer.Serialize(result); } - [Function(nameof(ListDocuments))] + [McpServerTool(Name = "ListDocuments", ReadOnly = true, Idempotent = true), Description("List stored document keys, optionally filtered by prefix. If the project has no matching documents and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")] public async Task ListDocuments( - [McpToolTrigger("ListDocuments", "List stored document keys, optionally filtered by prefix. If the project has no matching documents and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")] - ToolInvocationContext context, - [McpToolProperty("prefix", "Optional key prefix to filter by (e.g. sprint:).")] - string? prefix, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Optional key prefix to filter by (e.g. sprint:).")] string? prefix, + [Description("Project scope (default: \"default\").")] string? project) { var documents = await _store.ListAsync(project ?? "default", prefix); @@ -287,14 +252,10 @@ public async Task ListDocuments( return JsonSerializer.Serialize(projection); } - [Function(nameof(DeleteDocument))] + [McpServerTool(Name = "DeleteDocument", Destructive = true, Idempotent = true), Description("Delete a document by key. Idempotent — deleting a missing key returns a not-found note rather than an error. Project-scoped.")] public async Task DeleteDocument( - [McpToolTrigger("DeleteDocument", "Delete a document by key. Idempotent — deleting a missing key returns a not-found note rather than an error. Project-scoped.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to delete (e.g. sprint:old-notes).", isRequired: true)] - string key, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Document key to delete (e.g. sprint:old-notes).")] string key, + [Description("Project scope (default: \"default\").")] string? project) { try { @@ -317,21 +278,13 @@ public async Task DeleteDocument( } } - [Function(nameof(AppendDocument))] + [McpServerTool(Name = "AppendDocument", Destructive = false, Idempotent = false), Description("Append content to an existing document, or create it if missing. Intended for growing logs (session history, decision logs, audit trails) where UpsertDocument would force the caller to re-emit the entire existing body. Server-side concatenation is atomic from a reader's perspective. Tags are unioned with any existing tags.")] public async Task AppendDocument( - [McpToolTrigger("AppendDocument", "Append content to an existing document, or create it if missing. Intended for growing logs (session history, decision logs, audit trails) where UpsertDocument would force the caller to re-emit the entire existing body. Server-side concatenation is atomic from a reader's perspective. Tags are unioned with any existing tags.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key to append to (e.g. state:history).", isRequired: true)] - string key, - [McpToolProperty("content", "Text to append to the existing document body.", isRequired: true)] - string content, - [McpToolProperty("separator", "Separator inserted between existing content and the new content. Defaults to two newlines.")] - string? separator, - [McpToolProperty("tags", "Optional tags to union into the document's tag set.")] - string[]? tags, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project, - FunctionContext functionContext) + [Description("Document key to append to (e.g. state:history).")] string key, + [Description("Text to append to the existing document body.")] string content, + [Description("Separator inserted between existing content and the new content. Defaults to two newlines.")] string? separator, + [Description("Optional tags to union into the document's tag set.")] string[]? tags, + [Description("Project scope (default: \"default\").")] string? project) { var keyError = ValidateWriteKey(key); if (keyError is not null) @@ -341,7 +294,7 @@ public async Task AppendDocument( try { - var updatedBy = GetCallerIdentity(functionContext); + var updatedBy = GetCallerIdentity(); var resolvedProject = project ?? "default"; var resolvedSeparator = separator ?? "\n\n"; @@ -370,23 +323,14 @@ public async Task AppendDocument( } } - [Function(nameof(UpsertDocumentChunked))] + [McpServerTool(Name = "UpsertDocumentChunked", Destructive = true, Idempotent = false), Description("Upload a document in multiple chunks. Use when a document is too large to emit in a single LLM turn. Call once per chunk with the same key and totalChunks; the final chunk triggers server-side concatenation and a normal upsert. Chunks may arrive out of order. Abandoned uploads expire via TTL.")] public async Task UpsertDocumentChunked( - [McpToolTrigger("UpsertDocumentChunked", "Upload a document in multiple chunks. Use when a document is too large to emit in a single LLM turn. Call once per chunk with the same key and totalChunks; the final chunk triggers server-side concatenation and a normal upsert. Chunks may arrive out of order. Abandoned uploads expire via TTL.")] - ToolInvocationContext context, - [McpToolProperty("key", "Final document key (e.g. ref:long-spec). Must not start with '_staging:'.", isRequired: true)] - string key, - [McpToolProperty("content", "Text content for this chunk.", isRequired: true)] - string content, - [McpToolProperty("chunkIndex", "Zero-based index of this chunk within the upload.", isRequired: true)] - int chunkIndex, - [McpToolProperty("totalChunks", "Total number of chunks in this upload. Must match across all chunks of the same upload.", isRequired: true)] - int totalChunks, - [McpToolProperty("tags", "Optional tags applied to the finalized document.")] - string[]? tags, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project, - FunctionContext functionContext) + [Description("Final document key (e.g. ref:long-spec). Must not start with '_staging:'.")] string key, + [Description("Text content for this chunk.")] string content, + [Description("Zero-based index of this chunk within the upload.")] int chunkIndex, + [Description("Total number of chunks in this upload. Must match across all chunks of the same upload.")] int totalChunks, + [Description("Optional tags applied to the finalized document.")] string[]? tags, + [Description("Project scope (default: \"default\").")] string? project) { var keyError = ValidateWriteKey(key); if (keyError is not null) @@ -411,7 +355,7 @@ public async Task UpsertDocumentChunked( try { - var updatedBy = GetCallerIdentity(functionContext); + var updatedBy = GetCallerIdentity(); var resolvedProject = project ?? "default"; var result = await _store.UpsertChunkAsync( @@ -448,14 +392,10 @@ public async Task UpsertDocumentChunked( } } - [Function(nameof(SearchDocuments))] + [McpServerTool(Name = "SearchDocuments", ReadOnly = true, Idempotent = true), Description("Full-text substring search across document keys and content. If the project has no matches and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")] public async Task SearchDocuments( - [McpToolTrigger("SearchDocuments", "Full-text substring search across document keys and content. If the project has no matches and a similarly-named project exists, a single suggestion entry (key \"_suggestion\") is returned instead.")] - ToolInvocationContext context, - [McpToolProperty("query", "Search term to match against keys and content.", isRequired: true)] - string query, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project) + [Description("Search term to match against keys and content.")] string query, + [Description("Project scope (default: \"default\").")] string? project) { try { @@ -486,19 +426,12 @@ public async Task SearchDocuments( } } - [Function(nameof(EditTags))] + [McpServerTool(Name = "EditTags", Destructive = true, Idempotent = true), Description("Add and/or remove tags on a document without re-emitting its content. Provide 'add' and/or 'remove' as disjoint tag lists; the server applies the diff and records updatedAt/updatedBy. Document content is untouched. A tag present in both 'add' and 'remove' is rejected.")] public async Task EditTags( - [McpToolTrigger("EditTags", "Add and/or remove tags on a document without re-emitting its content. Provide 'add' and/or 'remove' as disjoint tag lists; the server applies the diff and records updatedAt/updatedBy. Document content is untouched. A tag present in both 'add' and 'remove' is rejected.")] - ToolInvocationContext context, - [McpToolProperty("key", "Document key whose tags to edit.", isRequired: true)] - string key, - [McpToolProperty("add", "Tags to add. Already-present tags are kept as-is (no duplicates).")] - string[]? add, - [McpToolProperty("remove", "Tags to remove. Absent tags are ignored (idempotent).")] - string[]? remove, - [McpToolProperty("project", "Project scope (default: \"default\").")] - string? project, - FunctionContext functionContext) + [Description("Document key whose tags to edit.")] string key, + [Description("Tags to add. Already-present tags are kept as-is (no duplicates).")] string[]? add, + [Description("Tags to remove. Absent tags are ignored (idempotent).")] string[]? remove, + [Description("Project scope (default: \"default\").")] string? project) { var keyError = ValidateWriteKey(key); if (keyError is not null) @@ -513,7 +446,7 @@ public async Task EditTags( project ?? "default", add ?? [], remove ?? [], - GetCallerIdentity(functionContext)); + GetCallerIdentity()); return JsonSerializer.Serialize(result); } @@ -541,10 +474,9 @@ public async Task EditTags( return $"Keys must use ':' as separator. Got '{key}' — did you mean '{suggested}'?"; } - private static string GetCallerIdentity(FunctionContext functionContext) + private string GetCallerIdentity() { - var features = functionContext.Features; - var claimsPrincipal = features.Get(); + var claimsPrincipal = _httpContextAccessor.HttpContext?.User; if (claimsPrincipal?.Identity?.IsAuthenticated == true) { diff --git a/src/DevBrain.Server/appsettings.json b/src/DevBrain.Server/appsettings.json new file mode 100644 index 0000000..abeca3b --- /dev/null +++ b/src/DevBrain.Server/appsettings.json @@ -0,0 +1,30 @@ +{ + "AllowedHosts": "localhost;127.0.0.1", + "CosmosDb": { + "DatabaseName": "devbrain", + "ContainerName": "documents", + "OAuthContainerName": "oauth_state", + "OAuthKeyPrefix": "v2:" + }, + "OAuth": { + "EntraScope": "openid profile offline_access", + "AccessTokenLifetimeMinutes": 10, + "RefreshReplayLifetimeMinutes": 5 + }, + "RateLimit": { + "PermitLimit": 120, + "WindowSeconds": 60 + }, + "Server": { + "MaxRequestBodySizeBytes": 4194304 + }, + "Cors": { + "AllowedOrigins": [] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/DevBrain.Functions.Tests/Auth/Crypto/PkceTests.cs b/tests/DevBrain.Core.Tests/Auth/Crypto/PkceTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Auth/Crypto/PkceTests.cs rename to tests/DevBrain.Core.Tests/Auth/Crypto/PkceTests.cs index 859d501..0abf3f9 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Crypto/PkceTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Crypto/PkceTests.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.Crypto; +using DevBrain.Core.Auth.Crypto; -namespace DevBrain.Functions.Tests.Auth.Crypto; +namespace DevBrain.Core.Tests.Auth.Crypto; /// /// Unit tests for the PKCE primitives. The endpoint-level downgrade test (acceptance gate #2) diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs b/tests/DevBrain.Core.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs similarity index 76% rename from tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs rename to tests/DevBrain.Core.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs index 68efff2..227433b 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs @@ -1,11 +1,11 @@ -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.DcrFacade; +namespace DevBrain.Core.Tests.Auth.DcrFacade; public sealed class AuthorizationHandlerTests { @@ -13,13 +13,16 @@ public sealed class AuthorizationHandlerTests private const string ClientId = "test-client-id"; private const string ValidRedirect = "https://localhost:8000/callback"; private const string ClientChallenge = "VGhpcy1pcy1hLWZha2UtY29kZS1jaGFsbGVuZ2UtZm9yLXRlc3Rpbmc"; // 43+ chars + private const string Issuer = "https://devbrain.example.com"; + private const string Resource = "https://devbrain.example.com/mcp"; - private static async Task<(AuthorizationHandler handler, FakeOAuthStateStore store, StubUpstream upstream)> CreateWithRegisteredClientAsync() + private static async Task<(AuthorizationHandler handler, FakeOAuthStateStore store, StubUpstream upstream)> CreateWithRegisteredClientAsync( + RecordingLogger? logger = null) { var clock = new FakeTimeProvider(Epoch); var store = new FakeOAuthStateStore(clock); var upstream = new StubUpstream(); - var handler = new AuthorizationHandler(store, upstream, clock); + var handler = new AuthorizationHandler(store, upstream, clock, logger); await store.SaveClientAsync(new RegisteredClient { @@ -37,7 +40,10 @@ await store.SaveClientAsync(new RegisteredClient RedirectUri: ValidRedirect, State: "client-state-xyz", CodeChallenge: ClientChallenge, - CodeChallengeMethod: "S256"); + CodeChallengeMethod: "S256", + Resource: Resource, + Issuer: Issuer, + CanonicalResource: Resource); [Fact] public async Task ValidRequest_PersistsTransaction_AndReturnsUpstreamRedirect() @@ -60,6 +66,8 @@ public async Task ValidRequest_PersistsTransaction_AndReturnsUpstreamRedirect() Assert.Equal(ClientChallenge, txn.ClientCodeChallenge); // client's challenge stored Assert.NotEqual(ClientChallenge, txn.UpstreamPkceVerifier); // upstream verifier is independent Assert.Equal("client-state-xyz", txn.ClientState); + Assert.Equal(Resource, txn.Resource); + Assert.Equal(Issuer, txn.Issuer); } [Theory] @@ -86,6 +94,26 @@ public async Task WrongResponseType_ReturnsError() Assert.Equal("unsupported_response_type", result.ErrorCode); } + [Fact] + public async Task Diagnostics_DoNotRenderRawAuthorizationValues() + { + var logger = new RecordingLogger(); + var (handler, _, _) = await CreateWithRegisteredClientAsync(logger); + const string maliciousResponseType = "token\r\nFORGED-AUTHORIZATION-LOG"; + + var result = await handler.HandleAsync(ValidRequest() with { ResponseType = maliciousResponseType }); + + Assert.False(result.IsSuccess); + Assert.Equal("unsupported_response_type", result.ErrorCode); + Assert.NotEmpty(logger.Messages); + Assert.All(logger.Messages, message => + { + Assert.DoesNotContain(maliciousResponseType, message, StringComparison.Ordinal); + Assert.DoesNotContain('\r', message); + Assert.DoesNotContain('\n', message); + }); + } + [Fact] public async Task MissingCodeChallenge_ReturnsError() { @@ -145,6 +173,20 @@ public async Task RedirectUri_ExactMatchOnly_NoSubstringMatches() Assert.False(prefix.IsSuccess); } + [Fact] + public async Task ResourceForDifferentServer_IsRejected() + { + var (handler, _, _) = await CreateWithRegisteredClientAsync(); + + var result = await handler.HandleAsync(ValidRequest() with + { + Resource = "https://other.example.com/mcp", + }); + + Assert.False(result.IsSuccess); + Assert.Equal("invalid_target", result.ErrorCode); + } + // ----- test double for IUpstreamOAuthClient that records what was passed to BuildAuthorizeUri ----- private sealed class StubUpstream : IUpstreamOAuthClient { diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs b/tests/DevBrain.Core.Tests/Auth/DcrFacade/CallbackHandlerTests.cs similarity index 82% rename from tests/DevBrain.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs rename to tests/DevBrain.Core.Tests/Auth/DcrFacade/CallbackHandlerTests.cs index 32e022d..b52391c 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/DcrFacade/CallbackHandlerTests.cs @@ -1,12 +1,12 @@ using System.Web; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.DcrFacade; +namespace DevBrain.Core.Tests.Auth.DcrFacade; /// /// Unit tests for . Covers acceptance gate #4 (expired transaction) @@ -21,6 +21,8 @@ public sealed class CallbackHandlerTests private const string UpstreamState = "upstream-state-abc"; private const string ClientChallenge = "client-code-challenge-value-matching-pkce-format"; private const string UpstreamVerifier = "upstream-verifier-from-authorize"; + private const string Issuer = "https://devbrain.example.com"; + private const string Resource = "https://devbrain.example.com/mcp"; private sealed record Harness( CallbackHandler Handler, @@ -28,12 +30,12 @@ private sealed record Harness( FakeUpstreamOAuthClient Upstream, FakeTimeProvider Clock); - private static Harness Create() + private static Harness Create(RecordingLogger? logger = null) { var clock = new FakeTimeProvider(Epoch); var store = new FakeOAuthStateStore(clock); var upstream = new FakeUpstreamOAuthClient(); - var handler = new CallbackHandler(store, upstream, clock); + var handler = new CallbackHandler(store, upstream, clock, logger); return new Harness(handler, store, upstream, clock); } @@ -45,6 +47,8 @@ await h.Store.SaveTransactionAsync(new AuthTransaction ClientId = ClientId, ClientRedirectUri = ClientRedirect, ClientState = ClientState, + Issuer = Issuer, + Resource = Resource, ClientCodeChallenge = ClientChallenge, ClientCodeChallengeMethod = "S256", UpstreamPkceVerifier = UpstreamVerifier, @@ -70,6 +74,7 @@ public async Task HappyPath_ExchangesUpstream_CreatesCodeAndVault_RedirectsToCli var query = HttpUtility.ParseQueryString(result.RedirectTo.Query); Assert.NotNull(query["code"]); Assert.Equal(ClientState, query["state"]); + Assert.Equal(Issuer, query["iss"]); // The DevBrain auth code exists and ties through to the upstream vault. var devbrainCode = query["code"]!; @@ -77,10 +82,12 @@ public async Task HappyPath_ExchangesUpstream_CreatesCodeAndVault_RedirectsToCli Assert.NotNull(redeemed); Assert.Equal(ClientId, redeemed.ClientId); Assert.Equal(ClientChallenge, redeemed.ClientCodeChallenge); + Assert.Equal(Resource, redeemed.Resource); var upstreamRecord = await h.Store.GetUpstreamTokenAsync(redeemed.UpstreamJti); Assert.NotNull(upstreamRecord); Assert.Equal("derek@ignitesolutions.group", upstreamRecord.UserPrincipalName); + Assert.Contains("DevBrain.User", upstreamRecord.Roles); // Upstream was called exactly once with DevBrain's PKCE verifier, not the client's challenge. Assert.Equal(1, h.Upstream.ExchangeCodeCalls); @@ -158,6 +165,33 @@ public async Task UpstreamError_ForwardedToClientRedirect() Assert.Null(await h.Store.GetTransactionAsync(UpstreamState)); } + [Fact] + public async Task Diagnostics_DoNotRenderRawCallbackErrors() + { + var logger = new RecordingLogger(); + var h = Create(logger); + await SeedTransactionAsync(h); + const string maliciousDescription = "User cancelled\r\nFORGED-CALLBACK-LOG"; + + var result = await h.Handler.HandleAsync(new CallbackRequest( + Code: null, + State: UpstreamState, + Error: "access_denied", + ErrorDescription: maliciousDescription)); + + Assert.Equal(CallbackResultKind.Redirect, result.Kind); + Assert.Equal( + maliciousDescription, + HttpUtility.ParseQueryString(result.RedirectTo!.Query)["error_description"]); + Assert.NotEmpty(logger.Messages); + Assert.All(logger.Messages, message => + { + Assert.DoesNotContain(maliciousDescription, message, StringComparison.Ordinal); + Assert.DoesNotContain('\r', message); + Assert.DoesNotContain('\n', message); + }); + } + [Fact] public async Task UpstreamException_ForwardedToClientRedirectAsServerError() { diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs b/tests/DevBrain.Core.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs similarity index 71% rename from tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs rename to tests/DevBrain.Core.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs index 3f31c32..023c98b 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs @@ -1,10 +1,11 @@ using System.Text.Json; using System.Text.Json.Serialization; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Tests.Auth.Services; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Tests.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.DcrFacade; +namespace DevBrain.Core.Tests.Auth.DcrFacade; /// /// Unit tests for . The HTTP adapter (RegisterEndpoint) is a @@ -15,11 +16,12 @@ public sealed class RegistrationHandlerTests { private static readonly DateTimeOffset Epoch = new(2026, 4, 11, 0, 0, 0, TimeSpan.Zero); - private static (RegistrationHandler handler, FakeOAuthStateStore store, FakeTimeProvider clock) Create() + private static (RegistrationHandler handler, FakeOAuthStateStore store, FakeTimeProvider clock) Create( + RecordingLogger? logger = null) { var clock = new FakeTimeProvider(Epoch); var store = new FakeOAuthStateStore(clock); - var handler = new RegistrationHandler(store, clock); + var handler = new RegistrationHandler(store, clock, logger); return (handler, store, clock); } @@ -37,12 +39,14 @@ public async Task ValidRequest_ReturnsClientIdAndPersists() Assert.NotEmpty(result.Response.ClientId); Assert.Equal("Claude Code CLI", result.Response.ClientName); Assert.Equal(["https://localhost:8000/callback"], result.Response.RedirectUris); + Assert.Equal("web", result.Response.ApplicationType); Assert.Equal("none", result.Response.TokenEndpointAuthMethod); Assert.Equal(Epoch.ToUnixTimeSeconds(), result.Response.ClientIdIssuedAt); var stored = await store.GetClientAsync(result.Response.ClientId); Assert.NotNull(stored); Assert.Equal("Claude Code CLI", stored.ClientName); + Assert.Equal("web", stored.ApplicationType); } [Fact] @@ -82,6 +86,71 @@ public async Task InvalidRedirectUriScheme_ReturnsError(string uri) Assert.Equal("invalid_redirect_uri", result.ErrorCode); } + [Fact] + public async Task Diagnostics_DoNotRenderRawRegistrationValues() + { + var logger = new RecordingLogger(); + var (handler, _, _) = Create(logger); + const string maliciousName = "Client\r\nFORGED-REGISTRATION-LOG"; + + var result = await handler.HandleAsync(new RegistrationRequest( + ["javascript:alert(1)\r\nFORGED-REDIRECT-LOG"], + maliciousName)); + + Assert.False(result.IsSuccess); + Assert.Equal("invalid_redirect_uri", result.ErrorCode); + Assert.NotEmpty(logger.Messages); + Assert.All(logger.Messages, message => + { + Assert.DoesNotContain("FORGED", message, StringComparison.Ordinal); + Assert.DoesNotContain('\r', message); + Assert.DoesNotContain('\n', message); + }); + } + + [Fact] + public async Task NativeApplicationType_IsPersisted() + { + var (handler, store, _) = Create(); + + var result = await handler.HandleAsync(new RegistrationRequest( + ["http://127.0.0.1:43123/callback"], + "Desktop Client", + ApplicationType: "native")); + + Assert.True(result.IsSuccess); + Assert.Equal("native", result.Response!.ApplicationType); + Assert.Equal("native", (await store.GetClientAsync(result.Response.ClientId))!.ApplicationType); + } + + [Fact] + public async Task NonLoopbackHttpRedirect_IsRejected() + { + var (handler, _, _) = Create(); + + var result = await handler.HandleAsync(new RegistrationRequest( + ["http://example.com/callback"], + "Unsafe Client", + ApplicationType: "native")); + + Assert.False(result.IsSuccess); + Assert.Equal("invalid_redirect_uri", result.ErrorCode); + } + + [Fact] + public async Task UnknownApplicationType_IsRejected() + { + var (handler, _, _) = Create(); + + var result = await handler.HandleAsync(new RegistrationRequest( + ["https://example.com/callback"], + "Client", + ApplicationType: "service")); + + Assert.False(result.IsSuccess); + Assert.Equal("invalid_client_metadata", result.ErrorCode); + } + [Fact] public async Task SubsequentCalls_ReturnDistinctClientIds() { @@ -112,6 +181,7 @@ public void RegistrationRequest_DeserializesRfc7591SnakeCaseJson() { "redirect_uris": ["https://localhost:8000/callback", "http://localhost:8000/oauth/callback"], "client_name": "Claude Desktop", + "application_type": "native", "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"] @@ -122,6 +192,7 @@ public void RegistrationRequest_DeserializesRfc7591SnakeCaseJson() Assert.NotNull(request); Assert.Equal("Claude Desktop", request.ClientName); + Assert.Equal("native", request.ApplicationType); Assert.NotNull(request.RedirectUris); Assert.Equal(2, request.RedirectUris.Length); Assert.Equal("https://localhost:8000/callback", request.RedirectUris[0]); diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs b/tests/DevBrain.Core.Tests/Auth/DcrFacade/TokenHandlerTests.cs similarity index 73% rename from tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs rename to tests/DevBrain.Core.Tests/Auth/DcrFacade/TokenHandlerTests.cs index 3b0b893..d2eeb77 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/DcrFacade/TokenHandlerTests.cs @@ -1,11 +1,12 @@ -using DevBrain.Functions.Auth.Crypto; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.Auth.Services; +using DevBrain.Core.Auth.Crypto; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.DcrFacade; +namespace DevBrain.Core.Tests.Auth.DcrFacade; /// /// Unit tests for . Directly verifies acceptance gates #2 (PKCE downgrade), @@ -24,12 +25,16 @@ private sealed record Harness( TokenHandler Handler, FakeOAuthStateStore Store, DevBrainJwtIssuer JwtIssuer, + FakeUpstreamOAuthClient Upstream, FakeTimeProvider Clock); - private static Harness Create(TokenHandlerOptions? options = null) + private static Harness Create( + TokenHandlerOptions? options = null, + RecordingLogger? logger = null) { var clock = new FakeTimeProvider(Epoch); var store = new FakeOAuthStateStore(clock); + var upstream = new FakeUpstreamOAuthClient(); var jwtIssuer = new DevBrainJwtIssuer( new DevBrainJwtIssuerOptions { @@ -39,10 +44,14 @@ private static Harness Create(TokenHandlerOptions? options = null) TenantId = TestTenantId, }, clock); - var handler = options is null - ? new TokenHandler(store, jwtIssuer, clock) - : new TokenHandler(store, jwtIssuer, clock, options, logger: null); - return new Harness(handler, store, jwtIssuer, clock); + var handler = new TokenHandler( + store, + jwtIssuer, + upstream, + clock, + options ?? TokenHandlerOptions.Default, + logger); + return new Harness(handler, store, jwtIssuer, upstream, clock); } /// Seeds an auth code + upstream vault entry, mirroring what /callback would have done. @@ -57,6 +66,7 @@ await h.Store.SaveAuthCodeAsync(new DevBrainAuthCode Code = code, ClientId = ClientId, ClientRedirectUri = ClientRedirect, + Resource = Audience, ClientCodeChallenge = challenge, ClientCodeChallengeMethod = "S256", UpstreamJti = jti, @@ -78,6 +88,33 @@ await h.Store.SaveUpstreamTokenAsync(new UpstreamTokenRecord return (code, verifier, jti); } + [Fact] + public async Task Diagnostics_DoNotRenderRawTokenRequestValues() + { + var logger = new RecordingLogger(); + var h = Create(logger: logger); + const string maliciousGrantType = "client_credentials\r\nFORGED-TOKEN-LOG"; + + var result = await h.Handler.HandleAsync(new TokenRequest( + maliciousGrantType, + "client-id\r\nFORGED-CLIENT-LOG", + null, + null, + null, + null)); + + Assert.False(result.IsSuccess); + Assert.Equal("unsupported_grant_type", result.ErrorCode); + Assert.Contains(maliciousGrantType, result.ErrorDescription, StringComparison.Ordinal); + Assert.NotEmpty(logger.Messages); + Assert.All(logger.Messages, message => + { + Assert.DoesNotContain("FORGED", message, StringComparison.Ordinal); + Assert.DoesNotContain('\r', message); + Assert.DoesNotContain('\n', message); + }); + } + [Fact] public async Task AuthorizationCode_ValidRequest_ReturnsJwtAndRefresh() { @@ -253,6 +290,7 @@ public async Task RefreshToken_RotatesOldAndAllowsShortReplay() Assert.True(refreshed.IsSuccess); Assert.NotEqual(firstRefresh, refreshed.Response!.RefreshToken); Assert.NotEmpty(refreshed.Response.AccessToken); + Assert.Equal(1, h.Upstream.RefreshCalls); // Immediate retry/restart with the old token returns the same replacement refresh token. var replayed = await h.Handler.HandleAsync(new TokenRequest( @@ -260,6 +298,7 @@ public async Task RefreshToken_RotatesOldAndAllowsShortReplay() Assert.True(replayed.IsSuccess); Assert.Equal(refreshed.Response.RefreshToken, replayed.Response!.RefreshToken); Assert.NotEmpty(replayed.Response.AccessToken); + Assert.Equal(1, h.Upstream.RefreshCalls); h.Clock.Advance(TimeSpan.FromMinutes(6)); @@ -356,6 +395,38 @@ public async Task RefreshToken_WrongClient_Rejected() Assert.True(legitimate.IsSuccess); } + [Fact] + public async Task RefreshToken_WrongResource_RejectedWithoutBurningToken() + { + var h = Create(); + var (code, verifier, _) = await SeedAuthCodeAsync(h); + + var initial = await h.Handler.HandleAsync(new TokenRequest( + "authorization_code", ClientId, code, verifier, ClientRedirect, null)); + + var wrongResource = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", + ClientId, + null, + null, + null, + initial.Response!.RefreshToken, + Resource: "https://other.example.com/mcp")); + + Assert.False(wrongResource.IsSuccess); + Assert.Equal("invalid_grant", wrongResource.ErrorCode); + + var legitimate = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", + ClientId, + null, + null, + null, + initial.Response.RefreshToken, + Resource: Audience)); + Assert.True(legitimate.IsSuccess); + } + [Fact] public async Task RefreshToken_ExtendsUpstreamVaultExpiry() { @@ -379,6 +450,64 @@ public async Task RefreshToken_ExtendsUpstreamVaultExpiry() Assert.Equal(Epoch.AddDays(30), after!.ExpiresAt); } + [Fact] + public async Task RefreshToken_RevalidatesEntraRolesAndIdentity() + { + var h = Create(); + var (code, verifier, upstreamJti) = await SeedAuthCodeAsync(h); + h.Upstream.RefreshResponder = _ => new UpstreamTokenResponse( + AccessToken: "new-at", + RefreshToken: "new-rt", + IdToken: "new.id.token", + ExpiresIn: TimeSpan.FromHours(1), + UserPrincipalName: "renamed@ignitesolutions.group", + ObjectId: "00000000-0000-0000-0000-000000000001", + TenantId: "tenant-guid", + Roles: []); + + var initial = await h.Handler.HandleAsync(new TokenRequest( + "authorization_code", ClientId, code, verifier, ClientRedirect, null)); + var refreshed = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, initial.Response!.RefreshToken)); + + Assert.True(refreshed.IsSuccess); + var upstreamRecord = await h.Store.GetUpstreamTokenAsync(upstreamJti); + Assert.NotNull(upstreamRecord); + Assert.Equal("renamed@ignitesolutions.group", upstreamRecord.UserPrincipalName); + Assert.Empty(upstreamRecord.Roles); + Assert.Equal("new-at", upstreamRecord.Envelope.AccessToken); + Assert.Equal("new-rt", upstreamRecord.Envelope.RefreshToken); + } + + [Fact] + public async Task RefreshToken_ChangedEntraIdentityRevokesLocalSession() + { + var h = Create(); + var (code, verifier, upstreamJti) = await SeedAuthCodeAsync(h); + h.Upstream.RefreshResponder = _ => new UpstreamTokenResponse( + AccessToken: "new-at", + RefreshToken: "new-rt", + IdToken: "new.id.token", + ExpiresIn: TimeSpan.FromHours(1), + UserPrincipalName: "attacker@example.com", + ObjectId: "00000000-0000-0000-0000-000000000099", + TenantId: "tenant-guid", + Roles: ["DevBrain.User"]); + + var initial = await h.Handler.HandleAsync(new TokenRequest( + "authorization_code", ClientId, code, verifier, ClientRedirect, null)); + var refreshed = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, initial.Response!.RefreshToken)); + + Assert.False(refreshed.IsSuccess); + Assert.Equal("invalid_grant", refreshed.ErrorCode); + Assert.Null(await h.Store.GetUpstreamTokenAsync(upstreamJti)); + + var retry = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, initial.Response.RefreshToken)); + Assert.False(retry.IsSuccess); + } + [Fact] public async Task UnsupportedGrantType_Rejected() { diff --git a/tests/DevBrain.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs b/tests/DevBrain.Core.Tests/Auth/Middleware/JwtAuthenticatorTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs rename to tests/DevBrain.Core.Tests/Auth/Middleware/JwtAuthenticatorTests.cs index 66bb199..83a7346 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Middleware/JwtAuthenticatorTests.cs @@ -1,10 +1,10 @@ -using DevBrain.Functions.Auth.Middleware; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.Auth.Services; +using DevBrain.Core.Auth.Middleware; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.Auth.Services; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.Middleware; +namespace DevBrain.Core.Tests.Auth.Middleware; /// /// Unit tests for . These are the final three acceptance gates: @@ -58,6 +58,7 @@ await h.Store.SaveUpstreamTokenAsync(new UpstreamTokenRecord UserPrincipalName = upn, ObjectId = "00000000-0000-0000-0000-000000000001", TenantId = tenantId, + Roles = ["DevBrain.User"], CreatedAt = Epoch, ExpiresAt = Epoch.AddHours(1), }); @@ -82,6 +83,7 @@ public async Task ValidToken_RehydratesClaimsPrincipalWithUpn() Assert.Equal("derek@ignitesolutions.group", principal.FindFirst("preferred_username")!.Value); Assert.Equal("00000000-0000-0000-0000-000000000001", principal.FindFirst("oid")!.Value); Assert.Equal(TenantA, principal.FindFirst("tid")!.Value); + Assert.True(principal.IsInRole("DevBrain.User")); } /// Gate #8: cross-tenant rejection with zero state store reads. diff --git a/tests/DevBrain.Core.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs new file mode 100644 index 0000000..844b874 --- /dev/null +++ b/tests/DevBrain.Core.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs @@ -0,0 +1,18 @@ +using DevBrain.Core.Auth.Services; + +namespace DevBrain.Core.Tests.Auth.Services; + +public sealed class CosmosOAuthStateStoreTests +{ + [Theory] + [InlineData("", "client", "abc", "client:abc")] + [InlineData("v2:", "upstream", "jti", "v2:upstream:jti")] + public void ComposeKey_AppliesHostNamespace( + string prefix, + string recordKind, + string identifier, + string expected) + { + Assert.Equal(expected, CosmosOAuthStateStore.ComposeKey(prefix, recordKind, identifier)); + } +} diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs similarity index 98% rename from tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs index f338ea5..957ba55 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// v1.6 post-deploy bug: production minter emitted a JWT with diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerTests.cs similarity index 99% rename from tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerTests.cs index 8e11209..4cab707 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/DevBrainJwtIssuerTests.cs @@ -1,8 +1,8 @@ -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Unit tests for . Covers round-trip issue/validate, expiry, diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientActivationTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientActivationTests.cs index 98f25b1..6ffc573 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientActivationTests.cs @@ -1,12 +1,12 @@ -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Regression guard for the v1.6 post-deploy bug where DI failed with diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs index 21d31a1..2cacff1 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs @@ -1,9 +1,9 @@ using System.Net; using System.Text.Json; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Acceptance gate #10 — id_token JWKS validation. @@ -58,6 +58,7 @@ private static EntraOAuthClient CreateClientExpectingIdToken( ["preferred_username"] = "derek@ignitesolutions.group", ["oid"] = "00000000-0000-0000-0000-000000000001", ["tid"] = TenantGuid, + ["roles"] = new[] { "DevBrain.User" }, }; /// Gate #10 / happy path — properly signed, issued, audienced, and unexpired token passes. @@ -77,6 +78,7 @@ public async Task ValidIdToken_Accepted() Assert.Equal("derek@ignitesolutions.group", result.UserPrincipalName); Assert.Equal(TenantGuid, result.TenantId); + Assert.Contains("DevBrain.User", result.Roles ?? []); } /// Gate #10 / wrong signing key — validator's key ring doesn't match the token's signature. diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientTests.cs similarity index 98% rename from tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientTests.cs index 6dbed37..ec0525a 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/EntraOAuthClientTests.cs @@ -1,10 +1,10 @@ using System.Net; using System.Text; using System.Text.Json; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Unit tests for . Uses to capture diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs b/tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStore.cs similarity index 94% rename from tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs rename to tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStore.cs index 48b43fd..c421567 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStore.cs @@ -1,8 +1,8 @@ -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// In-memory for unit tests. Semantics match @@ -159,6 +159,7 @@ public Task SaveUpstreamTokenAsync(UpstreamTokenRecord token) UserPrincipalName = token.UserPrincipalName, ObjectId = token.ObjectId, TenantId = token.TenantId, + Roles = token.Roles.ToArray(), CreatedAt = token.CreatedAt, ExpiresAt = token.ExpiresAt, Ttl = token.Ttl, @@ -184,6 +185,7 @@ public Task SaveUpstreamTokenAsync(UpstreamTokenRecord token) UserPrincipalName = dto.UserPrincipalName, ObjectId = dto.ObjectId, TenantId = dto.TenantId, + Roles = dto.Roles, CreatedAt = dto.CreatedAt, ExpiresAt = dto.ExpiresAt, Ttl = dto.Ttl, @@ -208,6 +210,7 @@ private sealed class UpstreamDto public string UserPrincipalName { get; set; } = string.Empty; public string ObjectId { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty; + public string[] Roles { get; set; } = []; public DateTimeOffset CreatedAt { get; set; } public DateTimeOffset ExpiresAt { get; set; } public int Ttl { get; set; } @@ -248,7 +251,8 @@ public Task RotateRefreshAsync( string replacementRefreshToken, TimeSpan replacementLifetime, TimeSpan replayLifetime, - TimeSpan upstreamVaultLifetime) + TimeSpan upstreamVaultLifetime, + string? resource = null) { lock (_lock) { @@ -271,6 +275,12 @@ public Task RotateRefreshAsync( { return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient)); } + if (!string.IsNullOrEmpty(resource) + && !string.IsNullOrEmpty(record.Resource) + && !string.Equals(record.Resource, resource, StringComparison.Ordinal)) + { + return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongResource)); + } if (record.IsReplayMarker) { @@ -298,6 +308,7 @@ public Task RotateRefreshAsync( RefreshToken = replacementRefreshToken, ClientId = record.ClientId, UpstreamJti = record.UpstreamJti, + Resource = record.Resource, CreatedAt = now, ExpiresAt = now + replacementLifetime, Ttl = (int)replacementLifetime.TotalSeconds, @@ -308,6 +319,7 @@ public Task RotateRefreshAsync( RefreshToken = refreshToken, ClientId = record.ClientId, UpstreamJti = record.UpstreamJti, + Resource = record.Resource, CreatedAt = record.CreatedAt, ExpiresAt = now + replayLifetime, RotatedAt = now, diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStoreTests.cs similarity index 98% rename from tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStoreTests.cs index fd4d599..a8ff608 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/FakeOAuthStateStoreTests.cs @@ -1,9 +1,9 @@ -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Tests.TestHelpers; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Covers the semantics that is expected to share with diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs b/tests/DevBrain.Core.Tests/Auth/Services/UpstreamTokenProtectorTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs rename to tests/DevBrain.Core.Tests/Auth/Services/UpstreamTokenProtectorTests.cs index cea385a..b4a98d2 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs +++ b/tests/DevBrain.Core.Tests/Auth/Services/UpstreamTokenProtectorTests.cs @@ -1,10 +1,10 @@ using System.Security.Cryptography; using System.Text; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using Microsoft.AspNetCore.DataProtection; -namespace DevBrain.Functions.Tests.Auth.Services; +namespace DevBrain.Core.Tests.Auth.Services; /// /// Unit tests for — acceptance gate #9. diff --git a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj b/tests/DevBrain.Core.Tests/DevBrain.Core.Tests.csproj similarity index 50% rename from tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj rename to tests/DevBrain.Core.Tests/DevBrain.Core.Tests.csproj index 054df31..f83640f 100644 --- a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj +++ b/tests/DevBrain.Core.Tests/DevBrain.Core.Tests.csproj @@ -1,6 +1,7 @@ + Exe false true @@ -8,17 +9,13 @@ - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + - + diff --git a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs b/tests/DevBrain.Core.Tests/Services/DocumentEditServiceTests.cs similarity index 98% rename from tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs rename to tests/DevBrain.Core.Tests/Services/DocumentEditServiceTests.cs index fbc8b8f..345e97f 100644 --- a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs +++ b/tests/DevBrain.Core.Tests/Services/DocumentEditServiceTests.cs @@ -1,7 +1,7 @@ -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; +using DevBrain.Core.Models; +using DevBrain.Core.Services; -namespace DevBrain.Functions.Tests.Services; +namespace DevBrain.Core.Tests.Services; public sealed class DocumentEditServiceTests { @@ -251,8 +251,6 @@ public Task> SearchAsync(string query, string proje }); } - public Task TouchAllAsync() => Task.FromResult(_documents.Count); - public Task DeleteAsync(string key, string project) => Task.FromResult(_documents.Remove((key, project))); diff --git a/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs b/tests/DevBrain.Core.Tests/Services/TagEditServiceTests.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs rename to tests/DevBrain.Core.Tests/Services/TagEditServiceTests.cs index 64af014..e664de3 100644 --- a/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs +++ b/tests/DevBrain.Core.Tests/Services/TagEditServiceTests.cs @@ -1,7 +1,7 @@ -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; +using DevBrain.Core.Models; +using DevBrain.Core.Services; -namespace DevBrain.Functions.Tests.Services; +namespace DevBrain.Core.Tests.Services; public sealed class TagEditServiceTests { @@ -217,8 +217,6 @@ public Task> SearchAsync(string query, string proje public Task GetMetadataAsync(string key, string project) => GetAsync(key, project); - public Task TouchAllAsync() => Task.FromResult(_documents.Count); - public Task DeleteAsync(string key, string project) => Task.FromResult(_documents.Remove((key, project))); diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/FakeHttpMessageHandler.cs b/tests/DevBrain.Core.Tests/TestHelpers/FakeHttpMessageHandler.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/TestHelpers/FakeHttpMessageHandler.cs rename to tests/DevBrain.Core.Tests/TestHelpers/FakeHttpMessageHandler.cs index 75b90e5..a4edd81 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeHttpMessageHandler.cs +++ b/tests/DevBrain.Core.Tests/TestHelpers/FakeHttpMessageHandler.cs @@ -1,6 +1,6 @@ using System.Net; -namespace DevBrain.Functions.Tests.TestHelpers; +namespace DevBrain.Core.Tests.TestHelpers; /// /// Minimal stub for typed tests. Records diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs b/tests/DevBrain.Core.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs similarity index 97% rename from tests/DevBrain.Functions.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs rename to tests/DevBrain.Core.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs index 15ae934..4419947 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs +++ b/tests/DevBrain.Core.Tests/TestHelpers/FakeOpenIdConfigurationManager.cs @@ -2,7 +2,7 @@ using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; -namespace DevBrain.Functions.Tests.TestHelpers; +namespace DevBrain.Core.Tests.TestHelpers; /// /// Test double for returning a pre-populated diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs b/tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamOAuthClient.cs similarity index 93% rename from tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs rename to tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamOAuthClient.cs index e7f115c..83e2382 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs +++ b/tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamOAuthClient.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; -namespace DevBrain.Functions.Tests.TestHelpers; +namespace DevBrain.Core.Tests.TestHelpers; /// /// In-memory test double for . Used by CallbackHandler and @@ -47,5 +47,6 @@ private static UpstreamTokenResponse DefaultResponder(string _, string __) => ExpiresIn: TimeSpan.FromHours(1), UserPrincipalName: "derek@ignitesolutions.group", ObjectId: "00000000-0000-0000-0000-000000000001", - TenantId: "tenant-guid"); + TenantId: "tenant-guid", + Roles: ["DevBrain.User"]); } diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs b/tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamTokenProtector.cs similarity index 84% rename from tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs rename to tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamTokenProtector.cs index 5f32a37..f5392ad 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs +++ b/tests/DevBrain.Core.Tests/TestHelpers/FakeUpstreamTokenProtector.cs @@ -1,8 +1,8 @@ using System.Text.Json; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; -namespace DevBrain.Functions.Tests.TestHelpers; +namespace DevBrain.Core.Tests.TestHelpers; /// /// Test double for . Round-trips envelopes via plain JSON @@ -12,7 +12,7 @@ namespace DevBrain.Functions.Tests.TestHelpers; /// /// Used by the state-store-level protector-invocation test to assert that every upstream save and /// every upstream read goes through the protector. Unit tests that round-trip real ciphertext use -/// backed by +/// backed by /// EphemeralDataProtectionProvider instead — see UpstreamTokenProtectorTests. /// /// diff --git a/tests/DevBrain.Core.Tests/TestHelpers/RecordingLogger.cs b/tests/DevBrain.Core.Tests/TestHelpers/RecordingLogger.cs new file mode 100644 index 0000000..bc83df2 --- /dev/null +++ b/tests/DevBrain.Core.Tests/TestHelpers/RecordingLogger.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; + +namespace DevBrain.Core.Tests.TestHelpers; + +internal sealed class RecordingLogger : ILogger +{ + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => Messages.Add(formatter(state, exception)); + + private sealed class NullScope : IDisposable + { + public static NullScope Instance { get; } = new(); + + public void Dispose() + { + } + } +} diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/TestJwtFactory.cs b/tests/DevBrain.Core.Tests/TestHelpers/TestJwtFactory.cs similarity index 98% rename from tests/DevBrain.Functions.Tests/TestHelpers/TestJwtFactory.cs rename to tests/DevBrain.Core.Tests/TestHelpers/TestJwtFactory.cs index 2332a0b..c56b35a 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/TestJwtFactory.cs +++ b/tests/DevBrain.Core.Tests/TestHelpers/TestJwtFactory.cs @@ -2,7 +2,7 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; -namespace DevBrain.Functions.Tests.TestHelpers; +namespace DevBrain.Core.Tests.TestHelpers; /// /// Test helper that mints signed id_tokens for EntraOAuthClient tests. The returned diff --git a/tests/DevBrain.Functions.Tests/Auth/Middleware/McpToolContextAccessorTests.cs b/tests/DevBrain.Functions.Tests/Auth/Middleware/McpToolContextAccessorTests.cs deleted file mode 100644 index 5fa003b..0000000 --- a/tests/DevBrain.Functions.Tests/Auth/Middleware/McpToolContextAccessorTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using DevBrain.Functions.Auth.Middleware; -using Microsoft.Azure.Functions.Worker.Extensions.Mcp; - -namespace DevBrain.Functions.Tests.Auth.Middleware; - -/// -/// Regression tests for the v1.6 post-deploy bug where the standard -/// FunctionContext.GetHttpRequestDataAsync() path returned null for MCP tool trigger -/// invocations, leaving unable to read the bearer token. -/// -/// -/// These tests exercise the extraction chain directly (FunctionContext.Items → -/// ToolInvocationContext → HttpTransport → Headers["Authorization"]) without constructing a -/// , so we don't need a Functions -/// host to diagnose the auth path. -/// -/// -public sealed class McpToolContextAccessorTests -{ - [Fact] - public void TryGetAuthorizationHeader_WithHttpTransport_ReturnsBearerValue() - { - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = new ToolInvocationContext - { - Name = "UpsertDocument", - Transport = new HttpTransport("http-streamable") - { - Headers = - { - ["Authorization"] = "Bearer test-token-value", - }, - }, - }, - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Equal("Bearer test-token-value", result); - Assert.Equal(string.Empty, failureReason); - } - - [Fact] - public void TryGetAuthorizationHeader_AuthorizationHeaderLookupIsCaseInsensitive() - { - // HttpTransport.Headers uses an ordinal-ignore-case comparer (see source). If a client - // sends 'authorization' instead of 'Authorization', the lookup must still hit. - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = new ToolInvocationContext - { - Name = "UpsertDocument", - Transport = new HttpTransport("http-streamable") - { - Headers = - { - ["authorization"] = "Bearer lowercase-key", - }, - }, - }, - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out _); - - Assert.Equal("Bearer lowercase-key", result); - } - - [Fact] - public void TryGetAuthorizationHeader_MissingToolContextItem_ReturnsNullWithReason() - { - var items = new Dictionary(); - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Null(result); - Assert.Equal("tool_context_missing", failureReason); - } - - [Fact] - public void TryGetAuthorizationHeader_ToolContextItemWrongType_ReturnsNullWithReason() - { - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = "not-a-tool-context", - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Null(result); - Assert.StartsWith("tool_context_wrong_type:", failureReason); - } - - [Fact] - public void TryGetAuthorizationHeader_ToolContextHasNoTransport_ReturnsNullWithReason() - { - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = new ToolInvocationContext - { - Name = "UpsertDocument", - Transport = null, - }, - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Null(result); - Assert.Equal("non_http_transport:null", failureReason); - } - - [Fact] - public void TryGetAuthorizationHeader_NonHttpTransport_ReturnsNullWithTransportName() - { - // Distinct from the null-transport case above: here the tool invocation has a real - // Transport subclass that is NOT HttpTransport (e.g., a future stdio or pipe transport - // variant). TryGetHttpTransport returns false, and the accessor's failure reason should - // carry the transport's Name so logs can distinguish "unknown transport type" from - // "transport entirely missing". - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = new ToolInvocationContext - { - Name = "UpsertDocument", - Transport = new StubNonHttpTransport { Name = "stdio" }, - }, - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Null(result); - Assert.Equal("non_http_transport:stdio", failureReason); - } - - /// - /// Minimal non- subclass of so we can test - /// the "non-null, non-HTTP transport" branch of . - /// The extension's public transport surface currently only includes , - /// but the base class is non-sealed so tests can exercise other-transport branches. - /// - private sealed class StubNonHttpTransport : Transport - { - } - - [Fact] - public void TryGetAuthorizationHeader_HttpTransportWithNoAuthorizationHeader_ReturnsNullWithReason() - { - var items = new Dictionary - { - [McpToolContextAccessor.ToolInvocationContextItemKey] = new ToolInvocationContext - { - Name = "UpsertDocument", - Transport = new HttpTransport("http-streamable") - { - Headers = - { - ["Content-Type"] = "application/json", - ["X-Forwarded-For"] = "203.0.113.1", - }, - }, - }, - }; - - var result = McpToolContextAccessor.TryGetAuthorizationHeader(items, out var failureReason); - - Assert.Null(result); - Assert.Equal("authorization_header_missing", failureReason); - } - - [Fact] - public void TryGetAuthorizationHeader_NullItemsDictionary_Throws() - { - Assert.Throws( - () => McpToolContextAccessor.TryGetAuthorizationHeader(null!, out _)); - } - - [Fact] - public void ToolInvocationContextItemKey_MatchesExtensionInternalConstant() - { - // Guard against drift. This literal mirrors - // Microsoft.Azure.Functions.Worker.Extensions.Mcp.Constants.ToolInvocationContextKey, - // which is internal to the extension. If the extension ever changes the key string, this - // assertion will still hold, but the runtime behavior will silently break — which is why - // there's also a post-deploy check via the diagnostic middleware to verify the tool - // context actually lands at this key on the next redeploy. - Assert.Equal("ToolInvocationContext", McpToolContextAccessor.ToolInvocationContextItemKey); - } -} diff --git a/tests/DevBrain.Server.Tests/DevBrain.Server.Tests.csproj b/tests/DevBrain.Server.Tests/DevBrain.Server.Tests.csproj new file mode 100644 index 0000000..3f62d09 --- /dev/null +++ b/tests/DevBrain.Server.Tests/DevBrain.Server.Tests.csproj @@ -0,0 +1,23 @@ + + + + Exe + false + true + $(NoWarn);CA1707;CA2007 + + + + + + + + + + + + + + + + diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs b/tests/DevBrain.Server.Tests/OAuthWireFormatTests.cs similarity index 84% rename from tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs rename to tests/DevBrain.Server.Tests/OAuthWireFormatTests.cs index 3a03207..b41c1a3 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs +++ b/tests/DevBrain.Server.Tests/OAuthWireFormatTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using DevBrain.Functions.Auth.DcrFacade; +using DevBrain.Server.Auth; -namespace DevBrain.Functions.Tests.Auth.DcrFacade; +namespace DevBrain.Server.Tests; /// /// Wire-format regression guards for every OAuth/DCR response DTO. Each test serializes the @@ -35,11 +35,10 @@ public sealed class OAuthWireFormatTests [Fact] public void TokenResponseDto_EmitsRfc6749SnakeCaseFieldNames() { - // Match the options TokenEndpoint uses exactly (line 19): JsonSerializerDefaults.Web - // with no additional customization. + // Minimal API Results.Json uses ASP.NET Core's default JsonSerializerDefaults.Web options. var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - var dto = new TokenEndpoint.TokenResponseDto( + var dto = new OAuthEndpoints.TokenResponseDto( AccessToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test", TokenType: "Bearer", ExpiresIn: 600, @@ -78,18 +77,15 @@ public void TokenResponseDto_EmitsRfc6749SnakeCaseFieldNames() [Fact] public void RegistrationResponseDto_EmitsRfc7591SnakeCaseFieldNames() { - // Match RegisterEndpoint options: Web defaults + DefaultIgnoreCondition.WhenWritingNull - // (line 21-24). - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - var dto = new RegisterEndpoint.RegistrationResponseDto( + // Minimal API Results.Json uses ASP.NET Core's default JsonSerializerDefaults.Web options. + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + + var dto = new OAuthEndpoints.RegistrationResponseDto( ClientId: "abc123def456", ClientIdIssuedAt: 1_700_000_000L, ClientName: "Claude Desktop", RedirectUris: ["https://claude.ai/api/mcp/auth_callback"], + ApplicationType: "web", TokenEndpointAuthMethod: "none"); var json = JsonSerializer.Serialize(dto, options); @@ -99,6 +95,7 @@ public void RegistrationResponseDto_EmitsRfc7591SnakeCaseFieldNames() Assert.True(node.ContainsKey("client_id_issued_at"), $"missing client_id_issued_at in {json}"); Assert.True(node.ContainsKey("client_name"), $"missing client_name in {json}"); Assert.True(node.ContainsKey("redirect_uris"), $"missing redirect_uris in {json}"); + Assert.True(node.ContainsKey("application_type"), $"missing application_type in {json}"); Assert.True(node.ContainsKey("token_endpoint_auth_method"), $"missing token_endpoint_auth_method in {json}"); // camelCase leak guards @@ -112,47 +109,22 @@ public void RegistrationResponseDto_EmitsRfc7591SnakeCaseFieldNames() Assert.Equal("abc123def456", (string)node["client_id"]!); Assert.Equal(1_700_000_000L, (long)node["client_id_issued_at"]!); Assert.Equal("Claude Desktop", (string)node["client_name"]!); + Assert.Equal("web", (string)node["application_type"]!); Assert.Equal("none", (string)node["token_endpoint_auth_method"]!); var uris = node["redirect_uris"]!.AsArray(); Assert.Single(uris); Assert.Equal("https://claude.ai/api/mcp/auth_callback", (string)uris[0]!); } - [Fact] - public void RegistrationResponseDto_NullClientName_IsOmittedFromOutput() - { - // Pin the WhenWritingNull behavior: a null ClientName should not appear in the JSON at - // all, not even as "client_name": null. This matches how the live endpoint is configured. - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - var dto = new RegisterEndpoint.RegistrationResponseDto( - ClientId: "abc", - ClientIdIssuedAt: 0L, - ClientName: null, - RedirectUris: ["https://example.com/cb"], - TokenEndpointAuthMethod: "none"); - - var json = JsonSerializer.Serialize(dto, options); - var node = JsonNode.Parse(json)!.AsObject(); - - Assert.False(node.ContainsKey("client_name"), $"client_name should be omitted when null, got {json}"); - } - // ---------------- RFC 8414 — Authorization Server Metadata ---------------- [Fact] public void AuthorizationServerMetadata_EmitsRfc8414SnakeCaseFieldNames() { - // Match DiscoveryEndpoints options (line 34-37): Web + WhenWritingNull. - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; + // Minimal API Results.Json uses ASP.NET Core's default JsonSerializerDefaults.Web options. + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - var dto = new DiscoveryEndpoints.AuthorizationServerMetadata( + var dto = new OAuthEndpoints.AuthorizationServerMetadataResponse( Issuer: "https://devbrain.example.com", RegistrationEndpoint: "https://devbrain.example.com/register", AuthorizationEndpoint: "https://devbrain.example.com/authorize", @@ -161,6 +133,7 @@ public void AuthorizationServerMetadata_EmitsRfc8414SnakeCaseFieldNames() GrantTypesSupported: ["authorization_code", "refresh_token"], CodeChallengeMethodsSupported: ["S256"], TokenEndpointAuthMethodsSupported: ["none"], + AuthorizationResponseIssParameterSupported: true, ScopesSupported: ["documents.readwrite"]); var json = JsonSerializer.Serialize(dto, options); @@ -175,6 +148,7 @@ public void AuthorizationServerMetadata_EmitsRfc8414SnakeCaseFieldNames() Assert.True(node.ContainsKey("grant_types_supported"), $"missing grant_types_supported in {json}"); Assert.True(node.ContainsKey("code_challenge_methods_supported"), $"missing code_challenge_methods_supported in {json}"); Assert.True(node.ContainsKey("token_endpoint_auth_methods_supported"), $"missing token_endpoint_auth_methods_supported in {json}"); + Assert.True(node.ContainsKey("authorization_response_iss_parameter_supported"), $"missing authorization_response_iss_parameter_supported in {json}"); Assert.True(node.ContainsKey("scopes_supported"), $"missing scopes_supported in {json}"); // camelCase leak guards (spot-check the ones with multi-word names) @@ -191,13 +165,10 @@ public void AuthorizationServerMetadata_EmitsRfc8414SnakeCaseFieldNames() [Fact] public void ProtectedResourceMetadata_EmitsRfc9728SnakeCaseFieldNames() { - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); - var dto = new DiscoveryEndpoints.ProtectedResourceMetadata( - Resource: "https://devbrain.example.com/runtime/webhooks/mcp", + var dto = new OAuthEndpoints.ProtectedResourceMetadataResponse( + Resource: "https://devbrain.example.com/mcp", AuthorizationServers: ["https://devbrain.example.com"], BearerMethodsSupported: ["header"], ScopesSupported: ["documents.readwrite"]); diff --git a/tests/DevBrain.Server.Tests/ServerEndpointTests.cs b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs new file mode 100644 index 0000000..75a41c4 --- /dev/null +++ b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs @@ -0,0 +1,248 @@ +using System.Net; +using System.Net.Http.Json; +using System.ComponentModel; +using System.Reflection; +using System.Security.Claims; +using System.Text.Json.Nodes; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; +using DevBrain.Server.Authentication; +using DevBrain.Server.Tools; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection.KeyManagement; +using Microsoft.AspNetCore.DataProtection.Repositories; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.Server; + +namespace DevBrain.Server.Tests; + +public sealed class ServerEndpointTests : IClassFixture +{ + private readonly HttpClient _client; + private readonly DevBrainWebApplicationFactory _factory; + + public ServerEndpointTests(DevBrainWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + }); + } + + [Fact] + public void ServerPublishesAllTwelveDocumentToolContracts() + { + var toolNames = typeof(ServerDocumentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Select(method => method.GetCustomAttribute()?.Name) + .Where(name => name is not null) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + new[] + { + "AppendDocument", + "ApplyEditDocument", + "CompareDocument", + "DeleteDocument", + "EditTags", + "GetDocument", + "GetDocumentMetadata", + "ListDocuments", + "PreviewEditDocument", + "SearchDocuments", + "UpsertDocument", + "UpsertDocumentChunked", + }, + toolNames); + } + + [Fact] + public async Task UserPolicyRequiresDevBrainUserRole() + { + var authorization = _factory.Services.GetRequiredService(); + var withoutRole = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("oid", "user-1")], + DevBrainAuthenticationDefaults.Scheme)); + var withRole = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim("oid", "user-1"), new Claim(ClaimTypes.Role, DevBrainAuthenticationDefaults.UserPolicy)], + DevBrainAuthenticationDefaults.Scheme)); + + Assert.False((await authorization.AuthorizeAsync( + withoutRole, + resource: null, + DevBrainAuthenticationDefaults.UserPolicy)).Succeeded); + Assert.True((await authorization.AuthorizeAsync( + withRole, + resource: null, + DevBrainAuthenticationDefaults.UserPolicy)).Succeeded); + } + + [Fact] + public async Task Healthz_IsAnonymousAndHealthy() + { + using var response = await _client.GetAsync("/healthz", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("healthy", body!["status"]); + } + + [Fact] + public async Task McpWithoutBearerToken_ReturnsProtectedResourceChallenge() + { + using var response = await _client.PostAsync("/mcp", JsonContent.Create(new { }), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal( + "Bearer resource_metadata=\"https://devbrain.example.com/.well-known/oauth-protected-resource\"", + response.Headers.WwwAuthenticate.Single().ToString()); + } + + [Fact] + public async Task ProtectedResourceMetadata_AdvertisesStatelessMcpResource() + { + using var response = await _client.GetAsync("/.well-known/oauth-protected-resource", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("https://devbrain.example.com/mcp", body!["resource"].ToString()); + } + + [Fact] + public async Task AuthenticatedDiscover_AdvertisesPinnedProtocolRevision() + { + const string jti = "server-initialize-test"; + var store = _factory.Services.GetRequiredService(); + store.UpstreamTokens[jti] = new UpstreamTokenRecord + { + Jti = jti, + UserPrincipalName = "user@example.com", + ObjectId = "00000000-0000-0000-0000-000000000001", + TenantId = "11111111-1111-1111-1111-111111111111", + Roles = [DevBrainAuthenticationDefaults.UserPolicy], + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + var issuer = _factory.Services.GetRequiredService(); + var (token, _) = issuer.IssueWithJti("server-test", jti, TimeSpan.FromMinutes(5)); + + using var request = new HttpRequestMessage(HttpMethod.Post, "/mcp") + { + Content = JsonContent.Create(new + { + jsonrpc = "2.0", + id = 1, + method = "server/discover", + @params = new + { + _meta = new Dictionary + { + ["io.modelcontextprotocol/protocolVersion"] = "2026-07-28", + ["io.modelcontextprotocol/clientCapabilities"] = new { }, + ["io.modelcontextprotocol/clientInfo"] = new { name = "DevBrain.Server.Tests", version = "1.0.0" }, + }, + }, + }), + }; + request.Headers.Authorization = new("Bearer", token); + request.Headers.Add("MCP-Protocol-Version", "2026-07-28"); + request.Headers.Add("Mcp-Method", "server/discover"); + request.Headers.Accept.ParseAdd("application/json"); + request.Headers.Accept.ParseAdd("text/event-stream"); + + using var response = await _client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + var jsonText = response.Content.Headers.ContentType?.MediaType == "text/event-stream" + ? responseText.Split('\n', StringSplitOptions.TrimEntries) + .Single(line => line.StartsWith("data: ", StringComparison.Ordinal))[6..] + : responseText; + var body = JsonNode.Parse(jsonText)!.AsObject(); + Assert.True(body["result"] is not null, responseText); + Assert.Contains( + "2026-07-28", + body!["result"]!["supportedVersions"]!.AsArray().Select(node => node!.GetValue())); + } +} + +public sealed class DevBrainWebApplicationFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseSetting("AllowedHosts", "localhost"); + builder.UseSetting("CosmosDb:AccountEndpoint", "https://localhost:8081"); + builder.UseSetting("OAuth:BaseUrl", "https://devbrain.example.com"); + builder.UseSetting("OAuth:JwtSigningSecret", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="); + builder.UseSetting("OAuth:EntraTenantId", "11111111-1111-1111-1111-111111111111"); + builder.UseSetting("OAuth:EntraClientId", "test-client-id"); + builder.UseSetting("OAuth:EntraClientSecret", "test-client-secret"); + builder.UseSetting("DataProtection:BlobUri", "https://example.blob.core.windows.net/dataprotection-v2/keys.xml"); + builder.UseSetting("DataProtection:KeyVaultKeyUri", "https://example.vault.azure.net/keys/data-protection-key"); + builder.ConfigureServices(services => + { + services.PostConfigure(options => + { + options.XmlRepository = new InMemoryXmlRepository(); + options.XmlEncryptor = null; + }); + services.RemoveAll(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + }); + } + + private sealed class InMemoryXmlRepository : IXmlRepository + { + private readonly List _elements = []; + + public IReadOnlyCollection GetAllElements() => + _elements.Select(element => new System.Xml.Linq.XElement(element)).ToArray(); + + public void StoreElement(System.Xml.Linq.XElement element, string friendlyName) => + _elements.Add(new System.Xml.Linq.XElement(element)); + } +} + +public sealed class ServerTestOAuthStateStore : IOAuthStateStore +{ + public Dictionary UpstreamTokens { get; } = new(StringComparer.Ordinal); + + public Task GetUpstreamTokenAsync(string jti) => + Task.FromResult(UpstreamTokens.GetValueOrDefault(jti)); + + public Task SaveUpstreamTokenAsync(UpstreamTokenRecord token) + { + UpstreamTokens[token.Jti] = token; + return Task.CompletedTask; + } + + public Task DeleteUpstreamTokenAsync(string jti) + { + UpstreamTokens.Remove(jti); + return Task.CompletedTask; + } + + public Task SaveClientAsync(RegisteredClient client) => throw new NotSupportedException(); + public Task GetClientAsync(string clientId) => throw new NotSupportedException(); + public Task SaveTransactionAsync(AuthTransaction transaction) => throw new NotSupportedException(); + public Task GetTransactionAsync(string upstreamState) => throw new NotSupportedException(); + public Task DeleteTransactionAsync(string upstreamState) => throw new NotSupportedException(); + public Task SaveAuthCodeAsync(DevBrainAuthCode code) => throw new NotSupportedException(); + public Task RedeemAuthCodeAsync(string code) => throw new NotSupportedException(); + public Task SaveRefreshAsync(DevBrainRefreshRecord refresh) => throw new NotSupportedException(); + public Task RotateRefreshAsync( + string refreshToken, + string clientId, + string replacementRefreshToken, + TimeSpan replacementLifetime, + TimeSpan replayLifetime, + TimeSpan upstreamVaultLifetime, + string? resource = null) => throw new NotSupportedException(); + public Task ConsumeRefreshAsync(string refreshToken) => throw new NotSupportedException(); +}