Skip to content

[FEATURE] Adopt JWT Authentication via Client Credentials Flow for protected API routes #105

@nanotaboada

Description

@nanotaboada

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
Loading

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

  1. Secure write operations: POST, PUT, DELETE require valid JWT
  2. Keep reads public: GET endpoints remain accessible without authentication
  3. Industry-standard auth: OAuth 2.0 Client Credentials Flow
  4. Production-ready: Secure credential storage, proper error handling
  5. Testable: Easy to test protected endpoints
  6. 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_@42

Generate a secure key:

# Generate 256-bit key (32 bytes, base64 encoded)
openssl rand -base64 32

4. 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.0

9. 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

  1. Constant-time comparison for credentials (prevents timing attacks)
  2. Environment-based secrets (not hardcoded)
  3. Short token expiration (15 min production, 60 min dev)
  4. Proper error messages (don't leak information)
  5. Structured logging (audit trail)
  6. JWT claims validation (issuer, audience, lifetime)

⚠️ Production Considerations

  1. Use HTTPS only - Never transmit tokens over HTTP
  2. Rotate secrets regularly - Update JWT_KEY and client secrets periodically
  3. Rate limiting - Add to /auth/token endpoint to prevent brute force
  4. Secret management - Use Azure Key Vault, AWS Secrets Manager, or similar
  5. Token revocation - Consider implementing a token blocklist for compromised tokens
  6. 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 player
  • PUT /players/squad/{squadNumber} - Update player
  • DELETE /players/squad/{squadNumber} - Delete player

Public Endpoints

  • GET /players - List all players
  • GET /players/{id} - Get player by ID
  • GET /players/squad/{squadNumber} - Get player by squad number

Acceptance Criteria

Functionality

  • /auth/token endpoint 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 Unauthorized with 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 /players requires valid JWT
  • PUT /players/squad/{squadNumber} requires valid JWT
  • DELETE /players/squad/{squadNumber} requires valid JWT
  • GET /players remains 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.example contains 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

  1. Add configuration (Phase 1)
  2. Implement token service (Phase 2)
  3. Create auth controller (Phase 3)
  4. Configure middleware (Phase 4)
  5. Protect endpoints (Phase 5)
  6. Add tests (Phase 6)
  7. Update documentation
  8. Deploy & verify

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    .NETPull requests that update .NET codeenhancementNew feature or requestplanningEnables automatic issue planning with CodeRabbitpriority lowNice-to-have improvement. Can be deferred without blocking other work.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions