From 2f1d4c478c9594b1133ac9390e160afad9268a64 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 25 Apr 2025 16:02:23 +0530 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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); } } } From 4ea66492fdac4cd6286a8698e4b77f6fd69e69bc Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 31 Jul 2026 17:15:39 +0530 Subject: [PATCH 05/13] Add OAuth support to Java SDK examples --- README.md | 22 ++++++++ examples/src/main/java/Common.java | 80 ++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8025372b..b64817f6 100644 --- a/README.md +++ b/README.md @@ -594,6 +594,28 @@ Run the command: BasicTableExample https://localhost:443 -useKVProxy -user driver \ -password Driver.User@01 +##### Run using OAuth 2.0 authorization + +The existing examples support exchanging an OAuth access token for a NoSQL +login token through a secure on-premises proxy by using the `-useOAuth` flag. +The store and proxy must already be configured for OAuth, and the OAuth +principal must have the privileges required by the selected example. + +The example reads a single access token and its remaining lifetime from +environment variables. Supplying the token this way keeps the example +independent of the identity provider and avoids placing the bearer token in +the command line. A production application should obtain fresh tokens in +`OAuthAccessTokenProvider.getAccessTokenInfo()` and leave automatic renewal +enabled. + +Run the example using an OAuth token that is valid for another 300 seconds: + + $ export NOSQL_OAUTH_ACCESS_TOKEN='' + $ export NOSQL_OAUTH_EXPIRES_IN_SECONDS=300 + $ java -Djavax.net.ssl.trustStorePassword=123456 \ + -Djavax.net.ssl.trustStore=driver.trust -cp .:../lib/nosqldriver.jar \ + BasicTableExample https://localhost:443 -useKVProxy -useOAuth + #### Run using the Oracle NoSQL Database Cloud Simulator Run against the Oracle NoSQL Cloud Simulator using its default endpoint diff --git a/examples/src/main/java/Common.java b/examples/src/main/java/Common.java index e48a264f..deeef261 100644 --- a/examples/src/main/java/Common.java +++ b/examples/src/main/java/Common.java @@ -14,6 +14,7 @@ import oracle.nosql.driver.ReadThrottlingException; import oracle.nosql.driver.Region; import oracle.nosql.driver.iam.SignatureProvider; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.kv.StoreAccessTokenProvider; import oracle.nosql.driver.ops.PrepareRequest; import oracle.nosql.driver.ops.PrepareResult; @@ -77,6 +78,13 @@ * BasicTableExample https://localhost:443 -useKVProxy -user driver \ * -password Driver.User@01 * + * Run against an OAuth-enabled secure proxy and store. The access token and + * its remaining lifetime are supplied in the NOSQL_OAUTH_ACCESS_TOKEN and + * NOSQL_OAUTH_EXPIRES_IN_SECONDS environment variables: + * java -Djavax.net.ssl.trustStorePassword=123456 \ + * -Djavax.net.ssl.trustStore=driver.trust -cp .:../lib/nosqldriver.jar \ + * BasicTableExample https://localhost:443 -useKVProxy -useOAuth + * * Credential Setup * ---------------- * If you are running against the cloud service, you will need to @@ -101,12 +109,18 @@ class Common { private static final String USER_FLAG = "-user"; private static final String PASSWORD_FLAG = "-password"; private static final String CONFIG_FLAG = "-configFile"; + private static final String OAUTH_FLAG = "-useOAuth"; + private static final String OAUTH_ACCESS_TOKEN_ENV = + "NOSQL_OAUTH_ACCESS_TOKEN"; + private static final String OAUTH_EXPIRES_IN_ENV = + "NOSQL_OAUTH_EXPIRES_IN_SECONDS"; private String endpoint; private final String exampleName; private boolean useCloudService; private boolean useCloudSim; private boolean useKVProxy; + private boolean useOAuth; private String user; private char[] password; private String configFile; @@ -183,6 +197,12 @@ private void checkArgs(String[] args) { "cloud simulator"); } configFile = args[currentArg++]; + } else if (OAUTH_FLAG.equals(nextArg)) { + if (useCloudService) { + usage(OAUTH_FLAG + " cannot be used with the " + + "cloud service endpoint"); + } + useOAuth = true; } else { usage("Unknown flag: " + nextArg); } @@ -194,10 +214,13 @@ private void checkArgs(String[] args) { } if (!useKVProxy) { useCloudSim = true; - if (user != null || password != null) { - usage("User and password are not valid " + + if (user != null || password != null || useOAuth) { + usage("Authentication options are not valid " + "with the cloud simulator"); } + } else if (useOAuth && (user != null || password != null)) { + usage(OAUTH_FLAG + " cannot be combined with " + + USER_FLAG + " or " + PASSWORD_FLAG); } } } @@ -208,6 +231,7 @@ private void usage(String msg) { } System.err.println("Usage: java " + exampleName + " " + "\n\t [ " + PROXY_FLAG + "]" + + "\n\t [ " + OAUTH_FLAG + "]" + "\n\t [ " + CONFIG_FLAG + "]" + "\n\t [ " + USER_FLAG + " ]" + "\n\t [ " + PASSWORD_FLAG + " ]"); @@ -241,7 +265,7 @@ char[] getPassword() { /** * Return an appropriate AuthorizationProvider: * Cloud Service - SignatureProvider - * KV Proxy - StoreAccessTokenProvider + * KV Proxy - StoreAccessTokenProvider or OAuthAccessTokenProvider * Cloud Simulator - CloudSimProvider */ AuthorizationProvider getAuthProvider() { @@ -267,6 +291,9 @@ AuthorizationProvider getAuthProvider() { return CloudSimProvider.getProvider(); } assert(useKVProxy); + if (useOAuth) { + return getOAuthProvider(); + } /* if user is not set, assume not secure */ if (user == null) { return new StoreAccessTokenProvider(); @@ -277,6 +304,53 @@ AuthorizationProvider getAuthProvider() { } } + private OAuthAccessTokenProvider getOAuthProvider() { + final String accessToken = + getRequiredEnvironment(OAUTH_ACCESS_TOKEN_ENV); + final long expiresInSeconds = getOAuthExpiresInSeconds(); + + OAuthAccessTokenProvider provider = + new OAuthAccessTokenProvider() { + @Override + protected AccessTokenInfo getAccessTokenInfo() { + return new AccessTokenInfo(accessToken, + expiresInSeconds); + } + }; + + /* + * This example has only one access token. Long-running applications + * should leave automatic renewal enabled and obtain a fresh token in + * getAccessTokenInfo(). + */ + provider.setAutoRenew(false); + return provider; + } + + private static String getRequiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException( + "Environment variable " + name + " must be set"); + } + return value; + } + + private static long getOAuthExpiresInSeconds() { + String value = getRequiredEnvironment(OAUTH_EXPIRES_IN_ENV); + try { + long expiresInSeconds = Long.parseLong(value); + if (expiresInSeconds <= 0) { + throw new IllegalArgumentException( + OAUTH_EXPIRES_IN_ENV + " must be greater than zero"); + } + return expiresInSeconds; + } catch (NumberFormatException nfe) { + throw new IllegalArgumentException( + OAUTH_EXPIRES_IN_ENV + " must be an integer", nfe); + } + } + /** * Runs a query in a loop to be sure that all results have been returned. * This method returns a single list of results, which is not recommended From 96e6e72040d970218c5bc8622321bfaab0746f56 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 7 Aug 2026 20:25:04 +0530 Subject: [PATCH 06/13] Validate structured OAuth login identities Parse the structured authenticatedIdentity returned by /oauthlogin and bind each provider instance to the canonical issuer, subject type, and stable subject ID established by KV. Reject missing, malformed, or changed identities and perform best-effort logout of a rejected candidate session. Keep the SDK provider-neutral and independent of KV implementation classes; it does not parse JWT claims. Cover same and changed issuer, subject type, and subject ID, missing identity, refresh and relogin, and logout behavior. --- .../driver/kv/OAuthAccessTokenProvider.java | 106 ++++++++++++++--- .../kv/OAuthAccessTokenProviderTest.java | 107 ++++++++++++------ 2 files changed, 160 insertions(+), 53 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 c668c05f..8abeaa9f 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -11,6 +11,7 @@ import static oracle.nosql.driver.util.HttpConstants.KV_SECURITY_PATH; import java.net.URL; +import java.util.Objects; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -79,9 +80,9 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider private long loginTokenExpireAt; /* - * KV-authenticated principal associated with this provider's login token. + * KV-authenticated identity associated with this provider's login token. */ - private String loginPrincipal; + private OAuthIdentity authenticatedIdentity; /* Default refresh time before effective token expiry, 10 seconds */ private static final int REFRESH_AHEAD_SECONDS = 10; @@ -206,7 +207,8 @@ private synchronized void performLogin(boolean force, Request request) { final LoginResult loginResult = parseJsonResult(response.getOutput()); try { - validateLoginPrincipal(loginResult.getPrincipal()); + validateAuthenticatedIdentity( + loginResult.getAuthenticatedIdentity()); } catch (InvalidAuthorizationException iae) { final String rejectedToken = loginResult.getToken(); if (rejectedToken != null && !rejectedToken.isEmpty()) { @@ -292,7 +294,7 @@ public synchronized void close() { tokenInfo = null; accessTokenExpireAt = 0; loginTokenExpireAt = 0; - loginPrincipal = null; + authenticatedIdentity = null; } private void logoutSession(String logoutAuth, int timeoutMs) { @@ -342,26 +344,45 @@ private LoginResult parseJsonResult(String jsonResult) { JsonUtils.createValueFromJson(jsonResult, null).asMap(); /* - * Extract login token, expiration, and authenticated principal from + * Extract login token, expiration, and authenticated identity from * JSON result. */ return new LoginResult( mapValue.getString("token"), mapValue.getLong("expireAt"), - mapValue.contains("principal") ? - mapValue.getString("principal") : null); + parseAuthenticatedIdentity(mapValue)); } - private void validateLoginPrincipal(String principal) { - if (principal == null || principal.isEmpty()) { + private OAuthIdentity parseAuthenticatedIdentity(MapValue loginResult) { + if (!loginResult.contains("authenticatedIdentity")) { + return null; + } + try { + final MapValue identity = + loginResult.get("authenticatedIdentity").asMap(); + return new OAuthIdentity( + identity.getString("type"), + identity.getString("issuer"), + identity.getString("subjectType"), + identity.getString("subjectId")); + } catch (RuntimeException re) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response: authenticated identity is " + + "invalid"); + } + } + + private void validateAuthenticatedIdentity(OAuthIdentity identity) { + if (identity == null) { throw new InvalidAuthorizationException( - "Invalid OAuth login response: principal is missing"); + "Invalid OAuth login response: authenticated identity is " + + "missing"); } - if (loginPrincipal == null) { - loginPrincipal = principal; + if (authenticatedIdentity == null) { + authenticatedIdentity = identity; return; } - if (!loginPrincipal.equals(principal)) { + if (!authenticatedIdentity.equals(identity)) { throw new InvalidAuthorizationException( "Logout required prior to logging in with new user identity."); } @@ -571,24 +592,73 @@ private static final class LoginResult { private final String token; private final long expireAt; - private final String principal; + private final OAuthIdentity authenticatedIdentity; - private LoginResult(String token, long expireAt, String principal) { + private LoginResult(String token, + long expireAt, + OAuthIdentity authenticatedIdentity) { this.token = token; this.expireAt = expireAt; - this.principal = principal; + this.authenticatedIdentity = authenticatedIdentity; } private String getToken() { return token; } - private String getPrincipal() { - return principal; + private OAuthIdentity getAuthenticatedIdentity() { + return authenticatedIdentity; } private long getExpireAt() { return expireAt; } } + + /** Immutable identity returned by the OAuth login endpoint. */ + private static final class OAuthIdentity { + + private final String issuer; + private final String subjectType; + private final String subjectId; + + private OAuthIdentity(String type, + String issuer, + String subjectType, + String subjectId) { + if (!"oauth".equals(type) || isBlank(issuer) || + !("user".equals(subjectType) || + "client".equals(subjectType)) || + isBlank(subjectId)) { + throw new IllegalArgumentException( + "Invalid OAuth authenticated identity"); + } + this.issuer = issuer; + this.subjectType = subjectType; + this.subjectId = subjectId; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof OAuthIdentity)) { + return false; + } + final OAuthIdentity that = (OAuthIdentity) other; + return issuer.equals(that.issuer) && + subjectType.equals(that.subjectType) && + subjectId.equals(that.subjectId); + } + + @Override + public int hashCode() { + return Objects.hash(issuer, subjectType, subjectId); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + } } 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 4e21786b..6d4c12bb 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -51,8 +51,11 @@ public class OAuthAccessTokenProviderTest { 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 = + private static final String loginIssuer = + "https://issuer.example.com/tenant"; + private static final String loginSubjectType = "user"; + private static final String loginSubjectId = "oauth-data/it@test.com"; + private static final String differentSubjectId = "oauth-data/other@test.com"; private static final String authTokenPrefix = "Bearer "; @@ -60,8 +63,10 @@ public class OAuthAccessTokenProviderTest { 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 String reloginIssuer = loginIssuer; + private static volatile String reloginSubjectType = loginSubjectType; + private static volatile String reloginSubjectId = loginSubjectId; + private static volatile boolean omitAuthenticatedIdentity; private static volatile long loginTokenLifetimeMs = 15_000; private static volatile long loginDelayMs; @@ -98,11 +103,14 @@ public void handle(HttpExchange exchange) if (count == 1) { generateLoginToken( loginToken, - omitLoginPrincipal ? null : loginPrincipal, + omitAuthenticatedIdentity ? null : loginIssuer, + loginSubjectType, + loginSubjectId, exchange); } else { - generateLoginToken(reloginToken, reloginPrincipal, - exchange); + generateLoginToken(reloginToken, reloginIssuer, + reloginSubjectType, + reloginSubjectId, exchange); } } }); @@ -135,8 +143,7 @@ public void testBasic() throws Exception { loginCounter.set(0); logoutCounter.set(0); lastLogoutToken = null; - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint); @@ -168,8 +175,7 @@ public void testBasic() throws Exception { public void testDisableAutoRenew() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); @@ -193,8 +199,7 @@ public void testDisableAutoRenew() throws Exception { public void testLoginTokenExpiryControlsRefresh() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); loginTokenLifetimeMs = 12_000; TestProvider provider = new TestProvider(60); provider.setEndpoint(endpoint); @@ -215,8 +220,7 @@ public void testLoginTokenExpiryControlsRefresh() throws Exception { public void testRefreshFailureRetainsLoginToken() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); FailingRefreshProvider provider = new FailingRefreshProvider(); provider.setEndpoint(endpoint); @@ -237,8 +241,7 @@ public void testRefreshFailureRetainsLoginToken() throws Exception { public void testLoginUsesRequestTimeout() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); loginDelayMs = 500; TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); @@ -263,8 +266,7 @@ public void testLoginUsesRequestTimeout() throws Exception { public void testFlushCacheRelogin() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); @@ -285,12 +287,34 @@ public void testFlushCacheRelogin() throws Exception { } @Test - public void testReloginWithDifferentPrincipalFails() throws Exception { + public void testReloginWithDifferentSubjectIdFails() throws Exception { + assertReloginIdentityRejected(loginIssuer, loginSubjectType, + differentSubjectId); + } + + @Test + public void testReloginWithDifferentIssuerFails() throws Exception { + assertReloginIdentityRejected("https://other.example.com/tenant", + loginSubjectType, loginSubjectId); + } + + @Test + public void testReloginWithDifferentSubjectTypeFails() throws Exception { + assertReloginIdentityRejected(loginIssuer, "client", loginSubjectId); + } + + private void assertReloginIdentityRejected(String issuer, + String subjectType, + String subjectId) + throws Exception { + loginCounter.set(0); logoutCounter.set(0); lastLogoutToken = null; - omitLoginPrincipal = false; - reloginPrincipal = differentLoginPrincipal; + resetAuthenticatedIdentity(); + reloginIssuer = issuer; + reloginSubjectType = subjectType; + reloginSubjectId = subjectId; TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); @@ -301,13 +325,13 @@ public void testReloginWithDifferentPrincipalFails() throws Exception { provider.flushCache(); provider.getAuthorizationString(null); - fail("Relogin with a different principal should have failed"); + fail("Relogin with a different identity should have failed"); } catch (InvalidAuthorizationException iae) { assertTrue(iae.getMessage().startsWith( "Logout required prior to logging in with new " + "user identity.")); } finally { - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); provider.close(); } assertEquals(1, logoutCounter.get()); @@ -315,23 +339,24 @@ public void testReloginWithDifferentPrincipalFails() throws Exception { } @Test - public void testLoginWithoutPrincipalFails() throws Exception { + public void testLoginWithoutAuthenticatedIdentityFails() throws Exception { loginCounter.set(0); logoutCounter.set(0); lastLogoutToken = null; - omitLoginPrincipal = true; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); + omitAuthenticatedIdentity = true; TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); try { provider.getAuthorizationString(null); - fail("Login without a principal should have failed"); + fail("Login without an authenticated identity should have failed"); } catch (InvalidAuthorizationException iae) { assertTrue(iae.getMessage().startsWith( - "Invalid OAuth login response: principal is missing")); + "Invalid OAuth login response: authenticated identity is " + + "missing")); } finally { - omitLoginPrincipal = false; + resetAuthenticatedIdentity(); provider.close(); } assertEquals(1, logoutCounter.get()); @@ -342,8 +367,7 @@ public void testLoginWithoutPrincipalFails() throws Exception { public void testCloseLogsOutLoginToken() throws Exception { loginCounter.set(0); logoutCounter.set(0); - omitLoginPrincipal = false; - reloginPrincipal = loginPrincipal; + resetAuthenticatedIdentity(); TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint).setAutoRenew(false); @@ -366,8 +390,17 @@ private void tryBadEndpoint(String ep) { } } + private static void resetAuthenticatedIdentity() { + omitAuthenticatedIdentity = false; + reloginIssuer = loginIssuer; + reloginSubjectType = loginSubjectType; + reloginSubjectId = loginSubjectId; + } + private static void generateLoginToken(String tokenText, - String principal, + String issuer, + String subjectType, + String subjectId, HttpExchange exchange) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(baos); @@ -385,8 +418,12 @@ private static void generateLoginToken(String tokenText, final String jsonString = "{\"token\":\"" + tokenString + "\"," + "\"expireAt\":" + expireTime + - (principal != null ? - ",\"principal\":\"" + principal + "\"" : "") + + (issuer != null ? + ",\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + issuer + "\"," + + "\"subjectType\":\"" + subjectType + "\"," + + "\"subjectId\":\"" + subjectId + "\"}" : "") + "}"; exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, From 66b7bea74d46b2573e2605a82564b0ecfaf3579f Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 7 Aug 2026 21:18:38 +0530 Subject: [PATCH 07/13] Use standard logout endpoint for OAuth cleanup OAuth login creates an ordinary NoSQL login session, so provider cleanup now uses the existing /logout endpoint. This preserves mixed-version compatibility and makes clear that closing the provider cleans up the KV session but does not revoke the original identity-provider access token. --- .../oracle/nosql/driver/kv/OAuthAccessTokenProvider.java | 5 +++-- .../oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java | 2 +- 2 files changed, 4 insertions(+), 3 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 8abeaa9f..395924aa 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -48,9 +48,10 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider private static final String LOGIN_SERVICE = "/oauthlogin"; /* - * logout service end point name. + * Existing NoSQL login-token logout service. This does not revoke the + * original OAuth access token at the identity provider. */ - private static final String LOGOUT_SERVICE = "/oauthlogout"; + private static final String LOGOUT_SERVICE = "/logout"; /* * Default timeout when sending http request to server 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 6d4c12bb..c03c1073 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -42,7 +42,7 @@ public class OAuthAccessTokenProviderTest { private static final String loginPath = KV_SECURITY_PATH + "/oauthlogin"; - private static final String logoutPath = KV_SECURITY_PATH + "/oauthlogout"; + private static final String logoutPath = KV_SECURITY_PATH + "/logout"; private static final int port = 1444; private static final String endpoint = "https://localhost:" + port; From 360f5b012cf4f9edfcb60a1e18d643e9d961cc32 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Thu, 13 Aug 2026 19:21:51 +0530 Subject: [PATCH 08/13] Harden OAuth login refresh and retry paths Prevent OAuth login and refresh from creating or retaining sessions after close, cache invalidation, or auto-renewal changes. Use checked token-expiry arithmetic, reject expired proxy login tokens, and clean up rejected sessions. Use conditional OAuth cache invalidation and one shared authentication retry allowance so stale failures cannot clear a newer token or obtain extra retries. Send OAuth login and logout through a single-attempt HTTP path and keep tokens, response bodies, and arbitrary exception messages out of OAuth logs. Remove cancelled refresh tasks promptly, exclude mock HTTPS OAuth tests from server-backed profiles, and add focused coverage for callback close, expiry validation, refresh cancellation, retry races, log redaction, and single-attempt failures. --- driver/pom.xml | 9 +- .../java/oracle/nosql/driver/http/Client.java | 64 +++- .../driver/kv/OAuthAccessTokenProvider.java | 258 ++++++++++--- .../nosql/driver/util/HttpRequestUtil.java | 61 +++- .../nosql/driver/iam/AuthRetryTest.java | 85 ++++- .../kv/OAuthAccessTokenProviderTest.java | 345 +++++++++++++++++- 6 files changed, 727 insertions(+), 95 deletions(-) diff --git a/driver/pom.xml b/driver/pom.xml index 9147628d..db02e8be 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -88,7 +88,8 @@ cloudsim - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, @@ -106,7 +107,8 @@ onprem - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, @@ -124,7 +126,8 @@ onprem - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.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 5a670992..0a3ac7af 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/Client.java +++ b/driver/src/main/java/oracle/nosql/driver/http/Client.java @@ -856,23 +856,22 @@ public Result execute(Request kvRequest) { * responses are surfaced as authentication failures * instead of eventually timing out the request. */ - if (retriedException(kvRequest, - AuthenticationException.class)) { + if (retriedOAuthAuthentication(kvRequest)) { kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); logFine(logger, - "Client OAuth re-auth failed: " + - rae.getMessage()); + "Client OAuth re-auth failed with " + + rae.getClass().getName()); throw rae; } - authProvider.flushCache(); + ((OAuthAccessTokenProvider) authProvider) + .invalidateAuthorizationString(authString); kvRequest.addRetryException(rae.getClass()); kvRequest.incrementRetries(); exception = rae; logFine(logger, "Client retrying OAuth re-auth on " + - "AuthenticationException: " + - rae.getMessage()); + rae.getClass().getName()); continue; } kvRequest.setRateLimitDelayedMs(rateDelayedMs); @@ -889,23 +888,47 @@ public Result execute(Request kvRequest) { * failures. This does not include permissions-related errors, * which would be a UnauthorizedException. */ - if (retriedException(kvRequest, - InvalidAuthorizationException.class)) { + final boolean oauthProvider = + authProvider instanceof OAuthAccessTokenProvider; + if ((oauthProvider && + retriedOAuthAuthentication(kvRequest)) || + (!oauthProvider && + retriedException( + kvRequest, + InvalidAuthorizationException.class))) { /* same as NoSQLException below */ kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); - logFine(logger, "Client execute NoSQLException: " + - iae.getMessage()); + if (oauthProvider) { + logFine(logger, + "Client OAuth authorization failed with " + + iae.getClass().getName()); + } else { + logFine(logger, "Client execute NoSQLException: " + + iae.getMessage()); + } throw iae; } /* flush auth cache and do one retry */ - authProvider.flushCache(); + if (oauthProvider) { + ((OAuthAccessTokenProvider) authProvider) + .invalidateAuthorizationString(authString); + } else { + authProvider.flushCache(); + } kvRequest.addRetryException(iae.getClass()); kvRequest.incrementRetries(); exception = iae; - logFine(logger, + if (oauthProvider) { + logFine(logger, + "Client retrying OAuth authorization after " + + iae.getClass().getName()); + } else { + logFine( + logger, "Client retrying on InvalidAuthorizationException: " + iae.getMessage()); + } continue; } catch (SecurityInfoNotReadyException sinre) { kvRequest.addRetryException(sinre.getClass()); @@ -1634,6 +1657,12 @@ private boolean retriedException( return rs.getNumExceptions(exceptionClass) > 0; } + private boolean retriedOAuthAuthentication(Request request) { + return retriedException(request, AuthenticationException.class) || + retriedException( + request, InvalidAuthorizationException.class); + } + private void throwIfTransportRetryNotAllowed(Request request, Throwable cause, int rateDelayedMs) { @@ -1665,8 +1694,15 @@ private void handleRetry(RetryableException re, private void logRetries(int numRetries, Throwable exception) { Level level = Level.FINE; if (logger != null) { + final String exceptionDetail = + exception == null ? "" : + ", exception: " + + (authProvider instanceof OAuthAccessTokenProvider && + (exception instanceof AuthenticationException || + exception instanceof InvalidAuthorizationException) ? + exception.getClass().getName() : exception); logger.log(level, "Client, doing retry: " + numRetries + - (exception != null ? ", exception: " + exception : "")); + exceptionDetail); } } 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 395924aa..f56f419f 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -12,10 +12,11 @@ import java.net.URL; import java.util.Objects; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; @@ -121,7 +122,7 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider /* * Whether this provider is closed */ - private volatile boolean isClosed = false; + private final AtomicBoolean isClosed = new AtomicBoolean(false); /* * SslContext used by http client @@ -141,24 +142,28 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider /* * A schedule used to periodically invoke the callback */ - private final ScheduledExecutorService scheduler; + private final ScheduledThreadPoolExecutor scheduler; /* * Current scheduled refresh task. */ private ScheduledFuture refreshTask; + /* Invalidates a scheduled refresh that has already started running. */ + private final AtomicLong refreshGeneration = new AtomicLong(); + public OAuthAccessTokenProvider() { loginHost = null; endpoint = null; loginPort = 0; logger = null; - scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + scheduler = new ScheduledThreadPoolExecutor(1, r -> { Thread t = new Thread(r, "OAuthTokenRefresher"); t.setDaemon(true); return t; }); + scheduler.setRemoveOnCancelPolicy(true); } /** @@ -170,15 +175,23 @@ public OAuthAccessTokenProvider() { */ protected abstract AccessTokenInfo getAccessTokenInfo(); - private synchronized void performLogin(boolean force, Request request) { + private synchronized void performLogin(boolean force, + Request request, + long expectedGeneration) { + final String oldAuthorization = authString.get(); /* re-check the authString in case of a race */ - if (isClosed || (!force && authString.get() != null)) { + if (loginAborted(force, expectedGeneration)) { return; } final AccessTokenInfo newTokenInfo = validateAccessTokenInfo(getAccessTokenInfo()); + if (loginAborted(force, expectedGeneration)) { + return; + } final long accessTokenAcquireTime = System.currentTimeMillis(); + final long newAccessTokenExpireAt = + getAccessTokenExpireAt(newTokenInfo, accessTokenAcquireTime); final int timeoutMs = (request != null) ? request.getTimeoutInternal() : 0; @@ -186,20 +199,25 @@ private synchronized void performLogin(boolean force, Request request) { /* * Send request to server for login token */ + if (loginAborted(force, expectedGeneration)) { + return; + } HttpResponse response = sendRequest(BEARER_PREFIX + newTokenInfo.getAccessToken(), LOGIN_SERVICE, timeoutMs); + if (loginAborted(force, expectedGeneration)) { + logoutLoginResponse(response, timeoutMs); + return; + } + /* * login fail */ if (response.getStatusCode() != HttpResponseStatus.OK.code()) { throw new InvalidAuthorizationException( - "Fail to login to service: " + response.getOutput()); - } - - if (isClosed) { - return; + "OAuth login failed with HTTP status " + + response.getStatusCode()); } /* @@ -208,6 +226,7 @@ private synchronized void performLogin(boolean force, Request request) { final LoginResult loginResult = parseJsonResult(response.getOutput()); try { + validateLoginTokenExpiration(loginResult); validateAuthenticatedIdentity( loginResult.getAuthenticatedIdentity()); } catch (InvalidAuthorizationException iae) { @@ -217,11 +236,16 @@ private synchronized void performLogin(boolean force, Request request) { } throw iae; } - authString.set(BEARER_PREFIX + loginResult.getToken()); + if (loginAborted(force, expectedGeneration) || + !authString.compareAndSet( + oldAuthorization, + BEARER_PREFIX + loginResult.getToken())) { + logoutSession( + BEARER_PREFIX + loginResult.getToken(), timeoutMs); + return; + } tokenInfo = newTokenInfo; - accessTokenExpireAt = accessTokenAcquireTime + - TimeUnit.SECONDS.toMillis( - newTokenInfo.getExpiresInSeconds()); + accessTokenExpireAt = newAccessTokenExpireAt; loginTokenExpireAt = loginResult.getExpireAt(); /* * Schedule access token refresh thread @@ -235,6 +259,31 @@ private synchronized void performLogin(boolean force, Request request) { } } + private boolean loginAborted(boolean force, long expectedGeneration) { + return isClosed.get() || + (expectedGeneration >= 0 && + (expectedGeneration != refreshGeneration.get() || + !autoRenew)) || + (!force && authString.get() != null); + } + + private long getAccessTokenExpireAt(AccessTokenInfo accessTokenInfo, + long acquireTime) { + final long expiresInSeconds = + accessTokenInfo.getExpiresInSeconds(); + if (expiresInSeconds == 0) { + return 0; + } + try { + return Math.addExact( + acquireTime, + Math.multiplyExact(expiresInSeconds, 1000L)); + } catch (ArithmeticException ae) { + throw new IllegalArgumentException( + "Access token lifetime is too large", ae); + } + } + /** * @hidden */ @@ -244,7 +293,7 @@ public String getAuthorizationString(Request request) { /* * Already close */ - if (isClosed) { + if (isClosed.get()) { return null; } @@ -253,7 +302,7 @@ public String getAuthorizationString(Request request) { * the login token and generate the auth string. */ if (authString.get() == null) { - performLogin(false, request); + performLogin(false, request, -1); } return authString.get(); } @@ -262,40 +311,33 @@ public String getAuthorizationString(Request request) { * Closes the provider, releasing resources such as a stored login token. */ @Override - public synchronized void close() { + public void close() { /* * Already closed */ - if (isClosed) { + if (!isClosed.compareAndSet(false, true)) { return; } - final String logoutAuth = authString.get(); - isClosed = true; - if (!scheduler.isShutdown()) { - scheduler.shutdownNow(); - } - if (refreshTask != null) { - refreshTask.cancel(false); - refreshTask = null; + refreshGeneration.incrementAndGet(); + final String logoutAuth; + synchronized (this) { + logoutAuth = authString.getAndSet(null); + if (!scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + cancelRefreshTask(); + + tokenInfo = null; + accessTokenExpireAt = 0; + loginTokenExpireAt = 0; + authenticatedIdentity = null; } - /* - * Send request for logout - */ if (logoutAuth != null) { logoutSession(logoutAuth, 0); } - - /* - * Clean up - */ - authString.set(null); - tokenInfo = null; - accessTokenExpireAt = 0; - loginTokenExpireAt = 0; - authenticatedIdentity = null; } private void logoutSession(String logoutAuth, int timeoutMs) { @@ -304,12 +346,13 @@ private void logoutSession(String logoutAuth, int timeoutMs) { sendRequest(logoutAuth, LOGOUT_SERVICE, timeoutMs); if (response.getStatusCode() != HttpResponseStatus.OK.code() && logger != null) { - logger.info("Failed to logout OAuth session, response: " + - response.getOutput()); + logger.info("Failed to logout OAuth session, HTTP status " + + response.getStatusCode()); } } catch (Exception e) { if (logger != null) { - logger.info("Failed to logout OAuth session, exception: " + e); + logger.info("Failed to logout OAuth session, exception type " + + e.getClass().getName()); } } } @@ -319,10 +362,53 @@ private void logoutSession(String logoutAuth, int timeoutMs) { */ @Override public void flushCache() { - if (isClosed) { - return; + refreshGeneration.incrementAndGet(); + synchronized (this) { + if (isClosed.get()) { + return; + } + authString.set(null); + cancelRefreshTask(); + clearTokenExpirationState(); + } + } + + /** + * Invalidates the cached login token only if it was used by the failed + * request. A newer token installed by another request is preserved. + * + * @hidden + * + * @param failedAuthorization authorization value used by the failed request + * @return true if the cached value was invalidated + */ + public boolean invalidateAuthorizationString(String failedAuthorization) { + if (isClosed.get() || failedAuthorization == null || + !failedAuthorization.equals(authString.get())) { + return false; } - authString.set(null); + + refreshGeneration.incrementAndGet(); + synchronized (this) { + if (isClosed.get()) { + return false; + } + if (!authString.compareAndSet(failedAuthorization, null)) { + if (authString.get() != null && tokenInfo != null) { + scheduleRefresh(); + } + return false; + } + cancelRefreshTask(); + clearTokenExpirationState(); + return true; + } + } + + private void clearTokenExpirationState() { + tokenInfo = null; + accessTokenExpireAt = 0; + loginTokenExpireAt = 0; } private AccessTokenInfo validateAccessTokenInfo( @@ -354,6 +440,34 @@ private LoginResult parseJsonResult(String jsonResult) { parseAuthenticatedIdentity(mapValue)); } + private void validateLoginTokenExpiration(LoginResult loginResult) { + final long expireAt = loginResult.getExpireAt(); + if (expireAt > 0 && expireAt <= System.currentTimeMillis()) { + throw new InvalidAuthorizationException( + "OAuth login response contains an expired login token"); + } + } + + private void logoutLoginResponse(HttpResponse response, int timeoutMs) { + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + return; + } + try { + final MapValue loginResult = + JsonUtils.createValueFromJson( + response.getOutput(), null).asMap(); + final String token = loginResult.getString("token"); + if (token != null && !token.isEmpty()) { + logoutSession(BEARER_PREFIX + token, timeoutMs); + } + } catch (RuntimeException re) { + if (logger != null) { + logger.info("Unable to clean up OAuth login response, " + + "exception type " + re.getClass().getName()); + } + } + } + private OAuthIdentity parseAuthenticatedIdentity(MapValue loginResult) { if (!loginResult.contains("authenticatedIdentity")) { return null; @@ -391,11 +505,9 @@ private void validateAuthenticatedIdentity(OAuthIdentity 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 || + final long generation = refreshGeneration.incrementAndGet(); + cancelRefreshTask(); + if (!autoRenew || isClosed.get() || tokenInfo == null || tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) { return; } @@ -410,25 +522,41 @@ private synchronized void scheduleRefresh() { refreshTask = scheduler.schedule(new Runnable() { @Override public void run() { - refreshLoginToken(); + refreshLoginToken(generation); } }, delay, TimeUnit.MILLISECONDS); } - private void refreshLoginToken() { - if (!autoRenew || isClosed) { + private void refreshLoginToken(long generation) { + if (!autoRenew || isClosed.get() || + generation != refreshGeneration.get()) { return; } try { - performLogin(true, null); + performLogin(true, null, generation); } catch (Exception e) { if (logger != null) { - logger.info("Failed to obtain refreshed token: " + e); + logger.info("Failed to obtain refreshed token, exception " + + "type " + e.getClass().getName()); } } } + private void invalidateRefreshTask() { + refreshGeneration.incrementAndGet(); + synchronized (this) { + cancelRefreshTask(); + } + } + + private void cancelRefreshTask() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + } + /** * Returns the logger, or null if not set. * @@ -516,7 +644,15 @@ public boolean isAutoRenew() { * @return this */ public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { + if (this.autoRenew == autoRenew) { + return this; + } this.autoRenew = autoRenew; + if (autoRenew) { + scheduleRefresh(); + } else { + invalidateRefreshTask(); + } return this; } @@ -537,15 +673,15 @@ private HttpResponse sendRequest(String authHeader, !disableSSLHook ? sslContext : null, sslHandshakeTimeoutMs, serviceName, - logger); + null); if (timeoutMs == 0) { timeoutMs = HTTP_TIMEOUT_MS; } - return HttpRequestUtil.doGetRequest( + return HttpRequestUtil.doGetRequestOnce( client, NoSQLHandleConfig.createURL(endpoint, basePath + serviceName) .toString(), - headers, timeoutMs, logger); + headers, timeoutMs, null); } finally { if (client != null) { client.shutdown(); @@ -563,7 +699,9 @@ public static final class AccessTokenInfo { * Creates access token information. * * @param accessToken OAuth access token - * @param expiresInSeconds token lifetime in seconds + * @param expiresInSeconds token lifetime in seconds. A value of zero + * disables automatic renewal. A positive value must be small enough to + * produce a future expiration time in milliseconds. */ public AccessTokenInfo(String accessToken, long expiresInSeconds) { if (expiresInSeconds < 0) { diff --git a/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java b/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java index 0ad0a1ba..42010603 100644 --- a/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java +++ b/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java @@ -30,6 +30,7 @@ import java.util.logging.Logger; import javax.net.ssl.SSLException; +import oracle.nosql.driver.NoSQLException; import oracle.nosql.driver.RequestTimeoutException; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; @@ -49,6 +50,7 @@ public class HttpRequestUtil { private static final Charset utf8 = StandardCharsets.UTF_8; private static final int DEFAULT_DELAY_MS = 200; + private static final int RETRY_UNTIL_TIMEOUT = -1; /** * Issue HTTP GET request using given HTTP client with retries and general @@ -83,7 +85,32 @@ public static HttpResponse doGetRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, GET, - null /* no payload */, timeoutMs, logger); + null /* no payload */, timeoutMs, logger, + RETRY_UNTIL_TIMEOUT); + } + + /** + * Issues one HTTP GET request without retrying transport failures or + * server-error responses. + * + * @hidden + * + * @param httpClient a HTTP client + * @param uri the request URI + * @param headers HTTP headers of this request + * @param timeoutMs request timeout in milliseconds + * @param logger logger + * @return HTTP response + */ + public static HttpResponse doGetRequestOnce(HttpClient httpClient, + String uri, + HttpHeaders headers, + int timeoutMs, + Logger logger) { + + return doRequest(httpClient, uri, headers, GET, + null /* no payload */, timeoutMs, logger, + 0 /* no retries */); } /** @@ -122,7 +149,7 @@ public static HttpResponse doPostRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, POST, - payload, timeoutMs, logger); + payload, timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } /** @@ -161,7 +188,7 @@ public static HttpResponse doPutRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, PUT, - payload, timeoutMs, logger); + payload, timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } /** @@ -197,7 +224,7 @@ public static HttpResponse doDeleteRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, DELETE, null, - timeoutMs, logger); + timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } private static HttpResponse doRequest(HttpClient httpClient, @@ -206,7 +233,8 @@ private static HttpResponse doRequest(HttpClient httpClient, HttpMethod method, byte[] payload, int timeoutMs, - Logger logger) { + Logger logger, + int maxRetries) { final long startTime = System.currentTimeMillis(); int numRetries = 0; @@ -253,6 +281,9 @@ private static HttpResponse doRequest(HttpClient httpClient, * this indicates server internal error. */ if (res.getStatusCode() >= 500) { + if (!canRetry(numRetries, maxRetries)) { + return res; + } logFine(logger, "Remote server temporarily unavailable," + " status code " + res.getStatusCode() + @@ -275,7 +306,6 @@ private static HttpResponse doRequest(HttpClient httpClient, * disconnected. Retry. */ exception = ioe; - ++numRetries; if (ioe instanceof SSLException) { /* disconnect the channel to force a new one */ if (channel != null) { @@ -284,8 +314,14 @@ private static HttpResponse doRequest(HttpClient httpClient, channel.disconnect(); } } else { - delay(); + if (canRetry(numRetries, maxRetries)) { + delay(); + } + } + if (!canRetry(numRetries, maxRetries)) { + throw requestFailed(ioe); } + ++numRetries; continue; } catch (InterruptedException ie) { throw new RuntimeException( @@ -308,6 +344,9 @@ private static HttpResponse doRequest(HttpClient httpClient, name + "message: " + t.getMessage()); exception = t; + if (!canRetry(numRetries, maxRetries)) { + throw requestFailed(t); + } delay(); ++numRetries; continue; @@ -324,6 +363,14 @@ private static HttpResponse doRequest(HttpClient httpClient, exception); } + private static boolean canRetry(int numRetries, int maxRetries) { + return maxRetries == RETRY_UNTIL_TIMEOUT || numRetries < maxRetries; + } + + private static NoSQLException requestFailed(Throwable cause) { + return new NoSQLException("Unable to execute HTTP request", cause); + } + private static FullHttpRequest buildRequest(String requestURI, HttpMethod method, HttpHeaders headers) { 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 56efb397..fadd709c 100644 --- a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java +++ b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java @@ -29,6 +29,7 @@ import java.net.URL; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; import static org.junit.Assert.assertEquals; @@ -66,7 +67,11 @@ public void testInvalidAuthorizationExceptionRetry() public void testOAuthAuthenticationExceptionRetry() throws Exception { - testHttpClient.authenticationExceptionMode = true; + testHttpClient.oauthFailures = + new OAuthFailure[] { + OAuthFailure.AUTHENTICATION, + OAuthFailure.AUTHENTICATION + }; TestOAuthProvider provider = new TestOAuthProvider(); TestClient client = getTestClient(provider); @@ -82,12 +87,50 @@ public void testOAuthAuthenticationExceptionRetry() () -> client.execute(request)); assertEquals(2, testHttpClient.execCount.get()); assertEquals(2, testHttpClient.authenticationExceptionCount.get()); - assertEquals(1, provider.flushCount.get()); + assertEquals(1, provider.invalidationCount.get()); + assertEquals("Bearer Test-1", provider.lastInvalidated.get()); + assertEquals(0, provider.flushCount.get()); assertEquals(1, request.getRetryStats() .getNumExceptions(AuthenticationException.class)); } + @Test + public void testOAuthAlternatingAuthenticationThenAuthorization() { + assertOAuthFailureSequence( + OAuthFailure.AUTHENTICATION, + OAuthFailure.INVALID_AUTHORIZATION, + InvalidAuthorizationException.class); + } + + @Test + public void testOAuthAlternatingAuthorizationThenAuthentication() { + assertOAuthFailureSequence( + OAuthFailure.INVALID_AUTHORIZATION, + OAuthFailure.AUTHENTICATION, + AuthenticationException.class); + } + + private void assertOAuthFailureSequence( + OAuthFailure first, + OAuthFailure second, + Class expectedClass) { + + testHttpClient.oauthFailures = new OAuthFailure[] { first, second }; + TestOAuthProvider provider = new TestOAuthProvider(); + TestClient client = getTestClient(provider); + Request request = new GetRequest().setTableName("foo") + .setKey(new MapValue().put("foo", "bar")); + + assertThrows(expectedClass, () -> client.execute(request)); + assertEquals(2, testHttpClient.execCount.get()); + assertEquals(1, provider.invalidationCount.get()); + assertEquals("Bearer Test-1", provider.lastInvalidated.get()); + assertEquals("Bearer Test-2", provider.authorization.get()); + assertEquals(0, provider.flushCount.get()); + assertEquals(1, request.getRetryStats().getRetries()); + } + private TestClient getTestClient() { AuthorizationProvider provider = new AuthorizationProvider() { @@ -129,7 +172,7 @@ private static class TestHttpClient extends HttpClient { private final AtomicInteger iaeCount = new AtomicInteger(0); private final AtomicInteger authenticationExceptionCount = new AtomicInteger(0); - private boolean authenticationExceptionMode; + private OAuthFailure[] oauthFailures; public TestHttpClient() { super("localhost", 8080, 1, 0, 0, 0, 0, null, 0, "test", null); @@ -139,10 +182,16 @@ public TestHttpClient() { public void runRequest(HttpRequest request, ResponseHandler handler, Channel channel) { - if (authenticationExceptionMode) { - execCount.incrementAndGet(); - authenticationExceptionCount.incrementAndGet(); - throw new AuthenticationException("test"); + if (oauthFailures != null) { + final int index = execCount.getAndIncrement(); + final OAuthFailure failure = + oauthFailures[Math.min(index, oauthFailures.length - 1)]; + if (failure == OAuthFailure.AUTHENTICATION) { + authenticationExceptionCount.incrementAndGet(); + throw new AuthenticationException("test"); + } + iaeCount.incrementAndGet(); + throw new InvalidAuthorizationException("test"); } /* @@ -177,11 +226,26 @@ public boolean isActive() { private static class TestOAuthProvider extends OAuthAccessTokenProvider { + private final AtomicReference authorization = + new AtomicReference("Bearer Test-1"); + private final AtomicReference lastInvalidated = + new AtomicReference(); + private final AtomicInteger invalidationCount = new AtomicInteger(0); private final AtomicInteger flushCount = new AtomicInteger(0); @Override public String getAuthorizationString(Request request) { - return "Bearer Test"; + return authorization.get(); + } + + @Override + public boolean invalidateAuthorizationString( + String failedAuthorization) { + + invalidationCount.incrementAndGet(); + lastInvalidated.set(failedAuthorization); + return authorization.compareAndSet( + failedAuthorization, "Bearer Test-2"); } @Override @@ -194,4 +258,9 @@ protected AccessTokenInfo getAccessTokenInfo() { return new AccessTokenInfo("Test", 60); } } + + private enum OAuthFailure { + AUTHENTICATION, + INVALID_AUTHORIZATION + } } 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 c03c1073..3018d448 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -10,6 +10,7 @@ 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.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -21,9 +22,18 @@ import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.OutputStream; +import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; import oracle.nosql.driver.InvalidAuthorizationException; import oracle.nosql.driver.NoSQLException; @@ -35,6 +45,7 @@ import com.sun.net.httpserver.HttpServer; import org.junit.AfterClass; +import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -69,6 +80,10 @@ public class OAuthAccessTokenProviderTest { private static volatile boolean omitAuthenticatedIdentity; private static volatile long loginTokenLifetimeMs = 15_000; private static volatile long loginDelayMs; + private static volatile int loginStatus = HttpURLConnection.HTTP_OK; + private static volatile String loginErrorBody = ""; + private static volatile int logoutStatus = HttpURLConnection.HTTP_OK; + private static volatile String logoutErrorBody = ""; @BeforeClass public static void staticSetUp() throws Exception { @@ -100,6 +115,10 @@ public void handle(HttpExchange exchange) throw new IOException("Login handler interrupted", ie); } } + if (loginStatus != HttpURLConnection.HTTP_OK) { + sendResponse(exchange, loginStatus, loginErrorBody); + return; + } if (count == 1) { generateLoginToken( loginToken, @@ -124,8 +143,7 @@ public void handle(HttpExchange exchange) assertTrue(authString.startsWith(authTokenPrefix)); lastLogoutToken = readTokenFromAuth(authString); logoutCounter.incrementAndGet(); - exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0); - exchange.close(); + sendResponse(exchange, logoutStatus, logoutErrorBody); } }); } @@ -138,6 +156,20 @@ public static void staticTearDown() throws Exception { } } + @Before + public void resetTestState() { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + resetAuthenticatedIdentity(); + loginTokenLifetimeMs = 15_000; + loginDelayMs = 0; + loginStatus = HttpURLConnection.HTTP_OK; + loginErrorBody = ""; + logoutStatus = HttpURLConnection.HTTP_OK; + logoutErrorBody = ""; + } + @Test public void testBasic() throws Exception { loginCounter.set(0); @@ -380,6 +412,176 @@ public void testCloseLogsOutLoginToken() throws Exception { assertEquals(1, logoutCounter.get()); } + @Test + public void testCallbackCloseStopsLogin() { + ClosingCallbackProvider provider = new ClosingCallbackProvider(); + provider.setEndpoint(endpoint); + + assertNull(provider.getAuthorizationString(null)); + assertEquals(0, loginCounter.get()); + assertEquals(0, logoutCounter.get()); + } + + @Test + public void testAccessTokenLifetimeOverflowRejected() { + TestProvider provider = new TestProvider(Long.MAX_VALUE); + provider.setEndpoint(endpoint); + + try { + provider.getAuthorizationString(null); + fail("An overflowing access-token lifetime should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("lifetime")); + } finally { + provider.close(); + } + assertEquals(0, loginCounter.get()); + } + + @Test + public void testExpiredLoginTokenRejectedAndLoggedOut() { + loginTokenLifetimeMs = -1_000; + TestProvider provider = new TestProvider(60); + provider.setEndpoint(endpoint); + + try { + provider.getAuthorizationString(null); + fail("An expired login token should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains("expired login token")); + } finally { + provider.close(); + } + assertEquals(1, loginCounter.get()); + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testZeroLifetimeDoesNotScheduleRefresh() throws Exception { + TestProvider provider = new TestProvider(0); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + assertEquals(1, loginCounter.get()); + assertTrue(getScheduler(provider).getQueue().isEmpty()); + } finally { + provider.close(); + } + } + + @Test + public void testConditionalCacheInvalidation() { + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String first = provider.getAuthorizationString(null); + provider.flushCache(); + final String second = provider.getAuthorizationString(null); + + assertFalse(provider.invalidateAuthorizationString(first)); + assertEquals(second, provider.getAuthorizationString(null)); + assertEquals(2, loginCounter.get()); + + assertTrue(provider.invalidateAuthorizationString(second)); + assertNotNull(provider.getAuthorizationString(null)); + assertEquals(3, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testCancelledRefreshRemovedFromQueue() throws Exception { + TestProvider provider = new TestProvider(60); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + final ScheduledThreadPoolExecutor scheduler = + getScheduler(provider); + assertTrue(scheduler.getRemoveOnCancelPolicy()); + assertEquals(1, scheduler.getQueue().size()); + + provider.flushCache(); + assertTrue(scheduler.getQueue().isEmpty()); + } finally { + provider.close(); + } + } + + @Test + public void testRunningRefreshCancelledBeforeLogin() throws Exception { + BlockingRefreshProvider provider = new BlockingRefreshProvider(); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + assertTrue(provider.awaitRefreshCallback(5_000)); + + final Thread disableRenewal = + new Thread(() -> provider.setAutoRenew(false)); + disableRenewal.start(); + waitForAutoRenew(provider, false, 5_000); + provider.releaseRefreshCallback(); + disableRenewal.join(5_000); + + assertFalse(disableRenewal.isAlive()); + assertEquals(1, loginCounter.get()); + } finally { + provider.releaseRefreshCallback(); + provider.close(); + } + } + + @Test + public void testOAuthLogsExcludeSensitiveValues() throws Exception { + final TestLogHandler handler = new TestLogHandler(); + final Logger testLogger = createLogger(handler); + final String responseSecret = "REMOTE_RESPONSE_SECRET"; + + loginStatus = HttpURLConnection.HTTP_UNAVAILABLE; + loginErrorBody = responseSecret; + TestProvider failedLogin = new TestProvider(); + failedLogin.setEndpoint(endpoint).setLogger(testLogger); + try { + failedLogin.getAuthorizationString(null); + fail("The OAuth login should have failed"); + } catch (InvalidAuthorizationException expected) { + assertFalse(expected.getMessage().contains(responseSecret)); + } finally { + failedLogin.close(); + } + assertEquals(1, loginCounter.get()); + assertLogExcludes(handler, oauthAccessToken, responseSecret); + + resetTestState(); + logoutStatus = HttpURLConnection.HTTP_INTERNAL_ERROR; + logoutErrorBody = responseSecret; + TestProvider failedLogout = new TestProvider(); + failedLogout.setEndpoint(endpoint) + .setLogger(testLogger) + .setAutoRenew(false); + assertNotNull(failedLogout.getAuthorizationString(null)); + failedLogout.close(); + assertLogExcludes(handler, loginToken, responseSecret); + + resetTestState(); + final String callbackSecret = "CALLBACK_EXCEPTION_SECRET"; + FailingRefreshProvider failedRefresh = + new FailingRefreshProvider(callbackSecret); + failedRefresh.setEndpoint(endpoint).setLogger(testLogger); + try { + assertNotNull(failedRefresh.getAuthorizationString(null)); + failedRefresh.waitForRefreshAttempt(5_000); + } finally { + failedRefresh.close(); + } + assertLogExcludes(handler, oauthAccessToken, callbackSecret); + } + private void tryBadEndpoint(String ep) { TestProvider provider = new TestProvider(); try { @@ -435,6 +637,20 @@ private static void generateLoginToken(String tokenText, } } + private static void sendResponse(HttpExchange exchange, + int status, + String body) + throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + if (bytes.length > 0) { + os.write(bytes); + } + } + } + private static String readTokenFromAuth(String authString) { final String authEncoded = authString.substring(authTokenPrefix.length()); @@ -468,6 +684,49 @@ private static void waitForAuthorizationToken( fail("Timed out waiting for refreshed OAuth login token"); } + private static void waitForAutoRenew(OAuthAccessTokenProvider provider, + boolean expected, + long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (provider.isAutoRenew() != expected && + System.currentTimeMillis() < limit) { + Thread.sleep(10); + } + assertEquals(expected, provider.isAutoRenew()); + } + + private static ScheduledThreadPoolExecutor getScheduler( + OAuthAccessTokenProvider provider) + throws Exception { + + final Field field = + OAuthAccessTokenProvider.class.getDeclaredField("scheduler"); + field.setAccessible(true); + return (ScheduledThreadPoolExecutor) field.get(provider); + } + + private static Logger createLogger(Handler handler) { + final Logger logger = Logger.getLogger( + OAuthAccessTokenProviderTest.class.getName() + "." + + System.nanoTime()); + logger.setUseParentHandlers(false); + logger.setLevel(Level.ALL); + handler.setLevel(Level.ALL); + logger.addHandler(handler); + return logger; + } + + private static void assertLogExcludes(TestLogHandler handler, + String... excludedValues) { + final String messages = handler.getMessages(); + for (String value : excludedValues) { + assertFalse("Log contains sensitive value: " + value, + messages.contains(value)); + } + } + private static class TestProvider extends OAuthAccessTokenProvider { private final AtomicInteger tokenCounter = new AtomicInteger(); @@ -491,17 +750,73 @@ protected AccessTokenInfo getAccessTokenInfo() { } } + private static class ClosingCallbackProvider + extends OAuthAccessTokenProvider { + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + close(); + return new AccessTokenInfo(oauthAccessToken, 60); + } + } + + private static class BlockingRefreshProvider + extends OAuthAccessTokenProvider { + + private final AtomicInteger callbackCount = new AtomicInteger(); + private final CountDownLatch refreshCallback = new CountDownLatch(1); + private final CountDownLatch releaseRefresh = new CountDownLatch(1); + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + if (callbackCount.incrementAndGet() == 1) { + return new AccessTokenInfo(oauthAccessToken, 11); + } + refreshCallback.countDown(); + try { + if (!releaseRefresh.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "Timed out waiting to release refresh callback"); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Refresh callback interrupted", ie); + } + return new AccessTokenInfo(secondOAuthAccessToken, 60); + } + + private boolean awaitRefreshCallback(long timeoutMs) + throws InterruptedException { + + return refreshCallback.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + private void releaseRefreshCallback() { + releaseRefresh.countDown(); + } + } + private static class FailingRefreshProvider extends OAuthAccessTokenProvider { private final AtomicInteger tokenCounter = new AtomicInteger(); + private final String failureMessage; + + FailingRefreshProvider() { + this("test refresh failure"); + } + + FailingRefreshProvider(String failureMessage) { + this.failureMessage = failureMessage; + } @Override protected AccessTokenInfo getAccessTokenInfo() { if (tokenCounter.incrementAndGet() == 1) { return new AccessTokenInfo(oauthAccessToken, 12); } - throw new IllegalStateException("test refresh failure"); + throw new IllegalStateException(failureMessage); } private void waitForRefreshAttempt(long timeoutMs) @@ -516,4 +831,28 @@ private void waitForRefreshAttempt(long timeoutMs) tokenCounter.get() >= 2); } } + + private static class TestLogHandler extends Handler { + + private final StringBuilder messages = new StringBuilder(); + + @Override + public synchronized void publish(LogRecord record) { + if (isLoggable(record)) { + messages.append(record.getMessage()).append('\n'); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + private synchronized String getMessages() { + return messages.toString(); + } + } } From 6fb41b1a096a87cc584ba6a0eb1eaeebdc7cfefb Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Thu, 13 Aug 2026 20:50:37 +0530 Subject: [PATCH 09/13] Simplify OAuth token renewal and harden login validation Use the KV-authenticated NoSQL session expiration as the single source of truth for SDK OAuth re-login scheduling. Replace AccessTokenInfo with a string-only getAccessToken callback so applications manage token acquisition without supplying duplicate or potentially stale lifetime metadata. Require HTTPS handle configuration even when an OAuth endpoint is supplied explicitly, complete missing SSL settings from NoSQLHandleConfig, and validate oauthlogin response fields strictly. Reject non-future login-token deadlines and best-effort logout sessions returned in malformed or identity-mismatched responses. Normalize OAuth retry accounting to base authentication exception classes so exception subclasses cannot bypass the shared one-retry allowance. Preserve conditional cache invalidation and refresh-generation cancellation behavior. Update unit coverage for response validation, TLS preparation, server-driven refresh timing, callback and refresh races, retry subclasses, cleanup, and sensitive logging. Update the on-prem OAuth example and README for the string-only callback API. Validation: focused OAuth and retry unit tests, examples reactor packaging, and driver Javadocs. --- README.md | 16 +- .../java/oracle/nosql/driver/http/Client.java | 7 +- .../nosql/driver/http/NoSQLHandleImpl.java | 7 +- .../driver/kv/OAuthAccessTokenProvider.java | 281 ++++++++++-------- .../nosql/driver/iam/AuthRetryTest.java | 54 +++- .../kv/OAuthAccessTokenProviderTest.java | 238 +++++++++++---- examples/src/main/java/Common.java | 30 +- 7 files changed, 406 insertions(+), 227 deletions(-) diff --git a/README.md b/README.md index b64817f6..a1ec53b0 100644 --- a/README.md +++ b/README.md @@ -601,17 +601,17 @@ login token through a secure on-premises proxy by using the `-useOAuth` flag. The store and proxy must already be configured for OAuth, and the OAuth principal must have the privileges required by the selected example. -The example reads a single access token and its remaining lifetime from -environment variables. Supplying the token this way keeps the example -independent of the identity provider and avoids placing the bearer token in -the command line. A production application should obtain fresh tokens in -`OAuthAccessTokenProvider.getAccessTokenInfo()` and leave automatic renewal -enabled. +The example reads a single access token from an environment variable. +Supplying the token this way keeps the example independent of the identity +provider and avoids placing the bearer token in the command line. A production +application should obtain a usable token in +`OAuthAccessTokenProvider.getAccessToken()` and leave automatic renewal +enabled. The SDK schedules re-login from the NoSQL login-token expiration +returned by the server. -Run the example using an OAuth token that is valid for another 300 seconds: +Run the example using an OAuth access token: $ export NOSQL_OAUTH_ACCESS_TOKEN='' - $ export NOSQL_OAUTH_EXPIRES_IN_SECONDS=300 $ java -Djavax.net.ssl.trustStorePassword=123456 \ -Djavax.net.ssl.trustStore=driver.trust -cp .:../lib/nosqldriver.jar \ BasicTableExample https://localhost:443 -useKVProxy -useOAuth 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 0a3ac7af..ec1b4f91 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/Client.java +++ b/driver/src/main/java/oracle/nosql/driver/http/Client.java @@ -866,7 +866,8 @@ public Result execute(Request kvRequest) { } ((OAuthAccessTokenProvider) authProvider) .invalidateAuthorizationString(authString); - kvRequest.addRetryException(rae.getClass()); + kvRequest.addRetryException( + AuthenticationException.class); kvRequest.incrementRetries(); exception = rae; logFine(logger, @@ -916,7 +917,9 @@ public Result execute(Request kvRequest) { } else { authProvider.flushCache(); } - kvRequest.addRetryException(iae.getClass()); + kvRequest.addRetryException( + oauthProvider ? InvalidAuthorizationException.class : + iae.getClass()); kvRequest.incrementRetries(); exception = iae; if (oauthProvider) { 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 90e169d1..a48de937 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java +++ b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java @@ -166,12 +166,7 @@ private void configAuthProvider(Logger logger, NoSQLHandleConfig config) { if (oatProvider.getLogger() == null) { oatProvider.setLogger(logger); } - if (oatProvider.getEndpoint() == null) { - oatProvider.setEndpoint(getAuthEndpoint(config)) - .setSslContext(config.getSslContext()) - .setSslHandshakeTimeout( - config.getSSLHandshakeTimeout()); - } + oatProvider.prepare(config); } else if (ap instanceof SignatureProvider) { SignatureProvider sigProvider = (SignatureProvider) ap; if (sigProvider.getLogger() == null) { 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 f56f419f..f8e17148 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -32,9 +32,27 @@ import oracle.nosql.driver.ops.Request; import oracle.nosql.driver.util.HttpRequestUtil; import oracle.nosql.driver.util.HttpRequestUtil.HttpResponse; +import oracle.nosql.driver.values.FieldValue; import oracle.nosql.driver.values.JsonUtils; import oracle.nosql.driver.values.MapValue; +/** + * On-premises only. + * + *

An authorization provider that exchanges an application-supplied OAuth + * access token for a NoSQL login token through an OAuth-enabled proxy. The + * NoSQL login token is cached and used to authorize subsequent operations.

+ * + *

Applications implement {@link #getAccessToken()} and remain responsible + * for acquiring and maintaining OAuth tokens. By default, this provider calls + * that method again and performs a new login shortly before the current NoSQL + * login token expires. The server-returned expiration is bounded by both the + * validated OAuth token expiration and the configured store session timeout. + * Automatic re-login can be disabled with {@link #setAutoRenew(boolean)}.

+ * + *

OAuth access tokens and NoSQL login tokens are bearer credentials, so an + * HTTPS service endpoint is required.

+ */ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider { @@ -66,18 +84,10 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider 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. + * The server caps this deadline at the earlier of the validated OAuth + * access-token expiration and the configured store session timeout. */ private long loginTokenExpireAt; @@ -86,7 +96,7 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private OAuthIdentity authenticatedIdentity; - /* Default refresh time before effective token expiry, 10 seconds */ + /* Default refresh time before NoSQL login-token expiry, 10 seconds */ private static final int REFRESH_AHEAD_SECONDS = 10; /* @@ -153,6 +163,9 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider private final AtomicLong refreshGeneration = new AtomicLong(); + /** + * Creates a provider with automatic re-login enabled. + */ public OAuthAccessTokenProvider() { loginHost = null; endpoint = null; @@ -167,13 +180,19 @@ public OAuthAccessTokenProvider() { } /** - * 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 + * Returns an OAuth access token for a login exchange. + * + *

This method is called for the initial login and each subsequent + * re-login. Implementations are responsible for obtaining a token that is + * usable when returned, including refreshing or replacing a cached token + * when necessary. Implementations should avoid returning a token they know + * is expired; the server performs the authoritative token validation. The + * SDK schedules re-login from the NoSQL login-token expiration returned by + * the server.

+ * + * @return an OAuth access token */ - protected abstract AccessTokenInfo getAccessTokenInfo(); + protected abstract String getAccessToken(); private synchronized void performLogin(boolean force, Request request, @@ -184,14 +203,10 @@ private synchronized void performLogin(boolean force, return; } - final AccessTokenInfo newTokenInfo = - validateAccessTokenInfo(getAccessTokenInfo()); + final String accessToken = validateAccessToken(getAccessToken()); if (loginAborted(force, expectedGeneration)) { return; } - final long accessTokenAcquireTime = System.currentTimeMillis(); - final long newAccessTokenExpireAt = - getAccessTokenExpireAt(newTokenInfo, accessTokenAcquireTime); final int timeoutMs = (request != null) ? request.getTimeoutInternal() : 0; @@ -203,7 +218,7 @@ private synchronized void performLogin(boolean force, return; } HttpResponse response = - sendRequest(BEARER_PREFIX + newTokenInfo.getAccessToken(), + sendRequest(BEARER_PREFIX + accessToken, LOGIN_SERVICE, timeoutMs); if (loginAborted(force, expectedGeneration)) { @@ -223,17 +238,14 @@ private synchronized void performLogin(boolean force, /* * Generate the authentication string using login token */ - final LoginResult loginResult = - parseJsonResult(response.getOutput()); + final LoginResult loginResult; try { + loginResult = parseJsonResult(response.getOutput()); validateLoginTokenExpiration(loginResult); validateAuthenticatedIdentity( loginResult.getAuthenticatedIdentity()); } catch (InvalidAuthorizationException iae) { - final String rejectedToken = loginResult.getToken(); - if (rejectedToken != null && !rejectedToken.isEmpty()) { - logoutSession(BEARER_PREFIX + rejectedToken, timeoutMs); - } + logoutLoginResponse(response, timeoutMs); throw iae; } if (loginAborted(force, expectedGeneration) || @@ -244,11 +256,10 @@ private synchronized void performLogin(boolean force, BEARER_PREFIX + loginResult.getToken(), timeoutMs); return; } - tokenInfo = newTokenInfo; - accessTokenExpireAt = newAccessTokenExpireAt; loginTokenExpireAt = loginResult.getExpireAt(); /* - * Schedule access token refresh thread + * Schedule re-login using the server-authoritative session + * expiration. */ scheduleRefresh(); @@ -267,23 +278,6 @@ private boolean loginAborted(boolean force, long expectedGeneration) { (!force && authString.get() != null); } - private long getAccessTokenExpireAt(AccessTokenInfo accessTokenInfo, - long acquireTime) { - final long expiresInSeconds = - accessTokenInfo.getExpiresInSeconds(); - if (expiresInSeconds == 0) { - return 0; - } - try { - return Math.addExact( - acquireTime, - Math.multiplyExact(expiresInSeconds, 1000L)); - } catch (ArithmeticException ae) { - throw new IllegalArgumentException( - "Access token lifetime is too large", ae); - } - } - /** * @hidden */ @@ -329,8 +323,6 @@ public void close() { } cancelRefreshTask(); - tokenInfo = null; - accessTokenExpireAt = 0; loginTokenExpireAt = 0; authenticatedIdentity = null; } @@ -369,7 +361,7 @@ public void flushCache() { } authString.set(null); cancelRefreshTask(); - clearTokenExpirationState(); + clearLoginTokenExpiration(); } } @@ -394,55 +386,67 @@ public boolean invalidateAuthorizationString(String failedAuthorization) { return false; } if (!authString.compareAndSet(failedAuthorization, null)) { - if (authString.get() != null && tokenInfo != null) { + if (authString.get() != null && loginTokenExpireAt > 0) { scheduleRefresh(); } return false; } cancelRefreshTask(); - clearTokenExpirationState(); + clearLoginTokenExpiration(); return true; } } - private void clearTokenExpirationState() { - tokenInfo = null; - accessTokenExpireAt = 0; + private void clearLoginTokenExpiration() { loginTokenExpireAt = 0; } - private AccessTokenInfo validateAccessTokenInfo( - AccessTokenInfo accessTokenInfo) { - - if (accessTokenInfo == null || - accessTokenInfo.getAccessToken() == null || - accessTokenInfo.getAccessToken().isEmpty()) { + private String validateAccessToken(String accessToken) { + if (accessToken == null || accessToken.isEmpty()) { throw new IllegalArgumentException( "Invalid access token provided"); } - return accessTokenInfo; + return accessToken; } /** * Retrieve login token from JSON string. */ private LoginResult parseJsonResult(String jsonResult) { - final MapValue mapValue = - JsonUtils.createValueFromJson(jsonResult, null).asMap(); + try { + final FieldValue result = + JsonUtils.createValueFromJson(jsonResult, null); + if (!result.isMap()) { + throw new IllegalArgumentException("Expected JSON object"); + } + final MapValue mapValue = result.asMap(); - /* - * Extract login token, expiration, and authenticated identity from - * JSON result. - */ - return new LoginResult( - mapValue.getString("token"), - mapValue.getLong("expireAt"), - parseAuthenticatedIdentity(mapValue)); + /* + * Extract login token, expiration, and authenticated identity from + * JSON result. Do not use the coercive MapValue getters here; the + * endpoint contract requires exact JSON types. + */ + final String token = getRequiredString(mapValue, "token"); + final FieldValue expireAtValue = mapValue.get("expireAt"); + if (expireAtValue == null || + !(expireAtValue.isLong() || expireAtValue.isInteger())) { + throw new IllegalArgumentException("Invalid expireAt"); + } + return new LoginResult( + token, + expireAtValue.getLong(), + parseAuthenticatedIdentity(mapValue)); + } catch (InvalidAuthorizationException iae) { + throw iae; + } catch (RuntimeException re) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response"); + } } private void validateLoginTokenExpiration(LoginResult loginResult) { final long expireAt = loginResult.getExpireAt(); - if (expireAt > 0 && expireAt <= System.currentTimeMillis()) { + if (expireAt <= System.currentTimeMillis()) { throw new InvalidAuthorizationException( "OAuth login response contains an expired login token"); } @@ -456,8 +460,10 @@ private void logoutLoginResponse(HttpResponse response, int timeoutMs) { final MapValue loginResult = JsonUtils.createValueFromJson( response.getOutput(), null).asMap(); - final String token = loginResult.getString("token"); - if (token != null && !token.isEmpty()) { + final FieldValue tokenValue = loginResult.get("token"); + if (tokenValue != null && tokenValue.isString() && + !tokenValue.getString().trim().isEmpty()) { + final String token = tokenValue.getString(); logoutSession(BEARER_PREFIX + token, timeoutMs); } } catch (RuntimeException re) { @@ -469,17 +475,21 @@ private void logoutLoginResponse(HttpResponse response, int timeoutMs) { } private OAuthIdentity parseAuthenticatedIdentity(MapValue loginResult) { - if (!loginResult.contains("authenticatedIdentity")) { + final FieldValue identityValue = + loginResult.get("authenticatedIdentity"); + if (identityValue == null) { return null; } try { - final MapValue identity = - loginResult.get("authenticatedIdentity").asMap(); + if (!identityValue.isMap()) { + throw new IllegalArgumentException("Expected identity object"); + } + final MapValue identity = identityValue.asMap(); return new OAuthIdentity( - identity.getString("type"), - identity.getString("issuer"), - identity.getString("subjectType"), - identity.getString("subjectId")); + getRequiredString(identity, "type"), + getRequiredString(identity, "issuer"), + getRequiredString(identity, "subjectType"), + getRequiredString(identity, "subjectId")); } catch (RuntimeException re) { throw new InvalidAuthorizationException( "Invalid OAuth login response: authenticated identity is " + @@ -487,6 +497,16 @@ private OAuthIdentity parseAuthenticatedIdentity(MapValue loginResult) { } } + private static String getRequiredString(MapValue value, String fieldName) { + final FieldValue field = value.get(fieldName); + if (field == null || !field.isString() || + field.getString().trim().isEmpty()) { + throw new IllegalArgumentException( + "Missing or invalid " + fieldName); + } + return field.getString(); + } + private void validateAuthenticatedIdentity(OAuthIdentity identity) { if (identity == null) { throw new InvalidAuthorizationException( @@ -503,21 +523,18 @@ private void validateAuthenticatedIdentity(OAuthIdentity identity) { } } - /* Schedule automatic re-login slightly before expiry */ + /* Schedule automatic re-login slightly before session expiry. */ private synchronized void scheduleRefresh() { final long generation = refreshGeneration.incrementAndGet(); cancelRefreshTask(); - if (!autoRenew || isClosed.get() || tokenInfo == null || - tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) { + if (!autoRenew || isClosed.get() || authString.get() == null || + loginTokenExpireAt <= 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 - + loginTokenExpireAt - now - TimeUnit.SECONDS.toMillis(REFRESH_AHEAD_SECONDS)); refreshTask = scheduler.schedule(new Runnable() { @Override @@ -606,6 +623,46 @@ public OAuthAccessTokenProvider setEndpoint(String endpoint) { return this; } + /** + * Internal use only. + *

+ * Completes handle-dependent configuration while preserving an explicitly + * configured OAuth endpoint or SSL context. + * + * @param config the handle configuration + * @return this + * @hidden + */ + public OAuthAccessTokenProvider prepare(NoSQLHandleConfig config) { + final URL serviceURL = config.getServiceURL(); + if (serviceURL == null || + !"https".equalsIgnoreCase(serviceURL.getProtocol())) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires use of https for the " + + "service endpoint"); + } + + if (endpoint == null) { + String serviceEndpoint = serviceURL.toString(); + if (serviceEndpoint.endsWith("/")) { + serviceEndpoint = serviceEndpoint.substring( + 0, serviceEndpoint.length() - 1); + } + setEndpoint(serviceEndpoint); + } + if (sslContext == null) { + sslContext = config.getSslContext(); + } + if (sslHandshakeTimeoutMs == 0) { + sslHandshakeTimeoutMs = config.getSSLHandshakeTimeout(); + } + if (!disableSSLHook && sslContext == null) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires an SSL context"); + } + return this; + } + /** * Sets the SSL context * @param sslCtx the context @@ -665,6 +722,10 @@ private HttpResponse sendRequest(String authHeader, int timeoutMs) throws Exception { HttpClient client = null; try { + if (!disableSSLHook && sslContext == null) { + throw new IllegalStateException( + "OAuthAccessTokenProvider requires an SSL context"); + } final HttpHeaders headers = new DefaultHttpHeaders(); headers.set(AUTHORIZATION, authHeader); client = HttpClient.createMinimalClient @@ -689,44 +750,6 @@ private HttpResponse sendRequest(String authHeader, } } - /** 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. A value of zero - * disables automatic renewal. A positive value must be small enough to - * produce a future expiration time in milliseconds. - */ - 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; 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 fadd709c..722372ae 100644 --- a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java +++ b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java @@ -111,6 +111,22 @@ public void testOAuthAlternatingAuthorizationThenAuthentication() { AuthenticationException.class); } + @Test + public void testOAuthAuthenticationSubclassRetryIsBounded() { + assertOAuthFailureSequence( + OAuthFailure.AUTHENTICATION_SUBCLASS, + OAuthFailure.INVALID_AUTHORIZATION, + InvalidAuthorizationException.class); + } + + @Test + public void testOAuthAuthorizationSubclassRetryIsBounded() { + assertOAuthFailureSequence( + OAuthFailure.INVALID_AUTHORIZATION_SUBCLASS, + OAuthFailure.AUTHENTICATION, + AuthenticationException.class); + } + private void assertOAuthFailureSequence( OAuthFailure first, OAuthFailure second, @@ -186,11 +202,19 @@ public void runRequest(HttpRequest request, final int index = execCount.getAndIncrement(); final OAuthFailure failure = oauthFailures[Math.min(index, oauthFailures.length - 1)]; - if (failure == OAuthFailure.AUTHENTICATION) { + if (failure == OAuthFailure.AUTHENTICATION || + failure == OAuthFailure.AUTHENTICATION_SUBCLASS) { authenticationExceptionCount.incrementAndGet(); + if (failure == OAuthFailure.AUTHENTICATION_SUBCLASS) { + throw new TestAuthenticationException("test"); + } throw new AuthenticationException("test"); } iaeCount.incrementAndGet(); + if (failure == + OAuthFailure.INVALID_AUTHORIZATION_SUBCLASS) { + throw new TestInvalidAuthorizationException("test"); + } throw new InvalidAuthorizationException("test"); } @@ -254,13 +278,35 @@ public void flushCache() { } @Override - protected AccessTokenInfo getAccessTokenInfo() { - return new AccessTokenInfo("Test", 60); + protected String getAccessToken() { + return "Test"; } } private enum OAuthFailure { AUTHENTICATION, - INVALID_AUTHORIZATION + AUTHENTICATION_SUBCLASS, + INVALID_AUTHORIZATION, + INVALID_AUTHORIZATION_SUBCLASS + } + + private static class TestAuthenticationException + extends AuthenticationException { + + private static final long serialVersionUID = 1L; + + private TestAuthenticationException(String message) { + super(message); + } + } + + private static class TestInvalidAuthorizationException + extends InvalidAuthorizationException { + + private static final long serialVersionUID = 1L; + + private TestInvalidAuthorizationException(String message) { + super(message); + } } } 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 3018d448..d1e8765c 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -36,6 +36,9 @@ import java.util.logging.Logger; import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLHandle; +import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.NoSQLHandleFactory; import oracle.nosql.driver.NoSQLException; import oracle.nosql.driver.ops.GetRequest; import oracle.nosql.driver.values.JsonUtils; @@ -82,6 +85,7 @@ public class OAuthAccessTokenProviderTest { private static volatile long loginDelayMs; private static volatile int loginStatus = HttpURLConnection.HTTP_OK; private static volatile String loginErrorBody = ""; + private static volatile String loginResponseOverride; private static volatile int logoutStatus = HttpURLConnection.HTTP_OK; private static volatile String logoutErrorBody = ""; @@ -119,6 +123,11 @@ public void handle(HttpExchange exchange) sendResponse(exchange, loginStatus, loginErrorBody); return; } + if (loginResponseOverride != null) { + sendResponse(exchange, HttpURLConnection.HTTP_OK, + loginResponseOverride); + return; + } if (count == 1) { generateLoginToken( loginToken, @@ -166,6 +175,7 @@ public void resetTestState() { loginDelayMs = 0; loginStatus = HttpURLConnection.HTTP_OK; loginErrorBody = ""; + loginResponseOverride = null; logoutStatus = HttpURLConnection.HTTP_OK; logoutErrorBody = ""; } @@ -233,7 +243,7 @@ public void testLoginTokenExpiryControlsRefresh() throws Exception { logoutCounter.set(0); resetAuthenticatedIdentity(); loginTokenLifetimeMs = 12_000; - TestProvider provider = new TestProvider(60); + TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint); try { @@ -253,6 +263,7 @@ public void testRefreshFailureRetainsLoginToken() throws Exception { loginCounter.set(0); logoutCounter.set(0); resetAuthenticatedIdentity(); + loginTokenLifetimeMs = 12_000; FailingRefreshProvider provider = new FailingRefreshProvider(); provider.setEndpoint(endpoint); @@ -423,15 +434,21 @@ public void testCallbackCloseStopsLogin() { } @Test - public void testAccessTokenLifetimeOverflowRejected() { - TestProvider provider = new TestProvider(Long.MAX_VALUE); + public void testMissingAccessTokenRejectedBeforeLogin() { + OAuthAccessTokenProvider provider = + new OAuthAccessTokenProvider() { + @Override + protected String getAccessToken() { + return null; + } + }; provider.setEndpoint(endpoint); try { provider.getAuthorizationString(null); - fail("An overflowing access-token lifetime should be rejected"); + fail("A missing access token should be rejected"); } catch (IllegalArgumentException expected) { - assertTrue(expected.getMessage().contains("lifetime")); + assertTrue(expected.getMessage().contains("access token")); } finally { provider.close(); } @@ -441,7 +458,7 @@ public void testAccessTokenLifetimeOverflowRejected() { @Test public void testExpiredLoginTokenRejectedAndLoggedOut() { loginTokenLifetimeMs = -1_000; - TestProvider provider = new TestProvider(60); + TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint); try { @@ -458,19 +475,120 @@ public void testExpiredLoginTokenRejectedAndLoggedOut() { } @Test - public void testZeroLifetimeDoesNotScheduleRefresh() throws Exception { - TestProvider provider = new TestProvider(0); + public void testMalformedIdentityRejectedAndLoggedOut() { + final long expireAt = System.currentTimeMillis() + 60_000; + final String encodedToken = encodeLoginToken(loginToken, expireAt); + loginResponseOverride = + "{\"token\":\"" + encodedToken + "\"," + + "\"expireAt\":" + expireAt + "," + + "\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + loginIssuer + "\"," + + "\"subjectType\":\"user\"}}"; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A malformed authenticated identity should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains( + "authenticated identity is invalid")); + } finally { + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testNonPositiveLoginExpiryRejectedAndLoggedOut() { + final String encodedToken = encodeLoginToken(loginToken, 0); + loginResponseOverride = createLoginResponse( + encodedToken, 0, loginIssuer, loginSubjectType, loginSubjectId); + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A non-positive login-token expiration should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains("expired login token")); + } finally { + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testNullLoginTokenRejected() { + final long expireAt = System.currentTimeMillis() + 60_000; + loginResponseOverride = + "{\"token\":null," + + "\"expireAt\":" + expireAt + "," + + "\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + loginIssuer + "\"," + + "\"subjectType\":\"user\"," + + "\"subjectId\":\"" + loginSubjectId + "\"}}"; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A null login token should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains( + "Invalid OAuth login response")); + } finally { + provider.close(); + } + assertEquals(0, logoutCounter.get()); + } + + @Test + public void testPreconfiguredEndpointStillRequiresHttpsHandle() { + TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint); + final NoSQLHandleConfig config = + new NoSQLHandleConfig("http://localhost:8080") + .setAuthorizationProvider(provider); try { - assertNotNull(provider.getAuthorizationString(null)); - assertEquals(1, loginCounter.get()); - assertTrue(getScheduler(provider).getQueue().isEmpty()); + NoSQLHandleFactory.createNoSQLHandle(config); + fail("An OAuth handle using HTTP should have been rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("service endpoint")); } finally { provider.close(); } } + @Test + public void testPreconfiguredEndpointReceivesHandleSslContext() + throws Exception { + + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + final NoSQLHandleConfig config = + new NoSQLHandleConfig("https://localhost:1445") + .setAuthorizationProvider(provider); + NoSQLHandle handle = null; + + try { + handle = NoSQLHandleFactory.createNoSQLHandle(config); + assertNotNull(getProviderField(provider, "sslContext")); + assertEquals(endpoint, provider.getEndpoint()); + } finally { + if (handle != null) { + handle.close(); + } else { + provider.close(); + } + } + } + @Test public void testConditionalCacheInvalidation() { TestProvider provider = new TestProvider(); @@ -495,7 +613,7 @@ public void testConditionalCacheInvalidation() { @Test public void testCancelledRefreshRemovedFromQueue() throws Exception { - TestProvider provider = new TestProvider(60); + TestProvider provider = new TestProvider(); provider.setEndpoint(endpoint); try { @@ -514,6 +632,7 @@ public void testCancelledRefreshRemovedFromQueue() throws Exception { @Test public void testRunningRefreshCancelledBeforeLogin() throws Exception { + loginTokenLifetimeMs = 11_000; BlockingRefreshProvider provider = new BlockingRefreshProvider(); provider.setEndpoint(endpoint); @@ -569,6 +688,7 @@ public void testOAuthLogsExcludeSensitiveValues() throws Exception { assertLogExcludes(handler, loginToken, responseSecret); resetTestState(); + loginTokenLifetimeMs = 12_000; final String callbackSecret = "CALLBACK_EXCEPTION_SECRET"; FailingRefreshProvider failedRefresh = new FailingRefreshProvider(callbackSecret); @@ -604,29 +724,12 @@ private static void generateLoginToken(String tokenText, String subjectType, String subjectId, HttpExchange exchange) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos); - OutputStream os = exchange.getResponseBody()) { - + try (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 + - (issuer != null ? - ",\"authenticatedIdentity\":{" + - "\"type\":\"oauth\"," + - "\"issuer\":\"" + issuer + "\"," + - "\"subjectType\":\"" + subjectType + "\"," + - "\"subjectId\":\"" + subjectId + "\"}" : "") + - "}"; + final String jsonString = createLoginResponse( + encodeLoginToken(tokenText, expireTime), expireTime, + issuer, subjectType, subjectId); exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, jsonString.length()); @@ -637,6 +740,35 @@ private static void generateLoginToken(String tokenText, } } + private static String encodeLoginToken(String tokenText, long expireAt) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeShort(1); + oos.writeLong(expireAt); + oos.writeBytes(tokenText); + oos.flush(); + return JsonUtils.convertBytesToHex(baos.toByteArray()); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to encode", ioe); + } + } + + private static String createLoginResponse(String encodedToken, + long expireAt, + String issuer, + String subjectType, + String subjectId) { + return "{\"token\":\"" + encodedToken + "\"," + + "\"expireAt\":" + expireAt + + (issuer != null ? + ",\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + issuer + "\"," + + "\"subjectType\":\"" + subjectType + "\"," + + "\"subjectId\":\"" + subjectId + "\"}" : "") + + "}"; + } + private static void sendResponse(HttpExchange exchange, int status, String body) @@ -707,6 +839,16 @@ private static ScheduledThreadPoolExecutor getScheduler( return (ScheduledThreadPoolExecutor) field.get(provider); } + private static Object getProviderField(OAuthAccessTokenProvider provider, + String fieldName) + throws Exception { + + final Field field = + OAuthAccessTokenProvider.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(provider); + } + private static Logger createLogger(Handler handler) { final Logger logger = Logger.getLogger( OAuthAccessTokenProviderTest.class.getName() + "." + @@ -730,23 +872,13 @@ private static void assertLogExcludes(TestLogHandler handler, 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() { + protected String getAccessToken() { if (tokenCounter.incrementAndGet() == 1) { - return new AccessTokenInfo(oauthAccessToken, expiresInSeconds); + return oauthAccessToken; } - return new AccessTokenInfo(secondOAuthAccessToken, - expiresInSeconds); + return secondOAuthAccessToken; } } @@ -754,9 +886,9 @@ private static class ClosingCallbackProvider extends OAuthAccessTokenProvider { @Override - protected AccessTokenInfo getAccessTokenInfo() { + protected String getAccessToken() { close(); - return new AccessTokenInfo(oauthAccessToken, 60); + return oauthAccessToken; } } @@ -768,9 +900,9 @@ private static class BlockingRefreshProvider private final CountDownLatch releaseRefresh = new CountDownLatch(1); @Override - protected AccessTokenInfo getAccessTokenInfo() { + protected String getAccessToken() { if (callbackCount.incrementAndGet() == 1) { - return new AccessTokenInfo(oauthAccessToken, 11); + return oauthAccessToken; } refreshCallback.countDown(); try { @@ -783,7 +915,7 @@ protected AccessTokenInfo getAccessTokenInfo() { throw new IllegalStateException( "Refresh callback interrupted", ie); } - return new AccessTokenInfo(secondOAuthAccessToken, 60); + return secondOAuthAccessToken; } private boolean awaitRefreshCallback(long timeoutMs) @@ -812,9 +944,9 @@ private static class FailingRefreshProvider } @Override - protected AccessTokenInfo getAccessTokenInfo() { + protected String getAccessToken() { if (tokenCounter.incrementAndGet() == 1) { - return new AccessTokenInfo(oauthAccessToken, 12); + return oauthAccessToken; } throw new IllegalStateException(failureMessage); } diff --git a/examples/src/main/java/Common.java b/examples/src/main/java/Common.java index deeef261..a24d34aa 100644 --- a/examples/src/main/java/Common.java +++ b/examples/src/main/java/Common.java @@ -78,9 +78,8 @@ * BasicTableExample https://localhost:443 -useKVProxy -user driver \ * -password Driver.User@01 * - * Run against an OAuth-enabled secure proxy and store. The access token and - * its remaining lifetime are supplied in the NOSQL_OAUTH_ACCESS_TOKEN and - * NOSQL_OAUTH_EXPIRES_IN_SECONDS environment variables: + * Run against an OAuth-enabled secure proxy and store. The access token is + * supplied in the NOSQL_OAUTH_ACCESS_TOKEN environment variable: * java -Djavax.net.ssl.trustStorePassword=123456 \ * -Djavax.net.ssl.trustStore=driver.trust -cp .:../lib/nosqldriver.jar \ * BasicTableExample https://localhost:443 -useKVProxy -useOAuth @@ -112,8 +111,6 @@ class Common { private static final String OAUTH_FLAG = "-useOAuth"; private static final String OAUTH_ACCESS_TOKEN_ENV = "NOSQL_OAUTH_ACCESS_TOKEN"; - private static final String OAUTH_EXPIRES_IN_ENV = - "NOSQL_OAUTH_EXPIRES_IN_SECONDS"; private String endpoint; private final String exampleName; @@ -307,21 +304,19 @@ AuthorizationProvider getAuthProvider() { private OAuthAccessTokenProvider getOAuthProvider() { final String accessToken = getRequiredEnvironment(OAUTH_ACCESS_TOKEN_ENV); - final long expiresInSeconds = getOAuthExpiresInSeconds(); OAuthAccessTokenProvider provider = new OAuthAccessTokenProvider() { @Override - protected AccessTokenInfo getAccessTokenInfo() { - return new AccessTokenInfo(accessToken, - expiresInSeconds); + protected String getAccessToken() { + return accessToken; } }; /* * This example has only one access token. Long-running applications * should leave automatic renewal enabled and obtain a fresh token in - * getAccessTokenInfo(). + * getAccessToken(). */ provider.setAutoRenew(false); return provider; @@ -336,21 +331,6 @@ private static String getRequiredEnvironment(String name) { return value; } - private static long getOAuthExpiresInSeconds() { - String value = getRequiredEnvironment(OAUTH_EXPIRES_IN_ENV); - try { - long expiresInSeconds = Long.parseLong(value); - if (expiresInSeconds <= 0) { - throw new IllegalArgumentException( - OAUTH_EXPIRES_IN_ENV + " must be greater than zero"); - } - return expiresInSeconds; - } catch (NumberFormatException nfe) { - throw new IllegalArgumentException( - OAUTH_EXPIRES_IN_ENV + " must be an integer", nfe); - } - } - /** * Runs a query in a loop to be sure that all results have been returned. * This method returns a single list of results, which is not recommended From d36b34a54896d6541aa704a9583cc5939b004cb0 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Fri, 14 Aug 2026 16:32:43 +0530 Subject: [PATCH 10/13] Fix OAuth proxy, timeout, and renewal races - OAuth login, refresh, cleanup, and logout now inherit the handle's HTTP proxy configuration. - Foreground OAuth login uses the operation's remaining timeout and fails immediately when exhausted. - Auto-renew state and task transitions use a dedicated lock, avoiding the race without blocking behind getAccessToken(). --- .../driver/kv/OAuthAccessTokenProvider.java | 123 ++++++++++------- .../kv/OAuthAccessTokenProviderTest.java | 127 ++++++++++++++++++ 2 files changed, 205 insertions(+), 45 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 f8e17148..cb816edb 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -28,6 +28,7 @@ import oracle.nosql.driver.InvalidAuthorizationException; import oracle.nosql.driver.NoSQLException; import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.RequestTimeoutException; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.ops.Request; import oracle.nosql.driver.util.HttpRequestUtil; @@ -89,7 +90,7 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider * The server caps this deadline at the earlier of the validated OAuth * access-token expiration and the configured store session timeout. */ - private long loginTokenExpireAt; + private volatile long loginTokenExpireAt; /* * KV-authenticated identity associated with this provider's login token. @@ -143,6 +144,10 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider * SSL handshake timeout in milliseconds; */ private int sslHandshakeTimeoutMs; + + /* Handle configuration used to propagate HTTP proxy settings. */ + private NoSQLHandleConfig handleConfig; + /** * @hidden * This is only used for unit test @@ -159,6 +164,9 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider */ private ScheduledFuture refreshTask; + /* Guards auto-renew state transitions and refreshTask. */ + private final Object refreshLock = new Object(); + /* Invalidates a scheduled refresh that has already started running. */ private final AtomicLong refreshGeneration = new AtomicLong(); @@ -202,13 +210,16 @@ private synchronized void performLogin(boolean force, if (loginAborted(force, expectedGeneration)) { return; } + String accessToken = getAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalArgumentException( + "Invalid access token provided"); + } - final String accessToken = validateAccessToken(getAccessToken()); if (loginAborted(force, expectedGeneration)) { return; } - final int timeoutMs = - (request != null) ? request.getTimeoutInternal() : 0; + final int timeoutMs = getRemainingTimeoutMs(request); try { /* @@ -241,7 +252,11 @@ private synchronized void performLogin(boolean force, final LoginResult loginResult; try { loginResult = parseJsonResult(response.getOutput()); - validateLoginTokenExpiration(loginResult); + final long expireAt = loginResult.getExpireAt(); + if (expireAt <= System.currentTimeMillis()) { + throw new InvalidAuthorizationException( + "OAuth login response contains an expired login token"); + } validateAuthenticatedIdentity( loginResult.getAuthenticatedIdentity()); } catch (InvalidAuthorizationException iae) { @@ -278,6 +293,29 @@ private boolean loginAborted(boolean force, long expectedGeneration) { (!force && authString.get() != null); } + private int getRemainingTimeoutMs(Request request) { + if (request == null) { + return 0; + } + + final int timeoutMs = request.getTimeoutInternal(); + final long startNanos = request.getStartNanos(); + if (timeoutMs <= 0 || startNanos == 0) { + return timeoutMs; + } + + final long elapsedNanos = System.nanoTime() - startNanos; + if (elapsedNanos <= 0) { + return timeoutMs; + } + final long elapsedMs = TimeUnit.NANOSECONDS.toMillis(elapsedNanos); + if (elapsedMs >= timeoutMs) { + throw new RequestTimeoutException( + timeoutMs, "OAuth login exceeded the request timeout"); + } + return timeoutMs - (int) elapsedMs; + } + /** * @hidden */ @@ -318,10 +356,12 @@ public void close() { final String logoutAuth; synchronized (this) { logoutAuth = authString.getAndSet(null); - if (!scheduler.isShutdown()) { - scheduler.shutdownNow(); + synchronized (refreshLock) { + if (!scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + cancelRefreshTaskLocked(); } - cancelRefreshTask(); loginTokenExpireAt = 0; authenticatedIdentity = null; @@ -361,7 +401,7 @@ public void flushCache() { } authString.set(null); cancelRefreshTask(); - clearLoginTokenExpiration(); + loginTokenExpireAt = 0; } } @@ -392,23 +432,11 @@ public boolean invalidateAuthorizationString(String failedAuthorization) { return false; } cancelRefreshTask(); - clearLoginTokenExpiration(); + loginTokenExpireAt = 0; return true; } } - private void clearLoginTokenExpiration() { - loginTokenExpireAt = 0; - } - - private String validateAccessToken(String accessToken) { - if (accessToken == null || accessToken.isEmpty()) { - throw new IllegalArgumentException( - "Invalid access token provided"); - } - return accessToken; - } - /** * Retrieve login token from JSON string. */ @@ -444,14 +472,6 @@ private LoginResult parseJsonResult(String jsonResult) { } } - private void validateLoginTokenExpiration(LoginResult loginResult) { - final long expireAt = loginResult.getExpireAt(); - if (expireAt <= System.currentTimeMillis()) { - throw new InvalidAuthorizationException( - "OAuth login response contains an expired login token"); - } - } - private void logoutLoginResponse(HttpResponse response, int timeoutMs) { if (response.getStatusCode() != HttpResponseStatus.OK.code()) { return; @@ -524,9 +544,15 @@ private void validateAuthenticatedIdentity(OAuthIdentity identity) { } /* Schedule automatic re-login slightly before session expiry. */ - private synchronized void scheduleRefresh() { + private void scheduleRefresh() { + synchronized (refreshLock) { + scheduleRefreshLocked(); + } + } + + private void scheduleRefreshLocked() { final long generation = refreshGeneration.incrementAndGet(); - cancelRefreshTask(); + cancelRefreshTaskLocked(); if (!autoRenew || isClosed.get() || authString.get() == null || loginTokenExpireAt <= 0 || scheduler.isShutdown()) { return; @@ -560,14 +586,13 @@ private void refreshLoginToken(long generation) { } } - private void invalidateRefreshTask() { - refreshGeneration.incrementAndGet(); - synchronized (this) { - cancelRefreshTask(); + private void cancelRefreshTask() { + synchronized (refreshLock) { + cancelRefreshTaskLocked(); } } - private void cancelRefreshTask() { + private void cancelRefreshTaskLocked() { if (refreshTask != null) { refreshTask.cancel(false); refreshTask = null; @@ -660,6 +685,7 @@ public OAuthAccessTokenProvider prepare(NoSQLHandleConfig config) { throw new IllegalArgumentException( "OAuthAccessTokenProvider requires an SSL context"); } + handleConfig = config; return this; } @@ -701,14 +727,17 @@ public boolean isAutoRenew() { * @return this */ public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { - if (this.autoRenew == autoRenew) { - return this; - } - this.autoRenew = autoRenew; - if (autoRenew) { - scheduleRefresh(); - } else { - invalidateRefreshTask(); + synchronized (refreshLock) { + if (this.autoRenew == autoRenew) { + return this; + } + this.autoRenew = autoRenew; + if (autoRenew) { + scheduleRefreshLocked(); + } else { + refreshGeneration.incrementAndGet(); + cancelRefreshTaskLocked(); + } } return this; } @@ -735,6 +764,10 @@ private HttpResponse sendRequest(String authHeader, sslHandshakeTimeoutMs, serviceName, null); + if (handleConfig != null && + handleConfig.getProxyHost() != null) { + client.configureProxy(handleConfig); + } if (timeoutMs == 0) { timeoutMs = HTTP_TIMEOUT_MS; } 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 d1e8765c..522a17a7 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -25,11 +25,14 @@ import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -305,6 +308,81 @@ public void testLoginUsesRequestTimeout() throws Exception { } } + @Test + public void testLoginUsesRemainingRequestTimeout() throws Exception { + loginDelayMs = 500; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + final GetRequest request = new GetRequest().setTimeout(200); + request.setStartNanos( + System.nanoTime() - TimeUnit.MILLISECONDS.toNanos(150)); + final long startNanos = System.nanoTime(); + + try { + provider.getAuthorizationString(request); + fail("OAuth login should have observed the remaining timeout"); + } catch (NoSQLException expected) { + final long elapsedMs = + TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startNanos); + assertTrue("OAuth login reused the original timeout: " + + elapsedMs, elapsedMs < 150); + } finally { + Thread.sleep(loginDelayMs + 100); + loginDelayMs = 0; + provider.close(); + } + } + + @Test + public void testLoginUsesConfiguredHttpProxy() throws Exception { + final CountDownLatch proxyAccepted = new CountDownLatch(1); + final AtomicReference proxyFailure = + new AtomicReference(); + final TestProvider provider = new TestProvider(); + + final ServerSocket proxyServer = new ServerSocket(0); + final Thread proxyThread = new Thread(() -> { + try { + final Socket proxySocket = proxyServer.accept(); + proxyAccepted.countDown(); + proxySocket.close(); + } catch (Throwable t) { + proxyFailure.set(t); + } + }, "OAuthProxyTest"); + proxyThread.start(); + + try { + final NoSQLHandleConfig config = + new NoSQLHandleConfig(endpoint) + .setProxyHost("localhost") + .setProxyPort(proxyServer.getLocalPort()); + provider.setEndpoint(endpoint).setAutoRenew(false); + provider.prepare(config); + + try { + provider.getAuthorizationString( + new GetRequest().setTimeout(1_000)); + fail("OAuth login should have connected to the test proxy"); + } catch (NoSQLException expected) { + assertTrue(proxyAccepted.await(5, TimeUnit.SECONDS)); + } + + proxyThread.join(5_000); + assertFalse(proxyThread.isAlive()); + if (proxyFailure.get() != null) { + throw new AssertionError( + "Test proxy failed", proxyFailure.get()); + } + assertEquals(0, loginCounter.get()); + } finally { + provider.close(); + proxyServer.close(); + proxyThread.join(5_000); + } + } + @Test public void testFlushCacheRelogin() throws Exception { loginCounter.set(0); @@ -655,6 +733,55 @@ public void testRunningRefreshCancelledBeforeLogin() throws Exception { } } + @Test + public void testConcurrentAutoRenewTogglesKeepRefreshScheduled() + throws Exception { + + loginTokenLifetimeMs = 120_000; + final TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + final CountDownLatch start = new CountDownLatch(1); + final AtomicReference toggleFailure = + new AtomicReference(); + final Runnable toggler = () -> { + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "Timed out waiting to start renewal toggles"); + } + for (int i = 0; i < 1_000; i++) { + provider.setAutoRenew(false); + Thread.yield(); + provider.setAutoRenew(true); + } + } catch (Throwable t) { + toggleFailure.compareAndSet(null, t); + } + }; + final Thread first = new Thread(toggler, "OAuthToggleOne"); + final Thread second = new Thread(toggler, "OAuthToggleTwo"); + first.start(); + second.start(); + start.countDown(); + first.join(15_000); + second.join(15_000); + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + if (toggleFailure.get() != null) { + throw new AssertionError( + "Concurrent renewal toggle failed", toggleFailure.get()); + } + assertTrue(provider.isAutoRenew()); + assertEquals(1, getScheduler(provider).getQueue().size()); + } finally { + provider.close(); + } + } + @Test public void testOAuthLogsExcludeSensitiveValues() throws Exception { final TestLogHandler handler = new TestLogHandler(); From 28cca9d6d33a6fb61745452f0810b9c19d160ddb Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Mon, 17 Aug 2026 17:07:29 +0530 Subject: [PATCH 11/13] Improve OAuth access token provider reliability - Handle HTTP 5xx login responses as retryable SystemExceptions. - Preserve NoSQLException subtypes from OAuth login failures. - Avoid authorization cache races by returning a stable authorization snapshot from performLogin(). - Use the cached authorization value from a single atomic read. - Modernize AtomicReference initialization with the diamond operator. - Use equalsIgnoreCase() for HTTPS protocol validation. - Remove the unnecessary checked Exception declaration from sendRequest(). --- .../driver/kv/OAuthAccessTokenProvider.java | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 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 cb816edb..e763aef6 100644 --- a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -23,12 +23,14 @@ 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.codec.http.HttpStatusClass; 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.RequestTimeoutException; +import oracle.nosql.driver.SystemException; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.ops.Request; import oracle.nosql.driver.util.HttpRequestUtil; @@ -82,8 +84,7 @@ public abstract class OAuthAccessTokenProvider implements AuthorizationProvider * Authentication string which contain the Bearer prefix and login token's * binary representation in hex format. */ - private final AtomicReference authString = - new AtomicReference(); + private final AtomicReference authString = new AtomicReference<>(); /* * Expiration time of the NoSQL login token, in milliseconds since epoch. @@ -202,13 +203,13 @@ public OAuthAccessTokenProvider() { */ protected abstract String getAccessToken(); - private synchronized void performLogin(boolean force, - Request request, - long expectedGeneration) { + private synchronized String performLogin(boolean force, + Request request, + long expectedGeneration) { final String oldAuthorization = authString.get(); /* re-check the authString in case of a race */ if (loginAborted(force, expectedGeneration)) { - return; + return oldAuthorization; } String accessToken = getAccessToken(); if (accessToken == null || accessToken.isEmpty()) { @@ -217,7 +218,7 @@ private synchronized void performLogin(boolean force, } if (loginAborted(force, expectedGeneration)) { - return; + return authString.get(); } final int timeoutMs = getRemainingTimeoutMs(request); @@ -226,7 +227,7 @@ private synchronized void performLogin(boolean force, * Send request to server for login token */ if (loginAborted(force, expectedGeneration)) { - return; + return authString.get(); } HttpResponse response = sendRequest(BEARER_PREFIX + accessToken, @@ -234,12 +235,17 @@ private synchronized void performLogin(boolean force, if (loginAborted(force, expectedGeneration)) { logoutLoginResponse(response, timeoutMs); - return; + return authString.get(); } /* * login fail */ + if(HttpStatusClass.SERVER_ERROR.contains(response.getStatusCode())) { + throw new SystemException( + "OAuth login failed with HTTP status " + + response.getStatusCode()); + } if (response.getStatusCode() != HttpResponseStatus.OK.code()) { throw new InvalidAuthorizationException( "OAuth login failed with HTTP status " + @@ -263,13 +269,12 @@ private synchronized void performLogin(boolean force, logoutLoginResponse(response, timeoutMs); throw iae; } + final String newAuthorization = + BEARER_PREFIX + loginResult.getToken(); if (loginAborted(force, expectedGeneration) || - !authString.compareAndSet( - oldAuthorization, - BEARER_PREFIX + loginResult.getToken())) { - logoutSession( - BEARER_PREFIX + loginResult.getToken(), timeoutMs); - return; + !authString.compareAndSet(oldAuthorization, newAuthorization)) { + logoutSession(newAuthorization, timeoutMs); + return authString.get(); } loginTokenExpireAt = loginResult.getExpireAt(); /* @@ -277,9 +282,10 @@ private synchronized void performLogin(boolean force, * expiration. */ scheduleRefresh(); + return newAuthorization; - } catch (InvalidAuthorizationException iae) { - throw iae; + } catch (NoSQLException nse) { + throw nse; } catch (Exception e) { throw new NoSQLException("Login with OAuth token failed", e); } @@ -333,10 +339,11 @@ 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) { - performLogin(false, request, -1); + final String authorization = authString.get(); + if (authorization != null) { + return authorization; } - return authString.get(); + return performLogin(false, request, -1); } /** @@ -635,7 +642,7 @@ public String getEndpoint() { */ public OAuthAccessTokenProvider setEndpoint(String endpoint) { URL url = NoSQLHandleConfig.createURL(endpoint, ""); - if (!url.getProtocol().toLowerCase().equals("https")) { + if (!"https".equalsIgnoreCase(url.getProtocol())) { throw new IllegalArgumentException( "OAuthAccessTokenProvider requires use of https"); } @@ -748,7 +755,7 @@ public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { */ private HttpResponse sendRequest(String authHeader, String serviceName, - int timeoutMs) throws Exception { + int timeoutMs) { HttpClient client = null; try { if (!disableSSLHook && sslContext == null) { From e8cd9185106cc03f7ae3d047240fee08d60e7d39 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Mon, 17 Aug 2026 17:14:48 +0530 Subject: [PATCH 12/13] Update readme about OAuthAccessTokenProvider support. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a1ec53b0..a75b5d6c 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Database. In addition, a running proxy service is required. See [Information about the proxy](https://docs.oracle.com/en/database/other-databases/nosql-database/24.3/admin/proxy.html) for proxy configuration information. -On-premise authorization requires use of [StoreAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/StoreAccessTokenProvider.html) +On-premise authorization supports [StoreAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/StoreAccessTokenProvider.html) for store credentials and [OAuthAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/OAuthAccessTokenProvider.html) for OAuth 2.0 access-token authentication. See the Quickstart example below for code details for connecting on-premise. ### Connecting to the Oracle NoSQL Database Cloud Simulator From ed50670ff006498b6c0dfeaac9cc8b73edcb3270 Mon Sep 17 00:00:00 2001 From: Rajdeep Chakraborty Date: Mon, 17 Aug 2026 17:29:27 +0530 Subject: [PATCH 13/13] Fix unit test to catch SystemException after 5xx response handling. --- .../oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 522a17a7..59b32482 100644 --- a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -43,6 +43,7 @@ import oracle.nosql.driver.NoSQLHandleConfig; import oracle.nosql.driver.NoSQLHandleFactory; import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.SystemException; import oracle.nosql.driver.ops.GetRequest; import oracle.nosql.driver.values.JsonUtils; @@ -795,7 +796,7 @@ public void testOAuthLogsExcludeSensitiveValues() throws Exception { try { failedLogin.getAuthorizationString(null); fail("The OAuth login should have failed"); - } catch (InvalidAuthorizationException expected) { + } catch (SystemException expected) { assertFalse(expected.getMessage().contains(responseSecret)); } finally { failedLogin.close();