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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
.kc/
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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" ] }
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+35
@@ -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" ] }
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user