Lab 3: Customers and Baristas

45 minutes · Starts from checkpoint-2 · Ends at checkpoint-3

You add real users, issue tokens, and lock down every endpoint so customers and staff can each do exactly what they should.

Step 1: Identity with the EF Core store

dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Create Models/RoasteryUser.cs:

public class RoasteryUser : IdentityUser
{
    public int? CustomerId { get; set; }
}

Change RoasteryDbContext to inherit from IdentityDbContext<RoasteryUser> and register Identity:

builder.Services.AddIdentityCore<RoasteryUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<RoasteryDbContext>()
    .AddApiEndpoints();

Add a migration (AddIdentity) and update the database. Extend the seed to create the Staff role and two users: barista@roastery.dev in the Staff role, and sam@example.com linked to customer 1. Passwords are in src/README.md.

Map Identity's endpoints under /api/auth:

app.MapGroup("/api/auth").MapIdentityApi<RoasteryUser>().WithTags("Auth");

Run the app and send the /api/auth/login request in module-3.http. You get a bearer token back. It works, but it does not carry the claims we need, so keep going.

Step 2: Issue a JWT with roles and customer id

Create Options/JwtOptions.cs (Issuer, Audience, SigningKey) and bind it from the Jwt section. Put the issuer and audience in appsettings.json and the key in user secrets:

dotnet user-secrets init
dotnet user-secrets set "Jwt:SigningKey" "roastery-dev-signing-key-change-me-32-bytes!!"

Add Endpoints/AuthEndpoints.cs with POST /api/auth/token using the IssueToken handler from the concepts page.

Send the two token requests in the .http file, one per seed user. The file captures each token into a variable (@customerToken, @staffToken) that later requests use automatically. Paste one token into jwt.io and confirm the role and customer_id claims are present.

Step 3: Validate tokens and declare policies

Add the AddAuthentication().AddJwtBearer(...) block from the concepts page, then:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Staff", p => p.RequireRole("Staff"))
    .AddPolicy("OwnsOrder", p => p.AddRequirements(new OwnsOrderRequirement()));
builder.Services.AddScoped<IAuthorizationHandler, OwnsOrderHandler>();
app.UseAuthentication();
app.UseAuthorization();

Both Use calls go before the Map calls and after UseHttpsRedirection.

Step 4: Lock it down

Restructure ProductEndpoints into two groups that share a prefix:

var products = app.MapGroup("/api/products").WithTags("Products");
products.MapGet("/", GetProducts);
products.MapGet("/{id:int}", GetProduct);

var staff = products.MapGroup("/").RequireAuthorization("Staff");
staff.MapPost("/", CreateProduct);
staff.MapPut("/{id:int}", UpdateProduct);
staff.MapDelete("/{id:int}", DeleteProduct);

In OrderEndpoints:

  • The whole group gets .RequireAuthorization() (any authenticated user).
  • PUT /{id}/status additionally gets .RequireAuthorization("Staff").
  • CreateOrder takes a ClaimsPrincipal user parameter and reads customer_id from it. Delete the hard-coded CustomerId = 1.
  • GetOrder uses IAuthorizationService with the OwnsOrder policy as shown on the concepts page.
  • GET /api/orders (the list) filters to the caller's own orders unless the caller is Staff.

Step 5: Rate limiting, CORS, and headers

Add the AddRateLimiter configuration from the concepts page, app.UseRateLimiter() after UseHttpsRedirection, and .RequireRateLimiting("auth") on the auth group.

Add CORS with a named policy that allows only https://localhost:5173 (the address of the workshop's small demo front end), with app.UseCors("Frontend").

Add Middleware/SecurityHeadersMiddleware.cs:

app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
    ctx.Response.Headers["Referrer-Policy"] = "no-referrer";
    ctx.Response.Headers["X-Frame-Options"] = "DENY";
    ctx.Response.Headers.Remove("Server");
    await next();
});

Wrap app.UseHsts() in if (!app.Environment.IsDevelopment()).

Step 6: Prove it

Send the verification block in module-3.http. Every request has its expected status in a comment:

Request Expected
GET /api/products with no token 200
POST /api/products with no token 401
POST /api/products with @customerToken 403
POST /api/products with @staffToken 201
POST /api/orders with @customerToken, body carries no customer id 201, and the order belongs to Sam
GET /api/orders/{someone else's id} with @customerToken 404
Same request with @staffToken 200
Sixth POST /api/auth/token inside a minute 429

Stretch goals

  • Wire up Identity's /api/auth/refresh and have the .http file refresh @customerToken when it expires. Shorten the JWT lifetime to two minutes to test it.
  • Add an ApiKey authentication scheme for a partner integration (a header, a lookup in configuration) and show that a single endpoint can accept either scheme with .RequireAuthorization(p => p.AddAuthenticationSchemes("Bearer", "ApiKey").RequireAuthenticatedUser()).
  • Replace RequireRole("Staff") with a claims-based permission scheme (orders:write, products:write) and issue the claims in the token.

Common pitfalls

  • Everything returns 401 even with a token. UseAuthorization is before UseAuthentication, or the Use calls come after the Map calls.
  • IDX10653: The encryption algorithm … key size. The signing key is shorter than 32 bytes.
  • Some staff endpoints are open. You applied RequireAuthorization to individual endpoints and missed one. Apply it to the group.
  • Orders belong to the wrong customer. A customerId is still being read from the request body.

Checkpoint

git add -A && git commit -m "Lab 3 complete"
# or: git checkout checkpoint-3

Next: Module 4: Performance, Resilience, and Testing