diff --git a/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs index a9eb9253852f..0b6dda38a89f 100644 --- a/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs +++ b/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; +using System.Text; using Microsoft.Extensions.CommandLineUtils; using Microsoft.Extensions.Tools.Internal; @@ -11,7 +12,7 @@ namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools; internal sealed class CreateCommand { private static readonly string[] _dateTimeFormats = new[] { - "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy/MM/dd", "yyyy/MM/dd HH:mm" }; + "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy/MM/dd", "yyyy/MM/dd HH:mm", "yyyy-MM-ddTHH:mm:ss.fffffffzzz" }; private static readonly string[] _timeSpanFormats = new[] { @"d\dh\hm\ms\s", @"d\dh\hm\m", @"d\dh\h", @"d\d", @"h\hm\ms\s", @"h\hm\m", @"h\h", @@ -32,7 +33,7 @@ public static void Register(ProjectCommandLineApplication app) ); var nameOption = cmd.Option( - "--name", + "-n|--name", Resources.CreateCommand_NameOption_Description, CommandOptionType.SingleValue); @@ -80,7 +81,7 @@ public static void Register(ProjectCommandLineApplication app) cmd.OnExecute(() => { - var (options, isValid) = ValidateArguments( + var (options, isValid, optionsString) = ValidateArguments( cmd.Reporter, cmd.ProjectOption, schemeNameOption, nameOption, audienceOption, issuerOption, notBeforeOption, expiresOnOption, validForOption, rolesOption, scopesOption, claimsOption); if (!isValid) @@ -88,12 +89,12 @@ public static void Register(ProjectCommandLineApplication app) return 1; } - return Execute(cmd.Reporter, cmd.ProjectOption.Value(), options); + return Execute(cmd.Reporter, cmd.ProjectOption.Value(), options, optionsString); }); }); } - private static (JwtCreatorOptions, bool) ValidateArguments( + private static (JwtCreatorOptions, bool, string) ValidateArguments( IReporter reporter, CommandOption projectOption, CommandOption schemeNameOption, @@ -109,16 +110,22 @@ private static (JwtCreatorOptions, bool) ValidateArguments( { var isValid = true; var project = DevJwtCliHelpers.GetProject(projectOption.Value()); + var scheme = schemeNameOption.HasValue() ? schemeNameOption.Value() : "Bearer"; + var optionsString = schemeNameOption.HasValue() ? $"{Resources.JwtPrint_Scheme}: {scheme}{Environment.NewLine}" : string.Empty; + var name = nameOption.HasValue() ? nameOption.Value() : Environment.UserName; + optionsString += $"{Resources.JwtPrint_Name}: {name}{Environment.NewLine}"; var audience = audienceOption.HasValue() ? audienceOption.Values : DevJwtCliHelpers.GetAudienceCandidatesFromLaunchSettings(project).ToList(); + optionsString += audienceOption.HasValue() ? $"{Resources.JwtPrint_Audiences}: {audience}{Environment.NewLine}" : string.Empty; if (audience is null) { reporter.Error(Resources.CreateCommand_NoAudience_Error); isValid = false; } var issuer = issuerOption.HasValue() ? issuerOption.Value() : DevJwtsDefaults.Issuer; + optionsString += issuerOption.HasValue() ? $"{Resources.JwtPrint_Issuer}: {issuer}{Environment.NewLine}" : string.Empty; var notBefore = DateTime.UtcNow; if (notBeforeOption.HasValue()) @@ -128,6 +135,7 @@ private static (JwtCreatorOptions, bool) ValidateArguments( reporter.Error(Resources.FormatCreateCommand_InvalidDate_Error("--not-before")); isValid = false; } + optionsString += $"{Resources.JwtPrint_NotBefore}: {notBefore:O}{Environment.NewLine}"; } var expiresOn = notBefore.AddMonths(3); @@ -138,6 +146,17 @@ private static (JwtCreatorOptions, bool) ValidateArguments( reporter.Error(Resources.FormatCreateCommand_InvalidDate_Error("--expires-on")); isValid = false; } + + if (validForOption.HasValue()) + { + reporter.Error(Resources.CreateCommand_InvalidExpiresOn_Error); + isValid = false; + } + else + { + optionsString += $"{Resources.JwtPrint_ExpiresOn}: {expiresOn:O}{Environment.NewLine}"; + } + } if (validForOption.HasValue()) @@ -147,10 +166,23 @@ private static (JwtCreatorOptions, bool) ValidateArguments( reporter.Error(Resources.FormatCreateCommand_InvalidPeriod_Error("--valid-for")); } expiresOn = notBefore.Add(validForValue); + + if (expiresOnOption.HasValue()) + { + reporter.Error(Resources.CreateCommand_InvalidExpiresOn_Error); + isValid = false; + } + else + { + optionsString += $"{Resources.JwtPrint_ExpiresOn}: {expiresOn:O}{Environment.NewLine}"; + } } var roles = rolesOption.HasValue() ? rolesOption.Values : new List(); + optionsString += rolesOption.HasValue() ? $"{Resources.JwtPrint_Roles}: [{string.Join(", ", roles)}]{Environment.NewLine}" : string.Empty; + var scopes = scopesOption.HasValue() ? scopesOption.Values : new List(); + optionsString += scopesOption.HasValue() ? $"{Resources.JwtPrint_Scopes}: {string.Join(", ", scopes)}{Environment.NewLine}" : string.Empty; var claims = new Dictionary(); if (claimsOption.HasValue()) @@ -160,9 +192,13 @@ private static (JwtCreatorOptions, bool) ValidateArguments( reporter.Error(Resources.CreateCommand_InvalidClaims_Error); isValid = false; } + optionsString += $"{Resources.JwtPrint_CustomClaims}: [{string.Join(", ", claims.Select(kvp => $"{kvp.Key}={kvp.Value}"))}]{Environment.NewLine}"; } - return (new JwtCreatorOptions(scheme, name, audience, issuer, notBefore, expiresOn, roles, scopes, claims), isValid); + return ( + new JwtCreatorOptions(scheme, name, audience, issuer, notBefore, expiresOn, roles, scopes, claims), + isValid, + optionsString); static bool ParseDate(string datetime, out DateTime parsedDateTime) => DateTime.TryParseExact(datetime, _dateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsedDateTime); @@ -171,7 +207,8 @@ static bool ParseDate(string datetime, out DateTime parsedDateTime) => private static int Execute( IReporter reporter, string projectPath, - JwtCreatorOptions options) + JwtCreatorOptions options, + string optionsString) { if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var project, out var userSecretsId)) { @@ -196,6 +233,8 @@ private static int Execute( settingsToWrite.Save(appsettingsFilePath); reporter.Output(Resources.FormatCreateCommand_Confirmed(jwtToken.Id)); + reporter.Output(optionsString); + reporter.Output($"{Resources.JwtPrint_Token}: {jwt.Token}"); return 0; } diff --git a/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs index f74c67a3f4b4..6ed346d20da2 100644 --- a/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs +++ b/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs @@ -42,11 +42,11 @@ private static int Execute(IReporter reporter, string projectPath, bool showToke if (jwtStore.Jwts is { Count: > 0 } jwts) { var table = new ConsoleTable(reporter); - table.AddColumns("Id", "Scheme Name", "Audience", "Issued", "Expires"); + table.AddColumns(Resources.JwtPrint_Id, Resources.JwtPrint_Scheme, Resources.JwtPrint_Audiences, Resources.JwtPrint_IssuedOn, Resources.JwtPrint_ExpiresOn); if (showTokens) { - table.AddColumns("Encoded Token"); + table.AddColumns(Resources.JwtPrint_Token); } foreach (var jwtRow in jwts) diff --git a/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs index 2842c13ef832..97873ad818df 100644 --- a/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs +++ b/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs @@ -15,11 +15,7 @@ public static void Register(ProjectCommandLineApplication app) cmd.Description = Resources.PrintCommand_Description; var idArgument = cmd.Argument("[id]", Resources.PrintCommand_IdArgument_Description); - - var showFullOption = cmd.Option( - "--show-full", - Resources.PrintCommand_ShowFullOption_Description, - CommandOptionType.NoValue); + var showAllOption = cmd.Option("--show-all", Resources.PrintCommand_ShowAllOption_Description, CommandOptionType.NoValue); cmd.HelpOption("-h|--help"); @@ -30,12 +26,16 @@ public static void Register(ProjectCommandLineApplication app) cmd.ShowHelp(); return 0; } - return Execute(cmd.Reporter, cmd.ProjectOption.Value(), idArgument.Value, showFullOption.HasValue()); + return Execute( + cmd.Reporter, + cmd.ProjectOption.Value(), + idArgument.Value, + showAllOption.HasValue()); }); }); } - private static int Execute(IReporter reporter, string projectPath, string id, bool showFull) + private static int Execute(IReporter reporter, string projectPath, string id, bool showAll) { if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var _, out var userSecretsId)) { @@ -50,13 +50,8 @@ private static int Execute(IReporter reporter, string projectPath, string id, bo } reporter.Output(Resources.FormatPrintCommand_Confirmed(id)); - JwtSecurityToken fullToken; - - if (showFull) - { - fullToken = JwtIssuer.Extract(jwt.Token); - DevJwtCliHelpers.PrintJwt(reporter, jwt, fullToken); - } + JwtSecurityToken fullToken = JwtIssuer.Extract(jwt.Token); + DevJwtCliHelpers.PrintJwt(reporter, jwt, showAll, fullToken); return 0; } diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs index 155c581cbf6a..eb300d533d1e 100644 --- a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs +++ b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Tools.Internal; +using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools; @@ -145,16 +146,48 @@ public static string[] GetAudienceCandidatesFromLaunchSettings(string project) return null; } - public static void PrintJwt(IReporter reporter, Jwt jwt, JwtSecurityToken fullToken = null) + public static void PrintJwt(IReporter reporter, Jwt jwt, bool showAll, JwtSecurityToken fullToken = null) { - reporter.Output(JsonSerializer.Serialize(jwt, new JsonSerializerOptions { WriteIndented = true })); + reporter.Output($"{Resources.JwtPrint_Id}: {jwt.Id}"); + reporter.Output($"{Resources.JwtPrint_Name}: {jwt.Name}"); + reporter.Output($"{Resources.JwtPrint_Scheme}: {jwt.Scheme}"); + reporter.Output($"{Resources.JwtPrint_Audiences}: {jwt.Audience}"); + reporter.Output($"{Resources.JwtPrint_NotBefore}: {jwt.NotBefore:O}"); + reporter.Output($"{Resources.JwtPrint_ExpiresOn}: {jwt.Expires:O}"); + reporter.Output($"{Resources.JwtPrint_IssuedOn}: {jwt.Issued:O}"); + + if (!jwt.Scopes.IsNullOrEmpty() || showAll) + { + var scopesValue = jwt.Scopes.IsNullOrEmpty() + ? "none" + : string.Join(", ", jwt.Scopes); + reporter.Output($"{Resources.JwtPrint_Scopes}: {scopesValue}"); + } + + if (!jwt.Roles.IsNullOrEmpty() || showAll) + { + var rolesValue = jwt.Roles.IsNullOrEmpty() + ? "none" + : String.Join(", ", jwt.Roles); + reporter.Output($"{Resources.JwtPrint_Roles}: [{rolesValue}]"); + } - if (fullToken is not null) + if (!jwt.CustomClaims.IsNullOrEmpty() || showAll) { - reporter.Output($"Token Header: {fullToken.Header.SerializeToJson()}"); - reporter.Output($"Token Payload: {fullToken.Payload.SerializeToJson()}"); + var customClaimsValue = jwt.CustomClaims.IsNullOrEmpty() + ? "none" + : string.Join(", ", jwt.CustomClaims.Select(kvp => $"{kvp.Key}={kvp.Value}")); + reporter.Output($"{Resources.JwtPrint_CustomClaims}: [{customClaimsValue}]"); } - reporter.Output($"Compact Token: {jwt.Token}"); + + if (showAll) + { + reporter.Output($"{Resources.JwtPrint_TokenHeader}: {fullToken.Header.SerializeToJson()}"); + reporter.Output($"{Resources.JwtPrint_TokenPayload}: {fullToken.Payload.SerializeToJson()}"); + } + + var tokenValueFieldName = showAll ? Resources.JwtPrint_CompactToken : Resources.JwtPrint_Token; + reporter.Output($"{tokenValueFieldName}: {jwt.Token}"); } public static bool TryParseClaims(List input, out Dictionary claims) diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs b/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs index e78112fa6cda..f465c9a95594 100644 --- a/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs +++ b/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs @@ -24,7 +24,7 @@ public static Jwt Create( IEnumerable roles = null, IDictionary customClaims = null) { - return new Jwt(token.Id, scheme, token.Subject, token.Audiences.FirstOrDefault(), token.ValidFrom, token.ValidTo, token.IssuedAt, encodedToken) + return new Jwt(token.Id, scheme, token.Subject, string.Join(", ", token.Audiences), token.ValidFrom, token.ValidTo, token.IssuedAt, encodedToken) { Scopes = scopes, Roles = roles, diff --git a/src/Tools/dotnet-user-jwts/src/Program.cs b/src/Tools/dotnet-user-jwts/src/Program.cs index 8967727ac24e..96ce31449cef 100644 --- a/src/Tools/dotnet-user-jwts/src/Program.cs +++ b/src/Tools/dotnet-user-jwts/src/Program.cs @@ -51,6 +51,11 @@ public void Run(string[] args) { userJwts.Execute(args); } + catch (CommandParsingException parsingException) + { + _reporter.Error(parsingException.Message); + userJwts.ShowHelp(); + } catch (Exception ex) { _reporter.Error(ex.Message); diff --git a/src/Tools/dotnet-user-jwts/src/Resources.resx b/src/Tools/dotnet-user-jwts/src/Resources.resx index b8b95ba88d07..3bd782f5b052 100644 --- a/src/Tools/dotnet-user-jwts/src/Resources.resx +++ b/src/Tools/dotnet-user-jwts/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -156,6 +156,9 @@ The date provided for '{type}' could not be parsed. Dates must consist of a date and can include an optional timestamp. + + '--valid-for' and '--expires-on' are mutually exclusive flags. Provide either option but not both. + The period provided for '{0}' could not be parsed. Ensure you use a format like '10d', '22h', '45s' etc. @@ -183,6 +186,51 @@ The period the JWT should expire after. Specify using a number followed by a duration type like 'd' for days, 'h' for hours, 'm' for minutes, and 's' for seconds, e.g. '365d'. Do not use this option in conjunction with the --expires-on option. + + Audience(s) + + + Compact Token + + + Custom Claims + + + Expires On + + + ID + + + Issued On + + + Issuer + + + Name + + + Not Before + + + Roles + + + Scheme + + + Scopes + + + Token + + + Token Header + + + Token Payload + Key reset canceled. @@ -234,8 +282,8 @@ No token with ID '{0}' found. - - Whether to show the full JWT contents in addition to the compact serialized format. + + Whether to show all details associated with the JWT. The path of the project to operate on. Defaults to the project in the current directory. @@ -252,4 +300,4 @@ No JWT with ID '{0}' found. - \ No newline at end of file + diff --git a/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs b/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs index e0db638ae63d..44a2a94798f7 100644 --- a/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs +++ b/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs @@ -147,4 +147,134 @@ public void Key_CanResetSigningKey() app.Run(new[] { "key", "--reset", "--force", "--project", project }); Assert.Contains("New signing key created:", _console.GetOutput()); } + + [Fact] + public void Command_ShowsHelpForInvalidCommand() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + var exception = Record.Exception(() => app.Run(new[] { "not-real", "--project", project })); + + Assert.Null(exception); + Assert.Contains("Unrecognized command or argument 'not-real'", _console.GetOutput()); + } + + [Fact] + public void CreateCommand_ShowsBasicTokenDetails() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project }); + var output = _console.GetOutput(); + + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains("Token: ", output); + Assert.DoesNotContain("Scheme", output); + } + + [Fact] + public void CreateCommand_SupportsODateTimeFormats() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project, "--expires-on", DateTime.Now.AddDays(2).ToString("O") }); + var output = _console.GetOutput(); + + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains("Token: ", output); + Assert.Contains("Expires On", output); + Assert.DoesNotContain("Scheme", output); + } + + [Fact] + public void CreateCommand_ShowsCustomizedTokenDetails() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project, "--scheme", "customScheme" }); + var output = _console.GetOutput(); + + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains("Token: ", output); + Assert.Contains("Scheme: customScheme", output); + } + + [Fact] + public void CreateCommand_DisplaysErrorForInvalidExpiresOnCombination() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project, "--expires-on", DateTime.UtcNow.AddDays(2).ToString("O"), "--valid-for", "2h" }); + var output = _console.GetOutput(); + + Assert.Contains($"'--valid-for' and '--expires-on' are mutually exclusive flags. Provide either option but not both.", output); + Assert.DoesNotContain("Expires On: ", output); + } + + [Fact] + public void PrintCommand_ShowsBasicOptions() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project }); + var matches = Regex.Matches(_console.GetOutput(), "New JWT saved with ID '(.*?)'"); + var id = matches.SingleOrDefault().Groups[1].Value; + + app.Run(new[] { "print", id, "--project", project }); + var output = _console.GetOutput(); + + Assert.Contains($"ID: {id}", output); + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains($"Scheme: Bearer", output); + Assert.Contains($"Audience(s): https://localhost:5001, http://localhost:5000", output); + } + + [Fact] + public void PrintCommand_ShowsCustomizedOptions() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project, "--role", "foobar" }); + var matches = Regex.Matches(_console.GetOutput(), "New JWT saved with ID '(.*?)'"); + var id = matches.SingleOrDefault().Groups[1].Value; + + app.Run(new[] { "print", id, "--project", project }); + var output = _console.GetOutput(); + + Assert.Contains($"ID: {id}", output); + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains($"Scheme: Bearer", output); + Assert.Contains($"Audience(s): https://localhost:5001, http://localhost:5000", output); + Assert.Contains($"Roles: [foobar]", output); + Assert.DoesNotContain("Custom Claims", output); + } + + [Fact] + public void PrintComamnd_ShowsAllOptionsWithShowAll() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + + app.Run(new[] { "create", "--project", project, "--claim", "foo=bar" }); + var matches = Regex.Matches(_console.GetOutput(), "New JWT saved with ID '(.*?)'"); + var id = matches.SingleOrDefault().Groups[1].Value; + + app.Run(new[] { "print", id, "--project", project, "--show-all" }); + var output = _console.GetOutput(); + + Assert.Contains($"ID: {id}", output); + Assert.Contains($"Name: {Environment.UserName}", output); + Assert.Contains($"Scheme: Bearer", output); + Assert.Contains($"Audience(s): https://localhost:5001, http://localhost:5000", output); + Assert.Contains($"Scopes: none", output); + Assert.Contains($"Roles: [none]", output); + Assert.Contains($"Custom Claims: [foo=bar]", output); + } }