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 =