Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth2 client: support for Device Code Flow #7899

Merged
merged 1 commit into from
Dec 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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