From e9477e393165fb892de9f3fe169170e63b475e89 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 21 Oct 2026 15:56:10 +0200 Subject: [PATCH 1/2] Apply JWT defaults in `jwt create` when options are omitted. Closes #1951 `--valid-for` was an `Option`, yielding 0 when omitted, and `--audiences` yielded an empty list, so the `JwtCreatorOptions` defaults (60 minutes, https://myserver.com) never applied. Tokens expired immediately and had no audience. Make `--valid-for` nullable and treat an empty audience list as omitted so the CLI matches `POST /proxy/jwtToken`. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5ef9e780-1aa5-4a65-bafe-6113ab5f1d4b --- DevProxy.Tests/JwtCommandTests.cs | 48 ++++++++++++++++++++++++++ DevProxy/Commands/JwtCommand.cs | 56 +++++++++++++++++++++---------- 2 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 DevProxy.Tests/JwtCommandTests.cs diff --git a/DevProxy.Tests/JwtCommandTests.cs b/DevProxy.Tests/JwtCommandTests.cs new file mode 100644 index 000000000..2f14e6ce9 --- /dev/null +++ b/DevProxy.Tests/JwtCommandTests.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using DevProxy.Commands; +using DevProxy.Jwt; +using System.IdentityModel.Tokens.Jwt; + +using Xunit; + +namespace DevProxy.Tests; + +public class JwtCommandTests +{ + [Fact] + public void GetJwtOptions_NoValidForOrAudiences_LeavesThemNull() + { + var command = new JwtCommand(); + + var options = command.GetJwtOptions(command.Parse("create")); + + Assert.Null(options.ValidFor); + Assert.Null(options.Audiences); + } + + [Fact] + public void GetJwtOptions_ValidForAndAudiences_UsesProvidedValues() + { + var command = new JwtCommand(); + + var options = command.GetJwtOptions(command.Parse("create --valid-for 30 -a api1 -a api2")); + + Assert.Equal(30, options.ValidFor); + Assert.Equal(["api1", "api2"], options.Audiences!); + } + + [Fact] + public void CreateToken_FromDefaultCliOptions_MatchesApiDefaults() + { + var command = new JwtCommand(); + var options = command.GetJwtOptions(command.Parse("create")); + + var token = new JwtSecurityTokenHandler().ReadJwtToken(JwtTokenGenerator.CreateToken(options)); + + Assert.Equal(["https://myserver.com"], token.Audiences); + Assert.Equal(TimeSpan.FromMinutes(60), token.ValidTo - token.ValidFrom); + } +} diff --git a/DevProxy/Commands/JwtCommand.cs b/DevProxy/Commands/JwtCommand.cs index 90c39a602..58e0001c2 100644 --- a/DevProxy/Commands/JwtCommand.cs +++ b/DevProxy/Commands/JwtCommand.cs @@ -10,6 +10,15 @@ namespace DevProxy.Commands; sealed class JwtCommand : Command { + private Option? _nameOption; + private Option>? _audiencesOption; + private Option? _issuerOption; + private Option>? _rolesOption; + private Option>? _scopesOption; + private Option>? _claimsOption; + private Option? _validForOption; + private Option? _signingKeyOption; + public JwtCommand() : base("jwt", "Manage JSON Web Tokens") { @@ -78,7 +87,7 @@ private void ConfigureCommand() } }; - var jwtValidForOption = new Option("--valid-for", "-v") + var jwtValidForOption = new Option("--valid-for", "-v") { Description = "The duration for which the token is valid. Duration is set in minutes." }; @@ -122,22 +131,16 @@ private void ConfigureCommand() "devproxy jwt create --claims \"dept:eng\" Custom claims", ]); - jwtCreateCommand.SetAction(parseResult => - { - var jwtOptions = new JwtOptions - { - Name = parseResult.GetValue(jwtNameOption), - Audiences = parseResult.GetValue(jwtAudiencesOption), - Issuer = parseResult.GetValue(jwtIssuerOption), - Roles = parseResult.GetValue(jwtRolesOption), - Scopes = parseResult.GetValue(jwtScopesOption), - Claims = parseResult.GetValue(jwtClaimsOption), - ValidFor = parseResult.GetValue(jwtValidForOption), - SigningKey = parseResult.GetValue(jwtSigningKeyOption) - }; - - GetToken(jwtOptions); - }); + _nameOption = jwtNameOption; + _audiencesOption = jwtAudiencesOption; + _issuerOption = jwtIssuerOption; + _rolesOption = jwtRolesOption; + _scopesOption = jwtScopesOption; + _claimsOption = jwtClaimsOption; + _validForOption = jwtValidForOption; + _signingKeyOption = jwtSigningKeyOption; + + jwtCreateCommand.SetAction(parseResult => GetToken(GetJwtOptions(parseResult))); this.AddCommands(new List { @@ -145,6 +148,25 @@ private void ConfigureCommand() }.OrderByName()); } + internal JwtOptions GetJwtOptions(ParseResult parseResult) + { + var audiences = parseResult.GetValue(_audiencesOption!); + + // Leave omitted options null so that JwtCreatorOptions applies the + // same defaults as the API (60 minutes, default audience). + return new JwtOptions + { + Name = parseResult.GetValue(_nameOption!), + Audiences = audiences?.Any() == true ? audiences : null, + Issuer = parseResult.GetValue(_issuerOption!), + Roles = parseResult.GetValue(_rolesOption!), + Scopes = parseResult.GetValue(_scopesOption!), + Claims = parseResult.GetValue(_claimsOption!), + ValidFor = parseResult.GetValue(_validForOption!), + SigningKey = parseResult.GetValue(_signingKeyOption!) + }; + } + private static void GetToken(JwtOptions jwtOptions) { var token = JwtTokenGenerator.CreateToken(jwtOptions); From 7cdc1707008f87b88b332f95e8524d14cc66ccd6 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Mon, 26 Oct 2026 11:30:03 +0100 Subject: [PATCH 2/2] Avoid clock-boundary flakiness in JWT defaults test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- DevProxy.Tests/JwtCommandTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DevProxy.Tests/JwtCommandTests.cs b/DevProxy.Tests/JwtCommandTests.cs index 2f14e6ce9..2376cc0bb 100644 --- a/DevProxy.Tests/JwtCommandTests.cs +++ b/DevProxy.Tests/JwtCommandTests.cs @@ -40,9 +40,12 @@ public void CreateToken_FromDefaultCliOptions_MatchesApiDefaults() var command = new JwtCommand(); var options = command.GetJwtOptions(command.Parse("create")); + var before = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var token = new JwtSecurityTokenHandler().ReadJwtToken(JwtTokenGenerator.CreateToken(options)); + var after = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); Assert.Equal(["https://myserver.com"], token.Audiences); - Assert.Equal(TimeSpan.FromMinutes(60), token.ValidTo - token.ValidFrom); + Assert.InRange(new DateTimeOffset(token.ValidFrom).ToUnixTimeSeconds(), before, after); + Assert.InRange(new DateTimeOffset(token.ValidTo).ToUnixTimeSeconds(), before + 3600, after + 3600); } }