diff --git a/driver/pom.xml b/driver/pom.xml
index 82d42293..9147628d 100644
--- a/driver/pom.xml
+++ b/driver/pom.xml
@@ -143,7 +143,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, RequestTest.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 5163cbde..5a670992 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);
@@ -1584,20 +1615,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 extends Throwable> 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 throwIfTransportRetryNotAllowed(Request request,
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 86231dc1..90e169d1 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;
@@ -154,15 +155,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) {
@@ -176,6 +185,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
new file mode 100644
index 00000000..c668c05f
--- /dev/null
+++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java
@@ -0,0 +1,594 @@
+/*-
+ * 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.ScheduledFuture;
+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 oracle.nosql.driver.util.HttpRequestUtil;
+import oracle.nosql.driver.util.HttpRequestUtil.HttpResponse;
+import oracle.nosql.driver.values.JsonUtils;
+import oracle.nosql.driver.values.MapValue;
+
+public abstract class OAuthAccessTokenProvider implements AuthorizationProvider {
+
+
+ /*
+ * This is the general prefix for the login token.
+ */
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ /*
+ * login service end point name.
+ */
+ private static final String LOGIN_SERVICE = "/oauthlogin";
+
+ /*
+ * 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 final AtomicReference authString =
+ new AtomicReference();
+
+ /*
+ * Access token and its lifetime
+ */
+ 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 effective token expiry, 10 seconds */
+ private static final int REFRESH_AHEAD_SECONDS = 10;
+
+ /*
+ * logger
+ */
+ 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
+ */
+ private String loginHost;
+
+ /*
+ * Port number of the proxy machine which host the login service
+ */
+ private int loginPort;
+
+ /*
+ * Endpoint to reach the authenticating entity (Proxy)
+ */
+ private String endpoint;
+
+ /*
+ * Base path for security related services
+ */
+ private final static String basePath = KV_SECURITY_PATH;
+
+ /*
+ * Whether this provider is closed
+ */
+ private volatile 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;
+
+ /*
+ * Current scheduled refresh task.
+ */
+ private ScheduledFuture> refreshTask;
+
+
+ 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();
+
+ private synchronized void performLogin(boolean force, Request request) {
+ /* re-check the authString in case of a race */
+ if (isClosed || (!force && authString.get() != null)) {
+ return;
+ }
+
+ 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 + newTokenInfo.getAccessToken(),
+ LOGIN_SERVICE, timeoutMs);
+
+ /*
+ * login fail
+ */
+ if (response.getStatusCode() != HttpResponseStatus.OK.code()) {
+ throw new InvalidAuthorizationException(
+ "Fail to login to service: " + response.getOutput());
+ }
+
+ if (isClosed) {
+ return;
+ }
+
+ /*
+ * 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, 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
+ */
+ scheduleRefresh();
+
+ } catch (InvalidAuthorizationException iae) {
+ throw iae;
+ } catch (Exception e) {
+ throw new NoSQLException("Login with OAuth token failed", e);
+ }
+ }
+
+ /**
+ * @hidden
+ */
+ @Override
+ public String getAuthorizationString(Request request) {
+
+ /*
+ * Already close
+ */
+ if (isClosed) {
+ return null;
+ }
+
+ /*
+ * If there is no cached auth string, re-authentication to retrieve
+ * the login token and generate the auth string.
+ */
+ if (authString.get() == null) {
+ performLogin(false, request);
+ }
+ return authString.get();
+ }
+
+ /**
+ * Closes the provider, releasing resources such as a stored login token.
+ */
+ @Override
+ public synchronized void close() {
+
+ /*
+ * Already closed
+ */
+ if (isClosed) {
+ 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, 0);
+ }
+
+ /*
+ * Clean up
+ */
+ authString.set(null);
+ tokenInfo = null;
+ accessTokenExpireAt = 0;
+ loginTokenExpireAt = 0;
+ loginPrincipal = null;
+ }
+
+ private void logoutSession(String logoutAuth, int timeoutMs) {
+ try {
+ final HttpResponse response =
+ sendRequest(logoutAuth, LOGOUT_SERVICE, timeoutMs);
+ 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, 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();
+
+ /*
+ * 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);
+ }
+
+ 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 synchronized void scheduleRefresh() {
+ if (refreshTask != null) {
+ refreshTask.cancel(false);
+ refreshTask = null;
+ }
+ if (!autoRenew || isClosed || tokenInfo == null ||
+ tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) {
+ return;
+ }
+ 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() {
+ refreshLoginToken();
+ }
+ }, delay, TimeUnit.MILLISECONDS);
+ }
+
+ private void refreshLoginToken() {
+ if (!autoRenew || isClosed) {
+ return;
+ }
+
+ try {
+ performLogin(true, null);
+ } catch (Exception e) {
+ if (logger != null) {
+ logger.info("Failed to obtain refreshed token: " + e);
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ URL url = NoSQLHandleConfig.createURL(endpoint, "");
+ if (!url.getProtocol().toLowerCase().equals("https")) {
+ throw new IllegalArgumentException(
+ "OAuthAccessTokenProvider requires use of https");
+ }
+ final String newLoginHost = url.getHost();
+ final int newLoginPort = url.getPort();
+
+ this.endpoint = endpoint;
+ this.loginHost = newLoginHost;
+ this.loginPort = newLoginPort;
+ 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;
+ }
+
+ /**
+ * Returns whether the login token is to be automatically renewed.
+ *
+ * @return true if auto-renew is set
+ */
+ public boolean isAutoRenew() {
+ return autoRenew;
+ }
+
+ /**
+ * 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/logout service location with proper
+ * authentication information.
+ */
+ private HttpResponse sendRequest(String authHeader,
+ String serviceName,
+ int timeoutMs) 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);
+ if (timeoutMs == 0) {
+ timeoutMs = HTTP_TIMEOUT_MS;
+ }
+ return HttpRequestUtil.doGetRequest(
+ client,
+ NoSQLHandleConfig.createURL(endpoint, basePath + serviceName)
+ .toString(),
+ headers, timeoutMs, 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 expiresInSeconds;
+
+ /**
+ * 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.expiresInSeconds = expiresInSeconds;
+ }
+
+ public String getAccessToken() {
+ return accessToken;
+ }
+
+ /**
+ * 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 long expireAt;
+ private final String principal;
+
+ private LoginResult(String token, long expireAt, String principal) {
+ this.token = token;
+ this.expireAt = expireAt;
+ this.principal = principal;
+ }
+
+ private String getToken() {
+ return token;
+ }
+
+ private String getPrincipal() {
+ return principal;
+ }
+
+ private long getExpireAt() {
+ return expireAt;
+ }
+ }
+}
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..4e21786b
--- /dev/null
+++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java
@@ -0,0 +1,482 @@
+/*-
+ * 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.NoSQLException;
+import oracle.nosql.driver.ops.GetRequest;
+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;
+ private static volatile long loginTokenLifetimeMs = 15_000;
+ private static volatile long loginDelayMs;
+
+ @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);
+ } 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 {
+ 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 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);
+ 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() + loginTokenLifetimeMs;
+ 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 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, 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);
+ }
+ assertTrue("Timed out waiting for refresh callback",
+ tokenCounter.get() >= 2);
+ }
+ }
+}