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.