Skip to content

Adopt JWT Authentication using Client Credentials Flow for protected API routes #132

Open
@nanotaboada

Description

@nanotaboada

Description

Secure the API's mutating endpoints (POST, PUT, and DELETE) by introducing JWT-based authentication following the OAuth 2.0 Client Credentials Flow.

In this flow, machine-to-machine communication is secured by issuing JWTs to clients authenticated using a client_id and client_secret. These short-lived tokens are then required in the Authorization header for protected endpoints. This ensures only registered clients can perform state-changing operations on the API.

sequenceDiagram
    participant Client as Client (Machine-to-Machine app)
    participant Server as Server (Spring Boot RESTful API) 

    Note over Client,Server: Step 1 - Obtain JWT Access Token

    Client->>Server: POST /auth/token (client_id, client_secret)
    Server-->>Client: 200 OK { access_token, expires_in, token_type }

    Note over Client,Server: Step 2 - Access Protected Resources

    Client->>Server: POST /{resource} (Authorization: Bearer {access_token})
    Server-->>Client: 201 Created

    Client->>Server: PUT /{resource}/{id} (Authorization: Bearer {access_token})
    Server-->>Client: 204 No Content

    Client->>Server: DELETE /{resource}/{id} (Authorization: Bearer {access_token})
    Server-->>Client: 204 No Content
Loading

Proposed Solution

Implement JWT issuance and validation using Spring Security and a lightweight in-memory client registry (or configurable store). The JWTs will be signed using a shared secret (HMAC) or an asymmetric key pair (RSA) depending on the security posture.

Key points:

  • Introduce a /token endpoint for clients to exchange their credentials for a JWT.
  • Secure mutating routes with Spring Security filters, enforcing valid JWTs.
  • Validate claims like expiration, audience, and scope (if required).
  • Add integration tests for token issuance and protected route access.

Suggested Approach

1. Define a configuration for client credentials

@Configuration
public class ClientConfig {
    @Bean
    public Map<String, String> clientStore() {
        return Map.of("foobarbaz", "#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09=@42");
    }
}

2. Token issuance endpoint

@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private Map<String, String> clientStore;

    @PostMapping
    public ResponseEntity<?> getToken(@RequestBody Map<String, String> request) {
        String grantType = request.get("grant_type");
        String clientId = request.get("client_id");
        String clientSecret = request.get("client_secret");

        if (!"client_credentials".equalsIgnoreCase(grantType)) {
            return ResponseEntity
                    .badRequest()
                    .body(Map.of(
                        "error", "unsupported_grant_type",
                        "error_description", "grant_type must be 'client_credentials'"
                    ));
        }

        if (clientId == null || clientSecret == null ||
            !clientStore.containsKey(clientId) ||
            !clientStore.get(clientId).equals(clientSecret)) {

            return ResponseEntity
                    .status(HttpStatus.UNAUTHORIZED)
                    .body(Map.of(
                        "error", "invalid_client",
                        "error_description", "Invalid client credentials"
                    ));
        }

        String token = jwtService.generateToken(clientId);
        return ResponseEntity.ok(Map.of(
            "access_token", token,
            "token_type", "Bearer",
            "expires_in", 3600
        ));
    }
}

3. JWT generation logic

@Service
public class JwtService {

    private final String secretKey = "super-secret-key"; // ideally load from env
    private final long expiration = 3600; // 1 hour in seconds

    public String generateToken(String clientId) {
        return Jwts.builder()
                .setSubject(clientId)
                .setIssuedAt(new Date())
                .setExpiration(Date.from(Instant.now().plusSeconds(expiration)))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody();
    }
}

4. Security configuration

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtService jwtService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()
            .authorizeHttpRequests()
                .requestMatchers(HttpMethod.GET, "/**").permitAll()
                .requestMatchers(HttpMethod.POST, "/**").authenticated()
                .requestMatchers(HttpMethod.PUT, "/**").authenticated()
                .requestMatchers(HttpMethod.DELETE, "/**").authenticated()
                .anyRequest().permitAll()
            .and()
            .addFilterBefore(new JwtAuthenticationFilter(jwtService), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

5. JWT Authentication Filter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    public JwtAuthenticationFilter(JwtService jwtService) {
        this.jwtService = jwtService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                Claims claims = jwtService.parseToken(token);
                Authentication auth = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, List.of()); // optionally add roles
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (JwtException e) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }
        }
        filterChain.doFilter(request, response);
    }
}

Sample request with curl

curl -X POST http://localhost:9000/auth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=foobarbaz" \
  -d "client_secret=#!_7h3qu1ck8r0wnf0xjump50v3r7h3l42yd09=@42"

Acceptance Criteria

  • A /token endpoint exists and accepts valid client_id/client_secret credentials to return a JWT.
  • The JWT includes sub, iat, and exp claims at minimum.
  • Mutating routes (POST, PUT, DELETE) are protected and require a valid JWT via Authorization: Bearer <token>.
  • Requests with missing, malformed, or expired tokens are rejected with a 401 Unauthorized.
  • The implementation is covered by integration tests simulating real client flows.
  • The solution does not break unauthenticated GET routes.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestjavaPull requests that update Java code

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions