refactor(api): one error body, token audiences, contract fixture
Build, Test & Push Core Service / quality-check (push) Successful in 1m7s
Build, Test & Push Core Service / docker (push) Successful in 1m23s

This commit is contained in:
lobo committed 2026-09-29 14:48:28 +02:00
1 parent 2360beeb81
commit 73cfcb58e2
24 files changed
+633 -129

No files matched your search

+11 -5
View File
@@ -77,14 +77,20 @@ To Be Played is split into three repositories:
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay JWKS by the token's `kid` and loads again when a new `kid` shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's until Core restarts. `JWT_PUBLIC_KEY` is used only until the first JWKS load works. The token comes from Auth's
`tbp_access` cookie or a `Bearer` header, whichever verifies first. Core reads `userId`, `steamId` and the active `tbp_access` cookie or a `Bearer` header, whichever verifies first. Every token names its kind in `aud`
`role` (missing or unknown = USER) from the claims and grants that role and every lower one (`tbp-login`, `tbp-public-profile`, `tbp-internal`, with `iss=tbp-auth`); a login's `sub` is the user id. Core reads
(`USER < AUDITOR < PLATFORM_ADMIN`). `steamId` and the active `role` (missing or unknown = USER) from the claims and grants that role and every lower one
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` comes from an Auth from before item 26 (kind in `typ`,
user id in `userId`) and is still accepted; item 29 removes that path. The claims are pinned in
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the `POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin. frontend origin.
A token with a `typ` claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is A token for another audience (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header. accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` header.
`/internal/**` has its own filter chain: it accepts only a service token of Auth (`typ=service`, with `exp`, sent Every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
`fields: [{field, message}]`, a duplicate key `409 CONFLICT`, anything unexpected `500 INTERNAL_ERROR` without details
(`shared/api/GlobalExceptionController`).
`/internal/**` has its own filter chain: it accepts only a service token of Auth (`aud=tbp-internal`, with `exp`, sent
as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its as `Bearer`), never a login, also not in the dev profile. Traefik exposes the path, so the signed token is its
only guard. only guard.
@@ -65,6 +65,6 @@ public class DevJwtAuthenticationFilter extends OncePerRequestFilter {
private UserPrincipal dummyPrincipal() { private UserPrincipal dummyPrincipal() {
log.trace("Dev mode: no JWT present, injecting dummy principal"); log.trace("Dev mode: no JWT present, injecting dummy principal");
return new UserPrincipal("1", "dev@example.com", 76561198000000001L); return new UserPrincipal("1", 76561198000000001L);
} }
} }
@@ -10,17 +10,26 @@ import io.jsonwebtoken.Jwts;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
* Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key. * Verifies JWTs issued by tbp-auth-service. Verify-only: {@link JwksKeyLocator} finds the Ed25519 public key.
* <p>
* A token names its kind in {@code aud}, and {@code sub} is the user id of a login. A token without {@code aud} comes
* from an Auth from before item 26: its kind is in {@code typ} and the user id in {@code userId}. Item 29 removes
* that path.
*/ */
@Service @Service
public class JwtService implements ProfileTicketVerifier { public class JwtService implements ProfileTicketVerifier {
// Only non-login tokens carry this claim; parseLoginToken rejects them. static final String ISSUER = "tbp-auth";
static final String LOGIN_AUDIENCE = "tbp-login";
static final String PROFILE_TICKET_AUDIENCE = "tbp-public-profile";
static final String SERVICE_AUDIENCE = "tbp-internal";
// The kind of a token before item 26; item 29 removes it.
private static final String TYPE_CLAIM = "typ"; private static final String TYPE_CLAIM = "typ";
private static final String PROFILE_TICKET_TYPE = "public-profile"; private static final String PROFILE_TICKET_TYPE = "public-profile";
private static final String SERVICE_TOKEN_TYPE = "service"; private static final String SERVICE_TOKEN_TYPE = "service";
@@ -36,6 +45,20 @@ public class JwtService implements ProfileTicketVerifier {
return parser.parseSignedClaims(token).getPayload(); return parser.parseSignedClaims(token).getPayload();
} }
private static boolean hasAudience(Claims claims) {
return claims.getAudience() != null && !claims.getAudience().isEmpty();
}
/**
* @param legacyType the {@code typ} of this kind before item 26, null for a login
*/
private static boolean isKind(Claims claims, String audience, String legacyType) {
if (hasAudience(claims)) {
return claims.getAudience().contains(audience) && ISSUER.equals(claims.getIssuer());
}
return Objects.equals(legacyType, claims.get(TYPE_CLAIM, String.class));
}
/** /**
* The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is * The user of a login token with a valid signature. Empty for anything else, also for a profile ticket: it is
* signed with the same key, but is no login. * signed with the same key, but is no login.
@@ -43,11 +66,15 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<UserPrincipal> parseLoginToken(String token) { public Optional<UserPrincipal> parseLoginToken(String token) {
try { try {
Claims claims = claims(token); Claims claims = claims(token);
String userId = claims.get("userId", String.class); if (!isKind(claims, LOGIN_AUDIENCE, null)) {
if (claims.get(TYPE_CLAIM) != null || claims.getSubject() == null || userId == null) {
return Optional.empty(); return Optional.empty();
} }
return Optional.of(new UserPrincipal(userId, claims.getSubject(), claims.get("steamId", Long.class), String userId = hasAudience(claims) ? claims.getSubject()
: claims.getSubject() == null ? null : claims.get("userId", String.class);
if (userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class),
Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null))); Role.parse(claims.get(ROLE_CLAIM) instanceof String role ? role : null)));
} catch (Exception e) { } catch (Exception e) {
return Optional.empty(); return Optional.empty();
@@ -60,7 +87,7 @@ public class JwtService implements ProfileTicketVerifier {
public boolean isServiceToken(String token) { public boolean isServiceToken(String token) {
try { try {
Claims claims = claims(token); Claims claims = claims(token);
return SERVICE_TOKEN_TYPE.equals(claims.get(TYPE_CLAIM, String.class)) && claims.getExpiration() != null; return isKind(claims, SERVICE_AUDIENCE, SERVICE_TOKEN_TYPE) && claims.getExpiration() != null;
} catch (Exception e) { } catch (Exception e) {
return false; return false;
} }
@@ -74,7 +101,7 @@ public class JwtService implements ProfileTicketVerifier {
public Optional<ProfileTicket> verifyProfileTicket(String ticket) { public Optional<ProfileTicket> verifyProfileTicket(String ticket) {
try { try {
Claims claims = claims(ticket); Claims claims = claims(ticket);
if (!PROFILE_TICKET_TYPE.equals(claims.get(TYPE_CLAIM, String.class))) { if (!isKind(claims, PROFILE_TICKET_AUDIENCE, PROFILE_TICKET_TYPE)) {
return Optional.empty(); return Optional.empty();
} }
long userId = Long.parseLong(claims.get("profileUserId", String.class)); long userId = Long.parseLong(claims.get("profileUserId", String.class));
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.ImportHistoryDto; import core_service.modules.library.api.dto.ImportHistoryDto;
import core_service.modules.library.api.dto.ImportRequest; import core_service.modules.library.api.dto.ImportRequest;
import core_service.modules.library.api.dto.ImportSaveRequest; import core_service.modules.library.api.dto.ImportSaveRequest;
@@ -28,9 +29,9 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class GameImportController { public class GameImportController {
private static final Map<String, String> NOT_FOUND = Map.of("code", "IMPORT_NOT_FOUND", "message", "No such import"); private static final ErrorResponse NOT_FOUND = new ErrorResponse("IMPORT_NOT_FOUND", "No such import");
private static final ResponseEntity<?> IMPORT_RUNNING = ResponseEntity.status(HttpStatus.CONFLICT) private static final ResponseEntity<?> IMPORT_RUNNING = ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "IMPORT_RUNNING", "message", "Another import is still being matched")); .body(new ErrorResponse("IMPORT_RUNNING", "Another import is still being matched"));
private final GameImportService importService; private final GameImportService importService;
@@ -42,7 +43,7 @@ public class GameImportController {
.body(Map.of("id", created.id())); .body(Map.of("id", created.id()));
case GameImportService.CreateResult.AlreadyMatching ignored -> IMPORT_RUNNING; case GameImportService.CreateResult.AlreadyMatching ignored -> IMPORT_RUNNING;
case GameImportService.CreateResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) case GameImportService.CreateResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("code", "IGDB_OFF", "message", "Importing games needs the IGDB app")); .body(new ErrorResponse("IGDB_OFF", "Importing games needs the IGDB app"));
}; };
} }
@@ -65,7 +66,7 @@ public class GameImportController {
case DONE -> ResponseEntity.accepted().build(); case DONE -> ResponseEntity.accepted().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND); case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT) case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "IMPORT_NOT_READY", "message", "The import is still matching or saved already")); .body(new ErrorResponse("IMPORT_NOT_READY", "The import is still matching or saved already"));
}; };
} }
@@ -75,7 +76,7 @@ public class GameImportController {
case DONE -> ResponseEntity.accepted().build(); case DONE -> ResponseEntity.accepted().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND); case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case WRONG_STATUS -> ResponseEntity.status(HttpStatus.CONFLICT) case WRONG_STATUS -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "IMPORT_NOT_FAILED", "message", "Only a failed import can be tried again")); .body(new ErrorResponse("IMPORT_NOT_FAILED", "Only a failed import can be tried again"));
case ALREADY_MATCHING -> IMPORT_RUNNING; case ALREADY_MATCHING -> IMPORT_RUNNING;
}; };
} }
@@ -93,7 +94,7 @@ public class GameImportController {
case DONE -> ResponseEntity.noContent().build(); case DONE -> ResponseEntity.noContent().build();
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND); case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT) case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "IMPORT_NOT_SAVED", "message", "Only a saved import can be undone")); .body(new ErrorResponse("IMPORT_NOT_SAVED", "Only a saved import can be undone"));
}; };
} }
} }
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AchievementSummaryDto; import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto; import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogEntryDto; import core_service.modules.library.api.dto.BacklogEntryDto;
@@ -62,6 +63,7 @@ import java.util.Set;
@RequiredArgsConstructor @RequiredArgsConstructor
public class LibraryController { public class LibraryController {
private static final ErrorResponse GAME_NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80); private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
private static final int DEFAULT_SIZE = 20; private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7; private static final int MIN_ACTIVITY_DAYS = 7;
@@ -129,7 +131,7 @@ public class LibraryController {
@PostMapping("/sync") @PostMapping("/sync")
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
if (principal.steamId() == null) { if (principal.steamId() == null) {
return ResponseEntity.badRequest().body(Map.of("code", "NO_STEAM_ID", "message", "User has no Steam ID")); return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
} }
try { try {
syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId())); syncService.forceSync(principal.getIdAsLong(), String.valueOf(principal.steamId()));
@@ -152,7 +154,7 @@ public class LibraryController {
return gameDetailService.getGameDetail(userId, steamId, appid) return gameDetailService.getGameDetail(userId, steamId, appid)
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); .body(GAME_NOT_FOUND));
} }
/** /**
@@ -201,9 +203,9 @@ public class LibraryController {
case GameMergeService.Result.Merged merged -> ResponseEntity.ok(new MergedGameDto(merged.appid())); case GameMergeService.Result.Merged merged -> ResponseEntity.ok(new MergedGameDto(merged.appid()));
case GameMergeService.Result.NotFound _ -> unknownGame(); case GameMergeService.Result.NotFound _ -> unknownGame();
case GameMergeService.Result.SameGame _ -> ResponseEntity.badRequest() case GameMergeService.Result.SameGame _ -> ResponseEntity.badRequest()
.body(Map.of("code", "SAME_GAME", "message", "A game cannot be merged with itself")); .body(new ErrorResponse("SAME_GAME", "A game cannot be merged with itself"));
case GameMergeService.Result.SteamGames _ -> ResponseEntity.badRequest() case GameMergeService.Result.SteamGames _ -> ResponseEntity.badRequest()
.body(Map.of("code", "STEAM_GAMES", "message", "Two Steam games cannot be merged")); .body(new ErrorResponse("STEAM_GAMES", "Two Steam games cannot be merged"));
}; };
} }
@@ -212,11 +214,11 @@ public class LibraryController {
} }
private static ResponseEntity<?> unknownGame() { private static ResponseEntity<?> unknownGame() {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("code", "GAME_NOT_FOUND", "message", "Unknown game")); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("GAME_NOT_FOUND", "Unknown game"));
} }
private static ResponseEntity<?> invalidCorrection(String message) { private static ResponseEntity<?> invalidCorrection(String message) {
return ResponseEntity.badRequest().body(Map.of("code", "INVALID_CORRECTION", "message", message)); return ResponseEntity.badRequest().body(new ErrorResponse("INVALID_CORRECTION", message));
} }
@PutMapping("/games/{appid}/backlog") @PutMapping("/games/{appid}/backlog")
@@ -226,7 +228,7 @@ public class LibraryController {
return backlogService.update(principal.getIdAsLong(), appid, entry) return backlogService.update(principal.getIdAsLong(), appid, entry)
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); .body(GAME_NOT_FOUND));
} }
@PutMapping("/games/{appid}/hidden") @PutMapping("/games/{appid}/hidden")
@@ -236,9 +238,9 @@ public class LibraryController {
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) { return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
case DONE -> ResponseEntity.ok(update); case DONE -> ResponseEntity.ok(update);
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND) case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")); .body(GAME_NOT_FOUND);
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT) case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "MANUAL_GAME", "message", "A game added by hand can be deleted, not hidden")); .body(new ErrorResponse("MANUAL_GAME", "A game added by hand can be deleted, not hidden"));
}; };
} }
@@ -248,7 +250,7 @@ public class LibraryController {
@Valid @RequestBody FinishedHintDismissal dismissal) { @Valid @RequestBody FinishedHintDismissal dismissal) {
if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) { if (!backlogService.dismissFinishedHint(principal.getIdAsLong(), appid, dismissal.dismissed())) {
return ResponseEntity.status(HttpStatus.NOT_FOUND) return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")); .body(GAME_NOT_FOUND);
} }
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@@ -260,7 +262,7 @@ public class LibraryController {
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status()) return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); .body(GAME_NOT_FOUND));
} }
@GetMapping("/activity") @GetMapping("/activity")
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse;
import core_service.modules.library.api.dto.AddGameRequest; import core_service.modules.library.api.dto.AddGameRequest;
import core_service.modules.library.api.dto.AddedGameDto; import core_service.modules.library.api.dto.AddedGameDto;
import core_service.modules.library.api.dto.GameSearchDto; import core_service.modules.library.api.dto.GameSearchDto;
@@ -23,7 +24,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.net.URI; import java.net.URI;
import java.util.Map;
/** /**
* Games added by hand from an IGDB search, next to the Steam library. * Games added by hand from an IGDB search, next to the Steam library.
@@ -33,9 +33,8 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ManualGameController { public class ManualGameController {
private static final Map<String, String> NOT_FOUND = Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"); private static final ErrorResponse NOT_FOUND = new ErrorResponse("GAME_NOT_FOUND", "Game not in user library");
private static final Map<String, String> STEAM_GAME = Map.of("code", "STEAM_GAME", private static final ErrorResponse STEAM_GAME = new ErrorResponse("STEAM_GAME", "Steam games come from the sync and cannot be changed here");
"message", "Steam games come from the sync and cannot be changed here");
private final ManualGameService manualGameService; private final ManualGameService manualGameService;
@@ -45,7 +44,7 @@ public class ManualGameController {
return manualGameService.search(principal.getIdAsLong(), query) return manualGameService.search(principal.getIdAsLong(), query)
.<ResponseEntity<?>>map(ResponseEntity::ok) .<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY) .orElseGet(() -> ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("code", "IGDB_FAILED", "message", "IGDB did not answer"))); .body(new ErrorResponse("IGDB_FAILED", "IGDB did not answer")));
} }
@PostMapping("/games") @PostMapping("/games")
@@ -58,11 +57,11 @@ public class ManualGameController {
.created(URI.create("/api/library/games/" + added.appid())) .created(URI.create("/api/library/games/" + added.appid()))
.body(new AddedGameDto(added.appid())); .body(new AddedGameDto(added.appid()));
case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT) case ManualGameService.AddResult.AlreadyInLibrary ignored -> ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("code", "ALREADY_IN_LIBRARY", "message", "The game is already in the library")); .body(new ErrorResponse("ALREADY_IN_LIBRARY", "The game is already in the library"));
case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST) case ManualGameService.AddResult.UnknownGame ignored -> ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("code", "UNKNOWN_GAME", "message", "IGDB has no such game or did not answer")); .body(new ErrorResponse("UNKNOWN_GAME", "IGDB has no such game or did not answer"));
case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) case ManualGameService.AddResult.SearchUnavailable ignored -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("code", "IGDB_OFF", "message", "Adding games needs the IGDB app")); .body(new ErrorResponse("IGDB_OFF", "Adding games needs the IGDB app"));
}; };
} }
@@ -1,5 +1,6 @@
package core_service.modules.library.api; package core_service.modules.library.api;
import core_service.shared.api.ErrorResponse;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.shared.security.ProfileTicket; import core_service.shared.security.ProfileTicket;
import core_service.shared.security.ProfileTicketVerifier; import core_service.shared.security.ProfileTicketVerifier;
@@ -11,7 +12,6 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -31,6 +31,6 @@ public class PublicProfileController {
return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket)) return (ticket == null ? Optional.<ProfileTicket>empty() : profileTicketVerifier.verifyProfileTicket(ticket))
.<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections()))) .<ResponseEntity<?>>map(t -> ResponseEntity.ok(queryService.getPublicLibrary(t.userId(), t.profileStats(), t.sections())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED) .orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("code", "INVALID_TICKET", "message", "Missing, invalid or expired profile ticket"))); .body(new ErrorResponse("INVALID_TICKET", "Missing, invalid or expired profile ticket")));
} }
} }
@@ -0,0 +1,21 @@
package core_service.shared.api;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
/**
* The body of every error answer.
*
* @param fields the refused request fields of a {@code VALIDATION_FAILED}, else left out
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ErrorResponse(String code, String message, List<FieldError> fields) {
public ErrorResponse(String code, String message) {
this(code, message, null);
}
public record FieldError(String field, String message) {
}
}
@@ -0,0 +1,145 @@
package core_service.shared.api;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.MethodParameter;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.sql.SQLException;
import java.util.List;
/**
* Gives every error the {@link ErrorResponse} body. Spring's own exceptions keep their status; a 400 is
* {@code VALIDATION_FAILED}. A module's own exceptions belong in its own handler.
*/
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
@Slf4j
public class GlobalExceptionController extends ResponseEntityExceptionHandler {
static final String VALIDATION_FAILED = "VALIDATION_FAILED";
private static final String INTERNAL_ERROR = "INTERNAL_ERROR";
// Postgres unique_violation. The state decides: the message text follows the server's language.
private static final String UNIQUE_VIOLATION = "23505";
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
return validationFailed(ex.getBindingResult().getFieldErrors().stream()
.map(error -> new ErrorResponse.FieldError(error.getField(), error.getDefaultMessage()))
.toList());
}
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(HandlerMethodValidationException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
return validationFailed(ex.getParameterValidationResults().stream()
.flatMap(result -> result.getResolvableErrors().stream()
.map(error -> new ErrorResponse.FieldError(
error instanceof FieldError field ? field.getField() : parameterName(result),
error.getDefaultMessage())))
.toList());
}
/**
* The base keeps its checks (a committed response, the error attribute of a 500); only the body is ours.
*/
@Override
protected ResponseEntity<Object> handleExceptionInternal(Exception ex, Object body, HttpHeaders headers,
HttpStatusCode statusCode, WebRequest request) {
ResponseEntity<Object> answer = super.handleExceptionInternal(ex, body, headers, statusCode, request);
if (answer == null) {
return null;
}
ErrorResponse error;
if (statusCode.is5xxServerError()) {
log.error("Unexpected error", ex);
error = new ErrorResponse(INTERNAL_ERROR, "Unexpected error");
} else if (statusCode.value() == HttpStatus.BAD_REQUEST.value()) {
String field = ex instanceof MethodArgumentTypeMismatchException mismatch ? mismatch.getName()
: ex instanceof MissingServletRequestParameterException missing ? missing.getParameterName() : null;
error = new ErrorResponse(VALIDATION_FAILED, "The request is not valid",
field == null ? null : List.of(new ErrorResponse.FieldError(field, "Missing or not readable")));
} else {
HttpStatus status = HttpStatus.resolve(statusCode.value());
error = new ErrorResponse(status != null ? status.name() : "ERROR",
status != null ? status.getReasonPhrase() : "Error");
}
return ResponseEntity.status(answer.getStatusCode()).headers(answer.getHeaders()).body(error);
}
/**
* A duplicate key is a parallel request that won. Any other refused write (a value too long, a missing row) is a
* bug and stays a 500.
*/
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> dataIntegrity(DataIntegrityViolationException ex) throws Exception {
if (!isUniqueViolation(ex)) {
return unexpected(ex);
}
log.warn("Database refused a write: {}", ex.getMostSpecificCause().getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("CONFLICT", "The data changed at the same time; try again"));
}
/**
* Rethrows Spring Security's exceptions: its filters answer them with 401 and 403.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> unexpected(Exception ex) throws Exception {
if (ex instanceof AccessDeniedException || ex instanceof AuthenticationException) {
throw ex;
}
log.error("Unexpected error", ex);
return ResponseEntity.internalServerError().body(new ErrorResponse(INTERNAL_ERROR, "Unexpected error"));
}
private static boolean isUniqueViolation(Throwable ex) {
for (Throwable cause = ex; cause != null; cause = cause.getCause()) {
if (cause instanceof SQLException sql && UNIQUE_VIOLATION.equals(sql.getSQLState())) {
return true;
}
}
return false;
}
private static ResponseEntity<Object> validationFailed(List<ErrorResponse.FieldError> fields) {
return ResponseEntity.badRequest().body(new ErrorResponse(VALIDATION_FAILED, "The request is not valid", fields));
}
// The name in the request, e.g. "q" of @RequestParam("q") String query.
private static String parameterName(ParameterValidationResult result) {
MethodParameter parameter = result.getMethodParameter();
RequestParam requestParam = parameter.getParameterAnnotation(RequestParam.class);
if (requestParam != null && !requestParam.name().isEmpty()) {
return requestParam.name();
}
PathVariable pathVariable = parameter.getParameterAnnotation(PathVariable.class);
if (pathVariable != null && !pathVariable.name().isEmpty()) {
return pathVariable.name();
}
return parameter.getParameterName();
}
}
@@ -7,13 +7,12 @@ import java.security.Principal;
*/ */
public record UserPrincipal( public record UserPrincipal(
String id, String id,
String email,
Long steamId, Long steamId,
Role role Role role
) implements Principal { ) implements Principal {
public UserPrincipal(String id, String email, Long steamId) { public UserPrincipal(String id, Long steamId) {
this(id, email, steamId, Role.USER); this(id, steamId, Role.USER);
} }
@Override @Override
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.lenient;
*/ */
class AccessTokenFiltersTest extends AbstractUnitTest { class AccessTokenFiltersTest extends AbstractUnitTest {
private static final UserPrincipal USER = new UserPrincipal("42", "a@b.de", 76561198000000042L); private static final UserPrincipal USER = new UserPrincipal("42", 76561198000000042L);
@Mock @Mock
private JwtService jwtService; private JwtService jwtService;
@@ -1,16 +1,15 @@
package core_service.configuration.security; package core_service.configuration.security;
import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.util.Date;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -25,13 +24,7 @@ class ExpiredAccessTokenTest extends AbstractIntegrationTest {
private MockMvc mockMvc; private MockMvc mockMvc;
private static String expiredAccessToken() { private static String expiredAccessToken() {
return Jwts.builder() return ContractTokens.sign(ContractTokens.login(42, Role.USER, null), TEST_KEYS.getPrivate(), -60_000);
.subject("a@b.de")
.claim("userId", "42")
.issuedAt(new Date(System.currentTimeMillis() - 120_000))
.expiration(new Date(System.currentTimeMillis() - 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
} }
@Test @Test
@@ -4,6 +4,7 @@ import core_service.shared.security.ProfileTicket;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest; import core_service.shared.test.AbstractUnitTest;
import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -16,6 +17,7 @@ import java.security.PrivateKey;
import java.util.Base64; import java.util.Base64;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
@@ -62,7 +64,7 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_ReturnsTheUser() { void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L); String token = signToken("0815", "test@example.com", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", "test@example.com", 123456789L)), assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token)); jwtService.parseLoginToken(token));
} }
@@ -205,4 +207,66 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role()); assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
} }
private String sign(Map<String, Object> claims) {
return ContractTokens.sign(claims, signingKey, 60_000);
}
// The shapes of item 29: without the claims an Auth from before item 26 needs.
private static Map<String, Object> without(Map<String, Object> claims, String... names) {
for (String name : names) {
claims.remove(name);
}
return claims;
}
@Test
void contractTokens_OfEveryKind_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.claims(ContractTokens.LOGIN))).orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE)))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(ContractTokens.claims(ContractTokens.SERVICE))));
}
@Test
void legacyTokens_OfAnAuthFromBeforeItem26_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(ContractTokens.legacyClaims(ContractTokens.LOGIN))).orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(ContractTokens.legacyClaims(ContractTokens.PUBLIC_PROFILE)))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))));
}
@Test
void tokensWithoutTheOldClaims_AreAccepted() {
assertEquals("42", jwtService.parseLoginToken(sign(without(ContractTokens.claims(ContractTokens.LOGIN), "userId")))
.orElseThrow().id());
assertEquals(42L, jwtService.verifyProfileTicket(sign(without(ContractTokens.claims(ContractTokens.PUBLIC_PROFILE), "typ")))
.orElseThrow().userId());
assertTrue(jwtService.isServiceToken(sign(without(ContractTokens.claims(ContractTokens.SERVICE), "typ"))));
}
@Test
void aTokenForAnotherAudience_IsRejected() {
Map<String, Object> ticketAsLogin = ContractTokens.claims(ContractTokens.LOGIN);
ticketAsLogin.put("aud", List.of(JwtService.PROFILE_TICKET_AUDIENCE));
Map<String, Object> loginAsService = ContractTokens.claims(ContractTokens.SERVICE);
loginAsService.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
Map<String, Object> loginAsTicket = ContractTokens.claims(ContractTokens.PUBLIC_PROFILE);
loginAsTicket.put("aud", List.of(JwtService.LOGIN_AUDIENCE));
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(ticketAsLogin)));
assertFalse(jwtService.isServiceToken(sign(loginAsService)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(sign(loginAsTicket)));
}
@Test
void aTokenWithAnAudienceButAnotherIssuer_IsRejected() {
Map<String, Object> login = ContractTokens.claims(ContractTokens.LOGIN);
login.put("iss", "someone-else");
Map<String, Object> service = ContractTokens.claims(ContractTokens.SERVICE);
service.remove("iss");
assertEquals(Optional.empty(), jwtService.parseLoginToken(sign(login)));
assertFalse(jwtService.isServiceToken(sign(service)));
}
} }
@@ -1,6 +1,6 @@
package core_service.configuration.security; package core_service.configuration.security;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.params.provider.ValueSource;
@@ -19,8 +19,8 @@ import org.testcontainers.junit.jupiter.Testcontainers;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.util.Base64; import java.util.Base64;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -77,9 +77,10 @@ class PublicEndpointsTest {
@Test @Test
void profileTicketIsNoLogin() throws Exception { void profileTicketIsNoLogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1") Map<String, Object> claims = ContractTokens.ticket(1, null, null);
.claim("userId", "1").expiration(new Date(System.currentTimeMillis() + 60_000)) // A ticket that also carries a user id is still no login.
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact(); claims.put("userId", "1");
String ticket = ContractTokens.sign(claims, KEYS.getPrivate(), 60_000);
int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket)) int status = mockMvc.perform(get("/api/library/games-count").header("Authorization", "Bearer " + ticket))
.andReturn().getResponse().getStatus(); .andReturn().getResponse().getStatus();
@@ -95,9 +96,8 @@ class PublicEndpointsTest {
// Without a login: a class role marker on the way would answer 401. // Without a login: a class role marker on the way would answer 401.
@Test @Test
void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception { void publicProfileWithSharedStats_WorksWithoutALogin() throws Exception {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "1") String ticket = ContractTokens.sign(ContractTokens.ticket(1, List.of("TOP_GAMES", "OVERVIEW"), null),
.claim("profileStats", List.of("TOP_GAMES", "OVERVIEW")).expiration(new Date(System.currentTimeMillis() + 60_000)) KEYS.getPrivate(), 60_000);
.signWith(KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket)) mockMvc.perform(get("/api/public/profiles/library").header("X-Profile-Ticket", ticket))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -4,7 +4,7 @@ import core_service.shared.security.Public;
import core_service.shared.security.RequiresRole; import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -18,8 +18,6 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -93,13 +91,7 @@ class RoleCheckFlowTest extends AbstractIntegrationTest {
private ProbeService probeService; private ProbeService probeService;
private static String token(Role role) { private static String token(Role role) {
return Jwts.builder() return ContractTokens.loginToken(4711, role);
.subject("probe@example.com")
.claim("userId", "4711")
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
} }
private ResultActions call(String path, Role role) throws Exception { private ResultActions call(String path, Role role) throws Exception {
@@ -1,25 +1,29 @@
package core_service.modules.account.api; package core_service.modules.account.api;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.JwtBuilder; import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.JsonNode;
import java.util.Date; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -40,20 +44,15 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@AfterEach @AfterEach
void cleanUp() { void cleanUp() {
jdbc.update("delete from deleted_user where user_id = ?", USER_ID); jdbc.update("delete from deleted_user where user_id in (?, 42)", USER_ID);
}
private static String token(JwtBuilder builder) {
return builder.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private static String serviceToken() { private static String serviceToken() {
return token(Jwts.builder().subject("tbp-auth").claim("typ", "service")); return ContractTokens.serviceToken();
} }
private static String loginToken() { private static String loginToken() {
return token(Jwts.builder().subject("a@b.de").claim("userId", String.valueOf(USER_ID))); return ContractTokens.loginToken(USER_ID);
} }
private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception { private ResultActions send(MockHttpServletRequestBuilder request, String body, String token) throws Exception {
@@ -107,6 +106,38 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
@Test @Test
void prune_WithAnEmptyListIs400() throws Exception { void prune_WithAnEmptyListIs400() throws Exception {
send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken()).andExpect(status().isBadRequest()); send(post("/internal/accounts/prune"), "{\"knownUserIds\":[],\"dryRun\":false}", serviceToken())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("knownUserIds"));
}
private static MockHttpServletRequestBuilder contractRequest(JsonNode call) {
return request(HttpMethod.valueOf(call.path("method").asString()), call.path("path").asString());
}
// The calls as Auth's CoreAccountService sends them, with the service tokens of an old and a new Auth.
@ParameterizedTest
@ValueSource(booleans = {false, true})
void contractCalls_AreAnswered(boolean legacyToken) throws Exception {
String token = legacyToken ? ContractTokens.sign(ContractTokens.legacyClaims(ContractTokens.SERVICE))
: ContractTokens.serviceToken();
// replaceDeleted with [] removes every tombstone, also those of other tests in this database.
List<Long> tombstones = jdbc.queryForList("select user_id from deleted_user", Long.class);
try {
for (String name : List.of("markDeleted", "replaceDeleted")) {
JsonNode call = ContractTokens.internalCall(name);
send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isNoContent());
}
} finally {
tombstones.forEach(userId -> jdbc.update("insert into deleted_user (user_id) values (?) on conflict do nothing", userId));
}
for (String name : List.of("purge", "prune")) {
JsonNode call = ContractTokens.internalCall(name);
ResultActions result = send(contractRequest(call), call.path("request").toString(), token).andExpect(status().isOk());
for (String field : call.path("response").propertyNames()) {
result.andExpect(jsonPath("$." + field).exists());
}
}
} }
} }
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -22,7 +22,6 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant; import java.time.Instant;
import java.util.Date;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.contains;
@@ -77,13 +76,7 @@ class GameCorrectionFlowTest extends AbstractIntegrationTest {
} }
private static String token(Role role) { private static String token(Role role) {
return Jwts.builder() return ContractTokens.loginToken(AUDITOR_ID, role);
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
} }
private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception { private ResultActions correct(Role role, long appid, String field, String valueJson) throws Exception {
@@ -7,16 +7,16 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.domain.UserStats;
import core_service.modules.library.repo.GameCorrectionRepository; import core_service.modules.library.repo.GameCorrectionRepository;
import core_service.modules.library.repo.GameMoodsRepository; import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.repo.UserStatsRepository; import core_service.modules.library.repo.UserStatsRepository;
import core_service.modules.library.domain.UserStats;
import core_service.shared.security.Role; import core_service.shared.security.Role;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -30,7 +30,6 @@ import org.springframework.test.web.servlet.ResultActions;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -94,13 +93,7 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
} }
private static String token(Role role) { private static String token(Role role) {
return Jwts.builder() return ContractTokens.loginToken(AUDITOR_ID, role);
.subject("auditor@example.com")
.claim("userId", String.valueOf(AUDITOR_ID))
.claim("role", role.name())
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact();
} }
private ResultActions merge(Role role, long appid, long otherAppid) throws Exception { private ResultActions merge(Role role, long appid, long otherAppid) throws Exception {
@@ -14,17 +14,28 @@ import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.sql.SQLException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -102,6 +113,50 @@ class LibraryControllerStatsTest extends AbstractIntegrationTest {
@Test @Test
void stats_UnknownGroup_Is400() throws Exception { void stats_UnknownGroup_Is400() throws Exception {
mockMvc.perform(get("/api/library/stats").param("groups", "MONEY")) mockMvc.perform(get("/api/library/stats").param("groups", "MONEY"))
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("groups"));
}
@Test
void anUnexpectedError_Is500WithoutDetails() throws Exception {
doThrow(new IllegalStateException("secret detail")).when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
.andExpect(content().string(not(containsString("secret detail"))));
}
@Test
void aDatabaseConflict_Is409() throws Exception {
doThrow(new DataIntegrityViolationException("doppelter Schlüsselwert", new SQLException("doppelter Schlüsselwert", "23505"))).when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("CONFLICT"));
}
@Test
void anotherRefusedWrite_Is500() throws Exception {
doThrow(new DataIntegrityViolationException("Wert zu lang", new SQLException("Wert zu lang", "22001")))
.when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
}
@Test
@ExtendWith(OutputCaptureExtension.class)
void aSpringServerError_IsLoggedAsInternalError(CapturedOutput output) throws Exception {
doThrow(new HttpMessageNotWritableException("body not writable"))
.when(moodService).refreshListedInBackground(USER_ID);
mockMvc.perform(get("/api/library/stats"))
.andExpect(status().isInternalServerError())
.andExpect(jsonPath("$.code").value("INTERNAL_ERROR"));
assertTrue(output.getAll().contains("body not writable"), "the 500 left no trace in the log");
} }
} }
@@ -2,7 +2,7 @@ package core_service.modules.library.api;
import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.library.repo.UserSteamSyncRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import jakarta.servlet.http.Cookie; import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -13,7 +13,6 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Date;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -42,12 +41,7 @@ class LibraryControllerSteamIdGoneTest extends AbstractIntegrationTest {
} }
private static Cookie loginWithoutSteamId() { private static Cookie loginWithoutSteamId() {
return new Cookie("tbp_access", Jwts.builder() return new Cookie("tbp_access", ContractTokens.loginToken(USER_ID));
.subject("unlinked@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA)
.compact());
} }
@Test @Test
@@ -3,8 +3,8 @@ package core_service.modules.library.api;
import core_service.configuration.security.ClientHeaderFilter; import core_service.configuration.security.ClientHeaderFilter;
import core_service.modules.igdb_adapter.service.IgdbApiService; import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
@@ -12,6 +12,7 @@ import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -215,6 +216,14 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
add(119388, " ").andExpect(status().isBadRequest()); add(119388, " ").andExpect(status().isBadRequest());
} }
@Test
void search_OneLetter_Is400WithTheField() throws Exception {
mockMvc.perform(get("/api/library/game-search").param("q", "a"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
.andExpect(jsonPath("$.fields[0].field").value("q"));
}
@Test @Test
void search_MarksGamesAlreadyInTheLibrary() throws Exception { void search_MarksGamesAlreadyInTheLibrary() throws Exception {
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now())); steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
@@ -342,12 +351,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
// Signed like an Auth login token, for a user who saved no Steam ID. // Signed like an Auth login token, for a user who saved no Steam ID.
private static String tokenWithoutSteamId() { private static String tokenWithoutSteamId() {
return io.jsonwebtoken.Jwts.builder() return ContractTokens.loginToken(USER_ID);
.subject("no-steam@example.com")
.claim("userId", String.valueOf(USER_ID))
.expiration(new java.util.Date(System.currentTimeMillis() + 60_000))
.signWith(TEST_KEYS.getPrivate(), io.jsonwebtoken.Jwts.SIG.EdDSA)
.compact();
} }
@Test @Test
@@ -11,7 +11,7 @@ import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository; import core_service.modules.library.repo.UserGameAchievementMetaRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import io.jsonwebtoken.Jwts; import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -19,7 +19,6 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant; import java.time.Instant;
import java.util.Date;
import java.util.List; import java.util.List;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
@@ -49,20 +48,15 @@ class PublicProfileControllerTest extends AbstractIntegrationTest {
private UserGameAchievementMetaRepository userAchievementMetaRepository; private UserGameAchievementMetaRepository userAchievementMetaRepository;
private static String ticket(long userId) { private static String ticket(long userId) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) return ContractTokens.ticketToken(userId, null, null);
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private static String ticket(long userId, List<String> profileStats) { private static String ticket(long userId, List<String> profileStats) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) return ContractTokens.ticketToken(userId, profileStats, null);
.claim("profileStats", profileStats)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private static String ticket(long userId, List<String> profileStats, List<String> sections) { private static String ticket(long userId, List<String> profileStats, List<String> sections) {
return Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", String.valueOf(userId)) return ContractTokens.ticketToken(userId, profileStats, sections);
.claim("profileStats", profileStats).claim("sections", sections)
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(TEST_KEYS.getPrivate(), Jwts.SIG.EdDSA).compact();
} }
private UserGame game(long appid, int playtime, GameStatus status, Integer rating) { private UserGame game(long appid, int playtime, GameStatus status, Integer rating) {
@@ -0,0 +1,120 @@
package core_service.shared.test;
import core_service.shared.security.Role;
import io.jsonwebtoken.Jwts;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Tokens only in the shapes of {@code contract/auth-core-contract.json}, the file Auth checks its tokens against. A
* test changes the values, never the claim names.
*/
public final class ContractTokens {
public static final String LOGIN = "login";
public static final String PUBLIC_PROFILE = "publicProfile";
public static final String SERVICE = "service";
private static final JsonMapper MAPPER = JsonMapper.builder().build();
private static final JsonNode CONTRACT = load();
private ContractTokens() {
}
private static JsonNode load() {
try (InputStream in = ContractTokens.class.getResourceAsStream("/contract/auth-core-contract.json")) {
return MAPPER.readTree(in);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
/**
* The claims Auth signs for this kind today.
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> claims(String kind) {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("tokens").required(kind), Map.class));
}
/**
* The claims of an Auth from before item 26.
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> legacyClaims(String kind) {
return new LinkedHashMap<>(MAPPER.treeToValue(CONTRACT.path("legacyTokens").required(kind), Map.class));
}
public static JsonNode internalCall(String name) {
return CONTRACT.path("internal").required(name);
}
public static String sign(Map<String, Object> claims, PrivateKey key, long expiresInMs) {
return Jwts.builder()
.claims(claims)
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(key, Jwts.SIG.EdDSA)
.compact();
}
public static String sign(Map<String, Object> claims) {
return sign(claims, AbstractIntegrationTest.TEST_KEYS.getPrivate(), 60_000);
}
/**
* @param steamId null for a user without a linked Steam account
*/
public static Map<String, Object> login(long userId, Role role, Long steamId) {
Map<String, Object> claims = claims(LOGIN);
claims.put("sub", String.valueOf(userId));
claims.put("userId", String.valueOf(userId));
claims.put("role", role.name());
putOrRemove(claims, "steamId", steamId);
return claims;
}
public static String loginToken(long userId, Role role) {
return sign(login(userId, role, null));
}
public static String loginToken(long userId) {
return loginToken(userId, Role.USER);
}
/**
* @param profileStats null for a ticket without the claim
* @param sections null for a ticket without the claim
*/
public static Map<String, Object> ticket(long userId, List<String> profileStats, List<String> sections) {
Map<String, Object> claims = claims(PUBLIC_PROFILE);
claims.put("profileUserId", String.valueOf(userId));
putOrRemove(claims, "profileStats", profileStats);
putOrRemove(claims, "sections", sections);
return claims;
}
public static String ticketToken(long userId, List<String> profileStats, List<String> sections) {
return sign(ticket(userId, profileStats, sections));
}
public static String serviceToken() {
return sign(claims(SERVICE));
}
private static void putOrRemove(Map<String, Object> claims, String name, Object value) {
if (value == null) {
claims.remove(name);
} else {
claims.put(name, value);
}
}
}
@@ -0,0 +1,71 @@
{
"_about": "The token and internal-call contract between Auth and Core. The same file is in both repos; ToBePlayed-Deployment/scripts/check-contract.sh compares them. iat and exp are left out. legacyTokens are the shapes of an Auth from before item 26: Core and Auth accept them until item 29.",
"tokens": {
"login": {
"iss": "tbp-auth",
"aud": ["tbp-login"],
"sub": "42",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"iss": "tbp-auth",
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"iss": "tbp-auth",
"aud": ["tbp-internal"],
"sub": "tbp-auth",
"typ": "service"
}
},
"legacyTokens": {
"login": {
"sub": "user@example.com",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
"publicProfile": {
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
},
"service": {
"sub": "tbp-auth",
"typ": "service"
}
},
"internal": {
"markDeleted": {
"method": "POST",
"path": "/internal/accounts/deleted",
"request": {"userIds": [42]}
},
"replaceDeleted": {
"method": "PUT",
"path": "/internal/accounts/deleted",
"request": {"userIds": []}
},
"purge": {
"method": "POST",
"path": "/internal/accounts/purge",
"request": {"userIds": [42], "dryRun": false},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
},
"prune": {
"method": "POST",
"path": "/internal/accounts/prune",
"request": {"knownUserIds": [1, 42], "dryRun": true},
"response": {"dryRun": true, "users": [42], "rows": {"user_game": 3}, "refused": false}
}
}
}