Skip to content

Commit

Permalink
[WIP] OAuth2 client: introduce proper support for token exchange
Browse files Browse the repository at this point in the history
  • Loading branch information
adutra committed Jun 12, 2024
1 parent 3b181c9 commit 2baf934
Show file tree
Hide file tree
Showing 12 changed files with 784 additions and 52 deletions.
25 changes: 18 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ as necessary. Empty sections will not end in the release notes.
on our web site projectnessie.org for more information.
- CEL access check scripts now receive the variable `roles` that can be used to check whether the current
principal has a role assigned using a CEL expression like `'rolename' in roles`.
- Support for token exchange in the Nessie client has been completely redesigned. The new API and
configuration options are described in the [Nessie authentication settings]. If this feature is
enabled, each time a new access token is obtained, the client will exchange it for another one by
performing a token exchange with the authorization server. We hope that this new feature will
unlock many advanced use cases for Nessie users, such as impersonation and delegation. Please note
that token exchange is considered in beta state and both the API and configuration options are
subject to change at any time; we appreciate early feedback, comments and suggestions.

[Nessie authentication settings]: https://projectnessie.org/tools/client_config/#authentication-settings

### Changes

Expand All @@ -41,13 +50,15 @@ as necessary. Empty sections will not end in the release notes.
specified explicitly in the JDBC URL.
- `nessie.version.store.persist.jdbc.catalog`
- `nessie.version.store.persist.jdbc.schema`
- Support for token exchange in the Nessie client, in its current form, is now deprecated. The token
exchange flow was being used to exchange a refresh token for an access token, but this is not its
recommended usage. From now on, if a refresh token is provided by the authorization server, the
Nessie client will use the `refresh_token` grant type to obtain a new access token; if a refresh
token is not provided, the client will use the configured initial grant type to fetch a new access
token. _This change should thus be transparent to all users._ The `token_exchange` flow will be
redesigned in a future release to support more advanced use cases.
- As mentioned above, token exchange in the Nessie client has been completely redesigned. As part of
this refactoring, _token exchange is not being used anymore to refresh tokens, because this is not
its recommended usage_. As a consequence, the
`nessie.authentication.oauth2.token-exchange-enabled` configuration property is now deprecated and
has no effect; it will be removed soon. From now on, if a refresh token is provided by the
authorization server, the Nessie client will always use the `refresh_token` grant type to obtain a
new access token; if a refresh token is not provided, the client will always fall back to the
configured initial grant type in order to fetch a new access token. _This change should thus be
transparent to all users._

### Fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,136 @@ public final class NessieConfigConstants {
*/
@Deprecated
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ENABLED =
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ENABLED_OLD =
"nessie.authentication.oauth2.token-exchange-enabled";

/**
* Enable OAuth2 token exchange. If enabled, each access token obtained from the OAuth2 server
* will be exchanged for a new token, using the token endpoint and the token exchange grant type,
* as defined in <a href="https://datatracker.ietf.org/doc/html/rfc8693">RFC 8693</a>.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ENABLED =
"nessie.authentication.oauth2.token-exchange.enabled";

/**
* For token exchanges only. The root URL of an alternate OpenID Connect identity issuer provider,
* to use when exchanging tokens only.
*
* <p>If neither this property nor {@value #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ISSUER_URL} are
* defined, the global token endpoint will be used. This means that the same authorization server
* will be used for both the initial token request and the token exchange.
*
* <p>Endpoint discovery is performed using the OpenID Connect Discovery metadata published by the
* issuer. See <a href="https://openid.net/specs/openid-connect-discovery-1_0.html">OpenID Connect
* Discovery 1.0</a> for more information.
*
* @see NessieConfigConstants#CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ISSUER_URL
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ISSUER_URL =
"nessie.authentication.oauth2.token-exchange.issuer-url";

/**
* For token exchanges only. The URL of an alternate OAuth2 token endpoint to use when exchanging
* tokens only.
*
* <p>If neither this property nor {@value #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ISSUER_URL} are
* defined, the global token endpoint will be used. This means that the same authorization server
* will be used for both the initial token request and the token exchange.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_TOKEN_ENDPOINT =
"nessie.authentication.oauth2.token-exchange.token-endpoint";

/**
* For token exchanges only. An alternate client ID to use. If not provided, the global client ID
* will be used. If provided, and if the client is confidential, then its secret must be provided
* as well with {@value #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_CLIENT_SECRET} – the global client
* secret will NOT be used.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_CLIENT_ID =
"nessie.authentication.oauth2.token-exchange.client-id";

/**
* For token exchanges only. The client secret to use, if {@value
* #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_CLIENT_ID} is defined and the token exchange client is
* confidential.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_CLIENT_SECRET =
"nessie.authentication.oauth2.token-exchange.client-secret";

/**
* For token exchanges only. A URI that indicates the target service or resource where the client
* intends to use the requested security token. Optional.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_RESOURCE =
"nessie.authentication.oauth2.token-exchange.resource";

/**
* For token exchanges only. The logical name of the target service where the client intends to
* use the requested security token. This serves a purpose similar to the resource parameter but
* with the client providing a logical name for the target service.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_AUDIENCE =
"nessie.authentication.oauth2.token-exchange.audience";

/**
* For token exchanges only. Space-separated list of scopes to include in each token exchange
* request to the OAuth2 server. Optional. If undefined, the global scopes configured through
* {@link #CONF_NESSIE_OAUTH2_CLIENT_SCOPES} will be used. If defined and null or empty, no scopes
* will be used.
*
* <p>The scope names will not be validated by the Nessie client; make sure they are valid
* according to <a href="https://datatracker.ietf.org/doc/html/rfc6749#section-3.3">RFC 6749
* Section 3.3</a>.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_SCOPES =
"nessie.authentication.oauth2.token-exchange.scopes";

/**
* For token exchanges only. The subject token to exchange.
*
* <p>By default, the client will use its current access token as the subject token. But if this
* property is set, the client will use the static token provided here instead.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_SUBJECT_TOKEN =
"nessie.authentication.oauth2.token-exchange.subject-token";

/**
* For token exchanges only. The type of the subject token. By default, {@code
* urn:ietf:params:oauth:token-type:access_token}. Only used if {@value
* #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_SUBJECT_TOKEN} is defined, ignored otherwise.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE =
"nessie.authentication.oauth2.token-exchange.subject-token-type";

/**
* For token exchanges only. The actor token to exchange.
*
* <p>By default, the client will not use an actor token. But if this property is set, the client
* will use the static token provided here as the actor token.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ACTOR_TOKEN =
"nessie.authentication.oauth2.token-exchange.actor-token";

/**
* For token exchanges only. The type of the actor token. By default, {@code
* urn:ietf:params:oauth:token-type:access_token}. Only used if {@value
* #CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ACTOR_TOKEN} is defined, ignored otherwise.
*/
@ConfigItem(section = "OAuth2 Authentication")
public static final String CONF_NESSIE_OAUTH2_TOKEN_EXCHANGE_ACTOR_TOKEN_TYPE =
"nessie.authentication.oauth2.token-exchange.actor-token-type";

/**
* Port of the OAuth2 authorization code flow web server.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,9 @@
*/
package org.projectnessie.client.auth.oauth2;

import static org.projectnessie.client.auth.oauth2.OAuth2ClientUtils.tokenExpirationTime;

import java.net.URI;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.projectnessie.client.http.HttpAuthentication;
import org.projectnessie.client.http.HttpRequest;
import org.projectnessie.client.http.HttpResponse;

Expand Down Expand Up @@ -57,37 +55,51 @@ abstract class AbstractFlow implements Flow {

<REQ extends TokensRequestBase, RESP extends TokensResponseBase> Tokens invokeTokenEndpoint(
TokensRequestBase.Builder<REQ> request, Class<? extends RESP> responseClass) {
config.getScope().ifPresent(request::scope);
getScope().ifPresent(request::scope);
maybeAddClientId(request);
return invokeEndpoint(config.getResolvedTokenEndpoint(), request.build(), responseClass)
return invokeEndpoint(getResolvedTokenEndpoint(), request.build(), responseClass)
.asTokens(config.getClock());
}

DeviceCodeResponse invokeDeviceAuthEndpoint() {
DeviceCodeRequest.Builder request = DeviceCodeRequest.builder();
config.getScope().ifPresent(request::scope);
getScope().ifPresent(request::scope);
maybeAddClientId(request);
return invokeEndpoint(
config.getResolvedDeviceAuthEndpoint(), request.build(), DeviceCodeResponse.class);
}

Optional<String> getScope() {
return config.getScope();
}

URI getResolvedTokenEndpoint() {
return config.getResolvedTokenEndpoint();
}

String getClientId() {
return config.getClientId();
}

boolean isPublicClient() {
return config.isPublicClient();
}

Optional<HttpAuthentication> getBasicAuthentication() {
return config.getBasicAuthentication();
}

private void maybeAddClientId(Object request) {
if (config.isPublicClient() && request instanceof PublicClientRequest.Builder) {
((PublicClientRequest.Builder<?>) request).clientId(config.getClientId());
if (isPublicClient() && request instanceof PublicClientRequest.Builder) {
((PublicClientRequest.Builder<?>) request).clientId(getClientId());
}
}

private <REQ, RESP> RESP invokeEndpoint(
URI endpoint, REQ request, Class<? extends RESP> responseClass) {
HttpRequest req = config.getHttpClient().newRequest(endpoint);
config.getBasicAuthentication().ifPresent(req::authentication);
getBasicAuthentication().ifPresent(req::authentication);
HttpResponse response = req.postForm(request);
return response.readEntity(responseClass);
}

protected boolean isAboutToExpire(Token token, Duration defaultLifespan) {
Instant now = config.getClock().get();
Instant expirationTime = tokenExpirationTime(now, token, defaultLifespan);
return expirationTime.isBefore(now.plus(config.getRefreshSafetyWindow()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ static OAuth2AuthenticatorConfig fromConfigSupplier(Function<String, String> con
CONF_NESSIE_OAUTH2_DEVICE_CODE_FLOW_POLL_INTERVAL,
builder::deviceCodeFlowPollInterval,
Duration::parse);
TokenExchangeConfig tokenExchangeConfig = TokenExchangeConfig.fromConfigSupplier(config);
builder.tokenExchangeConfig(tokenExchangeConfig);
return builder.build();
}

Expand Down Expand Up @@ -227,6 +229,12 @@ default boolean getTokenExchangeEnabled() {
return true;
}

/** The token exchange configuration. Optional. */
@Value.Default
default TokenExchangeConfig getTokenExchangeConfig() {
return TokenExchangeConfig.DISABLED;
}

/**
* The default access token lifespan. Optional, defaults to {@link
* NessieConfigConstants#DEFAULT_DEFAULT_ACCESS_TOKEN_LIFESPAN}.
Expand Down Expand Up @@ -407,9 +415,10 @@ default Builder password(String password) {

@Deprecated
@CanIgnoreReturnValue
default Builder tokenExchangeEnabled(boolean tokenExchangeEnabled) {
return this;
}
Builder tokenExchangeEnabled(boolean tokenExchangeEnabled);

@CanIgnoreReturnValue
Builder tokenExchangeConfig(TokenExchangeConfig tokenExchangeConfig);

@CanIgnoreReturnValue
Builder defaultAccessTokenLifespan(Duration defaultAccessTokenLifespan);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*/
package org.projectnessie.client.auth.oauth2;

import static org.projectnessie.client.auth.oauth2.OAuth2ClientUtils.tokenExpirationTime;

import java.io.Closeable;
import java.time.Duration;
import java.time.Instant;
Expand Down Expand Up @@ -73,7 +75,10 @@ class OAuth2Client implements OAuth2Authenticator, Closeable {
config.getGrantType().requiresUserInteraction()
? CompletableFuture.allOf(started, used)
: started;
currentTokensStage = ready.thenApplyAsync((v) -> fetchNewTokens(), executor);
currentTokensStage =
ready
.thenApplyAsync((v) -> fetchNewTokens(), executor)
.thenApply(this::maybeExchangeTokens);
currentTokensStage
.whenComplete((tokens, error) -> log(error))
.whenComplete((tokens, error) -> maybeScheduleTokensRenewal(tokens));
Expand Down Expand Up @@ -217,7 +222,8 @@ private void renewTokens() {
// try refreshing the current tokens (if they exist)
.thenApply(this::refreshTokens)
// if that fails, of if tokens weren't available, try fetching brand-new tokens
.exceptionally(error -> fetchNewTokens());
.exceptionally(error -> fetchNewTokens())
.thenApply(this::maybeExchangeTokens);
currentTokensStage
.whenComplete((tokens, error) -> log(error))
.whenComplete((tokens, error) -> maybeScheduleTokensRenewal(tokens));
Expand Down Expand Up @@ -253,12 +259,31 @@ Tokens refreshTokens(Tokens currentTokens) {
if (currentTokens.getRefreshToken() == null) {
throw new MustFetchNewTokensException("No refresh token available");
}
if (isAboutToExpire(currentTokens.getRefreshToken(), config.getDefaultRefreshTokenLifespan())) {
throw new MustFetchNewTokensException("Refresh token is about to expire");
}
LOGGER.debug("[{}] Refreshing tokens", config.getClientName());
try (Flow flow = GrantType.REFRESH_TOKEN.newFlow(config)) {
return flow.fetchNewTokens(currentTokens);
}
}

Tokens maybeExchangeTokens(Tokens currentTokens) {
if (config.getTokenExchangeConfig().isEnabled()) {
LOGGER.debug("[{}] Exchanging tokens", config.getClientName());
try (Flow flow = GrantType.TOKEN_EXCHANGE.newFlow(config)) {
return flow.fetchNewTokens(currentTokens);
}
}
return currentTokens;
}

private boolean isAboutToExpire(Token token, Duration defaultLifespan) {
Instant now = config.getClock().get();
Instant expirationTime = tokenExpirationTime(now, token, defaultLifespan);
return expirationTime.isBefore(now.plus(config.getRefreshSafetyWindow()));
}

/**
* Compute when the next token refresh should happen, depending on when the access token and the
* refresh token expire, and on the current time.
Expand All @@ -268,10 +293,10 @@ private Duration nextTokenRefresh(Tokens currentTokens, Instant now, Duration mi
return minRefreshDelay;
}
Instant accessExpirationTime =
OAuth2ClientUtils.tokenExpirationTime(
tokenExpirationTime(
now, currentTokens.getAccessToken(), config.getDefaultAccessTokenLifespan());
Instant refreshExpirationTime =
OAuth2ClientUtils.tokenExpirationTime(
tokenExpirationTime(
now, currentTokens.getRefreshToken(), config.getDefaultRefreshTokenLifespan());
return OAuth2ClientUtils.shortestDelay(
now,
Expand Down
Loading

0 comments on commit 2baf934

Please sign in to comment.