From 26f2a845d12a94fe8088601888ae68673303aed1 Mon Sep 17 00:00:00 2001 From: Bharath Krishna Date: Mon, 10 Aug 2026 16:41:51 -0700 Subject: [PATCH] Core: Refresh exchanged sessions by token exchange A session created by exchanging a subject token inherits both credential and exchangeEnabled from its parent. When token-exchange-enabled is false, refreshToken takes the client_credentials branch and mints a token for the catalog client, so the session silently stops representing the exchanged subject. Pin exchangeEnabled to true for sessions built by fromTokenExchange, so that how a session was obtained decides how it is renewed. The flag added in #13809 continues to control credential derived sessions. Fixes #17600 Generated-by: GitHub Copilot (Claude Opus 5) --- .../apache/iceberg/rest/auth/OAuth2Util.java | 26 ++++++++-- .../iceberg/rest/auth/TestOAuth2Util.java | 47 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java index b8db459edebd..8f5a43bb35ab 100644 --- a/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java +++ b/core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java @@ -665,7 +665,14 @@ public static AuthSession fromCredential( parent.scope(), parent.oauth2ServerUri(), parent.optionalOAuthParams()); - return fromTokenResponse(client, executor, response, startTimeMillis, parent, credential); + return fromTokenResponse( + client, + executor, + response, + startTimeMillis, + parent, + credential, + parent.config().exchangeEnabled()); } public static AuthSession fromTokenResponse( @@ -675,7 +682,13 @@ public static AuthSession fromTokenResponse( long startTimeMillis, AuthSession parent) { return fromTokenResponse( - client, executor, response, startTimeMillis, parent, parent.credential()); + client, + executor, + response, + startTimeMillis, + parent, + parent.credential(), + parent.config().exchangeEnabled()); } private static AuthSession fromTokenResponse( @@ -684,7 +697,8 @@ private static AuthSession fromTokenResponse( OAuthTokenResponse response, long startTimeMillis, AuthSession parent, - String credential) { + String credential, + boolean exchangeEnabled) { // issued_token_type is required in RFC 8693 but not in RFC 6749, // thus assume type is access_token for compatibility with RFC 6749. // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.4.3 @@ -701,6 +715,7 @@ private static AuthSession fromTokenResponse( .token(response.token()) .tokenType(issuedTokenType) .credential(credential) + .exchangeEnabled(exchangeEnabled) .expiresAtMillis(OAuth2Util.expiresAtMillis(response.token())) .build()); @@ -734,7 +749,10 @@ public static AuthSession fromTokenExchange( parent.scope(), parent.oauth2ServerUri(), parent.optionalOAuthParams()); - return fromTokenResponse(client, executor, response, startTimeMillis, parent); + // Renew by exchange regardless of the parent's setting: the parent's credential identifies + // the parent, so refreshing with it would replace the exchanged identity. + return fromTokenResponse( + client, executor, response, startTimeMillis, parent, parent.credential(), true); } } } diff --git a/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java b/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java index 04f7571eca4e..126b9e1f2587 100644 --- a/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java +++ b/core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java @@ -253,6 +253,53 @@ private static void assertRefreshIncludesOptionalOAuthParams(long expiresAtMilli } } + @Test + void exchangedSessionRefreshesByExchangeWhenExchangeIsDisabled() throws IOException { + AuthConfig parentConfig = + AuthConfig.builder() + .token(tokenWithExp(7200)) + .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE) + .credential("testClientId:testClientSecret") + .keepRefreshed(true) + .exchangeEnabled(false) + .oauth2ServerUri("/v1/token") + .build(); + + try (RESTClient client = Mockito.mock(RESTClient.class); + AuthSession parent = new AuthSession(Map.of(), parentConfig)) { + Mockito.when(client.postForm(any(), anyMap(), any(), anyMap(), any())) + .thenReturn( + childTokenResponse("exchanged_token", 3600), + childTokenResponse("refreshed_token", 3600)); + + try (AuthSession child = + AuthSession.fromTokenExchange( + client, null, "user_subject_token", OAuth2Properties.JWT_TOKEN_TYPE, parent)) { + child.refresh(client); + + Mockito.verify(client) + .postForm( + any(), + argThat( + formData -> + "urn:ietf:params:oauth:grant-type:token-exchange" + .equals(formData.get(GRANT_TYPE)) + && "exchanged_token".equals(formData.get("subject_token"))), + Mockito.eq(OAuthTokenResponse.class), + anyMap(), + any()); + Mockito.verify(client, Mockito.never()) + .postForm( + any(), + argThat(formData -> CLIENT_CREDENTIALS.equals(formData.get(GRANT_TYPE))), + Mockito.eq(OAuthTokenResponse.class), + anyMap(), + any()); + assertThat(child.token()).isEqualTo("refreshed_token"); + } + } + } + private static AuthSession parentSession(long expSeconds) { String parentToken = tokenWithExp(expSeconds); AuthConfig parentConfig =