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
- Security Headers: Always set appropriate security headers
- Token Validation: Validate tokens properly and handle expiration
- Rate Limiting: Implement rate limiting to prevent abuse
- Logging: Log authentication events for security monitoring
- Health Checks: Implement health endpoints for monitoring
- Circuit Breaker: Use circuit breakers for backend communication
- 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.