diff --git a/api/src/main/java/io/bosonnetwork/web/AccessToken.java b/api/src/main/java/io/bosonnetwork/web/AccessToken.java
new file mode 100644
index 0000000..13672ea
--- /dev/null
+++ b/api/src/main/java/io/bosonnetwork/web/AccessToken.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2023 - bosonnetwork.io
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.bosonnetwork.web;
+
+import java.io.IOException;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import io.bosonnetwork.Id;
+import io.bosonnetwork.Identity;
+import io.bosonnetwork.crypto.Random;
+import io.bosonnetwork.utils.Json;
+
+/**
+ * Helper class for generating Compact Web Tokens (CWT) signed by an Identity.
+ *
+ * This class is useful for clients (Users, Devices, Peers) to generate tokens
+ * for authentication with the Boson Director or other services.
+ *
+ */
+public class AccessToken {
+ private static final Base64.Encoder B64encoder = Base64.getUrlEncoder().withoutPadding();
+
+ private final Identity issuer;
+
+ /**
+ * Creates a new AccessToken generator for the given identity.
+ *
+ * @param issuer the identity that will issue and sign the tokens
+ */
+ public AccessToken(Identity issuer) {
+ this.issuer = Objects.requireNonNull(issuer, "issuer cannot be null");
+ }
+
+ /**
+ * Generates a signed token.
+ *
+ * @param subject the subject ID (usually the same as issuer or a user ID if issuer is a device)
+ * @param associated the associated entity ID (optional, e.g. device ID)
+ * @param audience the audience ID (the server node ID)
+ * @param scope the scope string (optional)
+ * @param ttl the time-to-live in seconds
+ * @return the generated token string
+ */
+ private String generate(Id subject, Id associated, Id audience, String scope, long ttl) {
+ Objects.requireNonNull(subject, "subject cannot be null");
+ Objects.requireNonNull(audience, "audience cannot be null");
+ if (ttl <= 0)
+ throw new IllegalArgumentException("ttl must be positive");
+
+ Map claims = new LinkedHashMap<>();
+ claims.put("jti", Random.randomBytes(24));
+ claims.put("iss", issuer.getId().bytes());
+ claims.put("sub", subject.bytes());
+ if (associated != null)
+ claims.put("asc", associated.bytes());
+ claims.put("aud", audience.bytes());
+ if (scope != null && !scope.isEmpty())
+ claims.put("scp", scope);
+
+ long now = System.currentTimeMillis() / 1000;
+ claims.put("exp", now + ttl);
+
+ byte[] payload;
+ try {
+ payload = Json.cborMapper().writeValueAsBytes(claims);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to serialize token claims", e);
+ }
+
+ byte[] sig = issuer.sign(payload);
+
+ return B64encoder.encodeToString(payload) + "." + B64encoder.encodeToString(sig);
+ }
+
+ /**
+ * Generates a signed token as the subject and without associated entity.
+ *
+ * @param audience the audience ID (the server node ID)
+ * @param scope the scope string (optional)
+ * @param ttl the time-to-live in seconds
+ * @return the generated token string
+ */
+ public String generate(Id audience, String scope, long ttl) {
+ return generate(issuer.getId(), null, audience, scope, ttl);
+ }
+
+ /**
+ * Generates a signed token as the associated entity.
+ *
+ * @param subject the subject ID (usually the same as issuer or a user ID if issuer is a device)
+ * @param audience the audience ID (the server node ID)
+ * @param scope the scope string (optional)
+ * @param ttl the time-to-live in seconds
+ * @return the generated token string
+ */
+ public String generate(Id subject, Id audience, String scope, long ttl) {
+ return generate(subject, issuer.getId(), audience, scope, ttl);
+ }
+}
\ No newline at end of file
diff --git a/api/src/test/java/io/bosonnetwork/web/AccessTokenTest.java b/api/src/test/java/io/bosonnetwork/web/AccessTokenTest.java
new file mode 100644
index 0000000..9ca7c55
--- /dev/null
+++ b/api/src/test/java/io/bosonnetwork/web/AccessTokenTest.java
@@ -0,0 +1,85 @@
+package io.bosonnetwork.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import io.vertx.core.Future;
+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.service.ClientDevice;
+import io.bosonnetwork.service.ClientUser;
+
+@ExtendWith(VertxExtension.class)
+public class AccessTokenTest {
+ private static final Identity superNodeIdentity = new CryptoIdentity();
+ private static final Identity aliceIdentity = new CryptoIdentity();
+ private static final Identity iPadIdentity = new CryptoIdentity();
+ private static final ClientUser alice = new TestClientUser(aliceIdentity.getId(), "Alice", null, null, null);
+ private static final ClientDevice iPad = new TestClientDevice(iPadIdentity.getId(), alice.getId(), "iPad", "TestCase");
+
+ // Mock repo for authentication verification
+ private static final CompactWebTokenAuth.UserRepository repo = new 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 CompactWebTokenAuth auth = CompactWebTokenAuth.create(superNodeIdentity, repo, 3600, 3600, 0);
+
+ @Test
+ void testGenerateUserToken(VertxTestContext context) {
+ AccessToken accessToken = new AccessToken(aliceIdentity);
+ Id audience = superNodeIdentity.getId();
+
+ String token = accessToken.generate(audience, "read write", 60);
+ System.out.println("Generated Token: " + token);
+
+ // Verify using CompactWebTokenAuth
+ auth.authenticate(new TokenCredentials(token)).onComplete(context.succeeding(user -> {
+ context.verify(() -> {
+ assertNotNull(user);
+ Assertions.assertEquals(alice.getId().toString(), user.subject());
+ Assertions.assertEquals(alice.getId(), user.get("sub"));
+ assertEquals("read write", user.get("scope"));
+ // 'aud' is checked implicitly by successful authentication for non-server-issued tokens
+ context.completeNow();
+ });
+ }));
+ }
+
+ @Test
+ void testGenerateDeviceToken(VertxTestContext context) {
+ AccessToken accessToken = new AccessToken(iPadIdentity);
+ Id audience = superNodeIdentity.getId();
+
+ String token = accessToken.generate(alice.getId(), audience, "read write", 60);
+
+ auth.authenticate(new TokenCredentials(token)).onComplete(context.succeeding(user -> {
+ context.verify(() -> {
+ assertNotNull(user);
+ Assertions.assertEquals(alice.getId().toString(), user.subject());
+ Assertions.assertEquals(alice.getId(), user.get("sub"));
+ Assertions.assertEquals(iPad.getId(), user.get("asc"));
+ assertEquals("read write", user.get("scope"));
+ context.completeNow();
+ });
+ }));
+ }
+}
\ No newline at end of file
diff --git a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java
index 33afab7..0549058 100644
--- a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java
+++ b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthHandlerTest.java
@@ -28,7 +28,7 @@ public class CompactWebTokenAuthHandlerTest {
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() {
+ private static final CompactWebTokenAuth.UserRepository repo = new CompactWebTokenAuth.UserRepository() {
@Override
public Future> getSubject(Id subject) {
return subject.equals(alice.getId()) ?
@@ -41,7 +41,7 @@ public Future> getAssociated(Id subject, Id associated) {
}
};
- private static final io.bosonnetwork.web.CompactWebTokenAuth auth = io.bosonnetwork.web.CompactWebTokenAuth.create(superNodeIdentity, repo,
+ private static final CompactWebTokenAuth auth = CompactWebTokenAuth.create(superNodeIdentity, repo,
3600, 3600, 0);
@Test
@@ -51,7 +51,7 @@ void testHandlerSuccess(Vertx vertx, VertxTestContext context) {
// Protected route requiring "read" scope
router.get("/protected")
- .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth).withScope("read"))
+ .handler(CompactWebTokenAuthHandler.create(auth).withScope("read"))
.handler(ctx -> ctx.response().end(new JsonObject().put("status", "ok").encode()));
vertx.createHttpServer()
@@ -80,7 +80,7 @@ void testHandlerSuccess(Vertx vertx, VertxTestContext context) {
void testHandlerMissingScope(Vertx vertx, VertxTestContext context) {
Router router = Router.router(vertx);
router.get("/protected")
- .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth).withScope("admin"))
+ .handler(CompactWebTokenAuthHandler.create(auth).withScope("admin"))
.handler(ctx -> ctx.response().end("ok"));
vertx.createHttpServer()
@@ -109,7 +109,7 @@ void testHandlerMissingScope(Vertx vertx, VertxTestContext context) {
void testHandlerWithPadding(Vertx vertx, VertxTestContext context) {
Router router = Router.router(vertx);
router.get("/protected")
- .handler(io.bosonnetwork.web.CompactWebTokenAuthHandler.create(auth))
+ .handler(CompactWebTokenAuthHandler.create(auth))
.handler(ctx -> ctx.response().end("ok"));
vertx.createHttpServer()
diff --git a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java
index fc9a5dd..9d2904f 100644
--- a/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java
+++ b/api/src/test/java/io/bosonnetwork/web/CompactWebTokenAuthTest.java
@@ -41,7 +41,7 @@ public class CompactWebTokenAuthTest {
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() {
+ private static final CompactWebTokenAuth.UserRepository repo = new CompactWebTokenAuth.UserRepository() {
@Override
public Future> getSubject(Id subject) {
return subject.equals(alice.getId()) ?
@@ -55,7 +55,7 @@ public Future> getAssociated(Id subject, Id associated) {
}
};
- private static final io.bosonnetwork.web.CompactWebTokenAuth auth = io.bosonnetwork.web.CompactWebTokenAuth.create(superNodeIdentity, repo,
+ private static final CompactWebTokenAuth auth = CompactWebTokenAuth.create(superNodeIdentity, repo,
DEFAULT_LIFETIME, DEFAULT_LIFETIME, 0);
@Test
@@ -77,7 +77,6 @@ void testSuperNodeIssuedToken(VertxTestContext context) {
});
}));
-
String deviceToken = auth.generateToken(alice.getId(), iPad.getId(), "test");
System.out.println(deviceToken);
Future f2 = auth.authenticate(new TokenCredentials(deviceToken)).andThen(context.succeeding(user -> {