From d47ce423650e1a40dbbf0c3f7739eee7d8777fa6 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Sat, 27 Dec 2025 17:36:59 +0800 Subject: [PATCH] Add test cases for CompactWebToken authentication --- api/pom.xml | 5 + .../web/CompactWebTokenAuthHandlerTest.java | 139 ++++++++++ .../web/CompactWebTokenAuthTest.java | 247 ++++++++++++++++++ .../io/bosonnetwork/web/TestClientDevice.java | 62 +++++ .../io/bosonnetwork/web/TestClientUser.java | 79 ++++++ 5 files changed, 532 insertions(+) create mode 100644 api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java create mode 100644 api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java create mode 100644 api/src/test/java/io/bosonnetwork/web/TestClientDevice.java create mode 100644 api/src/test/java/io/bosonnetwork/web/TestClientUser.java diff --git a/api/pom.xml b/api/pom.xml index 6df4a64..68b5ccf 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -156,6 +156,11 @@ test + + io.vertx + vertx-web-client + test + io.vertx vertx-junit5 diff --git a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java new file mode 100644 index 0000000..33afab7 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java @@ -0,0 +1,139 @@ +package io.bosonnetwork.web; + +import java.util.Base64; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.Router; +import io.vertx.ext.web.client.WebClient; +import io.vertx.ext.web.handler.BodyHandler; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Identity; +import io.bosonnetwork.crypto.CryptoIdentity; +import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.service.ClientUser; + +@ExtendWith(VertxExtension.class) +public class CompactWebTokenAuthHandlerTest { + private static final Identity superNodeIdentity = new CryptoIdentity(); + private static final Signature.KeyPair aliceKeyPair = Signature.KeyPair.random(); + private static final ClientUser alice = new TestClientUser(Id.of(aliceKeyPair.publicKey().bytes()), + "Alice", null, null, null); + + private static final io.bosonnetwork.web.CompactWebTokenAuth.UserRepository repo = new io.bosonnetwork.web.CompactWebTokenAuth.UserRepository() { + @Override + public Future getSubject(Id subject) { + return subject.equals(alice.getId()) ? + Future.succeededFuture(alice) : Future.succeededFuture(null); + } + + @Override + public Future getAssociated(Id subject, Id associated) { + return Future.succeededFuture(null); + } + }; + + private static final io.bosonnetwork.web.CompactWebTokenAuth auth = io.bosonnetwork.web.CompactWebTokenAuth.create(superNodeIdentity, repo, + 3600, 3600, 0); + + @Test + void testHandlerSuccess(Vertx vertx, VertxTestContext context) { + Router router = Router.router(vertx); + router.route().handler(BodyHandler.create()); + + // Protected route requiring "read" scope + router.get("/protected") + .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth).withScope("read")) + .handler(ctx -> ctx.response().end(new JsonObject().put("status", "ok").encode())); + + vertx.createHttpServer() + .requestHandler(router) + .listen(0) + .onComplete(context.succeeding(server -> { + int port = server.actualPort(); + WebClient client = WebClient.create(vertx); + + String token = auth.generateToken(alice.getId(), "read"); + + client.get(port, "localhost", "/protected") + .bearerTokenAuthentication(token) + .send() + .onComplete(context.succeeding(resp -> { + context.verify(() -> { + Assertions.assertEquals(200, resp.statusCode()); + Assertions.assertEquals("ok", resp.bodyAsJsonObject().getString("status")); + }); + server.close().onComplete(context.succeedingThenComplete()); + })); + })); + } + + @Test + void testHandlerMissingScope(Vertx vertx, VertxTestContext context) { + Router router = Router.router(vertx); + router.get("/protected") + .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth).withScope("admin")) + .handler(ctx -> ctx.response().end("ok")); + + vertx.createHttpServer() + .requestHandler(router) + .listen(0) + .onComplete(context.succeeding(server -> { + int port = server.actualPort(); + WebClient client = WebClient.create(vertx); + + // Token only has "read" scope + String token = auth.generateToken(alice.getId(), "read"); + + client.get(port, "localhost", "/protected") + .bearerTokenAuthentication(token) + .send() + .onComplete(context.succeeding(resp -> { + context.verify(() -> { + Assertions.assertEquals(403, resp.statusCode()); + }); + server.close().onComplete(context.succeedingThenComplete()); + })); + })); + } + + @Test + void testHandlerWithPadding(Vertx vertx, VertxTestContext context) { + Router router = Router.router(vertx); + router.get("/protected") + .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth)) + .handler(ctx -> ctx.response().end("ok")); + + vertx.createHttpServer() + .requestHandler(router) + .listen(0) + .onComplete(context.succeeding(server -> { + int port = server.actualPort(); + WebClient client = WebClient.create(vertx); + + // Generate token + String token = auth.generateToken(alice.getId()); + String[] parts = token.split("\\.", 2); + String paddingToken = Base64.getUrlEncoder().encodeToString(Base64.getUrlDecoder().decode(parts[0])) + '.' + + Base64.getUrlEncoder().encodeToString(Base64.getUrlDecoder().decode(parts[1])); + + client.get(port, "localhost", "/protected") + .bearerTokenAuthentication(paddingToken) + .send() + .onComplete(context.succeeding(resp -> { + context.verify(() -> { + Assertions.assertEquals(400, resp.statusCode()); + }); + server.close().onComplete(context.succeedingThenComplete()); + })); + })); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java new file mode 100644 index 0000000..fc9a5dd --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java @@ -0,0 +1,247 @@ +package io.bosonnetwork.web; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; + +import io.vertx.core.Future; +import io.vertx.core.Promise; +import io.vertx.core.Vertx; +import io.vertx.ext.auth.User; +import io.vertx.ext.auth.authentication.TokenCredentials; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; + +import io.bosonnetwork.Id; +import io.bosonnetwork.Identity; +import io.bosonnetwork.crypto.CryptoIdentity; +import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.service.ClientDevice; +import io.bosonnetwork.service.ClientUser; +import io.bosonnetwork.utils.Json; + +@ExtendWith(VertxExtension.class) +public class CompactWebTokenAuthTest { + private static final long DEFAULT_LIFETIME = 10; + private static final Identity superNodeIdentity = new CryptoIdentity(); + private static final Signature.KeyPair aliceKeyPair = Signature.KeyPair.random(); + private static final Signature.KeyPair iPadKeyPair = Signature.KeyPair.random(); + private static final ClientUser alice = new TestClientUser(Id.of(aliceKeyPair.publicKey().bytes()), + "Alice", null, null, null); + private static final ClientDevice iPad = new TestClientDevice(Id.of(iPadKeyPair.publicKey().bytes()), alice.getId(), + "iPad", "TestCase"); + + private static final io.bosonnetwork.web.CompactWebTokenAuth.UserRepository repo = new io.bosonnetwork.web.CompactWebTokenAuth.UserRepository() { + @Override + public Future getSubject(Id subject) { + return subject.equals(alice.getId()) ? + Future.succeededFuture(alice) : Future.succeededFuture(null); + } + + @Override + public Future getAssociated(Id subject, Id associated) { + return subject.equals(alice.getId()) && associated.equals(iPad.getId()) ? + Future.succeededFuture(iPad) : Future.succeededFuture(null); + } + }; + + private static final io.bosonnetwork.web.CompactWebTokenAuth auth = io.bosonnetwork.web.CompactWebTokenAuth.create(superNodeIdentity, repo, + DEFAULT_LIFETIME, DEFAULT_LIFETIME, 0); + + @Test + void testSuperNodeIssuedToken(VertxTestContext context) { + String userToken = auth.generateToken(alice.getId(), "test"); + System.out.println(userToken); + Future f1 = auth.authenticate(new TokenCredentials(userToken)).andThen(context.succeeding(user -> { + context.verify(() -> { + assertNotNull(user); + Assertions.assertEquals(alice.getId().toString(), user.subject()); + Id id = user.get("sub"); + Assertions.assertEquals(alice.getId(), id); + assertEquals(alice, user.get("user")); + assertNull(user.get("asc")); + assertNull(user.get("device")); + assertEquals("test", user.get("scope")); + assertEquals(userToken, user.get("access_token")); + assertFalse(user.expired()); + }); + })); + + + String deviceToken = auth.generateToken(alice.getId(), iPad.getId(), "test"); + System.out.println(deviceToken); + Future f2 = auth.authenticate(new TokenCredentials(deviceToken)).andThen(context.succeeding(user -> { + context.verify(() -> { + assertNotNull(user); + Assertions.assertEquals(alice.getId().toString(), user.subject()); + Assertions.assertEquals(alice.getId(), user.get("sub")); + assertEquals(alice, user.get("user")); + Assertions.assertEquals(iPad.getId(), user.get("asc")); + assertEquals(iPad, user.get("device")); + assertEquals("test", user.get("scope")); + assertEquals(deviceToken, user.get("access_token")); + assertFalse(user.expired()); + }); + })); + + Future.all(f1, f2).andThen(context.succeedingThenComplete()); + } + + @Test + void testSuperNodeIssuedAndExpiredToken(Vertx vertx, VertxTestContext context) { + String userToken = auth.generateToken(alice.getId(), "test"); + System.out.println(userToken); + String deviceToken = auth.generateToken(alice.getId(), iPad.getId(), "test"); + System.out.println(deviceToken); + + Promise promise = Promise.promise(); + vertx.setTimer(10000, l -> promise.complete()); + System.out.println("Waiting for token expiration..."); + promise.future().compose(v -> { + Future f1 = auth.authenticate(new TokenCredentials(userToken)).andThen(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("expired")); + }); + }).otherwiseEmpty(); + + Future f2 = auth.authenticate(new TokenCredentials(deviceToken)).andThen(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("expired")); + }); + }).otherwiseEmpty(); + + return Future.all(f1, f2); + }).andThen(context.succeedingThenComplete()); + } + + @Test + void testSuperNodeIssuedAndInvalidSigToken(Vertx vertx, VertxTestContext context) { + String userToken = auth.generateToken(alice.getId(), "test"); + System.out.println(userToken); + byte[] sig = Json.BASE64_DECODER.decode(userToken.substring(userToken.lastIndexOf('.') + 1)); + sig[0] = (byte) ~sig[0]; + String invalidUserToken = userToken.substring(0, userToken.lastIndexOf('.')) + '.' + Json.BASE64_ENCODER.encodeToString(sig); + + String deviceToken = auth.generateToken(alice.getId(), iPad.getId(), "test"); + System.out.println(deviceToken); + sig = Json.BASE64_DECODER.decode(deviceToken.substring(deviceToken.lastIndexOf('.') + 1)); + sig[0] = (byte) ~sig[0]; + String invalidDeviceToken = deviceToken.substring(0, deviceToken.lastIndexOf('.')) + '.' + Json.BASE64_ENCODER.encodeToString(sig); + + Future f1 = auth.authenticate(new TokenCredentials(invalidUserToken)).andThen(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("signature verification failed")); + }); + }).otherwiseEmpty(); + + Future f2 = auth.authenticate(new TokenCredentials(invalidDeviceToken)).andThen(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("signature verification failed")); + }); + }).otherwiseEmpty(); + + Future.all(f1, f2).andThen(context.succeedingThenComplete()); + } + + @Test + void testInvalidBase64Token(VertxTestContext context) { + String invalidToken = "invalid-payload.invalid-signature"; + auth.authenticate(new TokenCredentials(invalidToken)).onComplete(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("format error")); + context.completeNow(); + }); + }); + } + + private String generateClientToken(Signature.KeyPair signer, Id subject, Id associated, Id audience, long expiration, String scope) throws Exception { + Map claims = new LinkedHashMap<>(); + claims.put("jti", Random.randomBytes(24)); + claims.put("sub", subject.bytes()); + if (associated != null) + claims.put("asc", associated.bytes()); + + // Client issued token MUST have 'iss' (issuer) and 'aud' (audience) + claims.put("iss", signer.publicKey().bytes()); + if (audience != null) + claims.put("aud", audience.bytes()); + + if (scope != null) + claims.put("scp", scope); + + long now = System.currentTimeMillis() / 1000; + if (expiration <= 0) + expiration = now + DEFAULT_LIFETIME; + + claims.put("exp", expiration); + + byte[] payload = Json.cborMapper().writeValueAsBytes(claims); + byte[] sig = signer.privateKey().sign(payload); + + return Json.BASE64_ENCODER.encodeToString(payload) + "." + Json.BASE64_ENCODER.encodeToString(sig); + } + + @Test + void testClientIssuedToken(VertxTestContext context) throws Exception { + String userToken = generateClientToken(aliceKeyPair, alice.getId(), null, superNodeIdentity.getId(), 0, "test"); + System.out.println("ClientToken: " + userToken); + + auth.authenticate(new TokenCredentials(userToken)).onComplete(context.succeeding(user -> { + context.verify(() -> { + assertNotNull(user); + Assertions.assertEquals(alice.getId().toString(), user.subject()); + assertEquals(alice, user.get("user")); + assertNull(user.get("asc")); + assertEquals("test", user.get("scope")); + assertFalse(user.expired()); + context.completeNow(); + }); + })); + } + + @Test + void testDeviceIssuedToken(VertxTestContext context) throws Exception { + String deviceToken = generateClientToken(iPadKeyPair, alice.getId(), iPad.getId(), superNodeIdentity.getId(), 0, "test"); + System.out.println("DeviceToken: " + deviceToken); + + auth.authenticate(new TokenCredentials(deviceToken)).onComplete(context.succeeding(user -> { + context.verify(() -> { + assertNotNull(user); + Assertions.assertEquals(alice.getId().toString(), user.subject()); + Assertions.assertEquals(iPad.getId(), user.get("asc")); + assertEquals(iPad, user.get("device")); + assertFalse(user.expired()); + context.completeNow(); + }); + })); + } + + @Test + void testClientIssuedTokenWrongAudience(VertxTestContext context) throws Exception { + // Wrong audience (random ID) + String userToken = generateClientToken(aliceKeyPair, alice.getId(), null, Id.random(), 0, "test"); + + auth.authenticate(new TokenCredentials(userToken)).onComplete(ar -> { + context.verify(() -> { + assertTrue(ar.failed()); + assertTrue(ar.cause().getMessage().contains("wrong audience")); + context.completeNow(); + }); + }); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/web/TestClientDevice.java b/api/src/test/java/io/bosonnetwork/web/TestClientDevice.java new file mode 100644 index 0000000..e02fd10 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/web/TestClientDevice.java @@ -0,0 +1,62 @@ +package io.bosonnetwork.web; + +import io.bosonnetwork.Id; +import io.bosonnetwork.service.ClientDevice; + +public class TestClientDevice implements ClientDevice { + private final Id id; + private final Id userId; + private final String name; + private final String app; + private final long created; + private final long updated; + + public TestClientDevice(Id id, Id userId, String name, String app) { + this.id = id; + this.userId = userId; + this.name = name; + this.app = app; + this.created = System.currentTimeMillis(); + this.updated = created; + } + + @Override + public Id getId() { + return id; + } + + @Override + public Id getUserId() { + return userId; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getApp() { + return app; + } + + @Override + public long getCreated() { + return created; + } + + @Override + public long getUpdated() { + return updated; + } + + @Override + public long getLastSeen() { + return 0; + } + + @Override + public String getLastAddress() { + return null; + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/web/TestClientUser.java b/api/src/test/java/io/bosonnetwork/web/TestClientUser.java new file mode 100644 index 0000000..2ee24ab --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/web/TestClientUser.java @@ -0,0 +1,79 @@ +package io.bosonnetwork.web; + +import io.bosonnetwork.Id; +import io.bosonnetwork.service.ClientUser; + +public class TestClientUser implements ClientUser { + private final Id id; + private final String name; + private final String avatar; + private final String email; + private final String bio; + private final long created; + private final long updated; + + public TestClientUser(Id id, String name, String avatar, String email, String bio) { + this.id = id; + this.name = name; + this.avatar = avatar; + this.email = email; + this.bio = bio; + this.created = System.currentTimeMillis(); + this.updated = created; + } + + @Override + public Id getId() { + return id; + } + + @Override + public boolean verifyPassphrase(String passphrase) { + return true; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getAvatar() { + return avatar; + } + + @Override + public String getEmail() { + return email; + } + + @Override + public String getBio() { + return bio; + } + + @Override + public long getCreated() { + return created; + } + + @Override + public long getUpdated() { + return updated; + } + + @Override + public boolean isAnnounce() { + return false; + } + + @Override + public long getLastAnnounced() { + return 0; + } + + @Override + public String getPlanName() { + return "Free"; + } +} \ No newline at end of file