Skip to content

Commit 3cf59a7

Browse files
swibi-ttdclaude
andcommitted
Return a specific reason in Core 401 responses
A 401 from /attest (and other auth-gated endpoints) previously returned a bare "Unauthorized", giving operators no way to distinguish a mistyped/unknown key from a disabled key or a role problem. GenericFailureHandler now returns a JSON body with a reason (unrecognized_key / key_disabled / insufficient_role) and an actionable message, inferred from the resolved auth profile. The body surfaces in the operator's attestation log. Adds GenericFailureHandlerTest covering all three reasons and the non-401 passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 05d0bff commit 3cf59a7

2 files changed

Lines changed: 126 additions & 2 deletions

File tree

src/main/java/com/uid2/core/handler/GenericFailureHandler.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import io.vertx.core.Handler;
77
import io.vertx.core.http.HttpClosedException;
88
import io.vertx.core.http.HttpServerResponse;
9+
import io.vertx.core.json.JsonObject;
910
import io.vertx.ext.web.RoutingContext;
1011
import org.apache.http.impl.EnglishReasonPhraseCatalog;
1112
import org.slf4j.Logger;
1213
import org.slf4j.LoggerFactory;
1314

15+
import java.net.HttpURLConnection;
16+
1417
public class GenericFailureHandler implements Handler<RoutingContext> {
1518
private static final Logger LOGGER = LoggerFactory.getLogger(GenericFailureHandler.class);
1619

@@ -38,8 +41,36 @@ public void handle(RoutingContext ctx) {
3841
}
3942

4043
if (!response.ended() && !response.closed()) {
41-
response.setStatusCode(statusCode)
42-
.end(EnglishReasonPhraseCatalog.INSTANCE.getReason(statusCode, null));
44+
if (statusCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
45+
// Return the specific reason so the caller (e.g. a private operator at startup)
46+
// gets an actionable message.
47+
response.putHeader("Content-Type", "application/json")
48+
.setStatusCode(statusCode)
49+
.end(buildUnauthorizedBody(profile).encode());
50+
} else {
51+
response.setStatusCode(statusCode)
52+
.end(EnglishReasonPhraseCatalog.INSTANCE.getReason(statusCode, null));
53+
}
54+
}
55+
}
56+
57+
private static JsonObject buildUnauthorizedBody(IAuthorizable profile) {
58+
final String reason;
59+
final String message;
60+
if (profile == null) {
61+
// Key did not resolve to any record - the most common operator-onboarding mistake.
62+
reason = "unrecognized_key";
63+
message = "Operator key not recognized.";
64+
} else if (profile.isDisabled()) {
65+
reason = "key_disabled";
66+
message = "Operator key is recognized but has been disabled.";
67+
} else {
68+
reason = "insufficient_role";
69+
message = "Operator key is recognized but is not authorized for this operation.";
4370
}
71+
return new JsonObject()
72+
.put("status", "unauthorized")
73+
.put("reason", reason)
74+
.put("message", message);
4475
}
4576
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.uid2.core.handler;
2+
3+
import com.uid2.shared.auth.IAuthorizable;
4+
import com.uid2.shared.middleware.AuthMiddleware;
5+
import io.vertx.core.http.HttpServerResponse;
6+
import io.vertx.core.json.JsonObject;
7+
import io.vertx.ext.web.RoutingContext;
8+
import org.junit.jupiter.api.Test;
9+
import org.mockito.ArgumentCaptor;
10+
11+
import java.net.HttpURLConnection;
12+
import java.util.HashMap;
13+
import java.util.Map;
14+
15+
import static org.junit.jupiter.api.Assertions.assertEquals;
16+
import static org.junit.jupiter.api.Assertions.assertTrue;
17+
import static org.mockito.ArgumentMatchers.anyInt;
18+
import static org.mockito.ArgumentMatchers.anyString;
19+
import static org.mockito.Mockito.mock;
20+
import static org.mockito.Mockito.verify;
21+
import static org.mockito.Mockito.when;
22+
23+
class GenericFailureHandlerTest {
24+
25+
// Drives the handler for a given status code + resolved auth profile, returning the response body written.
26+
private static String handleAndCaptureBody(int statusCode, IAuthorizable profile) {
27+
RoutingContext ctx = mock(RoutingContext.class);
28+
HttpServerResponse response = mock(HttpServerResponse.class);
29+
30+
Map<String, Object> data = new HashMap<>();
31+
if (profile != null) {
32+
data.put(AuthMiddleware.API_CLIENT_PROP, profile);
33+
}
34+
35+
when(ctx.statusCode()).thenReturn(statusCode);
36+
when(ctx.response()).thenReturn(response);
37+
when(ctx.normalizedPath()).thenReturn("/attest");
38+
when(ctx.failure()).thenReturn(null);
39+
when(ctx.data()).thenReturn(data);
40+
41+
when(response.ended()).thenReturn(false);
42+
when(response.closed()).thenReturn(false);
43+
when(response.putHeader(anyString(), anyString())).thenReturn(response);
44+
when(response.setStatusCode(anyInt())).thenReturn(response);
45+
46+
new GenericFailureHandler().handle(ctx);
47+
48+
ArgumentCaptor<String> bodyCaptor = ArgumentCaptor.forClass(String.class);
49+
verify(response).end(bodyCaptor.capture());
50+
return bodyCaptor.getValue();
51+
}
52+
53+
@Test
54+
void unknownKeyReturnsUnrecognizedKeyReason() {
55+
// No auth profile resolved -> key was not recognized (the 4eyes.ai transcription-error case).
56+
JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, null));
57+
58+
assertEquals("unauthorized", body.getString("status"));
59+
assertEquals("unrecognized_key", body.getString("reason"));
60+
assertTrue(body.getString("message").toLowerCase().contains("not recognized"));
61+
}
62+
63+
@Test
64+
void disabledKeyReturnsKeyDisabledReason() {
65+
IAuthorizable disabledKey = mock(IAuthorizable.class);
66+
when(disabledKey.isDisabled()).thenReturn(true);
67+
68+
JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, disabledKey));
69+
70+
assertEquals("unauthorized", body.getString("status"));
71+
assertEquals("key_disabled", body.getString("reason"));
72+
assertTrue(body.getString("message").toLowerCase().contains("disabled"));
73+
}
74+
75+
@Test
76+
void recognizedButUnauthorizedKeyReturnsInsufficientRoleReason() {
77+
IAuthorizable wrongRoleKey = mock(IAuthorizable.class);
78+
when(wrongRoleKey.isDisabled()).thenReturn(false);
79+
80+
JsonObject body = new JsonObject(handleAndCaptureBody(HttpURLConnection.HTTP_UNAUTHORIZED, wrongRoleKey));
81+
82+
assertEquals("unauthorized", body.getString("status"));
83+
assertEquals("insufficient_role", body.getString("reason"));
84+
}
85+
86+
@Test
87+
void nonUnauthorizedStatusKeepsPlainReasonPhrase() {
88+
// Non-401 failures must keep the existing bare reason-phrase body (no behaviour change).
89+
String body = handleAndCaptureBody(HttpURLConnection.HTTP_BAD_REQUEST, null);
90+
91+
assertEquals("Bad Request", body);
92+
}
93+
}

0 commit comments

Comments
 (0)