Skip to content

Commit

Permalink
OAuth2 client: support for Device Code Flow
Browse files Browse the repository at this point in the history
  • Loading branch information
adutra committed Dec 29, 2023
1 parent db922a0 commit 7116f4c
Show file tree
Hide file tree
Showing 25 changed files with 1,601 additions and 380 deletions.
24 changes: 17 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,23 @@ as necessary. Empty sections will not end in the release notes.

### Highlights

- Nessie client now supports the Authorization Code flow when using OAuth 2 authentication. This
allows the client to be used with identity providers that do not support others flows. To use this
flow, the Nessie client must be configured to use the `authorization_code` grant type. See the
Nessie client documentation for details.
- Nessie client now supports endpoint discovery when using OAuth 2 authentication. If an identity
provider supports the OpenID Connect Discovery mechanism, the Nessie client can be configured to
use it to discover the OAuth 2 endpoints. See the Nessie client documentation for details.
- The Nessie client supports two new authentication flows when using OAuth 2 authentication:
the Authorization Code flow and the Device Code flow. These flows are well suited for use within
a command line program, such as a Spark SQL shell, where a user is interacting with Nessie using a
terminal. In these flows, the user must use their web browser to authenticate with the identity
provider. See the
[Nessie documentation](https://projectnessie.org/tools/client_config/#authentication-settings)
for details. The two new flows are enabled by the following new grant types:
- `authorization_code`: enables the Authorization Code flow; this flow can only be used with
a local shell session running on the user's machine.
- `device_code`: enables the Device Code flow; this flow can be used with either a local or a
remote shell session.

- The Nessie client now supports endpoint discovery when using OAuth 2 authentication. If an
identity provider supports the OpenID Connect Discovery mechanism, the Nessie client can be
configured to use it to discover the OAuth 2 endpoints. See the
[Nessie documentation](https://projectnessie.org/tools/client_config/#authentication-settings)
for details.

### Upgrade notes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@
*/
package org.projectnessie.client.auth.oauth2;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.projectnessie.client.auth.oauth2.GrantType.AUTHORIZATION_CODE;
import static org.projectnessie.client.auth.oauth2.GrantType.DEVICE_CODE;
import static org.projectnessie.client.auth.oauth2.GrantType.PASSWORD;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import dasniko.testcontainers.keycloak.KeycloakContainer;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.net.URI;
import java.time.Duration;
Expand Down Expand Up @@ -70,16 +75,16 @@ public class ITOAuth2Client {
private static URI issuerUrl;
private static URI tokenEndpoint;
private static URI authEndpoint;
private static URI deviceAuthEndpoint;

@InjectSoftAssertions private SoftAssertions soft;

@BeforeAll
static void setUpKeycloak() {
issuerUrl = URI.create(KEYCLOAK.getAuthServerUrl() + "/realms/master");
tokenEndpoint =
URI.create(KEYCLOAK.getAuthServerUrl() + "/realms/master/protocol/openid-connect/token");
authEndpoint =
URI.create(KEYCLOAK.getAuthServerUrl() + "/realms/master/protocol/openid-connect/auth");
issuerUrl = URI.create(KEYCLOAK.getAuthServerUrl() + "/realms/master/");
tokenEndpoint = issuerUrl.resolve("protocol/openid-connect/token");
authEndpoint = issuerUrl.resolve("protocol/openid-connect/auth");
deviceAuthEndpoint = issuerUrl.resolve("protocol/openid-connect/auth/device");
Keycloak keycloakAdmin = KEYCLOAK.getKeycloakAdminClient();
master = keycloakAdmin.realms().realm("master");
updateMasterRealm(10, 15);
Expand Down Expand Up @@ -113,16 +118,18 @@ static void setUpKeycloak() {
@Test
void testOAuth2ClientWithBackgroundRefresh() throws Exception {
OAuth2ClientConfig config1 = clientConfig("Client1", false).build();
OAuth2ClientConfig config2 =
clientConfig("Client2", false).grantType(GrantType.PASSWORD).build();
OAuth2ClientConfig config2 = clientConfig("Client2", false).grantType(PASSWORD).build();
OAuth2ClientConfig config3 =
clientConfig("Client2", false).grantType(GrantType.AUTHORIZATION_CODE).build();
clientConfig("Client2", false).grantType(AUTHORIZATION_CODE).build();
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
try (OAuth2Client client1 = new OAuth2Client(config1);
OAuth2Client client2 = new OAuth2Client(config2);
OAuth2Client client3 = new OAuth2Client(config3);
ResourceOwnerEmulator resourceOwner = new ResourceOwnerEmulator("Alice", "s3cr3t");
ResourceOwnerEmulator resourceOwner =
new ResourceOwnerEmulator(AUTHORIZATION_CODE, "Alice", "s3cr3t");
HttpClient validatingClient = validatingHttpClient("Client1").build()) {
resourceOwner.replaceSystemOut();
resourceOwner.setAuthServerBaseUri(URI.create(KEYCLOAK.getAuthServerUrl()));
resourceOwner.setErrorListener(e -> executor.shutdownNow());
client1.start();
client2.start();
Expand Down Expand Up @@ -155,16 +162,16 @@ void testOAuth2ClientWithBackgroundRefresh() throws Exception {
*
* <ul>
* <li>endpoint discovery on;
* <li>client_credentials, password or authorization_code grant type for obtaining the initial
* access token;
* <li>client_credentials, password, authorization_code or device_code grant type for obtaining
* the initial access token;
* <li>refresh token sent on the initial response;
* <li>refresh_token grant type for refreshing the access token.
* </ul>
*/
@ParameterizedTest
@EnumSource(
value = GrantType.class,
names = {"CLIENT_CREDENTIALS", "PASSWORD", "AUTHORIZATION_CODE"})
names = {"CLIENT_CREDENTIALS", "PASSWORD", "AUTHORIZATION_CODE", "DEVICE_CODE"})
void testOAuth2ClientInitialRefreshToken(GrantType initialGrantType) throws Exception {
OAuth2ClientConfig config = clientConfig("Client2", true).grantType(initialGrantType).build();
try (OAuth2Client client = new OAuth2Client(config);
Expand Down Expand Up @@ -270,10 +277,7 @@ void testOAuth2ClientUnauthorizedBadClientSecret() {
@Test
void testOAuth2ClientUnauthorizedBadPassword() {
OAuth2ClientConfig config =
clientConfig("Client2", false)
.grantType(GrantType.PASSWORD)
.password("BAD PASSWORD")
.build();
clientConfig("Client2", false).grantType(PASSWORD).password("BAD PASSWORD").build();
try (OAuth2Client client = new OAuth2Client(config)) {
client.start();
soft.assertThatThrownBy(client::authenticate)
Expand All @@ -286,9 +290,12 @@ void testOAuth2ClientUnauthorizedBadPassword() {
@Test
void testOAuth2ClientUnauthorizedBadAuthorizationCode() throws Exception {
OAuth2ClientConfig config =
clientConfig("Client2", false).grantType(GrantType.AUTHORIZATION_CODE).build();
clientConfig("Client2", false).grantType(AUTHORIZATION_CODE).build();
try (OAuth2Client client = new OAuth2Client(config);
ResourceOwnerEmulator resourceOwner = new ResourceOwnerEmulator("Alice", "s3cr3t")) {
ResourceOwnerEmulator resourceOwner =
new ResourceOwnerEmulator(AUTHORIZATION_CODE, "Alice", "s3cr3t")) {
resourceOwner.replaceSystemOut();
resourceOwner.setAuthServerBaseUri(URI.create(KEYCLOAK.getAuthServerUrl()));
resourceOwner.setErrorListener(e -> client.close());
resourceOwner.overrideAuthorizationCode("BAD_CODE", Status.UNAUTHORIZED);
client.start();
Expand All @@ -299,6 +306,25 @@ void testOAuth2ClientUnauthorizedBadAuthorizationCode() throws Exception {
}
}

@Test
void testOAuth2ClientDeviceCodeAccessDenied() throws Exception {
OAuth2ClientConfig config = clientConfig("Client2", false).grantType(DEVICE_CODE).build();
try (OAuth2Client client = new OAuth2Client(config);
ResourceOwnerEmulator resourceOwner =
new ResourceOwnerEmulator(DEVICE_CODE, "Alice", "s3cr3t")) {
resourceOwner.replaceSystemOut();
resourceOwner.setAuthServerBaseUri(URI.create(KEYCLOAK.getAuthServerUrl()));
resourceOwner.setErrorListener(e -> client.close());
resourceOwner.denyConsent();
client.start();
soft.assertThatThrownBy(client::authenticate)
.asInstanceOf(type(OAuth2Exception.class))
.extracting(OAuth2Exception::getStatus, OAuth2Exception::getErrorCode)
.containsExactly(
Status.BAD_REQUEST, "access_denied"); // Keycloak replies with 400 instead of 401
}
}

@Test
void testOAuth2ClientExpiredToken() {
OAuth2ClientConfig config = clientConfig("Client1", false).build();
Expand Down Expand Up @@ -368,11 +394,18 @@ private static OAuth2ClientConfig.Builder clientConfig(String clientId, boolean
.scope("openid")
.defaultAccessTokenLifespan(Duration.ofSeconds(10))
.defaultRefreshTokenLifespan(Duration.ofSeconds(15))
.refreshSafetyWindow(Duration.ofSeconds(5));
.refreshSafetyWindow(Duration.ofSeconds(5))
// Exercise the code path where Keycloak will request client to slow down
.ignoreDeviceCodeFlowServerPollInterval(true)
.minDeviceCodeFlowPollInterval(Duration.ofSeconds(1))
.deviceCodeFlowPollInterval(Duration.ofSeconds(1));
if (discovery) {
builder.issuerUrl(issuerUrl);
} else {
builder.tokenEndpoint(tokenEndpoint).authEndpoint(authEndpoint);
builder
.tokenEndpoint(tokenEndpoint)
.authEndpoint(authEndpoint)
.deviceAuthEndpoint(deviceAuthEndpoint);
}
return builder;
}
Expand Down Expand Up @@ -406,11 +439,14 @@ private static void createClient(String id, boolean sendRefreshTokenOnClientCred
client.setAttributes(
ImmutableMap.of(
"client_credentials.use_refresh_token",
String.valueOf(sendRefreshTokenOnClientCredentialsRequest)));
String.valueOf(sendRefreshTokenOnClientCredentialsRequest),
"oauth2.device.authorization.grant.enabled",
"true"));
ResourceServerRepresentation settings = new ResourceServerRepresentation();
settings.setPolicyEnforcementMode(PolicyEnforcementMode.DISABLED);
client.setAuthorizationSettings(settings);
master.clients().create(client);
Response response = master.clients().create(client);
assertThat(response.getStatus()).isEqualTo(201);
}

@SuppressWarnings("resource")
Expand All @@ -423,7 +459,8 @@ private static void createUser() {
credential.setTemporary(false);
user.setCredentials(ImmutableList.of(credential));
user.setEnabled(true);
master.users().create(user);
Response response = master.users().create(user);
assertThat(response.getStatus()).isEqualTo(201);
}

private static HttpClient.Builder validatingHttpClient(String clientId) {
Expand All @@ -446,6 +483,8 @@ private static Class<?> expectedResponseClass(GrantType initialGrantType) {
return PasswordTokensResponse.class;
case AUTHORIZATION_CODE:
return AuthorizationCodeTokensResponse.class;
case DEVICE_CODE:
return DeviceCodeTokensResponse.class;
default:
throw new IllegalArgumentException("Unexpected initial grant type: " + initialGrantType);
}
Expand All @@ -458,8 +497,12 @@ private AutoCloseable newTestSetup(GrantType initialGrantType, OAuth2Client clie
case PASSWORD:
return () -> {};
case AUTHORIZATION_CODE:
ResourceOwnerEmulator resourceOwner = new ResourceOwnerEmulator("Alice", "s3cr3t");
case DEVICE_CODE:
ResourceOwnerEmulator resourceOwner =
new ResourceOwnerEmulator(initialGrantType, "Alice", "s3cr3t");
resourceOwner.replaceSystemOut();
resourceOwner.setErrorListener(e -> client.close());
resourceOwner.setAuthServerBaseUri(URI.create(KEYCLOAK.getAuthServerUrl()));
return resourceOwner;
default:
throw new IllegalArgumentException("Unexpected initial grant type: " + initialGrantType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ public final class NessieConfigConstants {
public static final String CONF_NESSIE_OAUTH2_AUTH_ENDPOINT =
"nessie.authentication.oauth2.auth-endpoint";

/**
* Config property name ({@value #CONF_NESSIE_OAUTH2_DEVICE_AUTH_ENDPOINT}) for the OAuth2
* authentication provider. The URL of the OAuth2 device authorization endpoint. For Keycloak,
* this is typically {@code
* http://<keycloak-server>/realms/<realm-name>/protocol/openid-connect/auth/device}.
*
* <p>If using the "Device Code" grant type, either this property or {@link
* #CONF_NESSIE_OAUTH2_ISSUER_URL} must be set.
*/
public static final String CONF_NESSIE_OAUTH2_DEVICE_AUTH_ENDPOINT =
"nessie.authentication.oauth2.device-auth-endpoint";

public static final String CONF_NESSIE_OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS =
"client_credentials";

Expand All @@ -101,6 +113,8 @@ public final class NessieConfigConstants {
public static final String CONF_NESSIE_OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE =
"authorization_code";

public static final String CONF_NESSIE_OAUTH2_GRANT_TYPE_DEVICE_CODE = "device_code";

/**
* Config property name ({@value #CONF_NESSIE_OAUTH2_GRANT_TYPE}) for the OAuth2 authentication
* provider. The grant type to use when authenticating against the OAuth2 server. Valid values
Expand All @@ -110,6 +124,7 @@ public final class NessieConfigConstants {
* <li>{@value #CONF_NESSIE_OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS}
* <li>{@value #CONF_NESSIE_OAUTH2_GRANT_TYPE_PASSWORD}
* <li>{@value #CONF_NESSIE_OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE}
* <li>{@value #CONF_NESSIE_OAUTH2_GRANT_TYPE_DEVICE_CODE}
* </ul>
*
* Optional, defaults to {@value #CONF_NESSIE_OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS}.
Expand All @@ -120,7 +135,7 @@ public final class NessieConfigConstants {
*
* <ul>
* <li>{@linkplain #CONF_NESSIE_OAUTH2_TOKEN_ENDPOINT token endpoint} or {@linkplain
* #CONF_NESSIE_OAUTH2_ISSUER_URL discovery endpoint}
* #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_ID client ID}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_SECRET client secret}
* </ul>
Expand All @@ -129,7 +144,7 @@ public final class NessieConfigConstants {
*
* <ul>
* <li>{@linkplain #CONF_NESSIE_OAUTH2_TOKEN_ENDPOINT token endpoint} or {@linkplain
* #CONF_NESSIE_OAUTH2_ISSUER_URL discovery endpoint}
* #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_ID client ID}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_SECRET client secret}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_USERNAME username}
Expand All @@ -140,9 +155,20 @@ public final class NessieConfigConstants {
*
* <ul>
* <li>{@linkplain #CONF_NESSIE_OAUTH2_TOKEN_ENDPOINT token endpoint} or {@linkplain
* #CONF_NESSIE_OAUTH2_ISSUER_URL discovery endpoint}
* #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_AUTH_ENDPOINT authorization endpoint} or {@linkplain
* #CONF_NESSIE_OAUTH2_ISSUER_URL discovery endpoint}
* #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_ID client ID}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_SECRET client secret}
* </ul>
*
* <p>For the "device_code" grant type, the following properties must be provided:
*
* <ul>
* <li>{@linkplain #CONF_NESSIE_OAUTH2_TOKEN_ENDPOINT token endpoint} or {@linkplain
* #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_DEVICE_AUTH_ENDPOINT device authorization endpoint} or
* {@linkplain #CONF_NESSIE_OAUTH2_ISSUER_URL issuer URL}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_ID client ID}
* <li>{@linkplain #CONF_NESSIE_OAUTH2_CLIENT_SECRET client secret}
* </ul>
Expand Down Expand Up @@ -297,6 +323,30 @@ public final class NessieConfigConstants {

public static final String DEFAULT_AUTHORIZATION_CODE_FLOW_TIMEOUT = "PT5M";

/**
* Config property name ({@value #CONF_NESSIE_OAUTH2_DEVICE_CODE_FLOW_TIMEOUT}) for the OAuth2
* authentication provider. How long the client should wait for the device code flow to complete.
* This is only used if the grant type to use is {@value
* #CONF_NESSIE_OAUTH2_GRANT_TYPE_DEVICE_CODE}. Optional, defaults to {@value
* #DEFAULT_DEVICE_CODE_FLOW_TIMEOUT}.
*/
public static final String CONF_NESSIE_OAUTH2_DEVICE_CODE_FLOW_TIMEOUT =
"nessie.authentication.oauth2.device-code-flow.timeout";

public static final String DEFAULT_DEVICE_CODE_FLOW_TIMEOUT = "PT5M";

/**
* Config property name ({@value #CONF_NESSIE_OAUTH2_DEVICE_CODE_FLOW_POLL_INTERVAL}) for the
* OAuth2 authentication provider. How often the client should poll the OAuth2 server for the
* device code flow to complete. This is only used if the grant type to use is {@value
* #CONF_NESSIE_OAUTH2_GRANT_TYPE_DEVICE_CODE}. Optional, defaults to {@value
* #DEFAULT_DEVICE_CODE_FLOW_POLL_INTERVAL}.
*/
public static final String CONF_NESSIE_OAUTH2_DEVICE_CODE_FLOW_POLL_INTERVAL =
"nessie.authentication.oauth2.device-code-flow.poll-interval";

public static final String DEFAULT_DEVICE_CODE_FLOW_POLL_INTERVAL = "PT5S";

/**
* Config property name ({@value #CONF_NESSIE_AWS_REGION}) for the region used for AWS
* authentication.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,17 +131,17 @@ public Tokens fetchNewTokens() {
try {
return tokensFuture.get(flowTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
LOGGER.error(MSG_PREFIX + "Timed out waiting for authorization code.");
LOGGER.error("Timed out waiting for authorization code.");
abort();
throw new RuntimeException(e);
throw new RuntimeException("Timed out waiting waiting for authorization code", e);
} catch (InterruptedException e) {
abort();
Thread.currentThread().interrupt();
throw new RuntimeException(e);
} catch (ExecutionException e) {
abort();
Throwable cause = e.getCause();
LOGGER.error(MSG_PREFIX + "Authentication failed: " + cause.getMessage());
LOGGER.error("Authentication failed: " + cause.getMessage());
if (cause instanceof HttpClientException) {
throw (HttpClientException) cause;
}
Expand Down

0 comments on commit 7116f4c

Please sign in to comment.