Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions api/src/main/java/io/bosonnetwork/web/AccessToken.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* This class is useful for clients (Users, Devices, Peers) to generate tokens
* for authentication with the Boson Director or other services.
* </p>
*/
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<String, Object> 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);
}
}
85 changes: 85 additions & 0 deletions api/src/test/java/io/bosonnetwork/web/AccessTokenTest.java
Original file line number Diff line number Diff line change
@@ -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();
});
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) ?
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) ?
Expand All @@ -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
Expand All @@ -77,7 +77,6 @@ void testSuperNodeIssuedToken(VertxTestContext context) {
});
}));


String deviceToken = auth.generateToken(alice.getId(), iPad.getId(), "test");
System.out.println(deviceToken);
Future<User> f2 = auth.authenticate(new TokenCredentials(deviceToken)).andThen(context.succeeding(user -> {
Expand Down
Loading