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:
- Separation of Concerns: Authorization logic is separated from application code
- Policy as Code: Authorization policies are version-controlled and testable
- Flexibility: OPA policies can be updated without redeploying the application
- Centralized Management: Consistent authorization across multiple services
- 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.