Skip to content

Commit

Permalink
Add Quarkus tests for the OAuth2 Device Code flow (#7900)
Browse files Browse the repository at this point in the history
  • Loading branch information
adutra authored Jan 2, 2024
1 parent 3700c2f commit 08640f6
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ public static ResourceOwnerEmulator forAuthorizationCode() throws IOException {
return new ResourceOwnerEmulator(GrantType.AUTHORIZATION_CODE);
}

/**
* Dummy factory method to circumvent class loading issues when instantiating this class from
* within a Quarkus test.
*/
public static ResourceOwnerEmulator forDeviceCode() throws IOException {
return new ResourceOwnerEmulator(GrantType.DEVICE_CODE);
}

private static final Logger LOGGER = LoggerFactory.getLogger(ResourceOwnerEmulator.class);

private static final Pattern FORM_ACTION_PATTERN =
Expand All @@ -87,6 +95,7 @@ public static ResourceOwnerEmulator forAuthorizationCode() throws IOException {
private volatile URI baseUri;
private volatile Consumer<URL> authUrlListener;
private volatile Consumer<Throwable> errorListener;
private volatile Runnable flowCompletionListener;
private volatile String authorizationCode;
private volatile int expectedCallbackStatus = HTTP_OK;
private volatile boolean denyConsent = false;
Expand Down Expand Up @@ -138,6 +147,10 @@ public void setAuthUrlListener(Consumer<URL> callback) {
this.authUrlListener = callback;
}

public void setFlowCompletionListener(Runnable listener) {
this.flowCompletionListener = listener;
}

private void readConsole() {
try {
String line;
Expand Down Expand Up @@ -183,6 +196,10 @@ private void triggerAuthorizationCodeFlow(URL initialUrl) {
: login(initialUrl, cookies);
invokeCallbackUrl(callbackUrl);
LOGGER.info("Authorization code flow completed.");
Runnable listener = flowCompletionListener;
if (listener != null) {
listener.run();
}
} catch (Exception | AssertionError t) {
recordFailure(t);
}
Expand All @@ -203,6 +220,10 @@ private void triggerDeviceCodeFlow(URL initialUrl, String userCode) {
authorizeDevice(consentPageUrl, cookies);
}
LOGGER.info("Device code flow completed.");
Runnable listener = flowCompletionListener;
if (listener != null) {
listener.run();
}
} catch (Exception | AssertionError t) {
recordFailure(t);
}
Expand Down Expand Up @@ -324,7 +345,7 @@ private void authorizeDevice(URL consentPageUrl, Set<String> cookies) throws IOE
}
consentActionConn.getOutputStream().write(data.getBytes(UTF_8));
consentActionConn.getOutputStream().close();
assertThat(consentActionConn.getResponseCode()).isEqualTo(HTTP_OK);
readRedirectUrl(consentActionConn, cookies);
}

private void recordFailure(Throwable t) {
Expand All @@ -344,22 +365,17 @@ private void recordFailure(Throwable t) {

/**
* Open a connection to the given URL, optionally replacing hostname and port with those actually
* accessible by the client; this is necessary because the auth server may be sending URLs with
* its configured frontend hostname + port, which, usually when using Docker, is something like
* keycloak:8080.
*
* <p>FIXME: unfortunately this is not enough when KC_HOSTNAME_URL is set to keycloak:8080 and the
* device flow is being used: the consent URL returns 404 when localhost is used instead :-(
* accessible by this client; this is necessary because the auth server may be sending URLs with a
* hostname + port address that is only accessible within a Docker network, e.g. keycloak:8080.
*/
private HttpURLConnection openConnection(URL url) throws IOException {
HttpURLConnection conn;
if (baseUri == null || baseUri.getHost().equals(url.getHost())) {
conn = (HttpURLConnection) url.openConnection();
} else {
URL transformed =
new URL(url.getProtocol(), baseUri.getHost(), baseUri.getPort(), url.getFile());
new URL(baseUri.getScheme(), baseUri.getHost(), baseUri.getPort(), url.getFile());
conn = (HttpURLConnection) transformed.openConnection();
conn.setRequestProperty("Host", url.getHost() + ":" + url.getPort());
}
return conn;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.io.IOException;
import java.net.URI;
import java.util.Map;
import java.util.Properties;
import org.projectnessie.client.auth.oauth2.GrantType;
import org.projectnessie.client.auth.oauth2.ResourceOwnerEmulator;
import org.projectnessie.quarkus.tests.profiles.KeycloakTestResourceLifecycleManager;
Expand All @@ -39,19 +40,31 @@ public class ITOAuth2Authentication extends AbstractOAuth2Authentication {
private final KeycloakTestClient keycloakClient = new KeycloakTestClient();

@Override
protected String tokenEndpoint() {
return keycloakClient.getAuthServerUrl() + "/protocol/openid-connect/token";
protected Properties clientCredentialsConfig() {
Properties config = super.clientCredentialsConfig();
String issuerUrl = keycloakClient.getAuthServerUrl();
config.setProperty("nessie.authentication.oauth2.issuer-url", issuerUrl);
return config;
}

@Override
protected String authEndpoint() {
return keycloakClient.getAuthServerUrl() + "/protocol/openid-connect/auth";
protected Properties deviceCodeConfig() {
Properties config = super.deviceCodeConfig();
// Keycloak advertises the token endpoint using whichever hostname was provided in the request,
// but the authentication endpoints are always advertised at keycloak:8080, which is
// the configured KC_HOSTNAME_URL env var and corresponds to the Docker internal network
// address. This works for the authorization code flow because ResourceOwnerEmulator knows how
// to deal with this; but for the device flow we need to use a different hostname, so endpoint
// discovery is not an option here.
config.setProperty(
"nessie.authentication.oauth2.device-auth-endpoint",
keycloakClient.getAuthServerUrl() + "/protocol/openid-connect/auth/device");
return config;
}

@Override
protected ResourceOwnerEmulator newResourceOwner() throws IOException {
ResourceOwnerEmulator resourceOwner =
new ResourceOwnerEmulator(GrantType.AUTHORIZATION_CODE, "alice", "alice");
protected ResourceOwnerEmulator newResourceOwner(GrantType grantType) throws IOException {
ResourceOwnerEmulator resourceOwner = new ResourceOwnerEmulator(grantType, "alice", "alice");
resourceOwner.replaceSystemOut();
resourceOwner.setAuthServerBaseUri(URI.create(keycloakClient.getAuthServerUrl()));
resourceOwner.setErrorListener(e -> api().close());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import io.quarkus.test.common.QuarkusTestResource;
Expand All @@ -43,6 +44,7 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.projectnessie.client.auth.NessieAuthentication;
import org.projectnessie.client.auth.oauth2.GrantType;
import org.projectnessie.client.auth.oauth2.ResourceOwnerEmulator;
import org.projectnessie.client.http.impl.HttpUtils;
import org.projectnessie.client.rest.NessieNotAuthorizedException;
Expand All @@ -58,9 +60,13 @@ public class TestOAuth2Authentication extends AbstractOAuth2Authentication {
private static final String VALID_TOKEN = getAccessToken("alice", ImmutableSet.of("user"));
private static final String TOKEN_ENDPOINT_PATH = "/auth/realms/quarkus/token";
private static final String AUTH_ENDPOINT_PATH = "/auth/realms/quarkus/auth";
private static final String DEVICE_AUTH_ENDPOINT_PATH = "/auth/realms/quarkus/auth/device";
private static final String USER_DEVICE_AUTH_URL = "/auth/realms/quarkus/device";

@OidcWireMock private WireMockServer wireMockServer;

private volatile StubMapping authPendingMapping;

@Test
void testExpired() {
NessieAuthentication authentication = oauth2Authentication(expiredConfig());
Expand All @@ -81,6 +87,27 @@ void testWrongIssuer() {
e -> assertThat(e.getError().getStatus()).isEqualTo(401));
}

@Override
protected Properties clientCredentialsConfig() {
Properties config = super.clientCredentialsConfig();
config.setProperty("nessie.authentication.oauth2.token-endpoint", tokenEndpoint());
return config;
}

@Override
protected Properties authorizationCodeConfig() {
Properties config = super.authorizationCodeConfig();
config.setProperty("nessie.authentication.oauth2.auth-endpoint", authEndpoint());
return config;
}

@Override
protected Properties deviceCodeConfig() {
Properties config = super.deviceCodeConfig();
config.setProperty("nessie.authentication.oauth2.device-auth-endpoint", deviceAuthEndpoint());
return config;
}

private Properties expiredConfig() {
Properties config = clientCredentialsConfig();
config.setProperty("nessie.authentication.oauth2.client-secret", "EXPIRED");
Expand All @@ -93,16 +120,22 @@ private Properties wrongIssuerConfig() {
return config;
}

@Override
protected String tokenEndpoint() {
private String tokenEndpoint() {
return wireMockServer.baseUrl() + TOKEN_ENDPOINT_PATH;
}

@Override
protected String authEndpoint() {
private String authEndpoint() {
return wireMockServer.baseUrl() + AUTH_ENDPOINT_PATH;
}

private String deviceAuthEndpoint() {
return wireMockServer.baseUrl() + DEVICE_AUTH_ENDPOINT_PATH;
}

private String userDeviceAuthEndpoint() {
return wireMockServer.baseUrl() + USER_DEVICE_AUTH_URL;
}

@BeforeAll
void clientCredentialsStub() {
wireMockServer.stubFor(
Expand Down Expand Up @@ -134,6 +167,53 @@ void authorizationCodeStub() {
.willReturn(successfulResponse(VALID_TOKEN)));
}

@BeforeAll
void deviceCodeStub() {
// Endpoint where the client requests a device code
wireMockServer.stubFor(
WireMock.post(DEVICE_AUTH_ENDPOINT_PATH)
.withHeader("Authorization", equalTo("Basic cXVhcmt1cy1zZXJ2aWNlLWFwcDpzZWNyZXQ="))
.willReturn(
WireMock.aResponse()
.withHeader("Content-Type", "application/json")
.withBody(
"{\"device_code\":\"1234\","
+ "\"user_code\":\"CAFE-BABE\","
+ "\"verification_uri\":\""
+ userDeviceAuthEndpoint()
+ "\","
+ "\"expires_in\":600}")));
// Endpoint where the user enters the device code
wireMockServer.stubFor(
WireMock.get(urlPathEqualTo(USER_DEVICE_AUTH_URL))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader("Content-Type", "text/html")
.withBody("<html><body>Enter device code:</body></html>")));
wireMockServer.stubFor(
WireMock.post(USER_DEVICE_AUTH_URL)
.withRequestBody(containing("device_user_code=CAFE-BABE"))
.willReturn(
WireMock.aResponse()
.withStatus(200)
.withHeader("Content-Type", "text/html")
.withBody("<html><body>Device code received!</body></html>")));
// Tokens endpoint: initially configured to return "authorization pending"
authPendingMapping =
wireMockServer.stubFor(
WireMock.post(TOKEN_ENDPOINT_PATH)
.withHeader("Authorization", equalTo("Basic cXVhcmt1cy1zZXJ2aWNlLWFwcDpzZWNyZXQ="))
.withRequestBody(containing("device_code"))
.willReturn(
WireMock.aResponse()
.withStatus(400)
.withHeader("Content-Type", "application/json")
.withBody(
"{\"error\":\"authorization_pending\","
+ "\"error_description\":\"Authorization pending\"}")));
}

@BeforeAll
void unauthorizedStub() {
wireMockServer.stubFor(
Expand Down Expand Up @@ -182,20 +262,37 @@ void wrongIssuerStub() {
}

@Override
protected ResourceOwnerEmulator newResourceOwner() throws IOException {
ResourceOwnerEmulator resourceOwner = ResourceOwnerEmulator.forAuthorizationCode();
protected ResourceOwnerEmulator newResourceOwner(GrantType grantType) throws IOException {
ResourceOwnerEmulator resourceOwner =
grantType == GrantType.AUTHORIZATION_CODE
? ResourceOwnerEmulator.forAuthorizationCode()
: ResourceOwnerEmulator.forDeviceCode();
resourceOwner.replaceSystemOut();
resourceOwner.setAuthUrlListener(
url -> {
String state = HttpUtils.parseQueryString(url.getQuery()).get("state");
wireMockServer.stubFor(
WireMock.get(urlPathEqualTo(AUTH_ENDPOINT_PATH))
.withQueryParam("response_type", equalTo("code"))
.withQueryParam("client_id", equalTo("quarkus-service-app"))
.withQueryParam("redirect_uri", containing("http"))
.withQueryParam("state", equalTo(state))
.willReturn(authorizationCodeResponse(state)));
});
if (grantType == GrantType.AUTHORIZATION_CODE) {
resourceOwner.setAuthUrlListener(
url -> {
String state = HttpUtils.parseQueryString(url.getQuery()).get("state");
wireMockServer.stubFor(
WireMock.get(urlPathEqualTo(AUTH_ENDPOINT_PATH))
.withQueryParam("response_type", equalTo("code"))
.withQueryParam("client_id", equalTo("quarkus-service-app"))
.withQueryParam("redirect_uri", containing("http"))
.withQueryParam("state", equalTo(state))
.willReturn(authorizationCodeResponse(state)));
});
} else {
resourceOwner.setFlowCompletionListener(
() -> {
// Reconfigure token endpoint to send a valid token
wireMockServer.stubFor(
WireMock.post(TOKEN_ENDPOINT_PATH)
.withHeader(
"Authorization", equalTo("Basic cXVhcmt1cy1zZXJ2aWNlLWFwcDpzZWNyZXQ="))
.withRequestBody(containing("device_code"))
.willReturn(successfulResponse(VALID_TOKEN)));
wireMockServer.removeStubMapping(authPendingMapping);
});
}
return resourceOwner;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.Properties;
import org.junit.jupiter.api.Test;
import org.projectnessie.client.auth.NessieAuthentication;
import org.projectnessie.client.auth.oauth2.GrantType;
import org.projectnessie.client.auth.oauth2.OAuth2AuthenticationProvider;
import org.projectnessie.client.auth.oauth2.OAuth2Exception;
import org.projectnessie.client.auth.oauth2.ResourceOwnerEmulator;
Expand All @@ -47,14 +48,23 @@ void testAuthorizedPassword() throws Exception {

@Test
void testAuthorizedAuthorizationCode() throws Exception {
try (ResourceOwnerEmulator ignored = newResourceOwner()) {
try (ResourceOwnerEmulator ignored = newResourceOwner(GrantType.AUTHORIZATION_CODE)) {
NessieAuthentication authentication = oauth2Authentication(authorizationCodeConfig());
withClientCustomizer(b -> b.withAuthentication(authentication));
assertThat(api().getAllReferences().stream()).isNotEmpty();
}
}

protected abstract ResourceOwnerEmulator newResourceOwner() throws IOException;
@Test
void testAuthorizedDeviceCode() throws Exception {
try (ResourceOwnerEmulator ignored = newResourceOwner(GrantType.DEVICE_CODE)) {
NessieAuthentication authentication = oauth2Authentication(deviceCodeConfig());
withClientCustomizer(b -> b.withAuthentication(authentication));
assertThat(api().getAllReferences().stream()).isNotEmpty();
}
}

protected abstract ResourceOwnerEmulator newResourceOwner(GrantType grantType) throws IOException;

/**
* This test expects the OAuthClient to fail with a 401 UNAUTHORIZED, not Nessie. It is too
Expand All @@ -73,7 +83,6 @@ void testUnauthorized() {

protected Properties clientCredentialsConfig() {
Properties config = new Properties();
config.setProperty("nessie.authentication.oauth2.token-endpoint", tokenEndpoint());
config.setProperty("nessie.authentication.oauth2.grant-type", "client_credentials");
config.setProperty("nessie.authentication.oauth2.client-id", "quarkus-service-app");
config.setProperty("nessie.authentication.oauth2.client-secret", "secret");
Expand All @@ -91,21 +100,22 @@ protected Properties passwordConfig() {
protected Properties authorizationCodeConfig() {
Properties config = clientCredentialsConfig();
config.setProperty("nessie.authentication.oauth2.grant-type", "authorization_code");
config.setProperty("nessie.authentication.oauth2.auth-endpoint", authEndpoint());
config.setProperty("nessie.authentication.oauth2.auth-code-flow.web-port", "8989");
return config;
}

protected Properties deviceCodeConfig() {
Properties config = clientCredentialsConfig();
config.setProperty("nessie.authentication.oauth2.grant-type", "device_code");
return config;
}

protected Properties wrongPasswordConfig() {
Properties config = passwordConfig();
config.setProperty("nessie.authentication.oauth2.password", "WRONG");
return config;
}

protected abstract String tokenEndpoint();

protected abstract String authEndpoint();

protected NessieAuthentication oauth2Authentication(Properties config) {
return new OAuth2AuthenticationProvider().build(config::getProperty);
}
Expand Down

0 comments on commit 08640f6

Please sign in to comment.