From 6b09fe7b82843cbd693562a2bd2849175aeda840 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 15:50:36 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q --- README.md | 5 +- keycloak/.gitignore | 2 + keycloak/README.md | 28 ++++ keycloak/cap/01-claims.txt | 16 ++ keycloak/cap/02-authorities.txt | 4 + keycloak/cap/03-endpoints.txt | 8 + keycloak/cap/04-admin-client.txt | 8 + keycloak/cap/05-keycloak-startup.txt | 4 + keycloak/pom.xml | 78 +++++++++ keycloak/realm/demo-realm.json | 92 +++++++++++ keycloak/scripts/run-all.sh | 35 ++++ .../com/ankurm/keycloak/ApiController.java | 62 +++++++ .../ankurm/keycloak/KeycloakApplication.java | 12 ++ .../ankurm/keycloak/KeycloakJwtConverter.java | 55 +++++++ .../com/ankurm/keycloak/SecurityConfig.java | 34 ++++ .../com/ankurm/keycloak/UserAdminService.java | 83 ++++++++++ keycloak/src/main/resources/application.yml | 17 ++ .../src/test/java/com/ankurm/keycloak/Kc.java | 98 +++++++++++ .../keycloak/KeycloakIntegrationTest.java | 154 ++++++++++++++++++ .../keycloak/KeycloakTestcontainersTest.java | 34 ++++ keycloak/src/test/resources/demo-realm.json | 92 +++++++++++ 21 files changed, 919 insertions(+), 2 deletions(-) create mode 100644 keycloak/.gitignore create mode 100644 keycloak/README.md create mode 100644 keycloak/cap/01-claims.txt create mode 100644 keycloak/cap/02-authorities.txt create mode 100644 keycloak/cap/03-endpoints.txt create mode 100644 keycloak/cap/04-admin-client.txt create mode 100644 keycloak/cap/05-keycloak-startup.txt create mode 100644 keycloak/pom.xml create mode 100644 keycloak/realm/demo-realm.json create mode 100755 keycloak/scripts/run-all.sh create mode 100644 keycloak/src/main/java/com/ankurm/keycloak/ApiController.java create mode 100644 keycloak/src/main/java/com/ankurm/keycloak/KeycloakApplication.java create mode 100644 keycloak/src/main/java/com/ankurm/keycloak/KeycloakJwtConverter.java create mode 100644 keycloak/src/main/java/com/ankurm/keycloak/SecurityConfig.java create mode 100644 keycloak/src/main/java/com/ankurm/keycloak/UserAdminService.java create mode 100644 keycloak/src/main/resources/application.yml create mode 100644 keycloak/src/test/java/com/ankurm/keycloak/Kc.java create mode 100644 keycloak/src/test/java/com/ankurm/keycloak/KeycloakIntegrationTest.java create mode 100644 keycloak/src/test/java/com/ankurm/keycloak/KeycloakTestcontainersTest.java create mode 100644 keycloak/src/test/resources/demo-realm.json 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" ] } + } + ] +}