From 2f1d4c478c9594b1133ac9390e160afad9268a64 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 25 Apr 2025 16:02:23 +0530 Subject: [PATCH 1/4] Add OAuthAccessTokenProvider --- .../driver/kv/OAuthAccessTokenProvider.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java diff --git a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java new file mode 100644 index 00000000..cb4c0976 --- /dev/null +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -0,0 +1,111 @@ +package oracle.nosql.driver.kv; + +import oracle.nosql.driver.AuthorizationProvider; +import oracle.nosql.driver.ops.Request; +import io.netty.handler.codec.http.HttpHeaders; +import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; + +import com.nimbusds.oauth2.sdk.*; +import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; +import com.nimbusds.oauth2.sdk.id.ClientID; +import com.nimbusds.oauth2.sdk.auth.Secret; +import com.nimbusds.oauth2.sdk.token.RefreshToken; +import com.nimbusds.oauth2.sdk.token.Tokens; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; +import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; +import com.nimbusds.oauth2.sdk.token.BearerAccessToken; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; + +import java.net.URI; +import java.util.concurrent.locks.ReentrantLock; + +public class OAuthAccessTokenProvider implements AuthorizationProvider { + + private volatile BearerAccessToken accessToken; + private volatile RefreshToken refreshToken; + private final URI tokenEndpoint; + private final ClientID clientId; + private final Secret clientSecret; + private final ReentrantLock lock = new ReentrantLock(); + private volatile long tokenExpiryTimeMillis = 0; + + public OAuthAccessTokenProvider(String accessToken, + String refreshToken, + URI tokenEndpoint, + String clientId, + String clientSecret) { + this.accessToken = new BearerAccessToken(accessToken); + this.refreshToken = new RefreshToken(refreshToken); + this.tokenEndpoint = tokenEndpoint; + this.clientId = new ClientID(clientId); + this.clientSecret = new Secret(clientSecret); + this.tokenExpiryTimeMillis = System.currentTimeMillis() + this.accessToken.getLifetime() * 1000L; + } + + @Override + public String getAuthorizationString(Request request) { + if (accessTokenNeedsRefresh()) { + refreshAccessToken(); + } + return "Bearer " + accessToken.getValue(); + } + + private boolean accessTokenNeedsRefresh() { + return System.currentTimeMillis() > (tokenExpiryTimeMillis - 60000); // refresh 1 min early + } + + private void refreshAccessToken() { + lock.lock(); + try { + if (!accessTokenNeedsRefresh()) return; + + TokenRequest tokenRequest = new TokenRequest( + tokenEndpoint, + new ClientSecretBasic(clientId, clientSecret), + new RefreshTokenGrant(refreshToken)); + + HTTPResponse response = tokenRequest.toHTTPRequest().send(); + TokenResponse tokenResponse = OIDCTokenResponseParser.parse(response); + + if (!tokenResponse.indicatesSuccess()) { + throw new RuntimeException("Token refresh failed: " + + tokenResponse.toErrorResponse().getErrorObject()); + } + + OIDCTokenResponse success = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); + Tokens tokens = success.getTokens(); + this.accessToken = (BearerAccessToken) tokens.getAccessToken(); + this.tokenExpiryTimeMillis = System.currentTimeMillis() + this.accessToken.getLifetime() * 1000L; + + RefreshToken newRefreshToken = tokens.getRefreshToken(); + if (newRefreshToken != null) { + this.refreshToken = newRefreshToken; + } + + } catch (Exception e) { + throw new RuntimeException("Error refreshing OAuth access token", e); + } finally { + lock.unlock(); + } + } + + @Override + public void validateAuthString(String input) { + if (input == null || input.isEmpty()) { + throw new IllegalArgumentException("Access token must not be null or empty"); + } + } + + @Override + public void setRequiredHeaders(String authString, Request request, HttpHeaders headers, byte[] content) { + if (authString != null && !authString.isEmpty()) { + headers.set(AUTHORIZATION, authString); + } + } + + @Override + public void close() { + // Nothing to close for now + } + +} From 27d29f6f6e9d4d47dd6e1e29e4e3cd651b1c63c2 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Mon, 24 Nov 2025 17:51:41 +0530 Subject: [PATCH 2/4] Changes to OAuthAccessTokenProvider to reflect the OAuth spec. --- .../driver/kv/OAuthAccessTokenProvider.java | 466 +++++++++++++++--- 1 file changed, 385 insertions(+), 81 deletions(-) diff --git a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java index cb4c0976..54fe55f8 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -1,111 +1,415 @@ +/*- + * Copyright (c) 2011, 2026 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Universal Permissive License v 1.0 as shown at + * https://oss.oracle.com/licenses/upl/ + */ + package oracle.nosql.driver.kv; +import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; +import static oracle.nosql.driver.util.HttpConstants.KV_SECURITY_PATH; + +import java.net.URL; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.ssl.SslContext; import oracle.nosql.driver.AuthorizationProvider; +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.ops.Request; -import io.netty.handler.codec.http.HttpHeaders; -import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; +import oracle.nosql.driver.util.HttpRequestUtil; +import oracle.nosql.driver.util.HttpRequestUtil.HttpResponse; +import oracle.nosql.driver.values.JsonUtils; +import oracle.nosql.driver.values.MapValue; -import com.nimbusds.oauth2.sdk.*; -import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic; -import com.nimbusds.oauth2.sdk.id.ClientID; -import com.nimbusds.oauth2.sdk.auth.Secret; -import com.nimbusds.oauth2.sdk.token.RefreshToken; -import com.nimbusds.oauth2.sdk.token.Tokens; -import com.nimbusds.openid.connect.sdk.OIDCTokenResponse; -import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser; -import com.nimbusds.oauth2.sdk.token.BearerAccessToken; -import com.nimbusds.oauth2.sdk.http.HTTPResponse; - -import java.net.URI; -import java.util.concurrent.locks.ReentrantLock; - -public class OAuthAccessTokenProvider implements AuthorizationProvider { - - private volatile BearerAccessToken accessToken; - private volatile RefreshToken refreshToken; - private final URI tokenEndpoint; - private final ClientID clientId; - private final Secret clientSecret; - private final ReentrantLock lock = new ReentrantLock(); - private volatile long tokenExpiryTimeMillis = 0; - - public OAuthAccessTokenProvider(String accessToken, - String refreshToken, - URI tokenEndpoint, - String clientId, - String clientSecret) { - this.accessToken = new BearerAccessToken(accessToken); - this.refreshToken = new RefreshToken(refreshToken); - this.tokenEndpoint = tokenEndpoint; - this.clientId = new ClientID(clientId); - this.clientSecret = new Secret(clientSecret); - this.tokenExpiryTimeMillis = System.currentTimeMillis() + this.accessToken.getLifetime() * 1000L; - } +public abstract class OAuthAccessTokenProvider implements AuthorizationProvider { - @Override - public String getAuthorizationString(Request request) { - if (accessTokenNeedsRefresh()) { - refreshAccessToken(); - } - return "Bearer " + accessToken.getValue(); - } - private boolean accessTokenNeedsRefresh() { - return System.currentTimeMillis() > (tokenExpiryTimeMillis - 60000); // refresh 1 min early - } + /* + * This is the general prefix for the login token. + */ + private static final String BEARER_PREFIX = "Bearer "; - private void refreshAccessToken() { - lock.lock(); - try { - if (!accessTokenNeedsRefresh()) return; + /* + * login service end point name. + */ + private static final String LOGIN_SERVICE = "/oauthlogin"; + + /* + * login token renew service end point name. + */ + private static final String RENEW_SERVICE = "/oauthrenew"; + + /* + * logout service end point name. + */ + private static final String LOGOUT_SERVICE = "/oauthlogout"; + + /* + * Default timeout when sending http request to server + */ + private static final int HTTP_TIMEOUT_MS = 30000; + + /* + * Authentication string which contain the Bearer prefix and login token's + * binary representation in hex format. + */ + private AtomicReference authString = new AtomicReference(); + + /* + * Access token and its lifetime + */ + private AccessTokenInfo tokenInfo; + + /* Default refresh time before AT expiry, 10 seconds*/ + private static final int REFRESH_AHEAD_SECONDS = 10; + + /* + * logger + */ + private Logger logger; + + /* + * Host name of the proxy machine which host the login service + */ + private String loginHost; - TokenRequest tokenRequest = new TokenRequest( - tokenEndpoint, - new ClientSecretBasic(clientId, clientSecret), - new RefreshTokenGrant(refreshToken)); + /* + * Port number of the proxy machine which host the login service + */ + private int loginPort; - HTTPResponse response = tokenRequest.toHTTPRequest().send(); - TokenResponse tokenResponse = OIDCTokenResponseParser.parse(response); + /* + * Endpoint to reach the authenticating entity (Proxy) + */ + private String endpoint; - if (!tokenResponse.indicatesSuccess()) { - throw new RuntimeException("Token refresh failed: " + - tokenResponse.toErrorResponse().getErrorObject()); + /* + * Base path for security related services + */ + private final static String basePath = KV_SECURITY_PATH; + + /* + * Whether this provider is closed + */ + private boolean isClosed = false; + + /* + * SslContext used by http client + */ + private SslContext sslContext; + + /* + * SSL handshake timeout in milliseconds; + */ + private int sslHandshakeTimeoutMs; + /** + * @hidden + * This is only used for unit test + */ + public static boolean disableSSLHook; + + /* + * A schedule used to periodically invoke the callback + */ + private final ScheduledExecutorService scheduler; + + + public OAuthAccessTokenProvider() { + loginHost = null; + endpoint = null; + loginPort = 0; + logger = null; + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "OAuthTokenRefresher"); + t.setDaemon(true); + return t; + }); + } + + /** + * Returns an access token and its lifetime. + * Implementations decide: + * - How to obtain it (cached, freshly requested, etc.) + * - How to refresh it when expired + * - Whether to store/retrieve refresh tokens + */ + protected abstract AccessTokenInfo getAccessTokenInfo(); + + /** + * @hidden + * + * Login using the access token provided by the callback. + */ + public synchronized void login() { + /* re-check the authString in case of a race */ + if (isClosed || authString.get() != null) { + return; + } + + try { + tokenInfo = getAccessTokenInfo(); + final String accessToken = tokenInfo.getAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalArgumentException("Invalid access token " + + "provided"); + } + /* + * Send request to server for login token + */ + HttpResponse response = sendRequest(BEARER_PREFIX + accessToken, + LOGIN_SERVICE); + /* + * login fail + */ + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + throw new InvalidAuthorizationException( + "Fail to login to service: " + response.getOutput()); } - OIDCTokenResponse success = (OIDCTokenResponse) tokenResponse.toSuccessResponse(); - Tokens tokens = success.getTokens(); - this.accessToken = (BearerAccessToken) tokens.getAccessToken(); - this.tokenExpiryTimeMillis = System.currentTimeMillis() + this.accessToken.getLifetime() * 1000L; + if (isClosed) { + return; + } - RefreshToken newRefreshToken = tokens.getRefreshToken(); - if (newRefreshToken != null) { - this.refreshToken = newRefreshToken; + /* + * Generate the authentication string using login token + */ + authString.set(BEARER_PREFIX + + parseJsonResult(response.getOutput())); + /* + * Schedule access token refresh thread + */ + if (tokenInfo.getExpiresIn() > 0) { + scheduleRefresh(); } + } catch (InvalidAuthorizationException iae) { + throw iae; } catch (Exception e) { - throw new RuntimeException("Error refreshing OAuth access token", e); - } finally { - lock.unlock(); + throw new NoSQLException("Login with OAuth token failed", e); } } + /** + * @hidden + */ @Override - public void validateAuthString(String input) { - if (input == null || input.isEmpty()) { - throw new IllegalArgumentException("Access token must not be null or empty"); + public String getAuthorizationString(Request request) { + + /* + * Already close + */ + if (isClosed) { + return null; } - } - @Override - public void setRequiredHeaders(String authString, Request request, HttpHeaders headers, byte[] content) { - if (authString != null && !authString.isEmpty()) { - headers.set(AUTHORIZATION, authString); + /* + * If there is no cached auth string, re-authentication to retrieve + * the login token and generate the auth string. + */ + if (authString.get() == null) { + login(); } - } + return authString.get(); + } + /** + * Closes the provider, releasing resources such as a stored login token. +     */ @Override public void close() { - // Nothing to close for now + + /* + * Already closed + */ + if (isClosed) { + return; + } + + /* + * Send request for logout + */ + try { + final HttpResponse response = + sendRequest(authString.get(), LOGOUT_SERVICE); + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + if (logger != null) { + logger.info("Failed to logout OAuth session from token: " + + tokenInfo.getAccessToken() + ", response: " + + response.getOutput()); + } + } + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to logout OAuth session from token: " + + tokenInfo.getAccessToken() + ", exception: " + e); + } + } + + /* + * Clean up + */ + isClosed = true; + authString = null; + tokenInfo = null; + if (!scheduler.isShutdown()) { + scheduler.shutdown(); + } + } + + /* Schedule automatic re-login slightly before expiry */ + private void scheduleRefresh() { + long delay = Math.max(1000, + (tokenInfo.getExpiresIn() - REFRESH_AHEAD_SECONDS) * 1000); + scheduler.schedule(() -> { + try { + login(); + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to obtain refreshed token: " + e); + } + + if (!scheduler.isShutdown()) { + scheduler.shutdown(); + } + } + }, delay, TimeUnit.MILLISECONDS); + } + + /** + * Returns the logger, or null if not set. + * + * @return the logger + */ + public Logger getLogger() { + return logger; + } + + /** + * Sets a Logger instance for this provider. + * @param logger the logger + * @return this + */ + public OAuthAccessTokenProvider setLogger(Logger logger) { + this.logger = logger; + return this; + } + + /** + * Returns the endpoint of the authenticating entity + * @return the endpoint + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Sets the endpoint of the authenticating entity + * @param endpoint the endpoint + * @return this + * @throws IllegalArgumentException if the endpoint is not correctly + * formatted + */ + public OAuthAccessTokenProvider setEndpoint(String endpoint) { + this.endpoint = endpoint; + URL url = NoSQLHandleConfig.createURL(endpoint, ""); + if (!url.getProtocol().toLowerCase().equals("https")) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires use of https"); + } + this.loginHost = url.getHost(); + this.loginPort = url.getPort(); + return this; + } + + /** + * Sets the SSL context + * @param sslCtx the context + * @return this + */ + public OAuthAccessTokenProvider setSslContext(SslContext sslCtx) { + this.sslContext = sslCtx; + return this; } + /** + * Sets the SSL handshake timeout in milliseconds + * @param timeoutMs the timeout in milliseconds + * @return this + */ + public OAuthAccessTokenProvider setSslHandshakeTimeout(int timeoutMs) { + this.sslHandshakeTimeoutMs = timeoutMs; + return this; + } + + /** + * Retrieve login token from JSON string + */ + private String parseJsonResult(String jsonResult) { + final MapValue mapValue = + JsonUtils.createValueFromJson(jsonResult, null).asMap(); + + /* + * Extract login token from JSON result + */ + return mapValue.getString("token"); + } + + /** + * Send HTTPS request to login/renew/logout service location with proper + * authentication information. + */ + private HttpResponse sendRequest(String authHeader, + String serviceName) throws Exception { + HttpClient client = null; + try { + final HttpHeaders headers = new DefaultHttpHeaders(); + headers.set(AUTHORIZATION, authHeader); + client = HttpClient.createMinimalClient + (loginHost, + loginPort, + !disableSSLHook ? sslContext : null, + sslHandshakeTimeoutMs, + serviceName, + logger); + return HttpRequestUtil.doGetRequest( + client, + NoSQLHandleConfig.createURL(endpoint, basePath + serviceName) + .toString(), + headers, HTTP_TIMEOUT_MS, logger); + } finally { + if (client != null) { + client.shutdown(); + } + } + } + + /** Nested static class to store the access token and its lifetime */ + public static final class AccessTokenInfo { + + private final String accessToken; + private final long expiresIn; + + public AccessTokenInfo(String accessToken, long expiresIn) { + this.accessToken = accessToken; + this.expiresIn = expiresIn; + } + + public String getAccessToken() { + return accessToken; + } + public long getExpiresIn() { + return expiresIn; + } + } } From 6c1ae679eba75fa43c0b9945db7d46b32a078a38 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Thu, 2 Jul 2026 15:50:56 +0530 Subject: [PATCH 3/4] Add OAuth access token provider support --- driver/pom.xml | 3 +- .../java/oracle/nosql/driver/http/Client.java | 62 +++- .../nosql/driver/http/NoSQLHandleImpl.java | 27 +- .../driver/kv/OAuthAccessTokenProvider.java | 321 +++++++++++----- .../nosql/driver/iam/AuthRetryTest.java | 61 ++++ .../kv/OAuthAccessTokenProviderTest.java | 342 ++++++++++++++++++ 6 files changed, 706 insertions(+), 110 deletions(-) create mode 100644 driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java diff --git a/driver/pom.xml b/driver/pom.xml index d9cd6ba5..834f782b 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -138,7 +138,8 @@ none StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, - ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, + OAuthAccessTokenProviderTest.java, ConfigFileTest.java, + SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, SessionTokenProviderTest.java diff --git a/driver/src/main/java/oracle/nosql/driver/http/Client.java b/driver/src/main/java/oracle/nosql/driver/http/Client.java index 04d75eb4..0fb2e198 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/Client.java +++ b/driver/src/main/java/oracle/nosql/driver/http/Client.java @@ -81,6 +81,7 @@ import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; import oracle.nosql.driver.kv.AuthenticationException; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.kv.StoreAccessTokenProvider; import oracle.nosql.driver.ops.AddReplicaRequest; import oracle.nosql.driver.ops.DeleteRequest; @@ -285,9 +286,9 @@ public Client(Logger logger, "Must configure AuthorizationProvider to use HttpClient"); } - /* StoreAccessTokenProvider == onprem */ + /* StoreAccessTokenProvider/OAuthAccessTokenProvider == onprem */ if (config.getRateLimitingEnabled() && - !(authProvider instanceof StoreAccessTokenProvider)) { + !isOnPremAuthProvider()) { logFine(logger, "Starting client with rate limiting enabled"); rateLimiterMap = new RateLimiterMap(); tableLimitUpdateMap = new ConcurrentHashMap(); @@ -374,6 +375,11 @@ public int getFreeChannelCount() { return httpClient.getFreeChannelCount(); } + private boolean isOnPremAuthProvider() { + return authProvider instanceof StoreAccessTokenProvider || + authProvider instanceof OAuthAccessTokenProvider; + } + /** * Get the next client-scoped request id. It needs to be combined with the * client id to obtain a globally unique scope. @@ -675,12 +681,10 @@ public Result execute(Request kvRequest) { kvRequest.setTimeoutInternal(timeoutMs); /* - * If on-premises the authProvider will always be a - * StoreAccessTokenProvider. If so, check against - * configurable limit. Otherwise check against internal - * hardcoded cloud limit. + * If on-premises, check against configurable limit. + * Otherwise check against internal hardcoded cloud limit. */ - if (authProvider instanceof StoreAccessTokenProvider) { + if (isOnPremAuthProvider()) { if (buffer.readableBytes() > httpClient.getMaxContentLength()) { throw new RequestSizeLimitException("The request " + @@ -844,6 +848,32 @@ public Result execute(Request kvRequest) { "Client re-auth on AuthenticationException: " + rae.getMessage()); continue; + } else if (authProvider instanceof OAuthAccessTokenProvider) { + /* + * OAuthAccessTokenProvider obtains a new NoSQL login + * token lazily after the cache is flushed. Retry this + * path only once so repeated RETRY_AUTHENTICATION + * responses are surfaced as authentication failures + * instead of eventually timing out the request. + */ + if (retriedException(kvRequest, + AuthenticationException.class)) { + kvRequest.setRateLimitDelayedMs(rateDelayedMs); + statsControl.observeError(kvRequest); + logFine(logger, + "Client OAuth re-auth failed: " + + rae.getMessage()); + throw rae; + } + authProvider.flushCache(); + kvRequest.addRetryException(rae.getClass()); + kvRequest.incrementRetries(); + exception = rae; + logFine(logger, + "Client retrying OAuth re-auth on " + + "AuthenticationException: " + + rae.getMessage()); + continue; } kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); @@ -859,7 +889,8 @@ public Result execute(Request kvRequest) { * failures. This does not include permissions-related errors, * which would be a UnauthorizedException. */ - if (retriedInvalidAuthorizationException(kvRequest)) { + if (retriedException(kvRequest, + InvalidAuthorizationException.class)) { /* same as NoSQLException below */ kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); @@ -1579,20 +1610,23 @@ private void updateTableLimiters(String tableName, String compartmentId) { } /** - * Returns whether an {@link InvalidAuthorizationException} has been - * retried for the given request. + * Returns whether an exception type has been retried for the given + * request. * * @param request the request to check - * @return true if an {@link InvalidAuthorizationException} has been - * retried for the request, false otherwise + * @param exceptionClass the exception class to check + * @return true if the exception type has been retried for the request */ - private boolean retriedInvalidAuthorizationException(Request request) { + private boolean retriedException( + Request request, + Class exceptionClass) { + final RetryStats rs = request.getRetryStats(); if (rs == null || rs.getRetries() <= 0) { return false; } - return rs.getNumExceptions(InvalidAuthorizationException.class) > 0; + return rs.getNumExceptions(exceptionClass) > 0; } private void handleRetry(RetryableException re, diff --git a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java index 91c31a57..dd911e18 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java +++ b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java @@ -18,6 +18,7 @@ import oracle.nosql.driver.StatsControl; import oracle.nosql.driver.UserInfo; import oracle.nosql.driver.iam.SignatureProvider; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.kv.StoreAccessTokenProvider; import oracle.nosql.driver.ops.AddReplicaRequest; import oracle.nosql.driver.ops.DeleteRequest; @@ -152,15 +153,23 @@ private void configAuthProvider(Logger logger, NoSQLHandleConfig config) { } if (stProvider.isSecure() && stProvider.getEndpoint() == null) { - String endpoint = config.getServiceURL().toString(); - if (endpoint.endsWith("/")) { - endpoint = endpoint.substring(0, endpoint.length() - 1); - } - stProvider.setEndpoint(endpoint) + stProvider.setEndpoint(getAuthEndpoint(config)) .setSslContext(config.getSslContext()) .setSslHandshakeTimeout( config.getSSLHandshakeTimeout()); } + } else if (ap instanceof OAuthAccessTokenProvider) { + final OAuthAccessTokenProvider oatProvider = + (OAuthAccessTokenProvider) ap; + if (oatProvider.getLogger() == null) { + oatProvider.setLogger(logger); + } + if (oatProvider.getEndpoint() == null) { + oatProvider.setEndpoint(getAuthEndpoint(config)) + .setSslContext(config.getSslContext()) + .setSslHandshakeTimeout( + config.getSSLHandshakeTimeout()); + } } else if (ap instanceof SignatureProvider) { SignatureProvider sigProvider = (SignatureProvider) ap; if (sigProvider.getLogger() == null) { @@ -174,6 +183,14 @@ private void configAuthProvider(Logger logger, NoSQLHandleConfig config) { } } + private String getAuthEndpoint(NoSQLHandleConfig config) { + String endpoint = config.getServiceURL().toString(); + if (endpoint.endsWith("/")) { + endpoint = endpoint.substring(0, endpoint.length() - 1); + } + return endpoint; + } + @Override public DeleteResult delete(DeleteRequest request) { checkClient(); diff --git a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java index 54fe55f8..5dfd8d9f 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -13,6 +13,7 @@ import java.net.URL; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -45,11 +46,6 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private static final String LOGIN_SERVICE = "/oauthlogin"; - /* - * login token renew service end point name. - */ - private static final String RENEW_SERVICE = "/oauthrenew"; - /* * logout service end point name. */ @@ -60,18 +56,24 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private static final int HTTP_TIMEOUT_MS = 30000; - /* + /* * Authentication string which contain the Bearer prefix and login token's * binary representation in hex format. */ - private AtomicReference authString = new AtomicReference(); + private final AtomicReference authString = + new AtomicReference(); /* * Access token and its lifetime */ private AccessTokenInfo tokenInfo; - /* Default refresh time before AT expiry, 10 seconds*/ + /* + * KV-authenticated principal associated with this provider's login token. + */ + private String loginPrincipal; + + /* Default refresh time before AT expiry, 10 seconds */ private static final int REFRESH_AHEAD_SECONDS = 10; /* @@ -79,6 +81,11 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private Logger logger; + /* + * Whether to renew the login token automatically + */ + private volatile boolean autoRenew = true; + /* * Host name of the proxy machine which host the login service */ @@ -102,7 +109,7 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider /* * Whether this provider is closed */ - private boolean isClosed = false; + private volatile boolean isClosed = false; /* * SslContext used by http client @@ -124,6 +131,11 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private final ScheduledExecutorService scheduler; + /* + * Current scheduled refresh task. + */ + private ScheduledFuture refreshTask; + public OAuthAccessTokenProvider() { loginHost = null; @@ -146,32 +158,25 @@ public OAuthAccessTokenProvider() { */ protected abstract AccessTokenInfo getAccessTokenInfo(); - /** - * @hidden - * - * Login using the access token provided by the callback. - */ - public synchronized void login() { + private synchronized void performLogin(boolean force) { /* re-check the authString in case of a race */ - if (isClosed || authString.get() != null) { + if (isClosed || (!force && authString.get() != null)) { return; } + tokenInfo = validateAccessTokenInfo(getAccessTokenInfo()); + try { - tokenInfo = getAccessTokenInfo(); - final String accessToken = tokenInfo.getAccessToken(); - if (accessToken == null || accessToken.isEmpty()) { - throw new IllegalArgumentException("Invalid access token " + - "provided"); - } /* - * Send request to server for login token - */ - HttpResponse response = sendRequest(BEARER_PREFIX + accessToken, - LOGIN_SERVICE); + * Send request to server for login token + */ + HttpResponse response = + sendRequest(BEARER_PREFIX + tokenInfo.getAccessToken(), + LOGIN_SERVICE); + /* - * login fail - */ + * login fail + */ if (response.getStatusCode() != HttpResponseStatus.OK.code()) { throw new InvalidAuthorizationException( "Fail to login to service: " + response.getOutput()); @@ -182,16 +187,24 @@ public synchronized void login() { } /* - * Generate the authentication string using login token - */ - authString.set(BEARER_PREFIX + - parseJsonResult(response.getOutput())); - /* - * Schedule access token refresh thread - */ - if (tokenInfo.getExpiresIn() > 0) { - scheduleRefresh(); + * Generate the authentication string using login token + */ + final LoginResult loginResult = + parseJsonResult(response.getOutput()); + try { + validateLoginPrincipal(loginResult.getPrincipal()); + } catch (InvalidAuthorizationException iae) { + final String rejectedToken = loginResult.getToken(); + if (rejectedToken != null && !rejectedToken.isEmpty()) { + logoutSession(BEARER_PREFIX + rejectedToken); + } + throw iae; } + authString.set(BEARER_PREFIX + loginResult.getToken()); + /* + * Schedule access token refresh thread + */ + scheduleRefresh(); } catch (InvalidAuthorizationException iae) { throw iae; @@ -217,17 +230,17 @@ public String getAuthorizationString(Request request) { * If there is no cached auth string, re-authentication to retrieve * the login token and generate the auth string. */ - if (authString.get() == null) { - login(); + if (authString.get() == null) { + performLogin(false); } - return authString.get(); - } + return authString.get(); + } /** * Closes the provider, releasing resources such as a stored login token. -     */ + */ @Override - public void close() { + public synchronized void close() { /* * Already closed @@ -236,56 +249,136 @@ public void close() { return; } + final String logoutAuth = authString.get(); + isClosed = true; + if (!scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + /* * Send request for logout */ + if (logoutAuth != null) { + logoutSession(logoutAuth); + } + + /* + * Clean up + */ + authString.set(null); + tokenInfo = null; + loginPrincipal = null; + } + + private void logoutSession(String logoutAuth) { try { final HttpResponse response = - sendRequest(authString.get(), LOGOUT_SERVICE); - if (response.getStatusCode() != HttpResponseStatus.OK.code()) { - if (logger != null) { - logger.info("Failed to logout OAuth session from token: " + - tokenInfo.getAccessToken() + ", response: " + - response.getOutput()); - } + sendRequest(logoutAuth, LOGOUT_SERVICE); + if (response.getStatusCode() != HttpResponseStatus.OK.code() && + logger != null) { + logger.info("Failed to logout OAuth session, response: " + + response.getOutput()); } } catch (Exception e) { if (logger != null) { - logger.info("Failed to logout OAuth session from token: " + - tokenInfo.getAccessToken() + ", exception: " + e); + logger.info("Failed to logout OAuth session, exception: " + e); } } + } + + /** + * Invalidate the cached NoSQL login token. + */ + @Override + public void flushCache() { + if (isClosed) { + return; + } + authString.set(null); + } + + private AccessTokenInfo validateAccessTokenInfo( + AccessTokenInfo accessTokenInfo) { + + if (accessTokenInfo == null || + accessTokenInfo.getAccessToken() == null || + accessTokenInfo.getAccessToken().isEmpty()) { + throw new IllegalArgumentException( + "Invalid access token provided"); + } + return accessTokenInfo; + } + + /** + * Retrieve login token from JSON string. + */ + private LoginResult parseJsonResult(String jsonResult) { + final MapValue mapValue = + JsonUtils.createValueFromJson(jsonResult, null).asMap(); /* - * Clean up + * Extract login token and authenticated principal from JSON result. */ - isClosed = true; - authString = null; - tokenInfo = null; - if (!scheduler.isShutdown()) { - scheduler.shutdown(); + return new LoginResult( + mapValue.getString("token"), + mapValue.contains("principal") ? + mapValue.getString("principal") : null); + } + + private void validateLoginPrincipal(String principal) { + if (principal == null || principal.isEmpty()) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response: principal is missing"); + } + if (loginPrincipal == null) { + loginPrincipal = principal; + return; + } + if (!loginPrincipal.equals(principal)) { + throw new InvalidAuthorizationException( + "Logout required prior to logging in with new user identity."); } } - /* Schedule automatic re-login slightly before expiry */ - private void scheduleRefresh() { + /* Schedule automatic re-login slightly before expiry */ + private synchronized void scheduleRefresh() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + if (!autoRenew || isClosed || tokenInfo == null || + tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) { + return; + } long delay = Math.max(1000, - (tokenInfo.getExpiresIn() - REFRESH_AHEAD_SECONDS) * 1000); - scheduler.schedule(() -> { - try { - login(); - } catch (Exception e) { - if (logger != null) { - logger.info("Failed to obtain refreshed token: " + e); - } - - if (!scheduler.isShutdown()) { - scheduler.shutdown(); - } + (tokenInfo.getExpiresInSeconds() - REFRESH_AHEAD_SECONDS) * 1000); + refreshTask = scheduler.schedule(new Runnable() { + @Override + public void run() { + refreshLoginToken(); } }, delay, TimeUnit.MILLISECONDS); } + private void refreshLoginToken() { + if (!autoRenew || isClosed) { + return; + } + + try { + performLogin(true); + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to obtain refreshed token: " + e); + } + flushCache(); + } + } + /** * Returns the logger, or null if not set. * @@ -321,14 +414,17 @@ public String getEndpoint() { * formatted */ public OAuthAccessTokenProvider setEndpoint(String endpoint) { - this.endpoint = endpoint; URL url = NoSQLHandleConfig.createURL(endpoint, ""); if (!url.getProtocol().toLowerCase().equals("https")) { throw new IllegalArgumentException( "OAuthAccessTokenProvider requires use of https"); } - this.loginHost = url.getHost(); - this.loginPort = url.getPort(); + final String newLoginHost = url.getHost(); + final int newLoginPort = url.getPort(); + + this.endpoint = endpoint; + this.loginHost = newLoginHost; + this.loginPort = newLoginPort; return this; } @@ -352,21 +448,30 @@ public OAuthAccessTokenProvider setSslHandshakeTimeout(int timeoutMs) { return this; } - /** - * Retrieve login token from JSON string + /** + * Returns whether the login token is to be automatically renewed. + * + * @return true if auto-renew is set */ - private String parseJsonResult(String jsonResult) { - final MapValue mapValue = - JsonUtils.createValueFromJson(jsonResult, null).asMap(); + public boolean isAutoRenew() { + return autoRenew; + } - /* - * Extract login token from JSON result - */ - return mapValue.getString("token"); + /** + * Sets the auto-renew state. If true, automatic renewal of the login + * token is enabled. + * + * @param autoRenew set to true to enable auto-renew + * + * @return this + */ + public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { + this.autoRenew = autoRenew; + return this; } /** - * Send HTTPS request to login/renew/logout service location with proper + * Send HTTPS request to login/logout service location with proper * authentication information. */ private HttpResponse sendRequest(String authHeader, @@ -398,18 +503,54 @@ private HttpResponse sendRequest(String authHeader, public static final class AccessTokenInfo { private final String accessToken; - private final long expiresIn; + private final long expiresInSeconds; - public AccessTokenInfo(String accessToken, long expiresIn) { + /** + * Creates access token information. + * + * @param accessToken OAuth access token + * @param expiresInSeconds token lifetime in seconds + */ + public AccessTokenInfo(String accessToken, long expiresInSeconds) { + if (expiresInSeconds < 0) { + throw new IllegalArgumentException( + "Access token lifetime must be non-negative"); + } this.accessToken = accessToken; - this.expiresIn = expiresIn; + this.expiresInSeconds = expiresInSeconds; } public String getAccessToken() { return accessToken; } - public long getExpiresIn() { - return expiresIn; + + /** + * Returns the access token lifetime in seconds. + * + * @return the access token lifetime in seconds + */ + public long getExpiresInSeconds() { + return expiresInSeconds; + } + + } + + private static final class LoginResult { + + private final String token; + private final String principal; + + private LoginResult(String token, String principal) { + this.token = token; + this.principal = principal; + } + + private String getToken() { + return token; + } + + private String getPrincipal() { + return principal; } } } diff --git a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java index 6a97f900..56efb397 100644 --- a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java +++ b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java @@ -19,6 +19,8 @@ import oracle.nosql.driver.http.Client; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; +import oracle.nosql.driver.kv.AuthenticationException; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.ops.GetRequest; import oracle.nosql.driver.ops.Request; import oracle.nosql.driver.values.MapValue; @@ -60,6 +62,32 @@ public void testInvalidAuthorizationExceptionRetry() InvalidAuthorizationException.class)); } + @Test + public void testOAuthAuthenticationExceptionRetry() + throws Exception { + + testHttpClient.authenticationExceptionMode = true; + TestOAuthProvider provider = new TestOAuthProvider(); + TestClient client = getTestClient(provider); + + Request request = new GetRequest().setTableName("foo") + .setKey(new MapValue().put("foo", "bar")); + + /* + * Expect the AuthenticationException for OAuth to be retried once only. + * The second AuthenticationException should be returned immediately, + * not retried until request timeout. + */ + assertThrows(AuthenticationException.class, + () -> client.execute(request)); + assertEquals(2, testHttpClient.execCount.get()); + assertEquals(2, testHttpClient.authenticationExceptionCount.get()); + assertEquals(1, provider.flushCount.get()); + assertEquals(1, + request.getRetryStats() + .getNumExceptions(AuthenticationException.class)); + } + private TestClient getTestClient() { AuthorizationProvider provider = new AuthorizationProvider() { @@ -72,6 +100,10 @@ public String getAuthorizationString(Request request) { public void close() { } }; + return getTestClient(provider); + } + + private TestClient getTestClient(AuthorizationProvider provider) { NoSQLHandleConfig cf = new NoSQLHandleConfig("http://localhost:8080"); cf.setAuthorizationProvider(provider); return new TestClient(null, cf); @@ -95,6 +127,9 @@ public HttpClient createHttpClient(URL url, private static class TestHttpClient extends HttpClient { private final AtomicInteger execCount = new AtomicInteger(0); private final AtomicInteger iaeCount = new AtomicInteger(0); + private final AtomicInteger authenticationExceptionCount = + new AtomicInteger(0); + private boolean authenticationExceptionMode; public TestHttpClient() { super("localhost", 8080, 1, 0, 0, 0, 0, null, 0, "test", null); @@ -104,6 +139,12 @@ public TestHttpClient() { public void runRequest(HttpRequest request, ResponseHandler handler, Channel channel) { + if (authenticationExceptionMode) { + execCount.incrementAndGet(); + authenticationExceptionCount.incrementAndGet(); + throw new AuthenticationException("test"); + } + /* * Simulate an authentication failure scenario where the initial * attempt throws SecurityInfoNotReadyException, and subsequent @@ -133,4 +174,24 @@ public boolean isActive() { }; } } + + private static class TestOAuthProvider extends OAuthAccessTokenProvider { + + private final AtomicInteger flushCount = new AtomicInteger(0); + + @Override + public String getAuthorizationString(Request request) { + return "Bearer Test"; + } + + @Override + public void flushCache() { + flushCount.incrementAndGet(); + } + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + return new AccessTokenInfo("Test", 60); + } + } } diff --git a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java new file mode 100644 index 00000000..1a2b1f24 --- /dev/null +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -0,0 +1,342 @@ +/*- + * Copyright (c) 2011, 2026 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Universal Permissive License v 1.0 as shown at + * https://oss.oracle.com/licenses/upl/ + */ + +package oracle.nosql.driver.kv; + +import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; +import static oracle.nosql.driver.util.HttpConstants.KV_SECURITY_PATH; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; + +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.values.JsonUtils; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +@SuppressWarnings("restriction") +public class OAuthAccessTokenProviderTest { + + private static final String loginPath = KV_SECURITY_PATH + "/oauthlogin"; + private static final String logoutPath = KV_SECURITY_PATH + "/oauthlogout"; + + private static final int port = 1444; + private static final String endpoint = "https://localhost:" + port; + + private static final String oauthAccessToken = "OCI_ACCESS_TOKEN"; + private static final String secondOAuthAccessToken = "OCI_ACCESS_TOKEN_2"; + private static final String loginToken = "OAUTH_LOGIN_TOKEN"; + private static final String reloginToken = "OAUTH_RELOGIN_TOKEN"; + private static final String loginPrincipal = "oauth-data/it@test.com"; + private static final String differentLoginPrincipal = + "oauth-data/other@test.com"; + private static final String authTokenPrefix = "Bearer "; + + private static HttpServer server; + private static final AtomicInteger loginCounter = new AtomicInteger(); + private static final AtomicInteger logoutCounter = new AtomicInteger(); + private static volatile String lastLogoutToken; + private static volatile String reloginPrincipal = loginPrincipal; + private static volatile boolean omitLoginPrincipal; + + @BeforeClass + public static void staticSetUp() throws Exception { + OAuthAccessTokenProvider.disableSSLHook = true; + server = HttpServer.create(new InetSocketAddress(port), 0); + server.start(); + + server.createContext(loginPath, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) + throws IOException { + final String authString = + exchange.getRequestHeaders().get(AUTHORIZATION).get(0); + assertTrue(authString.startsWith(authTokenPrefix)); + final int count = loginCounter.incrementAndGet(); + if (count == 1) { + assertEquals(authTokenPrefix + oauthAccessToken, + authString); + generateLoginToken( + loginToken, + omitLoginPrincipal ? null : loginPrincipal, + exchange); + } else { + assertEquals(authTokenPrefix + secondOAuthAccessToken, + authString); + generateLoginToken(reloginToken, reloginPrincipal, + exchange); + } + } + }); + + server.createContext(logoutPath, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) + throws IOException { + final String authString = + exchange.getRequestHeaders().get(AUTHORIZATION).get(0); + assertTrue(authString.startsWith(authTokenPrefix)); + lastLogoutToken = readTokenFromAuth(authString); + logoutCounter.incrementAndGet(); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0); + exchange.close(); + } + }); + } + + @AfterClass + public static void staticTearDown() throws Exception { + OAuthAccessTokenProvider.disableSSLHook = false; + if (server != null) { + server.stop(0); + } + } + + @Test + public void testBasic() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + final String authString = provider.getAuthorizationString(null); + assertNotNull(authString); + assertTrue(authString.startsWith(authTokenPrefix)); + assertEquals(loginToken, readTokenFromAuth(authString)); + + Thread.sleep(10000); + + final String authReloginString = + provider.getAuthorizationString(null); + assertEquals(reloginToken, + readTokenFromAuth(authReloginString)); + + provider.close(); + assertNull(provider.getAuthorizationString(null)); + } finally { + provider.close(); + } + + tryBadEndpoint("http://localhost"); + tryBadEndpoint("localhost:8080"); + tryBadEndpoint("foo://localhost"); + } + + @Test + public void testDisableAutoRenew() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertNotNull(authString); + assertEquals(loginToken, readTokenFromAuth(authString)); + + Thread.sleep(10000); + + final String sameAuthString = + provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(sameAuthString)); + assertEquals(1, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testFlushCacheRelogin() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.flushCache(); + + final String authReloginString = + provider.getAuthorizationString(null); + assertEquals(reloginToken, + readTokenFromAuth(authReloginString)); + assertEquals(2, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testReloginWithDifferentPrincipalFails() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = false; + reloginPrincipal = differentLoginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.flushCache(); + + provider.getAuthorizationString(null); + fail("Relogin with a different principal should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Logout required prior to logging in with new " + + "user identity.")); + } finally { + reloginPrincipal = loginPrincipal; + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(reloginToken, lastLogoutToken); + } + + @Test + public void testLoginWithoutPrincipalFails() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = true; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("Login without a principal should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Invalid OAuth login response: principal is missing")); + } finally { + omitLoginPrincipal = false; + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testCloseLogsOutLoginToken() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.close(); + + assertNull(provider.getAuthorizationString(null)); + assertEquals(1, logoutCounter.get()); + } + + private void tryBadEndpoint(String ep) { + TestProvider provider = new TestProvider(); + try { + provider.setEndpoint(ep); + fail("Endpoint should have failed: " + ep); + } catch (IllegalArgumentException iae) { + assertNull(provider.getEndpoint()); + } + } + + private static void generateLoginToken(String tokenText, + String principal, + HttpExchange exchange) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + OutputStream os = exchange.getResponseBody()) { + + long expireTime = System.currentTimeMillis() + 15000; + oos.writeShort(1); + oos.writeLong(expireTime); + oos.writeBytes(tokenText); + oos.flush(); + + final String tokenString = + JsonUtils.convertBytesToHex(baos.toByteArray()); + final String jsonString = + "{\"token\":\"" + tokenString + "\"," + + "\"expireAt\":" + expireTime + + (principal != null ? + ",\"principal\":\"" + principal + "\"" : "") + + "}"; + + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, + jsonString.length()); + os.write(jsonString.getBytes()); + os.flush(); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to encode", ioe); + } + } + + private static String readTokenFromAuth(String authString) { + final String authEncoded = + authString.substring(authTokenPrefix.length()); + final byte[] token = JsonUtils.convertHexToBytes(authEncoded); + try (ByteArrayInputStream bais = new ByteArrayInputStream(token); + ObjectInputStream ois = new ObjectInputStream(bais)) { + ois.readShort(); + ois.readLong(); + byte[] tokenBytes = new byte[ois.available()]; + ois.read(tokenBytes); + return new String(tokenBytes); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to decode", ioe); + } + } + + private static class TestProvider extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + if (tokenCounter.incrementAndGet() == 1) { + return new AccessTokenInfo(oauthAccessToken, 15); + } + return new AccessTokenInfo(secondOAuthAccessToken, 15); + } + } +} From e6a461ca5c460c9d8516167e32ca3d3efb1e8977 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Mon, 6 Jul 2026 17:25:55 +0530 Subject: [PATCH 4/4] Align OAuth refresh with KV session lifetime Schedule reauthentication using the earlier of the OAuth access-token expiry and the NoSQL login-token expiry returned by the proxy. Preserve the current login session when proactive refresh fails and use the request timeout for request-driven login. Add regression coverage for shorter KV sessions, failed refresh callbacks, and OAuth login timeouts. --- .../driver/kv/OAuthAccessTokenProvider.java | 74 ++++++--- .../kv/OAuthAccessTokenProviderTest.java | 150 +++++++++++++++++- 2 files changed, 201 insertions(+), 23 deletions(-) diff --git a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java index 5dfd8d9f..c668c05f 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -68,12 +68,22 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private AccessTokenInfo tokenInfo; + /* + * Expiration time of the access token, in milliseconds since epoch. + */ + private long accessTokenExpireAt; + + /* + * Expiration time of the NoSQL login token, in milliseconds since epoch. + */ + private long loginTokenExpireAt; + /* * KV-authenticated principal associated with this provider's login token. */ private String loginPrincipal; - /* Default refresh time before AT expiry, 10 seconds */ + /* Default refresh time before effective token expiry, 10 seconds */ private static final int REFRESH_AHEAD_SECONDS = 10; /* @@ -158,21 +168,25 @@ public OAuthAccessTokenProvider() { */ protected abstract AccessTokenInfo getAccessTokenInfo(); - private synchronized void performLogin(boolean force) { + private synchronized void performLogin(boolean force, Request request) { /* re-check the authString in case of a race */ if (isClosed || (!force && authString.get() != null)) { return; } - tokenInfo = validateAccessTokenInfo(getAccessTokenInfo()); + final AccessTokenInfo newTokenInfo = + validateAccessTokenInfo(getAccessTokenInfo()); + final long accessTokenAcquireTime = System.currentTimeMillis(); + final int timeoutMs = + (request != null) ? request.getTimeoutInternal() : 0; try { /* * Send request to server for login token */ HttpResponse response = - sendRequest(BEARER_PREFIX + tokenInfo.getAccessToken(), - LOGIN_SERVICE); + sendRequest(BEARER_PREFIX + newTokenInfo.getAccessToken(), + LOGIN_SERVICE, timeoutMs); /* * login fail @@ -196,11 +210,16 @@ private synchronized void performLogin(boolean force) { } catch (InvalidAuthorizationException iae) { final String rejectedToken = loginResult.getToken(); if (rejectedToken != null && !rejectedToken.isEmpty()) { - logoutSession(BEARER_PREFIX + rejectedToken); + logoutSession(BEARER_PREFIX + rejectedToken, timeoutMs); } throw iae; } authString.set(BEARER_PREFIX + loginResult.getToken()); + tokenInfo = newTokenInfo; + accessTokenExpireAt = accessTokenAcquireTime + + TimeUnit.SECONDS.toMillis( + newTokenInfo.getExpiresInSeconds()); + loginTokenExpireAt = loginResult.getExpireAt(); /* * Schedule access token refresh thread */ @@ -231,7 +250,7 @@ public String getAuthorizationString(Request request) { * the login token and generate the auth string. */ if (authString.get() == null) { - performLogin(false); + performLogin(false, request); } return authString.get(); } @@ -263,7 +282,7 @@ public synchronized void close() { * Send request for logout */ if (logoutAuth != null) { - logoutSession(logoutAuth); + logoutSession(logoutAuth, 0); } /* @@ -271,13 +290,15 @@ public synchronized void close() { */ authString.set(null); tokenInfo = null; + accessTokenExpireAt = 0; + loginTokenExpireAt = 0; loginPrincipal = null; } - private void logoutSession(String logoutAuth) { + private void logoutSession(String logoutAuth, int timeoutMs) { try { final HttpResponse response = - sendRequest(logoutAuth, LOGOUT_SERVICE); + sendRequest(logoutAuth, LOGOUT_SERVICE, timeoutMs); if (response.getStatusCode() != HttpResponseStatus.OK.code() && logger != null) { logger.info("Failed to logout OAuth session, response: " + @@ -321,10 +342,12 @@ private LoginResult parseJsonResult(String jsonResult) { JsonUtils.createValueFromJson(jsonResult, null).asMap(); /* - * Extract login token and authenticated principal from JSON result. + * Extract login token, expiration, and authenticated principal from + * JSON result. */ return new LoginResult( mapValue.getString("token"), + mapValue.getLong("expireAt"), mapValue.contains("principal") ? mapValue.getString("principal") : null); } @@ -354,8 +377,14 @@ private synchronized void scheduleRefresh() { tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) { return; } - long delay = Math.max(1000, - (tokenInfo.getExpiresInSeconds() - REFRESH_AHEAD_SECONDS) * 1000); + final long now = System.currentTimeMillis(); + final long effectiveExpireAt = loginTokenExpireAt > 0 ? + Math.min(accessTokenExpireAt, loginTokenExpireAt) : + accessTokenExpireAt; + final long delay = Math.max( + 1000, + effectiveExpireAt - now - + TimeUnit.SECONDS.toMillis(REFRESH_AHEAD_SECONDS)); refreshTask = scheduler.schedule(new Runnable() { @Override public void run() { @@ -370,12 +399,11 @@ private void refreshLoginToken() { } try { - performLogin(true); + performLogin(true, null); } catch (Exception e) { if (logger != null) { logger.info("Failed to obtain refreshed token: " + e); } - flushCache(); } } @@ -475,7 +503,8 @@ public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { * authentication information. */ private HttpResponse sendRequest(String authHeader, - String serviceName) throws Exception { + String serviceName, + int timeoutMs) throws Exception { HttpClient client = null; try { final HttpHeaders headers = new DefaultHttpHeaders(); @@ -487,11 +516,14 @@ private HttpResponse sendRequest(String authHeader, sslHandshakeTimeoutMs, serviceName, logger); + if (timeoutMs == 0) { + timeoutMs = HTTP_TIMEOUT_MS; + } return HttpRequestUtil.doGetRequest( client, NoSQLHandleConfig.createURL(endpoint, basePath + serviceName) .toString(), - headers, HTTP_TIMEOUT_MS, logger); + headers, timeoutMs, logger); } finally { if (client != null) { client.shutdown(); @@ -538,10 +570,12 @@ public long getExpiresInSeconds() { private static final class LoginResult { private final String token; + private final long expireAt; private final String principal; - private LoginResult(String token, String principal) { + private LoginResult(String token, long expireAt, String principal) { this.token = token; + this.expireAt = expireAt; this.principal = principal; } @@ -552,5 +586,9 @@ private String getToken() { private String getPrincipal() { return principal; } + + private long getExpireAt() { + return expireAt; + } } } diff --git a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java index 1a2b1f24..4e21786b 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -26,6 +26,8 @@ import java.util.concurrent.atomic.AtomicInteger; import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.ops.GetRequest; import oracle.nosql.driver.values.JsonUtils; import com.sun.net.httpserver.HttpExchange; @@ -60,6 +62,8 @@ public class OAuthAccessTokenProviderTest { private static volatile String lastLogoutToken; private static volatile String reloginPrincipal = loginPrincipal; private static volatile boolean omitLoginPrincipal; + private static volatile long loginTokenLifetimeMs = 15_000; + private static volatile long loginDelayMs; @BeforeClass public static void staticSetUp() throws Exception { @@ -78,13 +82,25 @@ public void handle(HttpExchange exchange) if (count == 1) { assertEquals(authTokenPrefix + oauthAccessToken, authString); + } else { + assertEquals(authTokenPrefix + secondOAuthAccessToken, + authString); + } + final long delayMs = loginDelayMs; + if (delayMs > 0) { + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Login handler interrupted", ie); + } + } + if (count == 1) { generateLoginToken( loginToken, omitLoginPrincipal ? null : loginPrincipal, exchange); } else { - assertEquals(authTokenPrefix + secondOAuthAccessToken, - authString); generateLoginToken(reloginToken, reloginPrincipal, exchange); } @@ -173,6 +189,76 @@ public void testDisableAutoRenew() throws Exception { } } + @Test + public void testLoginTokenExpiryControlsRefresh() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + loginTokenLifetimeMs = 12_000; + TestProvider provider = new TestProvider(60); + provider.setEndpoint(endpoint); + + try { + assertEquals(loginToken, readTokenFromAuth( + provider.getAuthorizationString(null))); + + waitForAuthorizationToken(provider, reloginToken, 5_000); + assertTrue(loginCounter.get() >= 2); + } finally { + loginTokenLifetimeMs = 15_000; + provider.close(); + } + } + + @Test + public void testRefreshFailureRetainsLoginToken() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + FailingRefreshProvider provider = new FailingRefreshProvider(); + provider.setEndpoint(endpoint); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.waitForRefreshAttempt(5_000); + + assertEquals(authString, provider.getAuthorizationString(null)); + assertEquals(1, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testLoginUsesRequestTimeout() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + loginDelayMs = 500; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + final long startNanos = System.nanoTime(); + + try { + provider.getAuthorizationString(new GetRequest().setTimeout(50)); + fail("OAuth login should have observed the request timeout"); + } catch (NoSQLException expected) { + final long elapsedMs = + (System.nanoTime() - startNanos) / 1_000_000; + assertTrue("OAuth login exceeded request timeout: " + elapsedMs, + elapsedMs < loginDelayMs); + } finally { + Thread.sleep(loginDelayMs + 100); + loginDelayMs = 0; + provider.close(); + } + } + @Test public void testFlushCacheRelogin() throws Exception { loginCounter.set(0); @@ -287,7 +373,8 @@ private static void generateLoginToken(String tokenText, ObjectOutputStream oos = new ObjectOutputStream(baos); OutputStream os = exchange.getResponseBody()) { - long expireTime = System.currentTimeMillis() + 15000; + long expireTime = + System.currentTimeMillis() + loginTokenLifetimeMs; oos.writeShort(1); oos.writeLong(expireTime); oos.writeBytes(tokenText); @@ -327,16 +414,69 @@ private static String readTokenFromAuth(String authString) { } } + private static void waitForAuthorizationToken( + OAuthAccessTokenProvider provider, + String expectedToken, + long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < limit) { + final String authString = provider.getAuthorizationString(null); + if (expectedToken.equals(readTokenFromAuth(authString))) { + return; + } + Thread.sleep(50); + } + fail("Timed out waiting for refreshed OAuth login token"); + } + private static class TestProvider extends OAuthAccessTokenProvider { private final AtomicInteger tokenCounter = new AtomicInteger(); + private final long expiresInSeconds; + + TestProvider() { + this(15); + } + + TestProvider(long expiresInSeconds) { + this.expiresInSeconds = expiresInSeconds; + } @Override protected AccessTokenInfo getAccessTokenInfo() { if (tokenCounter.incrementAndGet() == 1) { - return new AccessTokenInfo(oauthAccessToken, 15); + return new AccessTokenInfo(oauthAccessToken, expiresInSeconds); + } + return new AccessTokenInfo(secondOAuthAccessToken, + expiresInSeconds); + } + } + + private static class FailingRefreshProvider + extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + if (tokenCounter.incrementAndGet() == 1) { + return new AccessTokenInfo(oauthAccessToken, 12); + } + throw new IllegalStateException("test refresh failure"); + } + + private void waitForRefreshAttempt(long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (tokenCounter.get() < 2 && + System.currentTimeMillis() < limit) { + Thread.sleep(50); } - return new AccessTokenInfo(secondOAuthAccessToken, 15); + assertTrue("Timed out waiting for refresh callback", + tokenCounter.get() >= 2); } } }