Open
Description
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
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 validclient_id
/client_secret
credentials to return a JWT. - The JWT includes
sub
,iat
, andexp
claims at minimum. - Mutating routes (
POST
,PUT
,DELETE
) are protected and require a valid JWT viaAuthorization: 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.