Skip to content

Commit 83b9937

Browse files
committed
feat(internal): Add CredentialExtractor for apiKey/bearer/basic
1 parent 7a99f14 commit 83b9937

3 files changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Credential;
4+
import com.retailsvc.http.spec.security.SecurityScheme;
5+
import com.retailsvc.http.spec.security.SecurityScheme.ApiKey;
6+
import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic;
7+
import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer;
8+
import com.sun.net.httpserver.HttpExchange;
9+
import java.net.URLDecoder;
10+
import java.nio.charset.StandardCharsets;
11+
import java.util.Base64;
12+
13+
final class CredentialExtractor {
14+
private CredentialExtractor() {}
15+
16+
static ExtractionResult extract(SecurityScheme scheme, HttpExchange exchange) {
17+
return switch (scheme) {
18+
case ApiKey ak -> extractApiKey(ak, exchange);
19+
case HttpBearer _ -> extractBearer(exchange);
20+
case HttpBasic _ -> extractBasic(exchange);
21+
case SecurityScheme.Unsupported _ ->
22+
throw new IllegalStateException(
23+
"extractor called with Unsupported scheme — should be caught at boot");
24+
};
25+
}
26+
27+
private static ExtractionResult extractApiKey(ApiKey scheme, HttpExchange exchange) {
28+
String value =
29+
switch (scheme.location()) {
30+
case HEADER -> exchange.getRequestHeaders().getFirst(scheme.name());
31+
case QUERY -> firstQueryValue(exchange.getRequestURI().getRawQuery(), scheme.name());
32+
case COOKIE -> firstCookieValue(exchange, scheme.name());
33+
};
34+
return value == null
35+
? ExtractionResult.missing()
36+
: ExtractionResult.found(new Credential.ApiKeyCredential(value));
37+
}
38+
39+
private static ExtractionResult extractBearer(HttpExchange exchange) {
40+
String auth = exchange.getRequestHeaders().getFirst("Authorization");
41+
if (auth == null) {
42+
return ExtractionResult.missing();
43+
}
44+
String[] parts = auth.split("\\s+", 2);
45+
if (parts.length != 2 || !parts[0].equalsIgnoreCase("Bearer")) {
46+
return ExtractionResult.missing();
47+
}
48+
return ExtractionResult.found(new Credential.BearerCredential(parts[1]));
49+
}
50+
51+
private static ExtractionResult extractBasic(HttpExchange exchange) {
52+
String auth = exchange.getRequestHeaders().getFirst("Authorization");
53+
if (auth == null) {
54+
return ExtractionResult.missing();
55+
}
56+
String[] parts = auth.split("\\s+", 2);
57+
if (parts.length != 2 || !parts[0].equalsIgnoreCase("Basic")) {
58+
return ExtractionResult.missing();
59+
}
60+
byte[] decoded;
61+
try {
62+
decoded = Base64.getDecoder().decode(parts[1]);
63+
} catch (IllegalArgumentException e) {
64+
return ExtractionResult.malformed();
65+
}
66+
String creds = new String(decoded, StandardCharsets.UTF_8);
67+
int sep = creds.indexOf(':');
68+
if (sep < 0) {
69+
return ExtractionResult.malformed();
70+
}
71+
return ExtractionResult.found(
72+
new Credential.BasicCredential(creds.substring(0, sep), creds.substring(sep + 1)));
73+
}
74+
75+
private static String firstQueryValue(String rawQuery, String name) {
76+
if (rawQuery == null) {
77+
return null;
78+
}
79+
String prefix = name + "=";
80+
for (String pair : rawQuery.split("&")) {
81+
if (pair.startsWith(prefix)) {
82+
return URLDecoder.decode(pair.substring(prefix.length()), StandardCharsets.UTF_8);
83+
}
84+
}
85+
return null;
86+
}
87+
88+
private static String firstCookieValue(HttpExchange exchange, String name) {
89+
String cookieHeader = exchange.getRequestHeaders().getFirst("Cookie");
90+
if (cookieHeader == null) {
91+
return null;
92+
}
93+
for (String pair : cookieHeader.split(";")) {
94+
String trimmed = pair.trim();
95+
if (trimmed.startsWith(name + "=")) {
96+
return trimmed.substring(name.length() + 1);
97+
}
98+
}
99+
return null;
100+
}
101+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.Credential;
4+
5+
record ExtractionResult(Kind kind, Credential credential) {
6+
enum Kind {
7+
FOUND,
8+
MISSING,
9+
MALFORMED
10+
}
11+
12+
static ExtractionResult found(Credential credential) {
13+
return new ExtractionResult(Kind.FOUND, credential);
14+
}
15+
16+
static ExtractionResult missing() {
17+
return new ExtractionResult(Kind.MISSING, null);
18+
}
19+
20+
static ExtractionResult malformed() {
21+
return new ExtractionResult(Kind.MALFORMED, null);
22+
}
23+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.Mockito.mock;
5+
import static org.mockito.Mockito.when;
6+
7+
import com.retailsvc.http.Credential;
8+
import com.retailsvc.http.spec.security.SecurityScheme.ApiKey;
9+
import com.retailsvc.http.spec.security.SecurityScheme.ApiKey.Location;
10+
import com.retailsvc.http.spec.security.SecurityScheme.HttpBasic;
11+
import com.retailsvc.http.spec.security.SecurityScheme.HttpBearer;
12+
import com.sun.net.httpserver.Headers;
13+
import com.sun.net.httpserver.HttpExchange;
14+
import java.net.URI;
15+
import java.util.Base64;
16+
import java.util.Optional;
17+
import org.junit.jupiter.api.Test;
18+
19+
class CredentialExtractorTest {
20+
21+
private HttpExchange exchangeWithHeader(String key, String value, String query) {
22+
HttpExchange ex = mock(HttpExchange.class);
23+
Headers h = new Headers();
24+
if (value != null) {
25+
h.add(key, value);
26+
}
27+
when(ex.getRequestHeaders()).thenReturn(h);
28+
when(ex.getRequestURI())
29+
.thenReturn(URI.create("http://h/x" + (query == null ? "" : "?" + query)));
30+
return ex;
31+
}
32+
33+
@Test
34+
void apiKeyHeaderPresentExtracts() {
35+
var scheme = new ApiKey("X-API-Key", Location.HEADER);
36+
var ex = exchangeWithHeader("X-API-Key", "abc123", null);
37+
assertThat(CredentialExtractor.extract(scheme, ex))
38+
.isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("abc123")));
39+
}
40+
41+
@Test
42+
void apiKeyHeaderMissingReturnsMissing() {
43+
var scheme = new ApiKey("X-API-Key", Location.HEADER);
44+
var ex = exchangeWithHeader("Other", "irrelevant", null);
45+
assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.missing());
46+
}
47+
48+
@Test
49+
void apiKeyQueryExtracts() {
50+
var scheme = new ApiKey("k", Location.QUERY);
51+
var ex = exchangeWithHeader("Ignored", null, "k=v1&other=v2");
52+
assertThat(CredentialExtractor.extract(scheme, ex))
53+
.isEqualTo(ExtractionResult.found(new Credential.ApiKeyCredential("v1")));
54+
}
55+
56+
@Test
57+
void httpBearerPresentExtracts() {
58+
var scheme = new HttpBearer(Optional.empty());
59+
var ex = exchangeWithHeader("Authorization", "Bearer abc.def.ghi", null);
60+
assertThat(CredentialExtractor.extract(scheme, ex))
61+
.isEqualTo(ExtractionResult.found(new Credential.BearerCredential("abc.def.ghi")));
62+
}
63+
64+
@Test
65+
void httpBearerCaseInsensitive() {
66+
var scheme = new HttpBearer(Optional.empty());
67+
var ex = exchangeWithHeader("Authorization", "bEaReR token", null);
68+
assertThat(CredentialExtractor.extract(scheme, ex))
69+
.isEqualTo(ExtractionResult.found(new Credential.BearerCredential("token")));
70+
}
71+
72+
@Test
73+
void httpBasicValidBase64Extracts() {
74+
var scheme = new HttpBasic();
75+
String creds = Base64.getEncoder().encodeToString("alice:s3cret".getBytes());
76+
var ex = exchangeWithHeader("Authorization", "Basic " + creds, null);
77+
assertThat(CredentialExtractor.extract(scheme, ex))
78+
.isEqualTo(ExtractionResult.found(new Credential.BasicCredential("alice", "s3cret")));
79+
}
80+
81+
@Test
82+
void httpBasicMalformedBase64ReturnsMalformed() {
83+
var scheme = new HttpBasic();
84+
var ex = exchangeWithHeader("Authorization", "Basic !!!not-base64", null);
85+
assertThat(CredentialExtractor.extract(scheme, ex)).isEqualTo(ExtractionResult.malformed());
86+
}
87+
}

0 commit comments

Comments
 (0)