Skip to content

b. Auth Service

auth owns the users of BoxOffice. It does three things: register a user (storing a BCrypt hash, never the password), log in (check the password and issue a signed token) and publish the public key that lets anybody verify those tokens. It is a regular Spring Boot service on port 8081, with its own schema auth.

flowchart LR
    subgraph auth["auth :8081"]
        ctl[AuthController] --> svc[AuthService]
        svc --> repo[UserRepository] --> db[("schema auth<br/>users")]
        svc --> tok["TokenService<br/>RSA key pair"]
    end
    gw[gateway] -->|"GET /auth/.well-known/jwks.json<br/>(public key, cached)"| ctl

1. Why a signed token?

After login, every request must prove who is calling. Two families of solutions:

Server-side session Self-contained token (JWT)
What the client holds a random session id (cookie) the claims themselves, signed: sub, scope, exp…
How a request is checked look the id up in a session store (Redis, DB) verify the signature with the public key — no lookup
Revocation delete the session: immediate hard: the token is valid until exp (keep it short)
Scales with the session store CPU only; every node can verify alone

BoxOffice uses JWTs signed with RS256 (RSA + SHA-256): auth signs with a private key only it knows; the gateway verifies with the public key, published as a JWKS (JSON Web Key Set). The gateway can verify tokens but can never mint one — unlike HS256, where the same shared secret signs and verifies, and every service holding it could forge tokens.

A token looks like header.payload.signature, each part Base64URL-encoded:

decoded payload of a BoxOffice token
{
  "iss": "boxoffice-auth",
  "aud": "boxoffice",
  "sub": "0b9d8a6e-4f3c-4c1e-9d52-1b0f3c2a7e11",
  "email": "ana@example.com",
  "name": "Ana",
  "scope": "user",
  "iat": 1795000000,
  "exp": 1795003600,
  "jti": "7c1f…"
}

Signed is not encrypted

Anybody can decode the payload (try jwt.io, or the jq command at the end of the curl session on the lab page). Never put secrets or personal data you would not show the user in a JWT.

2. Project and configuration

Create services/auth like catalog (same Dockerfile with EXPOSE 8081, same .dockerignore).

services/auth/Dockerfile and .dockerignore (same as catalog, only EXPOSE 8081 changes)
services/auth/Dockerfile
# syntax=docker/dockerfile:1

# ---- build stage: compile with Maven inside a JDK image -------------------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
# the cache mount keeps ~/.m2 between builds: dependencies are downloaded once
RUN --mount=type=cache,target=/root/.m2 mvn -B -q package -DskipTests

# ---- runtime stage: only a JRE and the jar --------------------------------
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd --system --uid 10001 app
COPY --from=build /src/target/app.jar app.jar
USER app
EXPOSE 8081
# use up to 75% of the container's memory limit for the heap
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]
services/auth/.dockerignore
target/
.idea/
.vscode/
*.iml
services/auth/pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
        <relativePath/>
    </parent>

    <groupId>boxoffice</groupId>
    <artifactId>auth</artifactId>
    <version>1.0.0</version>
    <name>auth</name>
    <description>BoxOffice auth: users, login, JWT issuing</description>

    <properties>
        <java.version>21</java.version>
        <!-- the same version Spring Security 7.1 uses in the gateway -->
        <nimbus-jose-jwt.version>10.9.1</nimbus-jose-jwt.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <!-- schema migrations (Boot 4: the starter brings the auto-configuration) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-flyway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- BCrypt password hashing only (not the whole Spring Security filter chain) -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-crypto</artifactId>
        </dependency>
        <!-- JWT signing (RS256) and the JWKS document -->
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>${nimbus-jose-jwt.version}</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>app</finalName>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Note what is not there: the full Spring Security starter. auth only needs the BCrypt encoder (spring-security-crypto) and a JWT library (nimbus-jose-jwt, the same one Spring Security uses inside the gateway).

services/auth/src/main/resources/application.yaml
server:
  port: 8081

spring:
  application:
    name: auth
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:boxoffice}
    username: ${DB_USER:boxoffice}
    password: ${DB_PASSWORD:boxoffice}
  flyway:
    schemas: auth
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: validate
  mvc:
    problemdetails:
      enabled: true

jwt:
  issuer: ${JWT_ISSUER:boxoffice-auth}
  audience: boxoffice
  ttl: ${JWT_TTL:PT1H}              # ISO-8601 duration: 1 hour

admin:
  email: ${ADMIN_EMAIL:admin@boxoffice.dev}
  password: ${ADMIN_PASSWORD:boxoffice-admin}

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics
  endpoint:
    health:
      probes:
        enabled: true
services/auth/src/main/resources/db/migration/V1__create_users.sql
CREATE TABLE auth.users (
    id             UUID         PRIMARY KEY,
    email          VARCHAR(200) NOT NULL UNIQUE,
    name           VARCHAR(200) NOT NULL,
    password_hash  VARCHAR(100) NOT NULL,          -- BCrypt, never the password itself
    role           VARCHAR(10)  NOT NULL CHECK (role IN ('USER', 'ADMIN')),
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now()
);

3. Users

services/auth/src/main/java/boxoffice/auth/User.java
package boxoffice.auth;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.OffsetDateTime;
import java.util.UUID;

@Entity
@Table(name = "users", schema = "auth")
public class User {

    @Id
    private UUID id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String name;

    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @Column(name = "created_at", nullable = false)
    private OffsetDateTime createdAt;

    protected User() {
        // required by JPA
    }

    public User(String email, String name, String passwordHash, Role role) {
        this.id = UUID.randomUUID();
        this.email = email;
        this.name = name;
        this.passwordHash = passwordHash;
        this.role = role;
        this.createdAt = OffsetDateTime.now();
    }

    public UUID getId() { return id; }
    public String getEmail() { return email; }
    public String getName() { return name; }
    public String getPasswordHash() { return passwordHash; }
    public Role getRole() { return role; }
}
services/auth/src/main/java/boxoffice/auth/Role.java
package boxoffice.auth;

public enum Role {
    USER, ADMIN
}
services/auth/src/main/java/boxoffice/auth/UserRepository.java
package boxoffice.auth;

import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, UUID> {

    Optional<User> findByEmail(String email);

    boolean existsByEmail(String email);
}
services/auth/src/main/java/boxoffice/auth/AuthApplication.java
package boxoffice.auth;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class AuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

The records exchanged with clients:

services/auth/src/main/java/boxoffice/auth/RegisterIn.java
package boxoffice.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record RegisterIn(
        @NotBlank @Size(max = 200) String name,
        @NotBlank @Email @Size(max = 200) String email,
        @NotBlank @Size(min = 8, max = 72) String password) {   // BCrypt uses at most 72 bytes
}
services/auth/src/main/java/boxoffice/auth/LoginIn.java
package boxoffice.auth;

import jakarta.validation.constraints.NotBlank;

public record LoginIn(@NotBlank String email, @NotBlank String password) {
}
services/auth/src/main/java/boxoffice/auth/TokenOut.java
package boxoffice.auth;

/** OAuth-style token response. */
public record TokenOut(String accessToken, String tokenType, long expiresIn) {
}
services/auth/src/main/java/boxoffice/auth/UserOut.java
package boxoffice.auth;

import java.util.UUID;

public record UserOut(UUID id, String name, String email, Role role) {

    static UserOut of(User u) {
        return new UserOut(u.getId(), u.getName(), u.getEmail(), u.getRole());
    }
}

4. Signing tokens and publishing the key

services/auth/src/main/java/boxoffice/auth/TokenService.java
package boxoffice.auth;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * Signs JWTs with a private RSA key (RS256) and publishes the public key as a JWKS.
 * Only auth holds the private key; anyone can verify a token with the public one.
 */
@Component
public class TokenService {

    private final RSAKey key;
    private final String issuer;
    private final String audience;
    private final Duration ttl;

    public TokenService(@Value("${jwt.issuer}") String issuer,
                        @Value("${jwt.audience}") String audience,
                        @Value("${jwt.ttl}") Duration ttl) throws JOSEException {
        // a new key pair at every start: fine for a lab, see the warning in the text
        this.key = new RSAKeyGenerator(2048)
                .keyID(UUID.randomUUID().toString())
                .keyUse(KeyUse.SIGNATURE)
                .algorithm(JWSAlgorithm.RS256)
                .generate();
        this.issuer = issuer;
        this.audience = audience;
        this.ttl = ttl;
    }

    public TokenOut issue(User user) {
        var now = Instant.now();
        var claims = new JWTClaimsSet.Builder()
                .issuer(issuer)
                .audience(audience)
                .subject(user.getId().toString())
                .claim("email", user.getEmail())
                .claim("name", user.getName())
                .claim("scope", user.getRole() == Role.ADMIN ? "user admin" : "user")
                .issueTime(Date.from(now))
                .expirationTime(Date.from(now.plus(ttl)))
                .jwtID(UUID.randomUUID().toString())
                .build();
        var header = new JWSHeader.Builder(JWSAlgorithm.RS256)
                .keyID(key.getKeyID())
                .type(JOSEObjectType.JWT)
                .build();
        try {
            var jwt = new SignedJWT(header, claims);
            jwt.sign(new RSASSASigner(key));
            return new TokenOut(jwt.serialize(), "Bearer", ttl.toSeconds());
        } catch (JOSEException e) {
            throw new IllegalStateException("could not sign the token", e);
        }
    }

    /** The public part only: {"keys":[{"kty":"RSA","kid":"...","n":"...","e":"AQAB",...}]} */
    public Map<String, Object> jwks() {
        return new JWKSet(key.toPublicJWK()).toJSONObject();
    }
}
Claim Meaning Checked by the gateway
iss who issued it (boxoffice-auth) yes: must match JWT_ISSUER
aud who it is for (boxoffice) yes: a token for another API is refused
sub the user id becomes X-User-Id
scope user or user admin becomes the authorities SCOPE_user, SCOPE_admin
exp expiry yes (with a 60 s clock-skew allowance)
kid (header) which key signed it used to pick the right key from the JWKS

A new key at every restart

TokenService generates the RSA key pair in memory when auth starts. Restart auth and every token issued before is rejected (unknown kid), and two auth replicas would each sign with a different key. In production the private key lives in a KMS or secrets manager, the JWKS lists the current and the previous key during a rotation, and keys rotate on a schedule. For a lab, a restart that logs everybody out is a feature: you see why rotation needs care.

5. Register, login, me

services/auth/src/main/java/boxoffice/auth/AuthService.java
package boxoffice.auth;

import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

@Service
public class AuthService {

    private static final Logger log = LoggerFactory.getLogger(AuthService.class);

    private final UserRepository users;
    private final TokenService tokens;
    // cost 10 = 2^10 rounds: deliberately slow (tens of ms) to make password guessing expensive
    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
    private final String adminEmail;
    private final String adminPassword;

    public AuthService(UserRepository users, TokenService tokens,
                       @Value("${admin.email}") String adminEmail,
                       @Value("${admin.password}") String adminPassword) {
        this.users = users;
        this.tokens = tokens;
        this.adminEmail = adminEmail;
        this.adminPassword = adminPassword;
    }

    @Transactional
    public UserOut register(RegisterIn in) {
        var email = in.email().toLowerCase();
        if (users.existsByEmail(email)) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "e-mail already registered");
        }
        var user = users.save(new User(email, in.name(), encoder.encode(in.password()), Role.USER));
        return UserOut.of(user);
    }

    @Transactional(readOnly = true)
    public TokenOut login(LoginIn in) {
        var user = users.findByEmail(in.email().toLowerCase())
                .filter(u -> encoder.matches(in.password(), u.getPasswordHash()))
                // same answer for "no such user" and "wrong password": do not help attackers
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid credentials"));
        return tokens.issue(user);
    }

    @Transactional(readOnly = true)
    public UserOut find(UUID id) {
        return users.findById(id).map(UserOut::of)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "user not found"));
    }

    /** Creates the administrator on the first start (credentials from ADMIN_EMAIL / ADMIN_PASSWORD). */
    @EventListener(ApplicationReadyEvent.class)
    @Transactional
    public void createAdmin() {
        if (!users.existsByEmail(adminEmail)) {
            users.save(new User(adminEmail, "Administrator", encoder.encode(adminPassword), Role.ADMIN));
            log.info("created administrator {}", adminEmail);
        }
    }
}
services/auth/src/main/java/boxoffice/auth/AuthController.java
package boxoffice.auth;

import jakarta.validation.Valid;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService service;
    private final TokenService tokens;

    public AuthController(AuthService service, TokenService tokens) {
        this.service = service;
        this.tokens = tokens;
    }

    @PostMapping("/register")
    @ResponseStatus(HttpStatus.CREATED)
    public UserOut register(@Valid @RequestBody RegisterIn in) {
        return service.register(in);
    }

    @PostMapping("/login")
    public TokenOut login(@Valid @RequestBody LoginIn in) {
        return service.login(in);
    }

    /** Protected route: the gateway validated the JWT and tells us who the user is. */
    @GetMapping("/me")
    public UserOut me(@RequestHeader("X-User-Id") UUID userId) {
        return service.find(userId);
    }

    @GetMapping("/.well-known/jwks.json")
    public Map<String, Object> jwks() {
        return tokens.jwks();
    }
}

Details that matter:

  • BCrypt cost 10 means 2¹⁰ rounds of key expansion: tens of milliseconds per hash. Fast enough for a human logging in, slow enough to make an offline attack on a leaked table expensive. It also makes /auth/login the most CPU-hungry endpoint of BoxOffice — you will measure it.
  • Same error for "unknown e-mail" and "wrong password" (401 invalid credentials): different messages would let an attacker discover which e-mails have accounts.
  • /auth/me does not read the token: it trusts X-User-Id, set by the gateway after validating it. auth is not reachable from outside except through the gateway.
  • The administrator is created once, on ApplicationReadyEvent, from ADMIN_EMAIL/ADMIN_PASSWORD. Change the password in any shared environment.

In the interview

"Authentication at the gateway, authorization close to the data": the gateway rejects anonymous or malformed requests early and cheaply; fine-grained rules ("is this hold yours?") stay in the service that owns the data — inventory checks user_id on every hold in lab 6.

Next: 2. The gateway.