diff --git a/dev-proxy/ApiControllers/JwtOptions.cs b/dev-proxy/ApiControllers/JwtOptions.cs new file mode 100644 index 00000000..ed4b1492 --- /dev/null +++ b/dev-proxy/ApiControllers/JwtOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.DevProxy.ApiControllers; + +public class JwtOptions +{ + public string? Name { get; set; } + public IEnumerable? Audiences { get; set; } + public string? Issuer { get; set; } + public IEnumerable? Roles { get; set; } + public IEnumerable? Scopes { get; set; } + public Dictionary? Claims { get; set; } + public double ValidFor { get; set; } +} + +public class JwtInfo +{ + public required string Token { get; set; } +} diff --git a/dev-proxy/ApiControllers/ProxyController.cs b/dev-proxy/ApiControllers/ProxyController.cs index 8b02462c..452acb43 100644 --- a/dev-proxy/ApiControllers/ProxyController.cs +++ b/dev-proxy/ApiControllers/ProxyController.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.AspNetCore.Mvc; +using Microsoft.DevProxy.CommandHandlers; namespace Microsoft.DevProxy.ApiControllers; @@ -50,4 +51,12 @@ public void StopProxy() Response.StatusCode = 202; _proxyState.StopProxy(); } + + [HttpPost("createJwtToken")] + public IActionResult CreateJwtToken([FromBody] JwtOptions jwtOptions) + { + var token = JwtTokenGenerator.CreateToken(jwtOptions); + + return Ok(new JwtInfo { Token = token }); + } } diff --git a/dev-proxy/CommandHandlers/JwtBinder.cs b/dev-proxy/CommandHandlers/JwtBinder.cs new file mode 100644 index 00000000..04bba903 --- /dev/null +++ b/dev-proxy/CommandHandlers/JwtBinder.cs @@ -0,0 +1,31 @@ +using Microsoft.DevProxy.ApiControllers; +using System.CommandLine; +using System.CommandLine.Binding; + +namespace Microsoft.DevProxy.CommandHandlers +{ + public class JwtBinder(Option nameOption, Option> audiencesOption, Option issuerOption, Option> rolesOption, Option> scopesOption, Option> claimsOption, Option validForOption) : BinderBase + { + private readonly Option _nameOption = nameOption; + private readonly Option> _audiencesOption = audiencesOption; + private readonly Option _issuerOption = issuerOption; + private readonly Option> _rolesOption = rolesOption; + private readonly Option> _scopesOption = scopesOption; + private readonly Option> _claimsOption = claimsOption; + private readonly Option _validForOption = validForOption; + + protected override JwtOptions GetBoundValue(BindingContext bindingContext) + { + return new JwtOptions + { + Name = bindingContext.ParseResult.GetValueForOption(_nameOption), + Audiences = bindingContext.ParseResult.GetValueForOption(_audiencesOption), + Issuer = bindingContext.ParseResult.GetValueForOption(_issuerOption), + Roles = bindingContext.ParseResult.GetValueForOption(_rolesOption), + Scopes = bindingContext.ParseResult.GetValueForOption(_scopesOption), + Claims = bindingContext.ParseResult.GetValueForOption(_claimsOption), + ValidFor = bindingContext.ParseResult.GetValueForOption(_validForOption) + }; + } + } +} diff --git a/dev-proxy/CommandHandlers/JwtCommandHandler.cs b/dev-proxy/CommandHandlers/JwtCommandHandler.cs new file mode 100644 index 00000000..48a89128 --- /dev/null +++ b/dev-proxy/CommandHandlers/JwtCommandHandler.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.DevProxy.ApiControllers; +using Microsoft.IdentityModel.Tokens; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Security.Principal; + +namespace Microsoft.DevProxy.CommandHandlers; + +internal static class JwtCommandHandler +{ + internal static void GetToken(JwtOptions jwtOptions) + { + var token = JwtTokenGenerator.CreateToken(jwtOptions); + + Console.WriteLine(token); + } +} + +internal static class JwtTokenGenerator +{ + internal static string CreateToken(JwtOptions jwtOptions) + { + var options = JwtCreatorOptions.Create(jwtOptions); + + var jwtIssuer = new JwtIssuer( + options.Issuer, + RandomNumberGenerator.GetBytes(32) + ); + + var jwtToken = jwtIssuer.Create(options); + + var jwt = Jwt.Create( + options.Scheme, + jwtToken, + new JwtSecurityTokenHandler().WriteToken(jwtToken), + options.Scopes, + options.Roles, + options.Claims + ); + + return jwt.Token; + } +} + +internal sealed class JwtIssuer(string issuer, byte[] signingKeyMaterial) +{ + private readonly SymmetricSecurityKey _signingKey = new(signingKeyMaterial); + + public string Issuer { get; } = issuer; + + public JwtSecurityToken Create(JwtCreatorOptions options) + { + var identity = new GenericIdentity(options.Name); + + identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, options.Name)); + + var id = Guid.NewGuid().ToString().GetHashCode().ToString("x", CultureInfo.InvariantCulture); + identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, id)); + + if (options.Scopes is { } scopesToAdd) + { + identity.AddClaims(scopesToAdd.Select(s => new Claim("scp", s))); + } + + if (options.Roles is { } rolesToAdd) + { + identity.AddClaims(rolesToAdd.Select(r => new Claim("roles", r))); + } + + if (options.Claims is { Count: > 0 } claimsToAdd) + { + // filter out registered claims + // https://www.rfc-editor.org/rfc/rfc7519#section-4.1 + claimsToAdd.Remove(JwtRegisteredClaimNames.Iss); + claimsToAdd.Remove(JwtRegisteredClaimNames.Sub); + claimsToAdd.Remove(JwtRegisteredClaimNames.Aud); + claimsToAdd.Remove(JwtRegisteredClaimNames.Exp); + claimsToAdd.Remove(JwtRegisteredClaimNames.Nbf); + claimsToAdd.Remove(JwtRegisteredClaimNames.Iat); + claimsToAdd.Remove(JwtRegisteredClaimNames.Jti); + claimsToAdd.Remove("scp"); + claimsToAdd.Remove("roles"); + + identity.AddClaims(claimsToAdd.Select(kvp => new Claim(kvp.Key, kvp.Value))); + } + + // Although the JwtPayload supports having multiple audiences registered, the + // creator methods and constructors don't provide a way of setting multiple + // audiences. Instead, we have to register an `aud` claim for each audience + // we want to add so that the multiple audiences are populated correctly. + + if (options.Audiences.ToList() is { Count: > 0 } audiences) + { + identity.AddClaims(audiences.Select(aud => new Claim(JwtRegisteredClaimNames.Aud, aud))); + } + + var handler = new JwtSecurityTokenHandler(); + var jwtSigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256Signature); + var jwtToken = handler.CreateJwtSecurityToken(Issuer, audience: null, identity, options.NotBefore, options.ExpiresOn, issuedAt: DateTime.UtcNow, jwtSigningCredentials); + return jwtToken; + } +} + +internal record Jwt(string Id, string Scheme, string Name, string Audience, DateTimeOffset NotBefore, DateTimeOffset Expires, DateTimeOffset Issued, string Token) +{ + public IEnumerable Scopes { get; set; } = []; + + public IEnumerable Roles { get; set; } = []; + + public IDictionary CustomClaims { get; set; } = new Dictionary(); + + public static Jwt Create( + string scheme, + JwtSecurityToken token, + string encodedToken, + IEnumerable scopes, + IEnumerable roles, + IDictionary customClaims) + { + return new Jwt(token.Id, scheme, token.Subject, string.Join(", ", token.Audiences), token.ValidFrom, token.ValidTo, token.IssuedAt, encodedToken) + { + Scopes = scopes, + Roles = roles, + CustomClaims = customClaims + }; + } +} + +internal sealed record JwtCreatorOptions +{ + public required string Scheme { get; init; } + public required string Name { get; init; } + public required IEnumerable Audiences { get; init; } + public required string Issuer { get; init; } + public DateTime NotBefore { get; init; } + public DateTime ExpiresOn { get; init; } + public required IEnumerable Roles { get; init; } + public required IEnumerable Scopes { get; init; } + public required Dictionary Claims { get; init; } + + public static JwtCreatorOptions Create(JwtOptions options) + { + return new JwtCreatorOptions + { + Scheme = "Bearer", + Name = options.Name ?? "Dev Proxy", + Audiences = options.Audiences ?? ["https://myserver.com"], + Issuer = options.Issuer ?? "dev-proxy", + Roles = options.Roles ?? [], + Scopes = options.Scopes ?? [], + Claims = options.Claims ?? [], + NotBefore = DateTime.UtcNow, + ExpiresOn = DateTime.UtcNow.AddMinutes(options.ValidFor == 0 ? 60 : options.ValidFor) + }; + } +} + diff --git a/dev-proxy/Properties/launchSettings.json b/dev-proxy/Properties/launchSettings.json index 43fe7879..31c0393e 100755 --- a/dev-proxy/Properties/launchSettings.json +++ b/dev-proxy/Properties/launchSettings.json @@ -41,6 +41,10 @@ "Config": { "commandName": "Project", "commandLineArgs": "config" + }, + "Jwt create": { + "commandName": "Project", + "commandLineArgs": "jwt create" } } } \ No newline at end of file diff --git a/dev-proxy/ProxyHost.cs b/dev-proxy/ProxyHost.cs index adf35d66..bcdc8c69 100755 --- a/dev-proxy/ProxyHost.cs +++ b/dev-proxy/ProxyHost.cs @@ -4,6 +4,7 @@ using Microsoft.DevProxy.Abstractions; using Microsoft.DevProxy.CommandHandlers; using System.CommandLine; +using System.CommandLine.Parsing; using System.Diagnostics; using System.Net; @@ -346,9 +347,91 @@ public RootCommand GetRootCommand(ILogger logger) var outdatedShortOption = new Option("--short", "Return version only"); outdatedCommand.AddOption(outdatedShortOption); outdatedCommand.SetHandler(async versionOnly => await OutdatedCommandHandler.CheckVersionAsync(versionOnly, logger), outdatedShortOption); - command.Add(outdatedCommand); + var jwtCommand = new Command("jwt", "Manage JSON Web Tokens "); + var jwtCreateCommand = new Command("create", "Create a new JWT token"); + var jwtNameOption = new Option("--name", "The name of the user to create the token for."); + jwtNameOption.AddAlias("-n"); + jwtCreateCommand.AddOption(jwtNameOption); + + var jwtAudienceOption = new Option>("--audience", "The audiences to create the token for. Specify once for each audience") + { + AllowMultipleArgumentsPerToken = true + }; + jwtAudienceOption.AddAlias("-a"); + jwtCreateCommand.AddOption(jwtAudienceOption); + + var jwtIssuerOption = new Option("--issuer", "The issuer of the token."); + jwtIssuerOption.AddAlias("-i"); + jwtCreateCommand.AddOption(jwtIssuerOption); + + var jwtRolesOption = new Option>("--roles", "A role claim to add to the token. Specify once for each role.") + { + AllowMultipleArgumentsPerToken = true + }; + jwtRolesOption.AddAlias("-r"); + jwtCreateCommand.AddOption(jwtRolesOption); + + var jwtScopesOption = new Option>("--scopes", "A scope claim to add to the token. Specify once for each scope.") + { + AllowMultipleArgumentsPerToken = true + }; + jwtScopesOption.AddAlias("-s"); + jwtCreateCommand.AddOption(jwtScopesOption); + + var jwtClaimsOption = new Option>("--claims", + description: "Claims to add to the token. Specify once for each claim in the format \"name:value\".", + parseArgument: result => { + var claims = new Dictionary(); + foreach (var token in result.Tokens) + { + var claim = token.Value.Split(":"); + + if (claim.Length != 2) + { + result.ErrorMessage = $"Invalid claim format: '{token.Value}'. Expected format is name:value."; + return claims ?? []; + } + + try + { + var (key, value) = (claim[0], claim[1]); + claims.Add(key, value); + } + catch (Exception ex) + { + result.ErrorMessage = ex.Message; + } + } + return claims; + } + ) + { + AllowMultipleArgumentsPerToken = true, + }; + jwtCreateCommand.AddOption(jwtClaimsOption); + + var jwtValidForOption = new Option("--valid-for", "The duration for which the token is valid. Duration is set in minutes."); + jwtValidForOption.AddAlias("-v"); + jwtCreateCommand.AddOption(jwtValidForOption); + + jwtCreateCommand.SetHandler( + JwtCommandHandler.GetToken, + new JwtBinder( + jwtNameOption, + jwtAudienceOption, + jwtIssuerOption, + jwtRolesOption, + jwtScopesOption, + jwtClaimsOption, + jwtValidForOption + ) + ); + jwtCommand.Add(jwtCreateCommand); + + command.Add(jwtCommand); + return command; }