Summer Security — APISIX, Keycloak & Roles
Gate: Verify summer-core loaded and io.f8a.summer:summer-platform in build.gradle.
Modules: summer-security-autoconfigure | summer-keycloak | summer-apisix-resource-server | summer-jwt-resource-server | summer-apikey-resource-server
Tracks LATEST stable schema (0.3.4). Older versions: load matching overlay from references/versions/. 0.3.5: no security changes.
APISIX Auth Integration
APISIX gateway validates JWT, forwards X-Userinfo header (Base64 JSON) decoded into Member.
@Configuration
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
final ReactiveSummerHttpSecurityCustomizer summerCustomizer;
final ReactiveApisixCustomizer apisixCustomizer;
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
summerCustomizer.customize(http); // CORS all, CSRF off, stateless
apisixCustomizer.customize(http); // X-Userinfo auth
return http.authorizeExchange(auth -> auth
.pathMatchers("/actuator/**").permitAll()
.anyExchange().authenticated()).build();
}
}
Enforce with @PreAuthorize("hasAnyRole(@roles.USER_VIEW)").
@AuthRoles — Role Definitions
Role string format: service-name:resource:action (e.g., my-svc:user:view).
7 mandatory actions — EVERY resource MUST define all 7:
| Action | Constant Suffix | Description |
|---|---|---|
view |
_VIEW |
Xem danh sách / chi tiết |
create |
_CREATE |
Tạo mới |
update |
_UPDATE |
Cập nhật |
delete |
_DELETE |
Xóa |
approve |
_APPROVE |
Phê duyệt |
import |
_IMPORT |
Nhập dữ liệu |
export |
_EXPORT |
Xuất dữ liệu |
FeatureDef / ResourceDef name — ALWAYS Vietnamese. code stays English kebab-case.
@AuthRoles(resources = {
@ResourceDef(code = "my-svc", name = "Dịch vụ của tôi",
attributes = @AttributeDef(key = "tier", value = "premium"),
features = {
@FeatureDef(code = "user-mgmt", name = "Quản lý người dùng"),
@FeatureDef(code = "order-mgmt", name = "Quản lý đơn hàng")
})
})
@Component
public class Roles {
// user-mgmt: all 7 actions
public static final String USER_VIEW = "my-svc:user:view";
public static final String USER_CREATE = "my-svc:user:create";
public static final String USER_UPDATE = "my-svc:user:update";
public static final String USER_DELETE = "my-svc:user:delete";
public static final String USER_APPROVE = "my-svc:user:approve";
public static final String USER_IMPORT = "my-svc:user:import";
public static final String USER_EXPORT = "my-svc:user:export";
// order-mgmt: all 7 actions (use the same pattern)
}
Annotations (summer-core): @AuthRoles (resources), @ResourceDef (code, name, description, attributes, features), @AttributeDef (key, value), @FeatureDef (code, name, description).
Configuration — Multi-realm (0.3.x canon)
f8a:
security:
apisix:
resource-server:
enabled: true
role-hierarchy: "ROLE_ADMIN > ROLE_USER"
blacklist-prefix-key: "auth-blacklist" # 0.3.0+: enable JWT blacklist
sync-role: backoffice # 0.3.2+: provider id, not boolean
providers:
backoffice: # provider id = scopeKey for caches
server-url: ${KC_INTERNAL_URL} # Admin API base URL
realm: ewallet_backoffice
# issuer-uri: <override> # 0.3.3+: optional, defaults to serverUrl + "/realms/" + realm
client-id: ${KC_CLIENT_ID}
client-secret: ${KC_CLIENT_SECRET}
group-role-authorization: true # 0.3.2+: per-provider opt-in
partner:
server-url: https://kc.partner.com
realm: tenant1
# No client-id/secret needed — partner only does inbound auth, no admin calls
group-role-authorization: # global cache config (0.3.2+)
claim-name: role_groups
l1:
ttl: 60s
l2: # omit entire block = L1-only
ttl: 5m
invalidation-channel: "group-role-changes"
key-prefix: "auth-group-role:"
Provider rules (0.3.x)
providers.<id>declares issuer. Map key (e.g.backoffice) becomes scope key for group-role caches and invalidation payloads.serverUrl + "/realms/" + realmcomputes JWTissclaim. Setissuer-uri(0.3.3+) when public token issuer differs from Admin API hostname (reverse proxy, internal/external split).- Admin credentials (
client-id+client-secret) required only when provider issync-roletarget or hasgroup-role-authorization: true. Pure inbound-auth providers skip them. - Startup fails fast (
IllegalStateException) on violations.
JWT Blacklist (0.3.0+)
Enable revocation by setting blacklist-prefix-key. Each request runs EXISTS <prefix>:<jti> against Redis; presence → 401. Fail-open on Redis error.
f8a.security.apisix.resource-server:
blacklist-prefix-key: "auth-blacklist"
Publish a revocation:
SET auth-blacklist:<jti> 1 EX <remaining-token-seconds>
Override by providing your own JwtBlacklistChecker bean (@ConditionalOnMissingBean) — for DB-backed lists, composite checks, or in-memory tests.
Group-Role Authorization (0.2.4+, scoped per provider since 0.3.2+)
Alternative to resource_access JWT claim. Resolves roles from group membership via layered cache: L1 Caffeine (per-instance) → L2 Redis Sets (shared) → L3 Keycloak Admin API. Each scope (provider) owns its own resolver and L2 key namespace (<keyPrefix><scopeKey>:<groupName>), so multiple realms share one Redis instance safely.
Key classes
| Class | Module | Purpose |
|---|---|---|
GroupRoleAuthenticationConverter |
apisix-resource-server | Reads role_groups claim → delegates to resolver |
GroupRoleResolver |
apisix-resource-server | L1 → L2 → L3 cascade. One per provider; carries scopeKey (0.3.2+) |
GroupRoleFetcher |
apisix-resource-server | @FunctionalInterface for L3 source of truth |
GroupRoleInvalidator |
apisix-resource-server | Publishes <scope>:<group> invalidation messages |
MultiRealmAuthenticationConverter |
apisix-resource-server | Routes tokens by iss claim to per-provider converters (0.3.2+) |
ReactiveApisixKeycloakAdminAutoConfiguration |
security-autoconfigure | Wires per-provider resolvers, invalidator, pub/sub listener |
Invalidating the cache
GroupRoleInvalidator exposes both targeted (per-scope) and broadcast forms:
// Targeted (preferred when caller knows the provider).
groupRoleInvalidator.invalidate("backoffice", "/admins").subscribe();
// Broadcast — clears "/admins" across every provider's cache. (0.3.3+)
groupRoleInvalidator.invalidate("/admins").subscribe();
Broadcast publishes *:<group>; targeted publishes <scope>:<group>. Both ride same Redis channel (f8a...resource-server.group-role-authorization.l2.invalidation-channel).
Unmatched scopeKey → DEBUG log, message dropped. Use wildcard when publisher can't map realm → provider id.
Server-Sent Events Auth (0.3.4+)
Browsers' EventSource can't send custom headers, so SSE endpoints authenticate via ?token=<jwt>. Summer 0.3.4 ships SseQueryParamTokenFilter — delete any service-local copy under config/sse/ and declare an SseAuthCustomizer bean.
Wiring
Auto-configured as WebFilter by ReactiveApisixResourceServerAutoConfiguration at order -100 (before APISIX AuthenticationWebFilter). No SseAuthCustomizer bean → zero rules, no-op. Services opt in by registering a customizer.
@Bean
SseAuthCustomizer sseRoutes() {
return reg -> reg
.pathStartsWith("/api/v1/public/sse/admin/", "bo") // only 'bo' tokens
.pathStartsWith("/api/v1/public/sse/", "end-user") // only 'end-user' tokens
.acceptEventStream("end-user"); // any other SSE → end-user
}
Rules tested in registration order; first match wins. No match → request bypasses SSE filter, downstream auth still runs.
Routing model — by provider key, not by iss
Rules carry a provider key (f8a.security.apisix.resource-server.providers.<id>), not an issuer URL. Auto-config builds ReactiveJwtDecoder against that provider's issuerUri(); decoder enforces iss natively — token from different provider never authenticates on another provider's path.
Fail-fast at startup
- Rule references unknown provider id →
IllegalStateExceptionlisting known ids. Catches typos before silent 401s. SseQueryParamTokenFilter.MatchRulerejectsnull/blankproviderIdandnullmatcher.
Public API
| Type | Package |
|---|---|
SseQueryParamTokenFilter |
io.f8a.summer.security.apisix.server.resource.web.server.filter |
SseQueryParamTokenFilter.MatchRule |
nested record (Predicate<ServerWebExchange> matcher, String providerId) |
SseAuthCustomizer |
io.f8a.summer.autoconfigure.security.apisix.resource.reactive (functional, single customize) |
SseAuthCustomizer.Registration |
inner class with matcher, pathStartsWith, pathContains, acceptEventStream builders |
Per-request flow
SSE request → first matching rule → providerId
→ decoderResolver.apply(providerId)
→ decoder.decode(token) // verifies signature + iss
→ MultiRealmAuthenticationConverter.convert(oidcIdToken)
→ ReactiveSecurityContextHolder.withAuthentication(auth)
→ continue chain
Filter reuses MultiRealmAuthenticationConverter — post-decode role/userinfo mapping identical to non-SSE routes.
Keep permitAll() on SSE paths
.pathMatchers(HttpMethod.GET, "/api/v1/public/sse/**").permitAll()
SSE filter populates security context before APISIX filter, but permitAll() lets request through authorizeExchange() for token-based auth via SSE path.
ProviderJwtDecoderResolver (0.3.4+)
Function<String, ReactiveJwtDecoder> extracted from SSE auto-config for reuse by anything needing provider key → ReactiveJwtDecoder.
Package: io.f8a.summer.security.apisix.server.resource.authentication.ProviderJwtDecoderResolver
- Decoders built lazily on first lookup via
ReactiveJwtDecoders.fromIssuerLocation(provider.issuerUri()). Lazy-init: unreachable OIDC server doesn't block startup. - Cached in
ConcurrentHashMapkeyed by provider id. - Returns
nullfor unknown provider id — treat as misconfiguration; auto-config validates at startup so production hits are unexpected.
Override by declaring your own ProviderJwtDecoderResolver bean (e.g. static decoder map for tests). Auto-configured with @ConditionalOnMissingBean.
KeycloakRoleSynchronizer — no more null bean (0.3.4+)
Previously returned null when sync-role unset, registering NullBean. @Autowired KeycloakRoleSynchronizer blew up with NPE-flavoured errors later.
Now: conditionally created via @ConditionalOnExpression("!'${f8a.security.apisix.resource-server.sync-role:}'.trim().isEmpty()") — unset sync-role → bean not registered. Plus @ConditionalOnMissingBean for override.
@Autowired(required = false)/ObjectProvider<KeycloakRoleSynchronizer>— same observable behaviour.@Autowired(required, sync disabled) — was:NullBean→ NPE. Now: cleanNoSuchBeanDefinitionExceptionat startup.
"sync-role not configured" log gone. Absence of "Role synchronization will run against provider..." signals sync off.
ReactiveKeycloakClient
Resource-based API mirroring keycloak-admin-client SPI. Auto-configured per provider with admin credentials.
var config = new KeycloakConfig();
config.setServerUrl("https://keycloak.example.com");
config.setRealm("my-realm");
config.setClientId("admin-cli");
config.setClientSecret("secret");
var keycloak = new ReactiveKeycloakClient(config);
Navigation: keycloak.users() / .clients() / .groups() / .realm() / .tokenResource() / .tokenProvider()
For complete ReactiveKeycloakClient API, see references/keycloak-error-map.md.
KeycloakRoleSynchronizer
ApplicationRunner at startup. Scans @AuthRoles → syncs clients, roles, features (JSON attribute), custom attributes to Keycloak. Runs against provider named by sync-role: <provider-id> (0.3.2+).
Version Notes
Headline only — full detail: load matching overlay:
- 0.3.4 —
SseQueryParamTokenFilter+SseAuthCustomizer;ProviderJwtDecoderResolverbean;KeycloakRoleSynchronizerno longer null bean. → - 0.3.3 — optional
provider.issuer-uri; broadcastGroupRoleInvalidator.invalidate(groupName)+BROADCAST_SCOPE="*". → - 0.3.2 — multi-realm
providers.<id>(BREAKING);MultiRealmAuthenticationConverter;sync-roletop-level provider-id; group-role cache per provider. → - 0.3.0 — JWT blacklist (
blacklist-prefix-key);JwtBlacklistChecker;ApisixAuthenticationManager3-arg ctor (BREAKING). → - 0.2.4 — Keycloak config → shared
keycloakblock (BREAKING);UserInfoAuthenticationConverter→Mono(BREAKING); group-role auth added. → - 0.2.3 —
KeycloakException~50 mappings;sync-role.enable→sync-role.enabled. → - 0.2.1 —
summer-security-core→summer-jwt-resource-server(BREAKING);summer-apikey-resource-serveradded. →
For the full feature × version table see summer-core/references/version-matrix.md.
Rules
- Use
summerCustomizer.customize(http)+apisixCustomizer.customize(http)inSecurityWebFilterChain— never hand-roll CORS/CSRF/session. - Define roles via
@AuthRoles+@ResourceDef; never hardcode role strings outsideRolesclass. - Define ALL 7 actions (view, create, update, delete, approve, import, export) per resource — incomplete sets break permission UIs and audits.
- Vietnamese for
@ResourceDef(name)and@FeatureDef(name).codestays English kebab-case. - Use
@PreAuthorize("hasAnyRole(@roles.XXX)")— path matchers alone insufficient. - Never expose Keycloak secrets in
application.yml— use env vars or Vault. - Check Summer version before suggesting features (group-role: 0.2.4+; multi-realm
providers.*: 0.3.0+; JWT blacklist: 0.3.0+; broadcast invalidator: 0.3.3+; SSE filter +ProviderJwtDecoderResolver: 0.3.4+). - 0.3.4+ SSE: never ship service-local
SseQueryParamTokenFilter— registerSseAuthCustomizerbean. Duplicate@Componentcollides or shadows silently. - 0.3.0+: never write legacy
keycloak.*block — removed. Useproviders.<id>:always. - Multi-realm: assign each provider stable
id, reuse asscopeKeyfor invalidation — never invent ad-hoc scope strings.
References
- references/keycloak-error-map.md —
KeycloakExceptionmapping table and Keycloak resource API. - references/versions/ — Per-version notes (0.2.1 → 0.3.3).
Related Skills
- summer-core — Shared types (
Member,CallerAware) fromX-Userinfo; version detection. - summer-test — Mock
X-Userinfofor testing@AuthRoles-protected endpoints. - spring-security — Spring Security patterns, JWT, CORS.
- summer-rest —
BaseController+RequestHandlersecured by@PreAuthorize.