Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,7 @@ Thumbs.db
/tools/

#Claude folder
.claude/
.claude/

# Serena local workspace metadata
.serena/
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to DevBrain are tracked in this file. Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- 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 five-minute 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.

### Changed
- 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.
- 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; it still reports broader direct/transitive updates in Azure Functions, Application Insights, IdentityModel, Cosmos, and test tooling that are left for a separate dependency refresh.
- `dotnet list devbrain.slnx package --deprecated` still reports two known migration items left outside this auth fix: `Microsoft.ApplicationInsights.WorkerService` 2.22.0 and `xunit` 2.9.3.
- `dotnet test devbrain.slnx` passes with 141 tests.

## [1.9.0] — 2026-04-15

A new `EditTags` tool lets callers adjust tag metadata on an existing document without re-emitting its content. Previously the only way to add or drop a tag was a full `UpsertDocument` round-trip that re-sent the entire body — wasteful for large documents whose content isn't changing.
Expand Down
11 changes: 9 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ Thanks for your interest in contributing to DevBrain!
1. Fork the repository and create a feature branch from `main`.
2. Make your changes. Keep commits focused and atomic.
3. Ensure `dotnet build` completes with no warnings (warnings are treated as errors).
4. Open a pull request against `main` with a clear description of the change.
5. A maintainer will review and merge once CI passes.
4. For dependency changes, check the whole solution from the repository root before opening the PR:
```bash
dotnet list devbrain.slnx package --vulnerable --include-transitive
dotnet list devbrain.slnx package --outdated --highest-patch
dotnet list devbrain.slnx package --outdated --include-transitive
dotnet list devbrain.slnx package --deprecated
```
5. Open a pull request against `main` with a clear description of the change.
6. A maintainer will review and merge once CI passes.

## Code Style

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ OAuth completes automatically — no proxy, no function key, no manual headers.
codex mcp add devbrain --transport http https://<FUNCTION_URL>/runtime/webhooks/mcp
```

DevBrain rotates OAuth refresh tokens on every refresh. To tolerate Codex Windows App/CLI retry or restart races while the local credential cache catches up, DevBrain keeps a short replay window for the just-rotated token and returns the same replacement refresh token during that window.

### 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.
Expand Down Expand Up @@ -270,6 +272,14 @@ Keys use colon as the separator (e.g. `sprint:license-sync`). **Writes** (`Upser
func start
```

5. Optional dependency health checks from the repository root:
```powershell
dotnet list devbrain.slnx package --vulnerable --include-transitive
dotnet list devbrain.slnx package --outdated --highest-patch
dotnet list devbrain.slnx package --outdated --include-transitive
dotnet list devbrain.slnx package --deprecated
```

## Authentication

DevBrain implements RFC 7591 Dynamic Client Registration (DCR) with an in-process OAuth proxy that brokers a single pre-registered Entra app. From the client's perspective, DevBrain *is* the authorization server. Internally it delegates to your tenant's Entra ID for user authentication.
Expand All @@ -281,6 +291,10 @@ This solves two problems that previously blocked MCP OAuth:

Every write operation records the authenticated user's Entra UPN in the `updatedBy` field.

### Refresh Token Rotation

Access tokens are short-lived and DevBrain refresh tokens rotate on every refresh. 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 that window still fail with `invalid_grant`, and every successful refresh or replay extends the upstream token vault record for the same local refresh window.

## Known Limitations

### VS Code / GitHub Copilot MCP extension — OAuth not triggered
Expand Down
43 changes: 25 additions & 18 deletions src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ namespace DevBrain.Functions.Auth.DcrFacade;
/// <summary>
/// Service layer for <c>POST /token</c>. Handles both the <c>authorization_code</c> and
/// <c>refresh_token</c> grant types. Atomicity of code redemption and refresh rotation lives in
/// <see cref="IOAuthStateStore.RedeemAuthCodeAsync"/> / <see cref="IOAuthStateStore.ConsumeRefreshAsync"/>
/// <see cref="IOAuthStateStore.RedeemAuthCodeAsync"/> / <see cref="IOAuthStateStore.RotateRefreshAsync"/>
/// — this handler is responsible for the validation around those two atomic pivots.
///
/// <para>Acceptance gates covered here:</para>
/// <list type="bullet">
/// <item><b>#2 PKCE downgrade</b> — verifier mismatch returns <c>invalid_grant</c>.</item>
/// <item><b>#3 Code replay</b> — second redemption returns <c>invalid_grant</c> via the atomic store.</item>
/// <item><b>#5 Refresh rotation</b> — every refresh grant rotates; the old token is consumed atomically.</item>
/// <item><b>#5 Refresh rotation</b> — every refresh grant rotates; the old token becomes a short-lived replay marker.</item>
/// </list>
/// </summary>
public sealed class TokenHandler
Expand All @@ -28,6 +28,14 @@ public sealed class TokenHandler
// Refresh tokens: 30 days to match the sprint spec. Rotated on every use.
private static readonly TimeSpan RefreshTokenLifetime = TimeSpan.FromDays(30);

// Short replay window for clients that retry or restart immediately after a rotation but still
// present the just-rotated token from local credential cache.
private static readonly TimeSpan RefreshReplayLifetime = TimeSpan.FromMinutes(5);

// Keep the upstream vault alive for the full local refresh window. CallbackHandler creates the
// initial vault record; the refresh path slides it forward on every successful rotation/replay.
private static readonly TimeSpan UpstreamVaultTtl = TimeSpan.FromDays(30);

private readonly IOAuthStateStore _store;
private readonly DevBrainJwtIssuer _jwtIssuer;
private readonly TimeProvider _timeProvider;
Expand Down Expand Up @@ -147,34 +155,33 @@ private async Task<TokenResult> HandleRefreshAsync(TokenRequest request)
return TokenResult.Error("invalid_request", "client_id is required.");
}

var record = await _store.ConsumeRefreshAsync(request.RefreshToken);
if (record is null)
{
_logger?.LogWarning("TokenHandler/refresh: rejected — refresh_token invalid, expired, or already rotated");
return TokenResult.Error("invalid_grant", "refresh_token is invalid, expired, or already rotated.");
}
var replacementRefresh = GenerateOpaqueToken();
var rotation = await _store.RotateRefreshAsync(
request.RefreshToken,
request.ClientId,
replacementRefresh,
RefreshTokenLifetime,
RefreshReplayLifetime,
UpstreamVaultTtl);

if (!string.Equals(record.ClientId, request.ClientId, StringComparison.Ordinal))
if (rotation is null)
{
_logger?.LogWarning(
"TokenHandler/refresh: rejected — client binding mismatch tokenClientId={TokenClientId} requestClientId={RequestClientId}",
record.ClientId, request.ClientId);
return TokenResult.Error("invalid_grant", "refresh_token was issued to a different client.");
_logger?.LogWarning("TokenHandler/refresh: rejected — refresh_token invalid, expired, wrong client, or upstream session expired");
return TokenResult.Error("invalid_grant", "refresh_token is invalid, expired, already rotated outside the replay window, or bound to a different client.");
}

var upstreamJti = record.UpstreamJti;
var upstreamJti = rotation.UpstreamJti;
var (jwt, _) = IssueJwtForUpstream(upstreamJti);
var newRefresh = await MintAndStoreRefreshAsync(record.ClientId, upstreamJti);

_logger?.LogInformation(
"TokenHandler/refresh: rotated refresh clientId={ClientId} upstreamJti={Jti}",
record.ClientId, upstreamJti);
"TokenHandler/refresh: {RotationKind} refresh clientId={ClientId} upstreamJti={Jti}",
rotation.IsReplay ? "replayed" : "rotated", request.ClientId, upstreamJti);

return TokenResult.Success(new TokenResponse(
AccessToken: jwt,
TokenType: "Bearer",
ExpiresIn: (int)AccessTokenLifetime.TotalSeconds,
RefreshToken: newRefresh,
RefreshToken: rotation.RefreshToken,
Scope: "documents.readwrite"));
}

Expand Down
18 changes: 15 additions & 3 deletions src/DevBrain.Functions/Auth/Models/DevBrainRefreshRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ namespace DevBrain.Functions.Auth.Models;

/// <summary>
/// A DevBrain refresh token. Rotated on every use (see
/// <see cref="Services.IOAuthStateStore.ConsumeRefreshAsync"/>) — FastMCP's rotation pattern,
/// adopted because a long-lived non-rotating refresh is a meaningfully worse stolen-token story.
/// Stored in Cosmos under key <c>refresh:{RefreshToken}</c>.
/// <see cref="Services.IOAuthStateStore.RotateRefreshAsync"/>) with a short replay marker for
/// idempotent client retries. Stored in Cosmos under key <c>refresh:{RefreshToken}</c>.
/// </summary>
public sealed class DevBrainRefreshRecord
{
Expand All @@ -32,6 +31,19 @@ public sealed class DevBrainRefreshRecord
[JsonPropertyName("expiresAt")]
public DateTimeOffset ExpiresAt { get; set; }

/// <summary>
/// When set, this record is no longer an active refresh token. It is a short-lived replay
/// marker pointing to the replacement token returned by the winning rotation request.
/// </summary>
[JsonPropertyName("rotatedToRefreshToken")]
public string? RotatedToRefreshToken { get; set; }

[JsonPropertyName("rotatedAt")]
public DateTimeOffset? RotatedAt { get; set; }

[JsonPropertyName("ttl")]
public int Ttl { get; set; }

[JsonIgnore]
public bool IsReplayMarker => !string.IsNullOrEmpty(RotatedToRefreshToken);
}
Loading
Loading