Server-side Usage
Sign credentials on a trusted Java server, serve them to your applications, and connect from a backend.
Before you start: add com.useceleris:celeris-server on a trusted server. Keep your Celeris client ID and signing secret in that server's configuration. Never put them in an application that you ship to users.
1. Understand the two responsibilities
celeris-server signs access. It does not provide a separate publish transport. A backend that publishes or receives uses celeris-client, just as any other application does, but it signs its own credentials through signer.credentialProvider.
Applications that you ship to users fetch credentials from an authenticated endpoint on your server instead. See Authentication.
2. Serve credentials to your applications
Create one signer from server configuration. It is safe for concurrent use, and its toString never shows the secret:
Signer signer =
Signer.create(
SignerOptions.builder()
.clientId(System.getenv("CELERIS_CLIENT_ID"))
.signingSecret(System.getenv("CELERIS_SIGNING_SECRET"))
.build());
The endpoint authenticates the user, decides the claims on the server, and signs fresh credentials for every request. This example runs on the JDK's HttpServer and reads JSON with Jackson 3. authenticate and User stand for your own authentication:
private static final ObjectMapper MAPPER = JsonMapper.builder().build();
static void handleCredentials(HttpExchange exchange, Signer signer) throws IOException {
if (!"POST".equals(exchange.getRequestMethod())) {
respond(exchange, 405, "method not allowed");
return;
}
Optional<User> user = authenticate(exchange); // your session or token check
if (user.isEmpty()) {
respond(exchange, 401, "unauthenticated");
return;
}
String channelReference = null;
try (InputStream input = exchange.getRequestBody()) {
byte[] body = input.readNBytes(4097);
if (body.length <= 4096) {
channelReference = MAPPER.readTree(body).path("channelReference").stringValue(null);
}
} catch (JacksonException invalid) {
// Not JSON: refused below.
}
if (channelReference == null || !user.get().mayAccess(channelReference)) {
respond(exchange, 403, "forbidden");
return;
}
Credentials credentials;
try {
credentials =
signer.sign(
Claims.builder()
.channels(ChannelScope.restricted(channelReference))
.permissions(
SegmentPermissions.restricted(
new SegmentClaim("chat", true, user.get().moderator())))
.reference(user.get().id()) // the identity peers see; no colons
.replay(Replay.lookback(Duration.ofSeconds(30)))
.build());
} catch (CelerisException failure) {
respond(exchange, 500, "signing failed");
return;
}
byte[] response = MAPPER.writeValueAsBytes(credentials); // {"payload": "...", "signature": "..."}
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, response.length);
exchange.getResponseBody().write(response);
}
static void respond(HttpExchange exchange, int status, String message) throws IOException {
byte[] response = message.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, response.length);
exchange.getResponseBody().write(response);
}
Mount it, or the same logic in any framework, and serve it with a bounded executor:
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext(
"/api/realtime-credentials",
exchange -> {
try (exchange) {
handleCredentials(exchange, signer);
}
});
server.setExecutor(Executors.newFixedThreadPool(8));
server.start();
Also bound the request time. The JDK's server reads the sun.net.httpserver.maxReqTime system property, in seconds. Never sign permissions only because the caller asked for them. Never cache or backdate credentials. Claims.Builder.build() validates the claims and throws CONFIGURATION, which names every claim and rule that failed. sign throws the same error when its clock reads a time outside the range that the server accepts.
Scope every credential to what the user needs. Restricted channels and segments are explicit. ChannelScope.all() or SegmentPermissions.all(...) is an opt-in, never a default. Claims without channels or permissions do not build. Replay and echo default to off.
3. Connect from a backend
import com.useceleris.client.CelerisClient;
import com.useceleris.client.Channel;
import com.useceleris.client.ClientOptions;
import com.useceleris.client.CredentialRequest;
import com.useceleris.client.Payloads;
import com.useceleris.client.Segment;
import com.useceleris.server.ChannelScope;
import com.useceleris.server.Claims;
import com.useceleris.server.Replay;
import com.useceleris.server.SegmentClaim;
import com.useceleris.server.SegmentPermissions;
import com.useceleris.server.Signer;
import com.useceleris.server.SignerOptions;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
public final class Worker {
private static final Duration MAXIMUM_REPLAY_LOOKBACK = Duration.ofSeconds(30);
private Worker() {}
static CompletionStage<Claims> claimsFor(CredentialRequest request) {
if (!request.channelReference().equals("demo-room")) {
return CompletableFuture.failedFuture(
new IllegalArgumentException("worker channel is not allowed"));
}
Claims.Builder claims =
Claims.builder()
.channels(ChannelScope.restricted("demo-room"))
.permissions(SegmentPermissions.restricted(new SegmentClaim("chat", true, true)))
.reference("demo-worker");
// Present only on reconnect: catch up on the outage, at most 30 seconds.
request
.replayLookback()
.ifPresent(
lookback ->
claims.replay(
Replay.lookback(
lookback.compareTo(MAXIMUM_REPLAY_LOOKBACK) > 0
? MAXIMUM_REPLAY_LOOKBACK
: lookback)));
return CompletableFuture.completedFuture(claims.build());
}
public static void main(String[] arguments) throws InterruptedException {
Signer signer =
Signer.create(
SignerOptions.builder()
.clientId(System.getenv("CELERIS_CLIENT_ID"))
.signingSecret(System.getenv("CELERIS_SIGNING_SECRET"))
.build());
CelerisClient client =
CelerisClient.create(
ClientOptions.builder(signer.credentialProvider(Worker::claimsFor)).build());
Channel channel = client.channel("demo-room");
// Interrupting the process, as Ctrl+C does, closes the channel.
Runtime.getRuntime().addShutdownHook(new Thread(channel::close));
Segment chat = channel.segment("chat");
channel.events().onError(error -> System.out.println("Error: " + error.getMessage()));
chat.onMessage(
(payload, metadata) -> System.out.println("Received: " + Payloads.readText(payload)));
chat.subscribe();
channel.connect().join();
chat.publish(Payloads.text("Hello from the backend")).join();
// The SDK's threads are daemon threads, so the main thread keeps the worker running.
Thread.currentThread().join();
}
}
The claims function is the authority. Nothing automatically turns the request into permission. This worker explicitly restricts its channel and segment. It replays only on reconnect, capped at 30 seconds. The provider calls the function afresh on every connection attempt, on the thread that asks for credentials. The provider then signs with a fresh timestamp.
The function returns a CompletionStage, so it can look up claims asynchronously. A cancellation of an attempt also cancels its claims stage. The SDK never signs claims that arrive after the cancellation. If the function throws, fails its stage, or cannot build its claims, connect() fails with the generic TRANSPORT error. To see the CONFIGURATION error that names the claim and rule, build the claims directly.
A publish has the same local-acceptance semantics on a server as anywhere else. When the publish future completes, the socket accepted the bytes. Completion does not confirm delivery.
4. Apply this to your service
Sign from server-owned configuration. For multi-tenant services, authorize each channel. Do not replace the restriction with unrestricted claims. The signer holds no resources, and you do not need to close it. The client channel owns the socket. Use Reconnection and Recovery to decide how your service reconciles missed or duplicate messages.
Next: Java server API and Java client API.