OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0 that enables clients to verify user identities and obtain basic profile information. Here's a comprehensive guide to implementing OIDC in Java applications.
Key OIDC Concepts
- ID Token: JWT containing user identity information
- UserInfo Endpoint: Returns additional user claims
- Discovery: Standardized metadata endpoint
- Dynamic Registration: Client registration with identity providers
Dependencies Setup
Maven Dependencies
<dependencies> <!-- Spring Security OAuth2 --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency> <!-- Spring Boot Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- JWT Libraries --> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.31</version> </dependency> <!-- For manual OIDC implementation --> <dependency> <groupId>org.apache.httpcomponents.client5</groupId> <artifactId>httpclient5</artifactId> </dependency> </dependencies>
Spring Boot OIDC Integration
Example 1: Basic Spring Security OIDC Configuration
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/", "/public/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .loginPage("/oauth2/authorization/google") .defaultSuccessUrl("/dashboard", true) .failureUrl("/login?error=true") ) .logout(logout -> logout .logoutSuccessUrl("/") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ); return http.build(); } } Example 2: Application Properties Configuration
# application.yml spring: security: oauth2: client: registration: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} scope: - openid - profile - email redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" okta: client-id: ${OKTA_CLIENT_ID} client-secret: ${OKTA_CLIENT_SECRET} scope: openid,profile,email azure: client-id: ${AZURE_CLIENT_ID} client-secret: ${AZURE_CLIENT_SECRET} scope: openid,profile,email authorization-grant-type: authorization_code client-authentication-method: client_secret_post provider: okta: issuer-uri: https://${OKTA_DOMAIN}/oauth2/default azure: issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0 Manual OIDC Implementation
Example 3: OIDC Discovery and Dynamic Client Registration
@Service public class OidcDiscoveryService { private final WebClient webClient; private final ObjectMapper objectMapper; public OidcDiscoveryService(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.build(); this.objectMapper = new ObjectMapper(); } public OidcProviderMetadata discoverProvider(String issuerUrl) { try { String discoveryUrl = issuerUrl + "/.well-known/openid-configuration"; return webClient.get() .uri(discoveryUrl) .retrieve() .bodyToMono(OidcProviderMetadata.class) .block(); } catch (Exception e) { throw new RuntimeException("OIDC discovery failed for issuer: " + issuerUrl, e); } } public OidcClientRegistration registerClient(OidcProviderMetadata metadata, OidcClientMetadata clientMetadata) { try { return webClient.post() .uri(metadata.getRegistrationEndpoint()) .contentType(MediaType.APPLICATION_JSON) .bodyValue(objectMapper.writeValueAsString(clientMetadata)) .retrieve() .bodyToMono(OidcClientRegistration.class) .block(); } catch (Exception e) { throw new RuntimeException("Client registration failed", e); } } } @Data class OidcProviderMetadata { private String issuer; private String authorizationEndpoint; private String tokenEndpoint; private String userinfoEndpoint; private String jwksUri; private String registrationEndpoint; private List<String> scopesSupported; private List<String> responseTypesSupported; private List<String> subjectTypesSupported; private List<String> idTokenSigningAlgValuesSupported; } @Data class OidcClientMetadata { private List<String> redirectUris; private String clientName; private List<String> grantTypes = Arrays.asList("authorization_code", "refresh_token"); private List<String> responseTypes = Arrays.asList("code"); private String applicationType = "web"; private List<String> contacts; } @Data class OidcClientRegistration { private String clientId; private String clientSecret; private String clientIdIssuedAt; private String clientSecretExpiresAt; private String registrationClientUri; private String registrationAccessToken; } Example 4: OIDC Authentication Flow
@Service public class OidcAuthenticationService { private final WebClient webClient; private final JwtDecoder jwtDecoder; private final OidcProviderMetadata providerMetadata; public OidcAuthenticationService(WebClient.Builder webClientBuilder, OidcProviderMetadata providerMetadata) { this.webClient = webClientBuilder.build(); this.providerMetadata = providerMetadata; this.jwtDecoder = JwtDecoders.fromIssuerLocation(providerMetadata.getIssuer()); } public String buildAuthorizationUrl(String clientId, String redirectUri, String state, String nonce) { return UriComponentsBuilder.fromHttpUrl(providerMetadata.getAuthorizationEndpoint()) .queryParam("response_type", "code") .queryParam("client_id", clientId) .queryParam("redirect_uri", redirectUri) .queryParam("scope", "openid profile email") .queryParam("state", state) .queryParam("nonce", nonce) .build() .toUriString(); } public OidcTokens exchangeCodeForTokens(String authorizationCode, String clientId, String clientSecret, String redirectUri) { try { MultiValueMap<String, String> formData = new LinkedMultiValueMap<>(); formData.add("grant_type", "authorization_code"); formData.add("code", authorizationCode); formData.add("redirect_uri", redirectUri); formData.add("client_id", clientId); formData.add("client_secret", clientSecret); TokenResponse tokenResponse = webClient.post() .uri(providerMetadata.getTokenEndpoint()) .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body(BodyInserters.fromFormData(formData)) .retrieve() .bodyToMono(TokenResponse.class) .block(); return validateIdToken(tokenResponse.getIdToken()); } catch (Exception e) { throw new RuntimeException("Token exchange failed", e); } } private OidcTokens validateIdToken(String idToken) { try { Jwt jwt = jwtDecoder.decode(idToken); // Validate required claims validateRequiredClaims(jwt.getClaims()); return new OidcTokens( idToken, jwt.getClaims(), jwt.getHeaders() ); } catch (Exception e) { throw new RuntimeException("ID token validation failed", e); } } private void validateRequiredClaims(Map<String, Object> claims) { if (!claims.containsKey("iss") || !claims.get("iss").equals(providerMetadata.getIssuer())) { throw new RuntimeException("Invalid issuer"); } if (!claims.containsKey("sub")) { throw new RuntimeException("Missing subject"); } if (!claims.containsKey("aud")) { throw new RuntimeException("Missing audience"); } if (!claims.containsKey("exp")) { throw new RuntimeException("Missing expiration"); } // Validate expiration long exp = Long.parseLong(claims.get("exp").toString()); if (System.currentTimeMillis() / 1000 > exp) { throw new RuntimeException("Token expired"); } } public OidcUserInfo getUserInfo(String accessToken) { return webClient.get() .uri(providerMetadata.getUserinfoEndpoint()) .header("Authorization", "Bearer " + accessToken) .retrieve() .bodyToMono(OidcUserInfo.class) .block(); } } @Data class TokenResponse { private String accessToken; private String tokenType; private Long expiresIn; private String idToken; private String refreshToken; private String scope; } @Data class OidcTokens { private final String idToken; private final Map<String, Object> idTokenClaims; private final Map<String, Object> idTokenHeaders; } @Data class OidcUserInfo { private String sub; private String name; private String givenName; private String familyName; private String middleName; private String nickname; private String preferredUsername; private String profile; private String picture; private String website; private String email; private Boolean emailVerified; private String gender; private String birthdate; private String zoneinfo; private String locale; private String phoneNumber; private Boolean phoneNumberVerified; private Map<String, Object> address; private Long updatedAt; } Advanced OIDC Features
Example 5: Custom OIDC User Service
@Service public class CustomOidcUserService implements OAuth2UserService<OidcUserRequest, OidcUser> { private final OidcUserService delegate = new OidcUserService(); private final UserRepository userRepository; public CustomOidcUserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { OidcUser oidcUser = delegate.loadUser(userRequest); // Extract claims from ID token Map<String, Object> claims = oidcUser.getClaims(); String email = (String) claims.get("email"); String subject = (String) claims.get("sub"); String issuer = userRequest.getClientRegistration().getProviderDetails().getIssuerUri(); // Find or create user in local database User user = userRepository.findBySubAndIssuer(subject, issuer) .orElseGet(() -> createNewUser(oidcUser, subject, issuer)); // Update user information updateUserFromClaims(user, claims); // Create custom OIDC user with additional attributes return new CustomOidcUser(oidcUser, user); } private User createNewUser(OidcUser oidcUser, String subject, String issuer) { User user = new User(); user.setSub(subject); user.setIssuer(issuer); user.setCreatedAt(LocalDateTime.now()); return userRepository.save(user); } private void updateUserFromClaims(User user, Map<String, Object> claims) { user.setEmail((String) claims.get("email")); user.setName((String) claims.get("name")); user.setGivenName((String) claims.get("given_name")); user.setFamilyName((String) claims.get("family_name")); user.setPicture((String) claims.get("picture")); user.setEmailVerified(Boolean.TRUE.equals(claims.get("email_verified"))); user.setUpdatedAt(LocalDateTime.now()); userRepository.save(user); } } @Data class CustomOidcUser extends DefaultOidcUser { private final User localUser; public CustomOidcUser(OidcUser oidcUser, User localUser) { super(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo()); this.localUser = localUser; } @Override public String getName() { return localUser.getEmail(); // Use email as principal name } public Long getUserId() { return localUser.getId(); } } @Entity @Table(name = "users") @Data class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String sub; // OIDC subject identifier @Column(nullable = false) private String issuer; private String email; private String name; private String givenName; private String familyName; private String picture; private Boolean emailVerified; private LocalDateTime createdAt; private LocalDateTime updatedAt; } Example 6: JWT Validation and Key Management
@Service public class JwtValidationService { private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>(); private final WebClient webClient; public JwtValidationService(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.build(); } public Jwt validateToken(String token, String issuer) { JwtDecoder decoder = jwtDecoders.computeIfAbsent(issuer, this::createJwtDecoder); return decoder.decode(token); } private JwtDecoder createJwtDecoder(String issuer) { try { // Fetch JWKS (JSON Web Key Set) from issuer String jwksUri = getJwksUriFromIssuer(issuer); JwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwksUri).build(); // Configure validators ((NimbusJwtDecoder) jwtDecoder).setJwtValidator(jwtValidator(issuer)); return jwtDecoder; } catch (Exception e) { throw new RuntimeException("Failed to create JWT decoder for issuer: " + issuer, e); } } private String getJwksUriFromIssuer(String issuer) { // For well-known providers, use discovery endpoint String discoveryUrl = issuer + "/.well-known/openid-configuration"; Map<String, Object> discovery = webClient.get() .uri(discoveryUrl) .retrieve() .bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {}) .block(); return (String) discovery.get("jwks_uri"); } private OAuth2TokenValidator<Jwt> jwtValidator(String issuer) { List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>(); validators.add(new JwtTimestampValidator()); validators.add(new JwtIssuerValidator(issuer)); validators.add(new JwtClaimValidator<String>("aud", aud -> aud != null && aud.contains("your-client-id"))); validators.add(new JwtClaimValidator<String>("nonce", nonce -> nonce != null)); // Validate nonce if present return new DelegatingOAuth2TokenValidator<>(validators); } public boolean validateTokenSignature(String token, String jwksUri) { try { // Parse token without validation SignedJWT signedJWT = SignedJWT.parse(token); // Get key ID from header String keyId = signedJWT.getHeader().getKeyID(); // Fetch JWKS and find the matching key JWKSet jwkSet = JWKSet.load(new URL(jwksUri).openStream()); JWK jwk = jwkSet.getKeyByKeyId(keyId); if (jwk instanceof RSAKey) { RSASSAVerifier verifier = new RSASSAVerifier(((RSAKey) jwk).toRSAPublicKey()); return signedJWT.verify(verifier); } return false; } catch (Exception e) { throw new RuntimeException("Token signature validation failed", e); } } } Example 7: OIDC-Protected REST API
@RestController @RequestMapping("/api") public class SecureApiController { @GetMapping("/user/profile") public ResponseEntity<UserProfile> getUserProfile(@AuthenticationPrincipal OidcUser principal) { UserProfile profile = new UserProfile(); profile.setEmail(principal.getEmail()); profile.setName(principal.getFullName()); profile.setPicture(principal.getPicture()); profile.setEmailVerified(principal.getEmailVerified()); return ResponseEntity.ok(profile); } @PostMapping("/user/preferences") public ResponseEntity<?> updateUserPreferences( @RequestBody UserPreferences preferences, @AuthenticationPrincipal CustomOidcUser principal) { // Use local user ID from custom OIDC user Long userId = principal.getUserId(); // Update preferences for this user return ResponseEntity.ok().build(); } } @Configuration @EnableWebSecurity @EnableMethodSecurity(prePostEnabled = true) public class ResourceServerConfig { @Bean public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(authz -> authz .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(grantedAuthoritiesExtractor()) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); return http.build(); } private Converter<Jwt, AbstractAuthenticationToken> grantedAuthoritiesExtractor() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { // Extract roles from JWT claims Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access"); if (realmAccess != null) { List<String> roles = (List<String>) realmAccess.get("roles"); if (roles != null) { return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); } } return Collections.emptyList(); }); return converter; } } Testing OIDC Integration
Example 8: Testing OIDC Authentication
@SpringBootTest @AutoConfigureTestDatabase class OidcAuthenticationTest { @Autowired private TestRestTemplate restTemplate; @MockBean private OAuth2AuthorizedClientService authorizedClientService; @Test void testUnauthenticatedAccess() { ResponseEntity<String> response = restTemplate.getForEntity("/api/user/profile", String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } @Test @WithMockOidcUser(email = "[email protected]", roles = {"USER"}) void testAuthenticatedAccess() { ResponseEntity<UserProfile> response = restTemplate.getForEntity("/api/user/profile", UserProfile.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody().getEmail()).isEqualTo("[email protected]"); } } // Custom security test annotation @Retention(RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithMockOidcUserSecurityContextFactory.class) public @interface WithMockOidcUser { String email() default "[email protected]"; String[] roles() default {"USER"}; String sub() default "1234567890"; } public class WithMockOidcUserSecurityContextFactory implements WithSecurityContextFactory<WithMockOidcUser> { @Override public SecurityContext createSecurityContext(WithMockOidcUser mockUser) { SecurityContext context = SecurityContextHolder.createEmptyContext(); Map<String, Object> claims = new HashMap<>(); claims.put("sub", mockUser.sub()); claims.put("email", mockUser.email()); claims.put("email_verified", true); Jwt jwt = Jwt.withTokenValue("mock-token") .header("alg", "RS256") .claims(c -> c.putAll(claims)) .build(); List<GrantedAuthority> authorities = Arrays.stream(mockUser.roles()) .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwt, authorities); context.setAuthentication(authentication); return context; } } Best Practices and Security Considerations
Security Configuration
@Configuration public class OidcSecurityBestPractices { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // CSRF protection for state-changing operations .csrf(csrf -> csrf .ignoringRequestMatchers("/api/**") ) // Session management .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(1) ) // Headers security .headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self' 'unsafe-inline'") ) .frameOptions(FrameOptionsConfig::sameOrigin) ) // OAuth2 client .oauth2Client(oauth2 -> oauth2 .authorizationCodeGrant(codeGrant -> codeGrant .authorizationRequestRepository(cookieAuthorizationRequestRepository()) ) ); return http.build(); } private CookieAuthorizationRequestRepository cookieAuthorizationRequestRepository() { return new HttpCookieOAuth2AuthorizationRequestRepository(); } @Bean public ClientRegistrationRepository clientRegistrationRepository() { return new InMemoryClientRegistrationRepository(this.googleClientRegistration()); } private ClientRegistration googleClientRegistration() { return ClientRegistration.withRegistrationId("google") .clientId(System.getenv("GOOGLE_CLIENT_ID")) .clientSecret(System.getenv("GOOGLE_CLIENT_SECRET")) .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") .scope("openid", "profile", "email") .authorizationUri("https://accounts.google.com/o/oauth2/v2/auth") .tokenUri("https://www.googleapis.com/oauth2/v4/token") .userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo") .userNameAttributeName(IdTokenClaimNames.SUB) .jwkSetUri("https://www.googleapis.com/oauth2/v3/certs") .clientName("Google") .build(); } } Conclusion
OpenID Connect provides a robust framework for implementing secure authentication in Java applications. Key takeaways:
- Use Spring Security OAuth2 for quick integration with major providers
- Implement proper JWT validation with signature verification and claim validation
- Handle user sessions securely with appropriate session management
- Follow security best practices for CSRF protection and headers security
- Test thoroughly with both unit tests and integration tests
OIDC integration enables secure, standards-based authentication that works across different identity providers while providing a good user experience.