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
Expand Up @@ -56,19 +56,23 @@ DidClient client = new DidClient(resourceKey, licenceKey);
// The 51Did arrives from the page in the URL-safe base64 alphabet.
FodId fodId = FodId.fromBase64(did);

// Offline, against the cloud's published key for the identifier's date.
// No use is charged, because the client holds the keys.
boolean genuine = client.verifySignature(fodId);

// Server side, with the licence key. One use.
RedeemResult redeemed = client.redeem(fodId, result, challenge);
RedeemResult.Context context = redeemed.getContext();
// Both client calls answer through a CompletableFuture, so the calling
// thread is released at once. Offline first, against the cloud's
// published key for the identifier's date, where no use is charged
// because the client holds the keys. Then server side, with the licence
// key, which is one use.
CompletableFuture<Answer> answer = client.verifySignature(fodId)
.thenCompose(genuine -> client.redeem(fodId, result, challenge)
.thenApply(redeemed -> toAnswer(redeemed, genuine)))
.exceptionally(CreatorContextDemoServer::failed);
```

The handler answers the page in the cloud's own shape, `signature`,
`context`, `factors` when present, `verifiedAt` and
`secondsSinceVerified`, built from the typed result, with one field
added, `serverSignature`, being the offline check. A malformed 51Did
added, `serverSignature`, being the offline check. A failure the client
reports arrives at the demo's `failed` method as the cause of a
`CompletionException` and is mapped there. A malformed 51Did
answers 400, a host without the creator context answers 404 with a
text body, and an unreachable cloud answers 502 with `{ "error": ... }`.
A production server would also remember the challenge it issued and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;

/**
* 51Did creator context demo server. Serves a page that runs the 51Did
Expand Down Expand Up @@ -191,18 +193,33 @@ static void servePage(HttpExchange exchange) throws IOException {
exchange.close();
}

static void redeem(HttpExchange exchange) throws IOException {
static void redeem(HttpExchange exchange) {
Map<String, String> query = parse(
exchange.getRequestURI().getRawQuery());
Answer answer = redeem(
// The route hands the exchange to the client's future and returns,
// so the web server's own thread is free while the cloud is asked.
redeem(
client,
decode(valueOr(query, "51did")),
decode(valueOr(query, "result")),
decode(valueOr(query, "challenge")));
exchange.getResponseHeaders().set("Content-Type", answer.type);
exchange.sendResponseHeaders(answer.status, answer.body.length);
exchange.getResponseBody().write(answer.body);
exchange.close();
decode(valueOr(query, "challenge")))
.thenAccept(answer -> write(exchange, answer));
}

/** Writes one answer to the page and closes the exchange. */
static void write(HttpExchange exchange, Answer answer) {
try {
exchange.getResponseHeaders().set("Content-Type", answer.type);
exchange.sendResponseHeaders(answer.status, answer.body.length);
exchange.getResponseBody().write(answer.body);
} catch (IOException gone) {
// The browser went away between asking and being answered,
// which is nothing this demo can do anything about.
System.err.println("The page could not be answered: "
+ gone.getMessage());
} finally {
exchange.close();
}
}

/**
Expand All @@ -214,44 +231,74 @@ static void redeem(HttpExchange exchange) throws IOException {
* with the licence key, which is added by the client here and only
* here, so the browser never sees it.
* <p>
* Both client calls answer through a {@link CompletableFuture}, so the
* calling thread is released at once and the answer is built when the
* cloud has replied. A failure the client reports arrives at
* {@link #failed(Throwable)} as the cause of a
* {@link CompletionException} and is mapped to a status there.
* <p>
* The page is answered in the cloud's own shape ({@code signature},
* {@code context}, {@code factors} when present, {@code verifiedAt},
* {@code secondsSinceVerified}) with one field added,
* {@code serverSignature}, being this server's own offline check. The
* page ignores fields it does not know.
*/
static Answer redeem(
static CompletableFuture<Answer> redeem(
DidClient client, String did, String result, String challenge) {
FodId fodId;
try {
fodId = FodId.fromBase64(did);
} catch (OwidException notA51Did) {
return Answer.json(400, errors(
"'" + did + "' is not a valid Base64-encoded 51Did."));
return CompletableFuture.completedFuture(Answer.json(400, errors(
"'" + did + "' is not a valid Base64-encoded 51Did.")));
} catch (IllegalArgumentException notA51Did) {
return Answer.json(400, errors(
"'" + did + "' is not a valid Base64-encoded 51Did."));
return CompletableFuture.completedFuture(Answer.json(400, errors(
"'" + did + "' is not a valid Base64-encoded 51Did.")));
}
try {
String serverSignature = client.verifySignature(fodId)
? "verified" : "invalid";
RedeemResult redeemed = client.redeem(fodId, result, challenge);
return Answer.json(
redeemed.getStatusCode(), toJson(redeemed, serverSignature));
} catch (DidNotSupportedException unsupported) {
return client.verifySignature(fodId)
.thenCompose(genuine -> client.redeem(fodId, result, challenge)
.thenApply(redeemed -> toAnswer(redeemed, genuine)))
.exceptionally(CreatorContextDemoServer::failed);
}

/** The cloud's answer and this server's own check, as one answer. */
static Answer toAnswer(RedeemResult redeemed, boolean genuine) {
return Answer.json(redeemed.getStatusCode(),
toJson(redeemed, genuine ? "verified" : "invalid"));
}

/**
* What the page is told when the client reports a failure. The failure
* arrives wrapped, so the cause is the part that carries the meaning.
*/
static Answer failed(Throwable reported) {
Throwable failure = reported instanceof CompletionException
&& reported.getCause() != null
? reported.getCause()
: reported;
if (failure instanceof DidNotSupportedException) {
// The host does not offer the creator context. The same status
// and a text body, which the page reports as not supported by
// this host.
return Answer.text(404, unsupported.getBody());
} catch (IllegalArgumentException malformed) {
return Answer.json(400, errors(malformed.getMessage()));
} catch (DidHttpException other) {
return Answer.text(404,
((DidNotSupportedException) failure).getBody());
}
if (failure instanceof IllegalArgumentException) {
return Answer.json(400, errors(failure.getMessage()));
}
if (failure instanceof DidHttpException) {
// Relayed as received, so the page sees what the cloud said.
DidHttpException other = (DidHttpException) failure;
return Answer.text(other.getStatusCode(), other.getBody());
} catch (IOException unreachable) {
}
if (failure instanceof IOException) {
return Answer.json(502, new JSONObject()
.put("error", String.valueOf(unreachable.getMessage())));
.put("error", String.valueOf(failure.getMessage())));
}
// Nothing the client is documented to report, so the page is told
// as much rather than being left waiting for an answer.
return Answer.json(500, new JSONObject()
.put("error", String.valueOf(failure)));
}

/** The cloud's own shape, plus {@code serverSignature}. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.CompletableFuture;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
Expand Down Expand Up @@ -89,7 +90,8 @@ public void Redeem_Route_Answers_In_The_Clouds_Shape_With_ServerSignature() {
+ "\"secondsSinceVerified\":2}");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

assertEquals(200, answer.status);
assertEquals("application/json", answer.type);
Expand Down Expand Up @@ -127,7 +129,8 @@ public void Redeem_Route_Relays_Factors_On_A_Mismatch() {
+ "\"secondsSinceVerified\":3}");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

JSONObject json = new JSONObject(answer.bodyText());
assertEquals("mismatch", json.getString("context"));
Expand All @@ -148,7 +151,8 @@ public void Redeem_Route_Reports_Its_Own_Signature_Check() throws Exception {
+ "\"secondsSinceVerified\":2}");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

JSONObject json = new JSONObject(answer.bodyText());
assertEquals("verified", json.getString("signature"));
Expand All @@ -161,7 +165,8 @@ public void Redeem_Route_Relays_503_Unconfirmed() {
transport.queue(503, "{\"context\":\"unconfirmed\"}");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

assertEquals(503, answer.status);
JSONObject json = new JSONObject(answer.bodyText());
Expand All @@ -176,7 +181,8 @@ public void Redeem_Route_Answers_404_As_Text_For_A_Host_Without_The_Feature() {
transport.queue(404, "Not Found");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

assertEquals(404, answer.status);
assertTrue(answer.type.startsWith("text/plain"));
Expand All @@ -187,7 +193,8 @@ public void Redeem_Route_Answers_404_As_Text_For_A_Host_Without_The_Feature() {
public void Redeem_Route_Answers_502_When_The_Cloud_Is_Unreachable() {
// Nothing queued, so the first request fails as the network would.
CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

assertEquals(502, answer.status);
JSONObject json = new JSONObject(answer.bodyText());
Expand All @@ -197,7 +204,8 @@ public void Redeem_Route_Answers_502_When_The_Cloud_Is_Unreachable() {
@Test
public void Redeem_Route_Answers_400_For_A_Malformed_51Did() {
CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, "not a 51did", "s", "c");
CreatorContextDemoServer.redeem(client, "not a 51did", "s", "c")
.join();

assertEquals(400, answer.status);
JSONObject json = new JSONObject(answer.bodyText());
Expand All @@ -213,7 +221,8 @@ public void Redeem_Route_Answers_400_When_The_Cloud_Refuses_The_51Did() {
+ "Base64-encoded 51Did.\"]}");

CreatorContextDemoServer.Answer answer =
CreatorContextDemoServer.redeem(client, did, "sealed", "abc");
CreatorContextDemoServer.redeem(client, did, "sealed", "abc")
.join();

assertEquals(400, answer.status);
JSONObject json = new JSONObject(answer.bodyText());
Expand Down Expand Up @@ -266,12 +275,17 @@ void queue(int status, String body) {
}

@Override
public Response send(Request request) throws IOException {
public CompletableFuture<Response> send(Request request) {
requests.add(request);
CompletableFuture<Response> answer =
new CompletableFuture<Response>();
if (responses.isEmpty()) {
throw new IOException("Nothing queued for " + request.getUrl());
answer.completeExceptionally(
new IOException("Nothing queued for " + request.getUrl()));
} else {
answer.complete(responses.removeFirst());
}
return responses.removeFirst();
return answer;
}
}
}
Loading
Loading