Skip to content

Commit

Permalink
Updated to use .NET 8 bearer API
Browse files Browse the repository at this point in the history
- First pass, not using identity endpoints yet
  • Loading branch information
davidfowl committed Jul 13, 2023
1 parent 58d28fc commit d25547d
Show file tree
Hide file tree
Showing 6 changed files with 65 additions and 83 deletions.
23 changes: 7 additions & 16 deletions TodoApi.Tests/TodoApplication.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace TodoApi.Tests;
using Microsoft.AspNetCore.DataProtection;

namespace TodoApi.Tests;

internal class TodoApplication : WebApplicationFactory<Program>
{
Expand All @@ -25,7 +27,7 @@ public HttpClient CreateClient(string id, bool isAdmin = false)
return CreateDefaultClient(new AuthHandler(req =>
{
var token = CreateToken(id, isAdmin);
req.Headers.Authorization = new AuthenticationHeaderValue(JwtBearerDefaults.AuthenticationScheme, token);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}));
}

Expand All @@ -52,21 +54,10 @@ protected override IHost CreateHost(IHostBuilder builder)
o.Password.RequireLowercase = false;
o.Password.RequireUppercase = false;
});
});

// We need to configure signing keys for CI scenarios where
// there's no user-jwts tool
var keyBytes = new byte[32];
RandomNumberGenerator.Fill(keyBytes);
var base64Key = Convert.ToBase64String(keyBytes);
builder.ConfigureAppConfiguration(config =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authentication:Schemes:Bearer:SigningKeys:0:Issuer"] = "dotnet-user-jwts",
["Authentication:Schemes:Bearer:SigningKeys:0:Value"] = base64Key
});
// Since tests run in parallel, it's possible multiple servers will startup,
// we use an ephemeral key provider and repository to avoid filesystem contention issues
services.AddSingleton<IDataProtectionProvider, EphemeralDataProtectionProvider>();
});

return base.CreateHost(builder);
Expand Down
12 changes: 10 additions & 2 deletions TodoApi.Tests/UserApiTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace TodoApi.Tests;
using System.Text.Json.Serialization;

namespace TodoApi.Tests;

public class UserApiTests
{
Expand Down Expand Up @@ -119,7 +121,7 @@ public async Task CanGetATokenForExternalUser()

var token = await response.Content.ReadFromJsonAsync<AuthToken>();

Assert.NotNull(token);
Assert.NotNull(token?.Token);

// Check that the token is indeed valid

Expand Down Expand Up @@ -148,4 +150,10 @@ public async Task BadRequestForInvalidCredentials()

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

class AuthToken
{
[JsonPropertyName("access_token")]
public string? Token { get; set; }
}
}
11 changes: 10 additions & 1 deletion TodoApi/Authentication/AuthenticationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ public static class AuthenticationExtensions
{
public static WebApplicationBuilder AddAuthentication(this WebApplicationBuilder builder)
{
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthentication().AddBearerToken(o =>
{
o.Events = new()
{
OnMessageReceived = context =>
{
return Task.CompletedTask;
}
};
});

return builder;
}
Expand Down
74 changes: 19 additions & 55 deletions TodoApi/Authentication/TokenService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
using System.Data;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.Extensions.Options;

namespace TodoApi;

Expand All @@ -19,77 +17,43 @@ public static IServiceCollection AddTokenService(this IServiceCollection service

public interface ITokenService
{
// Generate a JWT token for the specified user name and admin role
// Generate a token for the specified user name and admin role
string GenerateToken(string username, bool isAdmin = false);
}

public sealed class TokenService : ITokenService
{
private readonly string _issuer;
private readonly SigningCredentials _jwtSigningCredentials;
private readonly Claim[] _audiences;
private BearerTokenOptions _options;

public TokenService(IAuthenticationConfigurationProvider authenticationConfigurationProvider)
public TokenService(IOptionsMonitor<BearerTokenOptions> options)
{
// We're reading the authentication configuration for the Bearer scheme
var bearerSection = authenticationConfigurationProvider.GetSchemeConfiguration(JwtBearerDefaults.AuthenticationScheme);

// An example of what the expected schema looks like
// "Authentication": {
// "Schemes": {
// "Bearer": {
// "ValidAudiences": [ ],
// "ValidIssuer": "",
// "SigningKeys": [ { "Issuer": .., "Value": base64Key, "Length": 32 } ]
// }
// }
// }

var section = bearerSection.GetSection("SigningKeys:0");

_issuer = bearerSection["ValidIssuer"] ?? throw new InvalidOperationException("Issuer is not specified");
var signingKeyBase64 = section["Value"] ?? throw new InvalidOperationException("Signing key is not specified");

var signingKeyBytes = Convert.FromBase64String(signingKeyBase64);

_jwtSigningCredentials = new SigningCredentials(new SymmetricSecurityKey(signingKeyBytes),
SecurityAlgorithms.HmacSha256Signature);

_audiences = bearerSection.GetSection("ValidAudiences").GetChildren()
.Where(s => !string.IsNullOrEmpty(s.Value))
.Select(s => new Claim(JwtRegisteredClaimNames.Aud, s.Value!))
.ToArray();
_options = options.Get(BearerTokenDefaults.AuthenticationScheme);
}

public string GenerateToken(string username, bool isAdmin = false)
{
var identity = new ClaimsIdentity(JwtBearerDefaults.AuthenticationScheme);

identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, username));

// REVIEW: Check that this logic is OK for jti claims
var id = Guid.NewGuid().ToString().GetHashCode().ToString("x", CultureInfo.InvariantCulture);
var identity = new ClaimsIdentity(BearerTokenDefaults.AuthenticationScheme);

identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, id));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, username));

if (isAdmin)
{
identity.AddClaim(new Claim(ClaimTypes.Role, "admin"));
}

identity.AddClaims(_audiences);
var utcNow = (_options.TimeProvider ?? TimeProvider.System).GetUtcNow();

var properties = new AuthenticationProperties
{
ExpiresUtc = utcNow + _options.BearerTokenExpiration
};

var handler = new JwtSecurityTokenHandler();
var ticket = CreateBearerTicket(new ClaimsPrincipal(identity), properties);

var jwtToken = handler.CreateJwtSecurityToken(
_issuer,
audience: null,
identity,
notBefore: DateTime.UtcNow,
expires: DateTime.UtcNow.AddMinutes(30),
issuedAt: DateTime.UtcNow,
_jwtSigningCredentials);
static AuthenticationTicket CreateBearerTicket(ClaimsPrincipal user, AuthenticationProperties properties)
=> new(user, properties, $"{BearerTokenDefaults.AuthenticationScheme}:AccessToken");

return handler.WriteToken(jwtToken);
return _options.BearerTokenProtector.Protect(ticket);
}
}
4 changes: 0 additions & 4 deletions TodoApi/Users/AuthToken.cs

This file was deleted.

24 changes: 19 additions & 5 deletions TodoApi/Users/UsersApi.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Http.HttpResults;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;

namespace TodoApi;
Expand All @@ -25,7 +27,7 @@ public static RouteGroupBuilder MapUsers(this IEndpointRouteBuilder routes)
return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
});

group.MapPost("/token", async Task<Results<BadRequest, Ok<AuthToken>>> (UserInfo userInfo, UserManager<TodoUser> userManager, ITokenService tokenService) =>
group.MapPost("/token", async Task<Results<BadRequest, SignInHttpResult>> (UserInfo userInfo, UserManager<TodoUser> userManager) =>
{
var user = await userManager.FindByNameAsync(userInfo.Username);
Expand All @@ -34,10 +36,12 @@ public static RouteGroupBuilder MapUsers(this IEndpointRouteBuilder routes)
return TypedResults.BadRequest();
}
return TypedResults.Ok(new AuthToken(tokenService.GenerateToken(user.UserName!)));
ClaimsPrincipal principal = CreateClaimsPrincipal(user);
return TypedResults.SignIn(principal, authenticationScheme: BearerTokenDefaults.AuthenticationScheme);
});

group.MapPost("/token/{provider}", async Task<Results<Ok<AuthToken>, ValidationProblem>> (string provider, ExternalUserInfo userInfo, UserManager<TodoUser> userManager, ITokenService tokenService) =>
group.MapPost("/token/{provider}", async Task<Results<SignInHttpResult, ValidationProblem>> (string provider, ExternalUserInfo userInfo, UserManager<TodoUser> userManager) =>
{
var user = await userManager.FindByLoginAsync(provider, userInfo.ProviderKey);
Expand All @@ -57,12 +61,22 @@ public static RouteGroupBuilder MapUsers(this IEndpointRouteBuilder routes)
if (result.Succeeded)
{
return TypedResults.Ok(new AuthToken(tokenService.GenerateToken(user.UserName!)));
ClaimsPrincipal principal = CreateClaimsPrincipal(user);
return TypedResults.SignIn(principal, authenticationScheme: BearerTokenDefaults.AuthenticationScheme);
}
return TypedResults.ValidationProblem(result.Errors.ToDictionary(e => e.Code, e => new[] { e.Description }));
});

return group;
}

private static ClaimsPrincipal CreateClaimsPrincipal(TodoUser user)
{
return new ClaimsPrincipal(
new ClaimsIdentity(new[] {
new Claim(ClaimTypes.NameIdentifier, user.UserName!) },
BearerTokenDefaults.AuthenticationScheme));
}
}

0 comments on commit d25547d

Please sign in to comment.