chore(security)!: refuse tokens without aud

This commit is contained in:
lobo committed 2026-09-30 15:00:47 +02:00
1 parent 23bf5c41b4
commit e347b7aac8
6 files changed
+62 -118

No files matched your search

@@ -5,6 +5,7 @@ import core_service.shared.security.Role;
import core_service.shared.security.UserPrincipal;
import core_service.shared.test.AbstractUnitTest;
import core_service.shared.test.ContractTokens;
import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,10 +49,10 @@ class JwtServiceTest extends AbstractUnitTest {
jwtService = new JwtService(new JwksKeyLocator(jwtConfig, RestClient.create()));
}
private String signToken(String userId, String email, Long steamId) {
var builder = Jwts.builder()
.subject(email)
.claim("userId", userId)
private String signToken(String userId, Long steamId) {
JwtBuilder builder = Jwts.builder()
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject(userId)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 60_000));
if (steamId != null) {
@@ -62,7 +63,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_ReturnsTheUser() {
String token = signToken("0815", "test@example.com", 123456789L);
String token = signToken("0815", 123456789L);
assertEquals(Optional.of(new UserPrincipal("0815", 123456789L)),
jwtService.parseLoginToken(token));
@@ -77,8 +78,8 @@ class JwtServiceTest extends AbstractUnitTest {
void parseLoginToken_RejectsAForeignKey() throws Exception {
KeyPair other = KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String token = Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815")
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(other.getPrivate(), Jwts.SIG.EdDSA)
.compact();
@@ -89,7 +90,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_RejectsATokenWithoutUserId() {
String token = Jwts.builder()
.subject("test@example.com")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
.compact();
@@ -97,10 +98,10 @@ class JwtServiceTest extends AbstractUnitTest {
assertEquals(Optional.empty(), jwtService.parseLoginToken(token));
}
private String signTicket(String type, String profileUserId, long expiresInMs) {
private String signTicket(String audience, String profileUserId, long expiresInMs) {
return Jwts.builder()
.issuer(JwtService.ISSUER).audience().add(audience).and()
.subject("public-profile")
.claim("typ", type)
.claim("profileUserId", profileUserId)
.expiration(new Date(System.currentTimeMillis() + expiresInMs))
.signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -109,12 +110,12 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_ReturnsTheUser() {
assertEquals(Optional.of(new ProfileTicket(42L, Set.of(), null)),
jwtService.verifyProfileTicket(signTicket("public-profile", "42", 60_000)));
jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
}
@Test
void verifyProfileTicket_ReadsTheSharedStatGroups() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("profileStats", List.of("PILE", "TREND"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -123,7 +124,7 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_ReadsTheShownSections() {
String ticket = Jwts.builder().subject("public-profile").claim("typ", "public-profile").claim("profileUserId", "42")
String ticket = Jwts.builder().subject("public-profile").issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.claim("sections", List.of("PLAYING", "STATS"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
@@ -132,25 +133,26 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void verifyProfileTicket_RejectsLoginTokensOtherTypesExpiredAndForeignTickets() throws Exception {
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", "a@b.de", null)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signToken("42", null)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("something-else", "42", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "42", -1_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket("public-profile", "not-a-number", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", -1_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "not-a-number", 60_000)));
assertEquals(Optional.empty(), jwtService.verifyProfileTicket("garbage"));
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
String foreign = Jwts.builder().claim("typ", "public-profile").claim("profileUserId", "42")
String foreign = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.PROFILE_TICKET_AUDIENCE).and().claim("profileUserId", "42")
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(otherKey, Jwts.SIG.EdDSA).compact();
assertEquals(Optional.empty(), jwtService.verifyProfileTicket(foreign));
}
@Test
void parseLoginToken_ProfileTicketIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket("public-profile", "42", 60_000)));
assertEquals(Optional.empty(), jwtService.parseLoginToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)));
}
private String signServiceToken(PrivateKey key, String type, Long expiresInMs) {
io.jsonwebtoken.JwtBuilder builder = Jwts.builder().subject("tbp-auth").claim("typ", type).issuedAt(new Date());
private String signServiceToken(PrivateKey key, Long expiresInMs) {
JwtBuilder builder = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.SERVICE_AUDIENCE).and()
.subject("tbp-auth").issuedAt(new Date());
if (expiresInMs != null) {
builder.expiration(new Date(System.currentTimeMillis() + expiresInMs));
}
@@ -159,30 +161,30 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void isServiceToken_AcceptsAServiceTokenOfAuth() {
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, "service", 60_000L)));
assertTrue(jwtService.isServiceToken(signServiceToken(signingKey, 60_000L)));
}
@Test
void isServiceToken_RejectsEverythingElse() throws Exception {
PrivateKey otherKey = KeyPairGenerator.getInstance("Ed25519").generateKeyPair().getPrivate();
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, "service", -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, "service", 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", "a@b.de", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket("public-profile", "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, null)), "no exp");
assertFalse(jwtService.isServiceToken(signServiceToken(signingKey, -1_000L)), "expired");
assertFalse(jwtService.isServiceToken(signServiceToken(otherKey, 60_000L)), "foreign key");
assertFalse(jwtService.isServiceToken(signToken("42", null)), "login token");
assertFalse(jwtService.isServiceToken(signTicket(JwtService.PROFILE_TICKET_AUDIENCE, "42", 60_000)), "profile ticket");
assertFalse(jwtService.isServiceToken("garbage"));
}
@Test
void parseLoginToken_ServiceTokenIsNoLogin() {
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, "service", 60_000L)));
assertEquals(Optional.empty(), jwtService.parseLoginToken(signServiceToken(signingKey, 60_000L)));
}
private String signTokenWithRole(String role) {
return Jwts.builder()
.subject("test@example.com")
.claim("userId", "0815")
.issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815")
.claim("role", role)
.expiration(new Date(System.currentTimeMillis() + 60_000))
.signWith(signingKey, Jwts.SIG.EdDSA)
@@ -196,13 +198,14 @@ class JwtServiceTest extends AbstractUnitTest {
@Test
void parseLoginToken_WithoutOrWithAnUnknownRole_IsUser() {
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", "test@example.com", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signToken("0815", null)).orElseThrow().role());
assertEquals(Role.USER, jwtService.parseLoginToken(signTokenWithRole("GOD")).orElseThrow().role());
}
@Test
void parseLoginToken_WithARoleThatIsNoString_IsStillALoginAsUser() {
String token = Jwts.builder().subject("test@example.com").claim("userId", "0815").claim("role", List.of("AUDITOR"))
String token = Jwts.builder().issuer(JwtService.ISSUER).audience().add(JwtService.LOGIN_AUDIENCE).and()
.subject("0815").claim("role", List.of("AUDITOR"))
.expiration(new Date(System.currentTimeMillis() + 60_000)).signWith(signingKey, Jwts.SIG.EdDSA).compact();
assertEquals(Role.USER, jwtService.parseLoginToken(token).orElseThrow().role());
@@ -212,14 +215,6 @@ class JwtServiceTest extends AbstractUnitTest {
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());
@@ -229,20 +224,20 @@ class JwtServiceTest extends AbstractUnitTest {
}
@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))));
void tokensWithoutAud_AreRefused() {
assertTrue(jwtService.parseLoginToken(sign(withoutAudience(ContractTokens.LOGIN))).isEmpty());
assertTrue(jwtService.verifyProfileTicket(sign(withoutAudience(ContractTokens.PUBLIC_PROFILE))).isEmpty());
assertFalse(jwtService.isServiceToken(sign(withoutAudience(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"))));
// The shape of an Auth from before item 26, with the old claims it wrote.
private static Map<String, Object> withoutAudience(String kind) {
Map<String, Object> claims = ContractTokens.claims(kind);
claims.remove("aud");
claims.remove("iss");
claims.put("userId", "42");
claims.put("typ", kind.equals(ContractTokens.SERVICE) ? "service" : "public-profile");
return claims;
}
@Test
@@ -4,8 +4,6 @@ import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
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.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.HttpMethod;
@@ -117,12 +115,10 @@ class InternalAccountControllerTest extends AbstractIntegrationTest {
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();
// The calls as Auth's CoreAccountService sends them.
@Test
void contractCalls_AreAnswered() throws Exception {
String token = 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 {
@@ -46,14 +46,6 @@ public final class ContractTokens {
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);
}
@@ -1,11 +1,10 @@
{
"_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.",
"_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.",
"tokens": {
"login": {
"iss": "tbp-auth",
"aud": ["tbp-login"],
"sub": "42",
"userId": "42",
"role": "USER",
"steamId": 76561198000000001
},
@@ -13,7 +12,6 @@
"iss": "tbp-auth",
"aud": ["tbp-public-profile"],
"sub": "public-profile",
"typ": "public-profile",
"profileUserId": "42",
"profileStats": ["OVERVIEW"],
"sections": ["SUMMARY"]
@@ -21,27 +19,7 @@
"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"
"sub": "tbp-auth"
}
},
"internal": {