Skip to content

Commit 7e0929b

Browse files
committed
Add test coverage for QUARKUS-5858
1 parent 3250634 commit 7e0929b

File tree

4 files changed

+265
-0
lines changed

4 files changed

+265
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.quarkus.ts.security.keycloak.oidcclient.extended.restclient.dpop;
2+
3+
import jakarta.inject.Inject;
4+
import jakarta.ws.rs.GET;
5+
import jakarta.ws.rs.POST;
6+
import jakarta.ws.rs.Path;
7+
import jakarta.ws.rs.Produces;
8+
9+
import org.eclipse.microprofile.jwt.JsonWebToken;
10+
11+
import io.quarkus.security.Authenticated;
12+
13+
@Path("/dpop")
14+
@Authenticated
15+
public class DpopProtectedResource {
16+
17+
@Inject
18+
JsonWebToken principal;
19+
20+
@GET
21+
@Produces("text/plain")
22+
public String hello() {
23+
return "Hello, " + principal.getName();
24+
}
25+
26+
@POST
27+
@Produces("text/plain")
28+
public String postHello() {
29+
return "Hello, " + principal.getName();
30+
}
31+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package io.quarkus.ts.security.keycloak.oidcclient.extended.restclient;
2+
3+
import static io.restassured.RestAssured.given;
4+
import static org.junit.jupiter.api.Assertions.assertEquals;
5+
6+
import java.security.KeyPair;
7+
import java.security.NoSuchAlgorithmException;
8+
9+
import org.apache.http.HttpStatus;
10+
import org.junit.jupiter.api.Tag;
11+
import org.junit.jupiter.api.Test;
12+
13+
import io.quarkus.oidc.common.runtime.OidcCommonUtils;
14+
import io.quarkus.oidc.runtime.OidcUtils;
15+
import io.quarkus.test.bootstrap.KeycloakService;
16+
import io.quarkus.test.bootstrap.LookupService;
17+
import io.quarkus.test.bootstrap.Protocol;
18+
import io.quarkus.test.bootstrap.RestService;
19+
import io.quarkus.test.services.QuarkusApplication;
20+
import io.quarkus.test.services.URILike;
21+
import io.restassured.response.Response;
22+
import io.smallrye.jwt.build.Jwt;
23+
import io.smallrye.jwt.build.JwtClaimsBuilder;
24+
import io.smallrye.jwt.build.JwtSignatureBuilder;
25+
import io.smallrye.jwt.util.KeyUtils;
26+
27+
@Tag("https://issues.redhat.com/browse/QUARKUS-5858")
28+
abstract public class AbstractDpopIT {
29+
private static final String USERNAME = "test-user";
30+
private static final String PASSWORD = "test-user";
31+
32+
private static final String CLIENT_ID = "test-application-client";
33+
private static final String CLIENT_SECRET = "test-application-client-secret";
34+
35+
@LookupService
36+
static KeycloakService keycloak;
37+
38+
@QuarkusApplication
39+
static RestService app = new RestService()
40+
.withProperty("quarkus.oidc.auth-server-url", () -> keycloak.getRealmUrl())
41+
.withProperty("quarkus.oidc.token.authorization-scheme", "dpop");
42+
43+
@Test
44+
public void correctAccessTest() throws Exception {
45+
KeyPair keyPair = generateKeyPair();
46+
String accessToken = getAccessToken(keyPair);
47+
48+
Response response = given()
49+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "/dpop"))
50+
.header("Authorization", "DPoP " + accessToken)
51+
.get("/dpop")
52+
.thenReturn();
53+
54+
assertEquals(HttpStatus.SC_OK, response.statusCode(), "Http response should be 200");
55+
assertEquals("Hello, " + USERNAME, response.asString(), "Response should contain username");
56+
}
57+
58+
// test that DPoP works correctly with HTTP POST method
59+
@Test
60+
public void postMethodTest() throws Exception {
61+
KeyPair keyPair = generateKeyPair();
62+
String accessToken = getAccessToken(keyPair);
63+
64+
Response response = given()
65+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "POST", "/dpop"))
66+
.header("Authorization", "DPoP " + accessToken)
67+
.post("/dpop")
68+
.thenReturn();
69+
70+
assertEquals(HttpStatus.SC_OK, response.statusCode(), "Http response should be 200");
71+
assertEquals("Hello, " + USERNAME, response.asString(), "Response should contain username");
72+
}
73+
74+
@Test
75+
public void jwtAuthorizationTest() {
76+
String accessToken = keycloak.createAuthzClient(CLIENT_ID, CLIENT_SECRET).obtainAccessToken(USERNAME, PASSWORD)
77+
.getToken();
78+
79+
// App should require DPoP for authorization - normal access token should not work
80+
given()
81+
.header("Authorization", accessToken)
82+
.get("/dpop")
83+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
84+
}
85+
86+
@Test
87+
public void missingDPoPHeaderTest() throws Exception {
88+
KeyPair keyPair = generateKeyPair();
89+
String accessToken = getAccessToken(keyPair);
90+
91+
given()
92+
.header("Authorization", "DPoP " + accessToken)
93+
.get("/dpop")
94+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
95+
}
96+
97+
@Test
98+
public void malformedAuthorizationHeaderTest() throws Exception {
99+
KeyPair keyPair = generateKeyPair();
100+
String accessToken = getAccessToken(keyPair);
101+
102+
given()
103+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "/dpop"))
104+
.header("Authorization", "DPoP invalidToken" + accessToken)
105+
.get("/dpop")
106+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
107+
}
108+
109+
@Test
110+
public void DPoPHeaderSignedWithWrongKeyTest() throws Exception {
111+
KeyPair keyPair = generateKeyPair();
112+
String accessToken = getAccessToken(keyPair);
113+
114+
given()
115+
.header("DPoP", createDPopProofForQuarkus(generateKeyPair(), accessToken, "/dpop"))
116+
.header("Authorization", "DPoP" + accessToken)
117+
.get("/dpop")
118+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
119+
}
120+
121+
@Test
122+
public void mismatchingHttpEndpointTest() throws Exception {
123+
KeyPair keyPair = generateKeyPair();
124+
String accessToken = getAccessToken(keyPair);
125+
126+
given()
127+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "/anotherEndpoint"))
128+
.header("Authorization", "DPoP" + accessToken)
129+
.get("/dpop")
130+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
131+
}
132+
133+
@Test
134+
public void mismatchingHttpMethodTest() throws Exception {
135+
KeyPair keyPair = generateKeyPair();
136+
String accessToken = getAccessToken(keyPair);
137+
138+
// by default proofForQuarkus is signed for GET method
139+
given()
140+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "/dpop"))
141+
.header("Authorization", "DPoP" + accessToken)
142+
.post("/dpop")
143+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
144+
145+
given()
146+
.header("DPoP", createDPopProofForQuarkus(keyPair, accessToken, "POST", "/dpop"))
147+
.header("Authorization", "DPoP" + accessToken)
148+
.get("/dpop")
149+
.then().statusCode(HttpStatus.SC_UNAUTHORIZED);
150+
}
151+
152+
private KeyPair generateKeyPair() throws NoSuchAlgorithmException {
153+
return KeyUtils.generateKeyPair(2048);
154+
}
155+
156+
private String getAccessToken(KeyPair keyPair) {
157+
return given()
158+
.header("DPOP", createDPopProofForKeycloak(keyPair))
159+
.param("client_id", CLIENT_ID)
160+
.param("client_secret", CLIENT_SECRET)
161+
.param("grant_type", "password")
162+
.param("username", USERNAME)
163+
.param("password", PASSWORD)
164+
.post(keycloak.getRealmUrl() + "/protocol/openid-connect/token")
165+
.jsonPath().getString("access_token");
166+
}
167+
168+
private String createDPopProofForKeycloak(KeyPair keyPair) {
169+
return Jwt.claim("htm", "POST")
170+
.claim("htu", keycloak.getRealmUrl() + "/protocol/openid-connect/token")
171+
.jws()
172+
.header("typ", "dpop+jwt")
173+
.jwk(keyPair.getPublic())
174+
.sign(keyPair.getPrivate());
175+
}
176+
177+
private String createDPopProofForQuarkus(KeyPair keyPair, String accessToken, String dPopEndpointPath) throws Exception {
178+
return createDPopProofForQuarkus(keyPair, accessToken, "GET", dPopEndpointPath);
179+
}
180+
181+
private String createDPopProofForQuarkus(KeyPair keyPair, String accessToken, String httpMethod, String dPopEndpointPath)
182+
throws Exception {
183+
184+
URILike uriLike = app.getURI(Protocol.HTTP);
185+
String uri = "http://" + uriLike.getHost();
186+
/*
187+
* Quarkus drop default http port 80 from URI when validating DPoP proof.
188+
* So if string ":80" is in DPoP proof it will cause a mismatch in proof validation.
189+
* But for any other port, it has to be present
190+
*/
191+
if (uriLike.getPort() != 80) {
192+
uri += ":" + uriLike.getPort();
193+
}
194+
JwtClaimsBuilder jwtClaimsBuilder = Jwt.claim("htm", httpMethod)
195+
.claim("htu", uri + dPopEndpointPath);
196+
JwtSignatureBuilder jwtSignatureBuilder = jwtClaimsBuilder
197+
.claim("ath", OidcCommonUtils.base64UrlEncode(
198+
OidcUtils.getSha256Digest(accessToken)))
199+
.jws()
200+
.jwk(keyPair.getPublic())
201+
.header("typ", "dpop+jwt");
202+
return jwtSignatureBuilder.sign(keyPair.getPrivate());
203+
}
204+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.quarkus.ts.security.keycloak.oidcclient.extended.restclient;
2+
3+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM;
4+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM_BASE_PATH;
5+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM_FILE;
6+
7+
import io.quarkus.test.bootstrap.KeycloakService;
8+
import io.quarkus.test.scenarios.QuarkusScenario;
9+
import io.quarkus.test.services.KeycloakContainer;
10+
11+
@QuarkusScenario
12+
public class DpopIT extends AbstractDpopIT {
13+
@KeycloakContainer(command = { "start-dev", "--import-realm", "--features=dpop" })
14+
static KeycloakService keycloak = new KeycloakService(DEFAULT_REALM_FILE, DEFAULT_REALM, DEFAULT_REALM_BASE_PATH);
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.quarkus.ts.security.keycloak.oidcclient.extended.restclient;
2+
3+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM;
4+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM_BASE_PATH;
5+
import static io.quarkus.test.bootstrap.KeycloakService.DEFAULT_REALM_FILE;
6+
7+
import io.quarkus.test.bootstrap.KeycloakService;
8+
import io.quarkus.test.scenarios.OpenShiftScenario;
9+
import io.quarkus.test.services.KeycloakContainer;
10+
11+
@OpenShiftScenario
12+
public class OpenShiftDpopIT extends AbstractDpopIT {
13+
@KeycloakContainer(image = "${rhbk.image}", command = { "start-dev", "--import-realm", "--features=dpop" })
14+
static KeycloakService keycloak = new KeycloakService(DEFAULT_REALM_FILE, DEFAULT_REALM, DEFAULT_REALM_BASE_PATH);
15+
}

0 commit comments

Comments
 (0)