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