diff --git a/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs index 63a3342b95e0..5f45ce7b9809 100644 --- a/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs +++ b/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs @@ -123,9 +123,9 @@ private static (JwtCreatorOptions, bool, string) ValidateArguments( 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) + var audience = audienceOption.HasValue() ? audienceOption.Values : DevJwtCliHelpers.GetAudienceCandidatesFromLaunchSettings(project); + optionsString += audienceOption.HasValue() ? $"{Resources.JwtPrint_Audiences}: {string.Join(", ", audience)}{Environment.NewLine}" : string.Empty; + if (audience is null || audience.Count == 0) { reporter.Error(Resources.CreateCommand_NoAudience_Error); isValid = false; diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs index eb300d533d1e..999d5e751461 100644 --- a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs +++ b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs @@ -4,6 +4,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Tools.Internal; @@ -84,23 +85,23 @@ public static byte[] CreateSigningKeyMaterial(string userSecretsId, bool reset = var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId); Directory.CreateDirectory(Path.GetDirectoryName(secretsFilePath)); - IDictionary secrets = null; + JsonObject secrets = null; if (File.Exists(secretsFilePath)) { using var secretsFileStream = new FileStream(secretsFilePath, FileMode.Open, FileAccess.Read); if (secretsFileStream.Length > 0) { - secrets = JsonSerializer.Deserialize>(secretsFileStream) ?? new Dictionary(); + secrets = JsonSerializer.Deserialize(secretsFileStream); } } - secrets ??= new Dictionary(); + secrets ??= new JsonObject(); if (reset && secrets.ContainsKey(DevJwtsDefaults.SigningKeyConfigurationKey)) { secrets.Remove(DevJwtsDefaults.SigningKeyConfigurationKey); } - secrets.Add(DevJwtsDefaults.SigningKeyConfigurationKey, Convert.ToBase64String(newKeyMaterial)); + secrets.Add(DevJwtsDefaults.SigningKeyConfigurationKey, JsonValue.Create(Convert.ToBase64String(newKeyMaterial))); using var secretsWriteStream = new FileStream(secretsFilePath, FileMode.Create, FileAccess.Write); JsonSerializer.Serialize(secretsWriteStream, secrets); @@ -108,11 +109,12 @@ public static byte[] CreateSigningKeyMaterial(string userSecretsId, bool reset = return newKeyMaterial; } - public static string[] GetAudienceCandidatesFromLaunchSettings(string project) + public static List GetAudienceCandidatesFromLaunchSettings(string project) { ArgumentException.ThrowIfNullOrEmpty(nameof(project)); var launchSettingsFilePath = Path.Combine(Path.GetDirectoryName(project)!, "Properties", "launchSettings.json"); + var applicationUrls = new List(); if (File.Exists(launchSettingsFilePath)) { using var launchSettingsFileStream = new FileStream(launchSettingsFilePath, FileMode.Open, FileAccess.Read); @@ -124,26 +126,65 @@ public static string[] GetAudienceCandidatesFromLaunchSettings(string project) var profilesEnumerator = profiles.EnumerateObject(); foreach (var profile in profilesEnumerator) { - if (profile.Value.TryGetProperty("commandName", out var commandName)) + if (ExtractKestrelUrlsFromProfile(profile) is { } kestrelUrls) { - if (commandName.ValueEquals("Project")) - { - if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl)) - { - var value = applicationUrl.GetString(); - if (value is { } applicationUrls) - { - return applicationUrls.Split(';'); - } - } - } + applicationUrls.AddRange(kestrelUrls); + } + + if (ExtractIISExpressUrlFromProfile(profile) is { } iisUrls) + { + applicationUrls.AddRange(iisUrls); } } } } } - return null; + return applicationUrls; + + static List ExtractIISExpressUrlFromProfile(JsonProperty profile) + { + if (profile.NameEquals("iisSettings")) + { + if (profile.Value.TryGetProperty("iisExpress", out var iisExpress)) + { + List iisUrls = new(); + if (iisExpress.TryGetProperty("applicationUrl", out var iisUrl)) + { + iisUrls.Add(iisUrl.GetString()); + } + + if (iisExpress.TryGetProperty("sslPort", out var sslPort)) + { + iisUrls.Add($"https://localhost:{sslPort.GetInt32()}"); + } + + return iisUrls; + } + } + + return null; + } + + static string[] ExtractKestrelUrlsFromProfile(JsonProperty profile) + { + if (profile.Value.TryGetProperty("commandName", out var commandName)) + { + if (commandName.ValueEquals("Project")) + { + if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl)) + { + var value = applicationUrl.GetString(); + if (value is { } urls) + { + return urls.Split(';'); + } + } + } + } + + return null; + } } public static void PrintJwt(IReporter reporter, Jwt jwt, bool showAll, JwtSecurityToken fullToken = null) @@ -163,12 +204,12 @@ public static void PrintJwt(IReporter reporter, Jwt jwt, bool showAll, JwtSecuri : 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); + : string.Join(", ", jwt.Roles); reporter.Output($"{Resources.JwtPrint_Roles}: [{rolesValue}]"); } diff --git a/src/Tools/dotnet-user-jwts/src/Resources.resx b/src/Tools/dotnet-user-jwts/src/Resources.resx index 9a7dd45dbd52..06fbf4f2eea5 100644 --- a/src/Tools/dotnet-user-jwts/src/Resources.resx +++ b/src/Tools/dotnet-user-jwts/src/Resources.resx @@ -1,17 +1,17 @@  - @@ -169,7 +169,7 @@ The name of the user to create the JWT for. Defaults to the current environment user. - Could not determine the project's HTTPS URL. Please specify an audience for the JWT using the --audience option. + Could not determine the project's URL. Please specify an audience for the JWT using the --audience option or ensure that launchSettings.json is configured properly. The UTC date & time the JWT should not be valid before in the format 'yyyy-MM-dd [[HH:mm[[:ss]]]]'. Defaults to the date & time the JWT is created. @@ -303,4 +303,4 @@ No JWT with ID '{0}' found. - \ No newline at end of file + diff --git a/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj b/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj index 53cc0514d7e4..3c9eeae2b029 100644 --- a/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj +++ b/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj @@ -31,4 +31,8 @@ - \ No newline at end of file + + + + + diff --git a/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs b/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs index 08e003c8e456..d3af1030c3ed 100644 --- a/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs +++ b/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests; public class UserJwtsTestFixture : IDisposable { private Stack _disposables = new Stack(); - private string TestSecretsId = Guid.NewGuid().ToString(); + internal string TestSecretsId = Guid.NewGuid().ToString(); private const string ProjectTemplate = @" @@ -26,6 +26,14 @@ public class UserJwtsTestFixture : IDisposable private const string LaunchSettingsTemplate = @" { ""profiles"": { + ""iisSettings"": { + ""windowsAuthentication"": false, + ""anonymousAuthentication"": true, + ""iisExpress"": { + ""applicationUrl"": ""http://localhost:23528"", + ""sslPort"": 44395 + } + }, ""HttpApiSampleApp"": { ""commandName"": ""Project"", ""dotnetRunMessages"": true, @@ -71,7 +79,7 @@ public string CreateProject(bool hasSecret = true) catch { } }); } - + _disposables.Push(() => TryDelete(projectPath.FullName)); return projectPath.FullName; diff --git a/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs b/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs index bfacf663d61f..8947aa2234f1 100644 --- a/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs +++ b/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs @@ -7,12 +7,14 @@ using System.IO; using System.Text; using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Configuration.UserSecrets; using Microsoft.Extensions.Tools.Internal; using Microsoft.AspNetCore.Authentication.JwtBearer.Tools; using Xunit; using Xunit.Abstractions; using System.Text.RegularExpressions; using System.Text.Json; +using System.Text.Json.Nodes; using System.IdentityModel.Tokens.Jwt; namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests; @@ -150,6 +152,34 @@ public void Key_CanResetSigningKey() Assert.Contains("New signing key created:", _console.GetOutput()); } + [Fact] + public async Task Key_CanResetSigningKey_WhenSecretsHasPrepulatedData() + { + var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj"); + var app = new Program(_console); + var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(_fixture.TestSecretsId); + await File.WriteAllTextAsync(secretsFilePath, +@"{ + ""Foo"": { + ""Bar"": ""baz"" + } +}"); + + app.Run(new[] { "create", "--project", project }); + app.Run(new[] { "key", "--project", project }); + Assert.Contains("Signing Key:", _console.GetOutput()); + + app.Run(new[] { "key", "--reset", "--force", "--project", project }); + Assert.Contains("New signing key created:", _console.GetOutput()); + + using FileStream openStream = File.OpenRead(secretsFilePath); + var secretsJson = await JsonSerializer.DeserializeAsync(openStream); + Assert.NotNull(secretsJson); + Assert.True(secretsJson.ContainsKey(DevJwtsDefaults.SigningKeyConfigurationKey)); + Assert.True(secretsJson.TryGetPropertyValue("Foo", out var fooField)); + Assert.Equal("baz", fooField["Bar"].GetValue()); + } + [Fact] public void Command_ShowsHelpForInvalidCommand() { @@ -234,7 +264,7 @@ public void PrintCommand_ShowsBasicOptions() 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($"Audience(s): http://localhost:23528, https://localhost:44395, https://localhost:5001, http://localhost:5000", output); } [Fact] @@ -253,7 +283,7 @@ public void PrintCommand_ShowsCustomizedOptions() 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($"Audience(s): http://localhost:23528, https://localhost:44395, https://localhost:5001, http://localhost:5000", output); Assert.Contains($"Roles: [foobar]", output); Assert.DoesNotContain("Custom Claims", output); } @@ -274,7 +304,7 @@ public void PrintComamnd_ShowsAllOptionsWithShowAll() 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($"Audience(s): http://localhost:23528, https://localhost:44395, https://localhost:5001, http://localhost:5000", output); Assert.Contains($"Scopes: none", output); Assert.Contains($"Roles: [none]", output); Assert.Contains($"Custom Claims: [foo=bar]", output); @@ -307,4 +337,63 @@ public void Create_WithTokenOutput_ProducesSingleValue() var handler = new JwtSecurityTokenHandler(); Assert.True(handler.CanReadToken(output.Trim())); } + + [Fact] + public void Create_GracefullyHandles_NoLaunchSettings() + { + var projectPath = _fixture.CreateProject(); + var project = Path.Combine(projectPath, "TestProject.csproj"); + var app = new Program(_console); + var launchSettingsPath = Path.Combine(projectPath, "Properties", "launchSettings.json"); + + File.Delete(launchSettingsPath); + + app.Run(new[] { "create", "--project", project }); + var output = _console.GetOutput(); + + Assert.Contains(Resources.CreateCommand_NoAudience_Error, output); + } + + [Fact] + public async Task Create_GracefullyHandles_PrepopulatedSecrets() + { + var projectPath = _fixture.CreateProject(); + var project = Path.Combine(projectPath, "TestProject.csproj"); + var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(_fixture.TestSecretsId); + await File.WriteAllTextAsync(secretsFilePath, +@"{ + ""Foo"": { + ""Bar"": ""baz"" + } +}"); + var app = new Program(_console); + app.Run(new[] { "create", "--project", project}); + var output = _console.GetOutput(); + + Assert.Contains("New JWT saved", output); + using FileStream openStream = File.OpenRead(secretsFilePath); + var secretsJson = await JsonSerializer.DeserializeAsync(openStream); + Assert.NotNull(secretsJson); + Assert.True(secretsJson.ContainsKey(DevJwtsDefaults.SigningKeyConfigurationKey)); + Assert.True(secretsJson.TryGetPropertyValue("Foo", out var fooField)); + Assert.Equal("baz", fooField["Bar"].GetValue()); + } + + [Fact] + public void Create_GetsAudiencesFromAllIISAndKestrel() + { + var projectPath = _fixture.CreateProject(); + var project = Path.Combine(projectPath, "TestProject.csproj"); + var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(_fixture.TestSecretsId); + + 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, "--show-all" }); + var output = _console.GetOutput(); + + Assert.Contains("New JWT saved", output); + Assert.Contains($"Audience(s): http://localhost:23528, https://localhost:44395, https://localhost:5001, http://localhost:5000", output); + } }