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
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman
## [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.
- 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.
- Added Bicep validation for the required Entra tenant/client parameters so `azd provision` fails fast instead of blanking `OAuth__EntraTenantId` or `OAuth__EntraClientId`.

### 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 Codex credential generations can be correlated without logging token material.
- 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.5.0, `ModelContextProtocol` 1.3.0, `Microsoft.ApplicationInsights.WorkerService` 2.23.0, `Microsoft.Azure.Cosmos` 3.60.0, `Microsoft.Extensions.Azure` 1.14.0, IdentityModel 8.18.0, and the Data Protection/XML crypto 10.0.8 servicing line.
- 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.5.0, `ModelContextProtocol` 1.3.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.
- Kept Application Insights on the direct Azure Functions isolated worker integration path (`AddApplicationInsightsTelemetryWorkerService` + `ConfigureFunctionsApplicationInsights`) instead of moving to the newer OpenTelemetry telemetry wiring.
- Refreshed test tooling to `Microsoft.NET.Test.Sdk` 18.6.0, `xunit.runner.visualstudio` 3.1.5, and `Microsoft.Extensions.TimeProvider.Testing` 10.6.0.
- Synced the release notes with merged Dependabot PR #19, which already moved `Microsoft.AspNetCore.DataProtection` and `System.Security.Cryptography.Xml` to 10.0.7.
Expand All @@ -21,7 +24,7 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman
- `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 142 tests.
- `dotnet test devbrain.slnx` passes with 148 tests.

## [1.9.0] — 2026-04-15

Expand Down
37 changes: 32 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ azd up

**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:

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

```powershell
az keyvault secret set --vault-name <kv-name> --name jwt-signing-secret --value $(openssl rand -base64 32)
az keyvault secret set --vault-name <kv-name> --name entra-client-secret --value <secret-from-entra-app>
Expand Down Expand Up @@ -111,13 +113,39 @@ https://<FUNCTION_URL>/runtime/webhooks/mcp

OAuth completes automatically — no proxy, no function key, no manual headers.

### Codex (Windows App / CLI)
### ChatGPT / Codex (Windows App and CLI)

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://<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.
### OAuth token windows

DevBrain rotates OAuth refresh tokens on every refresh. To tolerate brief client retry or restart races while a credential cache catches up, the just-rotated token remains a replay marker for a short period and returns the same replacement refresh token during that window.

Deployments can tune the access-token lifetime and refresh replay window when their client mix or operating environment needs a different refresh cadence. For example:

```powershell
azd env set OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES 45
azd env set OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES 5
azd provision
azd deploy
```

`azd provision` applies the Bicep app settings. `azd deploy` only deploys application code, so existing Function App settings persist across code-only updates. If the `OAUTH_*` values are not set in the azd environment, Bicep creates blank settings and DevBrain uses its built-in defaults.

These `azd` values provision the Function App 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.

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

Expand Down Expand Up @@ -293,7 +321,7 @@ Every write operation records the authenticated user's Entra UPN in the `updated

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

Expand Down Expand Up @@ -321,11 +349,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 |
| Codex App | Windows | OAuth (DCR) | ✅ Working |
| ChatGPT / Codex unified app | Windows | OAuth (DCR) | ✅ Working; monitoring |
| 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) |
| ChatGPT | — | — | ❌ MCP not supported |
| Cursor | — | OAuth (DCR) | Not tested |

## Contributing
Expand Down
10 changes: 10 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@ param environmentName string
// lives in Key Vault, not here.

@description('Entra tenant GUID (single-tenant only — not "common" or "organizations").')
@minLength(1)
param entraTenantId string

@description('Entra app registration client ID for the single pre-registered DevBrain app.')
@minLength(1)
param entraClientId 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 = ''

var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))

// ─── Storage Account (required by Azure Functions) ───────────────────────────
Expand Down Expand Up @@ -283,6 +291,8 @@ resource functionApp 'Microsoft.Web/sites@2024-04-01' = {
{ name: 'OAuth__EntraClientId', value: entraClientId }
{ name: 'OAuth__EntraClientSecret', value: '@Microsoft.KeyVault(SecretUri=https://${keyVault.name}${environment().suffixes.keyvaultDns}/secrets/entra-client-secret/)' }
{ name: 'OAuth__JwtSigningSecret', value: '@Microsoft.KeyVault(SecretUri=https://${keyVault.name}${environment().suffixes.keyvaultDns}/secrets/jwt-signing-secret/)' }
{ name: 'OAuth__AccessTokenLifetimeMinutes', value: oauthAccessTokenLifetimeMinutes }
{ name: 'OAuth__RefreshReplayLifetimeMinutes', value: oauthRefreshReplayLifetimeMinutes }
{ name: 'KeyVault__Name', value: keyVault.name }
// v1.6 Data Protection: key ring persisted in blob storage, protected by the KV key above.
// Used exclusively by IUpstreamTokenProtector (purpose: DevBrain.OAuth.UpstreamToken).
Expand Down
6 changes: 6 additions & 0 deletions infra/main.parameters.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
},
"entraClientId": {
"value": "${ENTRA_CLIENT_ID}"
},
"oauthAccessTokenLifetimeMinutes": {
"value": "${OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES=}"
},
"oauthRefreshReplayLifetimeMinutes": {
"value": "${OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES=}"
}
}
}
53 changes: 40 additions & 13 deletions src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,28 +21,21 @@ namespace DevBrain.Functions.Auth.DcrFacade;
/// </summary>
public sealed class TokenHandler
{
// Short-lived access tokens (10 min) keep the stolen-token blast radius small without
// making the refresh loop noticeably expensive.
private static readonly TimeSpan AccessTokenLifetime = TimeSpan.FromMinutes(10);

// 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;
private readonly TokenHandlerOptions _options;
private readonly ILogger<TokenHandler>? _logger;

public TokenHandler(IOAuthStateStore store, DevBrainJwtIssuer jwtIssuer, TimeProvider timeProvider)
: this(store, jwtIssuer, timeProvider, logger: null)
: this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger: null)
{
}

Expand All @@ -51,10 +44,22 @@ public TokenHandler(
DevBrainJwtIssuer jwtIssuer,
TimeProvider timeProvider,
ILogger<TokenHandler>? logger)
: this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger)
{
}

public TokenHandler(
IOAuthStateStore store,
DevBrainJwtIssuer jwtIssuer,
TimeProvider timeProvider,
TokenHandlerOptions options,
ILogger<TokenHandler>? logger)
{
options.Validate();
_store = store;
_jwtIssuer = jwtIssuer;
_timeProvider = timeProvider;
_options = options;
_logger = logger;
}

Expand Down Expand Up @@ -137,7 +142,7 @@ private async Task<TokenResult> HandleAuthorizationCodeAsync(TokenRequest reques
return TokenResult.Success(new TokenResponse(
AccessToken: jwt,
TokenType: "Bearer",
ExpiresIn: (int)AccessTokenLifetime.TotalSeconds,
ExpiresIn: (int)_options.AccessTokenLifetime.TotalSeconds,
RefreshToken: refresh,
Scope: "documents.readwrite"));
}
Expand All @@ -161,7 +166,7 @@ private async Task<TokenResult> HandleRefreshAsync(TokenRequest request)
request.ClientId,
replacementRefresh,
RefreshTokenLifetime,
RefreshReplayLifetime,
_options.RefreshReplayLifetime,
UpstreamVaultTtl);

if (!rotation.Succeeded)
Expand All @@ -186,7 +191,7 @@ private async Task<TokenResult> HandleRefreshAsync(TokenRequest request)
return TokenResult.Success(new TokenResponse(
AccessToken: jwt,
TokenType: "Bearer",
ExpiresIn: (int)AccessTokenLifetime.TotalSeconds,
ExpiresIn: (int)_options.AccessTokenLifetime.TotalSeconds,
RefreshToken: rotation.RefreshToken!,
Scope: "documents.readwrite"));
}
Expand All @@ -206,7 +211,7 @@ private async Task<TokenResult> HandleRefreshAsync(TokenRequest request)
// But DevBrainJwtIssuer.Issue doesn't accept a pre-chosen JTI. This is the one place we
// need to side-step it and craft the token directly — or change the issuer to allow an
// override. The cleanest fix is the override route.
return _jwtIssuer.IssueWithJti(subject: $"upstream-{upstreamJti}", jti: upstreamJti, lifetime: AccessTokenLifetime);
return _jwtIssuer.IssueWithJti(subject: $"upstream-{upstreamJti}", jti: upstreamJti, lifetime: _options.AccessTokenLifetime);
}

private async Task<string> MintAndStoreRefreshAsync(string clientId, string upstreamJti)
Expand Down Expand Up @@ -240,6 +245,28 @@ private static string FingerprintToken(string token)
}
}

public sealed record TokenHandlerOptions(TimeSpan AccessTokenLifetime, TimeSpan RefreshReplayLifetime)
{
public static TokenHandlerOptions Default { get; } = new(
AccessTokenLifetime: TimeSpan.FromMinutes(10),
RefreshReplayLifetime: TimeSpan.FromMinutes(5));

public void Validate()
{
ValidateLifetime(nameof(AccessTokenLifetime), AccessTokenLifetime);
ValidateLifetime(nameof(RefreshReplayLifetime), RefreshReplayLifetime);
}

private static void ValidateLifetime(string name, TimeSpan lifetime)
{
if (lifetime < TimeSpan.FromMinutes(1) || lifetime > TimeSpan.FromDays(1))
{
throw new InvalidOperationException(
$"TokenHandlerOptions.{name} must be between 1 minute and 24 hours.");
}
}
}

public sealed record TokenRequest(
string GrantType,
string? ClientId,
Expand Down
46 changes: 45 additions & 1 deletion src/DevBrain.Functions/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using Azure.Core;
using Azure.Identity;
Expand Down Expand Up @@ -61,6 +62,9 @@
EnsureConfig(startupConfig, "DataProtection:BlobUri");
EnsureConfig(startupConfig, "DataProtection:KeyVaultKeyUri");

var tokenHandlerOptions = BuildTokenHandlerOptions(startupConfig);
builder.Services.AddSingleton(tokenHandlerOptions);

builder.Services.AddSingleton(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
Expand Down Expand Up @@ -171,7 +175,14 @@
builder.UseWhen<McpJwtValidationMiddleware>(ctx =>
ctx.FunctionDefinition.InputBindings.Values.Any(b => b.Type == "mcpToolTrigger"));

builder.Build().Run();
var host = builder.Build();
var startupLogger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("DevBrain.Startup");
startupLogger.LogInformation(
"OAuth token windows configured accessTokenLifetimeMinutes={AccessTokenLifetimeMinutes} refreshReplayLifetimeMinutes={RefreshReplayLifetimeMinutes}",
(int)tokenHandlerOptions.AccessTokenLifetime.TotalMinutes,
(int)tokenHandlerOptions.RefreshReplayLifetime.TotalMinutes);

host.Run();

static void EnsureConfig(IConfiguration config, string key)
{
Expand All @@ -181,3 +192,36 @@ static void EnsureConfig(IConfiguration config, string key)
"Set it via app settings, environment variable (double-underscore form), or local.settings.json.");
}
}

static TokenHandlerOptions BuildTokenHandlerOptions(IConfiguration config)
{
var options = new TokenHandlerOptions(
AccessTokenLifetime: TimeSpan.FromMinutes(ReadOptionalWholeMinutes(
config,
"OAuth:AccessTokenLifetimeMinutes",
(int)TokenHandlerOptions.Default.AccessTokenLifetime.TotalMinutes)),
RefreshReplayLifetime: TimeSpan.FromMinutes(ReadOptionalWholeMinutes(
config,
"OAuth:RefreshReplayLifetimeMinutes",
(int)TokenHandlerOptions.Default.RefreshReplayLifetime.TotalMinutes)));

options.Validate();
return options;
}

static int ReadOptionalWholeMinutes(IConfiguration config, string key, int defaultValue)
{
var value = config[key];
if (string.IsNullOrWhiteSpace(value))
{
return defaultValue;
}

if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var minutes))
{
throw new InvalidOperationException(
$"Configuration value '{key}' must be a whole number of minutes.");
}

return minutes;
}
Loading
Loading