diff --git a/api/client/src/testFixtures/java/org/projectnessie/client/auth/oauth2/ResourceOwnerEmulator.java b/api/client/src/testFixtures/java/org/projectnessie/client/auth/oauth2/ResourceOwnerEmulator.java index e631b66ec4e..d5bc069d7cb 100644 --- a/api/client/src/testFixtures/java/org/projectnessie/client/auth/oauth2/ResourceOwnerEmulator.java +++ b/api/client/src/testFixtures/java/org/projectnessie/client/auth/oauth2/ResourceOwnerEmulator.java @@ -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 = @@ -87,6 +95,7 @@ public static ResourceOwnerEmulator forAuthorizationCode() throws IOException { private volatile URI baseUri; private volatile Consumer authUrlListener; private volatile Consumer errorListener; + private volatile Runnable flowCompletionListener; private volatile String authorizationCode; private volatile int expectedCallbackStatus = HTTP_OK; private volatile boolean denyConsent = false; @@ -138,6 +147,10 @@ public void setAuthUrlListener(Consumer callback) { this.authUrlListener = callback; } + public void setFlowCompletionListener(Runnable listener) { + this.flowCompletionListener = listener; + } + private void readConsole() { try { String line; @@ -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); } @@ -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); } @@ -324,7 +345,7 @@ private void authorizeDevice(URL consentPageUrl, Set 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) { @@ -344,12 +365,8 @@ 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. - * - *

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; @@ -357,9 +374,8 @@ private HttpURLConnection openConnection(URL url) throws IOException { 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; } diff --git a/servers/quarkus-server/src/intTest/java/org/projectnessie/server/ITOAuth2Authentication.java b/servers/quarkus-server/src/intTest/java/org/projectnessie/server/ITOAuth2Authentication.java index b1f5c73bc94..0edf1b34fa2 100644 --- a/servers/quarkus-server/src/intTest/java/org/projectnessie/server/ITOAuth2Authentication.java +++ b/servers/quarkus-server/src/intTest/java/org/projectnessie/server/ITOAuth2Authentication.java @@ -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; @@ -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()); diff --git a/servers/quarkus-server/src/test/java/org/projectnessie/server/TestOAuth2Authentication.java b/servers/quarkus-server/src/test/java/org/projectnessie/server/TestOAuth2Authentication.java index 1709600a97c..2ef298fa7ef 100644 --- a/servers/quarkus-server/src/test/java/org/projectnessie/server/TestOAuth2Authentication.java +++ b/servers/quarkus-server/src/test/java/org/projectnessie/server/TestOAuth2Authentication.java @@ -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; @@ -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; @@ -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()); @@ -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"); @@ -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( @@ -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("Enter device code:"))); + 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("Device code received!"))); + // 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( @@ -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; } diff --git a/servers/quarkus-server/src/testFixtures/java/org/projectnessie/server/AbstractOAuth2Authentication.java b/servers/quarkus-server/src/testFixtures/java/org/projectnessie/server/AbstractOAuth2Authentication.java index 1a9ee666163..1e88f714a92 100644 --- a/servers/quarkus-server/src/testFixtures/java/org/projectnessie/server/AbstractOAuth2Authentication.java +++ b/servers/quarkus-server/src/testFixtures/java/org/projectnessie/server/AbstractOAuth2Authentication.java @@ -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; @@ -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 @@ -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"); @@ -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); }