Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add jwt create command and createJwtToken endpoint. Closes #882 #891

Merged
20 changes: 20 additions & 0 deletions dev-proxy/ApiControllers/JwtOptions.cs
Original file line number Diff line number Diff line change
@@ -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<string>? Audiences { get; set; }
public string? Issuer { get; set; }
public IEnumerable<string>? Roles { get; set; }
public IEnumerable<string>? Scopes { get; set; }
public Dictionary<string, string>? Claims { get; set; }
public double ValidFor { get; set; }
}

public class JwtInfo
{
public required string Token { get; set; }
}
9 changes: 9 additions & 0 deletions dev-proxy/ApiControllers/ProxyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Microsoft.AspNetCore.Mvc;
using Microsoft.DevProxy.CommandHandlers;

namespace Microsoft.DevProxy.ApiControllers;

Expand Down Expand Up @@ -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 });
}
}
31 changes: 31 additions & 0 deletions dev-proxy/CommandHandlers/JwtBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Microsoft.DevProxy.ApiControllers;
using System.CommandLine;
using System.CommandLine.Binding;

namespace Microsoft.DevProxy.CommandHandlers
{
public class JwtBinder(Option<string> nameOption, Option<IEnumerable<string>> audiencesOption, Option<string> issuerOption, Option<IEnumerable<string>> rolesOption, Option<IEnumerable<string>> scopesOption, Option<Dictionary<string, string>> claimsOption, Option<double> validForOption) : BinderBase<JwtOptions>
{
private readonly Option<string> _nameOption = nameOption;
private readonly Option<IEnumerable<string>> _audiencesOption = audiencesOption;
private readonly Option<string> _issuerOption = issuerOption;
private readonly Option<IEnumerable<string>> _rolesOption = rolesOption;
private readonly Option<IEnumerable<string>> _scopesOption = scopesOption;
private readonly Option<Dictionary<string, string>> _claimsOption = claimsOption;
private readonly Option<double> _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)
};
}
}
}
162 changes: 162 additions & 0 deletions dev-proxy/CommandHandlers/JwtCommandHandler.cs
Original file line number Diff line number Diff line change
@@ -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,
garrytrinder marked this conversation as resolved.
Show resolved Hide resolved
options.Roles,
garrytrinder marked this conversation as resolved.
Show resolved Hide resolved
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<string> Scopes { get; set; } = [];

public IEnumerable<string> Roles { get; set; } = [];

public IDictionary<string, string> CustomClaims { get; set; } = new Dictionary<string, string>();

public static Jwt Create(
string scheme,
JwtSecurityToken token,
string encodedToken,
IEnumerable<string> scopes,
IEnumerable<string> roles,
IDictionary<string, string> 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<string> Audiences { get; init; }
public required string Issuer { get; init; }
public DateTime NotBefore { get; init; }
public DateTime ExpiresOn { get; init; }
public required IEnumerable<string> Roles { get; init; }
public required IEnumerable<string> Scopes { get; init; }
public required Dictionary<string, string> 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)
};
}
}

4 changes: 4 additions & 0 deletions dev-proxy/Properties/launchSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
"Config": {
"commandName": "Project",
"commandLineArgs": "config"
},
"Jwt create": {
"commandName": "Project",
"commandLineArgs": "jwt create"
}
}
}
85 changes: 84 additions & 1 deletion dev-proxy/ProxyHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -346,9 +347,91 @@ public RootCommand GetRootCommand(ILogger logger)
var outdatedShortOption = new Option<bool>("--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<string>("--name", "The name of the user to create the token for.");
jwtNameOption.AddAlias("-n");
jwtCreateCommand.AddOption(jwtNameOption);

var jwtAudienceOption = new Option<IEnumerable<string>>("--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<string>("--issuer", "The issuer of the token.");
jwtIssuerOption.AddAlias("-i");
jwtCreateCommand.AddOption(jwtIssuerOption);

var jwtRolesOption = new Option<IEnumerable<string>>("--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<IEnumerable<string>>("--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<Dictionary<string, string>>("--claims",
description: "Claims to add to the token. Specify once for each claim in the format \"name:value\".",
parseArgument: result => {
var claims = new Dictionary<string, string>();
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<double>("--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;
}

Expand Down