Skip to content

Commit 9bd525d

Browse files
committed
test: ✅ add test for OIDC authentication
1 parent 04fde97 commit 9bd525d

File tree

3 files changed

+438
-5
lines changed

3 files changed

+438
-5
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
package net.netshot.netshot;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.io.IOException;
5+
import java.io.InputStream;
6+
import java.math.BigInteger;
7+
import java.net.URI;
8+
import java.nio.charset.StandardCharsets;
9+
import java.security.GeneralSecurityException;
10+
import java.security.InvalidKeyException;
11+
import java.security.KeyFactory;
12+
import java.security.NoSuchAlgorithmException;
13+
import java.security.PrivateKey;
14+
import java.security.Signature;
15+
import java.security.SignatureException;
16+
import java.security.cert.Certificate;
17+
import java.security.cert.CertificateException;
18+
import java.security.cert.CertificateFactory;
19+
import java.security.interfaces.RSAPublicKey;
20+
import java.security.spec.InvalidKeySpecException;
21+
import java.security.spec.PKCS8EncodedKeySpec;
22+
import java.time.Instant;
23+
import java.time.temporal.ChronoUnit;
24+
import java.util.Base64;
25+
import java.util.HashMap;
26+
import java.util.Map;
27+
28+
import org.glassfish.grizzly.http.server.HttpHandler;
29+
import org.glassfish.grizzly.http.server.HttpServer;
30+
import org.glassfish.grizzly.http.server.Request;
31+
import org.glassfish.grizzly.http.server.Response;
32+
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
33+
import com.fasterxml.jackson.databind.node.ObjectNode;
34+
35+
import jakarta.ws.rs.ForbiddenException;
36+
import jakarta.ws.rs.core.MediaType;
37+
import lombok.Getter;
38+
import lombok.Setter;
39+
40+
public class FakeOidcIdpServer {
41+
42+
public static class User {
43+
private String username;
44+
private String role;
45+
46+
public User(String username, String role) {
47+
this.username = username;
48+
this.role = role;
49+
}
50+
}
51+
52+
final static private URI DEFAULT_BASE_URI = URI.create("http://localhost:8980/");
53+
final static private String DISCOVERY_PATH = "/.well-known/openid-configuration";
54+
final static private String AUTH_PATH = "/protocol/openid-connect/auth";
55+
final static private String TOKEN_PATH = "/protocol/openid-connect/token";
56+
final static private String JWKS_PATH = "/protocol/openid-connect/certs";
57+
58+
final static private String SAMPLE_KID = "ukbTXswbjExjfqPBKPREf8VULCASfzi05E97YKsj6g0";
59+
60+
61+
// Sample RSA key (PCKS8, PEM-encoded)
62+
final static private String SAMPLE_PRIVATE_KEY =
63+
"MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDLFrHaHNdJAyV3" +
64+
"Tctaam0MX978CKEDKGlxeuRmSnjMVDfTljI7zThWowbJ4sqg6ICvDtTlfAAhIPxp" +
65+
"spPYHITyA79PV57/Y/k3Cz55DMpm19YwJUbEcHSn2Z6fIcOmXanPiNUB3A1Zx/Jx" +
66+
"ZHYPzgIaWGEwRTBECjSIlZwYM1iOVtZaMrOSqc5z2cfRVNfBYQ5/mDn3ZEGv+ny+" +
67+
"auVEAgmhmlaGxgX7mLToRBzv0t9TNfM8kosaYBG+QanCh92vsswEAASUDtzzkmiN" +
68+
"g1tQ0c8gew22hOJJnh/ohtn+mNHWkS5UISzBlpZWl9/6olv71s1tyvbZhfhyXp0x" +
69+
"FN6qsK7zAgMBAAECggEAApruBiInlHBKmAvl7c9+sNFya7sNzoIQTc92dzKn93Fy" +
70+
"U+MFDiWISvtry19lTqIc5uH8UvZ/wLnXf+DPRIIjjHOfp4kBHHD8xSF+S13U8A1l" +
71+
"wH4oUoqUwuoOPsFXbRHRHonrtzEXWyb7xUubt7RihevLUPiz17OZaYUg/vutotaU" +
72+
"p0pOiJ3OQStS4F44MIcc9xUUXo6j2OfkYRCIsgYhPdsjpGQhpEd6ANKPBkjqHoyQ" +
73+
"B47ctALf2nlwtjtn/Wa1sctBzy4Oy3F2guNtaZZGnuhk8NE8yv/pDgFmABE8+cAZ" +
74+
"9waivuf4OkbmRp7WWYIjA69bzcHJWWOZXjLHsPSHVQKBgQD8nLGbPTPwC2LK6nkk" +
75+
"8mkrGn130z9YX3zUXUuIXXbfc1IT0cBGgLk+gHKgo75yqlQCa7/Tkz2ZJA2eu0vV" +
76+
"D4IqpcgBCNlBKOIGG2ninvzy0dYzPP5LtwqFAjIxF63faMGU8+ym1/KMGzHqPoYz" +
77+
"BiDaWJgp+UrxwuigPeOOH50rFQKBgQDNz/g2G4+6+JCNfBlQOYubeLODrOVb8qEj" +
78+
"M2Gi1BUtqA+XmEElwtKWUXqgH+I9gKHRWo4AUD0Yi64f5T7zRwe0rDfUhDc67Wpz" +
79+
"8HVm98fTRK6VOdwXRt/1jAchWG07otz+KOA2ou3nQqMmY52tKdDz1mL6FzJ/fcAe" +
80+
"UyizQQtT5wKBgCVySPW9PdzAo1V3KpwqfyKPm7fOjd5Y0VVduxus1zlKjAk6F6mb" +
81+
"3VoBinx7qXiv/SIavOXtNr1j1c0I8LXVxbLyvlJA8IuzNsY2/BxG+zI3nuwbh4rL" +
82+
"yHhtGemjG/g5PDELc7JL4r2YLm8N87DOoMIdTfky5kQuY3OVmQzxbMf9AoGBAJXo" +
83+
"MBuBEbyWxfs389waPhSc4uw658iEPlg8WZZXMaHSsqCxdmpBsE9qw42UC57ObY7m" +
84+
"jV2vFAEn5Ek5GhPqnbM8aWHyd6QFP6946pp4SeUZNqxcu3F83y2js6HXHaD9bEf3" +
85+
"j/Bb1jrGr70Le9KgDaE9e1Q7xz1TY7byzUdbThvrAoGBAIsjuzdd376LUb6ClFVL" +
86+
"KMMCNFq9z2c8YHYITmKTRUVGtrKcS1daHByBiLA24jI43xzNVcjwaaI+OUmjH5XY" +
87+
"qhWo2p2F+diSkZSK7wBq9wFQaURXDv36QzdVbspMQluruMmUa5FCnApNoX4hPIUS" +
88+
"oby0A0cpHjPY+bR+KlAPivKg";
89+
90+
// Sample IdP certificate for tests (private key above)
91+
final static private String SAMPLE_CERTIFICATE =
92+
"MIIClzCCAX8CBgGXq4B+JzANBgkqhkiG9w0BAQsFADAPMQ0wCwYDVQQDDAR0ZXN0MB4XDTI1MDYyNjA5" +
93+
"MDgzNFoXDTM1MDYyNjA5MTAxNFowDzENMAsGA1UEAwwEdGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEP" +
94+
"ADCCAQoCggEBAMsWsdoc10kDJXdNy1pqbQxf3vwIoQMoaXF65GZKeMxUN9OWMjvNOFajBsniyqDogK8O" +
95+
"1OV8ACEg/Gmyk9gchPIDv09Xnv9j+TcLPnkMymbX1jAlRsRwdKfZnp8hw6Zdqc+I1QHcDVnH8nFkdg/O" +
96+
"AhpYYTBFMEQKNIiVnBgzWI5W1loys5KpznPZx9FU18FhDn+YOfdkQa/6fL5q5UQCCaGaVobGBfuYtOhE" +
97+
"HO/S31M18zySixpgEb5BqcKH3a+yzAQABJQO3POSaI2DW1DRzyB7DbaE4kmeH+iG2f6Y0daRLlQhLMGW" +
98+
"llaX3/qiW/vWzW3K9tmF+HJenTEU3qqwrvMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAasPZXVq0qJn4" +
99+
"mt1h0wZSlpyPm54hV2MNlxE7bd1I2fRtxmBTTUuOLEi6ca5iStKoHO6N7kCGlSWLs49/dqeNKjHweCV7" +
100+
"daK7t8tp8sKNPEcDoz0jFiVXVeUTLm0VjrdbdCSpamm9/Z/4EMaxE5SQKTHEHu83uqr1biNjr6n82WSj" +
101+
"xWMdh/hDQbtS08rItYSrkxJ3PLIdhxqJ/uUTZ3EqE3Ulcc+coiIzeyVdO/r4sZJvG2XLyidGsxQBcCef" +
102+
"5AlNWd7EQUc1lioS1HNSqTiXwxKvfAd06bGCN3mz8z6XgpFNTRDtHsmK3L7h5EYNa3DBKg/mgxfE3DJt" +
103+
"XXCXJHBCzQ==";
104+
105+
@Getter @Setter
106+
private URI baseUri;
107+
@Getter @Setter
108+
private String clientId;
109+
@Getter @Setter
110+
private String clientSecret;
111+
@Getter @Setter
112+
private URI redirectUri;
113+
private PrivateKey privKey;
114+
private Certificate certificate;
115+
116+
private Map<String, User> authorizationCodes = new HashMap<>();
117+
118+
private HttpServer server = null;
119+
120+
public FakeOidcIdpServer(URI baseUri) throws GeneralSecurityException {
121+
this.baseUri = baseUri;
122+
this.loadSampleCert();
123+
}
124+
125+
public FakeOidcIdpServer() throws GeneralSecurityException {
126+
this(DEFAULT_BASE_URI);
127+
}
128+
129+
public void registerClient(String clientId, String clientSecret, URI redirectUri) {
130+
this.clientId = clientId;
131+
this.clientSecret = clientSecret;
132+
this.redirectUri = redirectUri;
133+
}
134+
135+
public void addAuthorizatioCode(String code, String username, String role) {
136+
this.authorizationCodes.put(code, new User(username, role));
137+
}
138+
139+
private void loadSampleCert() throws NoSuchAlgorithmException, InvalidKeySpecException, CertificateException {
140+
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
141+
InputStream is = new ByteArrayInputStream(Base64.getDecoder().decode(SAMPLE_CERTIFICATE));
142+
this.certificate = certFactory.generateCertificate(is);
143+
PKCS8EncodedKeySpec privSpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(SAMPLE_PRIVATE_KEY));
144+
KeyFactory kf = KeyFactory.getInstance("RSA");
145+
this.privKey = kf.generatePrivate(privSpec);
146+
}
147+
148+
private void checkBasicAuth(Request request) throws ForbiddenException {
149+
final String basicPrefix = "Basic ";
150+
String authHeader = request.getHeader("Authorization");
151+
if (authHeader == null) {
152+
throw new ForbiddenException("Authorization header not present");
153+
}
154+
if (!authHeader.startsWith(basicPrefix)) {
155+
throw new ForbiddenException("Authorization header is not basic auth");
156+
}
157+
String encodedCredentials = authHeader.substring(basicPrefix.length());
158+
String decodedCredentials = new String(Base64.getDecoder().decode(encodedCredentials),
159+
StandardCharsets.UTF_8);
160+
String expectedCredentials = "%s:%s".formatted(this.clientId, this.clientSecret);
161+
if (!expectedCredentials.equals(decodedCredentials)) {
162+
throw new ForbiddenException("Invalid credentials");
163+
}
164+
}
165+
166+
private String signEncodeToken(ObjectNode token) throws SignatureException, InvalidKeyException, NoSuchAlgorithmException {
167+
ObjectNode header = JsonNodeFactory.instance.objectNode()
168+
.put("alg", "RS256")
169+
.put("typ", "JWT")
170+
.put("kid", SAMPLE_KID);
171+
String jwt = encodeJson(header) + "." + encodeJson(token);
172+
Signature signature = Signature.getInstance("SHA256withRSA");
173+
signature.initSign(privKey);
174+
signature.update(jwt.getBytes(StandardCharsets.UTF_8));
175+
byte[] signBytes = signature.sign();
176+
jwt = jwt + "." + Base64.getUrlEncoder().encodeToString(signBytes);
177+
return jwt;
178+
}
179+
180+
private String encodeJson(ObjectNode token) {
181+
return Base64.getUrlEncoder().encodeToString(
182+
token.toString().getBytes(StandardCharsets.UTF_8));
183+
}
184+
185+
private ObjectNode generateIdToken(User user) {
186+
ObjectNode token = JsonNodeFactory.instance.objectNode()
187+
.put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond())
188+
.put("iat", Instant.now().getEpochSecond())
189+
.put("auth_time", Instant.now().getEpochSecond())
190+
.put("jti", "f9e39317-81e0-4334-a75e-994003f1d160")
191+
.put("iss", this.baseUri.toString())
192+
.put("typ", "ID")
193+
.put("azp", this.clientId)
194+
.put("sid", "5eac7f33-3818-4438-b20a-78dfaec74abd")
195+
.put("scope", "openid")
196+
.put("acr", "1")
197+
.put("aud", this.clientId)
198+
.put("sub", "8b17b6a1-3c91-491b-9f9f-d7c314f08271")
199+
.put("preferred_username", user.username)
200+
.put("role", user.role);
201+
return token;
202+
}
203+
204+
private ObjectNode generateAccessToken(User user) {
205+
ObjectNode token = JsonNodeFactory.instance.objectNode()
206+
.put("exp", Instant.now().plus(1, ChronoUnit.HOURS).getEpochSecond())
207+
.put("iat", Instant.now().getEpochSecond())
208+
.put("jti", "trrtna:df789356-42fe-4bf3-8607-303161e9fe0e")
209+
.put("iss", this.baseUri.toString())
210+
.put("typ", "Bearer")
211+
.put("azp", this.clientId)
212+
.put("sid", "c83ec19e-fc41-4764-9fe6-2b92b46fa10e")
213+
.put("acr", "1")
214+
.put("preferred_username", user.username);
215+
return token;
216+
}
217+
218+
public void start() throws IOException {
219+
this.server = HttpServer.createSimpleServer(null,
220+
this.baseUri.getHost(), this.baseUri.getPort());
221+
this.server.getServerConfiguration().addHttpHandler(new HttpHandler() {
222+
@Override
223+
public void service(Request request, Response response) throws Exception {
224+
ObjectNode data = JsonNodeFactory.instance.objectNode()
225+
.put("issuer", baseUri.toString())
226+
.put("authorization_endpoint", baseUri.resolve(AUTH_PATH).toString())
227+
.put("token_endpoint", baseUri.resolve(TOKEN_PATH).toString())
228+
.put("jwks_uri", baseUri.resolve(JWKS_PATH).toString());
229+
data.putArray("grant_types_supported")
230+
.add("authorization_code");
231+
data.putArray("response_types_supported")
232+
.add("code")
233+
.add("none")
234+
.add("id_token")
235+
.add("token");
236+
data.putArray("claims_supported")
237+
.add("aud")
238+
.add("sub")
239+
.add("iss")
240+
.add("auth_time")
241+
.add("preferred_username")
242+
.add("email");
243+
data.putArray("subject_types_supported")
244+
.add("public");
245+
data.putArray("id_token_signing_alg_values_supported")
246+
.add("RS256");
247+
data.putArray("token_endpoint_auth_methods_supported")
248+
.add("client_secret_basic");
249+
String content = data.toPrettyString();
250+
response.setContentType(MediaType.APPLICATION_JSON);
251+
response.setContentLength(content.length());
252+
response.getWriter().write(content);
253+
}
254+
}, DISCOVERY_PATH);
255+
this.server.getServerConfiguration().addHttpHandler(new HttpHandler() {
256+
@Override
257+
public void service(Request request, Response response) throws Exception {
258+
try {
259+
checkBasicAuth(request);
260+
if (!MediaType.APPLICATION_FORM_URLENCODED_TYPE.withCharset(StandardCharsets.UTF_8.name())
261+
.equals(MediaType.valueOf(request.getContentType()))) {
262+
throw new Exception("Received token request content type is not form-urlencoded");
263+
}
264+
if (!"authorization_code".equals(request.getParameter("grant_type"))) {
265+
throw new Exception("The received grant_type is not correct");
266+
}
267+
if (!redirectUri.toString().equals(request.getParameter("redirect_uri"))) {
268+
throw new Exception("The received redirect_uri is not correct");
269+
}
270+
String code = request.getParameter("code");
271+
if (code == null) {
272+
throw new ForbiddenException("Missing code parameter");
273+
}
274+
User user = authorizationCodes.remove(code);
275+
if (user == null) {
276+
throw new ForbiddenException("Invalid authorization code");
277+
}
278+
ObjectNode data = JsonNodeFactory.instance.objectNode()
279+
.put("token_type", "Bearer")
280+
.put("refresh_token", "...")
281+
.put("access_token", signEncodeToken(generateAccessToken(user)))
282+
.put("id_token", signEncodeToken(generateIdToken(user)))
283+
.put("expires_in", 3600);
284+
String content = data.toPrettyString();
285+
response.setContentType(MediaType.APPLICATION_JSON);
286+
response.setContentLength(content.length());
287+
response.getWriter().write(content);
288+
}
289+
catch (ForbiddenException e) {
290+
response.setStatus(
291+
jakarta.ws.rs.core.Response.Status.UNAUTHORIZED.getStatusCode(), "Unauthorized");
292+
return;
293+
}
294+
catch (Exception e) {
295+
response.setStatus(
296+
jakarta.ws.rs.core.Response.Status.BAD_REQUEST.getStatusCode(), "Bad request");
297+
return;
298+
}
299+
}
300+
}, TOKEN_PATH);
301+
this.server.getServerConfiguration().addHttpHandler(new HttpHandler() {
302+
@Override
303+
public void service(Request request, Response response) throws Exception {
304+
ObjectNode data = JsonNodeFactory.instance.objectNode();
305+
BigInteger certModulus = ((RSAPublicKey) certificate.getPublicKey()).getModulus();
306+
BigInteger certExponent = ((RSAPublicKey) certificate.getPublicKey()).getPublicExponent();
307+
ObjectNode key = JsonNodeFactory.instance.objectNode()
308+
.put("kid", SAMPLE_KID)
309+
.put("kty", "RSA")
310+
.put("alg", "RS256")
311+
.put("n", Base64.getUrlEncoder().encodeToString(certModulus.toByteArray()))
312+
.put("e", Base64.getUrlEncoder().encodeToString(certExponent.toByteArray()));
313+
key.putArray("x5c").add(Base64.getEncoder().encodeToString(certificate.getEncoded()));
314+
data.putArray("keys").add(key);
315+
String content = data.toPrettyString();
316+
response.setContentType(MediaType.APPLICATION_JSON);
317+
response.setContentLength(content.length());
318+
response.getWriter().write(content);
319+
}
320+
}, JWKS_PATH);
321+
this.server.start();
322+
}
323+
324+
public void shutdown() {
325+
if (this.server != null) {
326+
this.server.shutdown();
327+
}
328+
}
329+
330+
}

src/test/java/net/netshot/netshot/NetshotApiClient.java

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ public WrongApiResponseException(String message, HttpResponse<?> response) {
8282
@Getter @Setter
8383
private String newPassword;
8484

85+
@Getter @Setter
86+
private String authorizationCode;
87+
88+
@Getter @Setter
89+
private String redirectUri;
90+
8591
@Getter @Setter
8692
private HttpCookie sessionCookie;
8793

@@ -111,6 +117,12 @@ public void setLogin(String username, String password) {
111117
this.apiToken = null;
112118
}
113119

120+
public void setOidcCodeLogin(String authorizationCode, String redirectUri) {
121+
this.authorizationCode = authorizationCode;
122+
this.redirectUri = redirectUri;
123+
this.apiToken = null;
124+
}
125+
114126
private HttpRequest.BodyPublisher jsonNodePublisher(JsonNode jsonNode) throws JsonProcessingException {
115127
return HttpRequest.BodyPublishers.ofString(
116128
this.getObjectMapper().writeValueAsString(jsonNode));
@@ -159,10 +171,16 @@ protected void login() throws IOException, InterruptedException {
159171
builder.header("Accept", this.mediaType.toString());
160172
builder.header("Content-Type", this.mediaType.toString());
161173
ObjectNode payload = JsonNodeFactory.instance.objectNode();
162-
payload.put("username", this.username);
163-
payload.put("password", this.password);
164-
if (this.newPassword != null) {
165-
payload.put("newPassword", this.newPassword);
174+
if (this.username != null) {
175+
payload.put("username", this.username);
176+
payload.put("password", this.password);
177+
if (this.newPassword != null) {
178+
payload.put("newPassword", this.newPassword);
179+
}
180+
}
181+
else if (this.authorizationCode != null) {
182+
payload.put("authorizationCode", this.authorizationCode);
183+
payload.put("redirectUri", this.redirectUri);
166184
}
167185
builder.POST(this.jsonNodePublisher(payload));
168186
HttpRequest request = builder.build();
@@ -205,7 +223,8 @@ protected HttpRequest.Builder initRequest(String path) throws IOException, Inter
205223
if (this.apiToken != null) {
206224
builder.header("X-Netshot-API-Token", this.apiToken);
207225
}
208-
else if (this.username != null && this.password != null) {
226+
else if ((this.username != null && this.password != null) ||
227+
this.authorizationCode != null) {
209228
if (this.sessionCookie == null) {
210229
this.login();
211230
}

0 commit comments

Comments
 (0)