Repository navigation
Conversation
|
FYI the full changes can be reviewed here for reference: #15704 . |
6be123b to
706008b
Compare
| String TOKEN_EXCHANGE_ENABLED = PREFIX + "token-exchange-enabled"; | ||
| String ACCESS_TOKEN_LIFESPAN = PREFIX + "access-token-lifespan"; | ||
| String SAFETY_MARGIN = PREFIX + "safety-margin"; | ||
|
|
There was a problem hiding this comment.
Taking the example of long-running Spark jobs against an Iceberg REST catalog backed by Azure Entra ID.
These jobs require per-user identity (user-delegated access), not a shared service account (client_credentials flow).
As a first step, we can have the users handle the Authorization Code + PKCE flow externally (via a CLI wrapper) and obtain an access token + refresh token pair before submitting the job.
Currently there's no way to pass that refresh token to Iceberg. The v1 OAuth2Util.refreshToken() only supports token exchange or client credentials re-auth. In the v2 config, TokenRefreshConfig controls refresh behavior but has no property to seed an external refresh token.
The flow would be:
- External tooling handles interactive auth (PKCE, device code, etc.) before job submission
- User configures rest.auth.oauth2.token and rest.auth.oauth2.refresh-token
- Iceberg uses the access token until near-expiry, then calls the token endpoint with grant_type=refresh_token
- Subsequent refreshes use the rotating refresh token from each response
Should we consider adding a config property like rest.auth.oauth2.refresh-token that provides an initial refresh token to RefreshTokenFlow, enabling grant_type=refresh_token without Iceberg needing to implement the interactive PKCE flow (which can be considered in a future update)?
We can support it in later PR as well after this, but want to understand if it already works , or if it makes sense to try supporting this case ?
There was a problem hiding this comment.
Currently there's no way to pass that refresh token to Iceberg.
That is a Bad Idea ™️ 😄
Refresh tokens are specific to the clients they were issued to, and are not meant to be shared. Sharing refresh tokens is not only a potential security issue, but also doesn't work in practice: most IDPs revoke the refresh token once used, so if the catalog is given a refresh token and shares it with all the FileIO instances, only the first refresh would succeed; all others would fail because the token was revoked.
Instead, the Iceberg catalog must be autonomous in terms of OAuth2 tokens: ideally, each component (the catalog, the SessionContext, the FileIO instances, etc.) should manage its token internally.
In AuthManager v2, this will eventually be possible, and the catalog itself will handle the Authorization Code flow. There will be no need for users to do the authorization flow externally.
You may ask: but what if the Authorization Code flow is handled externally (e.g. in Trino)? In this case the best alternative is to use token exchange:
- Trino logs in the user and gets an access token for them;
- Trino instantiates the catalog with a SessionContext configured with the user's access token and the token exchange grant;
- The SessionContext exchanges the user token against a token for itself;
- The SessionContext receives an exchanged access token, and a refresh token.
In this way, the catalog's SessionContext contains an independent token that can be refreshed with its own refresh token. It also doesn't interfere with FileIO instances, because the SessionContext is not shared with them.
| * The initial access token to use. Optional. If this is set, the OAuth2 client will not attempt | ||
| * to fetch an initial token from the Authorization server, but will use this token instead. | ||
| * | ||
| * <p>This option should be avoided as in most cases, the token cannot be refreshed. |
There was a problem hiding this comment.
for users who obtain tokens externally (PKCE, device code), this is the only entry point. As per my other comment, if refresh-token is supported, this option may become viable as well ?
There was a problem hiding this comment.
See my answer to your other comment.
In an ideal world, there should be no reason to pass an access token "as is" to the catalog.
You would instead either:
- Let the catalog fetch the token for you; or
- Exchange an external token for a new token, using the token exchange grant.
Passing a raw access token effectively beats the purpose of using OAuth2, and in most cases, the token cannot be refreshed.
This option only exists to "bridge the gap" until everyone is able to use OAuth2 optimally.
706008b to
eb0e935
Compare
|
This PR has been updated to incorporate the changes from #16081. Since Nimbus is now a dependency of All |
|
|
||
| // OAuth2 | ||
| implementation(libs.nimbus.oauth2.oidc.sdk) { | ||
| exclude group: 'net.minidev', module: 'accessors-smart' |
There was a problem hiding this comment.
These exclusions allow to narrow the amount of transitive dependencies brought by Nimbus:
accessors-smartis an optional bytecode-generation optimizer forjson-smart;json-smartfalls back to reflection without it. Besidesaccessors-smartalso brings ASM, which is a big jar. Removing these is safe because Nimbus objects all implementJSONAware(i.e. they expose a.toJSONString()method) and thus do not need reflection in order to be serialized.jcip-annotationsare compile-time thread-safety markers; they can be safely excluded.
There was a problem hiding this comment.
Does this need another JSON library? How larger are the dependency additions to the runtime Jars?
| * specific language governing permissions and limitations | ||
| * under the License. | ||
| */ | ||
| package org.apache.iceberg.rest.auth.oauth2.config; |
There was a problem hiding this comment.
I don't think we need a config package separate from the parent oauth2. I understand that this provides some structural separation, but it also forces all of these classes into the public, which isn't necessary.
We should move all of these up.
There was a problem hiding this comment.
OK, I understand the concern. I will move everything to a single package and use package-private visibility as the default.
There was a problem hiding this comment.
This will end up with a potentially very large package eventually. But I guess there is no better option.
| validator.validate(); | ||
| } | ||
|
|
||
| static ImmutableBasicConfig.Builder parse(Map<String, String> properties) { |
There was a problem hiding this comment.
This isn't actually parsing, so I'd prefer from(<properties>) as opposed to parse
| * new OAuth2 properties (as declared in {@link OAuth2Config}). | ||
| */ | ||
| @SuppressWarnings("deprecation") | ||
| public final class ConfigMigrator { |
There was a problem hiding this comment.
Minor point here, but this isn't Migrating anything since it's not updating the source based on what I see. This is more of a ConfigAdaptor (or maybe LegacyConfigAdaptor?).
There was a problem hiding this comment.
It takes a map of properties as its input, and outputs a map of properties semantically equivalent, but with all legacy properties migrated to their new counterparts – hence the Migrator suffix.
But if you still prefer LegacyConfigAdaptor, I'm fine with that.
|
|
||
| /** | ||
| * The root URL of the Authorization server, which will be used for discovering supported | ||
| * endpoints and their locations. For Keycloak, this is typically the realm URL: {@code |
There was a problem hiding this comment.
I think we should avoid using specific implementations like keycloak in the documentation. We can add site docs later for how to configure specific OAuth servers, but the javadocs should just stick to the spec/RFC definitions.
| * <li>{@link GrantType#TOKEN_EXCHANGE urn:ietf:params:oauth:grant-type:token-exchange} | ||
| * </ul> | ||
| * | ||
| * Optional, defaults to {@link #DEFAULT_GRANT_TYPE}. |
There was a problem hiding this comment.
How is this Optional? If there's a case where the grant type is unknown, we should enumerate that in the type. If that's not an option, it should be required.
There was a problem hiding this comment.
While the config classes are not considered public API, their javadocs will be used later to create a documentation page in markdown format (I wrote a small tool for that).
For example, this method's javadocs will render as below:
### `rest.auth.oauth2.grant-type`
The grant type to use when authenticating against the OAuth2 server. Valid values are:
- `client_credentials`
- `password`
- `authorization_code`
- `urn:ietf:params:oauth:grant-type:device_code`
- `urn:ietf:params:oauth:grant-type:token-exchange`
Optional, defaults to `client_credentials`.So "optional" here is a way to tell the users that if they don't specify the grant type, it will default to client_credentials.
You will notice that all javadocs of config classes were written with this documentation goal in mind.
There was a problem hiding this comment.
FYI I revisited some of the method javadocs and removed the word "optional" when it was misleading. Let me know if that's better.
|
|
||
| /** The token refresh configuration. Optional. */ | ||
| @Value.Default | ||
| default TokenRefreshConfig tokenRefreshConfig() { |
There was a problem hiding this comment.
We seem to be really inconsistent about how we're handling optional fields. This says optional but isn't wrapped as Optional<TokenRefreshConfig>. This is one of the places where I'd expect to see optional used.
There was a problem hiding this comment.
I rephrased a few method javadocs, lmk if that's better.
| * token, but won't refresh it. Defaults to {@code true}. | ||
| */ | ||
| @Value.Default | ||
| default boolean enabled() { |
There was a problem hiding this comment.
I don't feel like this should be necessary. If there's a refresh config, then you should refresh. If there's not, you don't. I don't see the value in providing a refresh config, but then saying you can't use it.
There was a problem hiding this comment.
But users need to tell if they want token refreshes or not. So there has to be a property for that, namely rest.auth.oauth2.token-refresh.enabled.
Each method in the configuration classes maps 1x1 to a user-facing configuration option.
| * <p>Optional, defaults to {@code true} if the initial grant is {@link | ||
| * GrantType#CLIENT_CREDENTIALS}. | ||
| */ | ||
| Optional<Boolean> tokenExchangeEnabled(); |
There was a problem hiding this comment.
Why do we need optional logic here? Seems like this would only be true or false.
There was a problem hiding this comment.
No, we can't have a static default here: the default is true if the initial grant is client_credentials, and false otherwise. This is awkward indeed, but it's a necessary precaution for people migrating from v1.
| String ACCESS_TOKEN_LIFESPAN = PREFIX + "access-token-lifespan"; | ||
| String SAFETY_MARGIN = PREFIX + "safety-margin"; | ||
|
|
||
| Duration DEFAULT_ACCESS_TOKEN_LIFESPAN = Duration.parse("PT1H"); |
There was a problem hiding this comment.
We shouldn't rely on parsed time durations like this when there's an explicit and more readable option: Duration.ofHours(1);
There was a problem hiding this comment.
That is true, but the documentation tool I wrote will leverage the Duration.parse() method to extract the ISO-8601 litteral and include it in the documentation.
For example, the accessTokenLifespan() method will render as :
### `rest.auth.oauth2.token-refresh.access-token-lifespan`
Default access token lifespan; if the OAuth2 server returns an access token without specifying its expiration time, this value will be used.
Optional, defaults to `PT1H`. Must be a valid [ISO-8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations).| * Default access token lifespan; if the OAuth2 server returns an access token without specifying | ||
| * its expiration time, this value will be used. | ||
| * | ||
| * <p>Optional, defaults to {@link #DEFAULT_ACCESS_TOKEN_LIFESPAN}. Must be a valid <a |
There was a problem hiding this comment.
This doesn't appear to be optional.
There was a problem hiding this comment.
As I explained in my other comment, the word "optional" here is intended for end users.
| } | ||
| } | ||
|
|
||
| static ImmutableTokenRefreshConfig.Builder parse(Map<String, String> properties) { |
There was a problem hiding this comment.
Same here, prefer from as opposed to parse
| * a valid <a href="https://en.wikipedia.org/wiki/ISO_8601#Durations">ISO-8601 duration</a>. | ||
| */ | ||
| @Value.Default | ||
| default Duration safetyMargin() { |
There was a problem hiding this comment.
Naming is a little awkward here. Something more like prefechDuration makes more sense than safety. Also, seeing the impact of this in the wild, we'll need to make sure to include reasonable jitter (refreshes across hundreds or thousands of workers can cause thundering herd problems). Not sure if this is where want that or in the actual fetch code.
There was a problem hiding this comment.
I took note of the jitter requirement. Will include it in the actual fetch code (that will come later).
There was a problem hiding this comment.
Renamed to prefetch() and added jitter(). PTAL.
| import org.apache.iceberg.rest.auth.AuthManager; | ||
| import org.apache.iceberg.rest.auth.AuthSession; | ||
|
|
||
| public class OAuth2Manager implements AuthManager { |
There was a problem hiding this comment.
I'm not convinced it makes sense to introduce this now if there's no implementation.
There was a problem hiding this comment.
OK let's remove and introduce them later on.
|
A few high-level observations:
|
|
This PR will help with some of the use cases we (Workday) face with regards to refresh tokens. How can we help to get this uptaken? |
b166ecd to
9f9e84f
Compare
|
@adutra I'm out through the end of the week, but will look first thing next week. |
| } | ||
|
|
||
| // OAuth2 | ||
| implementation(libs.nimbus.oauth2.oidc.sdk) { |
There was a problem hiding this comment.
I'm not sure how to address the dependency issues, but this pulls in dependencies for the runtime bundles without actually using them yet. Is there an easy way to exclude these until we have an implementation that would actually be used?
There was a problem hiding this comment.
We can use compileOnly for now and switch to implementation later.
| * @param properties The properties to migrate | ||
| * @param catalogUri The catalog URI, for extended token endpoint checks | ||
| */ | ||
| public OAuth2Config migrateCatalogConfig(Map<String, String> properties, String catalogUri) { |
There was a problem hiding this comment.
| public OAuth2Config migrateCatalogConfig(Map<String, String> properties, String catalogUri) { | |
| public OAuth2Config fromCatalogConfig(Map<String, String> properties, String catalogUri) { |
| * @param catalogUri The catalog URI, for extended token endpoint checks | ||
| */ | ||
| @SuppressWarnings("CyclomaticComplexity") | ||
| public OAuth2Config migrateContextualConfig( |
There was a problem hiding this comment.
| public OAuth2Config migrateContextualConfig( | |
| public OAuth2Config fromContextualConfig( |
| import org.apache.iceberg.rest.auth.AuthManager; | ||
| import org.apache.iceberg.rest.auth.AuthSession; | ||
|
|
||
| public class OAuth2Manager implements AuthManager { |
There was a problem hiding this comment.
Is there a reason this neads to be public at this point? If you configure it, it will just throw so it may be best to just keep it package private until there's functionality that can be used.
There was a problem hiding this comment.
I'll do the requested change, but in order for this to work we'll need to use hiddenImpl() in AuthManagers – hope that's OK.
There was a problem hiding this comment.
In fact, given the direction we are taking, and since @rdblue also requested that we revert the deprecations introduced in this PR, I think we should re-center this PR around config classes exclusively.
Let's remove this OAuth2Manager for now, revert the deprecations and revert the changes to the AuthManagers class. This will considerably reduce the PR scope.
| static final String AUTH_MANAGER_IMPL_OAUTH2_NEW = | ||
| "org.apache.iceberg.rest.auth.oauth2.OAuth2Manager"; | ||
| public static final String AUTH_MANAGER_IMPL_OAUTH2 = | ||
| AUTH_MANAGER_IMPL_OAUTH2_LEGACY; // TODO switch to new manager |
There was a problem hiding this comment.
Strong preference to not include TODO, we already call it out LEGACY/NEW, we should drop this comment.
| "access token lifespan must be greater than or equal to PT15S (rest.auth.oauth2.token-refresh.access-token-lifespan)", | ||
| "refresh prefetch must be less than the access token lifespan (rest.auth.oauth2.token-refresh.prefetch / rest.auth.oauth2.token-refresh.access-token-lifespan)", | ||
| "jitter plus prefetch must be less than the access token lifespan (rest.auth.oauth2.token-refresh.jitter / rest.auth.oauth2.token-refresh.prefetch / rest.auth.oauth2.token-refresh.access-token-lifespan)")), | ||
| Arguments.of( | ||
| Map.of(PREFETCH, "PT0.1S"), | ||
| List.of( | ||
| "refresh prefetch must be greater than or equal to PT10S (rest.auth.oauth2.token-refresh.prefetch)")), | ||
| Arguments.of( | ||
| Map.of(PREFETCH, "PT10M", ACCESS_TOKEN_LIFESPAN, "PT5M"), | ||
| List.of( | ||
| "refresh prefetch must be less than the access token lifespan (rest.auth.oauth2.token-refresh.prefetch / rest.auth.oauth2.token-refresh.access-token-lifespan)", | ||
| "jitter plus prefetch must be less than the access token lifespan (rest.auth.oauth2.token-refresh.jitter / rest.auth.oauth2.token-refresh.prefetch / rest.auth.oauth2.token-refresh.access-token-lifespan)")), | ||
| Arguments.of( | ||
| Map.of(JITTER, "PT-1S"), | ||
| List.of("jitter must not be negative (rest.auth.oauth2.token-refresh.jitter)")), | ||
| Arguments.of( | ||
| Map.of(JITTER, "PT1H"), | ||
| List.of( | ||
| "jitter plus prefetch must be less than the access token lifespan (rest.auth.oauth2.token-refresh.jitter / rest.auth.oauth2.token-refresh.prefetch / rest.auth.oauth2.token-refresh.access-token-lifespan)"))); |
There was a problem hiding this comment.
Our convention is that messages should start with a capitol letter. There's a lot of new check/validation code being added and it's unclear to me if we're holding true to the project convention on this.
There was a problem hiding this comment.
All messages generated by ConfigValidator respect that rule. Here is an example error message, where 3 validation failures are found:
Invalid OAuth2 configuration:
- either issuer URL or token endpoint must be set (rest.auth.oauth2.issuer-url / rest.auth.oauth2.token-endpoint)
- client ID must not be empty (rest.auth.oauth2.client-id)
- client secret must not be empty when client authentication is 'client_secret_basic' (rest.auth.oauth2.client-auth / rest.auth.oauth2.client-secret)
| Duration DEFAULT_TIMEOUT = Duration.parse("PT5M"); | ||
| Duration DEFAULT_SESSION_CACHE_TIMEOUT = Duration.parse("PT1H"); | ||
|
|
||
| Duration MIN_TIMEOUT = Duration.parse("PT30S"); |
There was a problem hiding this comment.
Any duration values we have in could should use explicit, compile-time checked APIs (e.g. Duration.ofSeconds(30) vs Duration.parse("PT30S"). It's both more readable and compile-time checked whereas parsed values are not.
| import org.apache.iceberg.relocated.com.google.common.collect.Lists; | ||
| import org.immutables.value.Value; | ||
|
|
||
| final class ConfigValidator { |
There was a problem hiding this comment.
I'm a little concerned about the complexity we're adding here. I understand what you're trying to accomplish by trying to validate all of the properites at once and throw an exception that includes everything that's misconfigured. However, this is a big shift from the conventions used everywhere else in the project and adds a lot of overhead for understanding checks.
Looking at all of the validation logic, it seems like we could just use the standard convention of Preconditions.check everywhere and avoid this. What value is this really adding?
There was a problem hiding this comment.
The added value is standardized error checks (e.g. when checking an endpoint URL) and standardized error messages. We can achieve the same with just Preconditions.check but it's going to be more brittle.
Should I remove ConfigValidator and replace it with Preconditions?
There was a problem hiding this comment.
The project has a standardized way of doing this using either Precondition checks or ValidationException (with the latter being used for more config validation). This is not following that model and introduces more complexity for very little added value.
| * @deprecated will be removed in 1.14.0, use {@link | ||
| * org.apache.iceberg.rest.auth.oauth2.OAuth2Manager} instead. | ||
| */ | ||
| @Deprecated |
There was a problem hiding this comment.
I don't think we should deprecate these in this PR. Before we deprecate, we need to fully replace this. Plus, this is unrelated and can go in a separate PR so this one doesn't require touching so many files. It's a best practice to keep unrelated changes in separate PRs.
There was a problem hiding this comment.
You are right, I shouldn't have mixed deprecations and config classes in the same PR. The revised PR now contains only changes related to configuration and nothing else.
8d1f18b to
e185b26
Compare
|
@danielcweeks and @rdblue I incorporated all your feedback. The PR now is considerably reduced in size (15 files only). PTAL 🙏 |
| } | ||
|
|
||
| // OAuth2 | ||
| // TODO switch from compileOnly to implementation when oauth2 v2 is complete |
There was a problem hiding this comment.
Strong preference to not in include TODOs in the codebase. Even if this is a work-in-progress, it's best to not use the codebase as a tracker.
| Duration DEFAULT_TIMEOUT = Duration.ofMinutes(5); | ||
| Duration DEFAULT_SESSION_CACHE_TIMEOUT = Duration.ofHours(1); | ||
|
|
||
| Duration MIN_TIMEOUT = Duration.ofSeconds(30); |
There was a problem hiding this comment.
Is there really a practical reason to expose configurable timeouts like this? We're introducing more config complexity and the library defaults should be adequate for OAuth. Does Nimbus not have reasonable defaults?
There was a problem hiding this comment.
It's exposed as a configurable property only for testing purposes. Let me remove the configurable option, I will figure out some other way to tweak this value for tests.
There was a problem hiding this comment.
Does Nimbus not have reasonable defaults?
Nimbus does not ship with an OAuth client. It only has the building blocks to create such a client.
| class TestOAuth2Config { | ||
|
|
||
| @Test | ||
| void testFrom() { |
There was a problem hiding this comment.
minor: we've been moving away from the test prefix, but I'm also thinking this is probably not a very descriptive test name. Something more like configFromProperties is closer to what it is doing.
| } | ||
|
|
||
| @Test | ||
| void testValidate() { |
There was a problem hiding this comment.
Similar naming: prefer something more descriptive like invalidConfigFromProperties
| * A sentinel value used to indicate that the parent session's token should be used. This is | ||
| * useful for the token exchange flow. | ||
| */ | ||
| public static final String PARENT_TOKEN = "::parent::"; |
There was a problem hiding this comment.
This sentinel has me concerned because I don't see the context in which it would be used. At this point I would have to assume I understand and agree with how it will be used, but I don't. I'd rather leave things like this out and introduce them only when they're used so it makes sense in context of the introduced changes.
There was a problem hiding this comment.
For the record, you could see how it would be used here:
Let me remove this for now. We can decide later if we really need to support this edge case.
The more it goes the more I feel that people are OK with us dropping support for all the weird edge cases in v2 anyways.
There was a problem hiding this comment.
FYI, Trino is moving away from vended token exchange. Since that was the only identified usage of this feature, I think it's fine to just drop it.
165a9d8 to
cb28586
Compare
This is the first PR related to the "Auth Manager v2" effort. Cf. [design document]. This PR introduces the configuration layer changes (section 5.1 of the design doc). It also introduces a few class deprecations. Finally, it also updates LICENSE and runtime-deps.txt files. The most important part is the migration of configuration properties. This is handled by `ConfigMigrator`. Its logic may seem complex, that's because we need to handle some edge cases, as detailed in the design doc, section 5.6. The most complex edge case is when a session context initiates a token exchange flow, using the catalog session's parent token as the actor token (a.k.a. the "Trino use case"): this is handled in v2 by introducing a special sentinel value that will be recognized and processed at runtime (this will come in a later PR though). Merging this PR has only one consequence: rRuntime jars (Spark, Flink, Kafka Connect) will now include the Nimbus libraries and their dependencies. [design document]:https://docs.google.com/document/d/1Hxw-t8Maa7wZFmrlSujm7LRawKsFP3Q31tET_3aRnQU/edit
e625ce9 to
448c2b2
Compare
|
FYI I rebased and squashed as the commit history was becoming quite large. |
|
This pull request has been marked as stale due to 30 days of inactivity. It will be closed in 1 week if no further activity occurs. If you think that’s incorrect or this pull request requires a review, please simply write any comment. If closed, you can revive the PR at any time and @mention a reviewer or discuss it on the dev@iceberg.apache.org list. Thank you for your contributions. |
|
Not stale. |
This is the first PR related to the "Auth Manager v2" effort. Cf. design document.
This PR introduces the configuration layer changes (section 5.1 of the design doc). It also introduces a few class deprecations. Finally, it also updates LICENSE and runtime-deps.txt files.
The most important part is the migration of configuration properties. This is handled by
ConfigMigrator. Its logic may seem complex, that's because we need to handle some edge cases, as detailed in the design doc, section 5.6. The most complex edge case is when a session context initiates a token exchange flow, using the catalog session's parent token as the actor token (a.k.a. the "Trino use case"): this is handled in v2 by introducing a special sentinel value that will be recognized and processed at runtime (this will come in a later PR though).Merging this PR has only two visible consequences:UPDATE: this PR does not introduce any visible change anymore.