OpenID Connect Integration in Java: Complete Implementation Guide

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:

  1. Use Spring Security OAuth2 for quick integration with major providers
  2. Implement proper JWT validation with signature verification and claim validation
  3. Handle user sessions securely with appropriate session management
  4. Follow security best practices for CSRF protection and headers security
  5. 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.

Leave a Reply

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


Macro Nepal Helper