From 9de6f677a73c810ad9c48dcb0898e0702cdecc6e Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 13 Oct 2026 17:02:08 -0700 Subject: [PATCH 1/5] Serve the protocol share list and get to recipient bearer tokens, on Armeria. --- .../asset/share/GetShareResponse.java | 12 ++ .../asset/share/ProtocolShares.java | 43 +++++ .../protocol/ActivationService.java | 4 +- .../protocol/OpenSharingProtocolService.java | 42 +++++ .../protocol/OpenSharingRecipientApi.java | 50 ++++++ .../protocol/RecipientTokenDecorator.java | 61 ++++++++ .../recipient/RecipientPrincipal.java | 4 + .../recipient/RecipientService.java | 14 +- .../opensharing/recipient/RecipientStore.java | 17 ++ .../io/opensharing/runtime/OpenSharing.java | 14 +- .../share/SharePermissionStore.java | 45 ++++++ .../io/opensharing/OpenSharingServer.java | 25 ++- .../OpenSharingProtocolServiceTest.java | 147 ++++++++++++++++++ .../protocol/ProtocolApiSupport.java | 73 +++++++++ 14 files changed, 533 insertions(+), 18 deletions(-) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/asset/share/GetShareResponse.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/GetShareResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/GetShareResponse.java new file mode 100644 index 00000000..f4cca3fa --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/GetShareResponse.java @@ -0,0 +1,12 @@ +package io.opensharing.asset.share; + +import io.opensharing.share.ShareResponse; + +/** + * Protocol {@code GET /shares/{share}} wraps the share in a single field: + * + *
{@code
+ * {"share": {"id": "", "name": "", ...}}
+ * }
+ */ +public record GetShareResponse(ShareResponse share) {} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java new file mode 100644 index 00000000..129b6da3 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java @@ -0,0 +1,43 @@ +package io.opensharing.asset.share; + +import io.opensharing.http.ApiException; +import io.opensharing.http.ListResponse; +import io.opensharing.http.Pagination; +import io.opensharing.recipient.RecipientPrincipal; +import io.opensharing.share.ShareResponse; +import io.opensharing.share.SharePermissionStore; +import io.opensharing.share.ShareStore; + +/** + * The shares a recipient sees through the sharing protocol: those it holds SELECT on. Any other + * share is reported as missing, so recipients cannot learn which shares exist. + */ +public class ProtocolShares { + + private final ShareStore shares; + private final SharePermissionStore permissions; + + public ProtocolShares(ShareStore shares, SharePermissionStore permissions) { + this.shares = shares; + this.permissions = permissions; + } + + /** Lists one page of the recipient's shares by name. */ + public ListResponse list( + RecipientPrincipal recipient, Integer maxResults, String pageToken) { + return Pagination.page( + maxResults, + pageToken, + (offset, limit) -> permissions.listSharesFor(recipient.recipientId(), offset, limit), + ShareResponse::from); + } + + /** Gets one of the recipient's shares by name in any case. */ + public GetShareResponse get(RecipientPrincipal recipient, String share) { + return shares + .find(share) + .filter(candidate -> permissions.hasSelect(candidate, recipient.recipientId())) + .map(granted -> new GetShareResponse(ShareResponse.from(granted))) + .orElseThrow(() -> ApiException.notFound("share '" + share + "' does not exist")); + } +} 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 index 1f1345f8..7824e621 100644 --- 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 @@ -11,8 +11,8 @@ 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. + * Public one-time redemption of a recipient activation URL. {@link OpenSharingRecipientApi} mounts + * it without authentication: the activation code itself is the credential. */ @Blocking public final class ActivationService { diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java new file mode 100644 index 00000000..208e52d7 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingProtocolService.java @@ -0,0 +1,42 @@ +package io.opensharing.protocol; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.annotation.Blocking; +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.Param; +import com.linecorp.armeria.server.annotation.ProducesJson; +import io.opensharing.asset.share.GetShareResponse; +import io.opensharing.asset.share.ProtocolShares; +import io.opensharing.http.ListResponse; +import io.opensharing.share.ShareResponse; + +/** + * The Delta Sharing protocol API: every endpoint recipients call with their bearer token, served + * behind {@link RecipientTokenDecorator}. + */ +@Blocking +@ProducesJson +public final class OpenSharingProtocolService { + + private final ProtocolShares shares; + + public OpenSharingProtocolService(ProtocolShares shares) { + this.shares = shares; + } + + /** {@code GET /shares}: lists one page of the shares the recipient holds SELECT on. */ + @Get("/shares") + public ListResponse listShares( + ServiceRequestContext ctx, + @Param @Nullable Integer maxResults, + @Param @Nullable String pageToken) { + return shares.list(RecipientTokenDecorator.recipient(ctx), maxResults, pageToken); + } + + /** {@code GET /shares/{share}}: gets a share the recipient holds SELECT on. */ + @Get("/shares/{share}") + public GetShareResponse getShare(ServiceRequestContext ctx, @Param String share) { + return shares.get(RecipientTokenDecorator.recipient(ctx), share); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java new file mode 100644 index 00000000..4c0bf1de --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/OpenSharingRecipientApi.java @@ -0,0 +1,50 @@ +package io.opensharing.protocol; + +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.annotation.JacksonRequestConverterFunction; +import com.linecorp.armeria.server.annotation.JacksonResponseConverterFunction; +import io.opensharing.http.ApiExceptionHandler; +import io.opensharing.http.ApiJson; +import io.opensharing.runtime.OpenSharing; + +/** + * The recipient-facing endpoints, which OpenSharing authenticates itself. A host mounts them on + * its Armeria server next to its own services. + */ +public final class OpenSharingRecipientApi { + + private OpenSharingRecipientApi() {} + + /** + * Serves the sharing protocol under {@code protocolBasePath}, behind recipient bearer tokens, and + * recipient activation under {@code activationBasePath}, behind none. Both bring their own JSON + * converters and error handling, which take precedence over the host's server-wide ones. + * + * @param publicUrl the address recipients reach the host at, such as {@code + * https://sharing.example.com}, which profile files point recipients to + */ + public static void mount( + ServerBuilder builder, + String publicUrl, + String protocolBasePath, + String activationBasePath, + OpenSharing openSharing) { + ApiExceptionHandler errors = new ApiExceptionHandler(); + // The token check must sit inside the error decorator, so that its 401s get the protocol body. + builder + .annotatedService() + .pathPrefix(protocolBasePath) + .requestConverters(new JacksonRequestConverterFunction(ApiJson.MAPPER)) + .responseConverters(new JacksonResponseConverterFunction(ApiJson.MAPPER)) + .exceptionHandlers(errors) + .decorator(new RecipientTokenDecorator(openSharing.recipients())) + .decorator(errors) + .build(new OpenSharingProtocolService(openSharing.protocolShares())); + builder + .annotatedService() + .pathPrefix(activationBasePath) + .exceptionHandlers(errors) + .decorator(errors) + .build(new ActivationService(openSharing.recipients(), publicUrl + protocolBasePath)); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java new file mode 100644 index 00000000..0296c83f --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java @@ -0,0 +1,61 @@ +package io.opensharing.protocol; + +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.auth.OAuth2Token; +import com.linecorp.armeria.server.DecoratingHttpServiceFunction; +import com.linecorp.armeria.server.HttpService; +import com.linecorp.armeria.server.ServiceRequestContext; +import com.linecorp.armeria.server.auth.AuthTokenExtractors; +import io.netty.util.AttributeKey; +import io.opensharing.http.ApiException; +import io.opensharing.recipient.RecipientPrincipal; +import io.opensharing.recipient.RecipientService; + +/** + * Authenticates a sharing protocol request by its recipient bearer token; services read the + * recipient with {@link #recipient}. Missing, unknown, unactivated and expired tokens get a 401. + */ +public final class RecipientTokenDecorator implements DecoratingHttpServiceFunction { + + private static final AttributeKey RECIPIENT = + AttributeKey.valueOf(RecipientTokenDecorator.class, "recipient"); + + private final RecipientService recipients; + + public RecipientTokenDecorator(RecipientService recipients) { + this.recipients = recipients; + } + + @Override + public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpRequest req) { + OAuth2Token token = AuthTokenExtractors.oAuth2().apply(req.headers()); + if (token == null || token.accessToken().isBlank()) { + throw ApiException.unauthenticated("bearer token is required"); + } + // The token lookup queries the database, which must not block the event loop. + return HttpResponse.of( + () -> { + try { + ctx.setAttr(RECIPIENT, recipients.authenticate(token.accessToken())); + return delegate.serve(ctx, req); + } catch (Exception e) { + return HttpResponse.ofFailure(e); + } + }, + ctx.blockingTaskExecutor()); + } + + /** + * Returns the recipient this decorator authenticated for the request. Only services mounted + * behind this decorator may call it; anywhere else there is no recipient, so it fails with a 401 + * rather than letting the service run anonymously. + */ + public static RecipientPrincipal recipient(ServiceRequestContext ctx) { + RecipientPrincipal recipient = ctx.attr(RECIPIENT); + if (recipient == null) { + throw ApiException.unauthenticated("this endpoint requires an authenticated recipient"); + } + return recipient; + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java new file mode 100644 index 00000000..c1e207aa --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java @@ -0,0 +1,4 @@ +package io.opensharing.recipient; + +/** The recipient a sharing protocol request's bearer token belongs to. */ +public record RecipientPrincipal(String recipientId) {} 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 55862364..d5d89883 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 @@ -24,7 +24,8 @@ * recipient needs CREATE_RECIPIENT from the catalog, and only the owner may change it. The owner * and callers with USE_RECIPIENT from the catalog may read a recipient; listing without * USE_RECIPIENT returns only the caller's own recipients. {@link #activate} is public: the - * activation code itself is the credential. + * activation code itself is the credential. {@link #authenticate} checks the bearer tokens + * recipients then present. * *

{@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. @@ -142,6 +143,17 @@ public ProfileFile activate(String code, String endpoint) { SHARE_CREDENTIALS_VERSION, bearer, endpoint, EXPIRATION_TIME.format(token.getExpiresAt())); } + /** + * Resolves a sharing protocol bearer token to its recipient. Fails with unauthenticated unless + * the token is activated and unexpired. + */ + public RecipientPrincipal authenticate(String bearer) { + return recipients + .findUsableToken(TokenHashes.sha256(bearer), Instant.now()) + .map(token -> new RecipientPrincipal(token.getRecipient().getId())) + .orElseThrow(() -> ApiException.unauthenticated("bearer token is invalid or expired")); + } + private static Instant expiresAt(Long days, Instant now) { if (days == null) { throw ApiException.invalidParameter("tokenExpirationDays is required"); 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 9af115db..ed72bb53 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 @@ -163,6 +163,23 @@ public RecipientTokenEntity activate(String activationCode, String tokenHash, In }); } + /** + * {@code SELECT * FROM os_recipient_tokens WHERE token_hash = ?}, kept only when the token is + * activated and unexpired at {@code now}. + */ + public Optional findUsableToken(String tokenHash, Instant now) { + return tx.inTransaction( + true, + em -> + em.createQuery( + "select t from RecipientTokenEntity t where t.tokenHash = :hash", + RecipientTokenEntity.class) + .setParameter("hash", tokenHash) + .getResultStream() + .findFirst() + .filter(token -> token.isActivated() && token.getExpiresAt().isAfter(now))); + } + /** * 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 diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java index 34fc31e8..d576f1d5 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java @@ -3,6 +3,7 @@ import io.opensharing.Transactions; import io.opensharing.asset.SharedDataObjectEntity; import io.opensharing.asset.SharedDataObjectStore; +import io.opensharing.asset.share.ProtocolShares; import io.opensharing.catalog.CatalogConnector; import io.opensharing.recipient.RecipientEntity; import io.opensharing.recipient.RecipientService; @@ -25,19 +26,23 @@ public final class OpenSharing { private final CatalogConnector catalog; private final ShareService shares; private final RecipientService recipients; + private final ProtocolShares protocolShares; private OpenSharing(CatalogConnector catalog, Transactions tx) { this.catalog = catalog; + ShareStore shareStore = new ShareStore(tx); + SharePermissionStore permissionStore = new SharePermissionStore(tx); RecipientStore recipientStore = new RecipientStore(tx); this.shares = new ShareService( catalog, tx, - new ShareStore(tx), + shareStore, new SharedDataObjectStore(tx), - new SharePermissionStore(tx), + permissionStore, recipientStore); this.recipients = new RecipientService(catalog, recipientStore); + this.protocolShares = new ProtocolShares(shareStore, permissionStore); } public static Builder builder() { @@ -66,6 +71,11 @@ public RecipientService recipients() { return recipients; } + /** The shares recipients see through the sharing protocol. */ + public ProtocolShares protocolShares() { + return protocolShares; + } + public static final class Builder { private CatalogConnector catalog; diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java index 55c4fb19..eee778c1 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java @@ -70,6 +70,51 @@ public List list(ShareEntity share, int offset, int limit .getResultList()); } + /** + * {@code SELECT s.* FROM os_share_permissions p JOIN os_shares s ON s.id = p.share_id WHERE + * p.recipient_id = ? AND p.privilege = 'SELECT' ORDER BY s.name LIMIT ? OFFSET ?}. Share names + * are unique, so the order is total. + */ + public List listSharesFor(String recipientId, int offset, int limit) { + return tx.inTransaction( + true, + em -> + em.createQuery( + "select s from SharePermissionEntity p join p.share s" + + " where p.recipient.id = :recipientId and p.privilege = :privilege" + + " order by s.name", + ShareEntity.class) + .setParameter("recipientId", recipientId) + .setParameter("privilege", SharePrivilege.SELECT) + .setFirstResult(offset) + .setMaxResults(limit) + .getResultList()); + } + + /** + * {@code SELECT id FROM os_share_permissions WHERE share_id = ? AND recipient_id = ? AND + * privilege = 'SELECT' LIMIT 1} + */ + public boolean hasSelect(ShareEntity share, String recipientId) { + return tx.inTransaction( + true, + em -> + !em.createQuery( + "select p.id from SharePermissionEntity p where p.share.id = :shareId" + + " and p.recipient.id = :recipientId and p.privilege = :privilege", + String.class) + .setParameter("shareId", share.getId()) + .setParameter("recipientId", recipientId) + .setParameter("privilege", SharePrivilege.SELECT) + .setMaxResults(1) + .getResultList() + .isEmpty()); + } + + /** + * {@code SELECT * FROM os_share_permissions WHERE share_id = ? AND recipient_id = ? AND + * privilege = ?} + */ private static Optional find( EntityManager em, ShareEntity share, RecipientEntity recipient, SharePrivilege privilege) { return em.createQuery( 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 a94023b2..e09cbd28 100644 --- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java +++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java @@ -16,7 +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.protocol.OpenSharingRecipientApi; import io.opensharing.recipient.RecipientAdminService; import io.opensharing.runtime.OpenSharing; import io.opensharing.share.ShareAdminService; @@ -56,10 +56,11 @@ 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}, 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. + * services are served under {@code provider.base-path}, the sharing protocol under {@code + * protocol.base-path} behind recipient tokens, recipient activation under {@code + * activation.base-path} behind none, 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. */ public static OpenSharingServer start(ServerProperties properties) { String publicUrl = properties.getPublicUrl(); @@ -100,14 +101,12 @@ 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))); + OpenSharingRecipientApi.mount( + builder, + publicUrl, + properties.get(Property.PROTOCOL_BASE_PATH), + properties.get(Property.ACTIVATION_BASE_PATH), + openSharing); Server server = builder.build(); try { server.start().join(); diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java new file mode 100644 index 00000000..ab2c7752 --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java @@ -0,0 +1,147 @@ +package io.opensharing.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.linecorp.armeria.common.MediaType; +import io.opensharing.TestServer.Response; +import io.opensharing.http.ErrorCodes; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class OpenSharingProtocolServiceTest extends ProtocolApiSupport { + + private static final String SHARES = PROTOCOL + "/shares"; + + @Test + void authenticatesARecipientAndListsOnlyGrantedShares() { + // Create two shares and grant only one to the recipient. + createShare("protocol-granted", "Visible"); + createShare("protocol-hidden", "Hidden"); + String bearer = createAndActivateRecipient("protocol-partner"); + grant("protocol-granted", "protocol-partner"); + + // The protocol list exposes the granted share and its protocol metadata only. + Response list = server.get(SHARES, bearer); + assertEquals(200, list.status()); + assertJsonUtf8(list); + assertEquals(1, list.body().path("items").size()); + JsonNode listed = list.body().path("items").path(0); + assertEquals("protocol-granted", listed.path("name").asText()); + assertEquals("Visible", listed.path("displayName").asText()); + assertTrue(listed.has("id")); + assertTrue(listed.path("objects").isMissingNode()); + assertTrue(list.body().path("nextPageToken").isMissingNode()); + + // Get wraps the granted share; lookup is case-insensitive. + Response get = server.get(SHARES + "/PROTOCOL-GRANTED", bearer); + assertEquals(200, get.status()); + assertJsonUtf8(get); + JsonNode share = get.body().path("share"); + assertEquals("protocol-granted", share.path("name").asText()); + assertEquals("Visible", share.path("displayName").asText()); + assertTrue(share.has("id")); + assertTrue(share.path("objects").isMissingNode()); + + // An ungranted share is reported as missing rather than forbidden. + Response hidden = server.get(SHARES + "/protocol-hidden", bearer); + assertEquals(404, hidden.status()); + assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, hidden.errorCode()); + } + + @Test + void rejectsMissingInvalidAndSupersededTokens() { + // Protocol requests require an activated recipient bearer. + Response missing = server.get(SHARES, null); + assertEquals(401, missing.status()); + assertEquals("Bearer", missing.headers().get("WWW-Authenticate")); + assertEquals(ErrorCodes.UNAUTHENTICATED, missing.errorCode()); + Response invalid = server.get(SHARES, "invalid"); + assertEquals(401, invalid.status()); + assertEquals(ErrorCodes.UNAUTHENTICATED, invalid.errorCode()); + + // Rotation with the default zero grace period invalidates the old bearer immediately. + String bearer = createAndActivateRecipient("protocol-rotated"); + Response rotated = + server.post( + PROVIDER + "/recipients/protocol-rotated/rotate-token", + ALICE, + "{\"tokenExpirationDays\":1}"); + assertEquals(201, rotated.status()); + Response superseded = server.get(SHARES, bearer); + assertEquals(401, superseded.status()); + assertEquals(ErrorCodes.UNAUTHENTICATED, superseded.errorCode()); + } + + @Test + void paginatesGrantedSharesWithOpaqueTokens() { + // Grant two ordered shares to one recipient. + createShare("protocol-page-a", "A"); + createShare("protocol-page-b", "B"); + String bearer = createAndActivateRecipient("protocol-paged"); + grant("protocol-page-a", "protocol-paged"); + grant("protocol-page-b", "protocol-paged"); + + // Resume from the opaque token without repeating the first item. + Response first = server.get(page(SHARES, 1, null), bearer); + assertEquals(200, first.status()); + assertEquals(1, first.body().path("items").size()); + String pageToken = first.body().path("nextPageToken").asText(); + assertFalse(pageToken.isEmpty()); + Response second = server.get(page(SHARES, 1, pageToken), bearer); + assertEquals(200, second.status()); + assertEquals(1, second.body().path("items").size()); + assertTrue(second.body().path("nextPageToken").isMissingNode()); + assertNotEquals( + first.body().path("items").path(0).path("name").asText(), + second.body().path("items").path(0).path("name").asText()); + + // Malformed pagination input is rejected. + Response malformed = server.get(SHARES + "?pageToken=not-a-token", bearer); + assertEquals(400, malformed.status()); + assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, malformed.errorCode()); + } + + @Test + void listsManyGrantedSharesAcrossPages() { + int total = 25; + String bearer = createAndActivateRecipient("protocol-many"); + for (int i = 0; i < total; i++) { + String name = "protocol-many-%02d".formatted(i); + createShare(name, "Share " + i); + grant(name, "protocol-many"); + } + + Response first = server.get(page(SHARES, 10, null), bearer); + assertNames(first, 10, "protocol-many-00", "protocol-many-09"); + Response second = + server.get(page(SHARES, 10, first.body().path("nextPageToken").asText()), bearer); + assertNames(second, 10, "protocol-many-10", "protocol-many-19"); + Response last = + server.get(page(SHARES, 10, second.body().path("nextPageToken").asText()), bearer); + assertNames(last, 5, "protocol-many-20", "protocol-many-24"); + assertTrue(last.body().path("nextPageToken").isMissingNode()); + + // A page size above the maximum is capped rather than rejected. + Response all = server.get(page(SHARES, 10000, null), bearer); + assertNames(all, total, "protocol-many-00", "protocol-many-24"); + assertTrue(all.body().path("nextPageToken").isMissingNode()); + } + + private static void assertJsonUtf8(Response response) { + MediaType contentType = response.headers().contentType(); + assertTrue(contentType.is(MediaType.JSON), contentType.toString()); + assertEquals(StandardCharsets.UTF_8, contentType.charset()); + } + + private static void assertNames(Response response, int size, String first, String last) { + assertEquals(200, response.status()); + JsonNode items = response.body().path("items"); + assertEquals(size, items.size()); + assertEquals(first, items.path(0).path("name").asText()); + assertEquals(last, items.path(size - 1).path("name").asText()); + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java new file mode 100644 index 00000000..053be8fe --- /dev/null +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java @@ -0,0 +1,73 @@ +package io.opensharing.protocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.opensharing.TestServer; +import io.opensharing.TestServer.Response; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +/** + * Provider setup shared by recipient protocol API tests. Each test class gets its own server. + * Catalog lookup and provider auth go through {@code LocalCatalogConnector} and {@code + * local-catalog.yml}. + */ +public abstract class ProtocolApiSupport { + + protected static final String PROVIDER = "/api/1.0/opensharing/provider"; + protected static final String PROTOCOL = "/api/1.0/opensharing"; + protected static final String ALICE = "alice-token"; + + protected static TestServer server; + + @BeforeAll + static void startServer() { + server = TestServer.start(); + } + + @AfterAll + static void stopServer() { + server.close(); + } + + protected static void createShare(String name, String displayName) { + String body = "{\"name\":\"%s\",\"displayName\":\"%s\"}".formatted(name, displayName); + assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status()); + } + + /** Creates a TOKEN recipient, redeems its activation URL, and returns the bearer token. */ + protected static String createAndActivateRecipient(String name) { + Response created = + server.post( + PROVIDER + "/recipients", + ALICE, + "{\"name\":\"%s\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}" + .formatted(name)); + assertEquals(201, created.status()); + String activationPath = + URI.create(created.body().path("activationUrl").asText()).getPath(); + Response profile = server.get(activationPath, null); + assertEquals(200, profile.status()); + return profile.body().path("bearerToken").asText(); + } + + protected static void grant(String share, String recipient) { + String body = "{\"changes\":[{\"recipientName\":\"%s\",\"add\":[\"SELECT\"]}]}"; + Response granted = + server.patch( + PROVIDER + "/shares/" + share + "/permissions", ALICE, body.formatted(recipient)); + assertEquals(200, granted.status()); + } + + /** {@code path} with a {@code maxResults} and, unless null, a {@code pageToken} parameter. */ + protected static String page(String path, int maxResults, String pageToken) { + String query = "?maxResults=" + maxResults; + if (pageToken != null) { + query += "&pageToken=" + URLEncoder.encode(pageToken, StandardCharsets.UTF_8); + } + return path + query; + } +} From 2173201db94c8724f8ad679f6e68a8ce20027389 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 15:00:30 -0700 Subject: [PATCH 2/5] Keep the protocol's maxResults=0 for recipient lists: no items, with a token to resume from. --- .../asset/share/ProtocolShares.java | 2 +- .../java/io/opensharing/http/Pagination.java | 13 ++++++ .../http/ProtocolPaginationTest.java | 46 +++++++++++++++++++ .../OpenSharingProtocolServiceTest.java | 7 +++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java index 129b6da3..06e2b348 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java @@ -25,7 +25,7 @@ public ProtocolShares(ShareStore shares, SharePermissionStore permissions) { /** Lists one page of the recipient's shares by name. */ public ListResponse list( RecipientPrincipal recipient, Integer maxResults, String pageToken) { - return Pagination.page( + return Pagination.protocolPage( maxResults, pageToken, (offset, limit) -> permissions.listSharesFor(recipient.recipientId(), offset, limit), diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/http/Pagination.java b/server/opensharing-server-core/src/main/java/io/opensharing/http/Pagination.java index f625c97e..ca5f0a7f 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/http/Pagination.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/http/Pagination.java @@ -35,6 +35,19 @@ public static ListResponse page( return new ListResponse<>(items, rows.size() > size ? encode(offset + size) : null); } + /** + * Like {@link #page}, but a {@code maxResults} of 0 returns no items, with a next page token when + * any remain, as the sharing protocol specifies. + */ + public static ListResponse protocolPage( + Integer maxResults, String pageToken, Slice slice, Function mapper) { + if (maxResults == null || maxResults != 0) { + return page(maxResults, pageToken, slice, mapper); + } + int offset = offsetOf(pageToken); + return new ListResponse<>(List.of(), slice.fetch(offset, 1).isEmpty() ? null : encode(offset)); + } + private static int pageSize(Integer maxResults) { if (maxResults == null || maxResults == 0) { return MAX_RESULTS; diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java new file mode 100644 index 00000000..ea6ba2d1 --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java @@ -0,0 +1,46 @@ +package io.opensharing.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.function.Function; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +class ProtocolPaginationTest { + + private static final List ROWS = IntStream.range(0, 10).boxed().toList(); + + @Test + void returnsNoItemsButATokenForAZeroPageSize() { + ListResponse empty = page(ROWS, 0, null); + assertEquals(List.of(), empty.items()); + assertEquals(List.of(0, 1), page(ROWS, 2, empty.nextPageToken()).items()); + + ListResponse later = page(ROWS, 0, page(ROWS, 4, null).nextPageToken()); + assertEquals(List.of(4, 5), page(ROWS, 2, later.nextPageToken()).items()); + } + + @Test + void returnsNoTokenForAZeroPageSizeOfAnEmptyList() { + ListResponse empty = page(List.of(), 0, null); + assertEquals(List.of(), empty.items()); + assertNull(empty.nextPageToken()); + } + + @Test + void pagesOtherSizesLikeProviderLists() { + assertEquals(ROWS, page(ROWS, null, null).items()); + assertEquals(List.of(0, 1, 2), page(ROWS, 3, null).items()); + } + + private static ListResponse page(List rows, Integer maxResults, String token) { + return Pagination.protocolPage( + maxResults, + token, + (offset, limit) -> + rows.subList(Math.min(offset, rows.size()), Math.min(offset + limit, rows.size())), + Function.identity()); + } +} diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java index ab2c7752..398a25cc 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java @@ -99,6 +99,13 @@ void paginatesGrantedSharesWithOpaqueTokens() { first.body().path("items").path(0).path("name").asText(), second.body().path("items").path(0).path("name").asText()); + // A page size of 0 returns no items, with a token that resumes from the same position. + Response empty = server.get(page(SHARES, 0, null), bearer); + assertEquals(200, empty.status()); + assertEquals(0, empty.body().path("items").size()); + String resume = empty.body().path("nextPageToken").asText(); + assertEquals(2, server.get(page(SHARES, 2, resume), bearer).body().path("items").size()); + // Malformed pagination input is rejected. Response malformed = server.get(SHARES + "?pageToken=not-a-token", bearer); assertEquals(400, malformed.status()); From a2be8565701e9f61dc95785618da61fead636807 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 17:02:12 -0700 Subject: [PATCH 3/5] Describe store queries in prose rather than SQL. --- .../io/opensharing/recipient/RecipientStore.java | 5 +---- .../io/opensharing/share/SharePermissionStore.java | 14 +++----------- 2 files changed, 4 insertions(+), 15 deletions(-) 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 ed72bb53..3e67bcdf 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 @@ -163,10 +163,7 @@ public RecipientTokenEntity activate(String activationCode, String tokenHash, In }); } - /** - * {@code SELECT * FROM os_recipient_tokens WHERE token_hash = ?}, kept only when the token is - * activated and unexpired at {@code now}. - */ + /** The token with {@code tokenHash}, when it is activated and unexpired at {@code now}. */ public Optional findUsableToken(String tokenHash, Instant now) { return tx.inTransaction( true, diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java index eee778c1..71726d58 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java @@ -71,9 +71,8 @@ public List list(ShareEntity share, int offset, int limit } /** - * {@code SELECT s.* FROM os_share_permissions p JOIN os_shares s ON s.id = p.share_id WHERE - * p.recipient_id = ? AND p.privilege = 'SELECT' ORDER BY s.name LIMIT ? OFFSET ?}. Share names - * are unique, so the order is total. + * Shares the recipient holds SELECT on, ordered by name. Share names are unique, so the order + * is total. */ public List listSharesFor(String recipientId, int offset, int limit) { return tx.inTransaction( @@ -91,10 +90,7 @@ public List listSharesFor(String recipientId, int offset, int limit .getResultList()); } - /** - * {@code SELECT id FROM os_share_permissions WHERE share_id = ? AND recipient_id = ? AND - * privilege = 'SELECT' LIMIT 1} - */ + /** Whether the recipient holds SELECT on {@code share}. */ public boolean hasSelect(ShareEntity share, String recipientId) { return tx.inTransaction( true, @@ -111,10 +107,6 @@ public boolean hasSelect(ShareEntity share, String recipientId) { .isEmpty()); } - /** - * {@code SELECT * FROM os_share_permissions WHERE share_id = ? AND recipient_id = ? AND - * privilege = ?} - */ private static Optional find( EntityManager em, ShareEntity share, RecipientEntity recipient, SharePrivilege privilege) { return em.createQuery( From 0c87745f78461ae495e0d71da06e1852658a50f0 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Wed, 21 Oct 2026 14:00:09 -0700 Subject: [PATCH 4/5] Comment the recipient token decorator and protocol test helpers. --- .../java/io/opensharing/protocol/RecipientTokenDecorator.java | 1 + .../io/opensharing/protocol/OpenSharingProtocolServiceTest.java | 2 ++ .../test/java/io/opensharing/protocol/ProtocolApiSupport.java | 2 ++ 3 files changed, 5 insertions(+) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java index 0296c83f..5a4f3706 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/protocol/RecipientTokenDecorator.java @@ -27,6 +27,7 @@ public RecipientTokenDecorator(RecipientService recipients) { this.recipients = recipients; } + /** Serves the request as the recipient its bearer token belongs to. */ @Override public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpRequest req) { OAuth2Token token = AuthTokenExtractors.oAuth2().apply(req.headers()); diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java index 398a25cc..521498a5 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java @@ -138,12 +138,14 @@ void listsManyGrantedSharesAcrossPages() { assertTrue(all.body().path("nextPageToken").isMissingNode()); } + /** The protocol serves its bodies as {@code application/json} in UTF-8. */ private static void assertJsonUtf8(Response response) { MediaType contentType = response.headers().contentType(); assertTrue(contentType.is(MediaType.JSON), contentType.toString()); assertEquals(StandardCharsets.UTF_8, contentType.charset()); } + /** A page of {@code size} shares from {@code first} to {@code last}. */ private static void assertNames(Response response, int size, String first, String last) { assertEquals(200, response.status()); JsonNode items = response.body().path("items"); diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java index 053be8fe..048b8383 100644 --- a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java +++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java @@ -33,6 +33,7 @@ static void stopServer() { server.close(); } + /** Creates a share owned by Alice through the provider API. */ protected static void createShare(String name, String displayName) { String body = "{\"name\":\"%s\",\"displayName\":\"%s\"}".formatted(name, displayName); assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status()); @@ -54,6 +55,7 @@ protected static String createAndActivateRecipient(String name) { return profile.body().path("bearerToken").asText(); } + /** Grants {@code recipient} SELECT on Alice's {@code share} through the provider API. */ protected static void grant(String share, String recipient) { String body = "{\"changes\":[{\"recipientName\":\"%s\",\"add\":[\"SELECT\"]}]}"; Response granted = From de51c534da3b50c5fff9f58d3dfc4e4aebc7c9f8 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Wed, 21 Oct 2026 14:49:29 -0700 Subject: [PATCH 5/5] Sort the protocol share imports. --- .../main/java/io/opensharing/asset/share/ProtocolShares.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java index 06e2b348..27847e5d 100644 --- a/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java +++ b/server/opensharing-server-core/src/main/java/io/opensharing/asset/share/ProtocolShares.java @@ -4,8 +4,8 @@ import io.opensharing.http.ListResponse; import io.opensharing.http.Pagination; import io.opensharing.recipient.RecipientPrincipal; -import io.opensharing.share.ShareResponse; import io.opensharing.share.SharePermissionStore; +import io.opensharing.share.ShareResponse; import io.opensharing.share.ShareStore; /**