jwt-demois a Spring Boot 4.0.3 / Java 25 OAuth2 proxy in front of Keycloak, not an identity store. The backend deliberately does not persist login/refresh/logout client credentials; callers sendclientId/clientSecreton each auth request (README.md,src/main/java/lt/satsyuk/service/KeycloakAuthService.java).- There are two distinct auth paths:
/api/auth/**forwards form-encoded requests to Keycloak viaKeycloakAuthService, while protected endpoints validate bearer tokens through opaque introspection using separate resource-server credentials from env; DPoP proofs are forwarded on auth requests and validated for protected endpoints byDpopAuthenticationFilterwhen tokens are bound viacnf.jkt(src/main/java/lt/satsyuk/config/SecurityConfig.java,src/main/java/lt/satsyuk/security/KeycloakOpaqueTokenIntrospector.java,src/main/java/lt/satsyuk/security/DpopAuthenticationFilter.java,src/main/resources/application.properties). - Business data is local PostgreSQL for
client,account, and asynchronousrequestrecords; auth state stays in Keycloak.POST /api/clientsnow persists arequest, schedules a persistent Quartz job, and the worker eventually creates bothclientand zero-balanceaccountrows (src/main/resources/db/migration/V2__create_request_table_and_quartz_schema.sql,src/main/resources/db/migration/V3__create_account_table.sql,src/main/java/lt/satsyuk/service/RequestService.java,src/main/java/lt/satsyuk/service/ClientService.java).
- Controllers expose standardized
AppResponse<T>envelopes. Regular CRUD endpoints returnAppResponse.ok(...); auth endpoints useResponseEntity<AppResponse<...>>so they can translateKeycloakAuthExceptionstatus codes inline (src/main/java/lt/satsyuk/controller/AuthController.java,src/main/java/lt/satsyuk/dto/AppResponse.java). POST /api/clientsis intentionally asynchronous: the controller returns202 AcceptedwithAppResponse<RequestAcceptedResponse>, and callers must pollGET /api/requests/{id}for aRequestStatusResponsewhoseresponsefield contains the final nestedAppResponsepayload. Preserve thePENDING -> PROCESSING -> COMPLETED|FAILEDlifecycle and stored JSON shapes when changing this flow (src/main/java/lt/satsyuk/controller/ClientController.java,src/main/java/lt/satsyuk/controller/RequestController.java,src/main/java/lt/satsyuk/service/RequestProcessingService.java).- Protected endpoints must declare method security with
@PreAuthorize("hasRole('...')"); roles are derived by prefixing Keycloak realm/client roles withROLE_inKeycloakOpaqueRoleConverter, so checks usehasRole('CLIENT_CREATE'), not raw claim names. - Keep Swagger annotations in sync with security: protected controllers use
@SecurityRequirement(name = "bearerAuth"), and OpenAPI bearer scheme is declared centrally insrc/main/java/lt/satsyuk/config/OpenApiConfig.java. - DTOs are Java records with Bean Validation message keys, e.g.
CreateClientRequest. If you add validation or new user-facing messages, update all bundles:messages.properties,messages_en.properties,messages_ru.properties. - Domain exceptions carry message codes/parameters and are localized in
GlobalExceptionHandlerthroughMessageService; followClientNotFoundException/PhoneAlreadyExistsExceptioninstead of hardcoding translated text in controllers. - Security failures return JSON from
JsonAuthEntryPointandJsonAccessDeniedHandler; do not introduce HTML/error-page defaults. - Auth endpoints accept optional
DPoPheaders, and protected endpoints may useAuthorization: DPoP <token>plusDPoP: <proof>when introspection returnscnf.jkt; keep DPoP failures going through the same JSON auth entry point instead of ad hoc controller handling (src/main/java/lt/satsyuk/controller/AuthController.java,src/main/java/lt/satsyuk/security/DpopAuthenticationFilter.java,src/test/java/lt/satsyuk/api/integrationtest/DpopIntegrationIT.java). KeycloakAuthServicealso emits Micrometer countersauth.login,auth.refresh,auth.logouttagged withresult=success|failure; preserve these counters when changing auth flows.- Outbound Keycloak calls should reuse the observation-enabled
RestTemplatebean fromRestTemplateConfigrather than constructing new clients ad hoc. - New client creation must also provision a linked
Accountwith zero balance, and/api/accounts/**follows two concurrency patterns: repository-level pessimistic locking for/balance/pessimisticandTransactionTemplateretries for/balance/optimistic(src/main/java/lt/satsyuk/service/ClientService.java,src/main/java/lt/satsyuk/service/AccountService.java,src/main/java/lt/satsyuk/repository/AccountRepository.java). - Bucket4j is configured through ordered
app.rate-limit.rules[], not hardcoded per-endpoint properties: login is keyed by client IP, while/api/clients/**is keyed by authenticatedclientId=spring-app. If you change token claims, client IDs, or add protected endpoints, re-check rule order, key strategy, cache names, andsecurityService.clientId()behavior (src/main/java/lt/satsyuk/config/RateLimitProperties.java,src/main/java/lt/satsyuk/security/RateLimitingFilter.java,src/main/resources/application.properties).
mvn testruns Surefire unit tests matching*Test*.javaand writes coverage totarget/site/jacoco/index.html.mvn verifyruns Failsafe integration tests matching*IT*.javaand writes coverage totarget/site/jacoco-it/index.html.- Run one integration test with:
mvn -DskipTests=false "-Dit.test=ClientIntegrationIT" verify(CONTRIBUTING.md). - For local runtime, copy
.env.exampleto.env, fill Keycloak/Postgres/Grafana secrets plusKEYCLOAK_RESOURCE_CLIENT_ID/KEYCLOAK_RESOURCE_CLIENT_SECRET, then start infra withdocker compose up -d postgres postgres-app keycloakand run the app withmvn spring-boot:run. - On Windows, docs explicitly recommend Docker Desktop 4.28.x; newer versions may break Testcontainers (
CONTRIBUTING.md).
AbstractIntegrationTestprovides shared Postgres Testcontainer, DPoP-aware HTTP helpers, typedAppResponseassertions, and cache cleanup between tests (it preservesfilterConfigCachebut clears other caches).KeycloakIntegrationTestadds a real Keycloak Testcontainer loaded fromkeycloak/realm-export.json; use it for happy-path auth/authorization scenarios.WireMockIntegrationTeststubs Keycloak token/introspection/logout endpoints and is the correct base for negative external-failure cases and rate-limit tests.- Test rate limits are intentionally shortened to 20 seconds in
src/test/resources/application-test.properties; do not “fix” waits inRateLimitingITwithout checking that profile. - Existing integration tests are the best executable spec for behavior:
ClientIntegrationITcovers async client creation + request polling + i18n + authz,AccountIntegrationITcovers account CRUD/locking flows,DpopIntegrationITcovers DPoP forwarding and proof validation,AuthValidationITcovers validation envelopes,KeycloakIntegrationITcovers real Keycloak flows,KeycloakNegativeITcovers malformed/unavailable upstream responses, andRateLimitingITcovers ordered Bucket4j rules.
docker-compose.yamlruns Keycloak 26, two Postgres containers (Keycloak DB + app DB), and an observability stack: OTel Collector, Tempo, Loki, Prometheus, Grafana.keycloak/realm-export.jsonseeds realmmy-realm, usersuser/admin, clientsspring-appandresource-server, plus role mappers that populaterealm_access.rolesandresource_access.spring-app.roles.- Localization is request-driven via
Accept-Language; Russian messages are asserted inClientIntegrationIT, so changing error text requires synchronized bundle/test updates.