-
Notifications
You must be signed in to change notification settings - Fork 15
Description
Description
We need to secure API endpoints that perform data mutations (POST, PUT, DELETE) so that only authorized machine-to-machine (M2M) clients — such as CLIs, daemons, or backend services — can access them.
To achieve this, we will implement JWT Bearer authentication using the OAuth 2.0 Client Credentials Flow. This allows external systems to authenticate with a client_id and client_secret, receive a signed JWT, and use it to authorize calls to protected API routes.
This pattern is the industry standard for M2M authentication used by public APIs (FedEx, UPS, Stripe, GitHub, etc.).
Authentication Flow
sequenceDiagram
participant Client as Client (M2M Application)
participant Auth as Auth Endpoint
participant API as Protected API Endpoint
Note over Client,API: Step 1 - Obtain Access Token
Client->>Auth: POST /auth/token<br/>(client_id, client_secret)
Auth->>Auth: Validate credentials
Auth->>Auth: Generate JWT with claims
Auth-->>Client: 200 OK<br/>{access_token, expires_in, token_type}
Note over Client,API: Step 2 - Access Protected Resources
Client->>API: POST /players<br/>Authorization: Bearer {token}
API->>API: Validate JWT signature
API->>API: Check claims & expiration
API-->>Client: 201 Created
Client->>API: PUT /players/7<br/>Authorization: Bearer {token}
API->>API: Validate JWT
API-->>Client: 204 No Content
Client->>API: DELETE /players/7<br/>Authorization: Bearer {token}
API->>API: Validate JWT
API-->>Client: 204 No Content
Current State
- ✅ All endpoints are currently public (no authentication required)
- ❌ No protection for data-modifying operations
- ❌ No audit trail of who made changes
- ❌ Unsuitable for production deployment
Goals
- Secure write operations:
POST,PUT,DELETErequire valid JWT - Keep reads public:
GETendpoints remain accessible without authentication - Industry-standard auth: OAuth 2.0 Client Credentials Flow
- Production-ready: Secure credential storage, proper error handling
- Testable: Easy to test protected endpoints
- Documented: Clear examples for consumers
Implementation Strategy
Phase 1: Configuration & Models
1. Add JWT Configuration to appsettings.json
{
"Jwt": {
"Key": "", // Will be set via environment variable
"Issuer": "dotnet-samples-aspnetcore-webapi",
"Audience": "dotnet-samples-aspnetcore-webapi-api",
"ExpirationMinutes": 60
},
"ClientCredentials": {
"ClientId": "", // Will be set via environment variable
"ClientSecret": "" // Will be set via environment variable
}
}2. Add Production Configuration (appsettings.Production.json)
{
"Jwt": {
"ExpirationMinutes": 15 // Shorter expiration for production
}
}3. Create .env.example for Local Development
# JWT Configuration
JWT_KEY=1LnBfWcu7gTDmqT41QCW4ANu1xsHMcseingKWruVveM=
JWT_ISSUER=dotnet-samples-aspnetcore-webapi
JWT_AUDIENCE=dotnet-samples-aspnetcore-webapi-api
# Client Credentials (M2M Authentication)
CLIENT_ID=demo-client
CLIENT_SECRET=#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09_@42Generate a secure key:
# Generate 256-bit key (32 bytes, base64 encoded)
openssl rand -base64 324. Create Request/Response Models
Models/Auth/TokenRequestModel.cs:
using System.ComponentModel.DataAnnotations;
namespace Dotnet.Samples.AspNetCore.WebApi.Models.Auth;
public class TokenRequestModel
{
[Required]
[JsonPropertyName("grant_type")]
public string GrantType { get; set; } = "client_credentials";
[Required]
[JsonPropertyName("client_id")]
public string ClientId { get; set; } = default!;
[Required]
[JsonPropertyName("client_secret")]
public string ClientSecret { get; set; } = default!;
}Models/Auth/TokenResponseModel.cs:
using System.Text.Json.Serialization;
namespace Dotnet.Samples.AspNetCore.WebApi.Models.Auth;
public class TokenResponseModel
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;
[JsonPropertyName("token_type")]
public string TokenType { get; set; } = "Bearer";
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("scope")]
public string Scope { get; set; } = "api:write";
}Models/Auth/ErrorResponseModel.cs:
using System.Text.Json.Serialization;
namespace Dotnet.Samples.AspNetCore.WebApi.Models.Auth;
public class ErrorResponseModel
{
[JsonPropertyName("error")]
public string Error { get; set; } = null!;
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
}Phase 2: Token Service
5. Create ITokenService Interface
Services/ITokenService.cs:
namespace Dotnet.Samples.AspNetCore.WebApi.Services;
public interface ITokenService
{
/// <summary>
/// Generates a JWT access token for the specified client.
/// </summary>
/// <param name="clientId">The client identifier</param>
/// <returns>A tuple containing the access token and expiration time in seconds</returns>
(string AccessToken, int ExpiresIn) GenerateToken(string clientId);
/// <summary>
/// Validates client credentials against configured values.
/// </summary>
/// <param name="clientId">The client identifier</param>
/// <param name="clientSecret">The client secret</param>
/// <returns>True if credentials are valid</returns>
bool ValidateClientCredentials(string clientId, string clientSecret);
}6. Implement TokenService
Services/TokenService.cs:
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Dotnet.Samples.AspNetCore.WebApi.Services;
public class TokenService : ITokenService
{
private readonly IConfiguration _configuration;
private readonly ILogger<TokenService> _logger;
public TokenService(IConfiguration configuration, ILogger<TokenService> logger)
{
_configuration = configuration;
_logger = logger;
}
public bool ValidateClientCredentials(string clientId, string clientSecret)
{
var configuredClientId = _configuration["ClientCredentials:ClientId"];
var configuredClientSecret = _configuration["ClientCredentials:ClientSecret"];
if (string.IsNullOrEmpty(configuredClientId) || string.IsNullOrEmpty(configuredClientSecret))
{
_logger.LogError("Client credentials not configured");
return false;
}
// Use constant-time comparison to prevent timing attacks
var clientIdValid = CryptographicEquals(clientId, configuredClientId);
var secretValid = CryptographicEquals(clientSecret, configuredClientSecret);
return clientIdValid && secretValid;
}
public (string AccessToken, int ExpiresIn) GenerateToken(string clientId)
{
var expirationMinutes = _configuration.GetValue<int>("Jwt:ExpirationMinutes", 60);
var expiresIn = expirationMinutes * 60; // Convert to seconds
var expires = DateTime.UtcNow.AddMinutes(expirationMinutes);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, clientId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new Claim("client_id", clientId),
new Claim("scope", "api:write")
};
var key = _configuration["Jwt:Key"];
if (string.IsNullOrEmpty(key))
{
throw new InvalidOperationException("JWT signing key is not configured");
}
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _configuration["Jwt:Issuer"],
audience: _configuration["Jwt:Audience"],
claims: claims,
expires: expires,
signingCredentials: signingCredentials
);
var accessToken = new JwtSecurityTokenHandler().WriteToken(token);
_logger.LogInformation("Generated JWT for client {ClientId}, expires at {ExpiresAt}",
clientId, expires);
return (accessToken, expiresIn);
}
// Constant-time string comparison to prevent timing attacks
private static bool CryptographicEquals(string a, string b)
{
if (a == null || b == null)
return false;
if (a.Length != b.Length)
return false;
var result = 0;
for (var i = 0; i < a.Length; i++)
{
result |= a[i] ^ b[i];
}
return result == 0;
}
}Phase 3: Authentication Controller
7. Create AuthController
Controllers/AuthController.cs:
using Dotnet.Samples.AspNetCore.WebApi.Models.Auth;
using Dotnet.Samples.AspNetCore.WebApi.Services;
using Microsoft.AspNetCore.Mvc;
namespace Dotnet.Samples.AspNetCore.WebApi.Controllers;
[ApiController]
[Route("auth")]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly ITokenService _tokenService;
private readonly ILogger<AuthController> _logger;
public AuthController(ITokenService tokenService, ILogger<AuthController> logger)
{
_tokenService = tokenService;
_logger = logger;
}
/// <summary>
/// OAuth 2.0 Token Endpoint (Client Credentials Flow)
/// </summary>
/// <param name="request">Token request containing client credentials</param>
/// <returns>JWT access token if credentials are valid</returns>
[HttpPost("token")]
[ProducesResponseType(typeof(TokenResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponseModel), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponseModel), StatusCodes.Status401Unauthorized)]
public IActionResult Token([FromBody] TokenRequestModel request)
{
// Validate grant type
if (request.GrantType != "client_credentials")
{
_logger.LogWarning("Unsupported grant type: {GrantType}", request.GrantType);
return BadRequest(new ErrorResponseModel
{
Error = "unsupported_grant_type",
ErrorDescription = "Only 'client_credentials' grant type is supported"
});
}
// Validate client credentials
if (!_tokenService.ValidateClientCredentials(request.ClientId, request.ClientSecret))
{
_logger.LogWarning("Invalid client credentials for client_id: {ClientId}", request.ClientId);
return Unauthorized(new ErrorResponseModel
{
Error = "invalid_client",
ErrorDescription = "Client authentication failed"
});
}
// Generate token
try
{
var (accessToken, expiresIn) = _tokenService.GenerateToken(request.ClientId);
return Ok(new TokenResponseModel
{
AccessToken = accessToken,
ExpiresIn = expiresIn
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating token for client: {ClientId}", request.ClientId);
return StatusCode(500, new ErrorResponseModel
{
Error = "server_error",
ErrorDescription = "An error occurred while generating the token"
});
}
}
}Phase 4: Configure Authentication & Authorization
8. Add NuGet Package
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.09. Register Services in ServiceCollectionExtensions.cs
public static IServiceCollection AddJwtAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddScoped<ITokenService, TokenService>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero, // No clock skew tolerance
ValidIssuer = configuration["Jwt:Issuer"],
ValidAudience = configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!))
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization();
return services;
}10. Update Program.cs
// Add authentication & authorization
builder.Services.AddJwtAuthentication(builder.Configuration);
var app = builder.Build();
// Add middleware (ORDER MATTERS!)
app.UseAuthentication(); // Must come before UseAuthorization
app.UseAuthorization();Phase 5: Protect API Endpoints
11. Update PlayerController
using Microsoft.AspNetCore.Authorization;
[ApiController]
[Route("players")]
public class PlayerController : ControllerBase
{
// Public endpoint - no authentication required
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> GetAll() { /* ... */ }
// Public endpoint - no authentication required
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetById(Guid id) { /* ... */ }
// Protected endpoint - requires JWT
[HttpPost]
[Authorize]
public async Task<IActionResult> Create([FromBody] PlayerRequestModel model) { /* ... */ }
// Protected endpoint - requires JWT
[HttpPut("squad/{squadNumber}")]
[Authorize]
public async Task<IActionResult> Update(int squadNumber, [FromBody] PlayerRequestModel model) { /* ... */ }
// Protected endpoint - requires JWT
[HttpDelete("squad/{squadNumber}")]
[Authorize]
public async Task<IActionResult> Delete(int squadNumber) { /* ... */ }
}Phase 6: Testing
12. Manual Testing with cURL
Step 1: Request a token
curl -X POST http://localhost:9000/auth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "demo-client",
"client_secret": "#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09_@42"
}'Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api:write"
}Step 2: Use token to create a player
curl -X POST http://localhost:9000/players \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-d '{
"firstName": "Lionel",
"lastName": "Messi",
"squadNumber": 10,
"position": "RW"
}'Step 3: Test without token (should fail)
curl -X POST http://localhost:9000/players \
-H "Content-Type: application/json" \
-d '{
"firstName": "Lionel",
"lastName": "Messi",
"squadNumber": 10,
"position": "RW"
}'Expected response: 401 Unauthorized
13. Unit Tests
test/Unit/AuthControllerTests.cs:
public class AuthControllerTests
{
[Fact]
public void Token_WithValidCredentials_ReturnsToken()
{
// Arrange
var mockTokenService = new Mock<ITokenService>();
mockTokenService
.Setup(x => x.ValidateClientCredentials("test-client", "test-secret"))
.Returns(true);
mockTokenService
.Setup(x => x.GenerateToken("test-client"))
.Returns(("fake-jwt-token", 3600));
var controller = new AuthController(mockTokenService.Object, Mock.Of<ILogger<AuthController>>());
var request = new TokenRequestModel
{
GrantType = "client_credentials",
ClientId = "test-client",
ClientSecret = "test-secret"
};
// Act
var result = controller.Token(request) as OkObjectResult;
// Assert
result.Should().NotBeNull();
result!.StatusCode.Should().Be(200);
var response = result.Value as TokenResponseModel;
response.Should().NotBeNull();
response!.AccessToken.Should().Be("fake-jwt-token");
response.ExpiresIn.Should().Be(3600);
}
[Fact]
public void Token_WithInvalidCredentials_ReturnsUnauthorized()
{
// Arrange
var mockTokenService = new Mock<ITokenService>();
mockTokenService
.Setup(x => x.ValidateClientCredentials(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
var controller = new AuthController(mockTokenService.Object, Mock.Of<ILogger<AuthController>>());
var request = new TokenRequestModel
{
GrantType = "client_credentials",
ClientId = "wrong-client",
ClientSecret = "wrong-secret"
};
// Act
var result = controller.Token(request) as UnauthorizedObjectResult;
// Assert
result.Should().NotBeNull();
result!.StatusCode.Should().Be(401);
}
}Security Best Practices
✅ Implemented
- Constant-time comparison for credentials (prevents timing attacks)
- Environment-based secrets (not hardcoded)
- Short token expiration (15 min production, 60 min dev)
- Proper error messages (don't leak information)
- Structured logging (audit trail)
- JWT claims validation (issuer, audience, lifetime)
⚠️ Production Considerations
- Use HTTPS only - Never transmit tokens over HTTP
- Rotate secrets regularly - Update
JWT_KEYand client secrets periodically - Rate limiting - Add to
/auth/tokenendpoint to prevent brute force - Secret management - Use Azure Key Vault, AWS Secrets Manager, or similar
- Token revocation - Consider implementing a token blocklist for compromised tokens
- Audit logging - Log all authentication attempts
🔐 Secret Storage by Environment
| Environment | Storage Method |
|---|---|
| Local Dev | .env file (git-ignored) |
| Docker | Docker secrets or .env file |
| Azure | Azure Key Vault + App Configuration |
| AWS | AWS Secrets Manager |
| CI/CD | GitHub Secrets, Azure DevOps Variables |
Documentation Updates
README.md
Add new section:
## Authentication
Protected endpoints require JWT authentication using the OAuth 2.0 Client Credentials Flow.
### Obtain Access Token
```bash
curl -X POST https://localhost:9000/auth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "your-client-id",
"client_secret": "your-client-secret"
}'Use Token
curl -X POST https://localhost:9000/players \
-H "Authorization: Bearer {your-access-token}" \
-H "Content-Type: application/json" \
-d '{ ... }'Protected Endpoints
POST /players- Create playerPUT /players/squad/{squadNumber}- Update playerDELETE /players/squad/{squadNumber}- Delete player
Public Endpoints
GET /players- List all playersGET /players/{id}- Get player by IDGET /players/squad/{squadNumber}- Get player by squad number
Acceptance Criteria
Functionality
-
/auth/tokenendpoint issues JWT for valid client credentials - Token contains required claims (
sub,jti,iat,client_id,scope) - Tokens expire after configured time (60 min dev, 15 min production)
- Invalid credentials return
401 Unauthorizedwith proper error response - Unsupported grant types return
400 Bad Request
Security
- JWT signing key stored in environment variables (not appsettings.json)
- Client credentials stored in environment variables
- Constant-time comparison used for credential validation
- No sensitive information leaked in error messages
- Authentication/authorization attempts logged
Authorization
-
POST /playersrequires valid JWT -
PUT /players/squad/{squadNumber}requires valid JWT -
DELETE /players/squad/{squadNumber}requires valid JWT -
GET /playersremains publicly accessible -
GET /players/{id}remains publicly accessible - Expired tokens return
401 Unauthorized - Missing/invalid tokens return
401 Unauthorized
Testing
- Unit tests for
TokenService.GenerateToken() - Unit tests for
TokenService.ValidateClientCredentials() - Unit tests for
AuthController.Token()(valid/invalid credentials) - Integration tests for protected endpoints
- Manual testing with cURL documented
Documentation
- README.md updated with authentication instructions
-
.env.examplecontains all required auth variables - Swagger UI shows which endpoints require authentication
- Example requests with Bearer tokens provided
- Security best practices documented
Docker
- Works with Docker Compose (credentials via
.env) - Healthcheck endpoint remains publicly accessible
- Swagger UI accessible (development only)
Migration Path
- Add configuration (Phase 1)
- Implement token service (Phase 2)
- Create auth controller (Phase 3)
- Configure middleware (Phase 4)
- Protect endpoints (Phase 5)
- Add tests (Phase 6)
- Update documentation
- Deploy & verify