google-site-verification: google61fe8ba583a51912.html
Implementing SAML 2.0 in Java: A Comprehensive Guide

SAML (Security Assertion Markup Language) 2.0 is an XML-based protocol for exchanging authentication and authorization data between parties, particularly between an Identity Provider (IdP) and a Service Provider (SP).


Core Concepts

SAML Roles:

  • Identity Provider (IdP): Authenticates users and generates SAML assertions
  • Service Provider (SP): Relies on the IdP for authentication and provides services
  • Principal: The user being authenticated

Key Components:

  • Assertions: XML documents containing authentication/authorization statements
  • Protocols: Define request/response messages (AuthnRequest, Response)
  • Bindings: Transport mechanisms (HTTP-POST, HTTP-Redirect)
  • Profiles: Specific use cases (Web Browser SSO Profile)

Dependencies and Setup

Maven Dependencies
<properties> <spring-boot.version>3.1.0</spring-boot.version> <saml.version>2.7.0</saml.version> <opensaml.version>4.3.0</opensaml.version> </properties> <dependencies> <!-- Spring Security SAML --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-saml2-service-provider</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- OpenSAML --> <dependency> <groupId>org.opensaml</groupId> <artifactId>opensaml-core</artifactId> <version>${opensaml.version}</version> </dependency> <dependency> <groupId>org.opensaml</groupId> <artifactId>opensaml-saml-api</artifactId> <version>${opensaml.version}</version> </dependency> <dependency> <groupId>org.opensaml</groupId> <artifactId>opensaml-saml-impl</artifactId> <version>${opensaml.version}</version> </dependency> <!-- Spring Boot Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- XML & Crypto --> <dependency> <groupId>org.apache.santuario</groupId> <artifactId>xmlsec</artifactId> <version>3.0.2</version> </dependency> </dependencies>
Initializing OpenSAML
@Component public class OpenSAMLInitializer { @PostConstruct public void initialize() { try { XMLObjectProviderRegistry registry = new XMLObjectProviderRegistry(); Configuration.setProviderRegistry(registry); // Initialize the library InitializationService.initialize(); // Register parsers and marshallers ParserPool parserPool = new BasicParserPool(); parserPool.setMaxPoolSize(50); Configuration.setParserPool(parserPool); } catch (Exception e) { throw new RuntimeException("Failed to initialize OpenSAML", e); } } }

Service Provider (SP) Implementation

1. SAML Configuration Properties
@Configuration @ConfigurationProperties(prefix = "saml") @Data public class SamlProperties { private String entityId; private String metadataLocation; private String keystoreLocation; private String keystorePassword; private String privateKeyPassword; private String idpMetadataUrl; private String assertionConsumerServiceUrl; private String singleLogoutServiceUrl; // Getters and setters }
2. Spring Security SAML Configuration
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/saml/**").permitAll() .requestMatchers("/css/**", "/js/**").permitAll() .anyRequest().authenticated() ) .saml2Login(saml2 -> saml2 .loginProcessingUrl("/saml/sso") .defaultSuccessUrl("/dashboard", true) ) .saml2Logout(saml2 -> saml2 .logoutRequest("/saml/logout") .logoutResponse("/saml/logout") ); return http.build(); } }
3. Advanced SAML Configuration
@Configuration public class SamlConfig { @Autowired private SamlProperties samlProperties; @Bean public RelyingPartyRegistrationRepository relyingPartyRegistrations() { RelyingPartyRegistration registration = RelyingPartyRegistrations .fromMetadataLocation(samlProperties.getIdpMetadataUrl()) .registrationId("my-idp") .entityId(samlProperties.getEntityId()) .assertionConsumerServiceLocation(samlProperties.getAssertionConsumerServiceUrl()) .singleLogoutServiceLocation(samlProperties.getSingleLogoutServiceUrl()) .signingX509Credentials(this::signingCredentials) .decryptionX509Credentials(this::decryptionCredentials) .build(); return new InMemoryRelyingPartyRegistrationRepository(registration); } private Collection<Saml2X509Credential> signingCredentials( RelyingPartyRegistration relyingPartyRegistration) { X509Certificate certificate = loadCertificate(); PrivateKey privateKey = loadPrivateKey(); Saml2X509Credential signingCredential = Saml2X509Credential.signing( privateKey, certificate); return Collections.singletonList(signingCredential); } private Collection<Saml2X509Credential> decryptionCredentials( RelyingPartyRegistration relyingPartyRegistration) { X509Certificate certificate = loadCertificate(); PrivateKey privateKey = loadPrivateKey(); Saml2X509Credential decryptionCredential = Saml2X509Credential.decryption( privateKey, certificate); return Collections.singletonList(decryptionCredential); } private X509Certificate loadCertificate() { try (FileInputStream is = new FileInputStream(samlProperties.getKeystoreLocation())) { KeyStore keyStore = KeyStore.getInstance("JKS"); keyStore.load(is, samlProperties.getKeystorePassword().toCharArray()); return (X509Certificate) keyStore.getCertificate("saml-cert"); } catch (Exception e) { throw new RuntimeException("Failed to load certificate", e); } } private PrivateKey loadPrivateKey() { try (FileInputStream is = new FileInputStream(samlProperties.getKeystoreLocation())) { KeyStore keyStore = KeyStore.getInstance("JKS"); keyStore.load(is, samlProperties.getKeystorePassword().toCharArray()); return (PrivateKey) keyStore.getKey("saml-key", samlProperties.getPrivateKeyPassword().toCharArray()); } catch (Exception e) { throw new RuntimeException("Failed to load private key", e); } } }
4. SP Metadata Endpoint
@RestController public class SamlMetadataController { @Autowired private RelyingPartyRegistrationRepository relyingPartyRegistrations; @GetMapping(value = "/saml/metadata", produces = "application/xml") public String metadata() { RelyingPartyRegistration registration = relyingPartyRegistrations .findByRegistrationId("my-idp"); String metadata = createSpMetadata(registration); return metadata; } private String createSpMetadata(RelyingPartyRegistration registration) { // Implementation to generate SP metadata XML // This would typically use OpenSAML to build the metadata document return generateMetadataXml(registration); } }

Custom SAML Processing

1. SAML Authentication Provider
@Component public class CustomSamlAuthenticationProvider implements AuthenticationProvider { private static final Logger logger = LoggerFactory.getLogger(CustomSamlAuthenticationProvider.class); @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Saml2AuthenticationToken token = (Saml2AuthenticationToken) authentication; try { SAMLMessageContext context = token.getSaml2MessageContext(); Assertion assertion = extractAssertion(context); // Validate assertion validateAssertion(assertion, context); // Extract user details String username = extractUsername(assertion); Map<String, List<Object>> attributes = extractAttributes(assertion); // Create authentication object List<GrantedAuthority> authorities = extractAuthorities(attributes); UserDetails userDetails = createUserDetails(username, attributes); logger.info("SAML authentication successful for user: {}", username); return new Saml2Authentication(userDetails, token.getSaml2Response(), authorities); } catch (Exception e) { logger.error("SAML authentication failed", e); throw new BadCredentialsException("SAML authentication failed", e); } } @Override public boolean supports(Class<?> authentication) { return Saml2AuthenticationToken.class.isAssignableFrom(authentication); } private String extractUsername(Assertion assertion) { // Extract NameID or other identifier Subject subject = assertion.getSubject(); if (subject != null && subject.getNameID() != null) { return subject.getNameID().getValue(); } // Fallback to attribute return extractAttributeValue(assertion, "email"); } private Map<String, List<Object>> extractAttributes(Assertion assertion) { Map<String, List<Object>> attributes = new HashMap<>(); for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) { for (Attribute attribute : attributeStatement.getAttributes()) { String name = attribute.getName(); List<Object> values = attribute.getAttributeValues().stream() .map(this::getAttributeValue) .collect(Collectors.toList()); attributes.put(name, values); } } return attributes; } private Object getAttributeValue(XMLObject attributeValue) { if (attributeValue instanceof XSString) { return ((XSString) attributeValue).getValue(); } else if (attributeValue instanceof XSInteger) { return ((XSInteger) attributeValue).getValue(); } else if (attributeValue instanceof XSURI) { return ((XSURI) attributeValue).getValue(); } return attributeValue.toString(); } private List<GrantedAuthority> extractAuthorities(Map<String, List<Object>> attributes) { List<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority("ROLE_USER")); // Extract roles from attributes List<Object> roles = attributes.getOrDefault("roles", Collections.emptyList()); for (Object role : roles) { authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toString().toUpperCase())); } return authorities; } private UserDetails createUserDetails(String username, Map<String, List<Object>> attributes) { return new SamlUserDetails(username, attributes); } private void validateAssertion(Assertion assertion, SAMLMessageContext context) { // Implement assertion validation logic validateSignature(assertion); validateConditions(assertion); validateIssuer(assertion, context); } }
2. Custom User Details
public class SamlUserDetails implements UserDetails { private final String username; private final Map<String, List<Object>> attributes; private final List<GrantedAuthority> authorities; public SamlUserDetails(String username, Map<String, List<Object>> attributes) { this.username = username; this.attributes = attributes != null ? attributes : new HashMap<>(); this.authorities = extractAuthoritiesFromAttributes(); } @Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; } @Override public String getPassword() { return null; // SAML doesn't use passwords } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public Map<String, List<Object>> getAttributes() { return Collections.unmodifiableMap(attributes); } public String getAttribute(String name) { List<Object> values = attributes.get(name); return values != null && !values.isEmpty() ? values.get(0).toString() : null; } public List<Object> getAttributeValues(String name) { return attributes.getOrDefault(name, Collections.emptyList()); } private List<GrantedAuthority> extractAuthoritiesFromAttributes() { List<GrantedAuthority> auths = new ArrayList<>(); auths.add(new SimpleGrantedAuthority("ROLE_USER")); List<Object> roles = attributes.getOrDefault("roles", Collections.emptyList()); for (Object role : roles) { auths.add(new SimpleGrantedAuthority("ROLE_" + role.toString().toUpperCase())); } return auths; } }
3. SAML Assertion Validator
@Component public class SamlAssertionValidator { private final Clock clock; private final Set<String> allowedIssuers; public SamlAssertionValidator() { this.clock = Clock.systemUTC(); this.allowedIssuers = Set.of( "https://idp.example.com/saml", "https://auth.company.com/saml" ); } public void validate(Assertion assertion, String expectedAudience) { validateSignature(assertion); validateIssuer(assertion); validateConditions(assertion, expectedAudience); validateSubject(assertion); validateAuthnStatement(assertion); } private void validateSignature(Assertion assertion) { Signature signature = assertion.getSignature(); if (signature == null) { throw new ValidationException("Assertion is not signed"); } // Implement signature validation logic if (!isSignatureValid(signature)) { throw new ValidationException("Invalid assertion signature"); } } private void validateIssuer(Assertion assertion) { Issuer issuer = assertion.getIssuer(); if (issuer == null || issuer.getValue() == null) { throw new ValidationException("Assertion has no issuer"); } if (!allowedIssuers.contains(issuer.getValue())) { throw new ValidationException("Untrusted issuer: " + issuer.getValue()); } } private void validateConditions(Assertion assertion, String expectedAudience) { Conditions conditions = assertion.getConditions(); if (conditions == null) { throw new ValidationException("Assertion has no conditions"); } // Validate notBefore/notOnOrAfter validateTimeConditions(conditions); // Validate audience restriction validateAudience(conditions, expectedAudience); } private void validateTimeConditions(Conditions conditions) { DateTime now = DateTime.now(clock); if (conditions.getNotBefore() != null && now.isBefore(conditions.getNotBefore())) { throw new ValidationException("Assertion is not yet valid"); } if (conditions.getNotOnOrAfter() != null && !now.isBefore(conditions.getNotOnOrAfter())) { throw new ValidationException("Assertion has expired"); } } private void validateAudience(Conditions conditions, String expectedAudience) { for (AudienceRestriction restriction : conditions.getAudienceRestrictions()) { for (Audience audience : restriction.getAudiences()) { if (expectedAudience.equals(audience.getAudienceURI())) { return; // Found matching audience } } } throw new ValidationException("Assertion audience does not match expected audience"); } private void validateSubject(Assertion assertion) { Subject subject = assertion.getSubject(); if (subject == null) { throw new ValidationException("Assertion has no subject"); } if (subject.getNameID() == null) { throw new ValidationException("Assertion subject has no NameID"); } } private void validateAuthnStatement(Assertion assertion) { List<AuthnStatement> authnStatements = assertion.getAuthnStatements(); if (authnStatements.isEmpty()) { throw new ValidationException("Assertion has no authentication statements"); } // Validate authn instant AuthnStatement authnStatement = authnStatements.get(0); if (authnStatement.getAuthnInstant() == null) { throw new ValidationException("Authentication statement has no instant"); } } private boolean isSignatureValid(Signature signature) { // Implement actual signature validation // This would use the IdP's public key to verify the signature return true; // Simplified for example } }

SAML Utility Classes

1. SAML Message Builder
@Component public class SamlMessageBuilder { @Autowired private XMLObjectProviderRegistry registry; public AuthnRequest buildAuthnRequest(String issuer, String destination, String assertionConsumerServiceUrl) { AuthnRequest authnRequest = (AuthnRequest) buildSAMLObject(AuthnRequest.DEFAULT_ELEMENT_NAME); // Set basic attributes authnRequest.setID(generateId()); authnRequest.setIssueInstant(new DateTime()); authnRequest.setDestination(destination); authnRequest.setAssertionConsumerServiceURL(assertionConsumerServiceUrl); authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI); // Set issuer Issuer issuerElement = (Issuer) buildSAMLObject(Issuer.DEFAULT_ELEMENT_NAME); issuerElement.setValue(issuer); authnRequest.setIssuer(issuerElement); // Set NameID policy NameIDPolicy nameIDPolicy = (NameIDPolicy) buildSAMLObject(NameIDPolicy.DEFAULT_ELEMENT_NAME); nameIDPolicy.setFormat(NameIDType.UNSPECIFIED); nameIDPolicy.setAllowCreate(true); authnRequest.setNameIDPolicy(nameIDPolicy); // Set requested authn context RequestedAuthnContext requestedAuthnContext = (RequestedAuthnContext) buildSAMLObject(RequestedAuthnContext.DEFAULT_ELEMENT_NAME); AuthnContextClassRef authnContextClassRef = (AuthnContextClassRef) buildSAMLObject(AuthnContextClassRef.DEFAULT_ELEMENT_NAME); authnContextClassRef.setAuthnContextClassRef(AuthnContext.PASSWORD_AUTHN_CTX); requestedAuthnContext.getAuthnContextClassRefs().add(authnContextClassRef); requestedAuthnContext.setComparison(AuthnContextComparisonTypeEnumeration.MINIMUM); authnRequest.setRequestedAuthnContext(requestedAuthnContext); return authnRequest; } public LogoutRequest buildLogoutRequest(String issuer, String destination, String nameId, String sessionIndex) { LogoutRequest logoutRequest = (LogoutRequest) buildSAMLObject(LogoutRequest.DEFAULT_ELEMENT_NAME); logoutRequest.setID(generateId()); logoutRequest.setIssueInstant(new DateTime()); logoutRequest.setDestination(destination); // Set issuer Issuer issuerElement = (Issuer) buildSAMLObject(Issuer.DEFAULT_ELEMENT_NAME); issuerElement.setValue(issuer); logoutRequest.setIssuer(issuerElement); // Set NameID NameID nameIdElement = (NameID) buildSAMLObject(NameID.DEFAULT_ELEMENT_NAME); nameIdElement.setValue(nameId); nameIdElement.setFormat(NameIDType.UNSPECIFIED); logoutRequest.setNameID(nameIdElement); // Set session index SessionIndex sessionIndexElement = (SessionIndex) buildSAMLObject(SessionIndex.DEFAULT_ELEMENT_NAME); sessionIndexElement.setValue(sessionIndex); logoutRequest.getSessionIndexes().add(sessionIndexElement); return logoutRequest; } private XMLObject buildSAMLObject(QName elementName) { return XMLObjectProviderRegistrySupport.getBuilderFactory() .getBuilder(elementName) .buildObject(elementName); } private String generateId() { return "_" + UUID.randomUUID().toString().replace("-", ""); } }
2. SAML Message Marshaller
@Component public class SamlMessageMarshaller { public String marshallToXml(XMLObject samlObject) { try { Element element = XMLObjectSupport.marshall(samlObject); return serializeElement(element); } catch (Exception e) { throw new RuntimeException("Failed to marshall SAML object to XML", e); } } public String marshallToBase64(XMLObject samlObject) { String xml = marshallToXml(samlObject); return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8)); } public String marshallToDeflatedBase64(XMLObject samlObject) { try { String xml = marshallToXml(samlObject); ByteArrayOutputStream bytesOut = new ByteArrayOutputStream(); Deflater deflater = new Deflater(Deflater.DEFLATED, true); DeflaterOutputStream deflaterStream = new DeflaterOutputStream(bytesOut, deflater); deflaterStream.write(xml.getBytes(StandardCharsets.UTF_8)); deflaterStream.finish(); return Base64.getEncoder().encodeToString(bytesOut.toByteArray()); } catch (Exception e) { throw new RuntimeException("Failed to deflate SAML message", e); } } private String serializeElement(Element element) { try { Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); StringWriter writer = new StringWriter(); transformer.transform(new DOMSource(element), new StreamResult(writer)); return writer.toString(); } catch (Exception e) { throw new RuntimeException("Failed to serialize XML element", e); } } }

SAML Controllers

1. SSO Initiation Controller
@Controller public class SamlSsoController { @Autowired private SamlMessageBuilder messageBuilder; @Autowired private SamlMessageMarshaller marshaller; @Autowired private SamlProperties samlProperties; @GetMapping("/saml/login") public String initiateSso(HttpServletRequest request, HttpServletResponse response) throws Exception { String idpSsoUrl = "https://idp.example.com/sso"; // Should come from metadata AuthnRequest authnRequest = messageBuilder.buildAuthnRequest( samlProperties.getEntityId(), idpSsoUrl, samlProperties.getAssertionConsumerServiceUrl() ); String authnRequestXml = marshaller.marshallToDeflatedBase64(authnRequest); String relayState = generateRelayState(); // Store relay state in session request.getSession().setAttribute("relayState", relayState); // Redirect to IdP String redirectUrl = buildRedirectUrl(idpSsoUrl, authnRequestXml, relayState); response.sendRedirect(redirectUrl); return null; } private String buildRedirectUrl(String idpUrl, String samlRequest, String relayState) { return idpUrl + "?SAMLRequest=" + URLEncoder.encode(samlRequest, StandardCharsets.UTF_8) + "&RelayState=" + URLEncoder.encode(relayState, StandardCharsets.UTF_8); } private String generateRelayState() { return UUID.randomUUID().toString(); } }
2. Assertion Consumer Service
@Controller public class AssertionConsumerController { private static final Logger logger = LoggerFactory.getLogger(AssertionConsumerController.class); @PostMapping("/saml/sso") public String processAssertion(@RequestParam("SAMLResponse") String samlResponse, @RequestParam(value = "RelayState", required = false) String relayState, HttpServletRequest request) { try { // Decode and parse the SAML response String decodedResponse = new String( Base64.getDecoder().decode(samlResponse), StandardCharsets.UTF_8 ); logger.debug("Received SAML response: {}", decodedResponse); // Process the response (this would be handled by Spring Security in real implementation) // For custom processing, you would: // 1. Parse the response XML // 2. Validate the assertion // 3. Extract user information // 4. Create authentication token // Redirect based on relay state or default URL String redirectUrl = (relayState != null) ? resolveRelayState(relayState) : "/dashboard"; return "redirect:" + redirectUrl; } catch (Exception e) { logger.error("Failed to process SAML assertion", e); return "redirect:/error?message=authentication_failed"; } } private String resolveRelayState(String relayState) { // Implement relay state resolution logic // This might map to specific application URLs return "/dashboard"; // Simplified } }

Configuration Properties

# application.yml saml: entity-id: https://myapp.example.com/saml metadata-location: classpath:saml/metadata.xml keystore-location: classpath:saml/keystore.jks keystore-password: changeit private-key-password: changeit idp-metadata-url: https://idp.example.com/metadata assertion-consumer-service-url: https://myapp.example.com/saml/sso single-logout-service-url: https://myapp.example.com/saml/logout server: ssl: key-store: classpath:keystore.p12 key-store-password: changeit key-store-type: PKCS12 key-alias: tomcat logging: level: org.springframework.security.saml2: DEBUG org.opensaml: DEBUG

Testing

1. Unit Tests
@SpringBootTest class SamlAuthenticationTest { @Autowired private SamlAssertionValidator assertionValidator; @Test void testValidAssertion() { Assertion assertion = createValidAssertion(); assertDoesNotThrow(() -> assertionValidator.validate(assertion, "https://myapp.example.com/saml") ); } @Test void testExpiredAssertion() { Assertion assertion = createExpiredAssertion(); assertThrows(ValidationException.class, () -> assertionValidator.validate(assertion, "https://myapp.example.com/saml") ); } }
2. Integration Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class SamlIntegrationTest { @LocalServerPort private int port; @Test void testSsoInitiation() { // Test SSO initiation flow ResponseEntity<String> response = restTemplate.getForEntity( "http://localhost:" + port + "/saml/login", String.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND); assertThat(response.getHeaders().getLocation()).isNotNull(); } }

Best Practices

  1. Certificate Management: Use proper key rotation and certificate validation
  2. Security: Validate all SAML messages thoroughly
  3. Error Handling: Implement comprehensive error handling and logging
  4. Configuration: Externalize all configuration parameters
  5. Testing: Test with various IdPs and scenarios
  6. Monitoring: Monitor authentication flows and failures
// Example security best practice @Component public class SecurityBestPractices { public void applySecuritySettings(AuthnRequest authnRequest) { // Force Authn for sensitive operations authnRequest.setForceAuthn(true); // Set reasonable session timeout // Validate all incoming messages // Implement proper logout handling } }

Conclusion

Implementing SAML 2.0 in Java provides:

  • Standardized SSO across multiple applications
  • Enterprise-grade security with strong cryptographic validation
  • Interoperability with various Identity Providers
  • Flexible authentication scenarios

The Spring Security SAML integration simplifies much of the complexity, while OpenSAML provides the low-level control needed for custom implementations. This combination allows you to build robust, secure SAML-based authentication systems that integrate seamlessly with enterprise identity providers.

Leave a Reply

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


Macro Nepal Helper