diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/auth/TokenHashes.java b/server/opensharing-server-core/src/main/java/io/opensharing/auth/TokenHashes.java new file mode 100644 index 00000000..06e07aca --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/auth/TokenHashes.java @@ -0,0 +1,24 @@ +package io.opensharing.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** SHA-256 hex of a bearer token. The plaintext is never persisted. */ +public final class TokenHashes { + + private TokenHashes() {} + + /** Lowercase hex, 64 characters. */ + public static String sha256(String token) { + try { + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (NoSuchAlgorithmException e) { + // Every Java runtime must provide SHA-256. + throw new IllegalStateException(e); + } + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/ActivationService.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/ActivationService.java new file mode 100644 index 00000000..1f1345f8 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/ActivationService.java @@ -0,0 +1,46 @@ +package io.opensharing.protocol; + +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.ResponseHeaders; +import com.linecorp.armeria.server.annotation.Blocking; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.Param; +import io.opensharing.http.ApiJson; +import io.opensharing.recipient.RecipientService; + +/** + * Public one-time redemption of a recipient activation URL. Hosts mount it without their own + * authentication: the activation code itself is the credential. + */ +@Blocking +public final class ActivationService { + + private final RecipientService recipients; + private final String endpoint; + + /** + * @param endpoint the absolute URL the host serves the sharing protocol under, on the address + * recipients reach it at, which profile files point recipients to + */ + public ActivationService(RecipientService recipients, String endpoint) { + this.recipients = recipients; + this.endpoint = endpoint; + } + + /** + * {@code GET /{code}}: redeems the code and downloads the recipient's profile file. Fails with + * not-found when the code is unknown, already redeemed, or expired. The response is not cacheable + * because it carries the bearer token. + */ + @Get("/{code}") + public HttpResponse activate(@Param String code) { + return ApiJson.response( + ResponseHeaders.builder(HttpStatus.OK) + .add(HttpHeaderNames.CONTENT_DISPOSITION, "attachment; filename=\"config.share\"") + .add(HttpHeaderNames.CACHE_CONTROL, "no-store") + .build(), + recipients.activate(code, endpoint)); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java index 20583d70..3d4fb2a0 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java @@ -3,6 +3,11 @@ /** * POST body to create a recipient. The authenticated caller becomes the owner. Only {@code TOKEN} * authentication is accepted for now. + * + * @param tokenExpirationDays first-token lifetime in days (required) */ public record CreateRecipientRequest( - String name, String comment, AuthenticationType authenticationType) {} + String name, + String comment, + AuthenticationType authenticationType, + Long tokenExpirationDays) {} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/IssuedTokenResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/IssuedTokenResponse.java new file mode 100644 index 00000000..df9a9187 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/IssuedTokenResponse.java @@ -0,0 +1,25 @@ +package io.opensharing.recipient; + +import java.time.Instant; + +/** + * Pending replacement token returned by the provider rotation API. The provider sends {@code + * activationUrl} to the recipient, who redeems it for a new profile. + * + *
{@code
+ * {
+ * "tokenId": "",
+ * "recipientName": "",
+ * "activationUrl": "",
+ * "expiresAt": ""
+ * }
+ * }
+ */
+public record IssuedTokenResponse(
+ String tokenId, String recipientName, String activationUrl, Instant expiresAt) {
+
+ static IssuedTokenResponse from(RecipientTokenEntity token, String activationUrl) {
+ return new IssuedTokenResponse(
+ token.getId(), token.getRecipient().getName(), activationUrl, token.getExpiresAt());
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/ProfileFile.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/ProfileFile.java
new file mode 100644
index 00000000..8f69887b
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/ProfileFile.java
@@ -0,0 +1,20 @@
+package io.opensharing.recipient;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * Delta Sharing profile file returned when a recipient redeems an activation URL. Clients read
+ * {@code endpoint} and {@code bearerToken} to call the sharing protocol.
+ *
+ * {@code
+ * {
+ * "shareCredentialsVersion": 1,
+ * "bearerToken": "",
+ * "endpoint": "",
+ * "expirationTime": ""
+ * }
+ * }
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record ProfileFile(
+ int shareCredentialsVersion, String bearerToken, String endpoint, String expirationTime) {}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java
index e897fb87..18dae077 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java
@@ -4,7 +4,7 @@
/**
* A recipient as the provider API reports it. {@code activationUrl} is the link the provider sends
- * to the recipient; omitted when the recipient has no activation code.
+ * to the recipient; omitted once the code has been redeemed.
*
* {@code
* {
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
index e5a2775f..8674ff64 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
@@ -3,24 +3,43 @@
import io.opensharing.ObjectNames;
import io.opensharing.auth.AuthContext;
import io.opensharing.auth.Privilege;
+import io.opensharing.auth.TokenHashes;
import io.opensharing.auth.UserContext;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.Base64;
import java.util.UUID;
/**
- * Provider recipient CRUD for a caller the host has authenticated. Creating a recipient needs
- * CREATE_RECIPIENT from the catalog, and only the owner may change it. Only the owner and callers
- * with USE_RECIPIENT, who may read anyone's recipients, are meant to list and get recipients.
+ * Provider recipient CRUD and token issuance for a caller the host has authenticated. Creating a
+ * recipient needs CREATE_RECIPIENT from the catalog, and only the owner may change it. Only the
+ * owner and callers with USE_RECIPIENT, who may read anyone's recipients, are meant to list and get
+ * recipients. {@link #activate} is public: the activation code itself is the credential.
*
* {@code activationBaseUrl} is the absolute URL recipient activation links are built under, such
* as {@code https://host/api/1.0/opensharing/activations}; the host knows its own address.
*/
public class RecipientService {
+ /** Profile file format version defined by the Delta Sharing protocol. */
+ private static final int SHARE_CREDENTIALS_VERSION = 1;
+
+ /** Random bytes per bearer token; 64 characters once base64url-encoded. */
+ private static final int BEARER_BYTES = 48;
+
+ /** Profile {@code expirationTime} format, as in {@code 2026-01-01T00:00:00.000Z}. */
+ private static final DateTimeFormatter EXPIRATION_TIME =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX").withZone(ZoneOffset.UTC);
+
private final CatalogConnector catalog;
private final RecipientStore recipients;
+ private final SecureRandom random = new SecureRandom();
public RecipientService(CatalogConnector catalog, RecipientStore recipients) {
this.catalog = catalog;
@@ -39,13 +58,16 @@ public RecipientResponse create(
if (request.authenticationType() == null) {
throw ApiException.invalidParameter("authenticationType is required");
}
+ Instant now = Instant.now();
+ Instant expiresAt = expiresAt(request.tokenExpirationDays(), now);
RecipientEntity recipient =
recipients.create(
user,
ObjectNames.validateRecipientName(request.name()),
request.comment(),
request.authenticationType(),
- UUID.randomUUID().toString());
+ UUID.randomUUID().toString(),
+ expiresAt);
return toResponse(recipient, activationBaseUrl);
}
@@ -72,13 +94,60 @@ public RecipientResponse update(
return toResponse(recipients.update(user, recipient, request.comment()), activationBaseUrl);
}
- /** Deletes the recipient and its tokens. Owner only. */
+ /** Deletes the recipient, its tokens and its permissions. Owner only. */
public void delete(UserContext user, String recipient) {
recipients.delete(recipient, user);
}
+ /**
+ * Issues a replacement token with a new activation URL. Existing tokens stop working after the
+ * grace window, immediately unless the request sets one. Owner only.
+ */
+ public IssuedTokenResponse rotateToken(
+ UserContext user, String recipient, RotateTokenRequest request, String activationBaseUrl) {
+ Long graceSeconds = request.existingTokenExpireInSeconds();
+ if (graceSeconds != null && graceSeconds < 0) {
+ throw ApiException.invalidParameter("existingTokenExpireInSeconds must not be negative");
+ }
+ Instant now = Instant.now();
+ Instant expiresAt = expiresAt(request.tokenExpirationDays(), now);
+ Duration grace = graceSeconds == null ? Duration.ZERO : Duration.ofSeconds(graceSeconds);
+ RecipientTokenEntity token =
+ recipients.rotate(user, recipient, UUID.randomUUID().toString(), expiresAt, now, grace);
+ return IssuedTokenResponse.from(token, activationBaseUrl + "/" + token.getActivationCode());
+ }
+
+ /**
+ * Redeems the activation code into a profile file with a new bearer token, stored only as a
+ * hash. Fails with not-found when the code is unknown, already redeemed, or expired. {@code
+ * endpoint} is the absolute URL the host serves the sharing protocol under.
+ */
+ public ProfileFile activate(String code, String endpoint) {
+ String bearer = newBearer();
+ RecipientTokenEntity token =
+ recipients.activate(code, TokenHashes.sha256(bearer), Instant.now());
+ return new ProfileFile(
+ SHARE_CREDENTIALS_VERSION, bearer, endpoint, EXPIRATION_TIME.format(token.getExpiresAt()));
+ }
+
+ private static Instant expiresAt(Long days, Instant now) {
+ if (days == null) {
+ throw ApiException.invalidParameter("tokenExpirationDays is required");
+ }
+ if (days <= 0) {
+ throw ApiException.invalidParameter("tokenExpirationDays must be positive");
+ }
+ return now.plus(Duration.ofDays(days));
+ }
+
private RecipientResponse toResponse(RecipientEntity recipient, String activationBaseUrl) {
String code = recipients.findActivationCode(recipient);
return RecipientResponse.from(recipient, code == null ? null : activationBaseUrl + "/" + code);
}
+
+ private String newBearer() {
+ byte[] bytes = new byte[BEARER_BYTES];
+ random.nextBytes(bytes);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+ }
}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
index 63b6d5a3..e99f0d4d 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
@@ -7,6 +7,8 @@
import io.opensharing.http.ApiException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
+import java.time.Duration;
+import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -24,15 +26,16 @@ public RecipientStore(Transactions tx) {
/**
* Creates a recipient owned by {@code author} and its first token holding {@code
- * activationCode}. {@code name} must already be validated and lowercase. Fails with
- * already-exists when the name is taken.
+ * activationCode}, expiring at {@code expiresAt}. {@code name} must already be validated and
+ * lowercase. Fails with already-exists when the name is taken.
*/
public RecipientEntity create(
UserContext author,
String name,
String comment,
AuthenticationType authenticationType,
- String activationCode) {
+ String activationCode,
+ Instant expiresAt) {
return tx.inTransaction(
false,
em -> {
@@ -48,6 +51,7 @@ public RecipientEntity create(
RecipientTokenEntity token = new RecipientTokenEntity();
token.setRecipient(recipient);
token.setActivationCode(activationCode);
+ token.setExpiresAt(expiresAt);
em.persist(token);
return recipient;
});
@@ -71,20 +75,21 @@ public RecipientEntity getOrThrow(String name) {
return tx.inTransaction(true, em -> getOrThrow(em, name, LockModeType.NONE));
}
- /** The newest token's activation code, or null when there is none. */
+ /** The newest token's activation code, or null once it has been redeemed. */
public String findActivationCode(RecipientEntity recipient) {
return tx.inTransaction(
true,
- em ->
- em.createQuery(
- "select t.activationCode from RecipientTokenEntity t"
- + " where t.recipient.id = :recipientId order by t.createdAt desc",
- String.class)
- .setParameter("recipientId", recipient.getId())
- .setMaxResults(1)
- .getResultStream()
- .findFirst()
- .orElse(null));
+ em -> {
+ List codes =
+ em.createQuery(
+ "select t.activationCode from RecipientTokenEntity t"
+ + " where t.recipient.id = :recipientId order by t.createdAt desc",
+ String.class)
+ .setParameter("recipientId", recipient.getId())
+ .setMaxResults(1)
+ .getResultList();
+ return codes.isEmpty() ? null : codes.get(0);
+ });
}
/** Every recipient, regardless of owner, ordered by name. */
@@ -115,6 +120,79 @@ public void delete(String name, UserContext user) {
});
}
+ /**
+ * Persists a bearer hash and consumes a valid one-time activation code. The row is locked so a
+ * concurrent redeem of the same code waits, then fails with not-found.
+ */
+ public RecipientTokenEntity activate(String activationCode, String tokenHash, Instant now) {
+ return tx.inTransaction(
+ false,
+ em -> {
+ RecipientTokenEntity token =
+ em.createQuery(
+ "select t from RecipientTokenEntity t where t.activationCode = :code",
+ RecipientTokenEntity.class)
+ .setParameter("code", activationCode)
+ .setLockMode(LockModeType.PESSIMISTIC_WRITE)
+ .getResultStream()
+ .findFirst()
+ .orElseThrow(() -> ApiException.notFound("activation code does not exist"));
+ if (token.isActivated() || !token.getExpiresAt().isAfter(now)) {
+ throw ApiException.notFound("activation code does not exist");
+ }
+ token.setTokenHash(tokenHash);
+ token.setActivationCode(null);
+ token.setActivated(true);
+ return token;
+ });
+ }
+
+ /**
+ * Supersedes live credentials of the recipient and persists a pending replacement. An
+ * unactivated credential or a zero grace window expires immediately because no recipient should
+ * keep using it. Only the owner may rotate.
+ */
+ public RecipientTokenEntity rotate(
+ UserContext user,
+ String name,
+ String activationCode,
+ Instant expiresAt,
+ Instant now,
+ Duration grace) {
+ return tx.inTransaction(
+ false,
+ em -> {
+ RecipientEntity recipient = getOwnedOrThrow(em, name, user);
+ List current =
+ em.createQuery(
+ "select t from RecipientTokenEntity t where t.recipient.id = :recipientId",
+ RecipientTokenEntity.class)
+ .setParameter("recipientId", recipient.getId())
+ .getResultList();
+ for (RecipientTokenEntity token : current) {
+ if (!token.getExpiresAt().isAfter(now)) {
+ continue;
+ }
+ token.setSupersededAt(now);
+ token.setActivationCode(null);
+ if (!token.isActivated() || grace.isZero() || grace.isNegative()) {
+ token.setExpiresAt(now);
+ } else {
+ Instant deadline = now.plus(grace);
+ if (token.getExpiresAt().isAfter(deadline)) {
+ token.setExpiresAt(deadline);
+ }
+ }
+ }
+ RecipientTokenEntity replacement = new RecipientTokenEntity();
+ replacement.setRecipient(recipient);
+ replacement.setActivationCode(activationCode);
+ replacement.setExpiresAt(expiresAt);
+ em.persist(replacement);
+ return replacement;
+ });
+ }
+
private static Optional find(EntityManager em, String name, LockModeType lock) {
return em.createQuery(
"select r from RecipientEntity r where r.name = :name", RecipientEntity.class)
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java
index a7c8dce3..a65806d5 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java
@@ -8,19 +8,25 @@
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
+import java.time.Instant;
-/** A token issued to a recipient. {@code activationCode} is stored plaintext until redeemed. */
+/**
+ * A recipient credential. Its activation code is plaintext until redeemed; only the bearer hash is
+ * persisted.
+ */
@Entity
@Table(
name = "os_recipient_tokens",
- uniqueConstraints =
- @UniqueConstraint(
- name = "uk_recipient_tokens_activation_code",
- columnNames = "activation_code"))
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_recipient_tokens_activation_code",
+ columnNames = "activation_code"),
+ @UniqueConstraint(name = "uk_recipient_tokens_token_hash", columnNames = "token_hash")
+ })
public class RecipientTokenEntity extends BaseEntity {
/** The recipient the token belongs to. {@code RecipientStore} deletes tokens with it. */
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @ManyToOne(fetch = FetchType.EAGER, optional = false)
@JoinColumn(name = "recipient_id", nullable = false)
private RecipientEntity recipient;
@@ -28,6 +34,22 @@ public class RecipientTokenEntity extends BaseEntity {
@Column(name = "activation_code", unique = true, length = 36)
private String activationCode;
+ /** SHA-256 hex of the bearer token; null until the activation code is redeemed. */
+ @Column(name = "token_hash", unique = true, length = 64)
+ private String tokenHash;
+
+ /** Whether the activation code has been redeemed. */
+ @Column(nullable = false)
+ private boolean activated;
+
+ /** When the token stops working. */
+ @Column(name = "expires_at", nullable = false)
+ private Instant expiresAt;
+
+ /** When a rotation replaced this token; null while it is the current token. */
+ @Column(name = "superseded_at")
+ private Instant supersededAt;
+
public RecipientEntity getRecipient() {
return recipient;
}
@@ -43,4 +65,36 @@ public String getActivationCode() {
public void setActivationCode(String activationCode) {
this.activationCode = activationCode;
}
+
+ public String getTokenHash() {
+ return tokenHash;
+ }
+
+ public void setTokenHash(String tokenHash) {
+ this.tokenHash = tokenHash;
+ }
+
+ public boolean isActivated() {
+ return activated;
+ }
+
+ public void setActivated(boolean activated) {
+ this.activated = activated;
+ }
+
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public void setExpiresAt(Instant expiresAt) {
+ this.expiresAt = expiresAt;
+ }
+
+ public Instant getSupersededAt() {
+ return supersededAt;
+ }
+
+ public void setSupersededAt(Instant supersededAt) {
+ this.supersededAt = supersededAt;
+ }
}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RotateTokenRequest.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RotateTokenRequest.java
new file mode 100644
index 00000000..bcc4077c
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RotateTokenRequest.java
@@ -0,0 +1,9 @@
+package io.opensharing.recipient;
+
+/**
+ * POST body to rotate a recipient's token.
+ *
+ * @param tokenExpirationDays replacement token lifetime in days (required)
+ * @param existingTokenExpireInSeconds old-token grace window; defaults to 0 (revoke immediately)
+ */
+public record RotateTokenRequest(Long tokenExpirationDays, Long existingTokenExpireInSeconds) {}
diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java
index fd6c44c7..389b7a1a 100644
--- a/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java
+++ b/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java
@@ -40,7 +40,7 @@ void createsARecipientOnlyWithCreateRecipientFromTheCatalog() {
() ->
recipients.create(
alice,
- new CreateRecipientRequest("acme", null, AuthenticationType.TOKEN),
+ new CreateRecipientRequest("acme", null, AuthenticationType.TOKEN, 30L),
"http://host/activations"));
assertEquals(List.of(Privilege.CREATE_RECIPIENT), privileges);
}
diff --git a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
index 3bdb4fe5..a94023b2 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
@@ -16,6 +16,7 @@
import io.opensharing.http.ApiExceptionHandler;
import io.opensharing.http.ApiJson;
import io.opensharing.persistence.JpaTransactions;
+import io.opensharing.protocol.ActivationService;
import io.opensharing.recipient.RecipientAdminService;
import io.opensharing.runtime.OpenSharing;
import io.opensharing.share.ShareAdminService;
@@ -55,7 +56,8 @@ public static void main(String[] args) throws InterruptedException {
/**
* Starts a server configured by {@code properties} and returns once it is listening. Provider
- * services are served under {@code provider.base-path}, and any other path gets a 404 in the
+ * services are served under {@code provider.base-path}, recipient activation under {@code
+ * activation.base-path} without a provider token, and any other path gets a 404 in the
* protocol's error format. Links handed to recipients are built on {@code server.public-url},
* which must be set. If startup fails, the database connections are closed.
*/
@@ -65,7 +67,10 @@ public static OpenSharingServer start(ServerProperties properties) {
JpaTransactions transactions = new JpaTransactions(OpenSharing.entities());
try {
OpenSharing openSharing =
- OpenSharing.builder().catalog(catalog).transactions(transactions).build();
+ OpenSharing.builder()
+ .catalog(catalog)
+ .transactions(transactions)
+ .build();
ApiExceptionHandler errors = new ApiExceptionHandler();
HttpService notFound =
(ctx, req) -> {
@@ -95,6 +100,14 @@ public static OpenSharingServer start(ServerProperties properties) {
new RecipientAdminService(
openSharing.recipients(),
publicUrl + properties.get(Property.ACTIVATION_BASE_PATH)));
+ builder
+ .annotatedService()
+ .pathPrefix(properties.get(Property.ACTIVATION_BASE_PATH))
+ .decorator(errors)
+ .build(
+ new ActivationService(
+ openSharing.recipients(),
+ publicUrl + properties.get(Property.PROTOCOL_BASE_PATH)));
Server server = builder.build();
try {
server.start().join();
@@ -119,6 +132,11 @@ public OpenSharing openSharing() {
return openSharing;
}
+ /** The database transactions behind the core services, for callers such as tests. */
+ public Transactions transactions() {
+ return transactions;
+ }
+
/** Stops the server, then closes the database connections its requests used. */
@Override
public void close() {
diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java b/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
index 3bf221c2..43bed5d7 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
@@ -27,6 +27,7 @@ public enum Property {
PUBLIC_URL("server.public-url", null),
PROVIDER_BASE_PATH("provider.base-path", "/api/1.0/opensharing/provider"),
ACTIVATION_BASE_PATH("activation.base-path", "/api/1.0/opensharing/activations"),
+ PROTOCOL_BASE_PATH("protocol.base-path", "/api/1.0/opensharing"),
CATALOG_TYPE("catalog.type", "local"),
LOCAL_CATALOG_FILE("catalog.local.file", "classpath:local-catalog.yml");
diff --git a/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java b/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java
index 44145edc..eafaae1a 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java
@@ -71,4 +71,17 @@ public HttpResponse delete(ServiceRequestContext ctx, @Param String recipient) {
recipients.delete(ProviderTokenDecorator.user(ctx), recipient);
return HttpResponse.of(HttpStatus.NO_CONTENT);
}
+
+ /**
+ * {@code POST /recipients/{recipient}/rotate-token}: issues a replacement token with a new
+ * activation URL. Existing tokens stop working after the grace window, immediately unless the
+ * request sets one. Owner only.
+ */
+ @Post("/recipients/{recipient}/rotate-token")
+ @StatusCode(201)
+ public IssuedTokenResponse rotateToken(
+ ServiceRequestContext ctx, @Param String recipient, RotateTokenRequest request) {
+ return recipients.rotateToken(
+ ProviderTokenDecorator.user(ctx), recipient, request, activationBaseUrl);
+ }
}
diff --git a/server/opensharing-server/src/main/resources/server.properties b/server/opensharing-server/src/main/resources/server.properties
index d311fc37..59ddc79b 100644
--- a/server/opensharing-server/src/main/resources/server.properties
+++ b/server/opensharing-server/src/main/resources/server.properties
@@ -13,6 +13,8 @@ server.public-url=
provider.base-path=/api/1.0/opensharing/provider
# Path prefix recipient activation links are built under, after server.public-url.
activation.base-path=/api/1.0/opensharing/activations
+# Path prefix of the sharing protocol; profile files point recipients to it after server.public-url.
+protocol.base-path=/api/1.0/opensharing
# Which CatalogConnector the server uses; local reads assets from a YAML file.
catalog.type=local
diff --git a/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java b/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java
index 42c6ef73..f3a5193e 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java
@@ -59,7 +59,7 @@ void buildsActivationLinksOnThePublicUrlRatherThanTheRequestAddress() throws Exc
.contentType(MediaType.JSON)
.add("authorization", "Bearer alice-token")
.build(),
- "{\"name\":\"acme\",\"authenticationType\":\"TOKEN\"}")
+ "{\"name\":\"acme\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}")
.aggregate()
.join();
diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ActivationServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ActivationServiceTest.java
new file mode 100644
index 00000000..747f659a
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ActivationServiceTest.java
@@ -0,0 +1,107 @@
+package io.opensharing.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.opensharing.TestServer;
+import io.opensharing.TestServer.Response;
+import io.opensharing.auth.TokenHashes;
+import io.opensharing.http.ErrorCodes;
+import io.opensharing.recipient.RecipientTokenEntity;
+import java.net.URI;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class ActivationServiceTest {
+
+ private static final String RECIPIENTS = "/api/1.0/opensharing/provider/recipients";
+
+ private static TestServer server;
+
+ @BeforeAll
+ static void startServer() {
+ server = TestServer.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.close();
+ }
+
+ @Test
+ void unknownActivationCodeIsNotFound() {
+ // Missing activation codes are 404 without a bearer token.
+ Response missing = server.get("/api/1.0/opensharing/activations/missing", null);
+ assertEquals(404, missing.status());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, missing.errorCode());
+ }
+
+ @Test
+ void redeemsAnActivationUrlOnce() {
+ // Owner creates a TOKEN recipient and gets an activation URL.
+ Response created =
+ server.post(
+ RECIPIENTS,
+ "alice-token",
+ "{\"name\":\"Acme\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}");
+ assertEquals(201, created.status());
+ String activationPath =
+ URI.create(created.body().path("activationUrl").asText()).getPath();
+
+ // Redeeming the URL issues a protocol profile with a bearer token.
+ Response profile = server.get(activationPath, null);
+ assertEquals(200, profile.status());
+ assertEquals("no-store", profile.headers().get("Cache-Control"));
+ assertEquals(
+ "attachment; filename=\"config.share\"", profile.headers().get("Content-Disposition"));
+ assertEquals(1, profile.body().path("shareCredentialsVersion").asInt());
+ String bearer = profile.body().path("bearerToken").asText();
+ assertTrue(bearer.matches("[A-Za-z0-9_-]{64}"));
+ assertEquals(
+ "http://127.0.0.1:" + server.server().port() + "/api/1.0/opensharing",
+ profile.body().path("endpoint").asText());
+ assertTrue(
+ profile
+ .body()
+ .path("expirationTime")
+ .asText()
+ .matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z"));
+ assertTrue(profile.body().path("icebergEndpoint").isMissingNode());
+
+ // Only a hash of the bearer token is stored, and the code is used up.
+ RecipientTokenEntity stored = newestToken("acme");
+ assertNull(stored.getActivationCode());
+ assertTrue(stored.isActivated());
+ assertEquals(TokenHashes.sha256(bearer), stored.getTokenHash());
+ assertNotEquals(bearer, stored.getTokenHash());
+
+ // The same code cannot be redeemed again.
+ Response again = server.get(activationPath, null);
+ assertEquals(404, again.status());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, again.errorCode());
+
+ // After activation the recipient no longer has an activation URL.
+ Response recipient = server.get(RECIPIENTS + "/acme", "alice-token");
+ assertEquals(200, recipient.status());
+ assertTrue(recipient.body().path("activationUrl").isMissingNode());
+ }
+
+ private static RecipientTokenEntity newestToken(String recipient) {
+ return server
+ .server()
+ .transactions()
+ .inTransaction(
+ true,
+ em ->
+ em.createQuery(
+ "select t from RecipientTokenEntity t where t.recipient.name = :name"
+ + " order by t.createdAt desc",
+ RecipientTokenEntity.class)
+ .setParameter("name", recipient)
+ .setMaxResults(1)
+ .getSingleResult());
+ }
+}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProfileEndpointTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProfileEndpointTest.java
new file mode 100644
index 00000000..e83fc639
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProfileEndpointTest.java
@@ -0,0 +1,51 @@
+package io.opensharing.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.RequestHeaders;
+import io.opensharing.OpenSharingServer;
+import io.opensharing.config.ServerProperties;
+import java.net.URI;
+import java.util.Properties;
+import org.junit.jupiter.api.Test;
+
+class ProfileEndpointTest {
+
+ private static final ObjectMapper JSON = new ObjectMapper();
+
+ @Test
+ void pointsProfilesAtThePublicUrlRatherThanTheRequestAddress() throws Exception {
+ Properties props = new Properties();
+ props.setProperty("server.port", "0");
+ props.setProperty("server.public-url", "https://sharing.example.com");
+ try (OpenSharingServer server = OpenSharingServer.start(new ServerProperties(props))) {
+ WebClient client = WebClient.of("http://127.0.0.1:" + server.port());
+ AggregatedHttpResponse created =
+ client
+ .execute(
+ RequestHeaders.builder(
+ HttpMethod.POST, "/api/1.0/opensharing/provider/recipients")
+ .contentType(MediaType.JSON)
+ .add("authorization", "Bearer alice-token")
+ .build(),
+ "{\"name\":\"acme\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}")
+ .aggregate()
+ .join();
+ assertEquals(201, created.status().code());
+ String activationUrl = JSON.readTree(created.contentUtf8()).path("activationUrl").asText();
+
+ AggregatedHttpResponse profile =
+ client.get(URI.create(activationUrl).getPath()).aggregate().join();
+ assertEquals(200, profile.status().code());
+ JsonNode body = JSON.readTree(profile.contentUtf8());
+ assertEquals(
+ "https://sharing.example.com/api/1.0/opensharing", body.path("endpoint").asText());
+ }
+ }
+}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java
index d14c248b..c8defe96 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java
@@ -7,6 +7,9 @@
import io.opensharing.TestServer;
import io.opensharing.TestServer.Response;
import io.opensharing.http.ErrorCodes;
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -45,7 +48,8 @@ void createsListsUpdatesAndDeletesARecipient() {
server.post(
RECIPIENTS,
"alice-token",
- "{\"name\":\"Acme\",\"comment\":\"partner\",\"authenticationType\":\"TOKEN\"}");
+ "{\"name\":\"Acme\",\"comment\":\"partner\",\"authenticationType\":\"TOKEN\","
+ + "\"tokenExpirationDays\":30}");
assertEquals(201, created.status());
assertEquals("acme", created.body().path("name").asText());
assertEquals("TOKEN", created.body().path("authenticationType").asText());
@@ -86,4 +90,100 @@ void createsListsUpdatesAndDeletesARecipient() {
assertEquals(404, deleted.status());
assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, deleted.errorCode());
}
+
+ @Test
+ void createsARecipientWithTheRequestedTokenLifetime() {
+ // Create requires the first token's lifetime.
+ Response missing =
+ server.post(
+ RECIPIENTS, "alice-token", "{\"name\":\"Timed\",\"authenticationType\":\"TOKEN\"}");
+ assertEquals(400, missing.status());
+ assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, missing.errorCode());
+
+ // Create sets the first token's lifetime in days.
+ Response created =
+ server.post(
+ RECIPIENTS,
+ "alice-token",
+ "{\"name\":\"Timed\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":1}");
+ assertEquals(201, created.status());
+
+ // Activating the issued URL puts that expiration on the profile.
+ Response profile = server.get(activationPath(created), null);
+ assertEquals(200, profile.status());
+ Instant expiresAt = Instant.parse(profile.body().path("expirationTime").asText());
+ Duration remaining = Duration.between(Instant.now(), expiresAt);
+ assertTrue(remaining.compareTo(Duration.ofHours(23)) >= 0);
+ assertTrue(remaining.compareTo(Duration.ofHours(25)) <= 0);
+ }
+
+ @Test
+ void rotatesARecipientToken() {
+ // Create a recipient with its initial pending activation.
+ Response created =
+ server.post(
+ RECIPIENTS,
+ "alice-token",
+ "{\"name\":\"Rotate\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}");
+ assertEquals(201, created.status());
+ String oldActivation = activationPath(created);
+
+ // Non-owner cannot rotate the token.
+ Response denied =
+ server.post(
+ RECIPIENTS + "/rotate/rotate-token", "bob-token", "{\"tokenExpirationDays\":1}");
+ assertEquals(403, denied.status());
+ assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode());
+
+ // Owner replaces it with a one-day token and no grace window.
+ Response rotated =
+ server.post(
+ RECIPIENTS + "/rotate/rotate-token",
+ "alice-token",
+ "{\"tokenExpirationDays\":1,\"existingTokenExpireInSeconds\":0}");
+ assertEquals(201, rotated.status());
+ assertFalse(rotated.body().path("tokenId").isMissingNode());
+ assertEquals("rotate", rotated.body().path("recipientName").asText());
+ assertFalse(rotated.body().path("expiresAt").isMissingNode());
+
+ // Rotation invalidates the old pending activation immediately.
+ Response old = server.get(oldActivation, null);
+ assertEquals(404, old.status());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, old.errorCode());
+
+ // The replacement activation yields the requested profile.
+ Response profile = server.get(activationPath(rotated), null);
+ assertEquals(200, profile.status());
+ assertEquals(1, profile.body().path("shareCredentialsVersion").asInt());
+ assertTrue(profile.body().path("bearerToken").asText().matches("[A-Za-z0-9_-]{64}"));
+ assertFalse(profile.body().path("expirationTime").isMissingNode());
+
+ // Rotation requires the replacement token's lifetime.
+ Response missing = server.post(RECIPIENTS + "/rotate/rotate-token", "alice-token", "{}");
+ assertEquals(400, missing.status());
+ assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, missing.errorCode());
+
+ // Without a grace window, rotating again expires the activated token immediately.
+ Instant before = Instant.now();
+ Response again =
+ server.post(
+ RECIPIENTS + "/rotate/rotate-token", "alice-token", "{\"tokenExpirationDays\":1}");
+ assertEquals(201, again.status());
+ assertTrue(again.body().path("activationUrl").asText().startsWith(activations));
+ Instant expired = expiresAt(rotated.body().path("tokenId").asText());
+ assertFalse(expired.isBefore(before));
+ assertFalse(expired.isAfter(Instant.now()));
+ }
+
+ private static Instant expiresAt(String tokenId) {
+ return server
+ .server()
+ .transactions()
+ .inTransaction(
+ true, em -> em.find(RecipientTokenEntity.class, tokenId).getExpiresAt());
+ }
+
+ private static String activationPath(Response response) {
+ return URI.create(response.body().path("activationUrl").asText()).getPath();
+ }
}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientRotationConcurrencyTest.java b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientRotationConcurrencyTest.java
new file mode 100644
index 00000000..7f19901b
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientRotationConcurrencyTest.java
@@ -0,0 +1,67 @@
+package io.opensharing.recipient;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.opensharing.TestServer;
+import io.opensharing.TestServer.Response;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import org.junit.jupiter.api.Test;
+
+class RecipientRotationConcurrencyTest {
+
+ private static final String RECIPIENTS = "/api/1.0/opensharing/provider/recipients";
+ private static final int ROTATIONS = 8;
+
+ @Test
+ void concurrentRotationsLeaveOnlyOneRedeemableActivation() throws Exception {
+ try (TestServer server = TestServer.start()) {
+ Response created =
+ server.post(
+ RECIPIENTS,
+ "alice-token",
+ "{\"name\":\"racer\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":1}");
+ assertEquals(201, created.status());
+
+ ExecutorService pool = Executors.newFixedThreadPool(ROTATIONS);
+ List activations = new ArrayList<>();
+ try {
+ CountDownLatch start = new CountDownLatch(1);
+ List> rotations = new ArrayList<>();
+ for (int i = 0; i < ROTATIONS; i++) {
+ rotations.add(
+ pool.submit(
+ () -> {
+ start.await();
+ return server.post(
+ RECIPIENTS + "/racer/rotate-token",
+ "alice-token",
+ "{\"tokenExpirationDays\":1}");
+ }));
+ }
+ start.countDown();
+ for (Future rotation : rotations) {
+ Response rotated = rotation.get();
+ assertEquals(201, rotated.status());
+ activations.add(URI.create(rotated.body().path("activationUrl").asText()).getPath());
+ }
+ } finally {
+ pool.shutdownNow();
+ }
+
+ // Each rotation supersedes the one before it, so only the last activation still redeems.
+ int redeemed = 0;
+ for (String activation : activations) {
+ if (server.get(activation, null).status() == 200) {
+ redeemed++;
+ }
+ }
+ assertEquals(1, redeemed);
+ }
+ }
+}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionAdminServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionAdminServiceTest.java
index 897878b6..3c2a9dbe 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionAdminServiceTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionAdminServiceTest.java
@@ -32,11 +32,9 @@ void grantsAndRevokesSelect() {
assertEquals(201, server.post(SHARES, "alice-token", "{\"name\":\"sales\"}").status());
// Create the recipient that will receive SELECT.
- assertEquals(
- 201,
- server
- .post(RECIPIENTS, "alice-token", "{\"name\":\"Acme\",\"authenticationType\":\"TOKEN\"}")
- .status());
+ String recipient =
+ "{\"name\":\"Acme\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}";
+ assertEquals(201, server.post(RECIPIENTS, "alice-token", recipient).status());
// Non-owner cannot grant.
Response denied =
diff --git a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionConcurrencyTest.java b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionConcurrencyTest.java
index 58d68f44..54885576 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionConcurrencyTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionConcurrencyTest.java
@@ -64,7 +64,8 @@ void concurrentRevokesOfTheSamePrivilegeFindItOnce() throws Exception {
// The recipient is named after the share.
private static void createShareAndRecipient(String share) {
assertEquals(201, server.post(SHARES, "alice-token", "{\"name\":\"" + share + "\"}").status());
- String body = "{\"name\":\"" + share + "\",\"authenticationType\":\"TOKEN\"}";
+ String body =
+ "{\"name\":\"" + share + "\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":1}";
assertEquals(201, server.post(RECIPIENTS, "alice-token", body).status());
}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionUpdateTransactionTest.java b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionUpdateTransactionTest.java
index e4b6ec32..160cec8f 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionUpdateTransactionTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/share/SharePermissionUpdateTransactionTest.java
@@ -71,7 +71,10 @@ void rollsBackWhenARecipientDoesNotExist() {
private static void createShareGrantedTo(String share, String... recipients) {
assertEquals(201, server.post(SHARES, "alice-token", "{\"name\":\"" + share + "\"}").status());
for (String recipient : recipients) {
- String body = "{\"name\":\"" + recipient + "\",\"authenticationType\":\"TOKEN\"}";
+ String body =
+ "{\"name\":\""
+ + recipient
+ + "\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}";
assertEquals(201, server.post(RECIPIENTS, "alice-token", body).status());
}
Response granted =