uros-b commented on code in PR #17389:
URL: https://github.com/apache/iceberg/pull/17389#discussion_r3978635814


##########
core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Util.java:
##########
@@ -651,6 +655,121 @@ public static AuthSession fromAccessToken(
       return session;
     }
 
+    /**
+     * Creates a session whose token is sourced from a file, such as a 
Kubernetes-mounted projected
+     * service account token. The file is periodically re-read ahead of the 
current token's
+     * expiration (see {@code refreshBufferMillis}) so that a token rotated in 
place is picked up
+     * without restarting the process. No {@link RESTClient} is required since 
refreshing never
+     * calls out over the network; it only re-reads the file.
+     */
+    public static AuthSession fromTokenFile(
+        ScheduledExecutorService executor,
+        String tokenPath,
+        long refreshBufferMillis,
+        AuthSession parent) {
+      String token;
+      try {
+        token = readTokenFile(tokenPath);
+      } catch (IOException e) {
+        throw new UncheckedIOException("Failed to read token file: " + 
tokenPath, e);
+      }
+
+      Long expiresAtMillis = OAuth2Util.expiresAtMillis(token);
+      if (null == expiresAtMillis) {
+        expiresAtMillis = System.currentTimeMillis() + 
OAuth2Properties.TOKEN_EXPIRES_IN_MS_DEFAULT;
+      }
+
+      AuthSession session =
+          new AuthSession(
+              RESTUtil.merge(parent.headers(), authHeaders(token)),
+              AuthConfig.builder()
+                  .from(parent.config())
+                  .token(token)
+                  .tokenPath(tokenPath)
+                  .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE)
+                  .expiresAtMillis(expiresAtMillis)
+                  .tokenPathRefreshBufferMillis(refreshBufferMillis)
+                  .build());
+
+      if (null != executor) {
+        scheduleFileTokenRefresh(executor, session, expiresAtMillis, 
refreshBufferMillis);
+      }
+
+      return session;
+    }
+
+    private static String readTokenFile(String tokenPath) throws IOException {
+      return Files.readString(Path.of(tokenPath)).trim();
+    }
+
+    /**
+     * Re-reads the token from {@link AuthConfig#tokenPath()} and updates this 
session's headers and
+     * config accordingly.
+     *
+     * @return the new token's expiration time in epoch millis, or null if the 
file could not be
+     *     read
+     */
+    Long refreshFromFile() {
+      String tokenPath = config.tokenPath();
+      String token;
+      try {
+        token = readTokenFile(tokenPath);
+      } catch (IOException e) {
+        LOG.warn("Failed to re-read token file {}, will retry", tokenPath, e);
+        return null;
+      }
+
+      Long expiresAtMillis = OAuth2Util.expiresAtMillis(token);
+      if (null == expiresAtMillis) {
+        expiresAtMillis = System.currentTimeMillis() + 
OAuth2Properties.TOKEN_EXPIRES_IN_MS_DEFAULT;
+      }
+
+      this.config =
+          AuthConfig.builder()
+              .from(config())
+              .token(token)
+              .tokenType(OAuth2Properties.ACCESS_TOKEN_TYPE)
+              .expiresAtMillis(expiresAtMillis)
+              .build();
+      this.headers = RESTUtil.merge(this.headers, authHeaders(token));
+
+      return expiresAtMillis;
+    }
+
+    /**
+     * Schedule the next file-based token refresh, {@code refreshBufferMillis} 
ahead of {@code
+     * expiresAtMillis}. Unlike {@link #scheduleTokenRefresh}, this never 
calls out over the
+     * network: on a transient read failure, it retries after a short fixed 
delay instead of giving
+     * up.
+     */
+    @SuppressWarnings("FutureReturnValueIgnored")
+    private static void scheduleFileTokenRefresh(

Review Comment:
   scheduleFileTokenRefresh (retry branch): the intended 5-second failure 
backoff collapses to ~10 ms in practice. On a failed re-read, the code sets 
nextExpiresAtMillis = now + FILE_REFRESH_RETRY_WAIT_MILLIS (now+5 s) and passes 
it back as the expiresAtMillis argument to the recursive call, which then 
computes waitMillis = max((now+5000) - refreshBufferMillis - now, 
MIN_REFRESH_WAIT_MILLIS). With the 300 s default buffer this yields max(5000 - 
300000, 10) = 10 ms. A persistently missing, unreadable, or already-expired-JWT 
file therefore produces ~100 reads/sec plus a LOG.warn every 10 ms on the 
shared authRefreshPool. FILE_REFRESH_RETRY_WAIT_MILLIS is effectively dead 
whenever refreshBufferMillis > FILE_REFRESH_RETRY_WAIT_MILLIS (the default). 
Fix: on the failure branch, call executor.schedule(retryTask, 
FILE_REFRESH_RETRY_WAIT_MILLIS, MILLISECONDS) directly rather than recursing 
with a fabricated near-future expiry.



##########
core/src/test/java/org/apache/iceberg/rest/auth/TestOAuth2Util.java:
##########
@@ -253,6 +258,98 @@ private static void 
assertRefreshIncludesOptionalOAuthParams(long expiresAtMilli
     }
   }
 
+  @Test
+  void fromTokenFileReadsInitialToken(@TempDir Path tempDir) throws 
IOException {
+    String token = tokenWithExp(7200);
+    Path tokenFile = tempDir.resolve("token");
+    Files.writeString(tokenFile, token);
+
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+    AuthSession session = AuthSession.fromTokenFile(null, 
tokenFile.toString(), 300_000L, parent);
+
+    assertThat(session.token()).isEqualTo(token);
+    
assertThat(session.expiresAtMillis()).isEqualTo(TimeUnit.SECONDS.toMillis(7200));
+    assertThat(session.headers()).containsEntry("Authorization", "Bearer " + 
token);
+  }
+
+  @Test
+  void fromTokenFileTrimsWhitespace(@TempDir Path tempDir) throws IOException {
+    String token = tokenWithExp(7200);
+    Path tokenFile = tempDir.resolve("token");
+    Files.writeString(tokenFile, token + "\n");
+
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+    AuthSession session = AuthSession.fromTokenFile(null, 
tokenFile.toString(), 300_000L, parent);
+
+    assertThat(session.token()).isEqualTo(token);
+  }
+
+  @Test
+  void fromTokenFileMissingFileThrows(@TempDir Path tempDir) {
+    Path missing = tempDir.resolve("does-not-exist");
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+
+    assertThatThrownBy(() -> AuthSession.fromTokenFile(null, 
missing.toString(), 300_000L, parent))
+        .isInstanceOf(UncheckedIOException.class)
+        .hasMessageContaining("Failed to read token file: " + missing);
+  }
+
+  @Test
+  void refreshFromFilePicksUpRotatedToken(@TempDir Path tempDir) throws 
IOException {
+    String initialToken = tokenWithExp(7200);
+    Path tokenFile = tempDir.resolve("token");
+    Files.writeString(tokenFile, initialToken);
+
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+    AuthSession session = AuthSession.fromTokenFile(null, 
tokenFile.toString(), 300_000L, parent);
+
+    String rotatedToken = tokenWithExp(500);
+    Files.writeString(tokenFile, rotatedToken);
+
+    Long newExpiresAtMillis = session.refreshFromFile();
+
+    assertThat(newExpiresAtMillis).isEqualTo(TimeUnit.SECONDS.toMillis(500));
+    assertThat(session.token()).isEqualTo(rotatedToken);
+    
assertThat(session.expiresAtMillis()).isEqualTo(TimeUnit.SECONDS.toMillis(500));
+    assertThat(session.headers()).containsEntry("Authorization", "Bearer " + 
rotatedToken);
+  }
+
+  @Test
+  void refreshFromFileOpaqueTokenFallsBackToDefaultExpiry(@TempDir Path 
tempDir)
+      throws IOException {
+    Path tokenFile = tempDir.resolve("token");
+    Files.writeString(tokenFile, "opaque-token");
+
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+    AuthSession session = AuthSession.fromTokenFile(null, 
tokenFile.toString(), 300_000L, parent);
+
+    long before = System.currentTimeMillis();
+    Long expiresAtMillis = session.refreshFromFile();
+    long after = System.currentTimeMillis();
+
+    assertThat(expiresAtMillis)
+        .isBetween(
+            before + OAuth2Properties.TOKEN_EXPIRES_IN_MS_DEFAULT,
+            after + OAuth2Properties.TOKEN_EXPIRES_IN_MS_DEFAULT);
+  }
+
+  @Test
+  void refreshFromFileTransientFailureKeepsStaleToken(@TempDir Path tempDir) 
throws IOException {
+    String token = tokenWithExp(7200);
+    Path tokenFile = tempDir.resolve("token");
+    Files.writeString(tokenFile, token);
+
+    AuthSession parent = new AuthSession(Map.of(), 
AuthConfig.builder().build());
+    AuthSession session = AuthSession.fromTokenFile(null, 
tokenFile.toString(), 300_000L, parent);
+
+    Files.delete(tokenFile);
+
+    Long result = session.refreshFromFile();
+
+    assertThat(result).isNull();
+    assertThat(session.token()).isEqualTo(token);
+  }
+

Review Comment:
   No test exercises scheduleFileTokenRefresh; every refresh test calls 
refreshFromFile() directly, so the retry-interval bug above is uncaught by CI. 
A test that injects an unreadable file after session creation and asserts the 
reschedule delay is at least FILE_REFRESH_RETRY_WAIT_MILLIS would close this 
gap.



##########
core/src/main/java/org/apache/iceberg/rest/auth/OAuth2Manager.java:
##########
@@ -56,6 +56,7 @@ public class OAuth2Manager implements AuthManager {
   private static final Set<String> TABLE_SESSION_ALLOW_LIST =
       ImmutableSet.<String>builder()
           .add(OAuth2Properties.TOKEN)
+          .add(OAuth2Properties.TOKEN_PATH)

Review Comment:
   TOKEN_PATH is added to TABLE_SESSION_ALLOW_LIST, but maybeCreateChildSession 
has no handling for it: the code checks TOKEN, CREDENTIAL, and 
TOKEN_PREFERENCE_ORDER only, so a table-level token-path silently causes the 
child session to inherit the parent rather than creating a file-based one. 
Either add a TOKEN_PATH branch parallel to the TOKEN branch in 
maybeCreateChildSession, or remove the allow-list addition and document the 
limitation; the decision should be explicit.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to