Authentication and account service in Quarkus (Java 17): register, login, renew and logout with RSA-signed JWT, accounts in PostgreSQL, sessions and caches in Redis, OpenAPI/Swagger UI, and a container image that can be built natively through Jib.
POST /auth/register POST /auth/login POST /auth/renew POST /auth/logout GET /users/me
- JWT that is actually verified:
smallrye-jwtdoes the parsing and signature check with an RSA public key,@RolesAllowed-style authorization is always enforced (smallrye.jwt.always-check-authorization=true), and the signing key is a file that never enters the repository -scripts/gen-dev-keys.shcreates a throwaway pair for development. - Cache-aside with Redisson: Redis is used through Redisson (connection pool, threads and Netty threads configured explicitly) rather than a raw client, which is what you want once several services share one Redis.
- Panache repositories as the data layer:
UserRepository/ConfigRepositoryover Hibernate ORM with an explicit schema policy (quarkus.hibernate-orm.database.generation), so the schema is a decision, not a side effect. - A documented API by default: SmallRye OpenAPI plus Swagger UI are served from the same build, which is how a mobile team can integrate without a spec document.
- Container-first build: Jib is wired up (
quarkus.container-image.*), and it is off by default so a plainmvn packagedoes not surprise you with a Docker build.
| Method | Path | Auth | Purpose |
|---|---|---|---|
| POST | /auth/register |
no | create an account |
| POST | /auth/login |
no | credentials in, JWT out |
| POST | /auth/renew |
refresh token | new access token |
| POST | /auth/logout |
bearer | invalidate the session |
| GET | /users/me |
bearer | the caller's profile |
| GET | /swagger, /swagger-ui.html |
no | the OpenAPI document and UI |
git clone https://github.com/lasttoss/quarkus-auth-api.git
cd quarkus-auth-api
make up # dev keys + postgres + redis + the api
# api : http://localhost:8081/auth
# swagger : http://localhost:8081/swagger-ui.html
make downLocally without Docker for the app:
./scripts/gen-dev-keys.sh certs # the JWT layer needs a key pair
docker compose up -d postgres redis # or have both services running
./mvnw quarkus:devEverything has a development default and is overridable from the environment
(src/main/resources/application.properties): AUTH_PORT, DB_URL, DB_USERNAME,
DB_PASSWORD, REDIS_URL, REDIS_PASSWORD, REDIS_DATABASE, JWT_PUBLIC_KEY,
JWT_PRIVATE_KEY. See .env.example and docker-compose.yml.
The jdbc URL defaults to PostgreSQL; a CockroachDB URL works as well since the datasource is
declared as postgresql.
Building a container image through Jib is opt-in:
./mvnw package -Dquarkus.container-image.build=true.
- The service could not start from a clean clone:
application.propertieswas committed asapplication.properties.bak, so Quarkus fell back to defaults with no datasource. There is now a realapplication.propertieswith placeholders for every environment value. - The JWT key pair the build depends on was missing (and must not be committed), so the app
died on startup looking for
certs/publickey.pem.scripts/gen-dev-keys.shgenerates one for development andcerts/is git-ignored - real keys are mounted. application.propertiesused to force a container image build on everymvn package(quarkus.container-image.build=true), which made a normal build slow and fail on machines without a Docker daemon. It is now opt-in.- A database password and a Redis password were committed in that
.bakfile. The file is deleted and every credential now comes from the environment.
- 27 unit tests, 71.2% of lines (the table below); the CI job runs them and builds the jar.
register/loginbehaviour has not been exercised end to end here - the compose stack is the intended way to try it, and the smoke path is: register → login → call/users/mewith the bearer token.
MIT - see LICENSE. Quarkus is Apache-2.0 and is not redistributed here.
%% Source for docs/diagrams/token-lifecycle.html
%% Issue, verify, renew, revoke - and the key that never leaves the server.
flowchart LR
R["register / login"] --> CR{"credentials<br/>checked against<br/>the stored hash"}
CR -->|"ok"| ISS["issue tokens<br/>RSA-signed JWT"]
ISS --> AT["access token<br/>short window"]
ISS --> RT["refresh token"]
AT --> V["any protected call"]
V --> SV{"smallrye-jwt<br/>parse + verify<br/>with the public key"}
SV -->|"signature ok"| RO{"@RolesAllowed<br/>the role check"}
RO -->|"allowed"| EP["endpoint runs"]
RT -->|"renew"| ISS
L["logout"] --> SS["session removed<br/>in Redis"]
classDef gate fill:#eef5ef,stroke:#1a6b3c,stroke-width:2px;
class CR,SV,RO gate;
Four endpoints and one key pair. The half that is easy to get wrong is on the right: a token is only worth anything once its signature is checked against the RSA public key, and being signed by the server is not the same as being allowed — the role is checked too. Decoding a payload is not verifying it, and that gap is the whole difference.
The other half is the two things a stateless token cannot do alone. /auth/renew exchanges a refresh token
for a new access token, which is also the one place a renewal can be refused. /auth/logout removes the
session in Redis, because a signed token cannot be un-signed — which is why the access token's window is
short: that window is the cost of revocation.
The private key signs and is supplied by the environment, never committed; the public key checks, which is what lets another service accept these tokens without being able to mint them.
docs/diagrams/token-lifecycle.mmd is the Mermaid source; make diagram exports a PNG if a browser is
present.
charts/auth-api/ deploys the service with the two things an auth service needs from a deploy: a rolling update
that never takes a replica out of the Service before its successor can answer (maxUnavailable: 0), so a login
arriving mid-roll is still answered, and a PodDisruptionBudget that keeps one serving through a disruption. It
also carries an HPA, no service-account token, a read-only root filesystem with an emptyDir for the /tmp a
JVM writes to, and a NetworkPolicy allowing the one port in and only DNS plus PostgreSQL and Redis out.
The signing key pair is not a value. The chart takes secret.existingSecret, the name of a Secret that already
holds the credentials and JWT_PRIVATE_KEY / JWT_PUBLIC_KEY - a chart that accepts a private key as a value is
a chart that gets one committed.
make chart # helm lint --strict + helm template./mvnw -B test runs the unit tests; these are JaCoCo's numbers, line coverage:
| class | lines |
|---|---|
AuthService |
100.0% (90/90) |
AuthResource |
0.0% (0/16) |
RedisService |
6.7% (1/15) |
UserMapperImpl |
93.3% (14/15) |
UserModel |
100.0% (14/14) |
UserService |
0.0% (0/13) |
ApiErrorConstants |
90.9% (10/11) |
EventLogger |
90.9% (10/11) |
| total | 71.2% (173/243 lines, 50.0% of 26 branches) |
AuthService is covered in full. It is the whole decision of whether a player gets a token, so the tests
are about that decision rather than about lines: which status and which error code each way of failing
produces (a taken username is EXIST_USER, an unknown one is USER_NOT_FOUND, and a renew that does not
match the token on record is FAILED_TO_VERIFY_TOKEN), that a refused request never carries a token, and
that an accepted one carries two different signed JWTs with the lifetimes the constants promise.
Nothing in the suite runs Quarkus, and nothing needs a database or a Redis: the service takes its collaborators as package-private fields, so the repository and redis are hand-written fakes and the JWT parser is a proxy, while the tokens are signed for real with a temporary key.
Two gaps worth naming. AuthResource and UserResource are the REST layer, and what covers them is the
end-to-end run against the stack rather than the unit suite. RedisService is a thin wrapper over
Redisson and only its first line is exercised; testing it properly means a real Redis.
One piece of behaviour is recorded rather than fixed:
renewCrashesRatherThanRefusingWhenTheKeyVanishesBetweenTheCheckAndTheRead - the renew path asks redis
whether the key exists and then reads it, so a key that expires in between answers with a
NullPointerException instead of an error response. The window is small and choosing which error to
return is a decision, so the test says what happens today.