A Spring Boot service for issuing and consuming invitation links for Keycloak.
Administrators generate invitation links with a limited lifetime and usage count. Recipients redeem these links to get a Keycloak account automatically provisioned with predefined realm roles.
The service focuses on safety, failure resilience, and operational clarity when automating user onboarding in Keycloak.
- Create invites per realm with configurable expiry and usage limits.
- Resend invites, including revoked, expired, or already used ones.
- Revoke and delete invites explicitly.
- Automatically clean up expired invites after the configured retention period (daily job).
- View invite status: active, expired, overused, or revoked.
GET /invite/{realm}/{token}validates the invitation token and renders a confirmation page.POST /invite/{realm}/{token}(on form submission) performs the redeem flow:- create a Keycloak user
- assign predefined realm roles
- trigger a required-actions email from Keycloak
- mark the invite as used
- Successful
POSTrequests redirect to/invite/success(PRG) to avoid form resubmission on refresh. GETperforms no side effects.POSTrequires:- a valid CSRF token
- a one-time confirmation challenge issued by the
GETpage
- The confirmation challenge expires after 10 minutes. Reopen the invite link to get a fresh confirmation page and challenge.
- Reopening the confirmation page for the same invite in the same browser session invalidates previously issued confirmation challenges for that invite (the latest page wins).
- While a redeem
POSTis in progress for an invite, the same browser session cannot issue a new confirmation challenge for that invite. - The confirmation challenge is stored in the server-side HTTP session.
A small serializable challenge map is stored as a session attribute.
For multi-instance deployments, configure either session affinity (
sticky sessions) or shared session state (for example, Spring Session with Redis). - Confirmation challenge and in-flight protections are scoped to a single HTTP session. They prevent re-entry and refresh races within the same browser session, but do not coordinate different browsers/devices/sessions using the same invite link at the same time.
The redeem flow (POST) uses compensating actions to stay failure-safe:
- If any step fails, the created Keycloak user is deleted.
- Permanent errors (for example: missing roles, client-side 4xx from Keycloak) revoke the invite.
- Transient errors keep the invite usable for retry.
- Expired invites beyond the configured retention period are removed by a daily scheduled cleanup job.
- Spring Boot MVC with Thymeleaf for server-rendered admin and public views.
- Keycloak Admin REST API accessed via reactive WebClient with retries for transient failures.
- PostgreSQL for persistence, with Flyway-managed schema migrations.
- Invite tokens are stored as a hash with salt (raw tokens are never persisted).
- Strict input normalization (for example: trimming and lowercasing emails).
- Sensitive values are masked in logs by default.
- Structured logging via SLF4J event builders.
- A servlet filter enriches MDC with:
current_user.id(username orsystem)current_user.sub(OIDC subject when available)
- All configuration is externalized via environment variables using Spring relaxed binding.
- Prefer
.envfiles over editingapplication.yml. - Docker Compose loads
.envautomatically viaenv_file: .env.
The bundled .env.example.local and .env.example.docker files:
- are meant for local development and demonstration
- are not exhaustive lists of all available configuration options
- contain placeholder values (for example,
KEYCLOAK_URL=https://id.example.com) that must be replaced
Copy one of them to .env and adjust it for your setup.
- User-facing localization is controlled by a single application-wide locale configured at startup. Use
APP_LOCALE=enorAPP_LOCALE=ru; the default isen. - To add a new locale:
- add a new message bundle file, for example
src/main/resources/messages_de.properties - translate all user-facing message keys
- set
APP_LOCALEto the target locale tag, for examplede - rebuild and redeploy the application so the new bundle is included in the deployment artifact
- add a new message bundle file, for example
- Contributions for additional locales are welcome. Open a pull request with the new message bundle file and its translations.
- Default profile:
prod - Local development: set
SPRING_PROFILES_ACTIVE=localin.env(the example files already do this)
Required:
KEYCLOAK_URLKEYCLOAK_CLIENT_SECRET
KEYCLOAK_URL must be the Keycloak base URL (scheme + host + optional port/base path),
without the realm suffix (do not append /realms/{realm}).
Examples:
https://sso.example.comhttps://sso.example.com/auth(if Keycloak is served under a base path)
The application builds the OIDC issuer URL as ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM} and
resolves OIDC discovery during startup, so KEYCLOAK_URL must be reachable from the runtime
environment (host process or container) and use a trusted certificate if HTTPS is enabled.
Defaults (override via env if needed):
- Realm:
master - Client ID:
invites-keycloak - Required admin role:
invite-admin - HTTP timeouts:
- Connect: 5 seconds
- Response: 10 seconds
Required:
INVITE_PUBLIC_BASE_URLINVITE_TOKEN_SECRET
Defaults for expiry bounds, token size, salt, MAC algorithm, realm mapping, and cleanup retention
are defined in application.yml and can be overridden via environment variables.
invite.realms is the allowlist of realms available in the admin UI.
invite.realms.<realm>.roles defines the default (preselected) realm roles for invites in that realm.
Example env overrides:
INVITE_REALMS_MASTER_ROLES_0=invite-adminINVITE_REALMS_MASTER_ROLES_1=another-roleINVITE_REALMS_PARTNERS_ROLES_0=partner-user
- Enable mail by setting
SPRING_MAIL_HOST(and relatedSPRING_MAIL_*variables). MAIL_FROMis optional.MAIL_SUBJECT_TEMPLATEis optional.- If
MAIL_SUBJECT_TEMPLATEis blank, the default localized subject from the message bundle is used.
To disable mail entirely, set:
SPRING_AUTOCONFIGURE_EXCLUDEtoorg.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration,org.springframework.boot.autoconfigure.mail.MailSenderValidatorAutoConfiguration
- API docs and Swagger UI are disabled by default.
- Enable via:
SPRINGDOC_API_DOCS_ENABLED=trueSPRINGDOC_SWAGGER_UI_ENABLED=true
Defaults point to PostgreSQL running at db:5432 with:
- database:
invites-keycloak - user:
invites-keycloak - password:
invites-keycloak
Override using:
POSTGRES_HOSTNAMEPOSTGRES_PORTPOSTGRES_DBPOSTGRES_USERPOSTGRES_PASSWORD
- Use the
masterrealm or setKEYCLOAK_REALMexplicitly.
- Confidential client named
invites-keycloak(or override withKEYCLOAK_CLIENT_ID). - Standard Flow enabled.
- Redirect URI:
<app-base-url>/login/oauth2/code/keycloak- Example (local):
http://localhost:8080/login/oauth2/code/keycloak
- Web Origins:
<app-base-url>(including scheme and port)
- Copy the client secret to
KEYCLOAK_CLIENT_SECRET.
- Realm role
invite-admin(or override viaKEYCLOAK_REQUIRED_ROLE). - Grant this role to users who should access the admin UI.
- Roles must be included in the ID token.
- Attach the built-in
rolesclient scope or add a mapper for:realm_access.roles- multivalued
- included in ID token, access token, and userinfo
- Enable the service account for the client.
- Grant the following realm-management roles at minimum:
manage-usersview-realmmanage-realm
Missing roles will result in 403 errors when listing roles or creating users.
- The application respects forwarded headers.
server.forward-headers-strategy=frameworkis enabled.
Ensure your reverse proxy sends:
HostX-Forwarded-ProtoX-Forwarded-PortX-Forwarded-For
nginx example:
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Without correct forwarding, OAuth redirects may downgrade to HTTP.
- Java 25
- Docker
- Docker Compose plugin
- pre-commit
- Install git hooks once:
pre-commit install - Before the first start, replace placeholder values in
.env, especially:KEYCLOAK_URLKEYCLOAK_CLIENT_SECRETINVITE_TOKEN_SECRETINVITE_PUBLIC_BASE_URL
- Run locally (starts Postgres via Compose, then Spring Boot):
make run-local - Fast dev loop:
- keep
docker compose up -d dbrunning - start the app with
./gradlew bootRun
- keep
- Tests:
- unit and integration tests:
make test - full linting and coverage:
make check
- unit and integration tests:
-
/
Redirects to/admin/invite(authentication required). -
/admin/invite/**
Admin UI for creating, resending, revoking, and deleting invites.
Protected by Keycloak OAuth2 login. -
/invite/{realm}/{token}
Public invite endpoint (also accepts a trailing slash for bothGETandPOST):GETrenders a minimal confirmation page (no side effects)POSTredeems the invite after explicit confirmation (CSRF+ one-time challenge) and redirects to/invite/success
-
/invite/success
Public success page used as the redirect target after successful invite redemption.
Admin pages include a logout action that signs out of Keycloak and returns to the start page.
CI builds and pushes images to:
ghcr.io/hu553in/invites-keycloak
Published tags:
latestand commit SHA on pushes tomain- git tag name (for example,
v1.2.3) when the tag matchesv*
Deployment steps:
- Provision a
.envfile on the VPS with Keycloak, invite, mail, and database settings. - Update
docker-compose.yml(or an override file) to reference the desired image tag. - Run:
docker compose pull && docker compose up -d --wait - Verify service health at the configured health endpoint (default:
/actuator/health).
See exact versions in gradle/libs.versions.toml and service wiring in docker-compose.yml.
- Java 25, Kotlin 2, Gradle 9, Spring Boot 4
- PostgreSQL 18, Flyway, Spring Data JPA
- Spring Security OAuth2 Client, Thymeleaf
- WebClient (reactive) for Keycloak admin API
- Micrometer with Prometheus registry
- Micrometer tracing (OTLP exporter optional)
- Detekt, Kover, Testcontainers, WireMock
- Actuator endpoints (these are public):
/actuator/health/actuator/prometheus
- All other actuator endpoints require the
invite-adminrole.
- Prometheus scraping is enabled by default.
- OTLP exporter dependency is present but disabled by default.
- Enable with:
MANAGEMENT_TRACING_EXPORT_ENABLED=true - Configure endpoint:
MANAGEMENT_OTLP_TRACING_ENDPOINT=http://otel-collector:4318/v1/traces - Adjust sampling with:
MANAGEMENT_TRACING_SAMPLING_PROBABILITY
- Optional servlet access log:
- enabled with
access-logging.enabled=true - emitted once per request
- includes method, path, status, duration, and MDC-enriched context
- enabled with
- Service layer owns
INFO-level audit logs for invite lifecycle events (create, resend, revoke, delete, use). - Controllers avoid duplicating success logs.
- Keycloak admin client logs:
- HTTP failures with status, context, and duration
- retries at
DEBUGlevel with retry counts
- Controller advice enriches logs with route and status for traceability.
- Use
log.dedupedEventForAppError(...)when handlingKeycloakAdminClientExceptionoutside the client to avoid double-logging. - Log level policy:
- validation and client-side issues:
WARN - Keycloak 4xx (misconfiguration-like:
400/401/403/404/422):ERROR - other Keycloak 4xx:
WARN - server issues and unexpected failures:
ERROR - routine reads and validation:
DEBUG - state changes and audit events:
INFO
- validation and client-side issues:
- Emails are always masked using
maskSensitive. - MDC helpers:
withAuthDataInMdcwithInviteContextInMdc