JWT Validation with OPA in Java

Introduction to JWT and OPA

JSON Web Tokens (JWT) are widely used for authentication and authorization in modern applications. Open Policy Agent (OPA) is a general-purpose policy engine that can be used to enforce authorization policies, including JWT validation and claims verification.

Architecture Overview

+-------------+     +-------------+     +-------------+
|   Client    | --> |  Java App   | --> |    OPA      |
|             |     |             |     |             |
| (JWT Token) |     | (Validation)|     | (Policy     |
+-------------+     +-------------+     |  Engine)    |
+-------------+

Dependencies Setup

Maven Dependencies

<properties>
<opa.client.version>1.0.0</opa.client.version>
<jwt.version>0.11.5</jwt.version>
<jackson.version>2.15.2</jackson.version>
<spring.boot.version>3.1.0</spring.boot.version>
</properties>
<dependencies>
<!-- OPA Client -->
<dependency>
<groupId>com.openpolicyagent</groupId>
<artifactId>opa-client</artifactId>
<version>${opa.client.version}</version>
</dependency>
<!-- JWT Library -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${jwt.version}</version>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.2.1</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring.boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Core JWT Validation Components

JWT Token Model

package com.example.jwtopa.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
public class JwtToken {
private String token;
private JwtHeader header;
private JwtPayload payload;
// Constructors
public JwtToken() {}
public JwtToken(String token) {
this.token = token;
}
// Getters and Setters
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public JwtHeader getHeader() { return header; }
public void setHeader(JwtHeader header) { this.header = header; }
public JwtPayload getPayload() { return payload; }
public void setPayload(JwtPayload payload) { this.payload = payload; }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class JwtHeader {
private String alg;
private String typ;
private String kid;
// Getters and Setters
public String getAlg() { return alg; }
public void setAlg(String alg) { this.alg = alg; }
public String getTyp() { return typ; }
public void setTyp(String typ) { this.typ = typ; }
public String getKid() { return kid; }
public void setKid(String kid) { this.kid = kid; }
}
@JsonIgnoreProperties(ignoreUnknown = true)
class JwtPayload {
private String iss;
private String sub;
private List<String> aud;
private Instant exp;
private Instant nbf;
private Instant iat;
private String jti;
private Map<String, Object> claims;
// Getters and Setters
public String getIss() { return iss; }
public void setIss(String iss) { this.iss = iss; }
public String getSub() { return sub; }
public void setSub(String sub) { this.sub = sub; }
public List<String> getAud() { return aud; }
public void setAud(List<String> aud) { this.aud = aud; }
public Instant getExp() { return exp; }
public void setExp(Instant exp) { this.exp = exp; }
public Instant getNbf() { return nbf; }
public void setNbf(Instant nbf) { this.nbf = nbf; }
public Instant getIat() { return iat; }
public void setIat(Instant iat) { this.iat = iat; }
public String getJti() { return jti; }
public void setJti(String jti) { this.jti = jti; }
public Map<String, Object> getClaims() { return claims; }
public void setClaims(Map<String, Object> claims) { this.claims = claims; }
// Utility methods
public boolean isExpired() {
return exp != null && Instant.now().isAfter(exp);
}
public boolean isNotBeforeValid() {
return nbf == null || Instant.now().isAfter(nbf);
}
public boolean hasClaim(String claim) {
return claims != null && claims.containsKey(claim);
}
public Object getClaim(String claim) {
return claims != null ? claims.get(claim) : null;
}
@SuppressWarnings("unchecked")
public List<String> getRoles() {
Object roles = getClaim("roles");
if (roles instanceof List) {
return (List<String>) roles;
}
return List.of();
}
@SuppressWarnings("unchecked")
public List<String> getScopes() {
Object scopes = getClaim("scope");
if (scopes instanceof String) {
return List.of(((String) scopes).split(" "));
} else if (scopes instanceof List) {
return (List<String>) scopes;
}
return List.of();
}
}

OPA Client Implementation

package com.example.jwtopa.client;
import com.example.jwtopa.model.JwtToken;
import com.example.jwtopa.model.OpaResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class OpaClient {
private static final Logger logger = LoggerFactory.getLogger(OpaClient.class);
private final String opaBaseUrl;
private final ObjectMapper objectMapper;
private final CloseableHttpClient httpClient;
public OpaClient(@Value("${opa.base.url:http://localhost:8181}") String opaBaseUrl) {
this.opaBaseUrl = opaBaseUrl;
this.objectMapper = new ObjectMapper();
this.httpClient = HttpClients.createDefault();
}
public OpaResponse validateJwt(String jwtToken, String path, Map<String, Object> input) {
String opaUrl = opaBaseUrl + "/v1/" + path;
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("input", input);
try {
String requestJson = objectMapper.writeValueAsString(requestBody);
logger.debug("Sending request to OPA: {}", requestJson);
HttpPost httpPost = new HttpPost(opaUrl);
httpPost.setHeader("Content-Type", "application/json");
httpPost.setEntity(new StringEntity(requestJson));
try (CloseableHttpResponse response = httpClient.execute(httpPost)) {
String responseBody = EntityUtils.toString(response.getEntity());
logger.debug("Received response from OPA: {}", responseBody);
if (response.getCode() == 200) {
return objectMapper.readValue(responseBody, OpaResponse.class);
} else {
logger.error("OPA request failed with status: {}", response.getCode());
throw new RuntimeException("OPA request failed with status: " + response.getCode());
}
}
} catch (JsonProcessingException e) {
logger.error("Error serializing OPA request", e);
throw new RuntimeException("Error serializing OPA request", e);
} catch (IOException e) {
logger.error("Error communicating with OPA", e);
throw new RuntimeException("Error communicating with OPA", e);
}
}
public OpaResponse validateJwtWithContext(String jwtToken, String method, String path, 
Map<String, Object> resourceAttributes) {
Map<String, Object> input = new HashMap<>();
input.put("jwt", jwtToken);
input.put("method", method);
input.put("path", path);
input.put("resource", resourceAttributes);
return validateJwt(jwtToken, "authz/jwt/validate", input);
}
public OpaResponse validateJwtBasic(String jwtToken) {
Map<String, Object> input = new HashMap<>();
input.put("jwt", jwtToken);
return validateJwt(jwtToken, "authz/jwt/basic", input);
}
public void close() {
try {
httpClient.close();
} catch (IOException e) {
logger.error("Error closing HTTP client", e);
}
}
}

OPA Response Model

package com.example.jwtopa.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Map;
@JsonIgnoreProperties(ignoreUnknown = true)
public class OpaResponse {
private boolean result;
private Map<String, Object> decision;
private String message;
// Constructors
public OpaResponse() {}
public OpaResponse(boolean result) {
this.result = result;
}
// Getters and Setters
public boolean isResult() { return result; }
public void setResult(boolean result) { this.result = result; }
public Map<String, Object> getDecision() { return decision; }
public void setDecision(Map<String, Object> decision) { this.decision = decision; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
// Utility methods
public boolean isAllowed() {
return result && decision != null && Boolean.TRUE.equals(decision.get("allowed"));
}
public String getReason() {
return decision != null ? (String) decision.get("reason") : null;
}
@SuppressWarnings("unchecked")
public Map<String, Object> getClaims() {
return decision != null ? (Map<String, Object>) decision.get("claims") : null;
}
public Object getClaim(String claimName) {
Map<String, Object> claims = getClaims();
return claims != null ? claims.get(claimName) : null;
}
}

JWT Parser and Validator

JWT Parser Service

package com.example.jwtopa.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.example.jwtopa.model.JwtPayload;
import com.example.jwtopa.model.JwtToken;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Base64;
import java.util.List;
import java.util.Map;
@Service
public class JwtParserService {
private static final Logger logger = LoggerFactory.getLogger(JwtParserService.class);
private final ObjectMapper objectMapper;
public JwtParserService() {
this.objectMapper = new ObjectMapper();
}
public JwtToken parseToken(String token) {
try {
DecodedJWT decodedJWT = JWT.decode(token);
JwtToken jwtToken = new JwtToken(token);
// Parse header
com.example.jwtopa.model.JwtHeader header = new com.example.jwtopa.model.JwtHeader();
header.setAlg(decodedJWT.getAlgorithm());
header.setTyp(decodedJWT.getType());
header.setKid(decodedJWT.getKeyId());
jwtToken.setHeader(header);
// Parse payload
String payloadJson = new String(Base64.getUrlDecoder().decode(decodedJWT.getPayload()));
Map<String, Object> claims = objectMapper.readValue(payloadJson, 
new TypeReference<Map<String, Object>>() {});
JwtPayload payload = new JwtPayload();
payload.setIss((String) claims.get("iss"));
payload.setSub((String) claims.get("sub"));
payload.setAud(parseAudience(claims.get("aud")));
payload.setExp(parseInstant(claims.get("exp")));
payload.setNbf(parseInstant(claims.get("nbf")));
payload.setIat(parseInstant(claims.get("iat")));
payload.setJti((String) claims.get("jti"));
payload.setClaims(claims);
jwtToken.setPayload(payload);
return jwtToken;
} catch (JWTDecodeException e) {
logger.error("Failed to decode JWT token", e);
throw new IllegalArgumentException("Invalid JWT token", e);
} catch (Exception e) {
logger.error("Error parsing JWT token", e);
throw new RuntimeException("Error parsing JWT token", e);
}
}
@SuppressWarnings("unchecked")
private List<String> parseAudience(Object aud) {
if (aud instanceof String) {
return List.of((String) aud);
} else if (aud instanceof List) {
return (List<String>) aud;
}
return List.of();
}
private Instant parseInstant(Object timestamp) {
if (timestamp instanceof Integer) {
return Instant.ofEpochSecond((Integer) timestamp);
} else if (timestamp instanceof Long) {
return Instant.ofEpochSecond((Long) timestamp);
}
return null;
}
public boolean isTokenExpired(JwtToken jwtToken) {
return jwtToken.getPayload() != null && jwtToken.getPayload().isExpired();
}
public boolean isTokenActive(JwtToken jwtToken) {
JwtPayload payload = jwtToken.getPayload();
if (payload == null) return false;
return !payload.isExpired() && payload.isNotBeforeValid();
}
public void validateTokenStructure(String token) {
if (token == null || token.trim().isEmpty()) {
throw new IllegalArgumentException("Token cannot be null or empty");
}
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid JWT token structure");
}
}
}

OPA Policy Integration

Policy Evaluation Service

package com.example.jwtopa.service;
import com.example.jwtopa.client.OpaClient;
import com.example.jwtopa.model.JwtToken;
import com.example.jwtopa.model.OpaResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class OpaPolicyService {
private static final Logger logger = LoggerFactory.getLogger(OpaPolicyService.class);
private final OpaClient opaClient;
private final JwtParserService jwtParserService;
public OpaPolicyService(OpaClient opaClient, JwtParserService jwtParserService) {
this.opaClient = opaClient;
this.jwtParserService = jwtParserService;
}
public AuthorizationResult validateToken(String jwtToken) {
try {
// Basic structural validation
jwtParserService.validateTokenStructure(jwtToken);
// Parse token to extract basic information
JwtToken parsedToken = jwtParserService.parseToken(jwtToken);
// Check if token is expired locally (fast fail)
if (jwtParserService.isTokenExpired(parsedToken)) {
return AuthorizationResult.denied("Token has expired");
}
// Validate with OPA
OpaResponse opaResponse = opaClient.validateJwtBasic(jwtToken);
if (opaResponse.isAllowed()) {
return AuthorizationResult.allowed(opaResponse.getClaims());
} else {
return AuthorizationResult.denied(opaResponse.getReason());
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid token structure: {}", e.getMessage());
return AuthorizationResult.denied("Invalid token structure: " + e.getMessage());
} catch (Exception e) {
logger.error("Error validating token with OPA", e);
return AuthorizationResult.denied("Internal validation error");
}
}
public AuthorizationResult authorizeRequest(String jwtToken, String method, 
String path, Map<String, Object> resourceAttributes) {
try {
// Basic token validation first
jwtParserService.validateTokenStructure(jwtToken);
// Validate with OPA including request context
OpaResponse opaResponse = opaClient.validateJwtWithContext(
jwtToken, method, path, resourceAttributes);
if (opaResponse.isAllowed()) {
return AuthorizationResult.allowed(opaResponse.getClaims());
} else {
return AuthorizationResult.denied(opaResponse.getReason());
}
} catch (IllegalArgumentException e) {
logger.warn("Invalid token structure: {}", e.getMessage());
return AuthorizationResult.denied("Invalid token structure: " + e.getMessage());
} catch (Exception e) {
logger.error("Error authorizing request with OPA", e);
return AuthorizationResult.denied("Internal authorization error");
}
}
public AuthorizationResult authorizeWithCustomPolicy(String jwtToken, String policyPath, 
Map<String, Object> additionalInput) {
try {
jwtParserService.validateTokenStructure(jwtToken);
Map<String, Object> input = new HashMap<>();
input.put("jwt", jwtToken);
if (additionalInput != null) {
input.putAll(additionalInput);
}
OpaResponse opaResponse = opaClient.validateJwt(jwtToken, policyPath, input);
if (opaResponse.isAllowed()) {
return AuthorizationResult.allowed(opaResponse.getClaims());
} else {
return AuthorizationResult.denied(opaResponse.getReason());
}
} catch (Exception e) {
logger.error("Error executing custom policy: {}", policyPath, e);
return AuthorizationResult.denied("Policy evaluation failed");
}
}
public static class AuthorizationResult {
private final boolean allowed;
private final String reason;
private final Map<String, Object> claims;
private AuthorizationResult(boolean allowed, String reason, Map<String, Object> claims) {
this.allowed = allowed;
this.reason = reason;
this.claims = claims;
}
public static AuthorizationResult allowed(Map<String, Object> claims) {
return new AuthorizationResult(true, "Access granted", claims);
}
public static AuthorizationResult denied(String reason) {
return new AuthorizationResult(false, reason, null);
}
// Getters
public boolean isAllowed() { return allowed; }
public String getReason() { return reason; }
public Map<String, Object> getClaims() { return claims; }
@SuppressWarnings("unchecked")
public List<String> getRoles() {
return claims != null && claims.containsKey("roles") 
? (List<String>) claims.get("roles") 
: List.of();
}
public String getSubject() {
return claims != null ? (String) claims.get("sub") : null;
}
}
}

Spring Security Integration

JWT Authentication Filter

package com.example.jwtopa.security;
import com.example.jwtopa.service.OpaPolicyService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
public class JwtOpaAuthenticationFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(JwtOpaAuthenticationFilter.class);
private final OpaPolicyService opaPolicyService;
private static final String AUTH_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
public JwtOpaAuthenticationFilter(OpaPolicyService opaPolicyService) {
this.opaPolicyService = opaPolicyService;
}
@Override
protected void doFilterInternal(HttpServletRequest request, 
HttpServletResponse response, 
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(AUTH_HEADER);
if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) {
filterChain.doFilter(request, response);
return;
}
String jwtToken = authHeader.substring(BEARER_PREFIX.length());
try {
// Validate token with OPA
OpaPolicyService.AuthorizationResult result = 
opaPolicyService.authorizeRequest(
jwtToken, 
request.getMethod(), 
request.getRequestURI(),
extractResourceAttributes(request)
);
if (result.isAllowed()) {
// Create authentication object
List<SimpleGrantedAuthority> authorities = result.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
UsernamePasswordAuthenticationToken authentication = 
new UsernamePasswordAuthenticationToken(
result.getSubject(), 
null, 
authorities
);
// Set authentication in security context
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Authenticated user: {} with roles: {}", 
result.getSubject(), result.getRoles());
} else {
logger.warn("Access denied for token. Reason: {}", result.getReason());
SecurityContextHolder.clearContext();
}
} catch (Exception e) {
logger.error("Error processing JWT token", e);
SecurityContextHolder.clearContext();
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
return;
}
filterChain.doFilter(request, response);
}
private java.util.Map<String, Object> extractResourceAttributes(HttpServletRequest request) {
java.util.Map<String, Object> attributes = new java.util.HashMap<>();
attributes.put("ip", getClientIpAddress(request));
attributes.put("userAgent", request.getHeader("User-Agent"));
attributes.put("queryParams", request.getParameterMap());
return attributes;
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

Security Configuration

package com.example.jwtopa.config;
import com.example.jwtopa.security.JwtOpaAuthenticationFilter;
import com.example.jwtopa.service.OpaPolicyService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final OpaPolicyService opaPolicyService;
public SecurityConfig(OpaPolicyService opaPolicyService) {
this.opaPolicyService = opaPolicyService;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> 
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
)
.addFilterBefore(
new JwtOpaAuthenticationFilter(opaPolicyService),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}

REST Controller Examples

Authentication Controller

package com.example.jwtopa.controller;
import com.example.jwtopa.model.JwtToken;
import com.example.jwtopa.service.JwtParserService;
import com.example.jwtopa.service.OpaPolicyService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private static final Logger logger = LoggerFactory.getLogger(AuthController.class);
private final OpaPolicyService opaPolicyService;
private final JwtParserService jwtParserService;
public AuthController(OpaPolicyService opaPolicyService, JwtParserService jwtParserService) {
this.opaPolicyService = opaPolicyService;
this.jwtParserService = jwtParserService;
}
@PostMapping("/validate")
public ResponseEntity<Map<String, Object>> validateToken(@RequestBody Map<String, String> request) {
String token = request.get("token");
if (token == null || token.trim().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"valid", false,
"message", "Token is required"
));
}
OpaPolicyService.AuthorizationResult result = opaPolicyService.validateToken(token);
return ResponseEntity.ok(Map.of(
"valid", result.isAllowed(),
"message", result.getReason(),
"subject", result.getSubject(),
"roles", result.getRoles()
));
}
@PostMapping("/parse")
public ResponseEntity<?> parseToken(@RequestBody Map<String, String> request) {
String token = request.get("token");
try {
JwtToken jwtToken = jwtParserService.parseToken(token);
return ResponseEntity.ok(jwtToken);
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Failed to parse token",
"message", e.getMessage()
));
}
}
@GetMapping("/userinfo")
public ResponseEntity<Map<String, Object>> getUserInfo(@RequestHeader("Authorization") String authHeader) {
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Missing or invalid Authorization header"
));
}
String token = authHeader.substring(7);
OpaPolicyService.AuthorizationResult result = opaPolicyService.validateToken(token);
if (!result.isAllowed()) {
return ResponseEntity.status(401).body(Map.of(
"error", "Invalid token",
"message", result.getReason()
));
}
return ResponseEntity.ok(Map.of(
"subject", result.getSubject(),
"roles", result.getRoles(),
"claims", result.getClaims()
));
}
}

Protected Resource Controller

package com.example.jwtopa.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class ResourceController {
private static final Logger logger = LoggerFactory.getLogger(ResourceController.class);
@GetMapping("/public/hello")
public ResponseEntity<Map<String, String>> publicHello() {
return ResponseEntity.ok(Map.of("message", "Hello from public endpoint!"));
}
@GetMapping("/user/profile")
public ResponseEntity<Map<String, Object>> userProfile(Authentication authentication) {
String username = authentication.getName();
logger.info("Accessing user profile for: {}", username);
return ResponseEntity.ok(Map.of(
"message", "Hello " + username + "!",
"profile", Map.of(
"username", username,
"role", "USER"
)
));
}
@GetMapping("/admin/dashboard")
public ResponseEntity<Map<String, Object>> adminDashboard(Authentication authentication) {
String username = authentication.getName();
logger.info("Accessing admin dashboard for: {}", username);
return ResponseEntity.ok(Map.of(
"message", "Welcome to admin dashboard, " + username + "!",
"stats", Map.of(
"users", 1500,
"revenue", 50000,
"activeSessions", 42
)
));
}
@PostMapping("/user/data")
public ResponseEntity<Map<String, Object>> createData(
@RequestBody Map<String, Object> data,
Authentication authentication) {
String username = authentication.getName();
logger.info("User {} creating data: {}", username, data);
return ResponseEntity.ok(Map.of(
"message", "Data created successfully",
"createdBy", username,
"data", data
));
}
}

OPA Policy Examples

Basic JWT Validation Policy

package authz.jwt.basic
import future.keywords.in
# Default deny
default allowed := false
# JWT validation result
allowed := {
"allowed": is_token_valid,
"reason": reason,
"claims": payload
} {
# Parse and validate JWT
io.jwt.verify_rs256(input.jwt)
[header, payload, signature] := io.jwt.decode(input.jwt)
# Check expiration
now := time.now_ns() / 1000000000
exp := payload.exp
exp > now
# Check not before
nbf := payload.nbf
nbf <= now
is_token_valid := true
reason := "Token is valid"
}
# Token expired
allowed := {
"allowed": false,
"reason": "Token has expired"
} {
[header, payload, signature] := io.jwt.decode(input.jwt)
now := time.now_ns() / 1000000000
exp := payload.exp
exp <= now
}
# Token not yet active
allowed := {
"allowed": false,
"reason": "Token not yet active"
} {
[header, payload, signature] := io.jwt.decode(input.jwt)
now := time.now_ns() / 1000000000
nbf := payload.nbf
nbf > now
}
# Invalid token
allowed := {
"allowed": false,
"reason": "Invalid token signature or format"
} {
not io.jwt.verify_rs256(input.jwt)
}

Advanced Authorization Policy

package authz.jwt.validate
import future.keywords.in
# Default deny
default allowed := false
# Main authorization logic
allowed := {
"allowed": decision,
"reason": reason,
"claims": payload
} {
# Parse JWT
[header, payload, signature] := io.jwt.decode(input.jwt)
# Validate token
is_token_valid := check_token_validity(header, payload, signature)
# Extract request context
method := input.method
path := input.path
resource := input.resource
# Make authorization decision
decision := is_token_valid and check_permissions(method, path, payload, resource)
reason := decision then "Access granted" else "Access denied"
}
# Token validation checks
check_token_validity(header, payload, signature) := valid {
# Verify signature
io.jwt.verify_rs256(input.jwt)
# Check expiration
now := time.now_ns() / 1000000000
exp := payload.exp
exp > now
# Check issuer
payload.iss == "https://auth.mycompany.com"
# Check audience
"api.mycompany.com" in payload.aud
valid := true
}
# Permission checks
check_permissions(method, path, payload, resource) := allowed {
# Extract user roles
roles := payload.roles
# Check based on path patterns and roles
allowed := any_rule_allows(method, path, roles, resource)
}
# Rule for admin endpoints
any_rule_allows(method, path, roles, resource) {
startswith(path, "/api/admin")
"admin" in roles
}
# Rule for user endpoints
any_rule_allows(method, path, roles, resource) {
startswith(path, "/api/user")
"user" in roles
}
# Rule for public endpoints
any_rule_allows(method, path, roles, resource) {
startswith(path, "/api/public")
}
# Rule for specific resource ownership
any_rule_allows(method, path, roles, resource) {
startswith(path, "/api/data/")
method == "GET"
resource.ownerId == payload.sub
}
# Get user roles from token
get_user_roles(payload) := roles {
roles := payload.roles
} else := [] {
true
}

Testing

Unit Tests

package com.example.jwtopa.service;
import com.example.jwtopa.client.OpaClient;
import com.example.jwtopa.model.OpaResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OpaPolicyServiceTest {
@Mock
private OpaClient opaClient;
@Mock
private JwtParserService jwtParserService;
private OpaPolicyService opaPolicyService;
@BeforeEach
void setUp() {
opaPolicyService = new OpaPolicyService(opaClient, jwtParserService);
}
@Test
void validateToken_ValidToken_ReturnsAllowed() {
// Given
String validToken = "valid.jwt.token";
OpaResponse opaResponse = new OpaResponse(true);
opaResponse.setDecision(Map.of("allowed", true, "reason", "Token is valid"));
when(opaClient.validateJwtBasic(validToken)).thenReturn(opaResponse);
// When
OpaPolicyService.AuthorizationResult result = opaPolicyService.validateToken(validToken);
// Then
assertTrue(result.isAllowed());
assertEquals("Token is valid", result.getReason());
}
@Test
void validateToken_InvalidToken_ReturnsDenied() {
// Given
String invalidToken = "invalid.jwt.token";
OpaResponse opaResponse = new OpaResponse(true);
opaResponse.setDecision(Map.of("allowed", false, "reason", "Invalid signature"));
when(opaClient.validateJwtBasic(invalidToken)).thenReturn(opaResponse);
// When
OpaPolicyService.AuthorizationResult result = opaPolicyService.validateToken(invalidToken);
// Then
assertFalse(result.isAllowed());
assertEquals("Invalid signature", result.getReason());
}
@Test
void authorizeRequest_AdminAccess_ReturnsAllowed() {
// Given
String token = "admin.jwt.token";
String method = "GET";
String path = "/api/admin/dashboard";
Map<String, Object> resourceAttributes = new HashMap<>();
OpaResponse opaResponse = new OpaResponse(true);
opaResponse.setDecision(Map.of(
"allowed", true,
"reason", "Access granted",
"claims", Map.of("sub", "admin-user", "roles", java.util.List.of("admin"))
));
when(opaClient.validateJwtWithContext(eq(token), eq(method), eq(path), anyMap()))
.thenReturn(opaResponse);
// When
OpaPolicyService.AuthorizationResult result = 
opaPolicyService.authorizeRequest(token, method, path, resourceAttributes);
// Then
assertTrue(result.isAllowed());
assertEquals("Access granted", result.getReason());
assertEquals("admin-user", result.getSubject());
assertTrue(result.getRoles().contains("admin"));
}
}

Integration Test

package com.example.jwtopa.integration;
import com.example.jwtopa.controller.AuthController;
import com.example.jwtopa.service.OpaPolicyService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(AuthController.class)
class AuthControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OpaPolicyService opaPolicyService;
@MockBean
private JwtParserService jwtParserService;
@Test
void validateToken_ValidToken_ReturnsSuccess() throws Exception {
// Given
String validToken = "valid.jwt.token";
OpaPolicyService.AuthorizationResult result = 
OpaPolicyService.AuthorizationResult.allowed(
Map.of("sub", "test-user", "roles", java.util.List.of("user"))
);
when(opaPolicyService.validateToken(anyString())).thenReturn(result);
// When & Then
mockMvc.perform(post("/api/auth/validate")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"token\": \"" + validToken + "\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.valid").value(true))
.andExpect(jsonPath("$.subject").value("test-user"))
.andExpect(jsonPath("$.roles[0]").value("user"));
}
}

Configuration

Application Properties

# application.yml
server:
port: 8080
opa:
base:
url: http://localhost:8181
logging:
level:
com.example.jwtopa: DEBUG
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.mycompany.com
app:
security:
jwt:
issuer: https://auth.mycompany.com
audience: api.mycompany.com
require-https: true

Error Handling

Global Exception Handler

package com.example.jwtopa.exception;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException e) {
logger.warn("Access denied: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of(
"error", "access_denied",
"message", "You don't have permission to access this resource",
"details", e.getMessage()
));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException e) {
logger.warn("Invalid request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of(
"error", "invalid_request",
"message", e.getMessage()
));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGenericException(Exception e) {
logger.error("Internal server error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
"error", "internal_error",
"message", "An internal server error occurred"
));
}
}

Best Practices

1. Token Validation Best Practices

package com.example.jwtopa.bestpractices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.TimeUnit;
public class TokenValidationBestPractices {
private static final Logger logger = LoggerFactory.getLogger(TokenValidationBestPractices.class);
// Cache OPA decisions for valid tokens to reduce load
public static class TokenCache {
private final java.util.Map<String, CachedDecision> cache = new java.util.concurrent.ConcurrentHashMap<>();
private final long ttlMillis;
public TokenCache(long ttl, TimeUnit unit) {
this.ttlMillis = unit.toMillis(ttl);
}
public void put(String token, boolean allowed, String reason) {
cache.put(token, new CachedDecision(allowed, reason, System.currentTimeMillis()));
}
public CachedDecision get(String token) {
CachedDecision decision = cache.get(token);
if (decision != null && System.currentTimeMillis() - decision.timestamp > ttlMillis) {
cache.remove(token);
return null;
}
return decision;
}
public void cleanup() {
long now = System.currentTimeMillis();
cache.entrySet().removeIf(entry -> 
now - entry.getValue().timestamp > ttlMillis);
}
private static class CachedDecision {
final boolean allowed;
final String reason;
final long timestamp;
CachedDecision(boolean allowed, String reason, long timestamp) {
this.allowed = allowed;
this.reason = reason;
this.timestamp = timestamp;
}
}
}
// Rate limiting for token validation
public static class RateLimiter {
private final java.util.Map<String, RateLimit> limits = new java.util.concurrent.ConcurrentHashMap<>();
private final int maxRequests;
private final long windowMillis;
public RateLimiter(int maxRequests, long window, TimeUnit unit) {
this.maxRequests = maxRequests;
this.windowMillis = unit.toMillis(window);
}
public boolean allow(String key) {
long now = System.currentTimeMillis();
RateLimit limit = limits.computeIfAbsent(key, k -> new RateLimit());
synchronized (limit) {
if (now - limit.windowStart > windowMillis) {
limit.count = 1;
limit.windowStart = now;
return true;
} else if (limit.count < maxRequests) {
limit.count++;
return true;
} else {
return false;
}
}
}
private static class RateLimit {
int count = 0;
long windowStart = System.currentTimeMillis();
}
}
}

Conclusion

JWT validation with OPA in Java provides a robust, flexible, and maintainable approach to authentication and authorization. Key benefits include:

  1. Separation of Concerns: Authorization logic is separated from application code
  2. Policy as Code: Authorization policies are version-controlled and testable
  3. Flexibility: OPA policies can be updated without redeploying the application
  4. Centralized Management: Consistent authorization across multiple services
  5. Auditability: Clear, readable policies that can be reviewed and audited

This approach enables fine-grained access control while maintaining security best practices and providing excellent developer experience.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper