Skip to content

Commit 92f9373

Browse files
authored
Add jwt create command and createJwtToken endpoint. Closes #882 (#891)
* Add jwt create command. Closes #882 * Address PR review comments * Update claims parsing and validation * Update claims parsing for handling duplicate keys * Update token generation to ignore registered claims passed in claims option * Fix audience default value * Add scp and roles to ignored claims * Refactor claim parsing to use tuple * Refactor options, add custom binding, add API endpoint, centralise defaults * Refactor ExpiresOn default * Update option descriptions
1 parent 0e6c537 commit 92f9373

File tree

6 files changed

+310
-1
lines changed

6 files changed

+310
-1
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.DevProxy.ApiControllers;
5+
6+
public class JwtOptions
7+
{
8+
public string? Name { get; set; }
9+
public IEnumerable<string>? Audiences { get; set; }
10+
public string? Issuer { get; set; }
11+
public IEnumerable<string>? Roles { get; set; }
12+
public IEnumerable<string>? Scopes { get; set; }
13+
public Dictionary<string, string>? Claims { get; set; }
14+
public double ValidFor { get; set; }
15+
}
16+
17+
public class JwtInfo
18+
{
19+
public required string Token { get; set; }
20+
}

dev-proxy/ApiControllers/ProxyController.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.DevProxy.CommandHandlers;
56

67
namespace Microsoft.DevProxy.ApiControllers;
78

@@ -50,4 +51,12 @@ public void StopProxy()
5051
Response.StatusCode = 202;
5152
_proxyState.StopProxy();
5253
}
54+
55+
[HttpPost("createJwtToken")]
56+
public IActionResult CreateJwtToken([FromBody] JwtOptions jwtOptions)
57+
{
58+
var token = JwtTokenGenerator.CreateToken(jwtOptions);
59+
60+
return Ok(new JwtInfo { Token = token });
61+
}
5362
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using Microsoft.DevProxy.ApiControllers;
2+
using System.CommandLine;
3+
using System.CommandLine.Binding;
4+
5+
namespace Microsoft.DevProxy.CommandHandlers
6+
{
7+
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>
8+
{
9+
private readonly Option<string> _nameOption = nameOption;
10+
private readonly Option<IEnumerable<string>> _audiencesOption = audiencesOption;
11+
private readonly Option<string> _issuerOption = issuerOption;
12+
private readonly Option<IEnumerable<string>> _rolesOption = rolesOption;
13+
private readonly Option<IEnumerable<string>> _scopesOption = scopesOption;
14+
private readonly Option<Dictionary<string, string>> _claimsOption = claimsOption;
15+
private readonly Option<double> _validForOption = validForOption;
16+
17+
protected override JwtOptions GetBoundValue(BindingContext bindingContext)
18+
{
19+
return new JwtOptions
20+
{
21+
Name = bindingContext.ParseResult.GetValueForOption(_nameOption),
22+
Audiences = bindingContext.ParseResult.GetValueForOption(_audiencesOption),
23+
Issuer = bindingContext.ParseResult.GetValueForOption(_issuerOption),
24+
Roles = bindingContext.ParseResult.GetValueForOption(_rolesOption),
25+
Scopes = bindingContext.ParseResult.GetValueForOption(_scopesOption),
26+
Claims = bindingContext.ParseResult.GetValueForOption(_claimsOption),
27+
ValidFor = bindingContext.ParseResult.GetValueForOption(_validForOption)
28+
};
29+
}
30+
}
31+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.DevProxy.ApiControllers;
5+
using Microsoft.IdentityModel.Tokens;
6+
using System.Globalization;
7+
using System.IdentityModel.Tokens.Jwt;
8+
using System.Security.Claims;
9+
using System.Security.Cryptography;
10+
using System.Security.Principal;
11+
12+
namespace Microsoft.DevProxy.CommandHandlers;
13+
14+
internal static class JwtCommandHandler
15+
{
16+
internal static void GetToken(JwtOptions jwtOptions)
17+
{
18+
var token = JwtTokenGenerator.CreateToken(jwtOptions);
19+
20+
Console.WriteLine(token);
21+
}
22+
}
23+
24+
internal static class JwtTokenGenerator
25+
{
26+
internal static string CreateToken(JwtOptions jwtOptions)
27+
{
28+
var options = JwtCreatorOptions.Create(jwtOptions);
29+
30+
var jwtIssuer = new JwtIssuer(
31+
options.Issuer,
32+
RandomNumberGenerator.GetBytes(32)
33+
);
34+
35+
var jwtToken = jwtIssuer.Create(options);
36+
37+
var jwt = Jwt.Create(
38+
options.Scheme,
39+
jwtToken,
40+
new JwtSecurityTokenHandler().WriteToken(jwtToken),
41+
options.Scopes,
42+
options.Roles,
43+
options.Claims
44+
);
45+
46+
return jwt.Token;
47+
}
48+
}
49+
50+
internal sealed class JwtIssuer(string issuer, byte[] signingKeyMaterial)
51+
{
52+
private readonly SymmetricSecurityKey _signingKey = new(signingKeyMaterial);
53+
54+
public string Issuer { get; } = issuer;
55+
56+
public JwtSecurityToken Create(JwtCreatorOptions options)
57+
{
58+
var identity = new GenericIdentity(options.Name);
59+
60+
identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, options.Name));
61+
62+
var id = Guid.NewGuid().ToString().GetHashCode().ToString("x", CultureInfo.InvariantCulture);
63+
identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, id));
64+
65+
if (options.Scopes is { } scopesToAdd)
66+
{
67+
identity.AddClaims(scopesToAdd.Select(s => new Claim("scp", s)));
68+
}
69+
70+
if (options.Roles is { } rolesToAdd)
71+
{
72+
identity.AddClaims(rolesToAdd.Select(r => new Claim("roles", r)));
73+
}
74+
75+
if (options.Claims is { Count: > 0 } claimsToAdd)
76+
{
77+
// filter out registered claims
78+
// https://www.rfc-editor.org/rfc/rfc7519#section-4.1
79+
claimsToAdd.Remove(JwtRegisteredClaimNames.Iss);
80+
claimsToAdd.Remove(JwtRegisteredClaimNames.Sub);
81+
claimsToAdd.Remove(JwtRegisteredClaimNames.Aud);
82+
claimsToAdd.Remove(JwtRegisteredClaimNames.Exp);
83+
claimsToAdd.Remove(JwtRegisteredClaimNames.Nbf);
84+
claimsToAdd.Remove(JwtRegisteredClaimNames.Iat);
85+
claimsToAdd.Remove(JwtRegisteredClaimNames.Jti);
86+
claimsToAdd.Remove("scp");
87+
claimsToAdd.Remove("roles");
88+
89+
identity.AddClaims(claimsToAdd.Select(kvp => new Claim(kvp.Key, kvp.Value)));
90+
}
91+
92+
// Although the JwtPayload supports having multiple audiences registered, the
93+
// creator methods and constructors don't provide a way of setting multiple
94+
// audiences. Instead, we have to register an `aud` claim for each audience
95+
// we want to add so that the multiple audiences are populated correctly.
96+
97+
if (options.Audiences.ToList() is { Count: > 0 } audiences)
98+
{
99+
identity.AddClaims(audiences.Select(aud => new Claim(JwtRegisteredClaimNames.Aud, aud)));
100+
}
101+
102+
var handler = new JwtSecurityTokenHandler();
103+
var jwtSigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256Signature);
104+
var jwtToken = handler.CreateJwtSecurityToken(Issuer, audience: null, identity, options.NotBefore, options.ExpiresOn, issuedAt: DateTime.UtcNow, jwtSigningCredentials);
105+
return jwtToken;
106+
}
107+
}
108+
109+
internal record Jwt(string Id, string Scheme, string Name, string Audience, DateTimeOffset NotBefore, DateTimeOffset Expires, DateTimeOffset Issued, string Token)
110+
{
111+
public IEnumerable<string> Scopes { get; set; } = [];
112+
113+
public IEnumerable<string> Roles { get; set; } = [];
114+
115+
public IDictionary<string, string> CustomClaims { get; set; } = new Dictionary<string, string>();
116+
117+
public static Jwt Create(
118+
string scheme,
119+
JwtSecurityToken token,
120+
string encodedToken,
121+
IEnumerable<string> scopes,
122+
IEnumerable<string> roles,
123+
IDictionary<string, string> customClaims)
124+
{
125+
return new Jwt(token.Id, scheme, token.Subject, string.Join(", ", token.Audiences), token.ValidFrom, token.ValidTo, token.IssuedAt, encodedToken)
126+
{
127+
Scopes = scopes,
128+
Roles = roles,
129+
CustomClaims = customClaims
130+
};
131+
}
132+
}
133+
134+
internal sealed record JwtCreatorOptions
135+
{
136+
public required string Scheme { get; init; }
137+
public required string Name { get; init; }
138+
public required IEnumerable<string> Audiences { get; init; }
139+
public required string Issuer { get; init; }
140+
public DateTime NotBefore { get; init; }
141+
public DateTime ExpiresOn { get; init; }
142+
public required IEnumerable<string> Roles { get; init; }
143+
public required IEnumerable<string> Scopes { get; init; }
144+
public required Dictionary<string, string> Claims { get; init; }
145+
146+
public static JwtCreatorOptions Create(JwtOptions options)
147+
{
148+
return new JwtCreatorOptions
149+
{
150+
Scheme = "Bearer",
151+
Name = options.Name ?? "Dev Proxy",
152+
Audiences = options.Audiences ?? ["https://myserver.com"],
153+
Issuer = options.Issuer ?? "dev-proxy",
154+
Roles = options.Roles ?? [],
155+
Scopes = options.Scopes ?? [],
156+
Claims = options.Claims ?? [],
157+
NotBefore = DateTime.UtcNow,
158+
ExpiresOn = DateTime.UtcNow.AddMinutes(options.ValidFor == 0 ? 60 : options.ValidFor)
159+
};
160+
}
161+
}
162+

dev-proxy/Properties/launchSettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
"Config": {
4242
"commandName": "Project",
4343
"commandLineArgs": "config"
44+
},
45+
"Jwt create": {
46+
"commandName": "Project",
47+
"commandLineArgs": "jwt create"
4448
}
4549
}
4650
}

dev-proxy/ProxyHost.cs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.DevProxy.Abstractions;
55
using Microsoft.DevProxy.CommandHandlers;
66
using System.CommandLine;
7+
using System.CommandLine.Parsing;
78
using System.Diagnostics;
89
using System.Net;
910

@@ -346,9 +347,91 @@ public RootCommand GetRootCommand(ILogger logger)
346347
var outdatedShortOption = new Option<bool>("--short", "Return version only");
347348
outdatedCommand.AddOption(outdatedShortOption);
348349
outdatedCommand.SetHandler(async versionOnly => await OutdatedCommandHandler.CheckVersionAsync(versionOnly, logger), outdatedShortOption);
349-
350350
command.Add(outdatedCommand);
351351

352+
var jwtCommand = new Command("jwt", "Manage JSON Web Tokens ");
353+
var jwtCreateCommand = new Command("create", "Create a new JWT token");
354+
var jwtNameOption = new Option<string>("--name", "The name of the user to create the token for.");
355+
jwtNameOption.AddAlias("-n");
356+
jwtCreateCommand.AddOption(jwtNameOption);
357+
358+
var jwtAudienceOption = new Option<IEnumerable<string>>("--audience", "The audiences to create the token for. Specify once for each audience")
359+
{
360+
AllowMultipleArgumentsPerToken = true
361+
};
362+
jwtAudienceOption.AddAlias("-a");
363+
jwtCreateCommand.AddOption(jwtAudienceOption);
364+
365+
var jwtIssuerOption = new Option<string>("--issuer", "The issuer of the token.");
366+
jwtIssuerOption.AddAlias("-i");
367+
jwtCreateCommand.AddOption(jwtIssuerOption);
368+
369+
var jwtRolesOption = new Option<IEnumerable<string>>("--roles", "A role claim to add to the token. Specify once for each role.")
370+
{
371+
AllowMultipleArgumentsPerToken = true
372+
};
373+
jwtRolesOption.AddAlias("-r");
374+
jwtCreateCommand.AddOption(jwtRolesOption);
375+
376+
var jwtScopesOption = new Option<IEnumerable<string>>("--scopes", "A scope claim to add to the token. Specify once for each scope.")
377+
{
378+
AllowMultipleArgumentsPerToken = true
379+
};
380+
jwtScopesOption.AddAlias("-s");
381+
jwtCreateCommand.AddOption(jwtScopesOption);
382+
383+
var jwtClaimsOption = new Option<Dictionary<string, string>>("--claims",
384+
description: "Claims to add to the token. Specify once for each claim in the format \"name:value\".",
385+
parseArgument: result => {
386+
var claims = new Dictionary<string, string>();
387+
foreach (var token in result.Tokens)
388+
{
389+
var claim = token.Value.Split(":");
390+
391+
if (claim.Length != 2)
392+
{
393+
result.ErrorMessage = $"Invalid claim format: '{token.Value}'. Expected format is name:value.";
394+
return claims ?? [];
395+
}
396+
397+
try
398+
{
399+
var (key, value) = (claim[0], claim[1]);
400+
claims.Add(key, value);
401+
}
402+
catch (Exception ex)
403+
{
404+
result.ErrorMessage = ex.Message;
405+
}
406+
}
407+
return claims;
408+
}
409+
)
410+
{
411+
AllowMultipleArgumentsPerToken = true,
412+
};
413+
jwtCreateCommand.AddOption(jwtClaimsOption);
414+
415+
var jwtValidForOption = new Option<double>("--valid-for", "The duration for which the token is valid. Duration is set in minutes.");
416+
jwtValidForOption.AddAlias("-v");
417+
jwtCreateCommand.AddOption(jwtValidForOption);
418+
419+
jwtCreateCommand.SetHandler(
420+
JwtCommandHandler.GetToken,
421+
new JwtBinder(
422+
jwtNameOption,
423+
jwtAudienceOption,
424+
jwtIssuerOption,
425+
jwtRolesOption,
426+
jwtScopesOption,
427+
jwtClaimsOption,
428+
jwtValidForOption
429+
)
430+
);
431+
jwtCommand.Add(jwtCreateCommand);
432+
433+
command.Add(jwtCommand);
434+
352435
return command;
353436
}
354437

0 commit comments

Comments
 (0)