Spring Boot SSO Integration Guide: OAuth2/OIDC and SAML in Practice
Introduction
When operating multiple systems in an enterprise environment, you frequently encounter Single Sign-On (SSO) requirements. Users can access multiple applications with a single login, and development teams can avoid duplicating authentication logic.
This guide covers two primary approaches to implementing SSO in a Spring Boot application:
- OAuth2/OIDC - The modern and most widely used approach
- SAML 2.0 - Still widely used in enterprise environments
Table of Contents
- SSO Fundamentals
- OAuth2/OIDC-Based SSO
- Keycloak Integration in Practice
- Okta/Azure AD Integration
- SAML 2.0-Based SSO
- Session Management and Logout
- Practical Tips and Troubleshooting
- FAQ
1. SSO Fundamentals
1.1 What is SSO?
Single Sign-On (SSO) is an authentication method that allows users to access multiple applications with a single authentication.
graph TD
IdP["Identity Provider (IdP)\n(Keycloak, Okta, Azure AD)"]
AppA["App A\n(SP)"]
AppB["App B\n(SP)"]
AppC["App C\n(SP)"]
IdP --> AppA
IdP --> AppB
IdP --> AppC
note["SP = Service Provider (the application we develop)"]
1.2 Protocol Comparison
| Item | OAuth2/OIDC | SAML 2.0 |
|---|---|---|
| Token Format | JWT (JSON) | XML Assertion |
| Transport Method | REST API | Browser Redirect/POST |
| Complexity | Low | High |
| Mobile Support | Excellent | Limited |
| Use Case | Modern web/mobile apps | Enterprise legacy |
| Spring Support | Very good | Good |
Recommendation: For new projects, choose OAuth2/OIDC. Consider SAML only when integrating with legacy systems.
1.3 Key Terminology
| Term | Description |
|---|---|
| IdP (Identity Provider) | Responsible for user authentication (Keycloak, Okta, Azure AD) |
| SP (Service Provider) | The application providing the service (our app) |
| Client ID | Application identifier registered with the IdP |
| Client Secret | Secret key for application authentication |
| Redirect URI | Callback URL to return to after authentication |
| Scope | Requested permission scope (openid, profile, email) |
2. OAuth2/OIDC-Based SSO
2.1 Adding Dependencies
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-security")
}
2.2 Authorization Code Flow
This is the most secure and recommended flow:
sequenceDiagram
participant User
participant SP
participant IdP
User->>SP: 1. Access /dashboard
SP-->>User: 2. 302 Redirect to IdP
User->>IdP: 3. IdP login page
User->>IdP: 4. Login (ID/PW or SSO)
IdP-->>User: 5. 302 Redirect with code
User->>SP: 6. Redirect to SP (/callback)
SP->>IdP: 7. Exchange code for token
IdP-->>SP: 8. Access Token + ID Token
SP-->>User: 9. Create session, redirect to original page
2.3 Basic Configuration (application.yml)
spring:
security:
oauth2:
client:
registration:
keycloak: # Registration ID (any name you choose)
client-id: my-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid, profile, email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/my-realm
# Setting only issuer-uri auto-configures the following:
# authorization-uri, token-uri, user-info-uri, jwk-set-uri
2.4 Security Config
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/", "/public/**", "/health").permitAll()
.anyRequest().authenticated()
}
.oauth2Login { oauth2 ->
oauth2
.loginPage("/login") // Custom login page (optional)
.defaultSuccessUrl("/dashboard", true)
.failureUrl("/login?error=true")
}
.logout { logout ->
logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
}
.build()
}
}
2.5 Accessing User Information
@RestController
@RequestMapping("/api")
class UserController {
@GetMapping("/me")
fun getCurrentUser(
@AuthenticationPrincipal oauth2User: OAuth2User
): Map<String, Any?> {
return mapOf(
"name" to oauth2User.getAttribute<String>("name"),
"email" to oauth2User.getAttribute<String>("email"),
"sub" to oauth2User.getAttribute<String>("sub"), // Unique ID
"roles" to oauth2User.authorities.map { it.authority }
)
}
// Using OidcUser provides access to more information
@GetMapping("/me/detailed")
fun getDetailedUser(
@AuthenticationPrincipal oidcUser: OidcUser
): Map<String, Any?> {
return mapOf(
"claims" to oidcUser.claims,
"idToken" to oidcUser.idToken.tokenValue,
"userInfo" to oidcUser.userInfo?.claims
)
}
}
3. Keycloak Integration in Practice
Keycloak is an open-source IdP widely used in both local development and production environments.
3.1 Running Keycloak with Docker
# docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8180:8080"
docker-compose up -d
# Access http://localhost:8180
3.2 Keycloak Configuration
- Create Realm:
my-realm - Create Client:
- Client ID:
my-app - Client authentication: ON
- Valid redirect URIs:
http://localhost:8080/* - Web origins:
http://localhost:8080
- Client ID:
- Create User: Test user
3.3 application.yml Configuration
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid, profile, email
provider:
keycloak:
issuer-uri: http://localhost:8180/realms/my-realm
3.4 Role Mapping
Mapping Keycloak roles to Spring Security authorities:
@Configuration
class OAuth2Config {
@Bean
fun keycloakGrantedAuthoritiesMapper(): GrantedAuthoritiesMapper {
return GrantedAuthoritiesMapper { authorities ->
val mappedAuthorities = mutableSetOf<GrantedAuthority>()
authorities.forEach { authority ->
mappedAuthorities.add(authority)
if (authority is OidcUserAuthority) {
// Extract roles from realm_access.roles
val realmAccess = authority.idToken
.getClaim<Map<String, Any>>("realm_access")
val roles = realmAccess?.get("roles") as? List<*>
roles?.forEach { role ->
mappedAuthorities.add(
SimpleGrantedAuthority("ROLE_${role.toString().uppercase()}")
)
}
}
}
mappedAuthorities
}
}
}
Now you can use @PreAuthorize("hasRole('ADMIN')") for Keycloak role-based access control.
4. Okta/Azure AD Integration
4.1 Okta Configuration
spring:
security:
oauth2:
client:
registration:
okta:
client-id: ${OKTA_CLIENT_ID}
client-secret: ${OKTA_CLIENT_SECRET}
scope: openid, profile, email
provider:
okta:
issuer-uri: https://${OKTA_DOMAIN}/oauth2/default
4.2 Azure AD (Microsoft Entra ID) Configuration
spring:
security:
oauth2:
client:
registration:
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: openid, profile, email
provider:
azure:
issuer-uri: https://login.microsoftonline.com/${AZURE_TENANT_ID}/v2.0
4.3 Multiple IdP Support
You can support multiple IdPs simultaneously:
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: ${KEYCLOAK_CLIENT_ID}
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid, profile, email
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
azure:
client-id: ${AZURE_CLIENT_ID}
client-secret: ${AZURE_CLIENT_SECRET}
scope: openid, profile, email
provider:
keycloak:
issuer-uri: https://auth.example.com/realms/my-realm
Selecting an IdP on the login page:
@Controller
class LoginController {
@GetMapping("/login")
fun login(
model: Model,
clientRegistrationRepository: ClientRegistrationRepository
): String {
val registrations = (clientRegistrationRepository as InMemoryClientRegistrationRepository)
.map { registration ->
mapOf(
"id" to registration.registrationId,
"name" to registration.clientName,
"url" to "/oauth2/authorization/${registration.registrationId}"
)
}
model.addAttribute("registrations", registrations)
return "login"
}
}
<!-- templates/login.html -->
<div th:each="registration : ${registrations}">
<a th:href="${registration.url}" th:text="${registration.name}">Login</a>
</div>
5. SAML 2.0-Based SSO
This is needed when integrating with legacy systems or IdPs that only support SAML.
5.1 Adding Dependencies
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.security:spring-security-saml2-service-provider")
}
5.2 SAML Configuration
spring:
security:
saml2:
relyingparty:
registration:
my-idp:
signing:
credentials:
- private-key-location: classpath:saml/private.key
certificate-location: classpath:saml/certificate.crt
assertingparty:
metadata-uri: https://idp.example.com/metadata.xml
singlelogout:
binding: POST
url: "{baseUrl}/logout/saml2/slo"
5.3 Security Config (SAML)
@Configuration
@EnableWebSecurity
class SamlSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/", "/public/**").permitAll()
.anyRequest().authenticated()
}
.saml2Login { saml2 ->
saml2
.defaultSuccessUrl("/dashboard", true)
}
.saml2Logout { }
.build()
}
}
5.4 Certificate Generation
Self-signed certificate for development:
# Generate Private Key
openssl genrsa -out private.key 2048
# Generate Certificate
openssl req -new -x509 -key private.key -out certificate.crt -days 365 \
-subj "/CN=my-app/O=My Company/C=KR"
6. Session Management and Logout
6.1 Single Logout (SLO)
When logging out from the IdP, the user should be logged out from all SPs:
@Configuration
class OAuth2LogoutConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.oauth2Login { }
.logout { logout ->
logout.logoutSuccessHandler(oidcLogoutSuccessHandler())
}
.build()
}
@Bean
fun oidcLogoutSuccessHandler(): LogoutSuccessHandler {
val handler = OidcClientInitiatedLogoutSuccessHandler(
clientRegistrationRepository
)
handler.setPostLogoutRedirectUri("{baseUrl}/")
return handler
}
}
6.2 Session Timeout Synchronization
@Component
class SessionExpirationChecker(
private val authorizedClientService: OAuth2AuthorizedClientService
) {
fun isTokenExpired(authentication: Authentication): Boolean {
if (authentication !is OAuth2AuthenticationToken) return false
val client = authorizedClientService.loadAuthorizedClient<OAuth2AuthorizedClient>(
authentication.authorizedClientRegistrationId,
authentication.name
)
val accessToken = client?.accessToken ?: return true
return accessToken.expiresAt?.isBefore(Instant.now()) ?: false
}
}
6.3 Automatic Token Renewal
@Configuration
class OAuth2ClientConfig {
@Bean
fun authorizedClientManager(
clientRegistrationRepository: ClientRegistrationRepository,
authorizedClientRepository: OAuth2AuthorizedClientRepository
): OAuth2AuthorizedClientManager {
val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken() // Refresh Token support
.build()
val authorizedClientManager = DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository,
authorizedClientRepository
)
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider)
return authorizedClientManager
}
}
7. Practical Tips and Troubleshooting
7.1 Common Issues
redirect_uri_mismatch Error
OAuth2 Error: redirect_uri_mismatch
Cause: Mismatch between the Redirect URI registered in the IdP and the request URI
Solution:
- Register the exact URI in the IdP settings:
http://localhost:8080/login/oauth2/code/keycloak - HTTPS is required in production
invalid_token Error
Cause: Clock skew (server time difference)
Solution:
spring:
security:
oauth2:
resourceserver:
jwt:
clock-skew: 60s # Allowed tolerance
7.2 Production Checklist
# Production settings
spring:
security:
oauth2:
client:
registration:
keycloak:
client-secret: ${KEYCLOAK_CLIENT_SECRET} # Use environment variables
redirect-uri: https://app.example.com/login/oauth2/code/keycloak # HTTPS
# Development settings (DO NOT use in production)
# client-secret: my-secret-123 # Hardcoded
# redirect-uri: http://localhost:8080/... # HTTP
7.3 Logging Configuration
Useful log settings for debugging issues:
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: TRACE
org.springframework.security.saml2: TRACE
7.4 Test Code
@WebMvcTest(UserController::class)
class UserControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
@WithMockUser // Basic authentication mocking
fun `authenticated user can access API`() {
mockMvc.get("/api/me")
.andExpect { status { isOk() } }
}
@Test
fun `unauthenticated user is redirected`() {
mockMvc.get("/api/me")
.andExpect { status { is3xxRedirection() } }
}
@Test
@WithMockOidcUser(
claims = [
OidcIdTokenClaim(name = "sub", value = "user-123"),
OidcIdTokenClaim(name = "email", value = "test@example.com")
]
)
fun `retrieve OIDC user information`() {
mockMvc.get("/api/me")
.andExpect {
status { isOk() }
jsonPath("$.email") { value("test@example.com") }
}
}
}
8. FAQ
Q1. What is the difference between OAuth2 and OIDC?
OAuth2 is an authorization framework that deals with “Is this app allowed to access user data?”
OIDC (OpenID Connect) adds an authentication layer on top of OAuth2, dealing with “Who is this user?” It provides user information through an ID Token (JWT).
In practice: Use OIDC. Spring Security’s oauth2Login() supports OIDC by default.
Q2. What is the difference between Access Token and ID Token?
| Item | Access Token | ID Token |
|---|---|---|
| Purpose | Proves API access authorization | Proves user identity |
| Audience | Resource Server (API) | Client Application |
| Included Information | scope, permissions | User info (sub, email, name) |
| Sent To | External APIs | Used only by the client |
Q3. Where should I store the Client Secret?
Recommended order:
- Vault/AWS Secrets Manager - Most secure
- Environment variables - Injected via CI/CD
- Encrypted configuration files - Using tools like jasypt
Strictly prohibited: Storing in plain text in a Git repository
Q4. How do I link SSO with an existing user table?
@Service
class CustomOidcUserService(
private val userRepository: UserRepository
) : OidcUserService() {
override fun loadUser(userRequest: OidcUserRequest): OidcUser {
val oidcUser = super.loadUser(userRequest)
// Unique ID from the IdP (sub claim)
val providerId = oidcUser.subject
val email = oidcUser.email
// Look up existing user or create a new one
val user = userRepository.findByProviderId(providerId)
?: userRepository.save(
User(
providerId = providerId,
email = email,
name = oidcUser.fullName
)
)
// Return OidcUser with custom information
return CustomOidcUser(oidcUser, user)
}
}
Q5. How can I test without an IdP during local development?
Method 1: Mock the IdP with WireMock
Method 2: Profile-based configuration
@Configuration
@Profile("local")
class LocalSecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
return http
.authorizeHttpRequests { it.anyRequest().permitAll() }
.build()
}
}
Method 3: Inject a test user
@Component
@Profile("local")
class DevUserInjector : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val devUser = // Create Mock User
SecurityContextHolder.getContext().authentication = devUser
filterChain.doFilter(request, response)
}
}
Summary
| Scenario | Recommended Approach |
|---|---|
| New project | OAuth2/OIDC |
| Legacy IdP integration | SAML 2.0 |
| Building your own IdP | Keycloak |
| Using a SaaS IdP | Okta, Auth0, Azure AD |
| Social login | Spring OAuth2 Client + Google/GitHub |
Key Points:
- Setting only
issuer-uriauto-configures most settings - Manage Client Secret with environment variables
- Always use HTTPS in production
- Connect IdP roles to Spring Security authorities through role mapping
- Strengthen security by implementing Single Logout