diff --git a/.trivyignore b/.trivyignore index b0cd47aff..35471bdbb 100644 --- a/.trivyignore +++ b/.trivyignore @@ -10,3 +10,9 @@ CVE-2025-59375 exp:2025-12-15 # UID2-6128 CVE-2025-55163 exp:2025-10-30 + +# UID2-6340 +CVE-2025-64720 exp:2025-12-16 + +# UID2-6340 +CVE-2025-65018 exp:2025-12-16 \ No newline at end of file diff --git a/src/main/java/com/uid2/operator/model/AdvertisingToken.java b/src/main/java/com/uid2/operator/model/AdvertisingToken.java index e1fcc4725..e79150d9f 100644 --- a/src/main/java/com/uid2/operator/model/AdvertisingToken.java +++ b/src/main/java/com/uid2/operator/model/AdvertisingToken.java @@ -7,6 +7,7 @@ public class AdvertisingToken extends VersionedToken { public final OperatorIdentity operatorIdentity; public final PublisherIdentity publisherIdentity; public final UserIdentity userIdentity; + public Integer siteKeyId; public AdvertisingToken(TokenVersion version, Instant createdAt, Instant expiresAt, OperatorIdentity operatorIdentity, PublisherIdentity publisherIdentity, UserIdentity userIdentity) { @@ -14,6 +15,13 @@ public AdvertisingToken(TokenVersion version, Instant createdAt, Instant expires this.operatorIdentity = operatorIdentity; this.publisherIdentity = publisherIdentity; this.userIdentity = userIdentity; + this.siteKeyId = null; + } + + public AdvertisingToken(TokenVersion version, Instant createdAt, Instant expiresAt, OperatorIdentity operatorIdentity, + PublisherIdentity publisherIdentity, UserIdentity userIdentity, Integer siteKeyId) { + this(version, createdAt, expiresAt, operatorIdentity, publisherIdentity, userIdentity); + this.siteKeyId = siteKeyId; } } diff --git a/src/main/java/com/uid2/operator/model/KeyManager.java b/src/main/java/com/uid2/operator/model/KeyManager.java index 8fb879222..e461e286c 100644 --- a/src/main/java/com/uid2/operator/model/KeyManager.java +++ b/src/main/java/com/uid2/operator/model/KeyManager.java @@ -1,11 +1,14 @@ package com.uid2.operator.model; +import com.uid2.operator.util.Tuple; import com.uid2.operator.vertx.UIDOperatorVerticle; import com.uid2.shared.Const; import com.uid2.shared.auth.Keyset; import com.uid2.shared.model.KeysetKey; import com.uid2.shared.store.IKeysetKeyStore; import com.uid2.shared.store.reader.RotatingKeysetProvider; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Metrics; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,6 +20,10 @@ public class KeyManager { private static final Logger LOGGER = LoggerFactory.getLogger(KeyManager.class); + private static final String SITE_KEYSET_STATUS = "site_keyset_found"; + private static final String FALLBACK_KEYSET_STATUS = "fallback_keyset_found"; + private static final String KEYSET_NOT_FOUND_STATUS = "keyset_not_found"; + private final IKeysetKeyStore keysetKeyStore; private final RotatingKeysetProvider keysetProvider; @@ -34,12 +41,45 @@ public KeyManagerSnapshot getKeyManagerSnapshot(int siteId) { this.getDefaultKeysetBySiteId(siteId)); } - public KeysetKey getActiveKeyBySiteIdWithFallback(int siteId, int fallbackSiteId, Instant asOf) { + private void recordSiteKeysetStatusMetrics(int siteId, Boolean keysetFound, Boolean isFallback, Map, Counter> siteKeysetStatusMetrics) { + String status; + if (!keysetFound) { + status = KEYSET_NOT_FOUND_STATUS; + } else if (isFallback) { + status = FALLBACK_KEYSET_STATUS; + } else { + status = SITE_KEYSET_STATUS; + } + + siteKeysetStatusMetrics.computeIfAbsent( + new Tuple.Tuple2<>(String.valueOf(siteId), status), + tuple -> Counter + .builder("uid2_site_keyset_status") + .description("counts site keyset status by site ID") + .tags( + "site_id", tuple.getItem1(), + "status", tuple.getItem2() + ) + .register(Metrics.globalRegistry) + ).increment(); + } + + public KeysetKey getActiveKeyBySiteIdWithFallback(int siteId, int fallbackSiteId, Instant asOf, Map, Counter> siteKeysetStatusMetrics) { + boolean isFallback = false; + KeysetKey key = getActiveKeyBySiteId(siteId, asOf); - if (key == null) key = getActiveKeyBySiteId(fallbackSiteId, asOf); + if (key == null) { + isFallback = true; + key = getActiveKeyBySiteId(fallbackSiteId, asOf); + } + + if (key == null) { + recordSiteKeysetStatusMetrics(siteId, false, null, siteKeysetStatusMetrics); throw new NoActiveKeyException(String.format("Cannot get active key in default keyset with SITE ID %d or %d.", siteId, fallbackSiteId)); } + + recordSiteKeysetStatusMetrics(siteId, true, isFallback, siteKeysetStatusMetrics); return key; } diff --git a/src/main/java/com/uid2/operator/model/TokenValidateResult.java b/src/main/java/com/uid2/operator/model/TokenValidateResult.java new file mode 100644 index 000000000..f0dd13be4 --- /dev/null +++ b/src/main/java/com/uid2/operator/model/TokenValidateResult.java @@ -0,0 +1,8 @@ +package com.uid2.operator.model; + +public enum TokenValidateResult { + MATCH, + MISMATCH, + UNAUTHORIZED, + INVALID_TOKEN, +} diff --git a/src/main/java/com/uid2/operator/service/EncryptedTokenEncoder.java b/src/main/java/com/uid2/operator/service/EncryptedTokenEncoder.java index 402b672b3..630918e19 100644 --- a/src/main/java/com/uid2/operator/service/EncryptedTokenEncoder.java +++ b/src/main/java/com/uid2/operator/service/EncryptedTokenEncoder.java @@ -1,6 +1,7 @@ package com.uid2.operator.service; import com.uid2.operator.model.*; +import com.uid2.operator.util.Tuple; import com.uid2.operator.vertx.ClientInputValidationException; import com.uid2.shared.Const.Data; import com.uid2.shared.encryption.AesCbc; @@ -14,9 +15,12 @@ import java.time.Instant; import java.util.Base64; +import java.util.HashMap; +import java.util.Map; public class EncryptedTokenEncoder implements ITokenEncoder { private final KeyManager keyManager; + private final Map, Counter> siteKeysetStatusMetrics = new HashMap<>(); public EncryptedTokenEncoder(KeyManager keyManager) { this.keyManager = keyManager; @@ -24,7 +28,7 @@ public EncryptedTokenEncoder(KeyManager keyManager) { public byte[] encode(AdvertisingToken t, Instant asOf) { final KeysetKey masterKey = this.keyManager.getMasterKey(asOf); - final KeysetKey siteEncryptionKey = this.keyManager.getActiveKeyBySiteIdWithFallback(t.publisherIdentity.siteId, Data.AdvertisingTokenSiteId, asOf); + final KeysetKey siteEncryptionKey = this.keyManager.getActiveKeyBySiteIdWithFallback(t.publisherIdentity.siteId, Data.AdvertisingTokenSiteId, asOf, siteKeysetStatusMetrics); return t.version == TokenVersion.V2 ? encodeV2(t, masterKey, siteEncryptionKey) @@ -225,7 +229,8 @@ public AdvertisingToken decodeAdvertisingTokenV2(Buffer b) { Instant.ofEpochMilli(expiresMillis), new OperatorIdentity(0, OperatorType.Service, 0, masterKeyId), new PublisherIdentity(siteId, siteKeyId, 0), - new UserIdentity(IdentityScope.UID2, IdentityType.Email, advertisingId, privacyBits, Instant.ofEpochMilli(establishedMillis), null) + new UserIdentity(IdentityScope.UID2, IdentityType.Email, advertisingId, privacyBits, Instant.ofEpochMilli(establishedMillis), null), + siteKeyId ); } catch (Exception e) { @@ -263,7 +268,8 @@ public AdvertisingToken decodeAdvertisingTokenV3orV4(Buffer b, byte[] bytes, Tok return new AdvertisingToken( tokenVersion, createdAt, expiresAt, operatorIdentity, publisherIdentity, - new UserIdentity(identityScope, identityType, id, privacyBits, establishedAt, refreshedAt) + new UserIdentity(identityScope, identityType, id, privacyBits, establishedAt, refreshedAt), + siteKeyId ); } diff --git a/src/main/java/com/uid2/operator/service/IUIDOperatorService.java b/src/main/java/com/uid2/operator/service/IUIDOperatorService.java index e48f5f8a5..e275144ef 100644 --- a/src/main/java/com/uid2/operator/service/IUIDOperatorService.java +++ b/src/main/java/com/uid2/operator/service/IUIDOperatorService.java @@ -25,5 +25,5 @@ void invalidateTokensAsync(UserIdentity userIdentity, Instant asOf, String uidTr String email, String phone, String clientIp, Handler> handler); - boolean advertisingTokenMatches(String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env); + TokenValidateResult validateAdvertisingToken(int participantSiteId, String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env); } diff --git a/src/main/java/com/uid2/operator/service/UIDOperatorService.java b/src/main/java/com/uid2/operator/service/UIDOperatorService.java index dc4ede411..e06cf5bb1 100644 --- a/src/main/java/com/uid2/operator/service/UIDOperatorService.java +++ b/src/main/java/com/uid2/operator/service/UIDOperatorService.java @@ -57,9 +57,10 @@ public class UIDOperatorService implements IUIDOperatorService { private final Handler saltRetrievalResponseHandler; private final UidInstanceIdProvider uidInstanceIdProvider; + private final KeyManager keyManager; public UIDOperatorService(IOptOutStore optOutStore, ISaltProvider saltProvider, ITokenEncoder encoder, Clock clock, - IdentityScope identityScope, Handler saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider) { + IdentityScope identityScope, Handler saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider, KeyManager keyManager) { this.saltProvider = saltProvider; this.encoder = encoder; this.optOutStore = optOutStore; @@ -67,6 +68,7 @@ public UIDOperatorService(IOptOutStore optOutStore, ISaltProvider saltProvider, this.identityScope = identityScope; this.saltRetrievalResponseHandler = saltRetrievalResponseHandler; this.uidInstanceIdProvider = uidInstanceIdProvider; + this.keyManager = keyManager; this.testOptOutIdentityForEmail = getFirstLevelHashIdentity(identityScope, IdentityType.Email, InputUtil.normalizeEmail(OptOutIdentityForEmail).getIdentityInput(), Instant.now()); @@ -197,12 +199,27 @@ public void invalidateTokensAsync(UserIdentity userIdentity, Instant asOf, Strin } @Override - public boolean advertisingTokenMatches(String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env) { + public TokenValidateResult validateAdvertisingToken(int participantSiteId, String advertisingToken, UserIdentity userIdentity, Instant asOf, IdentityEnvironment env) { final UserIdentity firstLevelHashIdentity = getFirstLevelHashIdentity(userIdentity, asOf); final MappedIdentity mappedIdentity = getMappedIdentity(firstLevelHashIdentity, asOf, env); - final AdvertisingToken token = this.encoder.decodeAdvertisingToken(advertisingToken); - return Arrays.equals(mappedIdentity.advertisingId, token.userIdentity.id); + final AdvertisingToken token; + try { + token = this.encoder.decodeAdvertisingToken(advertisingToken); + } catch (Exception e) { + return TokenValidateResult.INVALID_TOKEN; + } + + int tokenSiteId = this.keyManager.getSiteIdFromKeyId(token.siteKeyId); + if (tokenSiteId != participantSiteId) { + return TokenValidateResult.UNAUTHORIZED; + } + + if (!Arrays.equals(mappedIdentity.advertisingId, token.userIdentity.id)) { + return TokenValidateResult.MISMATCH; + } + + return TokenValidateResult.MATCH; } private void validateTokenDurations(Duration refreshIdentityAfter, Duration refreshExpiresAfter, Duration identityExpiresAfter) { diff --git a/src/main/java/com/uid2/operator/vertx/UIDOperatorVerticle.java b/src/main/java/com/uid2/operator/vertx/UIDOperatorVerticle.java index eb2dd22a3..fd11f4882 100644 --- a/src/main/java/com/uid2/operator/vertx/UIDOperatorVerticle.java +++ b/src/main/java/com/uid2/operator/vertx/UIDOperatorVerticle.java @@ -117,6 +117,7 @@ public class UIDOperatorVerticle extends AbstractVerticle { private final Map> _identityMapUnmappedIdentifiers = new HashMap<>(); private final Map _identityMapRequestWithUnmapped = new HashMap<>(); private final Map, Counter> _clientVersions = new HashMap<>(); + private final Map, Counter> _tokenValidateCounters = new HashMap<>(); private final Map optOutStatusCounters = new HashMap<>(); private final IdentityScope identityScope; @@ -210,7 +211,8 @@ public void start(Promise startPromise) throws Exception { this.identityScope, this.saltRetrievalResponseHandler, this.identityV3Enabled, - this.uidInstanceIdProvider + this.uidInstanceIdProvider, + this.keyManager ); final Router router = createRoutesSetup(); @@ -233,13 +235,8 @@ public void start(Promise startPromise) throws Exception { } - private Router createRoutesSetup() throws IOException { - final Router router = Router.router(vertx); - - router.allowForward(AllowForwardHeaders.X_FORWARD); - router.route().handler(new RequestCapturingHandler(siteProvider)); - router.route().handler(new ClientVersionCapturingHandler("static/js", "*.js", clientKeyProvider)); - router.route().handler(CorsHandler.create() + private CorsHandler createCorsHandler() { + return CorsHandler.create() .addRelativeOrigin(".*.") .allowedMethod(io.vertx.core.http.HttpMethod.GET) .allowedMethod(io.vertx.core.http.HttpMethod.POST) @@ -249,7 +246,17 @@ private Router createRoutesSetup() throws IOException { .allowedHeader("Access-Control-Allow-Credentials") .allowedHeader("Access-Control-Allow-Origin") .allowedHeader("Access-Control-Allow-Headers") - .allowedHeader("Content-Type")); + .allowedHeader("Content-Type"); + } + + private Router createRoutesSetup() throws IOException { + final Router router = Router.router(vertx); + + router.allowForward(AllowForwardHeaders.X_FORWARD); + router.route().handler(new RequestCapturingHandler(siteProvider)); + router.route().handler(new ClientVersionCapturingHandler("static/js", "*.js", clientKeyProvider)); + router.route(V2_TOKEN_VALIDATE.toString()).handler(createCorsHandler().allowedHeader("Authorization")); + router.route().handler(createCorsHandler()); router.route().handler(new StatsCollectorHandler(_statsCollectorQueue, vertx)); router.route("/static/*").handler(StaticHandler.create("static")); router.route().handler(ctx -> { @@ -807,6 +814,22 @@ private void recordOperatorServedSdkUsage(RoutingContext rc, Integer siteId, Str } } + private void recordTokenValidateStats(Integer siteId, String result) { + final String siteIdStr = siteId != null ? String.valueOf(siteId) : "unknown"; + _tokenValidateCounters.computeIfAbsent( + new Tuple.Tuple2<>(siteIdStr, result), + tuple -> Counter + .builder("uid2_token_validate_total") + .description("counter for token validate endpoint results") + .tags( + "site_id", tuple.getItem1(), + "site_name", getSiteName(siteProvider, Integer.valueOf(tuple.getItem1())), + "result", tuple.getItem2() + ) + .register(Metrics.globalRegistry) + ).increment(); + } + private void handleTokenRefreshV2(RoutingContext rc) { Integer siteId = null; TokenResponseStatsCollector.PlatformType platformType = TokenResponseStatsCollector.PlatformType.Other; @@ -848,33 +871,38 @@ private void handleTokenRefreshV2(RoutingContext rc) { private void handleTokenValidateV2(RoutingContext rc) { RuntimeConfig config = this.getConfigFromRc(rc); IdentityEnvironment env = config.getIdentityEnvironment(); + final Integer participantSiteId = AuthMiddleware.getAuthClient(rc).getSiteId(); try { final JsonObject req = (JsonObject) rc.data().get("request"); final InputUtil.InputVal input = getTokenInputV2(req); if (!isTokenInputValid(input, rc)) { + recordTokenValidateStats(participantSiteId, "invalid_input"); return; } - if ((input.getIdentityType() == IdentityType.Email && Arrays.equals(ValidateIdentityForEmailHash, input.getIdentityInput())) - || (input.getIdentityType() == IdentityType.Phone && Arrays.equals(ValidateIdentityForPhoneHash, input.getIdentityInput()))) { - try { - final Instant now = Instant.now(); - final String token = req.getString("token"); - - if (this.idService.advertisingTokenMatches(token, input.toUserIdentity(this.identityScope, 0, now), now, env)) { - ResponseUtil.SuccessV2(rc, Boolean.TRUE); - } else { - ResponseUtil.SuccessV2(rc, Boolean.FALSE); - } - } catch (Exception e) { - ResponseUtil.SuccessV2(rc, Boolean.FALSE); - } - } else { + + final Instant now = Instant.now(); + final String token = req.getString("token"); + + final TokenValidateResult result = this.idService.validateAdvertisingToken(participantSiteId, token, input.toUserIdentity(this.identityScope, 0, now), now, env); + + if (result == TokenValidateResult.MATCH) { + recordTokenValidateStats(participantSiteId, "match"); + ResponseUtil.SuccessV2(rc, Boolean.TRUE); + } else if (result == TokenValidateResult.MISMATCH) { + recordTokenValidateStats(participantSiteId, "mismatch"); ResponseUtil.SuccessV2(rc, Boolean.FALSE); + } else if (result == TokenValidateResult.UNAUTHORIZED) { + recordTokenValidateStats(participantSiteId, "unauthorized"); + ResponseUtil.LogInfoAndSend400Response(rc, "Unauthorised to validate token"); + } else if (result == TokenValidateResult.INVALID_TOKEN) { + recordTokenValidateStats(participantSiteId, "invalid_token"); + ResponseUtil.LogInfoAndSend400Response(rc, "Invalid token"); } } catch (Exception e) { - LOGGER.error("Unknown error while validating token v2", e); + recordTokenValidateStats(participantSiteId, "error"); + LOGGER.error("Unknown error while validating token", e); rc.fail(500); } } diff --git a/src/test/java/com/uid2/operator/UIDOperatorServiceTest.java b/src/test/java/com/uid2/operator/UIDOperatorServiceTest.java index 1c35ac1e3..e6e4d2d15 100644 --- a/src/test/java/com/uid2/operator/UIDOperatorServiceTest.java +++ b/src/test/java/com/uid2/operator/UIDOperatorServiceTest.java @@ -54,6 +54,7 @@ class UIDOperatorServiceTest { @Mock private OperatorShutdownHandler shutdownHandler; private EncryptedTokenEncoder tokenEncoder; + private KeyManager keyManager; private UidInstanceIdProvider uidInstanceIdProvider; private JsonObject uid2Config; private JsonObject euidConfig; @@ -62,8 +63,8 @@ class UIDOperatorServiceTest { private Instant now; static class ExtendedUIDOperatorService extends UIDOperatorService { - public ExtendedUIDOperatorService(IOptOutStore optOutStore, ISaltProvider saltProvider, ITokenEncoder encoder, Clock clock, IdentityScope identityScope, Handler saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider) { - super(optOutStore, saltProvider, encoder, clock, identityScope, saltRetrievalResponseHandler, identityV3Enabled, uidInstanceIdProvider); + public ExtendedUIDOperatorService(IOptOutStore optOutStore, ISaltProvider saltProvider, ITokenEncoder encoder, Clock clock, IdentityScope identityScope, Handler saltRetrievalResponseHandler, boolean identityV3Enabled, UidInstanceIdProvider uidInstanceIdProvider, KeyManager keyManager) { + super(optOutStore, saltProvider, encoder, clock, identityScope, saltRetrievalResponseHandler, identityV3Enabled, uidInstanceIdProvider, keyManager); } } @@ -88,7 +89,8 @@ void setup() throws Exception { "/com.uid2.core/test/salts/metadata.json"); saltProvider.loadContent(); - tokenEncoder = new EncryptedTokenEncoder(new KeyManager(keysetKeyStore, keysetProvider)); + keyManager = new KeyManager(keysetKeyStore, keysetProvider); + tokenEncoder = new EncryptedTokenEncoder(keyManager); setNow(Instant.now()); @@ -108,7 +110,8 @@ void setup() throws Exception { IdentityScope.UID2, this.shutdownHandler::handleSaltRetrievalResponse, uid2Config.getBoolean(IdentityV3Prop), - uidInstanceIdProvider + uidInstanceIdProvider, + keyManager ); euidConfig = new JsonObject(); @@ -125,7 +128,8 @@ void setup() throws Exception { IdentityScope.EUID, this.shutdownHandler::handleSaltRetrievalResponse, euidConfig.getBoolean(IdentityV3Prop), - uidInstanceIdProvider + uidInstanceIdProvider, + keyManager ); } @@ -154,7 +158,8 @@ private RotatingSaltProvider.SaltSnapshot setUpMockSalts() { IdentityScope.UID2, this.shutdownHandler::handleSaltRetrievalResponse, uid2Config.getBoolean(IdentityV3Prop), - uidInstanceIdProvider + uidInstanceIdProvider, + keyManager ); return saltSnapshot; @@ -820,7 +825,8 @@ void testExpiredSaltsNotifiesShutdownHandler(TestIdentityInputType type, String IdentityScope.UID2, this.shutdownHandler::handleSaltRetrievalResponse, uid2Config.getBoolean(IdentityV3Prop), - uidInstanceIdProvider + uidInstanceIdProvider, + keyManager ); UIDOperatorService euidService = new UIDOperatorService( @@ -831,7 +837,8 @@ void testExpiredSaltsNotifiesShutdownHandler(TestIdentityInputType type, String IdentityScope.EUID, this.shutdownHandler::handleSaltRetrievalResponse, euidConfig.getBoolean(IdentityV3Prop), - uidInstanceIdProvider + uidInstanceIdProvider, + keyManager ); when(this.optOutStore.getLatestEntry(any())).thenReturn(null); diff --git a/src/test/java/com/uid2/operator/UIDOperatorVerticleTest.java b/src/test/java/com/uid2/operator/UIDOperatorVerticleTest.java index a2fd79562..835d76d91 100644 --- a/src/test/java/com/uid2/operator/UIDOperatorVerticleTest.java +++ b/src/test/java/com/uid2/operator/UIDOperatorVerticleTest.java @@ -1543,11 +1543,6 @@ void tokenGenerateOptOutToken(String policyParameterKey, String identity, Identi assertArrayEquals(advertisingId, advertisingToken.userIdentity.id); assertArrayEquals(firstLevelHash, refreshToken.userIdentity.id); - String advertisingTokenString = body.getString("advertising_token"); - final Instant now = Instant.now(); - final String token = advertisingTokenString; - final boolean matchedOptedOutIdentity = this.uidOperatorVerticle.getIdService().advertisingTokenMatches(token, optOutTokenInput.toUserIdentity(getIdentityScope(), 0, now), now, IdentityEnvironment.TEST); - assertTrue(matchedOptedOutIdentity); assertFalse(PrivacyBits.fromInt(advertisingToken.userIdentity.privacyBits).isClientSideTokenGenerated()); assertTrue(PrivacyBits.fromInt(advertisingToken.userIdentity.privacyBits).isClientSideTokenOptedOut()); @@ -1931,6 +1926,36 @@ void tokenGenerateThenValidateWithEmailHash_Match(Vertx vertx, VertxTestContext }); } + @Test + void tokenGenerateThenValidate_Unauthorized(Vertx vertx, VertxTestContext testContext) { + final int clientSiteId = 201; + fakeAuth(clientSiteId, Role.GENERATOR); + setupSalts(); + setupKeys(); + + generateTokens(vertx, "email", ValidateIdentityForEmail, genRespJson -> { + assertEquals("success", genRespJson.getString("status")); + JsonObject genBody = genRespJson.getJsonObject("body"); + assertNotNull(genBody); + + String advertisingTokenString = genBody.getString("advertising_token"); + + // This site ID did not generate the token and is therefore not authorised to validate the token + fakeAuth(999, Role.GENERATOR); + + JsonObject v2Payload = new JsonObject(); + v2Payload.put("token", advertisingTokenString); + v2Payload.put("email", ValidateIdentityForEmail); + + send(vertx, "v2/token/validate", v2Payload, 400, json -> { + assertEquals("client_error", json.getString("status")); + assertEquals("Unauthorised to validate token", json.getString("message")); + + testContext.completeNow(); + }); + }); + } + @Test void tokenGenerateThenValidateWithBothEmailAndEmailHash(Vertx vertx, VertxTestContext testContext) { final int clientSiteId = 201; @@ -2252,24 +2277,34 @@ void tokenRefreshOptOutBeforeLogin(boolean useV4Uid, boolean useRefreshedV4Uid, @ValueSource(strings = {"text/plain", "application/octet-stream"}) void tokenValidateWithEmail_Mismatch(String contentType, Vertx vertx, VertxTestContext testContext) { final int clientSiteId = 201; + final String phone = ValidateIdentityForPhone; final String emailAddress = ValidateIdentityForEmail; fakeAuth(clientSiteId, Role.GENERATOR); setupSalts(); setupKeys(); - send(vertx, "v2/token/validate", new JsonObject().put("token", "abcdef").put("email", emailAddress), - 200, - respJson -> { - assertFalse(respJson.getBoolean("body")); - assertEquals("success", respJson.getString("status")); + generateTokens(vertx, "phone", phone, genRespJson -> { + assertEquals("success", genRespJson.getString("status")); + JsonObject genBody = genRespJson.getJsonObject("body"); + assertNotNull(genBody); - testContext.completeNow(); - }, - Map.of(HttpHeaders.CONTENT_TYPE.toString(), contentType)); + String advertisingTokenString = genBody.getString("advertising_token"); + + JsonObject v2Payload = new JsonObject(); + v2Payload.put("token", advertisingTokenString); + v2Payload.put("email", emailAddress); + + send(vertx, "v2/token/validate", v2Payload, 200, json -> { + assertFalse(json.getBoolean("body")); + assertEquals("success", json.getString("status")); + + testContext.completeNow(); + }); + }); } @Test - void tokenValidateWithEmailHash_Mismatch(Vertx vertx, VertxTestContext testContext) { + void tokenValidateWithInvalidToken(Vertx vertx, VertxTestContext testContext) { final int clientSiteId = 201; fakeAuth(clientSiteId, Role.GENERATOR); setupSalts(); @@ -2277,10 +2312,54 @@ void tokenValidateWithEmailHash_Mismatch(Vertx vertx, VertxTestContext testConte send(vertx, "v2/token/validate", new JsonObject().put("token", "abcdef").put("email_hash", EncodingUtils.toBase64String(ValidateIdentityForEmailHash)), - 200, + 400, respJson -> { - assertFalse(respJson.getBoolean("body")); - assertEquals("success", respJson.getString("status")); + assertEquals("client_error", respJson.getString("status")); + assertEquals("Invalid token", respJson.getString("message")); + + testContext.completeNow(); + }); + } + + @Test + void corsTokenValidateAllowsAuthorizationHeader(Vertx vertx, VertxTestContext testContext) { + WebClient client = WebClient.create(vertx); + client.requestAbs(io.vertx.core.http.HttpMethod.OPTIONS, getUrlForEndpoint("v2/token/validate")) + .putHeader("Origin", "https://example.com") + .putHeader("Access-Control-Request-Method", "POST") + .putHeader("Access-Control-Request-Headers", "Content-Type, Authorization") + .send(ar -> { + assertTrue(ar.succeeded()); + HttpResponse response = ar.result(); + assertEquals(204, response.statusCode()); + + String allowedHeaders = response.getHeader("Access-Control-Allow-Headers"); + assertNotNull(allowedHeaders, "Access-Control-Allow-Headers header should be present"); + assertTrue(allowedHeaders.contains("Content-Type"), "Content-Type should be allowed"); + assertTrue(allowedHeaders.contains(ClientVersionHeader), "Client version header should be allowed"); + assertTrue(allowedHeaders.contains("Authorization"), "Authorization header should be allowed for token/validate"); + + testContext.completeNow(); + }); + } + + @Test + void corsTokenGenerateDoesNotAllowAuthorizationHeader(Vertx vertx, VertxTestContext testContext) { + WebClient client = WebClient.create(vertx); + client.requestAbs(io.vertx.core.http.HttpMethod.OPTIONS, getUrlForEndpoint("v2/token/generate")) + .putHeader("Origin", "https://example.com") + .putHeader("Access-Control-Request-Method", "POST") + .putHeader("Access-Control-Request-Headers", "Content-Type, Authorization") + .send(ar -> { + assertTrue(ar.succeeded()); + HttpResponse response = ar.result(); + assertEquals(204, response.statusCode()); + + String allowedHeaders = response.getHeader("Access-Control-Allow-Headers"); + assertNotNull(allowedHeaders, "Access-Control-Allow-Headers header should be present"); + assertTrue(allowedHeaders.contains("Content-Type"), "Content-Type should be allowed"); + assertTrue(allowedHeaders.contains(ClientVersionHeader), "Client version header should be allowed"); + assertFalse(allowedHeaders.contains("Authorization"), "Authorization header should NOT be allowed for token/generate"); testContext.completeNow(); }); diff --git a/src/test/java/com/uid2/operator/benchmark/BenchmarkCommon.java b/src/test/java/com/uid2/operator/benchmark/BenchmarkCommon.java index a5d533bf3..84ca88611 100644 --- a/src/test/java/com/uid2/operator/benchmark/BenchmarkCommon.java +++ b/src/test/java/com/uid2/operator/benchmark/BenchmarkCommon.java @@ -69,7 +69,8 @@ public static IUIDOperatorService createUidOperatorService() throws Exception { "/com.uid2.core/test/salts/metadata.json"); saltProvider.loadContent(); - final EncryptedTokenEncoder tokenEncoder = new EncryptedTokenEncoder(new KeyManager(keysetKeyStore, keysetProvider)); + final KeyManager keyManager = new KeyManager(keysetKeyStore, keysetProvider); + final EncryptedTokenEncoder tokenEncoder = new EncryptedTokenEncoder(keyManager); final List optOutPartitionFiles = new ArrayList<>(); final ICloudStorage optOutLocalStorage = make1mOptOutEntryStorage( saltProvider.getSnapshot(Instant.now()).getFirstLevelSalt(), @@ -84,7 +85,8 @@ public static IUIDOperatorService createUidOperatorService() throws Exception { IdentityScope.UID2, shutdownHandler::handleSaltRetrievalResponse, false, - new UidInstanceIdProvider("test-instance", "id") + new UidInstanceIdProvider("test-instance", "id"), + keyManager ); }