Building an OAuth2 Proxy in Java: Complete Implementation Guide

An OAuth2 Proxy acts as an intermediary that handles OAuth2 authentication and forwards requests to backend services. It's commonly used to protect applications that don't have built-in authentication.


Architecture Overview

User → OAuth2 Proxy → [OAuth2 Provider] → OAuth2 Proxy → Backend Application
(Authentication)                   (Forward with headers)

Key Features of OAuth2 Proxy

  • OAuth2 authentication flow handling
  • Session management with cookies
  • JWT token validation and passing
  • Request forwarding with user identity headers
  • Rate limiting and security headers
  • SSL termination

Implementation Approaches

1. Spring Cloud Gateway with OAuth2

2. Custom Servlet Filter Approach

3. Spring Security OAuth2 Resource Server

Let's implement all three approaches.


Approach 1: Spring Cloud Gateway OAuth2 Proxy

1. Project Dependencies (pom.xml)

<dependencies>
<!-- Spring Cloud Gateway -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-jose</artifactId>
</dependency>
<!-- Redis for session storage -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<!-- Configuration Processor -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2022.0.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

2. Configuration (application.yml)

server:
port: 8080
spring:
application:
name: oauth2-proxy
security:
oauth2:
client:
registration:
google:
client-id: ${OAUTH2_CLIENT_ID:google-client-id}
client-secret: ${OAUTH2_CLIENT_SECRET:google-client-secret}
scope:
- openid
- profile
- email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
github:
client-id: ${GITHUB_CLIENT_ID:github-client-id}
client-secret: ${GITHUB_CLIENT_SECRET:github-client-secret}
scope:
- user:email
- read:user
cloud:
gateway:
routes:
- id: protected-app
uri: http://localhost:8081
predicates:
- Path=/app/**
filters:
- TokenRelay=
- name: RequestHeader
args:
name: X-User-Id
value: "#{@userService.getUserId(authentication)}"
- name: RequestHeader
args:
name: X-User-Email
value: "#{@userService.getUserEmail(authentication)}"
- StripPrefix=1
- id: public-app
uri: http://localhost:8082
predicates:
- Path=/public/**
filters:
- StripPrefix=1
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials
redis:
host: localhost
port: 6379
logging:
level:
org.springframework.cloud.gateway: DEBUG
org.springframework.security: DEBUG

3. Security Configuration

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/public/**", "/login", "/webjars/**", "/error").permitAll()
.anyExchange().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.authenticationSuccessHandler(authenticationSuccessHandler())
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessHandler(logoutSuccessHandler())
)
.csrf(ServerHttpSecurity.CsrfSpec::disable);
return http.build();
}
@Bean
public ServerAuthenticationSuccessHandler authenticationSuccessHandler() {
return new RedirectServerAuthenticationSuccessHandler("/app/");
}
@Bean
public ServerLogoutSuccessHandler logoutSuccessHandler() {
return new RedirectServerLogoutSuccessHandler();
}
@Bean
public ServerOAuth2AuthorizedClientRepository authorizedClientRepository() {
return new WebSessionServerOAuth2AuthorizedClientRepository();
}
}

4. User Service for Header Injection

@Service
public class UserService {
public String getUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return "anonymous";
}
Object principal = authentication.getPrincipal();
if (principal instanceof OidcUser) {
return ((OidcUser) principal).getSubject();
} else if (principal instanceof OAuth2User) {
return ((OAuth2User) principal).getName();
} else if (principal instanceof String) {
return (String) principal;
}
return "unknown";
}
public String getUserEmail(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return "[email protected]";
}
Object principal = authentication.getPrincipal();
if (principal instanceof OidcUser) {
return ((OidcUser) principal).getEmail();
} else if (principal instanceof OAuth2User) {
return ((OAuth2User) principal).getAttribute("email");
}
return "[email protected]";
}
public Map<String, Object> getUserClaims(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return Map.of();
}
Object principal = authentication.getPrincipal();
if (principal instanceof OidcUser) {
return ((OidcUser) principal).getClaims();
} else if (principal instanceof OAuth2User) {
return ((OAuth2User) principal).getAttributes();
}
return Map.of();
}
}

5. Custom Gateway Filter Factory

@Component
public class UserInfoGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
private final UserService userService;
public UserInfoGatewayFilterFactory(UserService userService) {
super(Object.class);
this.userService = userService;
}
@Override
public GatewayFilter apply(Object config) {
return (exchange, chain) -> {
return exchange.getPrincipal()
.cast(Authentication.class)
.map(authentication -> {
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Id", userService.getUserId(authentication))
.header("X-User-Email", userService.getUserEmail(authentication))
.header("X-User-Name", getUserName(authentication))
.build();
return exchange.mutate().request(request).build();
})
.defaultIfEmpty(exchange)
.flatMap(chain::filter);
};
}
private String getUserName(Authentication authentication) {
Object principal = authentication.getPrincipal();
if (principal instanceof OidcUser) {
return ((OidcUser) principal).getFullName();
} else if (principal instanceof OAuth2User) {
return ((OAuth2User) principal).getAttribute("name");
}
return "unknown";
}
}

Approach 2: Custom Servlet Filter OAuth2 Proxy

1. Dependencies for Servlet Approach

<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>

2. OAuth2 Proxy Filter

@Component
@Order(1)
public class OAuth2ProxyFilter implements Filter {
private final OAuth2AuthorizedClientService clientService;
private final UserService userService;
private final WebClient webClient;
public OAuth2ProxyFilter(OAuth2AuthorizedClientService clientService, 
UserService userService) {
this.clientService = clientService;
this.userService = userService;
this.webClient = WebClient.builder().build();
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, 
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = httpRequest.getRequestURI();
// Skip authentication for public paths
if (path.startsWith("/public/") || path.equals("/login") || path.equals("/oauth2/")) {
chain.doFilter(request, response);
return;
}
// Check if user is authenticated
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
httpResponse.sendRedirect("/login");
return;
}
// Proxy request to backend service
if (path.startsWith("/app/")) {
proxyRequest(httpRequest, httpResponse, authentication);
} else {
chain.doFilter(request, response);
}
}
private void proxyRequest(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
try {
String backendUrl = "http://localhost:8081" + request.getRequestURI().replace("/app", "");
// Get access token for backend service
String accessToken = getAccessToken(authentication);
// Forward request to backend
byte[] responseBody = webClient.method(HttpMethod.valueOf(request.getMethod()))
.uri(backendUrl)
.headers(headers -> copyHeaders(request, headers, accessToken))
.body(BodyInserters.fromInputStream(() -> {
try {
return request.getInputStream();
} catch (IOException e) {
throw new RuntimeException(e);
}
}))
.retrieve()
.bodyToMono(byte[].class)
.block();
// Copy backend response to client
if (responseBody != null) {
response.getOutputStream().write(responseBody);
}
} catch (Exception e) {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.getWriter().write("Proxy error: " + e.getMessage());
}
}
private String getAccessToken(Authentication authentication) {
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(
oauthToken.getAuthorizedClientRegistrationId(), 
oauthToken.getName()
);
return client != null ? client.getAccessToken().getTokenValue() : null;
}
private void copyHeaders(HttpServletRequest source, HttpHeaders target, String accessToken) {
Collections.list(source.getHeaderNames()).forEach(headerName -> {
if (!isSensitiveHeader(headerName)) {
Collections.list(source.getHeaders(headerName))
.forEach(headerValue -> target.add(headerName, headerValue));
}
});
// Add user identity headers
target.add("X-User-Id", userService.getUserIdFromAuthentication(SecurityContextHolder.getContext().getAuthentication()));
target.add("X-User-Email", userService.getUserEmailFromAuthentication(SecurityContextHolder.getContext().getAuthentication()));
// Add bearer token for backend
if (accessToken != null) {
target.setBearerAuth(accessToken);
}
}
private boolean isSensitiveHeader(String headerName) {
return headerName.equalsIgnoreCase("authorization") || 
headerName.equalsIgnoreCase("cookie") ||
headerName.equalsIgnoreCase("host");
}
}

3. Proxy Configuration

@Configuration
@EnableWebSecurity
public class ProxySecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**", "/login", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/app/")
.failureUrl("/login?error=true")
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout=true")
.permitAll()
)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public OAuth2AuthorizedClientService authorizedClientService(
ClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}
}

4. Login Controller

@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
@GetMapping("/")
public String home() {
return "redirect:/app/";
}
}

5. Login Page (src/main/resources/templates/login.html)

<!DOCTYPE html>
<html>
<head>
<title>OAuth2 Proxy - Login</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.login-buttons { display: flex; gap: 10px; margin-top: 20px; }
.login-btn { padding: 10px 20px; text-decoration: none; border-radius: 4px; }
.google { background: #4285f4; color: white; }
.github { background: #333; color: white; }
</style>
</head>
<body>
<h1>Welcome to OAuth2 Proxy</h1>
<p>Please log in with one of the following providers:</p>
<div class="login-buttons">
<a href="/oauth2/authorization/google" class="login-btn google">Login with Google</a>
<a href="/oauth2/authorization/github" class="login-btn github">Login with GitHub</a>
</div>
</body>
</html>

Approach 3: Spring Security Resource Server as Proxy

1. Resource Server Configuration

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
private Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
// Extract roles from JWT
Collection<String> roles = jwt.getClaimAsStringList("roles");
if (roles == null) {
roles = List.of("USER");
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList());
});
return converter;
}
}

2. JWT Token Service

@Service
public class JwtTokenService {
private final JwtDecoder jwtDecoder;
public JwtTokenService(@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") String issuerUri) {
this.jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);
}
public boolean validateToken(String token) {
try {
Jwt jwt = jwtDecoder.decode(token);
return !isTokenExpired(jwt);
} catch (Exception e) {
return false;
}
}
public String getUsernameFromToken(String token) {
Jwt jwt = jwtDecoder.decode(token);
return jwt.getSubject();
}
public List<String> getRolesFromToken(String token) {
Jwt jwt = jwtDecoder.decode(token);
return jwt.getClaimAsStringList("roles");
}
private boolean isTokenExpired(Jwt jwt) {
return jwt.getExpiresAt().isBefore(Instant.now());
}
}

Docker Configuration

Dockerfile

FROM openjdk:17-jdk-slim
WORKDIR /app
COPY target/oauth2-proxy-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

docker-compose.yml

version: '3.8'
services:
oauth2-proxy:
build: .
ports:
- "8080:8080"
environment:
- OAUTH2_CLIENT_ID=${OAUTH2_CLIENT_ID}
- OAUTH2_CLIENT_SECRET=${OAUTH2_CLIENT_SECRET}
- SPRING_REDIS_HOST=redis
depends_on:
- redis
backend-app:
image: nginx:alpine
ports:
- "8081:80"
volumes:
- ./backend:/usr/share/nginx/html
redis:
image: redis:alpine
ports:
- "6379:6379"

Configuration Management

application-prod.yml

spring:
security:
oauth2:
client:
registration:
google:
client-id: ${OAUTH2_CLIENT_ID}
client-secret: ${OAUTH2_CLIENT_SECRET}
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
server:
forward-headers-strategy: framework
tomcat:
remoteip:
remote-ip-header: X-Forwarded-For
protocol-header: X-Forwarded-Proto
logging:
level:
org.springframework.security: INFO

Best Practices

  1. Security Headers: Always set appropriate security headers
  2. Token Validation: Validate tokens properly and handle expiration
  3. Rate Limiting: Implement rate limiting to prevent abuse
  4. Logging: Log authentication events for security monitoring
  5. Health Checks: Implement health endpoints for monitoring
  6. Circuit Breaker: Use circuit breakers for backend communication
  7. TLS/SSL: Always use HTTPS in production

This implementation provides a robust OAuth2 Proxy that can handle authentication, session management, and request forwarding while maintaining security and performance.

Leave a Reply

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


Macro Nepal Helper