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