The auth-service module is the runnable implementation of the Auth microservice. It exposes the endpoints defined in auth, handles JWT generation via JwtService, and delegates account management to the account microservice via Feign. The RouterValidator component — also introduced here — is the piece that tells the Gateway which routes require authentication and which are open to everyone.
1. Open vs. Secured Routes
Not every endpoint requires authentication. Registration and login must be publicly accessible — otherwise, users could never authenticate in the first place. All other routes are secured: they require a valid JWT cookie.
The RouterValidator encodes this policy as a simple predicate:
flowchart LR
request[Incoming Request]
open{Is open route?}
secured[Proceed to filter]
forward[Forward request]
request --> open
open -- Yes --> forward
open -- No --> secured
secured --> forward
The open routes defined in this platform are:
Method
Path
Purpose
POST
/auth/register
Create a new account
POST
/auth/login
Authenticate and receive a JWT cookie
GET
/auth/logout
Clear the JWT cookie
Everything else — including all /accounts/** endpoints — requires a valid cookie.
2. The auth-service Module
2.1 Repository
Create a new git repository for the auth service and add it as a submodule:
Spring Boot entry point; enables Feign clients from the store.account package
AuthResource
REST controller implementing AuthController; sets the JWT cookie on login/logout
AuthService
Business logic: delegates registration and the credential check to account, calls JwtService to generate tokens
JwtService
Wraps the JJWT library; signs and parses JWTs using an HMAC secret key
AuthParser
Converts the RegisterIn DTO into the internal Register object
Register
Internal registration data (name, email, password) handed to AuthService.create
2.3 Checking credentials in account
auth-service never checks a password itself. The check belongs to account: it is the only service that stores the password, and it stores only its SHA-256 hash (field hashPassword, column hash_password, computed by AccountService.calcHash on create). So the account contract from Hands-on 1 gains one operation — POST /accounts/login (AccountController.login); add the pieces below to account and account-service. account-service hashes the received password with the samecalcHash and looks for an account with that email and that hash, through the Spring Data derived query findByEmailAndHashPassword. It answers 200 with the AccountOut (never the hash) or 401 Unauthorized. The password travels only inside the trusted layer — exactly as it already does on POST /accounts during registration — and the hash never leaves account.
A bare .orElseThrow() throws NoSuchElementException when no account matches, and Spring answers 500 Internal Server Error — as if the server were broken. Pass the exception to throw: .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, ...)). On the other side, Feign turns that 401 into a FeignException.Unauthorized inside auth-service (and the 400 of an empty password into a FeignException.BadRequest); AuthService.login (below) catches them and answers 401, otherwise the login would fail with 500 again.
Reinstall the account library
AccountController lives in the account interface module, so run mvn clean install in api/account again before building account-service and auth-service.
A note on the hash
A plain SHA-256 keeps the course simple, but it is fast and unsalted, so leaked hashes are easy to brute-force. Production systems use a slow, salted algorithm such as bcrypt or Argon2 (e.g. Spring Security's PasswordEncoder, which verifies with matches(raw, hash) instead of a database lookup by hash).
2.4 JwtService in detail
JwtService is responsible for two things:
Generating a token (generate): it builds a JWT with the account's id as the jti claim, the account's name as subject, and the email as a custom claim. The token is signed with an HMAC key derived from the base64-encoded secret, and it expires after the configured duration (store.jwt.duration, in milliseconds — 1 hour by default).
Validating a token (getId): the JJWT parser verifies the signature and also rejects a token that is premature (nbf) or expired (exp), throwing a JwtException (SignatureException, PrematureJwtException, ExpiredJwtException, …). getId turns any of them into 401 Unauthorized — without the catch, a merely expired cookie would end in 500. If the token is valid, it returns the account's id (jti).
sequenceDiagram
autonumber
actor User
User->>+AuthResource: POST /auth/login (LoginIn)
AuthResource->>+AuthService: login(email, password)
AuthService->>+AccountController: POST /accounts/login (AccountIn)
AccountController-->>-AuthService: AccountOut (or 401)
AuthService->>+JwtService: generate(AccountOut, duration)
JwtService-->>-AuthService: signed JWT string
AuthService-->>-AuthResource: signed JWT (String)
AuthResource->>-User: Set-Cookie: __store_jwt_token=<jwt>
spring:application:name:authstore:jwt:secretKey:${JWT_SECRET_KEY:EBwrdOOcAIDRLON9hbJV0+O3PTdfTjgMCQIxwLH7ws8=}httpOnly:${JWT_HTTP_ONLY:true}duration:${JWT_DURATION:3600000}# 1 hour in milliseconds
packagestore.auth;importjava.util.Map;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.http.HttpStatus;importorg.springframework.stereotype.Service;importorg.springframework.web.server.ResponseStatusException;importfeign.FeignException;importstore.account.AccountController;importstore.account.AccountIn;importstore.account.AccountOut;@ServicepublicclassAuthService{@AutowiredprivateAccountControlleraccountController;@AutowiredprivateJwtServicejwtService;@Value("${store.jwt.duration}")privateLongduration;@Value("${store.jwt.httpOnly}")privateBooleanhttpOnly;publicvoidcreate(Registerregister){accountController.create(AccountIn.builder().name(register.name()).email(register.email()).password(register.password()).build());}publicStringlogin(Stringemail,Stringpassword){finalAccountOutaccount;try{// account hashes the password and compares it with the stored hashaccount=accountController.login(AccountIn.builder().email(email).password(password).build()).getBody();}catch(FeignException.Unauthorized|FeignException.BadRequeste){thrownewResponseStatusException(HttpStatus.UNAUTHORIZED,"Invalid credentials");}returnjwtService.generate(account,getDuration());}publiclonggetDuration(){returnduration;}publicbooleangetHttpOnly(){returnhttpOnly;}publicMap<String,String>solve(finalStringjwt){returnMap.of("idAccount",jwtService.getId(jwt));}publicAccountOutme(StringidAccount){returnaccountController.findById(idAccount).getBody();}}
packagestore.auth;importjava.util.Base64;importjava.util.Date;importjava.util.Map;importjavax.crypto.SecretKey;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.http.HttpStatus;importorg.springframework.stereotype.Service;importorg.springframework.web.server.ResponseStatusException;importio.jsonwebtoken.Claims;importio.jsonwebtoken.JwtException;importio.jsonwebtoken.JwtParser;importio.jsonwebtoken.Jwts;importio.jsonwebtoken.security.Keys;importstore.account.AccountOut;@ServicepublicclassJwtService{@Value("${store.jwt.secretKey}")privateStringsecretKey;publicStringgenerate(AccountOutaccount,longduration){Datenow=newDate();returnJwts.builder().header().and().id(account.id()).issuer("Insper::PMA").claims(Map.of("email",account.email())).signWith(getKey()).subject(account.name()).notBefore(now).expiration(newDate(now.getTime()+duration)).compact();}privateSecretKeygetKey(){returnKeys.hmacShaKeyFor(Base64.getDecoder().decode(secretKey));}publicStringgetId(Stringjwt){try{JwtParserparser=Jwts.parser().verifyWith(getKey()).build();// checks the signature, nbf and exp: a JwtException otherwiseClaimsclaims=parser.parseSignedClaims(jwt).getPayload();returnclaims.getId();}catch(JwtExceptione){thrownewResponseStatusException(HttpStatus.UNAUTHORIZED,"Invalid token");}}}
The JWT is delivered to the browser as an HttpOnly cookie named __store_jwt_token. Using an HttpOnly cookie instead of storing the token in localStorage prevents JavaScript from reading it, which mitigates XSS attacks.
Additional cookie flags used:
Flag
Value
Purpose
HttpOnly
true
JavaScript cannot access the cookie
Secure
true
Cookie is only sent over HTTPS
SameSite
None
Cookie is sent on cross-site requests (needed for SPA frontends)
Path
/
Cookie applies to all paths
SameSite=None requires Secure=true
Browsers reject SameSite=None cookies without the Secure flag. This means the frontend and the API must communicate over HTTPS in production.
Done! The auth-service is ready. In the next section we will implement the Gateway filter that intercepts every request, extracts the cookie, and asks auth-service to resolve the token.