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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to DevBrain are tracked in this file. Versions follow [Seman
- 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
- 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.
- 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.
Expand All @@ -17,7 +18,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; 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.
- `dotnet test devbrain.slnx` passes with 142 tests.

## [1.9.0] — 2026-04-15

Expand Down
25 changes: 19 additions & 6 deletions src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,24 +164,30 @@ private async Task<TokenResult> HandleRefreshAsync(TokenRequest request)
RefreshReplayLifetime,
UpstreamVaultTtl);

if (rotation is null)
if (!rotation.Succeeded)
{
_logger?.LogWarning("TokenHandler/refresh: rejected — refresh_token invalid, expired, wrong client, or upstream session expired");
_logger?.LogWarning(
"TokenHandler/refresh: rejected reason={Reason} clientId={ClientId} refreshTokenFingerprint={RefreshTokenFingerprint}",
rotation.LogCode, request.ClientId, FingerprintToken(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;
var upstreamJti = rotation.UpstreamJti!;
var (jwt, _) = IssueJwtForUpstream(upstreamJti);

_logger?.LogInformation(
"TokenHandler/refresh: {RotationKind} refresh clientId={ClientId} upstreamJti={Jti}",
rotation.IsReplay ? "replayed" : "rotated", request.ClientId, upstreamJti);
"TokenHandler/refresh: {RotationKind} refresh clientId={ClientId} upstreamJti={Jti} refreshTokenFingerprint={RefreshTokenFingerprint} returnedRefreshTokenFingerprint={ReturnedRefreshTokenFingerprint}",
rotation.IsReplay ? "replayed" : "rotated",
request.ClientId,
upstreamJti,
FingerprintToken(request.RefreshToken),
FingerprintToken(rotation.RefreshToken!));

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

Expand Down Expand Up @@ -225,6 +231,13 @@ private static string GenerateOpaqueToken()
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}

private static string FingerprintToken(string token)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token), hash);
return Convert.ToHexString(hash[..6]).ToLowerInvariant();
}
}

public sealed record TokenRequest(
Expand Down
59 changes: 40 additions & 19 deletions src/DevBrain.Functions/Auth/Services/CosmosOAuthStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ public Task SaveRefreshAsync(DevBrainRefreshRecord refresh)
return UpsertAsync(refresh, key);
}

public async Task<RefreshRotationResult?> RotateRefreshAsync(
public async Task<RefreshRotationResult> RotateRefreshAsync(
string refreshToken,
string clientId,
string replacementRefreshToken,
Expand All @@ -216,38 +216,41 @@ record = response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.Missing);
}

if (record.ExpiresAt <= now)
{
await TryDeleteAsync<DevBrainRefreshRecord>(key, partition, etag);
return null;
return RefreshRotationResult.Rejected(
record.IsReplayMarker
? RefreshRotationOutcome.ReplayWindowExpired
: RefreshRotationOutcome.Expired);
}

if (!string.Equals(record.ClientId, clientId, StringComparison.Ordinal))
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient);
}

if (record.IsReplayMarker)
{
if (string.IsNullOrEmpty(record.RotatedToRefreshToken))
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.ReplayMarkerMissingReplacement);
}

if (!await TouchUpstreamTokenAsync(record.UpstreamJti, upstreamVaultLifetime))
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.UpstreamMissingOrExpired);
}

return new RefreshRotationResult(record.UpstreamJti, record.RotatedToRefreshToken, IsReplay: true);
return RefreshRotationResult.Replayed(record.UpstreamJti, record.RotatedToRefreshToken);
}

if (!await TouchUpstreamTokenAsync(record.UpstreamJti, upstreamVaultLifetime))
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.UpstreamMissingOrExpired);
}

var replacement = new DevBrainRefreshRecord
Expand Down Expand Up @@ -283,7 +286,7 @@ await _container.ReplaceItemAsync(
partition,
new ItemRequestOptions { IfMatchEtag = etag });

return new RefreshRotationResult(record.UpstreamJti, replacementRefreshToken, IsReplay: false);
return RefreshRotationResult.Rotated(record.UpstreamJti, replacementRefreshToken);
}
catch (CosmosException ex)
when (ex.StatusCode == HttpStatusCode.PreconditionFailed
Expand All @@ -292,7 +295,10 @@ 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<DevBrainRefreshRecord>(RefreshKey(replacementRefreshToken));
return await ReadRefreshReplayAsync(refreshToken, clientId, upstreamVaultLifetime);
var replay = await ReadRefreshReplayAsync(refreshToken, clientId, upstreamVaultLifetime);
return replay.Succeeded
? replay
: RefreshRotationResult.Rejected(RefreshRotationOutcome.ConcurrentReplayUnavailable);
}
}

Expand Down Expand Up @@ -338,7 +344,7 @@ await _container.DeleteItemAsync<DevBrainRefreshRecord>(

// ---------------- Internal helpers ----------------

private async Task<RefreshRotationResult?> ReadRefreshReplayAsync(
private async Task<RefreshRotationResult> ReadRefreshReplayAsync(
string refreshToken,
string clientId,
TimeSpan upstreamVaultLifetime)
Expand All @@ -348,24 +354,39 @@ await _container.DeleteItemAsync<DevBrainRefreshRecord>(
{
var response = await _container.ReadItemAsync<DevBrainRefreshRecord>(key, new PartitionKey(key));
var record = response.Resource;
if (record.ExpiresAt <= _timeProvider.GetUtcNow()
|| !record.IsReplayMarker
|| string.IsNullOrEmpty(record.RotatedToRefreshToken)
|| !string.Equals(record.ClientId, clientId, StringComparison.Ordinal))
if (record.ExpiresAt <= _timeProvider.GetUtcNow())
{
return null;
return RefreshRotationResult.Rejected(
record.IsReplayMarker
? RefreshRotationOutcome.ReplayWindowExpired
: RefreshRotationOutcome.Expired);
}

if (!string.Equals(record.ClientId, clientId, StringComparison.Ordinal))
{
return RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient);
}

if (!record.IsReplayMarker)
{
return RefreshRotationResult.Rejected(RefreshRotationOutcome.ConcurrentReplayUnavailable);
}

if (string.IsNullOrEmpty(record.RotatedToRefreshToken))
{
return RefreshRotationResult.Rejected(RefreshRotationOutcome.ReplayMarkerMissingReplacement);
}

if (!await TouchUpstreamTokenAsync(record.UpstreamJti, upstreamVaultLifetime))
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.UpstreamMissingOrExpired);
}

return new RefreshRotationResult(record.UpstreamJti, record.RotatedToRefreshToken, IsReplay: true);
return RefreshRotationResult.Replayed(record.UpstreamJti, record.RotatedToRefreshToken);
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
return RefreshRotationResult.Rejected(RefreshRotationOutcome.Missing);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/DevBrain.Functions/Auth/Services/IOAuthStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public interface IOAuthStateStore
/// replacement token. The referenced upstream vault record is defensively extended as part of
/// the rotation/replay path.
/// </summary>
Task<RefreshRotationResult?> RotateRefreshAsync(
Task<RefreshRotationResult> RotateRefreshAsync(
string refreshToken,
string clientId,
string replacementRefreshToken,
Expand Down
54 changes: 50 additions & 4 deletions src/DevBrain.Functions/Auth/Services/RefreshRotationResult.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,56 @@
namespace DevBrain.Functions.Auth.Services;

/// <summary>
/// Outcome of rotating a DevBrain refresh token.
/// </summary>
public enum RefreshRotationOutcome
{
Rotated,
Replayed,
Missing,
Expired,
ReplayWindowExpired,
WrongClient,
ReplayMarkerMissingReplacement,
UpstreamMissingOrExpired,
ConcurrentReplayUnavailable,
}

/// <summary>
/// Result of rotating a DevBrain refresh token. Immediate replays of the old token return the
/// same replacement refresh token so client retries remain idempotent.
/// same replacement refresh token so client retries remain idempotent. Rejections carry a
/// reason code for server-side diagnostics; callers still return a generic OAuth
/// <c>invalid_grant</c> to clients.
/// </summary>
public sealed record RefreshRotationResult(
string UpstreamJti,
string RefreshToken,
bool IsReplay);
string? UpstreamJti,
string? RefreshToken,
RefreshRotationOutcome Outcome)
{
public bool Succeeded => Outcome is RefreshRotationOutcome.Rotated or RefreshRotationOutcome.Replayed;

public bool IsReplay => Outcome is RefreshRotationOutcome.Replayed;

public string LogCode => Outcome switch
{
RefreshRotationOutcome.Rotated => "rotated",
RefreshRotationOutcome.Replayed => "replayed",
RefreshRotationOutcome.Missing => "missing",
RefreshRotationOutcome.Expired => "expired",
RefreshRotationOutcome.ReplayWindowExpired => "replay_window_expired",
RefreshRotationOutcome.WrongClient => "wrong_client",
RefreshRotationOutcome.ReplayMarkerMissingReplacement => "replay_marker_missing_replacement",
RefreshRotationOutcome.UpstreamMissingOrExpired => "upstream_missing_or_expired",
RefreshRotationOutcome.ConcurrentReplayUnavailable => "concurrent_replay_unavailable",
_ => "unknown",
};

public static RefreshRotationResult Rotated(string upstreamJti, string refreshToken) =>
new(upstreamJti, refreshToken, RefreshRotationOutcome.Rotated);

public static RefreshRotationResult Replayed(string upstreamJti, string refreshToken) =>
new(upstreamJti, refreshToken, RefreshRotationOutcome.Replayed);

public static RefreshRotationResult Rejected(RefreshRotationOutcome outcome) =>
new(null, null, outcome);
}
29 changes: 17 additions & 12 deletions tests/DevBrain.Functions.Tests/Auth/Services/FakeOAuthStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ public Task SaveRefreshAsync(DevBrainRefreshRecord refresh)
}
}

public Task<RefreshRotationResult?> RotateRefreshAsync(
public Task<RefreshRotationResult> RotateRefreshAsync(
string refreshToken,
string clientId,
string replacementRefreshToken,
Expand All @@ -255,35 +255,41 @@ public Task SaveRefreshAsync(DevBrainRefreshRecord refresh)
ReadCallCount++;
if (!_refreshes.TryGetValue(refreshToken, out var record))
{
return Task.FromResult<RefreshRotationResult?>(null);
return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.Missing));
}

if (IsExpired(record.ExpiresAt))
{
_refreshes.Remove(refreshToken);
return Task.FromResult<RefreshRotationResult?>(null);
return Task.FromResult(RefreshRotationResult.Rejected(
record.IsReplayMarker
? RefreshRotationOutcome.ReplayWindowExpired
: RefreshRotationOutcome.Expired));
}

if (!string.Equals(record.ClientId, clientId, StringComparison.Ordinal))
{
return Task.FromResult<RefreshRotationResult?>(null);
return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.WrongClient));
}

if (record.IsReplayMarker)
{
if (string.IsNullOrEmpty(record.RotatedToRefreshToken)
|| !TouchUpstreamTokenCore(record.UpstreamJti, upstreamVaultLifetime))
if (string.IsNullOrEmpty(record.RotatedToRefreshToken))
{
return Task.FromResult<RefreshRotationResult?>(null);
return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.ReplayMarkerMissingReplacement));
}

return Task.FromResult<RefreshRotationResult?>(
new RefreshRotationResult(record.UpstreamJti, record.RotatedToRefreshToken, IsReplay: true));
if (!TouchUpstreamTokenCore(record.UpstreamJti, upstreamVaultLifetime))
{
return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.UpstreamMissingOrExpired));
}

return Task.FromResult(RefreshRotationResult.Replayed(record.UpstreamJti, record.RotatedToRefreshToken));
}

if (!TouchUpstreamTokenCore(record.UpstreamJti, upstreamVaultLifetime))
{
return Task.FromResult<RefreshRotationResult?>(null);
return Task.FromResult(RefreshRotationResult.Rejected(RefreshRotationOutcome.UpstreamMissingOrExpired));
}

var now = _timeProvider.GetUtcNow();
Expand All @@ -309,8 +315,7 @@ public Task SaveRefreshAsync(DevBrainRefreshRecord refresh)
Ttl = (int)replayLifetime.TotalSeconds,
};

return Task.FromResult<RefreshRotationResult?>(
new RefreshRotationResult(record.UpstreamJti, replacementRefreshToken, IsReplay: false));
return Task.FromResult(RefreshRotationResult.Rotated(record.UpstreamJti, replacementRefreshToken));
}
}

Expand Down
Loading
Loading