From a7732af849a687347e78c26109ecbf74055ba9f8 Mon Sep 17 00:00:00 2001 From: Derek Gabriel Date: Sat, 8 Aug 2026 20:13:42 -1000 Subject: [PATCH] Make OAuth token windows configurable --- CHANGELOG.md | 11 ++- README.md | 37 ++++++-- infra/main.bicep | 10 +++ infra/main.parameters.json | 6 ++ .../Auth/DcrFacade/TokenHandler.cs | 53 ++++++++--- src/DevBrain.Functions/Program.cs | 46 +++++++++- .../Auth/DcrFacade/TokenHandlerTests.cs | 89 ++++++++++++++++++- 7 files changed, 227 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8226d6..1ff9622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/README.md b/README.md index 8ea7c3a..e4622e5 100644 --- a/README.md +++ b/README.md @@ -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 --name jwt-signing-secret --value $(openssl rand -base64 32) az keyvault secret set --vault-name --name entra-client-secret --value @@ -111,13 +113,39 @@ https:///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:///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 @@ -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 @@ -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 diff --git a/infra/main.bicep b/infra/main.bicep index 0e01a18..ab7f844 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -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) ─────────────────────────── @@ -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). diff --git a/infra/main.parameters.json b/infra/main.parameters.json index a9c672c..fc25df0 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -13,6 +13,12 @@ }, "entraClientId": { "value": "${ENTRA_CLIENT_ID}" + }, + "oauthAccessTokenLifetimeMinutes": { + "value": "${OAUTH_ACCESS_TOKEN_LIFETIME_MINUTES=}" + }, + "oauthRefreshReplayLifetimeMinutes": { + "value": "${OAUTH_REFRESH_REPLAY_LIFETIME_MINUTES=}" } } } diff --git a/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs b/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs index cbcf1ad..61da71b 100644 --- a/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs +++ b/src/DevBrain.Functions/Auth/DcrFacade/TokenHandler.cs @@ -21,17 +21,9 @@ namespace DevBrain.Functions.Auth.DcrFacade; /// 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); @@ -39,10 +31,11 @@ public sealed class TokenHandler private readonly IOAuthStateStore _store; private readonly DevBrainJwtIssuer _jwtIssuer; private readonly TimeProvider _timeProvider; + private readonly TokenHandlerOptions _options; private readonly ILogger? _logger; public TokenHandler(IOAuthStateStore store, DevBrainJwtIssuer jwtIssuer, TimeProvider timeProvider) - : this(store, jwtIssuer, timeProvider, logger: null) + : this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger: null) { } @@ -51,10 +44,22 @@ public TokenHandler( DevBrainJwtIssuer jwtIssuer, TimeProvider timeProvider, ILogger? logger) + : this(store, jwtIssuer, timeProvider, TokenHandlerOptions.Default, logger) { + } + + public TokenHandler( + IOAuthStateStore store, + DevBrainJwtIssuer jwtIssuer, + TimeProvider timeProvider, + TokenHandlerOptions options, + ILogger? logger) + { + options.Validate(); _store = store; _jwtIssuer = jwtIssuer; _timeProvider = timeProvider; + _options = options; _logger = logger; } @@ -137,7 +142,7 @@ private async Task 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")); } @@ -161,7 +166,7 @@ private async Task HandleRefreshAsync(TokenRequest request) request.ClientId, replacementRefresh, RefreshTokenLifetime, - RefreshReplayLifetime, + _options.RefreshReplayLifetime, UpstreamVaultTtl); if (!rotation.Succeeded) @@ -186,7 +191,7 @@ private async Task 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")); } @@ -206,7 +211,7 @@ private async Task 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 MintAndStoreRefreshAsync(string clientId, string upstreamJti) @@ -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, diff --git a/src/DevBrain.Functions/Program.cs b/src/DevBrain.Functions/Program.cs index 29dc524..d842967 100644 --- a/src/DevBrain.Functions/Program.cs +++ b/src/DevBrain.Functions/Program.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using Azure.Core; using Azure.Identity; @@ -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(); @@ -171,7 +175,14 @@ builder.UseWhen(ctx => ctx.FunctionDefinition.InputBindings.Values.Any(b => b.Type == "mcpToolTrigger")); -builder.Build().Run(); +var host = builder.Build(); +var startupLogger = host.Services.GetRequiredService().CreateLogger("DevBrain.Startup"); +startupLogger.LogInformation( + "OAuth token windows configured accessTokenLifetimeMinutes={AccessTokenLifetimeMinutes} refreshReplayLifetimeMinutes={RefreshReplayLifetimeMinutes}", + (int)tokenHandlerOptions.AccessTokenLifetime.TotalMinutes, + (int)tokenHandlerOptions.RefreshReplayLifetime.TotalMinutes); + +host.Run(); static void EnsureConfig(IConfiguration config, string key) { @@ -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; +} diff --git a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs index 498a9b7..3b0b893 100644 --- a/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs +++ b/tests/DevBrain.Functions.Tests/Auth/DcrFacade/TokenHandlerTests.cs @@ -26,7 +26,7 @@ private sealed record Harness( DevBrainJwtIssuer JwtIssuer, FakeTimeProvider Clock); - private static Harness Create() + private static Harness Create(TokenHandlerOptions? options = null) { var clock = new FakeTimeProvider(Epoch); var store = new FakeOAuthStateStore(clock); @@ -39,7 +39,9 @@ private static Harness Create() TenantId = TestTenantId, }, clock); - var handler = new TokenHandler(store, jwtIssuer, 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); } @@ -101,6 +103,29 @@ public async Task AuthorizationCode_ValidRequest_ReturnsJwtAndRefresh() Assert.True(validation.IsValid); } + [Fact] + public async Task AuthorizationCode_ConfiguredAccessTokenLifetime_ControlsExpiresIn() + { + var h = Create(new TokenHandlerOptions( + AccessTokenLifetime: TimeSpan.FromMinutes(45), + RefreshReplayLifetime: TimeSpan.FromMinutes(5))); + var (code, verifier, _) = await SeedAuthCodeAsync(h); + + var result = await h.Handler.HandleAsync(new TokenRequest( + GrantType: "authorization_code", + ClientId: ClientId, + Code: code, + CodeVerifier: verifier, + RedirectUri: ClientRedirect, + RefreshToken: null)); + + Assert.True(result.IsSuccess); + Assert.Equal(2700, result.Response!.ExpiresIn); + + var validation = await h.JwtIssuer.ValidateAsync(result.Response.AccessToken); + Assert.True(validation.IsValid); + } + /// Gate #3: authorization code replay rejection. [Fact] public async Task AuthorizationCode_ReplayRejected() @@ -250,6 +275,66 @@ public async Task RefreshToken_RotatesOldAndAllowsShortReplay() Assert.True(third.IsSuccess); } + [Fact] + public async Task RefreshToken_ConfiguredReplayWindow_AllowsLongerReplay() + { + var h = Create(new TokenHandlerOptions( + AccessTokenLifetime: TimeSpan.FromMinutes(10), + RefreshReplayLifetime: TimeSpan.FromMinutes(15))); + var (code, verifier, _) = await SeedAuthCodeAsync(h); + + var initial = await h.Handler.HandleAsync(new TokenRequest( + "authorization_code", ClientId, code, verifier, ClientRedirect, null)); + var firstRefresh = initial.Response!.RefreshToken; + + var refreshed = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, firstRefresh)); + Assert.True(refreshed.IsSuccess); + + h.Clock.Advance(TimeSpan.FromMinutes(10)); + + var replayed = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, firstRefresh)); + Assert.True(replayed.IsSuccess); + Assert.Equal(refreshed.Response!.RefreshToken, replayed.Response!.RefreshToken); + + h.Clock.Advance(TimeSpan.FromMinutes(6)); + + var lateReplay = await h.Handler.HandleAsync(new TokenRequest( + "refresh_token", ClientId, null, null, null, firstRefresh)); + Assert.False(lateReplay.IsSuccess); + Assert.Equal("invalid_grant", lateReplay.ErrorCode); + } + + [Theory] + [InlineData(0, 5)] + [InlineData(10, 0)] + [InlineData(1441, 5)] + [InlineData(10, 1441)] + public void Constructor_InvalidTokenHandlerOptions_Rejected(int accessMinutes, int replayMinutes) + { + var clock = new FakeTimeProvider(Epoch); + var store = new FakeOAuthStateStore(clock); + var jwtIssuer = new DevBrainJwtIssuer( + new DevBrainJwtIssuerOptions + { + SigningSecret = DevBrainJwtIssuer.GenerateSigningSecret(), + Issuer = Issuer, + Audience = Audience, + TenantId = TestTenantId, + }, + clock); + + Assert.Throws(() => new TokenHandler( + store, + jwtIssuer, + clock, + new TokenHandlerOptions( + AccessTokenLifetime: TimeSpan.FromMinutes(accessMinutes), + RefreshReplayLifetime: TimeSpan.FromMinutes(replayMinutes)), + logger: null)); + } + [Fact] public async Task RefreshToken_WrongClient_Rejected() {