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
@@ -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" ] }
}
]
}