Enterprise Identity Made Seamless: Integrating Microsoft Identity Platform with Java

In the corporate world, Microsoft's identity ecosystem (Azure Active Directory/ Microsoft Entra ID) is ubiquitous. For Java developers building enterprise applications, integrating with Microsoft Identity Platform is essential for single sign-on (SSO), accessing Microsoft 365 APIs, and securing both internal and customer-facing applications. Let's explore how to seamlessly connect Java applications with Microsoft's identity services.

Understanding Microsoft Identity Platform

The Microsoft Identity Platform (part of Microsoft Entra ID) is an evolution of Azure Active Directory (Azure AD), providing:

  • OAuth 2.0 and OpenID Connect compliance
  • Multi-tenant application support
  • Microsoft Graph API integration
  • Conditional Access policies
  • Enterprise-grade security and compliance

Spring Boot Integration with Microsoft Identity

1. Dependencies Setup

<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>com.microsoft.azure</groupId>
<artifactId>msal4j</artifactId>
<version>1.13.10</version>
</dependency>
<dependency>
<groupId>com.azure</groupId>
<artifactId>azure-identity</artifactId>
<version>1.9.1</version>
</dependency>
</dependencies>

2. Application Configuration

# application.yml
spring:
security:
oauth2:
client:
registration:
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope:
- openid
- profile
- User.Read
- Mail.Read
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/azure"
provider:
azure:
authorization-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/authorize
token-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token
user-info-uri: https://graph.microsoft.com/v1.0/me
jwk-set-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/discovery/v2.0/keys
user-name-attribute: sub
azure:
tenant-id: ${AZURE_TENANT_ID}
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
authority: https://login.microsoftonline.com/${AZURE_TENANT_ID}

3. Security Configuration

@Configuration
@EnableWebSecurity
public class AzureADSecurityConfig {
@Value("${azure.tenant-id}")
private String tenantId;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("Admin")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/azure")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)
.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(azureClientRegistration());
}
private ClientRegistration azureClientRegistration() {
return ClientRegistration.withRegistrationId("azure")
.clientId("${AZURE_CLIENT_ID}")
.clientSecret("${AZURE_CLIENT_SECRET}")
.scope("openid", "profile", "User.Read", "Mail.Read")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/authorize")
.tokenUri("https://login.microsoftonline.com/" + tenantId + "/oauth2/v2.0/token")
.userInfoUri("https://graph.microsoft.com/v1.0/me")
.userNameAttributeName("id")
.clientName("Microsoft Azure AD")
.build();
}
}

MSAL4J Integration for Advanced Scenarios

For more control over authentication flows, use the Microsoft Authentication Library for Java (MSAL4J):

1. Confidential Client Application (Web Apps)

@Service
public class MSALAuthService {
private final String clientId;
private final String clientSecret;
private final String authority;
public MSALAuthService(@Value("${azure.client-id}") String clientId,
@Value("${azure.client-secret}") String clientSecret,
@Value("${azure.authority}") String authority) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.authority = authority;
}
public ConfidentialClientApplication createClientInstance() throws Exception {
return ConfidentialClientApplication.builder(clientId, ClientCredentialFactory.createFromSecret(clientSecret))
.authority(authority)
.build();
}
public String getAuthorizationUrl() throws Exception {
ConfidentialClientApplication clientApp = createClientInstance();
String[] scopes = {"https://graph.microsoft.com/User.Read"};
AuthorizationRequestUrlParameters parameters = 
AuthorizationRequestUrlParameters.builder("http://localhost:8080/msal-auth", 
new HashSet<>(Arrays.asList(scopes)))
.responseMode(ResponseMode.QUERY)
.prompt(Prompt.SELECT_ACCOUNT)
.build();
return clientApp.getAuthorizationRequestUrl(parameters).toString();
}
public IAuthenticationResult acquireTokenByAuthorizationCode(String authorizationCode) 
throws Exception {
ConfidentialClientApplication clientApp = createClientInstance();
Set<String> scopes = new HashSet<>(Arrays.asList("https://graph.microsoft.com/User.Read"));
AuthorizationCodeParameters parameters = AuthorizationCodeParameters
.builder(authorizationCode, new URI("http://localhost:8080/msal-auth"))
.scopes(scopes)
.build();
return clientApp.acquireToken(parameters).get();
}
public IAuthenticationResult acquireTokenSilently(String accountId) throws Exception {
ConfidentialClientApplication clientApp = createClientInstance();
Set<String> scopes = new HashSet<>(Arrays.asList("https://graph.microsoft.com/User.Read"));
SilentParameters parameters = SilentParameters.builder(scopes)
.account(getAccountById(clientApp, accountId))
.build();
return clientApp.acquireTokenSilently(parameters).get();
}
private IAccount getAccountById(ConfidentialClientApplication clientApp, String accountId) 
throws Exception {
return clientApp.getAccounts().join().stream()
.filter(account -> account.homeAccountId().equals(accountId))
.findFirst()
.orElseThrow(() -> new AccountNotFoundException("Account not found"));
}
}

2. Microsoft Graph API Integration

@Service
public class MicrosoftGraphService {
private final MSALAuthService authService;
private final RestTemplate restTemplate;
public MicrosoftGraphService(MSALAuthService authService) {
this.authService = authService;
this.restTemplate = new RestTemplate();
}
public UserProfile getUserProfile(String accountId) throws Exception {
IAuthenticationResult authResult = authService.acquireTokenSilently(accountId);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authResult.accessToken());
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://graph.microsoft.com/v1.0/me",
HttpMethod.GET,
entity,
Map.class
);
return mapToUserProfile(response.getBody());
}
public List<EmailMessage> getUserEmails(String accountId) throws Exception {
IAuthenticationResult authResult = authService.acquireTokenSilently(accountId);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authResult.accessToken());
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://graph.microsoft.com/v1.0/me/messages?$top=10",
HttpMethod.GET,
entity,
Map.class
);
return extractEmailsFromResponse(response.getBody());
}
public void sendEmail(String accountId, EmailRequest emailRequest) throws Exception {
IAuthenticationResult authResult = authService.acquireTokenSilently(accountId);
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(authResult.accessToken());
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> emailPayload = createEmailPayload(emailRequest);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(emailPayload, headers);
restTemplate.exchange(
"https://graph.microsoft.com/v1.0/me/sendMail",
HttpMethod.POST,
entity,
Void.class
);
}
private Map<String, Object> createEmailPayload(EmailRequest emailRequest) {
Map<String, Object> message = new HashMap<>();
message.put("subject", emailRequest.getSubject());
Map<String, Object> body = new HashMap<>();
body.put("contentType", "Text");
body.put("content", emailRequest.getBody());
message.put("body", body);
Map<String, Object> toRecipient = new HashMap<>();
toRecipient.put("emailAddress", Map.of("address", emailRequest.getTo()));
message.put("toRecipients", Collections.singletonList(toRecipient));
return Map.of("message", message);
}
}

Multi-Tenant Application Support

@Service
public class MultiTenantAuthService {
public ConfidentialClientApplication createClientInstanceForTenant(String tenantId) 
throws Exception {
String authority = String.format("https://login.microsoftonline.com/%s", tenantId);
return ConfidentialClientApplication.builder(clientId, 
ClientCredentialFactory.createFromSecret(clientSecret))
.authority(authority)
.build();
}
public String getTenantSpecificAuthUrl(String tenantId, String redirectUri) 
throws Exception {
ConfidentialClientApplication clientApp = createClientInstanceForTenant(tenantId);
Set<String> scopes = new HashSet<>(Arrays.asList("https://graph.microsoft.com/.default"));
AuthorizationRequestUrlParameters parameters = 
AuthorizationRequestUrlParameters.builder(redirectUri, scopes)
.responseMode(ResponseMode.QUERY)
.build();
return clientApp.getAuthorizationRequestUrl(parameters).toString();
}
public IAuthenticationResult acquireTokenForTenant(String tenantId, String authorizationCode) 
throws Exception {
ConfidentialClientApplication clientApp = createClientInstanceForTenant(tenantId);
Set<String> scopes = new HashSet<>(Arrays.asList("https://graph.microsoft.com/.default"));
AuthorizationCodeParameters parameters = AuthorizationCodeParameters
.builder(authorizationCode, new URI("http://localhost:8080/auth/callback"))
.scopes(scopes)
.build();
return clientApp.acquireToken(parameters).get();
}
}

Conditional Access and Advanced Security

@Service
public class ConditionalAccessService {
public void validateAccessCompliance(Authentication authentication, 
HttpServletRequest request) {
AccessContext context = buildAccessContext(authentication, request);
// Check device compliance
if (!isDeviceCompliant(context.getDeviceId())) {
throw new AccessDeniedException("Device not compliant with security policy");
}
// Check location compliance
if (!isLocationAllowed(context.getIpAddress())) {
throw new AccessDeniedException("Access from this location is restricted");
}
// Check user risk
UserRisk risk = calculateUserRisk(context.getUserId());
if (risk == UserRisk.HIGH) {
requireStepUpAuthentication(context);
}
}
private AccessContext buildAccessContext(Authentication authentication, 
HttpServletRequest request) {
return AccessContext.builder()
.userId(authentication.getName())
.ipAddress(request.getRemoteAddr())
.userAgent(request.getHeader("User-Agent"))
.deviceId(extractDeviceId(request))
.accessTime(LocalDateTime.now())
.resource(request.getRequestURI())
.build();
}
private boolean isDeviceCompliant(String deviceId) {
// Integrate with Microsoft Intune or other MDM solutions
// to check device compliance
return deviceComplianceService.isDeviceCompliant(deviceId);
}
private boolean isLocationAllowed(String ipAddress) {
// Check against allowed IP ranges or countries
GeoLocation location = geoLocationService.getLocation(ipAddress);
return allowedCountries.contains(location.getCountryCode());
}
}

Spring Security with Azure AD JWT Tokens

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class AzureADJwtSecurityConfig {
@Value("${azure.tenant-id}")
private String tenantId;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authz -> authz
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(azureAdJwtConverter())
.decoder(jwtDecoder())
)
)
.build();
}
@Bean
public JwtDecoder jwtDecoder() {
String issuerUri = String.format("https://login.microsoftonline.com/%s/v2.0", tenantId);
NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);
// Add Azure AD specific validators
OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>(
new JwtTimestampValidator(),
new JwtIssuerValidator(issuerUri),
new AzureADAudienceValidator(),
new AzureADTokenVersionValidator()
);
jwtDecoder.setJwtValidator(validator);
return jwtDecoder;
}
@Bean
public JwtAuthenticationConverter azureAdJwtConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new AzureADJwtGrantedAuthoritiesConverter());
return converter;
}
}
@Component
class AzureADJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
// Extract roles from roles claim
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> 
authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
// Extract groups from groups claim (if using group claims)
List<String> groups = jwt.getClaimAsStringList("groups");
if (groups != null) {
groups.forEach(group -> 
authorities.add(new SimpleGrantedAuthority("GROUP_" + group)));
}
// Extract scopes from scp claim
String scopes = jwt.getClaimAsString("scp");
if (scopes != null) {
Arrays.stream(scopes.split(" "))
.forEach(scope -> 
authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)));
}
return authorities;
}
}
@Component
class AzureADAudienceValidator implements OAuth2TokenValidator<Jwt> {
@Value("${azure.client-id}")
private String clientId;
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
List<String> audiences = jwt.getAudience();
if (audiences.contains(clientId)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", 
"The token was not issued for this application", null)
);
}
}
@Component
class AzureADTokenVersionValidator implements OAuth2TokenValidator<Jwt> {
@Override
public OAuth2TokenValidatorResult validate(Jwt jwt) {
String version = jwt.getClaimAsString("ver");
// Validate token version (v2.0 tokens)
if ("2.0".equals(version)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", 
"Unsupported token version", null)
);
}
}

Controller Examples

@RestController
@RequestMapping("/api")
public class AzureADController {
private final MicrosoftGraphService graphService;
private final MSALAuthService authService;
@GetMapping("/user/profile")
public ResponseEntity<UserProfile> getUserProfile(Authentication authentication) {
try {
String accountId = extractAccountId(authentication);
UserProfile profile = graphService.getUserProfile(accountId);
return ResponseEntity.ok(profile);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/user/emails")
public ResponseEntity<List<EmailMessage>> getUserEmails(Authentication authentication) {
try {
String accountId = extractAccountId(authentication);
List<EmailMessage> emails = graphService.getUserEmails(accountId);
return ResponseEntity.ok(emails);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/email/send")
public ResponseEntity<Void> sendEmail(@RequestBody EmailRequest emailRequest,
Authentication authentication) {
try {
String accountId = extractAccountId(authentication);
graphService.sendEmail(accountId, emailRequest);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@GetMapping("/auth/url")
public ResponseEntity<Map<String, String>> getAuthUrl() {
try {
String authUrl = authService.getAuthorizationUrl();
return ResponseEntity.ok(Map.of("authorizationUrl", authUrl));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private String extractAccountId(Authentication authentication) {
// Extract account ID from authentication principal
if (authentication.getPrincipal() instanceof Jwt) {
Jwt jwt = (Jwt) authentication.getPrincipal();
return jwt.getClaim("oid"); // Object ID claim
}
throw new AuthenticationException("Unable to extract account ID");
}
}

Testing Microsoft Identity Integration

@SpringBootTest
@TestPropertySource(properties = {
"azure.tenant-id=test-tenant",
"azure.client-id=test-client",
"azure.client-secret=test-secret"
})
class AzureADIntegrationTest {
@Mock
private MSALAuthService authService;
@Test
void shouldAuthenticateWithValidToken() throws Exception {
// Given
IAuthenticationResult authResult = mock(IAuthenticationResult.class);
when(authResult.accessToken()).thenReturn("valid-token");
when(authService.acquireTokenSilently(anyString())).thenReturn(authResult);
// When
UserProfile profile = graphService.getUserProfile("user123");
// Then
assertNotNull(profile);
}
@Test
void shouldHandleAuthenticationError() throws Exception {
// Given
when(authService.acquireTokenSilently(anyString()))
.thenThrow(new Exception("Token acquisition failed"));
// When/Then
assertThrows(Exception.class, () -> {
graphService.getUserProfile("user123");
});
}
}

Best Practices for Production

  1. Secure Configuration - Store client secrets in Azure Key Vault or secure environment variables
  2. Token Management - Implement proper token caching and refresh mechanisms
  3. Error Handling - Gracefully handle token expiration and revocation
  4. Monitoring - Track authentication failures and suspicious activities
  5. Compliance - Ensure compliance with organizational security policies

Conclusion

Integrating Microsoft Identity Platform with Java applications provides enterprise-grade security, seamless single sign-on, and access to the rich Microsoft 365 ecosystem. Whether you're building internal enterprise applications or customer-facing SaaS solutions, the combination of Spring Security and MSAL4J offers a robust foundation for authentication and authorization.

Key benefits for Java developers include:

  • Standards-based implementation using OAuth 2.0 and OpenID Connect
  • Rich API access to Microsoft Graph and other Microsoft services
  • Enterprise features like multi-tenancy and conditional access
  • Spring integration for seamless developer experience
  • Production-ready security and scalability

By following the patterns and best practices outlined here, Java developers can build secure, enterprise-ready applications that leverage the full power of Microsoft's identity ecosystem.


Leave a Reply

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


Macro Nepal Helper