Authenticating with Tokens
Suppose a small service needs to recognize a configured client, but the client and server communicate over a TCP
connection that others can observe.
Sending a password or a reusable bearer token would let anyone who captures it sign in later.
An AuthenticationTokenPair gives the client a private
signing credential and the server a matching public verifier.
The client proves possession of its credential by signing a fresh challenge, without sending the credential itself.
This page follows a complete client and server that read separate ELCL files and exchange JSON Lines over TCP. You can run the programs together, capture their wire traffic, and inspect every protocol message. The example also makes the trust boundary visible: a successful challenge response identifies the holder of the client key for this attempt, but it does not protect the rest of the connection.
Authentication Guarantees, Replay Protection, and Channel Limitations
The server chooses a fresh random nonce and remembers it in a
PendingAuthenticationChallenge.
The client signs the entire challenge with its private Ed25519 key.
The server checks the signature with the corresponding public key, checks that the signed challenge is exactly the one
it issued, and consumes the pending state on the first verification attempt.
A captured response therefore cannot authenticate a later attempt, even when it is sent by the same client.
The normal challenge lifetime is 60 seconds; the demo uses 30 seconds.
An observer of the TCP stream can read the identifier, challenge, response, and result. Those values do not reveal the private credential. An active intermediary can still relay a live challenge to the real client and relay its response to the server. The proof does not authenticate the server to the client, bind the response to a particular TCP connection, encrypt traffic, or authenticate messages sent after the proof. Use a server-authenticated, end-to-end secure channel when those properties matter. If the application continues after authentication, define how that channel or an additional message protocol binds later requests to the verified connection.
Credentials and Configuration
Generate a pair once for each client identity.
The identifier is a public lookup value of one to 64 lowercase ASCII letters, digits, or underscores.
Choose a meaningful stable name such as site_001; do not treat the visible name as proof that independently obtained
client and server token texts belong together.
When both are present during provisioning,
ServerAuthenticationToken::matches() checks the
key relationship as well as the identifier.
After building the demo, generate a pair with:
cmake-build-debug/demo-apps/cryptology/token_authentication generate site_001
The command prints one Server token and one Client token.
Run it in a private terminal and copy each complete value into its own ELCL file.
The -s- token contains the public verifier and can be installed on the server.
The -c- token contains a private seed: restrict access to the client file, do not log its value, and never put it in
a protocol message.
The two values share the same visible identifier so administrators can match their configuration files.
For a local run, create server.elcl and client.elcl as follows, replacing the token placeholders with the values
printed by the generator:
# server.elcl
[server]
Address: "127.0.0.1"
Port: "45891"
Token: "<Server token>"
# client.elcl
[client]
Host: "127.0.0.1"
Port: "45891"
Token: "<Client token>"
The application parses the configuration with Parser and imports each text value
using the role-specific fromStringOrThrow() method.
It marks the client token text as sensitive before importing the private key.
The server file needs only the public verifier; a server compromise alone cannot produce a valid client response.
cryptology/TokenAuthentication/main.cpp
void registerCommandLineOptions(const el::OptionsPtr &options) override {
options->setHelpDescription("Generate a token pair or authenticate over JSONL/TCP."_el);
options->addOption("mode"_el).setRequired().setHelpTitle("generate, server, or client"_el);
options->addOption("value"_el).setRequired().setHelpTitle("identifier or ELCL configuration path"_el);
}
void handleCommandLineOptions(const el::OptionValuesPtr &values) override {
const auto mode = values->getText("mode"_el);
const auto value = values->getText("value"_el);
if (mode == "generate"_el) {
auto pair = el::cryptology::AuthenticationTokenPair::generate(value);
el::io::printLine("Server token: "_el, pair.server().toString());
el::io::printLine("Client token: "_el, pair.client().toString());
events()->invoke([this]() -> void { quit(); });
return;
}
const auto document = el::conf::Parser{}.parseFileOrThrow(el::Path::fromNativeOrThrow(value));
if (mode == "server"_el) {
const auto address = el::IpAddress::fromStringOrThrow(document->getTextOrThrow("server.address"_el));
const auto port = el::Port::fromStringOrThrow(document->getTextOrThrow("server.port"_el));
const auto token = el::cryptology::ServerAuthenticationToken::fromStringOrThrow(
document->getTextOrThrow("server.token"_el));
_server = std::make_unique<Server>(events(), address, port, token);
events()->invoke([this]() -> void { _server->start(); });
} else if (mode == "client"_el) {
const auto host = el::Host::fromStringOrThrow(document->getTextOrThrow("client.host"_el));
const auto port = el::Port::fromStringOrThrow(document->getTextOrThrow("client.port"_el));
auto tokenText = document->getTextOrThrow("client.token"_el);
tokenText.markAsSensitive();
auto token = el::cryptology::ClientAuthenticationToken::fromStringOrThrow(tokenText);
_client = std::make_unique<Client>(events(), host, port, std::move(token));
events()->invoke([this]() -> void { _client->start(); });
} else {
throw el::ApplicationError{"Mode must be generate, server, or client."_el};
}
}
Challenge-Response Protocol over JSON Lines
The client sends a hello record containing only its public identifier.
The server uses that identifier to select a verifier, creates a fresh challenge, and sends the challenge bytes as
canonical, unpadded Base64url.
The client checks that the binary challenge addresses its own identifier before signing it.
It returns a serialized signed response in the same text encoding.
The server verifies the response once and replies with an acceptance result.
client -> server {"type":"hello","id":"site_001"}
server -> client {"type":"challenge","data":"<Base64url challenge>"}
client -> server {"type":"response","data":"<Base64url signed response>"}
server -> client {"type":"result","accepted":true}
Each JSON object is followed by one LF byte. TCP can split or combine writes, so the demo collects bytes until it sees a complete line; it never assumes one socket callback equals one message. It limits an incoming line to 4096 bytes, parses one JSON object, and rejects malformed Base64url. JSON makes a packet capture easy to read while Base64url preserves the exact binary challenge and signed response. The JSON wrapper is only transport framing; it is not part of the signed data.
cryptology/TokenAuthentication/JsonLine.hpp
void receive(const el::ByteBlock &data, Fn &&onLine) {
for (const auto byte : data.span()) {
if (byte == el::Byte{'\n'}) {
if (_buffer.isEmpty()) {
throw el::ApplicationError{"Empty authentication record."_el};
}
const auto source = el::StringDecoder{_buffer}.decode(
el::StringEncoding::Utf8, el::StringBomMode::Reject, el::EncodingMode::Strict);
_buffer.clear();
auto limits = el::json::JsonParseOptions{};
limits.setMaximumInputLength(el::ByteCount{4096U});
const auto value = el::json::JsonValue::fromStringOrThrow(source, limits);
if (!value.is(el::json::JsonType::Object)) {
throw el::ApplicationError{"Authentication record must be a JSON object."_el};
}
onLine(value);
} else {
if (_buffer.length() >= el::ByteCount{4096U} || byte == el::Byte{'\r'}) {
throw el::ApplicationError{"Authentication record is too long or contains CR."_el};
}
_buffer.append(byte);
}
}
}
Serving One Authentication Attempt
The server stores a public ServerAuthenticationToken.
In this compact demo it has one configured client, so a hello ID must match that token.
A service with several clients would look up the verifier by ID in its own registry and still keep the selected verifier
and pending challenge with the individual connection.
Each accepted TCP connection gets a separate ServerSession.
After hello, the session calls createChallenge() and keeps the returned pending state until it receives one
response.
It decodes the response and calls verify() once; a failure, malformed frame, or disconnect ends that attempt.
An expired response is rejected, while a production listener should also close connections that stay idle.
The result message is the last application message, so this example grants no access to a later request that would
need separate protection.
cryptology/TokenAuthentication/Server.hpp
void onRecord(const el::json::JsonValue &record) {
if (_state == State::Hello) {
if (record.getOrThrow("type"_el).getTextOrThrow() != "hello"_el ||
record.getOrThrow("id"_el).getTextOrThrow() != _token.id()) {
throw el::ApplicationError{"Unknown authentication identifier."_el};
}
// Keep the one-use pending state with this TCP session.
_pending = _token.createChallenge(el::time::Seconds{30});
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "challenge"_el);
answer.set("data"_el, JsonLine::base64(_pending->challengeBytes()));
send(answer);
_state = State::Proof;
return;
}
if (_state != State::Proof || record.getOrThrow("type"_el).getTextOrThrow() != "response"_el) {
throw el::ApplicationError{"Unexpected authentication record."_el};
}
const auto response = JsonLine::unbase64(record.getOrThrow("data"_el).getTextOrThrow(), el::ByteCount{497U});
const auto accepted = _pending->verify(response.span());
_pending.reset();
_state = State::Finished;
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "result"_el);
answer.set("accepted"_el, accepted);
send(answer);
// The demo grants no application access beyond this result.
_connection->close();
}
Answering the Challenge
The client loads a ClientAuthenticationToken from its
protected ELCL file.
After sending its public ID, it decodes the received challenge and passes the exact bytes to createResponse().
That call checks the challenge structure and identifier before signing, then returns a
SignedByteBlock.
The client sends only its serialized signed response.
The client treats a missing or negative result as failure.
In an application that needs to trust a server’s answer, the application must protect that answer with an authenticated
channel; an unauthenticated TCP peer can forge accepted or close the stream.
cryptology/TokenAuthentication/Client.hpp
void sendHello() {
auto hello = el::json::JsonValue{el::json::JsonObject{}};
hello.set("type"_el, "hello"_el);
hello.set("id"_el, _token.id());
send(hello);
}
void onRecord(const el::json::JsonValue &record) {
if (_state == State::Challenge) {
if (record.getOrThrow("type"_el).getTextOrThrow() != "challenge"_el) {
throw el::ApplicationError{"Expected an authentication challenge."_el};
}
const auto challenge =
JsonLine::unbase64(record.getOrThrow("data"_el).getTextOrThrow(), el::ByteCount{102U});
// createResponse checks that the challenge addresses this token's identifier.
const auto response = _token.createResponse(challenge.span()).toByteBlock();
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "response"_el);
answer.set("data"_el, JsonLine::base64(response));
send(answer);
_state = State::Result;
return;
}
if (_state != State::Result || record.getOrThrow("type"_el).getTextOrThrow() != "result"_el) {
throw el::ApplicationError{"Unexpected authentication record."_el};
}
_accepted = record.getOrThrow("accepted"_el).getBoolOrThrow();
_receivedResult = true;
_state = State::Finished;
el::io::printLine(_accepted ? "Authentication accepted."_el : "Authentication rejected."_el);
_connection->close();
}
Run the server in one terminal and the client in another:
cmake-build-debug/demo-apps/cryptology/token_authentication server server.elcl
cmake-build-debug/demo-apps/cryptology/token_authentication client client.elcl
The client prints Authentication accepted. and exits successfully when the configured tokens match.
Because the transport is plain TCP, a capture of this local exchange shows all four JSONL records, including the public
ID and binary values encoded as text, but no elctk-...-c-... credential.
On macOS, for example, sudo tcpdump -A -i lo0 'tcp port 45891' captures the local exchange while the client runs.
For a real deployment, restrict who can reach the listener and use a secure channel where server identity, message
integrity, or confidentiality is required.
Token and Response Format
The JSONL exchange has two layers. JSON supplies message boundaries and carries binary values as Base64url text; the authentication rules operate on the decoded bytes. If you implement a client or server in another language, reproduce the byte formats below before wrapping them in JSON. The signed byte block topic explains the shared response format in full.
Credential Text
The two credentials have parallel text shapes:
client credential: elctk-<id>-c-<data>
server credential: elctk-<id>-s-<data>
<id> public identifier
c / s role: private client seed / public server key
<data> Base64url of 34 binary bytes
The public <id> contains one to 64 lowercase ASCII letters, digits, or underscores: [_a-z0-9]{1,64}.
It is a lookup name, so matching text alone does not prove that two tokens contain a related key pair.
The -c- and -s- role markers are literal parts of the token text; they are not among the encoded bytes.
Decode <data> as canonical Base64url with no = padding or whitespace.
It represents exactly 34 bytes:
byte 0 0x01 token format version
byte 1 0x01 Ed25519 algorithm
bytes 2..33 32-byte value private seed for c; public key for s
Generate the private seed with a cryptographically secure random source and derive its public key using standard
Ed25519. Install the c token only on the client and the s token on the server.
A parser rejects a wrong role, version, algorithm, length, invalid key, or noncanonical encoding rather than trying to
repair it.
Challenge Bytes
After a hello names a client, the server creates a new challenge for that attempt.
The challenge is a compact binary message, which the JSONL challenge.data field carries as unpadded Base64url:
ASCII "ELCA" | 0x01 | ID length | ASCII ID | 32 random nonce bytes
4 bytes 1 byte 1 byte 1..64 32 bytes
The ID must be the same public identifier used for this credential. Thus the binary challenge is 39 to 102 bytes long, depending on ID length. The client validates the magic, version, exact length, identifier syntax, and matching ID before it signs anything.
The server retains the exact challenge bytes and a local monotonic deadline with this authentication attempt. The nonce makes another attempt different even if the same ID is used. The deadline is server state; it is not a timestamp supplied by the client and is not embedded in the challenge. When the response arrives, the server compares its verified payload byte for byte with the challenge it retained and consumes the attempt, including on failure.
Response Bytes
The client signs the complete challenge as a
SignedByteBlock.
Its response.data JSON field is unpadded Base64url of that binary record.
At a high level, the decoded record has this layout:
ELSB header | client ID | "erbsland.core.auth-token.challenge.v1" |
| exact ELCA challenge bytes | 64-byte Ed25519 signature
The ELSB header contains version 0x01, algorithm 0x01 for Ed25519, byte lengths for the ID and purpose, and a
four-byte big-endian payload length.
The signed key-ID field holds the client’s public ID; the purpose is exactly erbsland.core.auth-token.challenge.v1
in UTF-8; and the payload is the complete challenge, including its ELCA header and nonce.
The signature covers the entire ELSB record before the signature, not merely the nonce or payload.
See Signing and Verifying Byte Blocks for field offsets, canonical parsing rules, and the signature input.
The server uses the ID only to locate a candidate public key.
It accepts the proof only when the Ed25519 signature verifies with that key, the expected purpose matches, the signed ID
matches the selected client, and the verified payload equals its still-pending challenge.
Only then can the server treat the response as proof of possession for this attempt.
The JSON wrapper, including its type and data field names, is not signed; it is transport framing around the
binary proof.
Complete demo sources
cryptology/TokenAuthentication/main.cpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#include "Client.hpp"
#include "Server.hpp"
#include <erbsland/Application.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/AuthenticationTokenPair.hpp>
#include <erbsland/ClientAuthenticationToken.hpp>
#include <erbsland/conf/Parser.hpp>
#include <erbsland/Host.hpp>
#include <erbsland/IpAddress.hpp>
#include <erbsland/Options.hpp>
#include <erbsland/OptionValues.hpp>
#include <erbsland/Path.hpp>
#include <erbsland/Port.hpp>
#include <erbsland/ServerAuthenticationToken.hpp>
#include <erbsland/StandardStreams.hpp>
#include <memory>
namespace demo {
/// Generate a token pair or run either side of the ELCL-configured TCP example.
/// @notest{Compiled and exercised by the token authentication demo.}
class TokenAuthenticationApp final : public el::Application {
public:
using Application::Application;
protected: // implement Application
void initialize() override { info().setApplicationName("Token Authentication Demo"_el); }
void registerCommandLineOptions(const el::OptionsPtr &options) override {
options->setHelpDescription("Generate a token pair or authenticate over JSONL/TCP."_el);
options->addOption("mode"_el).setRequired().setHelpTitle("generate, server, or client"_el);
options->addOption("value"_el).setRequired().setHelpTitle("identifier or ELCL configuration path"_el);
}
void handleCommandLineOptions(const el::OptionValuesPtr &values) override {
const auto mode = values->getText("mode"_el);
const auto value = values->getText("value"_el);
if (mode == "generate"_el) {
auto pair = el::cryptology::AuthenticationTokenPair::generate(value);
el::io::printLine("Server token: "_el, pair.server().toString());
el::io::printLine("Client token: "_el, pair.client().toString());
events()->invoke([this]() -> void { quit(); });
return;
}
const auto document = el::conf::Parser{}.parseFileOrThrow(el::Path::fromNativeOrThrow(value));
if (mode == "server"_el) {
const auto address = el::IpAddress::fromStringOrThrow(document->getTextOrThrow("server.address"_el));
const auto port = el::Port::fromStringOrThrow(document->getTextOrThrow("server.port"_el));
const auto token = el::cryptology::ServerAuthenticationToken::fromStringOrThrow(
document->getTextOrThrow("server.token"_el));
_server = std::make_unique<Server>(events(), address, port, token);
events()->invoke([this]() -> void { _server->start(); });
} else if (mode == "client"_el) {
const auto host = el::Host::fromStringOrThrow(document->getTextOrThrow("client.host"_el));
const auto port = el::Port::fromStringOrThrow(document->getTextOrThrow("client.port"_el));
auto tokenText = document->getTextOrThrow("client.token"_el);
tokenText.markAsSensitive();
auto token = el::cryptology::ClientAuthenticationToken::fromStringOrThrow(tokenText);
_client = std::make_unique<Client>(events(), host, port, std::move(token));
events()->invoke([this]() -> void { _client->start(); });
} else {
throw el::ApplicationError{"Mode must be generate, server, or client."_el};
}
}
private:
std::unique_ptr<Server> _server;
std::unique_ptr<Client> _client;
};
}
auto main(const int argc, char *argv[]) -> int {
auto app = demo::TokenAuthenticationApp{argc, argv};
return app.run();
}
cryptology/TokenAuthentication/JsonLine.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <erbsland/ApplicationError.hpp>
#include <erbsland/base_n/BaseNDecoder.hpp>
#include <erbsland/base_n/BaseNEncoder.hpp>
#include <erbsland/base_n/BaseNFormat.hpp>
#include <erbsland/base_n/BaseNFormatFlag.hpp>
#include <erbsland/Byte.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ByteBlockEditor.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/CharSet.hpp>
#include <erbsland/EncodingMode.hpp>
#include <erbsland/json/JsonParseOptions.hpp>
#include <erbsland/json/JsonType.hpp>
#include <erbsland/json/JsonValue.hpp>
#include <erbsland/Literals.hpp>
#include <erbsland/String.hpp>
#include <erbsland/StringBomMode.hpp>
#include <erbsland/StringDecoder.hpp>
#include <erbsland/StringEncoder.hpp>
#include <erbsland/StringEncoding.hpp>
namespace demo {
using namespace el::text::literals;
/// Encode one JSON object per LF-terminated TCP record and decode bounded incoming records.
/// @notest{The token authentication demo exercises this framing in both processes.}
class JsonLine final {
public:
[[nodiscard]] static auto encode(const el::json::JsonValue &value) -> el::ByteBlock {
auto bytes = el::ByteBlockEditor{
el::StringEncoder{value.toString()}.encode(el::StringEncoding::Utf8, el::StringBomMode::Reject)};
bytes.append(el::Byte{'\n'});
return bytes;
}
[[nodiscard]] static auto base64(const el::ByteBlock &bytes) -> el::String {
return el::text::base_n::BaseNEncoder{bytes, base64Format()}.toString();
}
[[nodiscard]] static auto unbase64(const el::String &text, const el::ByteCount maximum) -> el::ByteBlock {
const auto result = el::text::base_n::BaseNDecoder{text, base64Format()}.toDataOrThrow(maximum);
if (base64(result) != text) {
throw el::ApplicationError{"Noncanonical Base64url in authentication record."_el};
}
return result;
}
template <typename Fn>
void receive(const el::ByteBlock &data, Fn &&onLine) {
for (const auto byte : data.span()) {
if (byte == el::Byte{'\n'}) {
if (_buffer.isEmpty()) {
throw el::ApplicationError{"Empty authentication record."_el};
}
const auto source = el::StringDecoder{_buffer}.decode(
el::StringEncoding::Utf8, el::StringBomMode::Reject, el::EncodingMode::Strict);
_buffer.clear();
auto limits = el::json::JsonParseOptions{};
limits.setMaximumInputLength(el::ByteCount{4096U});
const auto value = el::json::JsonValue::fromStringOrThrow(source, limits);
if (!value.is(el::json::JsonType::Object)) {
throw el::ApplicationError{"Authentication record must be a JSON object."_el};
}
onLine(value);
} else {
if (_buffer.length() >= el::ByteCount{4096U} || byte == el::Byte{'\r'}) {
throw el::ApplicationError{"Authentication record is too long or contains CR."_el};
}
_buffer.append(byte);
}
}
}
private:
[[nodiscard]] static auto base64Format() -> el::text::base_n::BaseNFormat {
auto format = el::text::base_n::BaseNFormat::base64Url();
format.clearFlags(el::text::base_n::BaseNFormatFlag::All);
format.setPadding(std::nullopt);
format.setWhitespace(el::text::CharSet{});
return format;
}
private:
el::ByteBlockEditor _buffer;
};
}
cryptology/TokenAuthentication/Server.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "JsonLine.hpp"
#include <erbsland/all_network_source.hpp>
#include <erbsland/all_network_tcp.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/Events.hpp>
#include <erbsland/IpAddress.hpp>
#include <erbsland/IpEndpoint.hpp>
#include <erbsland/json/JsonValue.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkError.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/PendingAuthenticationChallenge.hpp>
#include <erbsland/Port.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/ServerAuthenticationToken.hpp>
#include <erbsland/SocketBufferLimits.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/TcpAcceptOptions.hpp>
#include <erbsland/TcpConnection.hpp>
#include <erbsland/TcpConnectionRequest.hpp>
#include <erbsland/TcpListener.hpp>
#include <algorithm>
#include <functional>
#include <memory>
#include <optional>
#include <vector>
namespace demo {
/// One incoming TCP connection owns its challenge and accepts exactly one response.
/// A failed or malformed attempt ends this connection; another attempt needs a fresh challenge.
/// @notest{Compiled and exercised by the token authentication demo.}
class ServerSession final {
public:
ServerSession(
el::EventsPtr events,
el::TcpConnectionRequestPtr request,
el::cryptology::ServerAuthenticationToken token,
std::function<void(ServerSession *)> done) :
_events{std::move(events)}, _request{std::move(request)}, _token{std::move(token)}, _done{std::move(done)} {}
void start() {
_connection = _events->get<el::Network>().createTcpConnection();
_connection->events()
.onData([this](el::ByteBlock data) -> void { onData(std::move(data)); })
.onError([this](const el::NetworkErrorContext &) -> void { _connection->abort(); })
.onFinal([this]() -> void { _done(this); });
auto options = el::TcpAcceptOptions{};
options.setBufferLimits(el::SocketBufferLimits{el::ByteCount{8192U}, el::ByteCount{8192U}});
_connection->accept(std::move(_request), options);
}
private:
void onData(el::ByteBlock data) {
try {
_lines.receive(data, [this](const el::json::JsonValue &record) -> void { onRecord(record); });
} catch (const el::RuntimeError &) {
_connection->abort();
}
}
void onRecord(const el::json::JsonValue &record) {
if (_state == State::Hello) {
if (record.getOrThrow("type"_el).getTextOrThrow() != "hello"_el ||
record.getOrThrow("id"_el).getTextOrThrow() != _token.id()) {
throw el::ApplicationError{"Unknown authentication identifier."_el};
}
// Keep the one-use pending state with this TCP session.
_pending = _token.createChallenge(el::time::Seconds{30});
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "challenge"_el);
answer.set("data"_el, JsonLine::base64(_pending->challengeBytes()));
send(answer);
_state = State::Proof;
return;
}
if (_state != State::Proof || record.getOrThrow("type"_el).getTextOrThrow() != "response"_el) {
throw el::ApplicationError{"Unexpected authentication record."_el};
}
const auto response = JsonLine::unbase64(record.getOrThrow("data"_el).getTextOrThrow(), el::ByteCount{497U});
const auto accepted = _pending->verify(response.span());
_pending.reset();
_state = State::Finished;
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "result"_el);
answer.set("accepted"_el, accepted);
send(answer);
// The demo grants no application access beyond this result.
_connection->close();
}
void send(const el::json::JsonValue &record) {
if (!_connection->send(JsonLine::encode(record)).isAccepted()) {
throw el::ApplicationError{"Authentication record could not be queued."_el};
}
}
enum class State { Hello, Proof, Finished };
private:
el::EventsPtr _events;
el::TcpConnectionRequestPtr _request;
el::cryptology::ServerAuthenticationToken _token;
std::function<void(ServerSession *)> _done;
el::TcpConnectionPtr _connection;
JsonLine _lines;
std::optional<el::cryptology::PendingAuthenticationChallenge> _pending;
State _state{State::Hello};
};
/// Listen on the configured endpoint and give every connection independent verification state.
/// @notest{Compiled and exercised by the token authentication demo.}
class Server final {
public:
Server(
el::EventsPtr events, el::IpAddress address, el::Port port, el::cryptology::ServerAuthenticationToken token) :
_events{std::move(events)}, _address{std::move(address)}, _port{port}, _token{std::move(token)} {}
void start() {
_listener = _events->get<el::Network>().createTcpListener();
_listener->events()
.onListening([this]() -> void {
el::io::printLine("Listening on "_el, _listener->localEndpoint().value().toString());
})
.onConnection([this](el::TcpConnectionRequestPtr request) -> void {
auto session = std::make_shared<ServerSession>(
_events, std::move(request), _token, [this](ServerSession *finished) -> void {
removeSession(finished);
});
_sessions.push_back(session);
session->start();
})
.onError([](const el::NetworkErrorContext &error) -> void { throw el::NetworkError{error}; });
_listener->start(el::IpEndpoint{_address, _port});
}
private:
void removeSession(ServerSession *finished) {
_events->invoke([this, finished]() -> void {
std::erase_if(_sessions, [finished](const auto &session) -> bool { return session.get() == finished; });
});
}
private:
el::EventsPtr _events;
el::IpAddress _address;
el::Port _port;
el::cryptology::ServerAuthenticationToken _token;
el::TcpListenerPtr _listener;
std::vector<std::shared_ptr<ServerSession>> _sessions;
};
}
cryptology/TokenAuthentication/Client.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "JsonLine.hpp"
#include <erbsland/all_network_source.hpp>
#include <erbsland/all_network_tcp.hpp>
#include <erbsland/Application.hpp>
#include <erbsland/ApplicationError.hpp>
#include <erbsland/ByteBlock.hpp>
#include <erbsland/ByteCount.hpp>
#include <erbsland/ClientAuthenticationToken.hpp>
#include <erbsland/ConnectionCloseContext.hpp>
#include <erbsland/Events.hpp>
#include <erbsland/ExitCode.hpp>
#include <erbsland/Host.hpp>
#include <erbsland/HostEndpoint.hpp>
#include <erbsland/json/JsonValue.hpp>
#include <erbsland/Network.hpp>
#include <erbsland/NetworkErrorContext.hpp>
#include <erbsland/Port.hpp>
#include <erbsland/RuntimeError.hpp>
#include <erbsland/SignedByteBlock.hpp>
#include <erbsland/StandardStreams.hpp>
#include <erbsland/String.hpp>
#include <erbsland/TcpConnection.hpp>
namespace demo {
/// Prove possession of a client credential using a challenge from the configured TCP server.
/// The secret token text and private key never enter a JSON record.
/// @notest{Compiled and exercised by the token authentication demo.}
class Client final {
public:
Client(el::EventsPtr events, el::Host host, el::Port port, el::cryptology::ClientAuthenticationToken token) :
_events{std::move(events)}, _host{std::move(host)}, _port{port}, _token{std::move(token)} {}
void start() {
_connection = _events->get<el::Network>().createTcpConnection();
_connection->events()
.onConnected([this]() -> void { sendHello(); })
.onData([this](el::ByteBlock data) -> void { onData(std::move(data)); })
.onClosed([this](const el::ConnectionCloseContext &) -> void {
if (!_receivedResult) {
el::io::printLine("Server closed without an authentication result."_el);
el::application().quit(el::ExitCode::failure());
}
})
.onError([](const el::NetworkErrorContext &) -> void { el::application().quit(el::ExitCode::failure()); })
.onFinal([this]() -> void {
el::application().quit(_accepted ? el::ExitCode::success() : el::ExitCode::failure());
});
_connection->connect(el::HostEndpoint{_host, _port});
}
private:
void onData(el::ByteBlock data) {
try {
_lines.receive(data, [this](const el::json::JsonValue &record) -> void { onRecord(record); });
} catch (const el::RuntimeError &) {
_connection->abort();
}
}
void onRecord(const el::json::JsonValue &record) {
if (_state == State::Challenge) {
if (record.getOrThrow("type"_el).getTextOrThrow() != "challenge"_el) {
throw el::ApplicationError{"Expected an authentication challenge."_el};
}
const auto challenge =
JsonLine::unbase64(record.getOrThrow("data"_el).getTextOrThrow(), el::ByteCount{102U});
// createResponse checks that the challenge addresses this token's identifier.
const auto response = _token.createResponse(challenge.span()).toByteBlock();
auto answer = el::json::JsonValue{el::json::JsonObject{}};
answer.set("type"_el, "response"_el);
answer.set("data"_el, JsonLine::base64(response));
send(answer);
_state = State::Result;
return;
}
if (_state != State::Result || record.getOrThrow("type"_el).getTextOrThrow() != "result"_el) {
throw el::ApplicationError{"Unexpected authentication record."_el};
}
_accepted = record.getOrThrow("accepted"_el).getBoolOrThrow();
_receivedResult = true;
_state = State::Finished;
el::io::printLine(_accepted ? "Authentication accepted."_el : "Authentication rejected."_el);
_connection->close();
}
void sendHello() {
auto hello = el::json::JsonValue{el::json::JsonObject{}};
hello.set("type"_el, "hello"_el);
hello.set("id"_el, _token.id());
send(hello);
}
void send(const el::json::JsonValue &record) {
if (!_connection->send(JsonLine::encode(record)).isAccepted()) {
throw el::ApplicationError{"Authentication record could not be queued."_el};
}
}
enum class State { Challenge, Result, Finished };
private:
el::EventsPtr _events;
el::Host _host;
el::Port _port;
el::cryptology::ClientAuthenticationToken _token;
el::TcpConnectionPtr _connection;
JsonLine _lines;
State _state{State::Challenge};
bool _receivedResult{false};
bool _accepted{false};
};
}