Minimal API example?
Closed this issue · 6 comments
Do you have an example of using this with .NET minimal API, especially with roles and RequireAuthorization
on the RouteGroupBuilder
?
This is the way I was doing it with JWT:
public string Generate(ProducerDTO user) {
var identity = new ClaimsIdentity(new Claim[] {
new(JwtRegisteredClaimNames.Email, user.Email),
new(JwtRegisteredClaimNames.Name, user.Name),
new(JwtRegisteredClaimNames.Aud, Audience),
new(ProducerIdKey, sqidEncoder.Encode(user.Id))
});
if (user.Admin)
identity.AddClaim(new Claim(ClaimTypes.Role, AdminRoleKey));
var handler = new JwtSecurityTokenHandler();
var descriptor = new SecurityTokenDescriptor {
Subject = identity,
Expires = DateTime.UtcNow.AddDays(14),
Issuer = Issuer,
SigningCredentials = new(Key, SecurityAlgorithms.HmacSha256Signature)
};
var token = handler.CreateToken(descriptor);
return handler.WriteToken(token);
}
public static void ConfigureJwtBearerOptions(JwtBearerOptions options) {
options.RequireHttpsMetadata = false;
options.SaveToken = true;
options.TokenValidationParameters = new() {
ValidateIssuerSigningKey = true,
IssuerSigningKey = Key,
IssuerValidator =
(issuer, _, _) =>
issuer == Issuer ? issuer : throw new SecurityTokenInvalidIssuerException("Bad monkey, no!"),
AudienceValidator = (x, _, _) =>
x?.SingleOrDefault() == Audience
? true
: throw new SecurityTokenInvalidAudienceException("Bad monkey, no!")
};
}
I might need to take a look at it since I haven't toyed that much with Minimal APIs; nonetheless, there's already a NuGet package that you could probably use for integrating PASETO into the API here:
https://github.com/blazkaro/PasetoBearer.Authentication
There's also another project that uses PASETO in .NET that might be worth checking out:
If none of those suits you, I could give it a shot and try to have a working example with Minimal APIs.
Thanks. I'm pretty close to having this working. However, when I decode, I'm getting this error:
Claim 'exp' must be a DateTime
If I tell it to not validate the lifetime it's fine, and when I print out the raw payload it has this:
"exp": "2024-07-06T23:52:14.755066Z"
I'm not sure why it's failing. You can reproduce like so:
byte[] byteArray = new byte[32];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(byteArray);
var keys = new PasetoBuilder()
.UseV4(Purpose.Public)
.GenerateAsymmetricKeyPair(byteArray);
var encoded = new PasetoBuilder()
.UseV4(Purpose.Public)
.WithSecretKey([.. keys.SecretKey.Key.Span])
.IssuedAt(DateTime.UtcNow)
.Expiration(DateTime.UtcNow.AddHours(1))
.Encode();
var ValidationParameters = new PasetoTokenValidationParameters
{
ValidateLifetime = true
};
var decoded = new PasetoBuilder()
.UseV4(Purpose.Public)
.WithPublicKey([.. keys.PublicKey.Key.Span])
.Decode(encoded, ValidationParameters);
if (decoded.IsValid is false)
decoded.Exception.Dump();
I see the issue, it is deserialized as JsonElement caused by the change to System.Text.Json.
I will work on a fix.
Fixed in a9672cc, should see a v1.2.1 NuGet soon.
Works great, thank you for the wonderful support and package.
@daviddesmet Here's how I got this working if you want to include in your documentation.
You have to create an authentication handler that .NET can use, which I implemented like so:
public sealed class PasetoBearerHandler(
IPasetoToken tokenHelper,
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder) {
private static readonly string MessageKey = Guid.NewGuid().ToString();
private static readonly string RoleClaimsType;
static PasetoBearerHandler() {
var identity = new ClaimsIdentity();
RoleClaimsType = identity.RoleClaimType;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync() {
if (Request.Headers.TryGetValue(HeaderNames.Authorization, out var authorization) is false)
return AuthenticateResult.NoResult();
var token = authorization
.ToString()
.Replace($"{PasetoToken.Scheme} ", string.Empty, StringComparison.InvariantCultureIgnoreCase);
var result = tokenHelper.Validate(token);
if (result.IsFailed) {
var errors = string.Join('\n', result.Errors.Select(x => x.Message));
var properties = new AuthenticationProperties();
properties.Items.Add(MessageKey, errors);
await ChallengeAsync(properties);
return AuthenticateResult.Fail(errors);
}
var claims = result
.Value
.SelectMany(x => {
if (x.Key == PasetoToken.RolesKey)
return CreateRoleClaims(x);
return new[] { CreateClaim(x) };
});
var claimsIdentity = new ClaimsIdentity(claims, PasetoToken.Scheme);
var ticket = new AuthenticationTicket(new(claimsIdentity), PasetoToken.Scheme);
return AuthenticateResult.Success(ticket);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties) {
var response = Context.Response;
var message = properties.Items.TryGetValue(MessageKey, out var failureMessage)
? failureMessage
: null;
var problemDetails = new ProblemDetails {
Status = StatusCodes.Status401Unauthorized,
Title = "Authentication Failure",
Detail = message,
Instance = Context.Request.Path
};
response.StatusCode = StatusCodes.Status401Unauthorized;
return response.WriteAsJsonAsync(problemDetails, ProblemDetailsJsonSerializerContext.Default.ProblemDetails,
"application/problem+json", Context.RequestAborted);
}
private static Claim CreateClaim(KeyValuePair<string, object> pair) {
var value = pair.Value switch {
string str => str,
DateTime dt => dt.ToString(),
_ => JsonSerializer.Serialize(pair.Value)
};
return new Claim(pair.Key, value);
}
private static IEnumerable<Claim> CreateRoleClaims(KeyValuePair<string, object> pair) {
if (pair.Value is not JsonElement { ValueKind: JsonValueKind.Array } element)
return [];
return element
.EnumerateArray()
.Where(x => x.ValueKind == JsonValueKind.String)
.Select(x => x.GetString()!.Trim())
.Where(x => x.Length > 0)
.Select(x => new Claim(RoleClaimsType, x));
}
}
And you register it in your Program.cs
builder.Services
.AddAuthentication(PasetoToken.Scheme)
.AddScheme<AuthenticationSchemeOptions, PasetoBearerHandler>(PasetoToken.Scheme, x => {
x.Validate();
});
Specifically note the trick for handling roles. .NET wants you to explicitly use their key for your roles. Unfortunately they don't make it a public static property, thus the static constructor there that just grabs it and hold onto it for later use.
The IPasetoToken
is declared like so:
public partial interface IPasetoToken {
FluentResults.Result<PasetoPayload> Validate(string token);
static abstract string Scheme { get; }
static abstract string RolesKey { get; }
}
The static string for Scheme
is just want you want to use in place of "Bearer" and the RolesKey
static string is what you called the list of roles in your payload.