diff --git a/src/dotnet/skills/securing-aspnetcore-apis/SKILL.md b/src/dotnet/skills/securing-aspnetcore-apis/SKILL.md new file mode 100644 index 0000000000..2a5ee0e4de --- /dev/null +++ b/src/dotnet/skills/securing-aspnetcore-apis/SKILL.md @@ -0,0 +1,204 @@ +--- +name: securing-aspnetcore-apis +description: Secure ASP.NET Core APIs with authentication, authorization, JWT bearer tokens, CORS configuration, and rate limiting. Use when adding security to web APIs, configuring auth middleware, or fixing common security misconfigurations. +--- + +# Securing ASP.NET Core APIs + +## When to Use + +- Adding authentication/authorization to an ASP.NET Core API +- Configuring JWT bearer token validation +- Setting up CORS policies for browser clients +- Implementing rate limiting to prevent abuse +- Fixing security misconfigurations + +## When Not to Use + +- The user is building a server-rendered MVC app with cookie auth (different patterns) +- The app is internal-only behind a service mesh that handles auth +- The user needs OAuth provider setup (IdP-specific, not general .NET) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| ASP.NET Core project | Yes | The API project to secure | +| Auth requirements | No | JWT, API key, OAuth, or mixed | + +## Workflow + +### Step 1: Add JWT Bearer authentication + +```bash +dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer +``` + +```csharp +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.Authority = "https://login.microsoftonline.com/{tenant-id}/v2.0"; + options.Audience = "api://{client-id}"; + + // CRITICAL: Do NOT disable these in production + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ClockSkew = TimeSpan.FromMinutes(5) // default; resist reducing to 0 + }; + }); + +builder.Services.AddAuthorization(); +``` + +### Step 2: Configure middleware in the CORRECT order + +**Middleware order is critical. Wrong order = auth bypassed silently.** + +```csharp +var app = builder.Build(); + +// 1. Exception handling first (catches errors from all middleware) +app.UseExceptionHandler("/error"); + +// 2. HTTPS redirection +app.UseHttpsRedirection(); + +// 3. CORS — MUST be before auth for preflight requests to work +app.UseCors(); + +// 4. Authentication — MUST be before Authorization +app.UseAuthentication(); + +// 5. Authorization — MUST be after Authentication +app.UseAuthorization(); + +// 6. Rate limiting — after auth so you can rate-limit per user +app.UseRateLimiter(); + +// 7. Endpoints +app.MapControllers(); +``` + +**Common mistake:** Putting `UseAuthorization()` before `UseAuthentication()` — auth checks run but identity is never set, so everything returns 401. + +### Step 3: Apply authorization policies + +**Per-endpoint (Minimal APIs):** +```csharp +app.MapGet("/api/orders", GetOrders) + .RequireAuthorization(); + +app.MapDelete("/api/orders/{id}", DeleteOrder) + .RequireAuthorization("AdminOnly"); +``` + +**Policy-based authorization:** +```csharp +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => + policy.RequireRole("Admin")); + + options.AddPolicy("CanManageOrders", policy => + policy.RequireClaim("permission", "orders.write")); + + // Fallback policy — applies to ALL endpoints without explicit auth + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); +}); +``` + +**IMPORTANT:** Setting `FallbackPolicy` makes ALL endpoints require auth by default. Explicitly allow anonymous where needed: + +```csharp +app.MapGet("/health", () => "OK").AllowAnonymous(); +app.MapPost("/api/auth/login", Login).AllowAnonymous(); +``` + +### Step 4: Configure CORS correctly + +```csharp +builder.Services.AddCors(options => +{ + options.AddPolicy("Production", policy => + { + policy.WithOrigins( + "https://app.example.com", + "https://admin.example.com") + .WithMethods("GET", "POST", "PUT", "DELETE") + .WithHeaders("Authorization", "Content-Type") + .AllowCredentials(); // Required if frontend sends cookies/tokens + }); +}); + +// Apply globally +app.UseCors("Production"); +``` + +**NEVER do this in production:** +```csharp +// INSECURE — allows any origin to call your API +policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader(); +``` + +### Step 5: Add rate limiting (.NET 7+) + +```csharp +builder.Services.AddRateLimiter(options => +{ + // Global limiter + options.GlobalLimiter = PartitionedRateLimiter.Create( + context => RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.User?.Identity?.Name ?? context.Connection.RemoteIpAddress?.ToString() ?? "anonymous", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0 + })); + + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); +``` + +### Step 6: Security headers + +```csharp +app.Use(async (context, next) => +{ + context.Response.Headers.Append("X-Content-Type-Options", "nosniff"); + context.Response.Headers.Append("X-Frame-Options", "DENY"); + context.Response.Headers.Append("X-XSS-Protection", "0"); // Modern browsers don't need it + context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); + context.Response.Headers.Append("Content-Security-Policy", "default-src 'self'"); + await next(); +}); +``` + +## Security Checklist + +- [ ] `UseAuthentication()` comes BEFORE `UseAuthorization()` +- [ ] JWT validation checks issuer, audience, lifetime, and signing key +- [ ] CORS `WithOrigins` lists specific origins (not `AllowAnyOrigin`) +- [ ] All endpoints require auth by default (FallbackPolicy) +- [ ] Health/login endpoints explicitly marked `AllowAnonymous` +- [ ] Rate limiting enabled with per-user partitioning +- [ ] HTTPS enforced with `UseHttpsRedirection` +- [ ] No secrets in `appsettings.json` (use user-secrets or Key Vault) + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Auth middleware order wrong | Auth → Authz, always in that order. CORS before both | +| `AllowAnyOrigin` in production | Whitelist specific origins | +| JWT secret in appsettings.json | Use environment variables, user-secrets, or Azure Key Vault | +| 401 instead of 403 | 401 = not authenticated; 403 = authenticated but not authorized. Check claims | +| CORS preflight failures | Browser sends OPTIONS; ensure CORS middleware handles it before auth | +| Rate limiter not per-user | Partition by user identity OR IP, not globally | diff --git a/src/dotnet/tests/securing-aspnetcore-apis/eval.yaml b/src/dotnet/tests/securing-aspnetcore-apis/eval.yaml new file mode 100644 index 0000000000..293fb8e4f5 --- /dev/null +++ b/src/dotnet/tests/securing-aspnetcore-apis/eval.yaml @@ -0,0 +1,55 @@ +scenarios: + - name: "Fix CORS preflight 401 with JWT auth middleware ordering" + prompt: | + I set up JWT authentication and CORS on my ASP.NET Core 8 API but the React SPA on https://myapp.example.com keeps getting 401 errors on preflight OPTIONS requests. Here is my middleware pipeline: + + ```csharp + var app = builder.Build(); + app.UseHttpsRedirection(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseCors("AllowSPA"); + app.UseRateLimiter(); + app.MapControllers(); + app.Run(); + ``` + + And CORS is configured as: + ```csharp + builder.Services.AddCors(options => + { + options.AddPolicy("AllowSPA", b => b + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); + }); + ``` + + The JWT auth works fine when I test directly from Postman. What's wrong? + assertions: + - type: "output_matches" + pattern: "(UseCors.*before.*UseAuthentication|middleware.*order|order.*middleware|UseCors.*UseAuth)" + - type: "output_matches" + pattern: "(WithOrigins|specific.*origin|AllowAnyOrigin.*AllowCredentials.*conflict|cannot.*AllowAnyOrigin.*AllowCredentials)" + rubric: + - "Identified that UseCors must come BEFORE UseAuthentication so OPTIONS preflight requests are handled before auth rejects them" + - "Showed the correct middleware order: UseCors → UseAuthentication → UseAuthorization → UseRateLimiter" + - "Identified the AllowAnyOrigin + AllowCredentials conflict (CORS spec forbids this combination)" + - "Fixed CORS to use WithOrigins(\"https://myapp.example.com\") instead of AllowAnyOrigin when using AllowCredentials" + - "Explained why OPTIONS requests fail: browsers send preflight with no auth token, so auth middleware returns 401 before CORS handles it" + - "Provided the corrected full middleware pipeline code" + expect_tools: ["bash"] + timeout: 120 + + - name: "Security skill should not activate for internal service question" + prompt: "I have an internal gRPC service running in Kubernetes behind a service mesh. How do I set up communication between my two microservices?" + assertions: + - type: "output_not_contains" + value: "JwtBearer" + - type: "output_not_matches" + pattern: "(AddAuthentication|JWT|CORS|rate.limit)" + rubric: + - "Did NOT suggest JWT/CORS/rate limiting for an internal service mesh question" + - "Provided gRPC or service mesh relevant guidance" + timeout: 60