# OAuth with Spring Security

**GitHub:** [github.com/mnafshin/oauth](https://github.com/mnafshin/oauth)

* * *

## What we are building

A minimal **server-rendered** Spring Boot web app that:

1.  Redirects unauthenticated users to Keycloak
    
2.  Completes Authorization Code + PKCE (Spring handles PKCE automatically)
    
3.  Establishes a session and shows the logged-in user in Thymeleaf templates
    
4.  Enforces business authorization on `/dashboard` via Keycloak client roles and `@PreAuthorize`
    

This uses the **OAuth 2.0 Login** support in Spring Security — the right default when your UI is rendered on the server (HTML forms, CSRF tokens, same-origin session cookies).

> **Building a React or Angular SPA?** That is a different integration pattern — tokens stay on a backend-for-frontend (BFF). This post keeps Thymeleaf deliberately simple so you can compare both approaches in the same repo.

**Repo:** [oauth-demo](https://github.com/mnafshin/oauth) → `modules/spring-boot`

* * *

## Prerequisites

*   Java 21+ (the demo repo targets **Java 25** with **Spring Boot 4.1** — any 21+ JDK works)
    
*   Docker (for local Keycloak) — full Keycloak setup is covered in the Keycloak integration post
    
*   Keycloak realm with a client configured (we use `orders-web`) and a client role for app authorization (we use `app-user`)
    

* * *

## Dependencies

Add to `modules/spring-boot/build.gradle`:

```gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'
    id 'io.spring.dependency-management' version '1.1.7'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testImplementation 'org.springframework.security:spring-security-test'
}
```

*   `spring-boot-starter-oauth2-client` — OAuth2/OIDC client support, PKCE, JWT parsing for user info
    
*   `spring-boot-starter-thymeleaf` — server-rendered views for `/` and `/dashboard`
    

* * *

## Configuration

`src/main/resources/application.properties`:

```properties
spring.application.name=oauth-demo

# Keycloak OIDC (see the Keycloak integration post for realm setup)
spring.security.oauth2.client.registration.keycloak.client-id=orders-web
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_CLIENT_SECRET:orders-web-secret}
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo
```

**Important:** `issuer-uri` triggers auto-discovery of authorize, token, and JWKS endpoints from:

```text
http://localhost:8081/realms/oauth-demo/.well-known/openid-configuration
```

Never hard-code those URLs when Spring can discover them.

* * *

## Security filter chain

`SecurityConfig` wires OAuth2 login, OIDC logout, method security, and a mapper that turns Keycloak client roles into Spring authorities. Application roles belong on the client (`orders-web`), not the realm — Keycloak puts them in `resource_access.orders-web.roles` on the ID token.

```java
package info.mnafshin.oauth_demo;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, LogoutSuccessHandler oidcLogoutSuccessHandler)
            throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .defaultSuccessUrl("/dashboard", true)
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(keycloakRolesMapper())
                )
            )
            .logout(logout -> logout
                .logoutSuccessHandler(oidcLogoutSuccessHandler)
            );
        return http.build();
    }

    @Bean
    LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) {
        OidcClientInitiatedLogoutSuccessHandler handler =
                new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
        handler.setPostLogoutRedirectUri("{baseUrl}/");
        return handler;
    }

    @Bean
    GrantedAuthoritiesMapper keycloakRolesMapper() {
        return authorities -> authorities.stream()
            .flatMap(authority -> {
                if (authority instanceof OidcUserAuthority oidc) {
                    @SuppressWarnings("unchecked")
                    Map<String, Object> resourceAccess = oidc.getIdToken().getClaim("resource_access");
                    if (resourceAccess == null) {
                        return Stream.of(authority);
                    }
                    @SuppressWarnings("unchecked")
                    Map<String, Object> clientAccess =
                            (Map<String, Object>) resourceAccess.get("orders-web");
                    if (clientAccess == null) {
                        return Stream.of(authority);
                    }
                    @SuppressWarnings("unchecked")
                    List<String> roles = (List<String>) clientAccess.get("roles");
                    if (roles == null) {
                        return Stream.of(authority);
                    }
                    return roles.stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role));
                }
                return Stream.of(authority);
            })
            .collect(Collectors.toSet());
    }
}
```

What Spring does for you:

*   Generates PKCE `code_verifier` / `code_challenge`
    
*   Sends `state` and validates it on callback
    
*   Exchanges `code` at `/token` with client secret (confidential client)
    
*   Builds an `OAuth2AuthenticationToken` with user attributes from ID token / userinfo
    

* * *

## Controller

`@PreAuthorize` on `/dashboard` checks the mapped client role. The imported realm assigns `app-user` on `orders-web` to `alice` (see the Keycloak post).

```java
package info.mnafshin.oauth_demo;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {

    @GetMapping("/")
    public String home() {
        return "home";
    }

    @GetMapping("/dashboard")
    @PreAuthorize("hasRole('app-user')")
    public String dashboard(@AuthenticationPrincipal OAuth2User principal, Model model) {
        model.addAttribute("name", principal.getAttribute("preferred_username"));
        model.addAttribute("email", principal.getAttribute("email"));
        return "dashboard";
    }
}
```

For REST APIs (no server-side session), use `oauth2ResourceServer` with JWT instead — same series, different starter.

* * *

## Templates

The controller returns view names; add Thymeleaf HTML under `src/main/resources/templates/`.

`home.html`:

```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>OAuth Demo</title>
</head>
<body>
    <h1>OAuth Demo</h1>
    <p>A minimal Spring Security OAuth 2.0 login with Keycloak.</p>
    <p><a th:href="@{/dashboard}">Go to dashboard</a></p>
</body>
</html>
```

`dashboard.html` — include a CSRF token on the logout form (Spring Security enables CSRF by default):

```html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Dashboard</title>
</head>
<body>
    <h1>Dashboard</h1>
    <p>Hello, <span th:text="${name}">user</span>!</p>
    <p>Email: <span th:text="${email}">email</span></p>
    <form th:action="@{/logout}" method="post">
        <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
        <button type="submit">Logout</button>
    </form>
</body>
</html>
```

Without the hidden `_csrf` field, POST `/logout` returns **403 Forbidden**. With the OIDC logout handler below, logout also ends the Keycloak SSO session and returns the browser to `/`.

* * *

## What Spring Security does **not** do

Even with `oauth2Login` working:

| Still your job | Why |
| --- | --- |
| Map roles from token claims | Spring loads authorities from `resource_access` only if you add a mapper (shown above) |
| Protect downstream APIs | Resource servers need separate JWT validation |
| Store refresh tokens safely | Configure `OAuth2AuthorizedClientService` / JDBC for persistence |
| Logout from Keycloak | Use `OidcClientInitiatedLogoutSuccessHandler` + valid post-logout redirect URI on the client |
| Business authorization rules | You define which roles/scopes guard which endpoints (`@PreAuthorize`, etc.) |

See the identity providers post — the IdP is not the complete solution.

* * *

## Run locally

From the repo root:

```bash
# Start Keycloak with imported oauth-demo realm (see Keycloak post)
make start-keycloak
# or: docker compose -f modules/keycloak/docker-compose.yml up -d

export KEYCLOAK_CLIENT_SECRET=orders-web-secret
make start-spring-boot
# or: ./gradlew :spring-boot:bootRun
```

Each module also has its own `Makefile` — run `make start` from `modules/spring-boot/`.

1.  Open `http://localhost:8080/dashboard`
    
2.  Redirect to Keycloak login (user `alice` / `password` if using the imported realm)
    
3.  After login, land on dashboard with username and email — `alice` has the `orders-web` client role `app-user` required by `@PreAuthorize`
    

* * *

## Go deeper

### OAuth2 Authorized Client for calling APIs

To call a protected API with the user's access token:

```java
@GetMapping("/api/proxy/orders")
OAuth2AuthorizedClient authorizedClient(
    @RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client) {
    String accessToken = client.getAccessToken().getTokenValue();
    // Use WebClient with Bearer header
}
```

Register an `OAuth2AuthorizedClientManager` bean (Spring Security 6+ — `@EnableOAuth2Client` was removed).

### Multiple IdPs

Add another `registration` (e.g. `google`, `okta`) — each gets `/login/oauth2/code/{registrationId}`.

### Testing

MockMvc + `oauth2Login()` from `spring-security-test` exercises controllers without a live IdP. Because `/dashboard` uses `@PreAuthorize("hasRole('app-user')")`, tests must supply `ROLE_app-user` — login alone is not enough:

```java
@SpringBootTest
@AutoConfigureMockMvc
class HomeControllerTest {

    @Autowired MockMvc mockMvc;

    @Test
    void dashboardRequiresAuthentication() throws Exception {
        mockMvc.perform(get("/dashboard"))
            .andExpect(status().is3xxRedirection());
    }

    @Test
    void dashboardRequiresAppUserRole() throws Exception {
        mockMvc.perform(get("/dashboard")
                .with(oauth2Login()
                    .attributes(attrs -> attrs.put("preferred_username", "testuser"))))
            .andExpect(status().isForbidden());
    }

    @Test
    void dashboardShowsAuthenticatedUser() throws Exception {
        mockMvc.perform(get("/dashboard")
                .with(oauth2Login()
                    .authorities(new SimpleGrantedAuthority("ROLE_app-user"))
                    .attributes(attrs -> {
                        attrs.put("preferred_username", "testuser");
                        attrs.put("email", "test@example.com");
                    })))
            .andExpect(status().isOk())
            .andExpect(content().string(containsString("testuser")));
    }

    @Test
    void logoutWithoutCsrfTokenIsRejected() throws Exception {
        mockMvc.perform(post("/logout").with(oauth2Login()))
            .andExpect(status().isForbidden());
    }
}
```

For end-to-end login against Keycloak, use Testcontainers or the docker-compose setup from the Keycloak post.

* * *

## What you should know after this post

*   \[ \] You can configure `issuer-uri` and a Keycloak client in Spring Boot
    
*   \[ \] You know what Spring auto-handles (PKCE, state, code exchange)
    
*   \[ \] You can map Keycloak client roles to Spring authorities and guard endpoints with `@PreAuthorize`
    
*   \[ \] You know what remains manual (API protection as resource server, refresh token storage)
