Skip to content

Commit 480bc43

Browse files
committed
feat(internal): SecurityFilter renders 401/403 with WWW-Authenticate
Replace the UnsupportedOperationException placeholder with a full rejection path: DENIED failures produce 403 Forbidden with no challenge header; MISSING/MALFORMED failures produce 401 Unauthorized with one WWW-Authenticate header per distinct scheme. Added a generic ProblemDetailRenderer.render(status, title, detail) overload used by the rejection renderer. Three new SecurityFilterTest cases cover bearer-missing→401, bearer-denied→403, and apiKey-missing→401.
1 parent aa9732b commit 480bc43

3 files changed

Lines changed: 218 additions & 11 deletions

File tree

src/main/java/com/retailsvc/http/internal/ProblemDetailRenderer.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@ public final class ProblemDetailRenderer {
2222

2323
private ProblemDetailRenderer() {}
2424

25+
public static String render(int status, String title, String detail) {
26+
StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY);
27+
out.append('{');
28+
appendStringField(out, "type", PROBLEM_TYPE);
29+
out.append(',');
30+
appendStringField(out, "title", title);
31+
out.append(',');
32+
appendIntField(out, "status", status);
33+
out.append(',');
34+
appendStringField(out, "detail", detail);
35+
out.append('}');
36+
return out.toString();
37+
}
38+
2539
public static String render(ValidationError error) {
2640
StringBuilder out = new StringBuilder(INITIAL_BUFFER_CAPACITY);
2741
out.append('{');

src/main/java/com/retailsvc/http/internal/SecurityFilter.java

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@
88
import com.sun.net.httpserver.Filter;
99
import com.sun.net.httpserver.HttpExchange;
1010
import java.io.IOException;
11+
import java.nio.charset.StandardCharsets;
12+
import java.util.ArrayList;
13+
import java.util.Comparator;
1114
import java.util.LinkedHashMap;
15+
import java.util.LinkedHashSet;
1216
import java.util.List;
17+
import java.util.Locale;
1318
import java.util.Map;
1419
import java.util.Optional;
1520

@@ -55,11 +60,12 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
5560
return;
5661
}
5762

63+
List<GroupOutcome.Failed> failures = new ArrayList<>();
5864
for (SecurityRequirement group : effective) {
59-
Map<String, Object> principals = trySatisfy(group, exchange, request);
60-
if (principals != null) {
65+
GroupOutcome outcome = tryGroup(group, exchange, request);
66+
if (outcome instanceof GroupOutcome.Allowed allowed) {
6167
try {
62-
ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(principals))
68+
ScopedValue.where(DispatchHandler.CURRENT, request.withPrincipals(allowed.principals()))
6369
.call(
6470
() -> {
6571
chain.doFilter(exchange);
@@ -72,29 +78,96 @@ public void doFilter(HttpExchange exchange, Chain chain) throws IOException {
7278
}
7379
return;
7480
}
81+
failures.add((GroupOutcome.Failed) outcome);
7582
}
7683

77-
// Rejection rendering is Task 9.
78-
throw new UnsupportedOperationException("rejection path not implemented yet");
84+
renderRejection(exchange, failures);
7985
}
8086

81-
private Map<String, Object> trySatisfy(
82-
SecurityRequirement group, HttpExchange exchange, Request request) {
87+
private GroupOutcome tryGroup(SecurityRequirement group, HttpExchange exchange, Request request) {
8388
Map<String, Object> principals = new LinkedHashMap<>();
8489
for (var entry : group.schemes().entrySet()) {
8590
String schemeName = entry.getKey();
8691
SecurityScheme scheme = schemes.get(schemeName);
8792
ExtractionResult result = CredentialExtractor.extract(scheme, exchange);
88-
if (result.kind() != ExtractionResult.Kind.FOUND) {
89-
return null;
93+
if (result.kind() == ExtractionResult.Kind.MISSING) {
94+
return new GroupOutcome.Failed(FailureKind.MISSING, schemeName);
95+
}
96+
if (result.kind() == ExtractionResult.Kind.MALFORMED) {
97+
return new GroupOutcome.Failed(FailureKind.MALFORMED, schemeName);
9098
}
9199
Optional<Object> principal =
92100
validators.get(schemeName).validate(request, result.credential());
93101
if (principal.isEmpty()) {
94-
return null;
102+
return new GroupOutcome.Failed(FailureKind.DENIED, schemeName);
95103
}
96104
principals.put(schemeName, principal.get());
97105
}
98-
return Map.copyOf(principals);
106+
return new GroupOutcome.Allowed(Map.copyOf(principals));
107+
}
108+
109+
private void renderRejection(HttpExchange exchange, List<GroupOutcome.Failed> failures)
110+
throws IOException {
111+
boolean anyDenied = failures.stream().anyMatch(f -> f.kind() == FailureKind.DENIED);
112+
int status = anyDenied ? 403 : 401;
113+
String title = anyDenied ? "Forbidden" : "Unauthorized";
114+
115+
GroupOutcome.Failed pick =
116+
failures.stream().max(Comparator.comparing(GroupOutcome.Failed::kind)).orElseThrow();
117+
String detail = describe(pick);
118+
119+
byte[] body =
120+
ProblemDetailRenderer.render(status, title, detail).getBytes(StandardCharsets.UTF_8);
121+
exchange.getResponseHeaders().add("Content-Type", "application/problem+json");
122+
if (!anyDenied) {
123+
LinkedHashSet<String> attempted = new LinkedHashSet<>();
124+
for (GroupOutcome.Failed f : failures) {
125+
attempted.add(f.schemeName());
126+
}
127+
for (String name : attempted) {
128+
exchange.getResponseHeaders().add("WWW-Authenticate", challengeFor(name));
129+
}
130+
}
131+
exchange.sendResponseHeaders(status, body.length);
132+
exchange.getResponseBody().write(body);
133+
exchange.close();
134+
}
135+
136+
private String describe(GroupOutcome.Failed f) {
137+
return switch (f.kind()) {
138+
case MISSING -> "credential missing for scheme '" + f.schemeName() + "'";
139+
case MALFORMED -> "credential malformed for scheme '" + f.schemeName() + "'";
140+
case DENIED -> "validator denied for scheme '" + f.schemeName() + "'";
141+
};
142+
}
143+
144+
private String challengeFor(String schemeName) {
145+
SecurityScheme scheme = schemes.get(schemeName);
146+
return switch (scheme) {
147+
case SecurityScheme.HttpBearer _ -> "Bearer realm=\"api\"";
148+
case SecurityScheme.HttpBasic _ -> "Basic realm=\"api\"";
149+
case SecurityScheme.ApiKey ak ->
150+
"ApiKey location="
151+
+ ak.location().name().toLowerCase(Locale.ROOT)
152+
+ ", name=\""
153+
+ ak.name()
154+
+ "\"";
155+
case SecurityScheme.Unsupported _ ->
156+
throw new IllegalStateException(
157+
"Unsupported scheme reached challenge rendering for '" + schemeName + "'");
158+
};
159+
}
160+
161+
private sealed interface GroupOutcome permits GroupOutcome.Allowed, GroupOutcome.Failed {
162+
163+
record Allowed(Map<String, Object> principals) implements GroupOutcome {}
164+
165+
record Failed(FailureKind kind, String schemeName) implements GroupOutcome {}
166+
}
167+
168+
private enum FailureKind {
169+
MISSING,
170+
MALFORMED,
171+
DENIED
99172
}
100173
}

src/test/java/com/retailsvc/http/internal/SecurityFilterTest.java

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.ArgumentMatchers.anyLong;
6+
import static org.mockito.ArgumentMatchers.eq;
57
import static org.mockito.Mockito.doAnswer;
68
import static org.mockito.Mockito.mock;
79
import static org.mockito.Mockito.verify;
@@ -13,10 +15,13 @@
1315
import com.retailsvc.http.spec.Operation;
1416
import com.retailsvc.http.spec.security.SecurityRequirement;
1517
import com.retailsvc.http.spec.security.SecurityScheme;
18+
import com.retailsvc.http.spec.security.SecurityScheme.ApiKey;
19+
import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location;
1620
import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer;
1721
import com.sun.net.httpserver.Filter.Chain;
1822
import com.sun.net.httpserver.Headers;
1923
import com.sun.net.httpserver.HttpExchange;
24+
import java.io.ByteArrayOutputStream;
2025
import java.net.URI;
2126
import java.util.List;
2227
import java.util.Map;
@@ -93,6 +98,121 @@ void passesThroughWhenOperationHasNoSecurity() throws Exception {
9398
assertThat(ScopedValueHarness.lastSeenPrincipals()).isEmpty();
9499
}
95100

101+
@Test
102+
void missingCredentialReturns401WithBearerChallenge() throws Exception {
103+
Operation op =
104+
new Operation(
105+
"getX",
106+
HttpMethod.GET,
107+
null,
108+
Optional.empty(),
109+
List.of(),
110+
Map.of(),
111+
Map.of(),
112+
Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of())))));
113+
114+
SecurityFilter filter =
115+
new SecurityFilter(
116+
Map.of("getX", op),
117+
Map.of("bearerAuth", new HttpBearer(Optional.empty())),
118+
List.of(),
119+
Map.of("bearerAuth", (req, cred) -> Optional.of("never-called")),
120+
false);
121+
122+
HttpExchange ex = mock(HttpExchange.class);
123+
Headers headers = new Headers();
124+
when(ex.getRequestHeaders()).thenReturn(headers);
125+
Headers responseHeaders = new Headers();
126+
when(ex.getResponseHeaders()).thenReturn(responseHeaders);
127+
ByteArrayOutputStream body = new ByteArrayOutputStream();
128+
when(ex.getResponseBody()).thenReturn(body);
129+
when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX"));
130+
131+
Chain chain = mock(Chain.class);
132+
ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain));
133+
134+
verify(ex).sendResponseHeaders(eq(401), anyLong());
135+
assertThat(responseHeaders.getFirst("WWW-Authenticate")).isEqualTo("Bearer realm=\"api\"");
136+
assertThat(responseHeaders.getFirst("Content-Type")).isEqualTo("application/problem+json");
137+
assertThat(body.toString())
138+
.contains("\"status\":401")
139+
.contains("credential missing")
140+
.contains("bearerAuth");
141+
}
142+
143+
@Test
144+
void deniedValidatorReturns403WithoutChallenge() throws Exception {
145+
Operation op =
146+
new Operation(
147+
"getX",
148+
HttpMethod.GET,
149+
null,
150+
Optional.empty(),
151+
List.of(),
152+
Map.of(),
153+
Map.of(),
154+
Optional.of(List.of(new SecurityRequirement(Map.of("bearerAuth", List.of())))));
155+
156+
SecurityFilter filter =
157+
new SecurityFilter(
158+
Map.of("getX", op),
159+
Map.of("bearerAuth", new HttpBearer(Optional.empty())),
160+
List.of(),
161+
Map.of("bearerAuth", (req, cred) -> Optional.empty()),
162+
false);
163+
164+
HttpExchange ex = mock(HttpExchange.class);
165+
Headers headers = new Headers();
166+
headers.add("Authorization", "Bearer t");
167+
when(ex.getRequestHeaders()).thenReturn(headers);
168+
Headers responseHeaders = new Headers();
169+
when(ex.getResponseHeaders()).thenReturn(responseHeaders);
170+
when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream());
171+
when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX"));
172+
173+
Chain chain = mock(Chain.class);
174+
ScopedValueHarness.runWith(newMinimalRequest("getX"), () -> filter.doFilter(ex, chain));
175+
176+
verify(ex).sendResponseHeaders(eq(403), anyLong());
177+
assertThat(responseHeaders.getFirst("WWW-Authenticate")).isNull();
178+
}
179+
180+
@Test
181+
void apiKeyMissingReturnsApiKeyChallengeHeader() throws Exception {
182+
Operation op =
183+
new Operation(
184+
"getX",
185+
HttpMethod.GET,
186+
null,
187+
Optional.empty(),
188+
List.of(),
189+
Map.of(),
190+
Map.of(),
191+
Optional.of(List.of(new SecurityRequirement(Map.of("apiKeyAuth", List.of())))));
192+
193+
SecurityFilter filter =
194+
new SecurityFilter(
195+
Map.of("getX", op),
196+
Map.of("apiKeyAuth", new ApiKey("X-API-Key", Location.HEADER)),
197+
List.of(),
198+
Map.of("apiKeyAuth", (req, cred) -> Optional.of("ok")),
199+
false);
200+
201+
HttpExchange ex = mock(HttpExchange.class);
202+
when(ex.getRequestHeaders()).thenReturn(new Headers());
203+
Headers responseHeaders = new Headers();
204+
when(ex.getResponseHeaders()).thenReturn(responseHeaders);
205+
when(ex.getResponseBody()).thenReturn(new ByteArrayOutputStream());
206+
when(ex.getRequestURI()).thenReturn(URI.create("http://h/getX"));
207+
208+
ScopedValueHarness.runWith(
209+
newMinimalRequest("getX"), () -> filter.doFilter(ex, mock(Chain.class)));
210+
211+
verify(ex).sendResponseHeaders(eq(401), anyLong());
212+
assertThat(responseHeaders.getFirst("WWW-Authenticate"))
213+
.isEqualTo("ApiKey location=header, name=\"X-API-Key\"");
214+
}
215+
96216
private static Request newMinimalRequest(String operationId) {
97217
return new Request(new byte[0], null, null, operationId, Map.of(), null, h -> null);
98218
}

0 commit comments

Comments
 (0)