daviddesmet / paseto-dotnet

🔑 Paseto.NET, a Paseto (Platform-Agnostic Security Tokens) implementation for .NET
MIT License
96 stars 8 forks source link

Minimal API example? #129

Closed grosch closed 3 weeks ago

grosch commented 3 weeks ago

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!")
        };
    }
daviddesmet commented 3 weeks ago

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:

https://github.com/blazkaro/FAPIServer/blob/master/FAPIServer/Authentication/Default/ClientAuthenticator.cs

If none of those suits you, I could give it a shot and try to have a working example with Minimal APIs.

grosch commented 3 weeks ago

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();
daviddesmet commented 3 weeks ago

I see the issue, it is deserialized as JsonElement caused by the change to System.Text.Json. I will work on a fix.

daviddesmet commented 3 weeks ago

Fixed in a9672ccecc6dec95036f3452517945e57120e8b0, should see a v1.2.1 NuGet soon.

grosch commented 3 weeks ago

Works great, thank you for the wonderful support and package.

grosch commented 3 weeks ago

@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.