diff --git a/README.md b/README.md
index 06ab721..91a7de4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# spring-auth-demo
-Runnable companion code for four articles on [ankurm.com](https://ankurm.com):
+Runnable companion code for five articles on [ankurm.com](https://ankurm.com):
| | article | code |
|---|---|---|
@@ -8,8 +8,9 @@ Runnable companion code for four articles on [ankurm.com](https://ankurm.com):
| 2 | [Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation](https://ankurm.com/spring-security-oauth2-resource-server-jwks-key-rotation/) | [`oauth2-resource-server/`](oauth2-resource-server) |
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
| 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) |
+| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
-Four Maven projects, one shared [`docs/`](docs) tree. The first mints and verifies its own
+Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
a real Keycloak, and a stub issuer whose signing keys can be rotated on command. The third
*is* the somebody else: a real OAuth2 / OIDC provider, with a client and a resource server
diff --git a/keycloak/.gitignore b/keycloak/.gitignore
new file mode 100644
index 0000000..14ac615
--- /dev/null
+++ b/keycloak/.gitignore
@@ -0,0 +1,2 @@
+target/
+.kc/
diff --git a/keycloak/README.md b/keycloak/README.md
new file mode 100644
index 0000000..97665e6
--- /dev/null
+++ b/keycloak/README.md
@@ -0,0 +1,28 @@
+# keycloak
+
+Companion code for **[Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/)**.
+
+Spring Boot 4.1.1, Spring Security 7.1.1, Keycloak server 26.8.1, `keycloak-admin-client` 26.0.12, JDK 25.
+
+| Piece | Code |
+|---|---|
+| Realm with roles, clients, users and a service account | `realm/demo-realm.json` |
+| Resource server, audience check, Keycloak role mapping | `application.yml`, `SecurityConfig`, `KeycloakJwtConverter` |
+| Method security | `ApiController`, `UserAdminService` (`@PreAuthorize`) |
+| Admin API through `keycloak-admin-client` | `UserAdminService` |
+| Tests against a real Keycloak | `KeycloakIntegrationTest` (skipped when nothing listens on `keycloak.url`) |
+| Same realm in a container | `KeycloakTestcontainersTest` (needs Docker) |
+
+```bash
+./scripts/run-all.sh # downloads Keycloak from Maven Central, imports the realm, runs the tests, writes cap/*.txt
+```
+
+## What was and was not run
+
+- Everything in `cap/` came from a real Keycloak 26.8.1 (`kc.sh start-dev`, unpacked from the Maven Central
+ distribution) and the real Spring application. This machine has no Docker.
+- `KeycloakTestcontainersTest` compiles and is skipped without Docker. It was **not** run, so the Testcontainers path
+ is untested here.
+- `start-dev` is for development only: HTTP, an embedded H2 database, relaxed hostname checks. Production Keycloak
+ needs `kc.sh start` with TLS, a real database and a configured hostname.
+- The users' passwords (`password`) and the `admin-svc` client secret in the realm file are demo values.
diff --git a/keycloak/cap/01-claims.txt b/keycloak/cap/01-claims.txt
new file mode 100644
index 0000000..a736649
--- /dev/null
+++ b/keycloak/cap/01-claims.txt
@@ -0,0 +1,16 @@
+user=alice client=demo-cli
+ iss="http://localhost:8180/realms/demo" azp="demo-cli" aud=(absent)
+ realm_access={"roles":["user"]} resource_access=(absent)
+ scope="profile email"
+user=alice client=demo-cli-aud
+ iss="http://localhost:8180/realms/demo" azp="demo-cli-aud" aud="spring-api"
+ realm_access={"roles":["user"]} resource_access=(absent)
+ scope="profile email"
+user=bob client=demo-cli
+ iss="http://localhost:8180/realms/demo" azp="demo-cli" aud="spring-api"
+ realm_access={"roles":["admin","user"]} resource_access={"spring-api":{"roles":["reports-write"]}}
+ scope="profile email"
+service account admin-svc (client credentials)
+ preferred_username="service-account-admin-svc" resource_access={"realm-management":{"roles":["view-realm","manage-users","view-users","query-groups","query-users"]}}
+same login through http://127.0.0.1:8180 instead of localhost
+ iss="http://127.0.0.1:8180/realms/demo"
diff --git a/keycloak/cap/02-authorities.txt b/keycloak/cap/02-authorities.txt
new file mode 100644
index 0000000..ca63ac3
--- /dev/null
+++ b/keycloak/cap/02-authorities.txt
@@ -0,0 +1,4 @@
+alice: Spring's default JwtGrantedAuthoritiesConverter -> [SCOPE_email, SCOPE_profile]
+alice: KeycloakJwtConverter roles -> [ROLE_user]
+bob: Spring's default JwtGrantedAuthoritiesConverter -> [SCOPE_email, SCOPE_profile]
+bob: KeycloakJwtConverter roles -> [ROLE_admin, ROLE_spring-api:reports-write, ROLE_user]
diff --git a/keycloak/cap/03-endpoints.txt b/keycloak/cap/03-endpoints.txt
new file mode 100644
index 0000000..7f4e62d
--- /dev/null
+++ b/keycloak/cap/03-endpoints.txt
@@ -0,0 +1,8 @@
+caller /api/me /api/reports /api/reports/export /api/admin/users
+no token 401 401 401 401
+garbage token 401 401 401 401
+alice, client with audience mapper 200 200 403 403
+bob, client with audience mapper 200 200 200 200
+alice, client WITHOUT audience mapper 401 401 401 401
+bob, client WITHOUT audience mapper 200 200 200 200
+alice, token issued via 127.0.0.1 401 401 401 401
diff --git a/keycloak/cap/04-admin-client.txt b/keycloak/cap/04-admin-client.txt
new file mode 100644
index 0000000..b72501c
--- /dev/null
+++ b/keycloak/cap/04-admin-client.txt
@@ -0,0 +1,8 @@
+GET /api/admin/users as bob -> ["alice","bob"]
+POST /api/admin/users {carol} as bob -> status=200 (id returned: true)
+GET /api/admin/users as bob -> ["alice","bob","carol"]
+carol logs in; realm_access={"roles":["offline_access","default-roles-demo","uma_authorization","user"]}
+carol -> /api/reports status=200, /api/admin/users status=403
+DELETE /api/admin/users/{id} as bob -> status=200
+GET /api/admin/users as bob -> ["alice","bob"]
+POST /api/admin/users as alice (no admin role) -> status=403
diff --git a/keycloak/cap/05-keycloak-startup.txt b/keycloak/cap/05-keycloak-startup.txt
new file mode 100644
index 0000000..209f885
--- /dev/null
+++ b/keycloak/cap/05-keycloak-startup.txt
@@ -0,0 +1,4 @@
+Keycloak distribution: keycloak-quarkus-dist-26.8.1 from Maven Central (start-dev, built-in H2 database)
+INFO [org.keycloak.services] (main) KC-SERVICES0030: Full model import requested. Strategy: IGNORE_EXISTING
+INFO [org.keycloak.exportimport.util.ImportUtils] (main) Realm 'demo' imported
+INFO [io.quarkus] (main) Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 30.899s. Listening on: http://localhost:8180
diff --git a/keycloak/pom.xml b/keycloak/pom.xml
new file mode 100644
index 0000000..a8352d8
--- /dev/null
+++ b/keycloak/pom.xml
@@ -0,0 +1,78 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ keycloak-demo
+ 1.0.0
+ keycloak-demo
+ Keycloak 26.8 with Spring Boot 4.1 / Spring Security 7.1: a resource server with realm and client
+ role mapping, audience validation, method security, and the Keycloak admin client.
+
+
+ 25
+ 26.0.12
+ 4.4.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-security-oauth2-resource-server
+
+
+
+ org.keycloak
+ keycloak-admin-client
+ ${keycloak-admin-client.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-security-test
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
+
+ com.github.dasniko
+ testcontainers-keycloak
+ ${testcontainers-keycloak.version}
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/keycloak/realm/demo-realm.json b/keycloak/realm/demo-realm.json
new file mode 100644
index 0000000..c4d9707
--- /dev/null
+++ b/keycloak/realm/demo-realm.json
@@ -0,0 +1,92 @@
+{
+ "realm": "demo",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "roles": {
+ "realm": [
+ { "name": "user", "description": "Can read reports" },
+ { "name": "admin", "description": "Can manage users through the admin API" }
+ ],
+ "client": {
+ "spring-api": [
+ { "name": "reports-write", "description": "Client role: can export reports" }
+ ]
+ }
+ },
+ "clients": [
+ {
+ "clientId": "spring-api",
+ "name": "Spring resource server",
+ "enabled": true,
+ "bearerOnly": true
+ },
+ {
+ "clientId": "demo-cli",
+ "name": "Public client WITHOUT an audience mapper",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true
+ },
+ {
+ "clientId": "demo-cli-aud",
+ "name": "Public client WITH an audience mapper for spring-api",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "protocolMappers": [
+ {
+ "name": "spring-api-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "config": {
+ "included.client.audience": "spring-api",
+ "id.token.claim": "false",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "admin-svc",
+ "name": "Service account used by the admin client",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "admin-svc-demo-secret",
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": true
+ }
+ ],
+ "users": [
+ {
+ "username": "alice",
+ "enabled": true,
+ "email": "alice@example.com",
+ "emailVerified": true,
+ "firstName": "Alice",
+ "lastName": "Anderson",
+ "credentials": [ { "type": "password", "value": "password", "temporary": false } ],
+ "realmRoles": [ "user" ]
+ },
+ {
+ "username": "bob",
+ "enabled": true,
+ "email": "bob@example.com",
+ "emailVerified": true,
+ "firstName": "Bob",
+ "lastName": "Brown",
+ "credentials": [ { "type": "password", "value": "password", "temporary": false } ],
+ "realmRoles": [ "user", "admin" ],
+ "clientRoles": { "spring-api": [ "reports-write" ] }
+ },
+ {
+ "username": "service-account-admin-svc",
+ "enabled": true,
+ "serviceAccountClientId": "admin-svc",
+ "clientRoles": { "realm-management": [ "manage-users", "view-users", "view-realm" ] }
+ }
+ ]
+}
diff --git a/keycloak/scripts/run-all.sh b/keycloak/scripts/run-all.sh
new file mode 100755
index 0000000..758b55f
--- /dev/null
+++ b/keycloak/scripts/run-all.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Downloads the real Keycloak distribution from Maven Central, imports realm/demo-realm.json, runs the tests against it
+# and writes the transcripts to cap/. No Docker needed. First run downloads about 170 MB.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+KC_VERSION=26.8.1
+KC_HOME=.kc/keycloak-$KC_VERSION
+mkdir -p cap .kc
+
+if [ ! -d "$KC_HOME" ]; then
+ curl -fsSL --retry 5 --retry-delay 15 -o .kc/keycloak.tar.gz \
+ "https://repo1.maven.org/maven2/org/keycloak/keycloak-quarkus-dist/$KC_VERSION/keycloak-quarkus-dist-$KC_VERSION.tar.gz"
+ tar -xzf .kc/keycloak.tar.gz -C .kc
+fi
+rm -rf "$KC_HOME/data"
+mkdir -p "$KC_HOME/data/import"
+cp realm/demo-realm.json "$KC_HOME/data/import/"
+
+KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
+ "$KC_HOME/bin/kc.sh" start-dev --http-port=8180 --import-realm > .kc/keycloak.log 2>&1 &
+KC_PID=$!
+trap 'kill $KC_PID 2>/dev/null || true; wait $KC_PID 2>/dev/null || true' EXIT
+
+for _ in $(seq 1 120); do
+ curl -s -o /dev/null http://localhost:8180/realms/demo/.well-known/openid-configuration && break
+ sleep 2
+done
+curl -fs -o /dev/null http://localhost:8180/realms/demo/.well-known/openid-configuration
+
+{
+ echo "Keycloak distribution: keycloak-quarkus-dist-$KC_VERSION from Maven Central (start-dev, built-in H2 database)"
+ grep -E "KC-SERVICES0030|Realm 'demo' imported|started in" .kc/keycloak.log | sed -E 's/^[0-9-]+ [0-9:,]+ //'
+} > cap/05-keycloak-startup.txt
+
+mvn -q -B test -Dkeycloak.url=http://localhost:8180
diff --git a/keycloak/src/main/java/com/ankurm/keycloak/ApiController.java b/keycloak/src/main/java/com/ankurm/keycloak/ApiController.java
new file mode 100644
index 0000000..ca2f9ae
--- /dev/null
+++ b/keycloak/src/main/java/com/ankurm/keycloak/ApiController.java
@@ -0,0 +1,62 @@
+package com.ankurm.keycloak;
+
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class ApiController {
+
+ public record NewUser(String username, String password) { }
+
+ private final UserAdminService admin;
+
+ public ApiController(UserAdminService admin) {
+ this.admin = admin;
+ }
+
+ @GetMapping("/api/me")
+ public Map me(@AuthenticationPrincipal Jwt jwt, org.springframework.security.core.Authentication auth) {
+ List authorities = auth.getAuthorities().stream().map(GrantedAuthority::getAuthority).sorted().toList();
+ return Map.of("username", auth.getName(), "authorities", authorities, "issuer", jwt.getIssuer().toString(),
+ "audience", jwt.getAudience());
+ }
+
+ @GetMapping("/api/reports")
+ @PreAuthorize("hasRole('user')")
+ public List reports() {
+ return List.of("q1", "q2");
+ }
+
+ /** Needs the CLIENT role reports-write of the spring-api client, not a realm role. */
+ @GetMapping("/api/reports/export")
+ @PreAuthorize("hasRole('spring-api:reports-write')")
+ public String export() {
+ return "exported";
+ }
+
+ @GetMapping("/api/admin/users")
+ public List users() {
+ return admin.usernames();
+ }
+
+ @PostMapping("/api/admin/users")
+ public Map create(@RequestBody NewUser user) {
+ return Map.of("id", admin.create(user.username(), user.password()));
+ }
+
+ @DeleteMapping("/api/admin/users/{id}")
+ public void delete(@PathVariable String id) {
+ admin.delete(id);
+ }
+}
diff --git a/keycloak/src/main/java/com/ankurm/keycloak/KeycloakApplication.java b/keycloak/src/main/java/com/ankurm/keycloak/KeycloakApplication.java
new file mode 100644
index 0000000..9e69f80
--- /dev/null
+++ b/keycloak/src/main/java/com/ankurm/keycloak/KeycloakApplication.java
@@ -0,0 +1,12 @@
+package com.ankurm.keycloak;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class KeycloakApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(KeycloakApplication.class, args);
+ }
+}
diff --git a/keycloak/src/main/java/com/ankurm/keycloak/KeycloakJwtConverter.java b/keycloak/src/main/java/com/ankurm/keycloak/KeycloakJwtConverter.java
new file mode 100644
index 0000000..63e35e2
--- /dev/null
+++ b/keycloak/src/main/java/com/ankurm/keycloak/KeycloakJwtConverter.java
@@ -0,0 +1,55 @@
+package com.ankurm.keycloak;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
+import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
+
+/**
+ * Keycloak puts roles in {@code realm_access.roles} and {@code resource_access..roles}. Spring Security's
+ * default converter only reads the {@code scope} claim, so without this class every role check fails.
+ *
+ * - realm role {@code admin} becomes {@code ROLE_admin}
+ * - client role {@code reports-write} of client {@code spring-api} becomes {@code ROLE_spring-api:reports-write}
+ * - scopes keep their default {@code SCOPE_} prefix
+ *
+ */
+public final class KeycloakJwtConverter {
+
+ private KeycloakJwtConverter() { }
+
+ public static JwtAuthenticationConverter create(String resourceClient) {
+ JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
+ Converter> all = jwt -> {
+ List authorities = new ArrayList<>(scopes.convert(jwt));
+ authorities.addAll(roles(jwt, resourceClient));
+ return authorities;
+ };
+ JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
+ converter.setJwtGrantedAuthoritiesConverter(all);
+ converter.setPrincipalClaimName("preferred_username");
+ return converter;
+ }
+
+ /** Just the role authorities; public so tests can show the difference from the default converter. */
+ public static List roles(Jwt jwt, String resourceClient) {
+ List out = new ArrayList<>();
+ Map realmAccess = jwt.getClaim("realm_access");
+ if (realmAccess != null && realmAccess.get("roles") instanceof Collection> realmRoles) {
+ realmRoles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + r)));
+ }
+ Map resourceAccess = jwt.getClaim("resource_access");
+ if (resourceAccess != null && resourceAccess.get(resourceClient) instanceof Map, ?> client
+ && client.get("roles") instanceof Collection> clientRoles) {
+ clientRoles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + resourceClient + ":" + r)));
+ }
+ return out;
+ }
+}
diff --git a/keycloak/src/main/java/com/ankurm/keycloak/SecurityConfig.java b/keycloak/src/main/java/com/ankurm/keycloak/SecurityConfig.java
new file mode 100644
index 0000000..54078cf
--- /dev/null
+++ b/keycloak/src/main/java/com/ankurm/keycloak/SecurityConfig.java
@@ -0,0 +1,34 @@
+package com.ankurm.keycloak;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+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.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+
+@Configuration
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ static final String RESOURCE_CLIENT = "spring-api";
+
+ @Bean
+ SecurityFilterChain api(HttpSecurity http) throws Exception {
+ http
+ .authorizeHttpRequests(a -> a
+ .requestMatchers("/api/admin/**").hasRole("admin")
+ .requestMatchers("/api/**").authenticated()
+ .anyRequest().denyAll())
+ .oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(KeycloakJwtConverter.create(RESOURCE_CLIENT))))
+ // Stateless bearer-token API: no session, so no CSRF cookie to protect.
+ .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .csrf(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .cors(Customizer.withDefaults());
+ return http.build();
+ }
+}
diff --git a/keycloak/src/main/java/com/ankurm/keycloak/UserAdminService.java b/keycloak/src/main/java/com/ankurm/keycloak/UserAdminService.java
new file mode 100644
index 0000000..c8fbef1
--- /dev/null
+++ b/keycloak/src/main/java/com/ankurm/keycloak/UserAdminService.java
@@ -0,0 +1,83 @@
+package com.ankurm.keycloak;
+
+import java.util.List;
+
+import jakarta.ws.rs.core.Response;
+
+import org.keycloak.OAuth2Constants;
+import org.keycloak.admin.client.CreatedResponseUtil;
+import org.keycloak.admin.client.Keycloak;
+import org.keycloak.admin.client.KeycloakBuilder;
+import org.keycloak.representations.idm.CredentialRepresentation;
+import org.keycloak.representations.idm.RoleRepresentation;
+import org.keycloak.representations.idm.UserRepresentation;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Service;
+
+/**
+ * Talks to the Keycloak Admin REST API as the {@code admin-svc} service account (client credentials grant).
+ * The caller's token is not forwarded: the service account has its own, narrower, realm-management roles.
+ * The method security below is the application's own check on who may trigger these calls.
+ */
+@Service
+public class UserAdminService implements AutoCloseable {
+
+ private final Keycloak keycloak;
+ private final String realm;
+
+ public UserAdminService(@Value("${keycloak.url}") String url, @Value("${keycloak.realm}") String realm,
+ @Value("${keycloak.admin.client-id}") String clientId, @Value("${keycloak.admin.client-secret}") String secret) {
+ this.realm = realm;
+ this.keycloak = KeycloakBuilder.builder()
+ .serverUrl(url)
+ .realm(realm)
+ .grantType(OAuth2Constants.CLIENT_CREDENTIALS)
+ .clientId(clientId)
+ .clientSecret(secret)
+ .build();
+ }
+
+ @PreAuthorize("hasRole('admin')")
+ public List usernames() {
+ return keycloak.realm(realm).users().list().stream().map(UserRepresentation::getUsername).sorted().toList();
+ }
+
+ @PreAuthorize("hasRole('admin')")
+ public String create(String username, String password) {
+ UserRepresentation user = new UserRepresentation();
+ user.setUsername(username);
+ user.setEmail(username + "@example.com");
+ user.setEmailVerified(true);
+ user.setFirstName(username);
+ user.setLastName("Created");
+ user.setEnabled(true);
+ String id;
+ try (Response response = keycloak.realm(realm).users().create(user)) {
+ id = CreatedResponseUtil.getCreatedId(response);
+ }
+ CredentialRepresentation credential = new CredentialRepresentation();
+ credential.setType(CredentialRepresentation.PASSWORD);
+ credential.setValue(password);
+ credential.setTemporary(false);
+ keycloak.realm(realm).users().get(id).resetPassword(credential);
+
+ RoleRepresentation role = keycloak.realm(realm).roles().get("user").toRepresentation();
+ keycloak.realm(realm).users().get(id).roles().realmLevel().add(List.of(role));
+ return id;
+ }
+
+ @PreAuthorize("hasRole('admin')")
+ public void delete(String id) {
+ try (Response response = keycloak.realm(realm).users().delete(id)) {
+ if (response.getStatus() != 204) {
+ throw new IllegalStateException("delete failed: HTTP " + response.getStatus());
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ keycloak.close();
+ }
+}
diff --git a/keycloak/src/main/resources/application.yml b/keycloak/src/main/resources/application.yml
new file mode 100644
index 0000000..df95816
--- /dev/null
+++ b/keycloak/src/main/resources/application.yml
@@ -0,0 +1,17 @@
+keycloak:
+ url: ${KEYCLOAK_URL:http://localhost:8180}
+ realm: demo
+ admin:
+ client-id: admin-svc
+ # The demo secret from realm/demo-realm.json. Real deployments read this from a secret store.
+ client-secret: ${KEYCLOAK_ADMIN_SECRET:admin-svc-demo-secret}
+
+spring:
+ security:
+ oauth2:
+ resourceserver:
+ jwt:
+ # The issuer must match the token's iss claim character for character, host included.
+ issuer-uri: ${keycloak.url}/realms/${keycloak.realm}
+ # Rejects tokens whose aud claim does not contain this value.
+ audiences: spring-api
diff --git a/keycloak/src/test/java/com/ankurm/keycloak/Kc.java b/keycloak/src/test/java/com/ankurm/keycloak/Kc.java
new file mode 100644
index 0000000..0aa6d5a
--- /dev/null
+++ b/keycloak/src/test/java/com/ankurm/keycloak/Kc.java
@@ -0,0 +1,98 @@
+package com.ankurm.keycloak;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.Base64;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.json.JsonMapper;
+
+/** Test helpers: get tokens from a running Keycloak, call the API, decode claims, write the transcript to cap/. */
+final class Kc {
+
+ static final Path DIR = Path.of("cap");
+ static final HttpClient HTTP = HttpClient.newHttpClient();
+ static final JsonMapper JSON = JsonMapper.builder().build();
+ static final String KEYCLOAK = System.getProperty("keycloak.url", "http://localhost:8180");
+
+ private Kc() { }
+
+ static boolean up() {
+ try {
+ return HTTP.send(HttpRequest.newBuilder(URI.create(KEYCLOAK + "/realms/demo/.well-known/openid-configuration")).build(),
+ HttpResponse.BodyHandlers.discarding()).statusCode() == 200;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ static void reset(String file) {
+ try {
+ Files.createDirectories(DIR);
+ Files.deleteIfExists(DIR.resolve(file));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static void say(String file, String line) {
+ System.out.println(line);
+ try {
+ Files.writeString(DIR.resolve(file), line + System.lineSeparator(), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ static String form(Map fields) {
+ return fields.entrySet().stream().map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8))
+ .collect(Collectors.joining("&"));
+ }
+
+ /** Password grant against the given base URL (host matters: it becomes the token's iss). */
+ static String token(String base, String client, String user, String password) throws Exception {
+ return tokenRequest(base, Map.of("grant_type", "password", "client_id", client, "username", user, "password", password));
+ }
+
+ static String serviceToken(String client, String secret) throws Exception {
+ return tokenRequest(KEYCLOAK, Map.of("grant_type", "client_credentials", "client_id", client, "client_secret", secret));
+ }
+
+ private static String tokenRequest(String base, Map fields) throws Exception {
+ HttpResponse r = HTTP.send(HttpRequest.newBuilder(URI.create(base + "/realms/demo/protocol/openid-connect/token"))
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .POST(HttpRequest.BodyPublishers.ofString(form(fields))).build(), HttpResponse.BodyHandlers.ofString());
+ if (r.statusCode() != 200) {
+ throw new IllegalStateException("token request failed: " + r.statusCode() + " " + r.body());
+ }
+ return JSON.readTree(r.body()).get("access_token").asString();
+ }
+
+ /** Payload of a JWT, decoded WITHOUT verifying it. For printing claims only. */
+ static JsonNode claims(String jwt) {
+ return JSON.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1]));
+ }
+
+ static HttpResponse call(String apiBase, String method, String path, String token, String body) throws Exception {
+ HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(apiBase + path));
+ if (token != null) {
+ b.header("Authorization", "Bearer " + token);
+ }
+ if (body != null) {
+ b.header("Content-Type", "application/json");
+ }
+ b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
+ return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
+ }
+}
diff --git a/keycloak/src/test/java/com/ankurm/keycloak/KeycloakIntegrationTest.java b/keycloak/src/test/java/com/ankurm/keycloak/KeycloakIntegrationTest.java
new file mode 100644
index 0000000..bd4388d
--- /dev/null
+++ b/keycloak/src/test/java/com/ankurm/keycloak/KeycloakIntegrationTest.java
@@ -0,0 +1,154 @@
+package com.ankurm.keycloak;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import tools.jackson.databind.JsonNode;
+
+/**
+ * Runs against a REAL Keycloak (started by scripts/run-all.sh, or by hand: see the README). Skipped, not failed,
+ * when none is listening on keycloak.url.
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class KeycloakIntegrationTest {
+
+ static final String PW = "password";
+
+ @LocalServerPort int port;
+ @Autowired JwtDecoder decoder;
+
+ @DynamicPropertySource
+ static void kc(DynamicPropertyRegistry r) {
+ r.add("keycloak.url", () -> Kc.KEYCLOAK);
+ }
+
+ @BeforeAll
+ static void clean() {
+ assumeTrue(Kc.up(), "no Keycloak at " + Kc.KEYCLOAK);
+ for (String f : List.of("01-claims.txt", "02-authorities.txt", "03-endpoints.txt", "04-admin-client.txt")) {
+ Kc.reset(f);
+ }
+ }
+
+ String api() {
+ return "http://localhost:" + port;
+ }
+
+ static String show(JsonNode n) {
+ return n == null || n.isMissingNode() || n.isNull() ? "(absent)" : n.toString();
+ }
+
+ @Test
+ @Order(1)
+ void whatKeycloakPutsInTheToken() throws Exception {
+ String f = "01-claims.txt";
+ for (String[] c : new String[][] {{"alice", "demo-cli"}, {"alice", "demo-cli-aud"}, {"bob", "demo-cli"}}) {
+ JsonNode claims = Kc.claims(Kc.token(Kc.KEYCLOAK, c[1], c[0], PW));
+ Kc.say(f, "user=" + c[0] + " client=" + c[1]);
+ Kc.say(f, " iss=" + show(claims.get("iss")) + " azp=" + show(claims.get("azp")) + " aud=" + show(claims.get("aud")));
+ Kc.say(f, " realm_access=" + show(claims.get("realm_access")) + " resource_access=" + show(claims.get("resource_access")));
+ Kc.say(f, " scope=" + show(claims.get("scope")));
+ }
+ JsonNode svc = Kc.claims(Kc.serviceToken("admin-svc", "admin-svc-demo-secret"));
+ Kc.say(f, "service account admin-svc (client credentials)");
+ Kc.say(f, " preferred_username=" + show(svc.get("preferred_username")) + " resource_access=" + show(svc.get("resource_access")));
+ String viaIp = Kc.token("http://127.0.0.1:8180", "demo-cli-aud", "alice", PW);
+ Kc.say(f, "same login through http://127.0.0.1:8180 instead of localhost");
+ Kc.say(f, " iss=" + show(Kc.claims(viaIp).get("iss")));
+ }
+
+ @Test
+ @Order(2)
+ void defaultConverterNeverSeesTheRoles() throws Exception {
+ String f = "02-authorities.txt";
+ JwtGrantedAuthoritiesConverter spring = new JwtGrantedAuthoritiesConverter();
+ for (String user : List.of("alice", "bob")) {
+ Jwt jwt = decoder.decode(Kc.token(Kc.KEYCLOAK, "demo-cli-aud", user, PW));
+ List byDefault = spring.convert(jwt).stream().map(GrantedAuthority::getAuthority).sorted().toList();
+ List ours = KeycloakJwtConverter.roles(jwt, "spring-api").stream().map(GrantedAuthority::getAuthority).sorted().toList();
+ Kc.say(f, user + ": Spring's default JwtGrantedAuthoritiesConverter -> " + byDefault);
+ Kc.say(f, user + ": KeycloakJwtConverter roles -> " + ours);
+ }
+ }
+
+ @Test
+ @Order(3)
+ void whoCanCallWhat() throws Exception {
+ String f = "03-endpoints.txt";
+ String aliceAud = Kc.token(Kc.KEYCLOAK, "demo-cli-aud", "alice", PW);
+ String bobAud = Kc.token(Kc.KEYCLOAK, "demo-cli-aud", "bob", PW);
+ String aliceNoAud = Kc.token(Kc.KEYCLOAK, "demo-cli", "alice", PW);
+ String bobNoMapper = Kc.token(Kc.KEYCLOAK, "demo-cli", "bob", PW);
+ String aliceViaIp = Kc.token("http://127.0.0.1:8180", "demo-cli-aud", "alice", PW);
+
+ record Case(String who, String token) { }
+ List cases = List.of(
+ new Case("no token", null),
+ new Case("garbage token", "not.a.jwt"),
+ new Case("alice, client with audience mapper", aliceAud),
+ new Case("bob, client with audience mapper", bobAud),
+ new Case("alice, client WITHOUT audience mapper", aliceNoAud),
+ new Case("bob, client WITHOUT audience mapper", bobNoMapper),
+ new Case("alice, token issued via 127.0.0.1", aliceViaIp));
+ List paths = List.of("/api/me", "/api/reports", "/api/reports/export", "/api/admin/users");
+ Kc.say(f, String.format("%-40s %s", "caller", String.join(" ", paths)));
+ List aliceRow = new ArrayList<>();
+ for (Case c : cases) {
+ StringBuilder row = new StringBuilder(String.format("%-40s", c.who()));
+ for (String p : paths) {
+ int status = Kc.call(api(), "GET", p, c.token(), null).statusCode();
+ row.append(String.format(" %" + Math.max(3, p.length()) + "d ", status));
+ if (c.who().startsWith("alice, client with")) {
+ aliceRow.add(status);
+ }
+ }
+ Kc.say(f, row.toString().stripTrailing());
+ }
+ assertThat(aliceRow).containsExactly(200, 200, 403, 403);
+ }
+
+ @Test
+ @Order(4)
+ void adminClientManagesUsers() throws Exception {
+ String f = "04-admin-client.txt";
+ String bob = Kc.token(Kc.KEYCLOAK, "demo-cli-aud", "bob", PW);
+ Kc.say(f, "GET /api/admin/users as bob -> " + Kc.call(api(), "GET", "/api/admin/users", bob, null).body());
+
+ var created = Kc.call(api(), "POST", "/api/admin/users", bob, "{\"username\":\"carol\",\"password\":\"carol-pw\"}");
+ String id = Kc.JSON.readTree(created.body()).get("id").asString();
+ Kc.say(f, "POST /api/admin/users {carol} as bob -> status=" + created.statusCode() + " (id returned: " + !id.isBlank() + ")");
+ Kc.say(f, "GET /api/admin/users as bob -> " + Kc.call(api(), "GET", "/api/admin/users", bob, null).body());
+
+ String carol = Kc.token(Kc.KEYCLOAK, "demo-cli-aud", "carol", "carol-pw");
+ Kc.say(f, "carol logs in; realm_access=" + show(Kc.claims(carol).get("realm_access")));
+ Kc.say(f, "carol -> /api/reports status=" + Kc.call(api(), "GET", "/api/reports", carol, null).statusCode()
+ + ", /api/admin/users status=" + Kc.call(api(), "GET", "/api/admin/users", carol, null).statusCode());
+
+ var deleted = Kc.call(api(), "DELETE", "/api/admin/users/" + id, bob, null);
+ Kc.say(f, "DELETE /api/admin/users/{id} as bob -> status=" + deleted.statusCode());
+ Kc.say(f, "GET /api/admin/users as bob -> " + Kc.call(api(), "GET", "/api/admin/users", bob, null).body());
+
+ String alice = Kc.token(Kc.KEYCLOAK, "demo-cli-aud", "alice", PW);
+ Kc.say(f, "POST /api/admin/users as alice (no admin role) -> status="
+ + Kc.call(api(), "POST", "/api/admin/users", alice, "{\"username\":\"mallory\",\"password\":\"x\"}").statusCode());
+ }
+}
diff --git a/keycloak/src/test/java/com/ankurm/keycloak/KeycloakTestcontainersTest.java b/keycloak/src/test/java/com/ankurm/keycloak/KeycloakTestcontainersTest.java
new file mode 100644
index 0000000..ef03132
--- /dev/null
+++ b/keycloak/src/test/java/com/ankurm/keycloak/KeycloakTestcontainersTest.java
@@ -0,0 +1,34 @@
+package com.ankurm.keycloak;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+
+import org.junit.jupiter.api.Test;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import dasniko.testcontainers.keycloak.KeycloakContainer;
+
+/**
+ * The same realm in a Keycloak container. It compiles and is skipped when Docker is not available. It was NOT
+ * run while writing the article, because the build machine had no Docker; the real-Keycloak run used the
+ * unpacked distribution instead (scripts/run-all.sh).
+ */
+@Testcontainers(disabledWithoutDocker = true)
+class KeycloakTestcontainersTest {
+
+ @Container
+ static final KeycloakContainer KEYCLOAK = new KeycloakContainer("quay.io/keycloak/keycloak:26.8.1")
+ .withRealmImportFile("demo-realm.json");
+
+ @Test
+ void realmIsImportedAndServesDiscovery() throws Exception {
+ HttpResponse r = Kc.HTTP.send(HttpRequest.newBuilder(URI.create(
+ KEYCLOAK.getAuthServerUrl() + "/realms/demo/.well-known/openid-configuration")).build(), HttpResponse.BodyHandlers.ofString());
+ assertThat(r.statusCode()).isEqualTo(200);
+ assertThat(r.body()).contains("/realms/demo");
+ }
+}
diff --git a/keycloak/src/test/resources/demo-realm.json b/keycloak/src/test/resources/demo-realm.json
new file mode 100644
index 0000000..c4d9707
--- /dev/null
+++ b/keycloak/src/test/resources/demo-realm.json
@@ -0,0 +1,92 @@
+{
+ "realm": "demo",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "roles": {
+ "realm": [
+ { "name": "user", "description": "Can read reports" },
+ { "name": "admin", "description": "Can manage users through the admin API" }
+ ],
+ "client": {
+ "spring-api": [
+ { "name": "reports-write", "description": "Client role: can export reports" }
+ ]
+ }
+ },
+ "clients": [
+ {
+ "clientId": "spring-api",
+ "name": "Spring resource server",
+ "enabled": true,
+ "bearerOnly": true
+ },
+ {
+ "clientId": "demo-cli",
+ "name": "Public client WITHOUT an audience mapper",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true
+ },
+ {
+ "clientId": "demo-cli-aud",
+ "name": "Public client WITH an audience mapper for spring-api",
+ "enabled": true,
+ "publicClient": true,
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": true,
+ "protocolMappers": [
+ {
+ "name": "spring-api-audience",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-mapper",
+ "config": {
+ "included.client.audience": "spring-api",
+ "id.token.claim": "false",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ },
+ {
+ "clientId": "admin-svc",
+ "name": "Service account used by the admin client",
+ "enabled": true,
+ "publicClient": false,
+ "secret": "admin-svc-demo-secret",
+ "standardFlowEnabled": false,
+ "directAccessGrantsEnabled": false,
+ "serviceAccountsEnabled": true
+ }
+ ],
+ "users": [
+ {
+ "username": "alice",
+ "enabled": true,
+ "email": "alice@example.com",
+ "emailVerified": true,
+ "firstName": "Alice",
+ "lastName": "Anderson",
+ "credentials": [ { "type": "password", "value": "password", "temporary": false } ],
+ "realmRoles": [ "user" ]
+ },
+ {
+ "username": "bob",
+ "enabled": true,
+ "email": "bob@example.com",
+ "emailVerified": true,
+ "firstName": "Bob",
+ "lastName": "Brown",
+ "credentials": [ { "type": "password", "value": "password", "temporary": false } ],
+ "realmRoles": [ "user", "admin" ],
+ "clientRoles": { "spring-api": [ "reports-write" ] }
+ },
+ {
+ "username": "service-account-admin-svc",
+ "enabled": true,
+ "serviceAccountClientId": "admin-svc",
+ "clientRoles": { "realm-management": [ "manage-users", "view-users", "view-realm" ] }
+ }
+ ]
+}