Skip to content

Commit

Permalink
Added per user todos and auth
Browse files Browse the repository at this point in the history
- Made tests generate JWTs using the same key material.
  • Loading branch information
davidfowl committed Nov 12, 2022
1 parent 4abc6f0 commit 627c163
Show file tree
Hide file tree
Showing 15 changed files with 388 additions and 32 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,5 @@ __pycache__/

# MacOS
.DS_Store
*.db-shm
*.db-wal
96 changes: 96 additions & 0 deletions TodoApi.Tests/JwtIssuer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Principal;
using Microsoft.IdentityModel.Tokens;

namespace TodoApi.Tests;

// Lifted from: https://github.com/dotnet/aspnetcore/blob/b39a258cbce1b16ee98679ef7d2ddc2e09040a6b/src/Tools/dotnet-user-jwts/src/Helpers/JwtIssuer.cs#L13
internal sealed class JwtIssuer
{
private readonly SymmetricSecurityKey _signingKey;

public JwtIssuer(string issuer, byte[] signingKeyMaterial)
{
Issuer = issuer;
_signingKey = new SymmetricSecurityKey(signingKeyMaterial);
}

public string Issuer { get; }

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("scope", s)));
}

if (options.Roles is { } rolesToAdd)
{
identity.AddClaims(rolesToAdd.Select(r => new Claim(ClaimTypes.Role, r)));
}

if (options.Claims is { Count: > 0 } claimsToAdd)
{
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 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;
}

public static string WriteToken(JwtSecurityToken token)
{
var handler = new JwtSecurityTokenHandler();
return handler.WriteToken(token);
}

public static JwtSecurityToken Extract(string token) => new JwtSecurityToken(token);

public bool IsValid(string encodedToken)
{
var handler = new JwtSecurityTokenHandler();
var tokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = _signingKey,
ValidateAudience = false,
ValidateIssuer = false,
ValidateIssuerSigningKey = true
};
if (handler.ValidateToken(encodedToken, tokenValidationParameters, out _).Identity?.IsAuthenticated == true)
{
return true;
}
return false;
}
}

internal sealed record JwtCreatorOptions(
string Scheme,
string Name,
List<string> Audiences,
string Issuer,
DateTime NotBefore,
DateTime ExpiresOn,
List<string> Roles,
List<string> Scopes,
Dictionary<string, string> Claims);
57 changes: 56 additions & 1 deletion TodoApi.Tests/TodoApplication.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
using Microsoft.AspNetCore.Mvc.Testing;
using System.Net.Http.Headers;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using TodoApi.Tests;

internal class TodoApplication : WebApplicationFactory<Program>
{
private JwtIssuer? _jwtIssuer;
private List<string>? _audiences;

protected override IHost CreateHost(IHostBuilder builder)
{
var root = new InMemoryDatabaseRoot();
Expand All @@ -21,4 +28,52 @@ protected override IHost CreateHost(IHostBuilder builder)

return base.CreateHost(builder);
}

private string CreateToken(string id)
{
var configuration = Services.GetRequiredService<IConfiguration>();
var bearerSection = configuration.GetSection("Authentication:Schemes:Bearer");
var section = bearerSection.GetSection("SigningKeys:0");
var issuer = section["Issuer"]!;
var value = Convert.FromBase64String(section["Value"]!);
_audiences = bearerSection.GetSection("ValidAudiences").GetChildren().Select(s => s.Value!).ToList();

_jwtIssuer = new JwtIssuer(issuer, value);
var token = _jwtIssuer!.Create(new(
JwtBearerDefaults.AuthenticationScheme,
Name: Guid.NewGuid().ToString(),
Audiences: _audiences!,
Issuer: _jwtIssuer.Issuer,
NotBefore: DateTime.UtcNow,
ExpiresOn: DateTime.UtcNow.AddDays(1),
Roles: new List<string> { },
Scopes: new List<string> { },
Claims: new Dictionary<string, string> { ["id"] = id }));
return JwtIssuer.WriteToken(token);
}

public HttpClient CreateClient(string id)
{
return CreateDefaultClient(new AuthHandler(req =>
{
var token = CreateToken(id);
req.Headers.Authorization = new AuthenticationHeaderValue(JwtBearerDefaults.AuthenticationScheme, token);
}));
}

private class AuthHandler : DelegatingHandler
{
private readonly Action<HttpRequestMessage> _onRequest;

public AuthHandler(Action<HttpRequestMessage> onRequest)
{
_onRequest = onRequest;
}

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
_onRequest(request);
return base.SendAsync(request, cancellationToken);
}
}
}
39 changes: 35 additions & 4 deletions TodoApi.Tests/TodoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ public class TodoTests
[Fact]
public async Task GetTodos()
{
await using var application = new TodoApplication();
var userId = "34";

var client = application.CreateClient();
await using var application = new TodoApplication();
var client = application.CreateClient(userId);
var todos = await client.GetFromJsonAsync<List<Todo>>("/todos");
Assert.NotNull(todos);

Expand All @@ -19,9 +20,10 @@ public async Task GetTodos()
[Fact]
public async Task PostTodos()
{
var userId = "34";
await using var application = new TodoApplication();

var client = application.CreateClient();
var client = application.CreateClient(userId);
var response = await client.PostAsJsonAsync("/todos", new Todo { Title = "I want to do this thing tomorrow" });

Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Expand All @@ -35,12 +37,41 @@ public async Task PostTodos()
Assert.False(todo.IsComplete);
}

[Fact]
public async Task CanOnlySeeTodosPostedBySameUser()
{
var userId0 = "34";
var userId1 = "35";
await using var application = new TodoApplication();

var client0 = application.CreateClient(userId0);
var client1 = application.CreateClient(userId1);

var response = await client0.PostAsJsonAsync("/todos", new Todo { Title = "I want to do this thing tomorrow" });

Assert.Equal(HttpStatusCode.Created, response.StatusCode);

var todos0 = await client0.GetFromJsonAsync<List<Todo>>("/todos");
Assert.NotNull(todos0);

var todos1 = await client1.GetFromJsonAsync<List<Todo>>("/todos");
Assert.NotNull(todos1);

Assert.Empty(todos1);

var todo = Assert.Single(todos0);
Assert.Equal("I want to do this thing tomorrow", todo.Title);
Assert.False(todo.IsComplete);
}

[Fact]
public async Task DeleteTodos()
{
var userId = "34";

await using var application = new TodoApplication();

var client = application.CreateClient();
var client = application.CreateClient(userId);
var response = await client.PostAsJsonAsync("/todos", new Todo { Title = "I want to do this thing tomorrow" });

Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Expand Down
45 changes: 45 additions & 0 deletions TodoApi/Migrations/20221112065523_Owners.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions TodoApi/Migrations/20221112065523_Owners.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Sample.Migrations
{
/// <inheritdoc />
public partial class Owners : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OwnerId",
table: "Todos",
type: "TEXT",
nullable: false,
defaultValue: "");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OwnerId",
table: "Todos");
}
}
}
9 changes: 7 additions & 2 deletions TodoApi/Migrations/TodoDbContextModelSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

#nullable disable

namespace Sample.Migrations
{
[DbContext(typeof(TodoDbContext))]
Expand All @@ -11,8 +13,7 @@ partial class TodoDbContextModelSnapshot : ModelSnapshot
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.0-preview.6.21318.1");
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");

modelBuilder.Entity("Todo", b =>
{
Expand All @@ -23,6 +24,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsComplete")
.HasColumnType("INTEGER");

b.Property<string>("OwnerId")
.IsRequired()
.HasColumnType("TEXT");

b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
Expand Down
34 changes: 34 additions & 0 deletions TodoApi/OpenApiExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.OpenApi.Models;

namespace TodoApi;

public static class OpenApiExtensions
{
public static IEndpointConventionBuilder AddOpenApiSecurityRequirement(this IEndpointConventionBuilder builder)
{
var scheme = new OpenApiSecurityScheme()
{
Type = SecuritySchemeType.Http,
Name = JwtBearerDefaults.AuthenticationScheme,
Scheme = JwtBearerDefaults.AuthenticationScheme,
Reference = new()
{
Type = ReferenceType.SecurityScheme,
Id = JwtBearerDefaults.AuthenticationScheme
}
};

return builder.WithOpenApi(operation => new(operation)
{
Security =
{
new()
{
[scheme] = new List<string>()
}
}
});
}

}
Loading

0 comments on commit 627c163

Please sign in to comment.