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 -> {