Add keycloak project: realm import, resource server with realm and client role mapping, audience and issuer traps, method security, admin client

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q
This commit is contained in:
Claude
2026-10-17 15:50:36 +00:00
parent f6dd692177
commit 6b09fe7b82
21 changed files with 919 additions and 2 deletions
+3 -2
View File
@@ -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 &mdash;
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
+2
View File
@@ -0,0 +1,2 @@
target/
.kc/
+28
View File
@@ -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.
+16
View File
@@ -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"
+4
View File
@@ -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]
+8
View File
@@ -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
+8
View File
@@ -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
+4
View File
@@ -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
+78
View File
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>keycloak-demo</artifactId>
<version>1.0.0</version>
<name>keycloak-demo</name>
<description>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.</description>
<properties>
<java.version>25</java.version>
<keycloak-admin-client.version>26.0.12</keycloak-admin-client.version>
<testcontainers-keycloak.version>4.4.0</testcontainers-keycloak.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
<version>${keycloak-admin-client.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>${testcontainers-keycloak.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+92
View File
@@ -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": "[email protected]",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Anderson",
"credentials": [ { "type": "password", "value": "password", "temporary": false } ],
"realmRoles": [ "user" ]
},
{
"username": "bob",
"enabled": true,
"email": "[email protected]",
"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" ] }
}
]
}
+35
View File
@@ -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
@@ -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<String, Object> me(@AuthenticationPrincipal Jwt jwt, org.springframework.security.core.Authentication auth) {
List<String> 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<String> 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<String> users() {
return admin.usernames();
}
@PostMapping("/api/admin/users")
public Map<String, String> 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);
}
}
@@ -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);
}
}
@@ -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.<client>.roles}. Spring Security's
* default converter only reads the {@code scope} claim, so without this class every role check fails.
* <ul>
* <li>realm role {@code admin} becomes {@code ROLE_admin}</li>
* <li>client role {@code reports-write} of client {@code spring-api} becomes {@code ROLE_spring-api:reports-write}</li>
* <li>scopes keep their default {@code SCOPE_} prefix</li>
* </ul>
*/
public final class KeycloakJwtConverter {
private KeycloakJwtConverter() { }
public static JwtAuthenticationConverter create(String resourceClient) {
JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();
Converter<Jwt, Collection<GrantedAuthority>> all = jwt -> {
List<GrantedAuthority> 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<GrantedAuthority> roles(Jwt jwt, String resourceClient) {
List<GrantedAuthority> out = new ArrayList<>();
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> realmRoles) {
realmRoles.forEach(r -> out.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
Map<String, Object> 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;
}
}
@@ -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();
}
}
@@ -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<String> 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();
}
}
@@ -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
@@ -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<String, String> 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<String, String> fields) throws Exception {
HttpResponse<String> 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<String> 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());
}
}
@@ -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<String> byDefault = spring.convert(jwt).stream().map(GrantedAuthority::getAuthority).sorted().toList();
List<String> 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<Case> 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<String> 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<Integer> 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());
}
}
@@ -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<String> 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");
}
}
@@ -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": "[email protected]",
"emailVerified": true,
"firstName": "Alice",
"lastName": "Anderson",
"credentials": [ { "type": "password", "value": "password", "temporary": false } ],
"realmRoles": [ "user" ]
},
{
"username": "bob",
"enabled": true,
"email": "[email protected]",
"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" ] }
}
]
}