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..1fb50ea 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -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 v2 container image + run: docker build --file src/DevBrain.Server/Dockerfile . diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed9249..a95f3ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,27 +4,55 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman ## [Unreleased] +## [2.0.0] — 2026-08-09 + +DevBrain 2.0 introduces a standalone ASP.NET Core host on Azure Container Apps while retaining the Azure Functions host as an explicitly temporary compatibility target for side-by-side client validation. + +### Added + +- Added `DevBrain.Server`, a .NET 10 Minimal API host using `ModelContextProtocol.AspNetCore` 2.1.0 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 OAuth/DCR implementation used by both hosting models. +- 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, removing the Functions host-layer blocker for clients such as VS Code and GitHub Copilot. +- 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 OpenTelemetry, and `0`–`3` replica defaults at `0.5` vCPU / `1 GiB`. +- Added a v2-specific Data Protection key ring and `v2:` OAuth-state key namespace while reusing the existing `documents` and `oauth_state` Cosmos containers. No Redis or additional Cosmos throughput is required. +- Added server integration tests for `/healthz`, protected-resource metadata, the MCP authentication challenge, app-role authorization, and all 12 published tool contracts. + +### Removed + +- 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 compatibility 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. +- Removed the compatibility host's temporary raw DCR request-body diagnostic logging; OAuth metadata is now logged only as bounded structural fields. - 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; `azd deploy server` 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. +- Refreshed the deployed runtime dependency stack to current compatible NuGet releases, including `Microsoft.Azure.Functions.Worker` 2.52.0, `Microsoft.Azure.Functions.Worker.Sdk` 2.1.0, `Microsoft.Azure.Functions.Worker.Extensions.Mcp` 1.6.0, `Microsoft.Azure.Functions.Worker.ApplicationInsights` 2.51.0, `Microsoft.ApplicationInsights.WorkerService` 2.23.0, `Microsoft.Azure.Cosmos` 3.62.1, `Microsoft.Extensions.Azure` 1.14.0, IdentityModel 8.22.0, `Microsoft.AspNetCore.DataProtection` 10.0.10, and `System.Security.Cryptography.Xml` 10.0.10. Removed the unused direct `ModelContextProtocol` package reference. +- Kept the compatibility Functions host on its direct isolated-worker Application Insights integration path (`AddApplicationInsightsTelemetryWorkerService` + `ConfigureFunctionsApplicationInsights`); the v2 ASP.NET Core host uses Azure Monitor OpenTelemetry. +- Refreshed test tooling to `Microsoft.NET.Test.Sdk` 18.8.1, `xunit.runner.visualstudio` 3.1.5, and `Microsoft.Extensions.TimeProvider.Testing` 10.8.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. +- Patched the remaining Azure Data Protection helper packages to `Azure.Extensions.AspNetCore.DataProtection.Blobs` 1.5.3 and `Azure.Extensions.AspNetCore.DataProtection.Keys` 1.6.3, then replaced the stale 10.0.6 workaround comment in the project file. - 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 list devbrain.slnx package --deprecated --include-transitive` reports no deprecated packages in `DevBrain.Core` or `DevBrain.Server`. The retained Functions extension chain still brings legacy caching abstractions, and the test projects retain xUnit 2.9.3 pending a separate xUnit v3 migration. +- `dotnet test devbrain.slnx` passes with the Functions/core and ASP.NET Core server test suites. +- A side-by-side Azure deployment was validated through the unified ChatGPT/Codex Windows app: all 12 tools were discovered, OAuth completed with `DevBrain.User`, and read-only calls returned existing documents from the shared Cosmos DB container. ## [1.9.0] — 2026-04-15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 65876d9..223fabd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,9 @@ 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)) + - [Azure Functions Core Tools v4](https://learn.microsoft.com/azure/azure-functions/functions-run-local) only when changing the compatibility host 2. **Clone and build** ```bash @@ -18,17 +18,16 @@ 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 `CosmosDb__*`, `OAuth__*`, and `DataProtection__*` values through environment variables or .NET user secrets. The server intentionally fails fast when required values are absent. 4. **Run locally** ```bash - cd src/DevBrain.Functions - func start + dotnet run --project src/DevBrain.Server ``` + The v2 MCP endpoint is `/mcp` and the anonymous health endpoint is `/healthz`. For Functions compatibility-host work, configure `src/DevBrain.Functions/local.settings.json` and run `func start` from that directory. + ## Pull Request Process 1. Fork the repository and create a feature branch from `main`. diff --git a/README.md b/README.md index e4622e5..dc191fc 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,31 +51,66 @@ DevBrain is the only approach that gives every AI tool (Claude, Copilot, Codex, └──────────────────┘ ``` +### Hosting defaults + +The v2 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 - [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [PowerShell 7 (`pwsh`)](https://learn.microsoft.com/powershell/scripting/install/installing-powershell) for the cross-platform post-provision hook - [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) ## Deploy +Create a single-tenant Entra app registration with a client secret. Add an app role whose value is `DevBrain.User` with `User/Groups` as the allowed member type, then assign that role to the users or groups allowed to connect. DevBrain does not expose a separate privileged application role; Azure resources remain administered through normal Azure RBAC. + +Initialize the environment and supply the Entra values. `JWT_SIGNING_SECRET` must be a base64-encoded value containing at least 32 random bytes. + ```powershell azd init -t Ignite-Solutions-Group/devbrain azd env set ENTRA_TENANT_ID azd env set ENTRA_CLIENT_ID -azd up +azd env set ENTRA_CLIENT_SECRET +$jwtSigningSecret = [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) +azd env set JWT_SIGNING_SECRET $jwtSigningSecret +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: +The post-provision hook finalizes the Container App's Cosmos DB data-role assignment. It retries the assignment while a newly created managed identity propagates through Entra, so the first deployment does not require a manual second provision. The operation is deterministic and safe to rerun. + +The two secret values seed Key Vault through secure deployment parameters. The `.azure/` environment directory is git-ignored; after the first successful provision, the local bootstrap values can also be cleared because later provisions leave existing Key Vault secrets unchanged: + +```powershell +azd env set ENTRA_CLIENT_SECRET "" +azd env set JWT_SIGNING_SECRET "" +``` + +After provisioning, add the emitted `AZURE_CONTAINER_APP_URL` plus `/callback` as a Web redirect URI on the Entra app registration. Then deploy the v2 server: + +```powershell +azd deploy server +``` -`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. +The template also retains the Azure Functions host as a compatibility deployment target while v2 client validation is completed. It uses the same `documents` container but a separate OAuth key namespace and Data Protection key ring, so both hosts can run safely side by side. Deploy it only when compatibility testing requires it: ```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 deploy api ``` -Restart the Function App to pick up the Key Vault references, then connect any MCP client. +The Container App uses the platform-provided HTTPS hostname. Front Door and a custom domain are optional additions, not requirements. ## First Run @@ -83,13 +120,7 @@ After a fresh deployment, seed the default reference documents so any AI tool co UpsertDocument(key="ref:devbrain-usage", project="default", content=) ``` -Or use the seed script (requires a valid MCP connection): - -```powershell -./scripts/seed-devbrain.ps1 -``` - -Re-running is safe — every upsert is a full overwrite. Source content for the seed lives under [`docs/seed/`](docs/seed/). +Re-running the upsert is safe because it is a full overwrite. Source content for the seed lives under [`docs/seed/`](docs/seed/). ## Configure Your MCP Client @@ -98,7 +129,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 +139,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 +149,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 +162,29 @@ 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 deploy server ``` -`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 Bicep settings to both hosts. `azd deploy server` only deploys the v2 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 host, set the same `OAuth__*` environment variables directly and create a new revision or 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. 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. This removes the Azure Functions extension host-layer limitation that previously prevented VS Code and GitHub Copilot from starting OAuth. End-to-end client validation remains part of the v2 parallel rollout. ### Cursor -Not yet tested with v1.6 OAuth. Expected to work if the client supports MCP OAuth with DCR. +Not yet validated against v2. It is expected to work if the client supports MCP OAuth with DCR. ## Session Startup / AGENTS.md @@ -281,25 +312,22 @@ 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 prerequisites: .NET 10 SDK and Azure CLI. Azure Functions Core Tools v4 is only needed to run the compatibility host. -2. Log in to Azure (for Cosmos access via `DefaultAzureCredential`): +2. Log in to Azure (for `DefaultAzureCredential`). A local identity using the deployed Azure data services needs Cosmos DB Built-in Data Contributor, Storage Blob Data Contributor (or Owner), and Key Vault Crypto User on the corresponding resources: ```powershell az login ``` -3. Copy and configure local settings: - ```powershell - Copy-Item src/DevBrain.Functions/local.settings.json.example src/DevBrain.Functions/local.settings.json - # Edit with your Cosmos DB account endpoint - ``` +3. Configure the required `CosmosDb__*`, `OAuth__*`, and `DataProtection__*` values with environment variables or .NET user secrets. The server fails fast when a required value is missing. -4. Run: +4. Run the v2 host: ```powershell - cd src/DevBrain.Functions - func start + dotnet run --project src/DevBrain.Server ``` + The local MCP endpoint is `http://localhost:/mcp`; `/healthz` is anonymous. To run the compatibility Functions host instead, configure `src/DevBrain.Functions/local.settings.json` and use `func start` from that directory. + 5. Optional dependency health checks from the repository root: ```powershell dotnet list devbrain.slnx package --vulnerable --include-transitive @@ -312,6 +340,8 @@ Keys use colon as the separator (e.g. `sprint:license-sync`). **Writes** (`Upser 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 +349,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) +DevBrain 2.0 is designed to run beside the Functions implementation until its client matrix is validated. The direct ASP.NET Core host structurally resolves the former VS Code/Copilot OAuth challenge blocker. “Working” entries below reflect current DevBrain OAuth operational evidence; the v2 endpoint still needs to be checked across the same clients before the compatibility host is retired. | Client | Platform | Auth | Status | |--------|----------|------|--------| @@ -349,10 +368,10 @@ 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) | ✅ v2 validated; 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) | +| VS Code / GitHub Copilot | Windows | OAuth (DCR) | 🧪 Former challenge blocker addressed in v2; validation pending | | Cursor | — | OAuth (DCR) | Not tested | ## Contributing diff --git a/azure.yaml b/azure.yaml index 42411ed..0977d68 100644 --- a/azure.yaml +++ b/azure.yaml @@ -2,12 +2,20 @@ 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 + host: function + 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..66a755c 100644 --- a/devbrain.slnx +++ b/devbrain.slnx @@ -1,8 +1,11 @@ + + + diff --git a/infra/main.bicep b/infra/main.bicep index ab7f844..9b36368 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -6,9 +6,8 @@ param environmentName string // ─── v1.6 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,12 +17,31 @@ 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 v2 Container App. azd replaces it with the built DevBrain image during deployment.') +param containerAppImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + +@description('Minimum v2 Container App replica count. Set to 1 or higher when interactive cold-start latency is important.') +@minValue(0) +param containerAppMinReplicas int = 0 + +@description('Maximum v2 Container App replica count.') +@minValue(1) +param containerAppMaxReplicas int = 3 + var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) // ─── Storage Account (required by Azure Functions) ─────────────────────────── @@ -68,6 +86,14 @@ resource dataProtectionKeysContainer 'Microsoft.Storage/storageAccounts/blobServ } } +resource dataProtectionV2KeysContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: 'dataprotection-keys-v2' + properties: { + publicAccess: 'None' + } +} + // ─── Cosmos DB ─────────────────────────────────────────────────────────────── resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2024-05-15' = { @@ -77,6 +103,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' } @@ -164,16 +196,61 @@ resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { } } +// ─── DevBrain v2 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 (v1.6 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 @@ -237,6 +314,7 @@ resource functionApp 'Microsoft.Web/sites@2024-04-01' = { kind: 'functionapp,linux' tags: { 'azd-service-name': 'api' + 'hidden-link: /app-insights-resource-id': applicationInsights.id } identity: { type: 'SystemAssigned' @@ -284,8 +362,8 @@ resource functionApp 'Microsoft.Web/sites@2024-04-01' = { // 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). + // OAuth DCR facade. Secrets are Key Vault references. They can be seeded through the + // optional secure first-provision parameters or managed directly in Key Vault. { name: 'OAuth__BaseUrl', value: 'https://func-devbrain-${resourceToken}.azurewebsites.net' } { name: 'OAuth__EntraTenantId', value: entraTenantId } { name: 'OAuth__EntraClientId', value: entraClientId } @@ -303,6 +381,165 @@ resource functionApp 'Microsoft.Web/sites@2024-04-01' = { } } +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: { + value: jwtSigningSecretValue + } +} + +var containerAppName = 'ca-devbrain-${substring(resourceToken, 0, 6)}' +var containerAppHostName = '${containerAppName}.${containerAppsEnvironment.properties.defaultDomain}' +var containerAppBaseUrl = 'https://${containerAppHostName}' + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: containerAppName + location: location + tags: { + 'azd-service-name': 'server' + } + identity: { + type: 'SystemAssigned, UserAssigned' + userAssignedIdentities: { + '${containerAppIdentity.id}': {} + } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + activeRevisionsMode: 'Single' + registries: [ + { + server: containerRegistry.properties.loginServer + identity: 'system' + } + ] + ingress: { + external: true + allowInsecure: false + targetPort: 8080 + transport: 'auto' + } + // 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 + } + ] + } + 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' } + { 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 @@ -391,6 +628,7 @@ resource monitoringMetricsPublisherRole 'Microsoft.Authorization/roleAssignments // Cosmos DB Built-in Data Contributor role var cosmosDataContributorRoleId = '00000000-0000-0000-0000-000000000002' +var containerAppCosmosRoleAssignmentName = guid(cosmosAccount.id, containerAppIdentity.id, cosmosDataContributorRoleId) resource cosmosRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2024-05-15' = { parent: cosmosAccount @@ -431,9 +669,65 @@ resource keyVaultSecretsOfficerRole 'Microsoft.Authorization/roleAssignments@202 } } +// ─── DevBrain v2 Container App managed-identity access ────────────────────── + +resource containerAppAcrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(containerRegistry.id, containerApp.id, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + scope: containerRegistry + properties: { + principalId: containerApp.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + +resource containerAppStorageBlobDataOwnerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, containerAppIdentity.id, 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + scope: storageAccount + properties: { + principalId: containerAppIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + } +} + +// 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. + +resource containerAppKeyVaultCryptoUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, containerAppIdentity.id, '12338af0-0e69-4776-bea7-57ae8d297424') + scope: keyVault + properties: { + principalId: containerAppIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '12338af0-0e69-4776-bea7-57ae8d297424') + } +} + +resource containerAppKeyVaultSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, containerAppIdentity.id, '4633458b-17de-408a-b874-0445c86b69e6') + scope: keyVault + properties: { + principalId: containerAppIdentity.properties.principalId + principalType: 'ServicePrincipal' + 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 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..cee6cc7 --- /dev/null +++ b/scripts/complete-container-app-provision.ps1 @@ -0,0 +1,110 @@ +[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, + [ValidateRange(0, 300)] + [int]$FunctionWarmupSeconds = 30 +) + +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 + } +} + +if ($FunctionWarmupSeconds -gt 0) { + Write-Host "Waiting $FunctionWarmupSeconds seconds for the Functions host to settle before deployment." + Start-Sleep -Seconds $FunctionWarmupSeconds +} 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..6d0a05a --- /dev/null +++ b/src/DevBrain.Core/DevBrain.Core.csproj @@ -0,0 +1,23 @@ + + + + 2.0.0 + + + + + + + + + + + + + + + + + + + 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 index 241a6d3..13e7c11 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/AuthorizeEndpoint.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/AuthorizeEndpoint.cs @@ -1,8 +1,10 @@ using System.Net; using System.Text.Json; using System.Web; +using DevBrain.Core.Auth.DcrFacade; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace DevBrain.Functions.Auth.DcrFacade; @@ -15,11 +17,18 @@ public sealed class AuthorizeEndpoint { private readonly AuthorizationHandler _handler; private readonly ILogger _logger; + private readonly string _issuer; + private readonly string _canonicalResource; - public AuthorizeEndpoint(AuthorizationHandler handler, ILogger logger) + public AuthorizeEndpoint( + AuthorizationHandler handler, + ILogger logger, + IConfiguration configuration) { _handler = handler; _logger = logger; + _issuer = configuration["OAuth:BaseUrl"]!.TrimEnd('/'); + _canonicalResource = $"{_issuer}/runtime/webhooks/mcp"; } [Function("AuthorizeEndpoint")] @@ -35,7 +44,10 @@ public async Task Run( RedirectUri: query["redirect_uri"] ?? string.Empty, State: query["state"], CodeChallenge: query["code_challenge"] ?? string.Empty, - CodeChallengeMethod: query["code_challenge_method"] ?? "plain"); + CodeChallengeMethod: query["code_challenge_method"] ?? "plain", + Resource: query["resource"], + Issuer: _issuer, + CanonicalResource: _canonicalResource); var result = await _handler.HandleAsync(request); diff --git a/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs index 80cb56c..929b84b 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/CallbackEndpoint.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using System.Web; +using DevBrain.Core.Auth.DcrFacade; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; diff --git a/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs b/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs index 01f5db2..8a1a535 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/DiscoveryEndpoints.cs @@ -65,6 +65,7 @@ public async Task AuthorizationServer( GrantTypesSupported: ["authorization_code", "refresh_token"], CodeChallengeMethodsSupported: ["S256"], TokenEndpointAuthMethodsSupported: ["none"], + AuthorizationResponseIssParameterSupported: true, ScopesSupported: ["documents.readwrite"]); return await WriteJsonAsync(req, metadata); @@ -110,6 +111,7 @@ internal sealed record AuthorizationServerMetadata( [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 ProtectedResourceMetadata( diff --git a/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs index 7ed0a1d..27736a6 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/RegisterEndpoint.cs @@ -1,6 +1,7 @@ using System.Net; using System.Text.Json; using System.Text.Json.Serialization; +using DevBrain.Core.Auth.DcrFacade; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -38,31 +39,12 @@ public async Task Run( { _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 { @@ -113,9 +95,10 @@ internal sealed record RegistrationResponseDto( [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.TokenEndpointAuthMethod) { } + : this(response.ClientId, response.ClientIdIssuedAt, response.ClientName, response.RedirectUris, response.ApplicationType, response.TokenEndpointAuthMethod) { } } } diff --git a/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs b/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs index 8600d2e..b38ae8a 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/TokenEndpoint.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Web; +using DevBrain.Core.Auth.DcrFacade; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -43,7 +44,8 @@ public async Task Run( Code: form["code"], CodeVerifier: form["code_verifier"], RedirectUri: form["redirect_uri"], - RefreshToken: form["refresh_token"]); + RefreshToken: form["refresh_token"], + Resource: form["resource"]); var result = await _handler.HandleAsync(request); diff --git a/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs b/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs index d654515..b3d7aa4 100644 --- a/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs +++ b/src/DevBrain.Functions/Auth/Middleware/McpJwtValidationMiddleware.cs @@ -1,6 +1,7 @@ using System.Net; using System.Security.Claims; using System.Text.Json; +using DevBrain.Core.Auth.Middleware; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Azure.Functions.Worker.Middleware; diff --git a/src/DevBrain.Functions/DevBrain.Functions.csproj b/src/DevBrain.Functions/DevBrain.Functions.csproj index bdbd7e5..0dbab72 100644 --- a/src/DevBrain.Functions/DevBrain.Functions.csproj +++ b/src/DevBrain.Functions/DevBrain.Functions.csproj @@ -7,21 +7,25 @@ 1.9.0 + + + + - + - + - + - + - - + + diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs index d842967..3775214 100644 --- a/src/DevBrain.Functions/Program.cs +++ b/src/DevBrain.Functions/Program.cs @@ -2,10 +2,11 @@ using System.Text.Json; using Azure.Core; using Azure.Identity; -using DevBrain.Functions.Auth.DcrFacade; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Middleware; using DevBrain.Functions.Auth.Middleware; -using DevBrain.Functions.Auth.Services; -using DevBrain.Functions.Services; +using DevBrain.Core.Auth.Services; +using DevBrain.Core.Services; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.DataProtection; using Microsoft.Azure.Cosmos; 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/Tools/DocumentTools.cs b/src/DevBrain.Functions/Tools/DocumentTools.cs index eacf999..baeb07c 100644 --- a/src/DevBrain.Functions/Tools/DocumentTools.cs +++ b/src/DevBrain.Functions/Tools/DocumentTools.cs @@ -1,6 +1,6 @@ using System.Text.Json; -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; +using DevBrain.Core.Models; +using DevBrain.Core.Services; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Extensions.Mcp; 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..3a59f97 --- /dev/null +++ b/src/DevBrain.Server/DevBrain.Server.csproj @@ -0,0 +1,20 @@ + + + + 2.0.0 + 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..83c0c81 --- /dev/null +++ b/src/DevBrain.Server/Program.cs @@ -0,0 +1,258 @@ +using System.Globalization; +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 = "2.0.0", + 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 v2 configured MCP stateless=true accessTokenLifetimeMinutes={AccessTokenLifetimeMinutes} refreshReplayLifetimeMinutes={RefreshReplayLifetimeMinutes}", + (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; +} + +public partial class Program; diff --git a/src/DevBrain.Server/Tools/ServerDocumentTools.cs b/src/DevBrain.Server/Tools/ServerDocumentTools.cs new file mode 100644 index 0000000..c58349c --- /dev/null +++ b/src/DevBrain.Server/Tools/ServerDocumentTools.cs @@ -0,0 +1,494 @@ +using System.Text.Json; +using DevBrain.Core.Models; +using DevBrain.Core.Services; +using System.ComponentModel; +using Microsoft.AspNetCore.Http; +using ModelContextProtocol.Server; + +namespace DevBrain.Server.Tools; + +[McpServerToolType] +public sealed class ServerDocumentTools +{ + private readonly IDocumentStore _store; + private readonly IDocumentEditService _editService; + private readonly ITagEditService _tagEditService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ServerDocumentTools( + IDocumentStore store, + IDocumentEditService editService, + ITagEditService tagEditService, + IHttpContextAccessor httpContextAccessor) + { + _store = store; + _editService = editService; + _tagEditService = tagEditService; + _httpContextAccessor = httpContextAccessor; + } + + [McpServerTool(Name = "UpsertDocument", Destructive = true, Idempotent = true), Description("Create or replace a document by key.")] + public async Task UpsertDocument( + [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) + { + return keyError; + } + + try + { + var updatedBy = GetCallerIdentity(); + var resolvedProject = project ?? "default"; + + var document = new BrainDocument + { + Id = key, + Key = key, + Project = resolvedProject, + Content = content, + Tags = tags ?? [], + UpdatedAt = DateTimeOffset.UtcNow, + UpdatedBy = updatedBy + }; + + var saved = await _store.UpsertAsync(document); + return JsonSerializer.Serialize(saved); + } + catch (Exception ex) + { + return $"Error upserting document: {ex.Message}"; + } + } + + [McpServerTool(Name = "GetDocument", ReadOnly = true, Idempotent = true), Description("Retrieve a document by key.")] + public async Task GetDocument( + [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) + { + return $"Document not found: '{key}'"; + } + + return JsonSerializer.Serialize(document); + } + + [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( + [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) + { + return $"Document not found: '{key}'"; + } + + return JsonSerializer.Serialize(new + { + key = document.Key, + project = document.Project, + tags = document.Tags, + updatedAt = document.UpdatedAt, + updatedBy = document.UpdatedBy, + contentHash = document.ContentHash, + contentLength = document.ContentLength + }); + } + + [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( + [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) + { + return "Provide either 'content' or 'contentHash' to compare against."; + } + + if (content is not null && contentHash is not null) + { + return "Provide either 'content' or 'contentHash', not both."; + } + + var candidateHash = contentHash ?? ContentHashing.ComputeSha256(content!); + + var document = await _store.GetMetadataAsync(key, project ?? "default"); + if (document is null) + { + return JsonSerializer.Serialize(new + { + key, + found = false, + match = false, + message = $"Document not found: '{key}'" + }); + } + + var isMatch = string.Equals(document.ContentHash, candidateHash, StringComparison.OrdinalIgnoreCase); + + return JsonSerializer.Serialize(new + { + key, + found = true, + match = isMatch, + storedContentHash = document.ContentHash, + storedContentLength = document.ContentLength, + candidateHash, + updatedAt = document.UpdatedAt, + updatedBy = document.UpdatedBy + }); + } + + [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( + [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)) + { + return JsonSerializer.Serialize(new EditPreviewResult + { + Key = key, + Project = project ?? "default", + Found = false, + WouldReplace = false, + Message = "'oldText' must be a non-empty string." + }); + } + + var result = await _editService.PreviewAsync( + key, + project ?? "default", + oldText, + newText, + expectedOccurrences ?? 1, + caseSensitive ?? false); + + return JsonSerializer.Serialize(result); + } + + [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( + [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)) + { + return JsonSerializer.Serialize(new EditApplyResult + { + Key = key, + Project = project ?? "default", + Applied = false, + Message = "'oldText' must be a non-empty string." + }); + } + + if (string.IsNullOrWhiteSpace(expectedContentHash)) + { + return JsonSerializer.Serialize(new EditApplyResult + { + Key = key, + Project = project ?? "default", + Applied = false, + Message = "'expectedContentHash' is required." + }); + } + + var result = await _editService.ApplyAsync( + key, + project ?? "default", + oldText, + newText, + expectedOccurrences ?? 1, + caseSensitive ?? false, + expectedContentHash, + GetCallerIdentity()); + + return JsonSerializer.Serialize(result); + } + + [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( + [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); + + if (documents.Count == 1 && documents[0].Key == "_suggestion") + { + return JsonSerializer.Serialize(new[] + { + new { key = documents[0].Key, content = documents[0].Content } + }); + } + + var projection = documents.Select(d => new + { + key = d.Key, + tags = d.Tags, + updatedAt = d.UpdatedAt, + updatedBy = d.UpdatedBy, + project = d.Project + }); + + return JsonSerializer.Serialize(projection); + } + + [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( + [Description("Document key to delete (e.g. sprint:old-notes).")] string key, + [Description("Project scope (default: \"default\").")] string? project) + { + try + { + var resolvedProject = project ?? "default"; + var deleted = await _store.DeleteAsync(key, resolvedProject); + + return JsonSerializer.Serialize(new + { + key, + project = resolvedProject, + deleted, + message = deleted + ? $"Deleted '{key}' from project '{resolvedProject}'." + : $"No document found at '{key}' in project '{resolvedProject}' (nothing to delete)." + }); + } + catch (Exception ex) + { + return $"Error deleting document: {ex.Message}"; + } + } + + [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( + [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) + { + return keyError; + } + + try + { + var updatedBy = GetCallerIdentity(); + var resolvedProject = project ?? "default"; + var resolvedSeparator = separator ?? "\n\n"; + + var saved = await _store.AppendAsync( + key, + resolvedProject, + content, + resolvedSeparator, + tags ?? [], + updatedBy); + + return JsonSerializer.Serialize(new + { + key = saved.Key, + project = saved.Project, + tags = saved.Tags, + updatedAt = saved.UpdatedAt, + updatedBy = saved.UpdatedBy, + contentHash = saved.ContentHash, + contentLength = saved.Content.Length + }); + } + catch (Exception ex) + { + return $"Error appending document: {ex.Message}"; + } + } + + [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( + [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) + { + return keyError; + } + + if (key.StartsWith("_staging:", StringComparison.Ordinal)) + { + return "Keys starting with '_staging:' are reserved for chunked-upload internals."; + } + + if (totalChunks <= 0) + { + return "totalChunks must be a positive integer."; + } + + if (chunkIndex < 0 || chunkIndex >= totalChunks) + { + return $"chunkIndex {chunkIndex} is out of range for totalChunks {totalChunks}."; + } + + try + { + var updatedBy = GetCallerIdentity(); + var resolvedProject = project ?? "default"; + + var result = await _store.UpsertChunkAsync( + key, + resolvedProject, + content, + chunkIndex, + totalChunks, + tags ?? [], + updatedBy); + + return JsonSerializer.Serialize(new + { + key, + project = resolvedProject, + status = result.Status, + chunksReceived = result.ChunksReceived, + totalChunks = result.TotalChunks, + document = result.Document is null ? null : new + { + key = result.Document.Key, + project = result.Document.Project, + tags = result.Document.Tags, + updatedAt = result.Document.UpdatedAt, + updatedBy = result.Document.UpdatedBy, + contentHash = result.Document.ContentHash, + contentLength = result.Document.Content.Length + } + }); + } + catch (Exception ex) + { + return $"Error processing chunk: {ex.Message}"; + } + } + + [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( + [Description("Search term to match against keys and content.")] string query, + [Description("Project scope (default: \"default\").")] string? project) + { + try + { + var documents = await _store.SearchAsync(query, project ?? "default"); + + if (documents.Count == 1 && documents[0].Key == "_suggestion") + { + return JsonSerializer.Serialize(new[] + { + new { key = documents[0].Key, content = documents[0].Content } + }); + } + + var projection = documents.Select(d => new + { + key = d.Key, + tags = d.Tags, + updatedAt = d.UpdatedAt, + project = d.Project, + contentExcerpt = d.Content.Length > 300 ? d.Content[..300] + "..." : d.Content + }); + + return JsonSerializer.Serialize(projection); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { results = Array.Empty(), message = $"Search failed: {ex.Message}" }); + } + } + + [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( + [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) + { + return keyError; + } + + try + { + var result = await _tagEditService.EditTagsAsync( + key, + project ?? "default", + add ?? [], + remove ?? [], + GetCallerIdentity()); + + return JsonSerializer.Serialize(result); + } + catch (Exception ex) + { + return $"Error editing tags: {ex.Message}"; + } + } + + /// + /// Enforces the colon-key convention on write paths. Writes that use '/' as a separator + /// collide on id (EncodeId maps '/' → ':') but land in a different partition (raw key), + /// producing two distinct documents that look identical from the id axis. Rejecting at + /// the write boundary prevents the collision at the source. Reads keep the slash fallback + /// so older callers continue to work (Postel's law). + /// + private static string? ValidateWriteKey(string key) + { + if (string.IsNullOrEmpty(key) || !key.Contains('/')) + { + return null; + } + + var suggested = key.Replace('/', ':'); + return $"Keys must use ':' as separator. Got '{key}' — did you mean '{suggested}'?"; + } + + private string GetCallerIdentity() + { + var claimsPrincipal = _httpContextAccessor.HttpContext?.User; + + if (claimsPrincipal?.Identity?.IsAuthenticated == true) + { + var upn = claimsPrincipal.FindFirst("preferred_username")?.Value; + if (!string.IsNullOrEmpty(upn)) + return upn; + + var oid = claimsPrincipal.FindFirst("oid")?.Value; + if (!string.IsNullOrEmpty(oid)) + return oid; + } + + return "unknown"; + } +} 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.Functions.Tests/Auth/Crypto/PkceTests.cs index 859d501..1319f5d 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Crypto/PkceTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Crypto/PkceTests.cs @@ -1,4 +1,4 @@ -using DevBrain.Functions.Auth.Crypto; +using DevBrain.Core.Auth.Crypto; namespace DevBrain.Functions.Tests.Auth.Crypto; diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs index 68efff2..e5e53a7 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/AuthorizationHandlerTests.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; @@ -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.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs index 32e022d..4b8208a 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/CallbackHandlerTests.cs @@ -1,7 +1,7 @@ using System.Web; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; @@ -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/OAuthWireFormatTests.cs b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs index 3a03207..dac46b8 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/OAuthWireFormatTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using DevBrain.Core.Auth.DcrFacade; using DevBrain.Functions.Auth.DcrFacade; namespace DevBrain.Functions.Tests.Auth.DcrFacade; @@ -90,6 +91,7 @@ public void RegistrationResponseDto_EmitsRfc7591SnakeCaseFieldNames() 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 +101,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,6 +115,7 @@ 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); @@ -133,6 +137,7 @@ public void RegistrationResponseDto_NullClientName_IsOmittedFromOutput() ClientIdIssuedAt: 0L, ClientName: null, RedirectUris: ["https://example.com/cb"], + ApplicationType: "web", TokenEndpointAuthMethod: "none"); var json = JsonSerializer.Serialize(dto, options); @@ -161,6 +166,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 +181,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) diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs index 3f31c32..f07a2a8 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/RegistrationHandlerTests.cs @@ -1,7 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; -using DevBrain.Functions.Auth.DcrFacade; +using DevBrain.Core.Auth.DcrFacade; using DevBrain.Functions.Tests.Auth.Services; +using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; namespace DevBrain.Functions.Tests.Auth.DcrFacade; @@ -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.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs index 3b0b893..3ea749c 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs @@ -1,8 +1,9 @@ -using DevBrain.Functions.Auth.Crypto; -using DevBrain.Functions.Auth.DcrFacade; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Crypto; +using DevBrain.Core.Auth.DcrFacade; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.Auth.Services; +using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; namespace DevBrain.Functions.Tests.Auth.DcrFacade; @@ -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.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs index 66bb199..7b57b67 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Middleware/JwtAuthenticatorTests.cs @@ -1,6 +1,6 @@ -using DevBrain.Functions.Auth.Middleware; -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Middleware; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.Auth.Services; using Microsoft.Extensions.Time.Testing; @@ -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.Functions.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs b/tests/DevBrain.Functions.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs new file mode 100644 index 0000000..f8c6617 --- /dev/null +++ b/tests/DevBrain.Functions.Tests/Auth/Services/CosmosOAuthStateStoreTests.cs @@ -0,0 +1,18 @@ +using DevBrain.Core.Auth.Services; + +namespace DevBrain.Functions.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.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs index f338ea5..4361c77 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerRoundTripTests.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs b/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs index 8e11209..7236266 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/DevBrainJwtIssuerTests.cs @@ -1,4 +1,4 @@ -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using Microsoft.Extensions.Time.Testing; using Microsoft.IdentityModel.JsonWebTokens; diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs b/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs index 98f25b1..35a5111 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientActivationTests.cs @@ -1,4 +1,4 @@ -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs b/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs index 21d31a1..e85e626 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientIdTokenValidationTests.cs @@ -1,6 +1,6 @@ using System.Net; using System.Text.Json; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; namespace DevBrain.Functions.Tests.Auth.Services; @@ -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.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs index 6dbed37..2e88800 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/EntraOAuthClientTests.cs @@ -1,7 +1,7 @@ using System.Net; using System.Text; using System.Text.Json; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; namespace DevBrain.Functions.Tests.Auth.Services; diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs b/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs index 48b43fd..a3941fb 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs @@ -1,5 +1,5 @@ -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; namespace DevBrain.Functions.Tests.Auth.Services; @@ -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.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs index fd4d599..01b610f 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStoreTests.cs @@ -1,5 +1,5 @@ -using DevBrain.Functions.Auth.Models; -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Models; +using DevBrain.Core.Auth.Services; using DevBrain.Functions.Tests.TestHelpers; using Microsoft.Extensions.Time.Testing; diff --git a/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs b/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs index cea385a..6372c1a 100644 --- a/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/Services/UpstreamTokenProtectorTests.cs @@ -1,7 +1,7 @@ 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; diff --git a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj b/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj index 054df31..30005ed 100644 --- a/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj +++ b/tests/DevBrain.Functions.Tests/DevBrain.Functions.Tests.csproj @@ -8,13 +8,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs index fbc8b8f..1d6506a 100644 --- a/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs +++ b/tests/DevBrain.Functions.Tests/Services/DocumentEditServiceTests.cs @@ -1,5 +1,5 @@ -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; +using DevBrain.Core.Models; +using DevBrain.Core.Services; namespace DevBrain.Functions.Tests.Services; @@ -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.Functions.Tests/Services/TagEditServiceTests.cs index 64af014..fc19d53 100644 --- a/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs +++ b/tests/DevBrain.Functions.Tests/Services/TagEditServiceTests.cs @@ -1,5 +1,5 @@ -using DevBrain.Functions.Models; -using DevBrain.Functions.Services; +using DevBrain.Core.Models; +using DevBrain.Core.Services; namespace DevBrain.Functions.Tests.Services; @@ -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/FakeUpstreamOAuthClient.cs b/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs index e7f115c..e476f52 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs +++ b/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamOAuthClient.cs @@ -1,4 +1,4 @@ -using DevBrain.Functions.Auth.Services; +using DevBrain.Core.Auth.Services; namespace DevBrain.Functions.Tests.TestHelpers; @@ -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.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs index 5f32a37..71d09d2 100644 --- a/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs +++ b/tests/DevBrain.Functions.Tests/TestHelpers/FakeUpstreamTokenProtector.cs @@ -1,6 +1,6 @@ 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; diff --git a/tests/DevBrain.Functions.Tests/TestHelpers/RecordingLogger.cs b/tests/DevBrain.Functions.Tests/TestHelpers/RecordingLogger.cs new file mode 100644 index 0000000..22fa0ec --- /dev/null +++ b/tests/DevBrain.Functions.Tests/TestHelpers/RecordingLogger.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; + +namespace DevBrain.Functions.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.Server.Tests/DevBrain.Server.Tests.csproj b/tests/DevBrain.Server.Tests/DevBrain.Server.Tests.csproj new file mode 100644 index 0000000..9c82ef4 --- /dev/null +++ b/tests/DevBrain.Server.Tests/DevBrain.Server.Tests.csproj @@ -0,0 +1,28 @@ + + + + false + true + $(NoWarn);CA1707;CA2007 + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/tests/DevBrain.Server.Tests/ServerEndpointTests.cs b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs new file mode 100644 index 0000000..3bb913f --- /dev/null +++ b/tests/DevBrain.Server.Tests/ServerEndpointTests.cs @@ -0,0 +1,332 @@ +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.Functions.Tools; +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 void ServerToolContractsMatchFunctionsCompatibilityHost() + { + var functionsContracts = ReadFunctionsContracts(); + var serverContracts = ReadServerContracts(); + + Assert.Equal(functionsContracts.Length, serverContracts.Length); + for (var index = 0; index < functionsContracts.Length; index++) + { + Assert.Equal(functionsContracts[index].Name, serverContracts[index].Name); + Assert.Equal(functionsContracts[index].Description, serverContracts[index].Description); + Assert.Equal(functionsContracts[index].Parameters, serverContracts[index].Parameters); + } + } + + [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"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + Assert.Equal("healthy", body!["status"]); + } + + [Fact] + public async Task McpWithoutBearerToken_ReturnsProtectedResourceChallenge() + { + using var response = await _client.PostAsync("/mcp", JsonContent.Create(new { })); + + 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"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(); + 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); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var responseText = await response.Content.ReadAsStringAsync(); + 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())); + } + + private static ToolContract[] ReadFunctionsContracts() => + typeof(DocumentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Select(method => + { + var trigger = method.GetParameters() + .SelectMany(parameter => parameter.GetCustomAttributesData()) + .SingleOrDefault(attribute => attribute.AttributeType.Name == "McpToolTriggerAttribute"); + if (trigger is null) + { + return null; + } + + var parameters = method.GetParameters() + .Select(parameter => (Parameter: parameter, Attribute: parameter.GetCustomAttributesData() + .SingleOrDefault(attribute => attribute.AttributeType.Name == "McpToolPropertyAttribute"))) + .Where(item => item.Attribute is not null) + .Select(item => new ToolParameterContract( + Name: (string)item.Attribute!.ConstructorArguments[0].Value!, + Description: (string)item.Attribute.ConstructorArguments[1].Value!, + Type: item.Parameter.ParameterType, + Required: (bool)item.Attribute.ConstructorArguments[2].Value!)) + .ToArray(); + + return new ToolContract( + Name: (string)trigger.ConstructorArguments[0].Value!, + Description: (string)trigger.ConstructorArguments[1].Value!, + Parameters: parameters); + }) + .Where(contract => contract is not null) + .Cast() + .OrderBy(contract => contract.Name, StringComparer.Ordinal) + .ToArray(); + + private static ToolContract[] ReadServerContracts() + { + var nullability = new NullabilityInfoContext(); + return typeof(ServerDocumentTools) + .GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Select(method => (Method: method, Tool: method.GetCustomAttribute())) + .Where(item => item.Tool is not null) + .Select(item => new ToolContract( + Name: item.Tool!.Name!, + Description: item.Method.GetCustomAttribute()!.Description, + Parameters: item.Method.GetParameters() + .Select(parameter => new ToolParameterContract( + Name: parameter.Name!, + Description: parameter.GetCustomAttribute()!.Description, + Type: parameter.ParameterType, + Required: parameter.ParameterType.IsValueType + ? Nullable.GetUnderlyingType(parameter.ParameterType) is null + : nullability.Create(parameter).ReadState == NullabilityState.NotNull)) + .ToArray())) + .OrderBy(contract => contract.Name, StringComparer.Ordinal) + .ToArray(); + } + + private sealed record ToolContract( + string Name, + string Description, + ToolParameterContract[] Parameters); + + private sealed record ToolParameterContract( + string Name, + string Description, + Type Type, + bool Required); +} + +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(); +}