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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>{@code
* {
* "tokenId": "<token id>",
* "recipientName": "<recipient name>",
* "activationUrl": "<activation URL>",
* "expiresAt": "<ISO-8601 instant>"
* }
* }</pre>
*/
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());
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>{@code
* {
* "shareCredentialsVersion": 1,
* "bearerToken": "<bearer token>",
* "endpoint": "<sharing protocol URL>",
* "expirationTime": "<ISO-8601 instant>"
* }
* }</pre>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ProfileFile(
int shareCredentialsVersion, String bearerToken, String endpoint, String expirationTime) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <pre>{@code
* {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>{@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;
Expand All @@ -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);
}

Expand All @@ -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);
}
}
Loading
Loading