Centralized Access Control: Implementing SSO in Java with OpenUnison


Article

In enterprise Java environments, managing authentication across multiple applications—Kubernetes dashboards, Jenkins, GitLab, and custom Spring Boot services—creates significant operational overhead. OpenUnison is an open-source identity and access management solution that provides a unified Single Sign-On (SSO) portal for your entire infrastructure. For Java development teams, it simplifies security by centralizing authentication against existing identity providers like Active Directory, LDAP, or OIDC providers.

What is OpenUnison?

OpenUnison is a cloud-native identity and access management proxy that provides:

  • Single Sign-On (SSO): Unified login for all your applications
  • Multi-protocol Support: SAML, OAuth 2.0, OpenID Connect, and LDAP
  • Kubernetes Integration: Seamless access to Kubernetes clusters
  • Identity Federation: Connect to existing identity providers
  • Access Request Workflows: Self-service access management

Why OpenUnison for Java Applications?

  1. Unified Authentication: One login for all applications and services
  2. Enterprise Integration: Leverage existing Active Directory or LDAP
  3. Kubernetes-Native: Designed for cloud-native Java deployments
  4. Reduced Development Overhead: No need to implement auth in every service
  5. Compliance Ready: Built-in auditing and access controls

OpenUnison Architecture for Java Ecosystems

OpenUnison acts as an identity gateway between your users and applications:

User → OpenUnison Portal → Identity Provider (AD/LDAP/OIDC) → Java Applications
↓
Kubernetes Dashboard
↓
Jenkins, GitLab, etc.

Deploying OpenUnison in Kubernetes

1. Deploy OpenUnison Operator:

# openunison-operator.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: openunison-operator
namespace: openunison
spec:
replicas: 1
selector:
matchLabels:
name: openunison-operator
template:
metadata:
labels:
name: openunison-operator
spec:
serviceAccountName: openunison-operator
containers:
- name: openunison-operator
image: docker.io/tremolosecurity/openunison-operator:latest
imagePullPolicy: Always
env:
- name: WATCH_NAMESPACE
value: ""
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: OPERATOR_NAME
value: "openunison-operator"

2. Create OpenUnison Custom Resource:

# openunison-cr.yaml
apiVersion: openunison.tremolo.io/v1
kind: OpenUnison
metadata:
name: openunison
namespace: openunison
spec:
enable_activemq: false
network:
openunison_host: sso.mycompany.com
dashboard_host: k8s.mycompany.com
api_server_host: k8s-api.mycompany.com
session_inactivity_timeout_seconds: 900
certificates:
use_k8s_cm: true
trust_certs:
- name: my-company-ca
pem_b64: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
keyvalue:
- name: K8S_DB_NAMESPACE
value: openunison-database
- name: K8S_DB_SECRET
value: orchestra-db
deployments:
- name: openunison
replicas: 2
image: docker.io/tremolosecurity/openunison:latest
- name: orchestra
replicas: 2
image: docker.io/tremolosecurity/orchestra:latest

Integrating Java Applications with OpenUnison

Approach 1: SAML Integration with Spring Security

1. Add SAML Dependencies:

<!-- pom.xml -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-saml2-service-provider</artifactId>
<version>6.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

2. SAML Security Configuration:

@Configuration
@EnableWebSecurity
public class SamlSecurityConfig {
@Value("${openunison.entity-id}")
private String openUnisonEntityId;
@Value("${openunison.metadata-url}")
private String metadataUrl;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.saml2Login(saml2 -> saml2
.loginProcessingUrl("/login/saml2/sso")
.authenticationManager(authenticationManager())
)
.saml2Logout(saml2 -> saml2
.logoutRequest(logout -> logout
.logoutUrl("/logout/saml2/slo")
)
.logoutResponse(logout -> logout
.logoutUrl("/logout/saml2/slo")
)
);
return http.build();
}
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
RelyingPartyRegistration registration = RelyingPartyRegistrations
.fromMetadataLocation(metadataUrl)
.registrationId("openunison")
.entityId(openUnisonEntityId)
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(samlAuthenticationProvider());
}
@Bean
public Saml2AuthenticationProvider samlAuthenticationProvider() {
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseAuthenticationConverter(responseToken -> {
Saml2Authentication authentication = 
OpenSaml4AuthenticationProvider
.createDefaultResponseAuthenticationConverter()
.convert(responseToken);
// Extract attributes from SAML response
Saml2AuthenticatedPrincipal principal = 
(Saml2AuthenticatedPrincipal) authentication.getPrincipal();
String username = principal.getName();
List<String> groups = principal.getAttribute("groups");
String email = principal.getAttribute("email");
// Create custom authentication with extracted attributes
return new Saml2Authentication(
new CustomSaml2AuthenticatedPrincipal(principal, groups, email),
authentication.getSaml2Response(),
authorities(groups)
);
});
return provider;
}
private Collection<? extends GrantedAuthority> authorities(List<String> groups) {
return groups.stream()
.map(group -> new SimpleGrantedAuthority("ROLE_" + group.toUpperCase()))
.collect(Collectors.toList());
}
}

3. Custom SAML Principal:

public class CustomSaml2AuthenticatedPrincipal implements Saml2AuthenticatedPrincipal {
private final Saml2AuthenticatedPrincipal delegate;
private final List<String> groups;
private final String email;
public CustomSaml2AuthenticatedPrincipal(Saml2AuthenticatedPrincipal delegate, 
List<String> groups, String email) {
this.delegate = delegate;
this.groups = groups != null ? groups : Collections.emptyList();
this.email = email;
}
@Override
public String getName() {
return delegate.getName();
}
@Override
public Map<String, List<Object>> getAttributes() {
return delegate.getAttributes();
}
@Override
public Collection<String> getSessionIndexes() {
return delegate.getSessionIndexes();
}
@Override
public String getRelyingPartyRegistrationId() {
return delegate.getRelyingPartyRegistrationId();
}
public List<String> getGroups() {
return groups;
}
public String getEmail() {
return email;
}
}

Approach 2: OpenID Connect Integration

1. OIDC Security Configuration:

@Configuration
@EnableWebSecurity
public class OidcSecurityConfig {
@Value("${openunison.issuer-uri}")
private String issuerUri;
@Value("${openunison.client-id}")
private String clientId;
@Value("${openunison.client-secret}")
private String clientSecret;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/oauth2/authorization/openunison")
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
)
.oauth2Client(oauth2 -> oauth2
.authorizationCodeGrant(codeGrant -> codeGrant
.authorizationRequestRepository(new HttpSessionOAuth2AuthorizationRequestRepository())
)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
);
return http.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
ClientRegistration openUnisonRegistration = ClientRegistration
.withRegistrationId("openunison")
.clientId(clientId)
.clientSecret(clientSecret)
.scope("openid", "profile", "email", "groups")
.authorizationUri(issuerUri + "/authorize")
.tokenUri(issuerUri + "/token")
.userInfoUri(issuerUri + "/userinfo")
.jwkSetUri(issuerUri + "/protocol/openid-connect/certs")
.clientName("OpenUnison")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.build();
return new InMemoryClientRegistrationRepository(openUnisonRegistration);
}
@Bean
public OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
final OidcUserService delegate = new OidcUserService();
return userRequest -> {
OidcUser oidcUser = delegate.loadUser(userRequest);
// Extract groups and custom attributes
Map<String, Object> claims = oidcUser.getClaims();
List<String> groups = (List<String>) claims.get("groups");
String email = (String) claims.get("email");
return new CustomOidcUser(oidcUser, groups, email);
};
}
}

2. Custom OIDC User:

public class CustomOidcUser extends DefaultOidcUser {
private final List<String> groups;
private final String email;
public CustomOidcUser(OidcUser oidcUser, List<String> groups, String email) {
super(oidcUser.getAuthorities(), oidcUser.getIdToken(), oidcUser.getUserInfo());
this.groups = groups != null ? groups : Collections.emptyList();
this.email = email;
}
public List<String> getGroups() {
return groups;
}
public String getEmail() {
return email;
}
@Override
public String getName() {
return getPreferredUsername();
}
public String getPreferredUsername() {
return (String) getAttributes().get("preferred_username");
}
}

Application Configuration

application.yml:

# OpenUnison Configuration
openunison:
entity-id: https://sso.mycompany.com/auth/metadata
metadata-url: https://sso.mycompany.com/auth/metadata
issuer-uri: https://sso.mycompany.com/auth/realms/mycompany
client-id: my-java-app
client-secret: ${OIDC_CLIENT_SECRET}
# Spring Security
spring:
security:
oauth2:
client:
provider:
openunison:
issuer-uri: https://sso.mycompany.com/auth/realms/mycompany
registration:
openunison:
client-id: my-java-app
client-secret: ${OIDC_CLIENT_SECRET}
scope: openid,profile,email,groups
# Application
server:
port: 8080
servlet:
session:
cookie:
name: JSESSIONID
secure: true

Secure Controller Implementation

@RestController
@RequestMapping("/api")
public class SecureController {
@GetMapping("/user/profile")
public ResponseEntity<UserProfile> getUserProfile(@AuthenticationPrincipal CustomOidcUser user) {
UserProfile profile = UserProfile.builder()
.username(user.getPreferredUsername())
.email(user.getEmail())
.groups(user.getGroups())
.fullName(user.getFullName())
.build();
return ResponseEntity.ok(profile);
}
@PreAuthorize("hasRole('DEVELOPERS')")
@GetMapping("/admin/dashboard")
public ResponseEntity<Map<String, String>> adminDashboard() {
return ResponseEntity.ok(Collections.singletonMap("message", "Admin access granted"));
}
@GetMapping("/public/info")
public ResponseEntity<Map<String, String>> publicInfo() {
return ResponseEntity.ok(Collections.singletonMap("message", "Public information"));
}
}
@Data
@Builder
class UserProfile {
private String username;
private String email;
private List<String> groups;
private String fullName;
}

Kubernetes Deployment with OpenUnison Integration

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: java-app
namespace: my-java-apps
labels:
app: java-app
spec:
replicas: 3
selector:
matchLabels:
app: java-app
template:
metadata:
labels:
app: java-app
spec:
containers:
- name: app
image: myregistry/java-app:latest
ports:
- containerPort: 8080
env:
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: java-app-secrets
key: oidc-client-secret
- name: SPRING_PROFILES_ACTIVE
value: "prod"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "500m"
livenessProbe:
httpGet:
path: /actuator/health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 15
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: java-app
namespace: my-java-apps
spec:
selector:
app: java-app
ports:
- port: 80
targetPort: 8080
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: java-app
namespace: my-java-apps
annotations:
nginx.ingress.kubernetes.io/auth-url: "https://sso.mycompany.com/auth/url"
nginx.ingress.kubernetes.io/auth-signin: "https://sso.mycompany.com/login"
spec:
rules:
- host: app.mycompany.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: java-app
port:
number: 80
tls:
- hosts:
- app.mycompany.com
secretName: java-app-tls

OpenUnison Configuration for Multiple Java Apps

1. Application Registration in OpenUnison:

# app-registration.yaml
apiVersion: openunison.tremolo.io/v1
kind: AppPortal
metadata:
name: java-app-portal
namespace: openunison
spec:
label: Java Applications
logo: /static/java.png
groups:
- "cn=developers,ou=groups,dc=mycompany,dc=com"
links:
- name: "Order Service"
url: "https://orders.mycompany.com"
groups: 
- "cn=orders-users,ou=groups,dc=mycompany,dc=com"
- name: "User Service"  
url: "https://users.mycompany.com"
groups:
- "cn=user-admins,ou=groups,dc=mycompany,dc=com"
- name: "Reporting Dashboard"
url: "https://reports.mycompany.com"
groups:
- "cn=report-viewers,ou=groups,dc=mycompany,dc=com"

Monitoring and Health Checks

@Component
public class OpenUnisonHealthIndicator implements HealthIndicator {
private final RestTemplate restTemplate;
private final String metadataUrl;
public OpenUnisonHealthIndicator(@Value("${openunison.metadata-url}") String metadataUrl) {
this.metadataUrl = metadataUrl;
this.restTemplate = new RestTemplate();
}
@Override
public Health health() {
try {
ResponseEntity<String> response = restTemplate.getForEntity(metadataUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return Health.up()
.withDetail("service", "OpenUnison")
.withDetail("status", "Available")
.build();
} else {
return Health.down()
.withDetail("service", "OpenUnison")
.withDetail("status", "Unavailable")
.withDetail("httpStatus", response.getStatusCodeValue())
.build();
}
} catch (Exception e) {
return Health.down(e)
.withDetail("service", "OpenUnison")
.build();
}
}
}

Best Practices for Java Teams

  1. Centralized Configuration: Store OpenUnison settings in ConfigMaps or external configuration
  2. Proper Session Management: Configure session timeouts and secure cookies
  3. Role-Based Access Control: Leverage group memberships from OpenUnison
  4. TLS Everywhere: Ensure all communication is encrypted
  5. Regular Auditing: Monitor authentication events and access patterns
@Aspect
@Component
public class SecurityAuditAspect {
private static final Logger logger = LoggerFactory.getLogger(SecurityAuditAspect.class);
@AfterReturning("execution(* com.example..*Controller.*(..)) && @annotation(org.springframework.security.access.prepost.PreAuthorize)")
public void auditAuthorizedAccess(JoinPoint joinPoint) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof CustomOidcUser) {
CustomOidcUser user = (CustomOidcUser) authentication.getPrincipal();
logger.info("User {} accessed {} with roles {}", 
user.getPreferredUsername(), 
joinPoint.getSignature().getName(),
user.getGroups());
}
}
}

Conclusion

OpenUnison provides a powerful, enterprise-ready SSO solution for Java applications in Kubernetes environments. By centralizing authentication and authorization, it eliminates the need for each Java service to implement its own security layer while providing seamless integration with existing identity providers.

The combination of OpenUnison with Spring Security's SAML and OIDC support creates a robust security foundation that scales across multiple applications and environments. This approach not only enhances security but also significantly reduces development and operational overhead, allowing Java teams to focus on delivering business value rather than managing authentication complexity.

Leave a Reply

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


Macro Nepal Helper