diff --git a/driver/pom.xml b/driver/pom.xml index 82d42293..9147628d 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -143,7 +143,8 @@ none StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, - ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, + OAuthAccessTokenProviderTest.java, ConfigFileTest.java, + SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, SessionTokenProviderTest.java, RequestTest.java diff --git a/driver/src/main/java/oracle/nosql/driver/http/Client.java b/driver/src/main/java/oracle/nosql/driver/http/Client.java index 5163cbde..5a670992 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/Client.java +++ b/driver/src/main/java/oracle/nosql/driver/http/Client.java @@ -81,6 +81,7 @@ import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; import oracle.nosql.driver.kv.AuthenticationException; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.kv.StoreAccessTokenProvider; import oracle.nosql.driver.ops.AddReplicaRequest; import oracle.nosql.driver.ops.DeleteRequest; @@ -285,9 +286,9 @@ public Client(Logger logger, "Must configure AuthorizationProvider to use HttpClient"); } - /* StoreAccessTokenProvider == onprem */ + /* StoreAccessTokenProvider/OAuthAccessTokenProvider == onprem */ if (config.getRateLimitingEnabled() && - !(authProvider instanceof StoreAccessTokenProvider)) { + !isOnPremAuthProvider()) { logFine(logger, "Starting client with rate limiting enabled"); rateLimiterMap = new RateLimiterMap(); tableLimitUpdateMap = new ConcurrentHashMap(); @@ -374,6 +375,11 @@ public int getFreeChannelCount() { return httpClient.getFreeChannelCount(); } + private boolean isOnPremAuthProvider() { + return authProvider instanceof StoreAccessTokenProvider || + authProvider instanceof OAuthAccessTokenProvider; + } + /** * Get the next client-scoped request id. It needs to be combined with the * client id to obtain a globally unique scope. @@ -675,12 +681,10 @@ public Result execute(Request kvRequest) { kvRequest.setTimeoutInternal(timeoutMs); /* - * If on-premises the authProvider will always be a - * StoreAccessTokenProvider. If so, check against - * configurable limit. Otherwise check against internal - * hardcoded cloud limit. + * If on-premises, check against configurable limit. + * Otherwise check against internal hardcoded cloud limit. */ - if (authProvider instanceof StoreAccessTokenProvider) { + if (isOnPremAuthProvider()) { if (buffer.readableBytes() > httpClient.getMaxContentLength()) { throw new RequestSizeLimitException("The request " + @@ -844,6 +848,32 @@ public Result execute(Request kvRequest) { "Client re-auth on AuthenticationException: " + rae.getMessage()); continue; + } else if (authProvider instanceof OAuthAccessTokenProvider) { + /* + * OAuthAccessTokenProvider obtains a new NoSQL login + * token lazily after the cache is flushed. Retry this + * path only once so repeated RETRY_AUTHENTICATION + * responses are surfaced as authentication failures + * instead of eventually timing out the request. + */ + if (retriedException(kvRequest, + AuthenticationException.class)) { + kvRequest.setRateLimitDelayedMs(rateDelayedMs); + statsControl.observeError(kvRequest); + logFine(logger, + "Client OAuth re-auth failed: " + + rae.getMessage()); + throw rae; + } + authProvider.flushCache(); + kvRequest.addRetryException(rae.getClass()); + kvRequest.incrementRetries(); + exception = rae; + logFine(logger, + "Client retrying OAuth re-auth on " + + "AuthenticationException: " + + rae.getMessage()); + continue; } kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); @@ -859,7 +889,8 @@ public Result execute(Request kvRequest) { * failures. This does not include permissions-related errors, * which would be a UnauthorizedException. */ - if (retriedInvalidAuthorizationException(kvRequest)) { + if (retriedException(kvRequest, + InvalidAuthorizationException.class)) { /* same as NoSQLException below */ kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); @@ -1584,20 +1615,23 @@ private void updateTableLimiters(String tableName, String compartmentId) { } /** - * Returns whether an {@link InvalidAuthorizationException} has been - * retried for the given request. + * Returns whether an exception type has been retried for the given + * request. * * @param request the request to check - * @return true if an {@link InvalidAuthorizationException} has been - * retried for the request, false otherwise + * @param exceptionClass the exception class to check + * @return true if the exception type has been retried for the request */ - private boolean retriedInvalidAuthorizationException(Request request) { + private boolean retriedException( + Request request, + Class exceptionClass) { + final RetryStats rs = request.getRetryStats(); if (rs == null || rs.getRetries() <= 0) { return false; } - return rs.getNumExceptions(InvalidAuthorizationException.class) > 0; + return rs.getNumExceptions(exceptionClass) > 0; } private void throwIfTransportRetryNotAllowed(Request request, diff --git a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java index 86231dc1..90e169d1 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java +++ b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java @@ -18,6 +18,7 @@ import oracle.nosql.driver.StatsControl; import oracle.nosql.driver.UserInfo; import oracle.nosql.driver.iam.SignatureProvider; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.kv.StoreAccessTokenProvider; import oracle.nosql.driver.ops.AddReplicaRequest; import oracle.nosql.driver.ops.DeleteRequest; @@ -154,15 +155,23 @@ private void configAuthProvider(Logger logger, NoSQLHandleConfig config) { } if (stProvider.isSecure() && stProvider.getEndpoint() == null) { - String endpoint = config.getServiceURL().toString(); - if (endpoint.endsWith("/")) { - endpoint = endpoint.substring(0, endpoint.length() - 1); - } - stProvider.setEndpoint(endpoint) + stProvider.setEndpoint(getAuthEndpoint(config)) .setSslContext(config.getSslContext()) .setSslHandshakeTimeout( config.getSSLHandshakeTimeout()); } + } else if (ap instanceof OAuthAccessTokenProvider) { + final OAuthAccessTokenProvider oatProvider = + (OAuthAccessTokenProvider) ap; + if (oatProvider.getLogger() == null) { + oatProvider.setLogger(logger); + } + if (oatProvider.getEndpoint() == null) { + oatProvider.setEndpoint(getAuthEndpoint(config)) + .setSslContext(config.getSslContext()) + .setSslHandshakeTimeout( + config.getSSLHandshakeTimeout()); + } } else if (ap instanceof SignatureProvider) { SignatureProvider sigProvider = (SignatureProvider) ap; if (sigProvider.getLogger() == null) { @@ -176,6 +185,14 @@ private void configAuthProvider(Logger logger, NoSQLHandleConfig config) { } } + private String getAuthEndpoint(NoSQLHandleConfig config) { + String endpoint = config.getServiceURL().toString(); + if (endpoint.endsWith("/")) { + endpoint = endpoint.substring(0, endpoint.length() - 1); + } + return endpoint; + } + @Override public DeleteResult delete(DeleteRequest request) { checkClient(); diff --git a/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java new file mode 100644 index 00000000..c668c05f --- /dev/null +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -0,0 +1,594 @@ +/*- + * Copyright (c) 2011, 2026 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Universal Permissive License v 1.0 as shown at + * https://oss.oracle.com/licenses/upl/ + */ + +package oracle.nosql.driver.kv; + +import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; +import static oracle.nosql.driver.util.HttpConstants.KV_SECURITY_PATH; + +import java.net.URL; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.ssl.SslContext; +import oracle.nosql.driver.AuthorizationProvider; +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.httpclient.HttpClient; +import oracle.nosql.driver.ops.Request; +import oracle.nosql.driver.util.HttpRequestUtil; +import oracle.nosql.driver.util.HttpRequestUtil.HttpResponse; +import oracle.nosql.driver.values.JsonUtils; +import oracle.nosql.driver.values.MapValue; + +public abstract class OAuthAccessTokenProvider implements AuthorizationProvider { + + + /* + * This is the general prefix for the login token. + */ + private static final String BEARER_PREFIX = "Bearer "; + + /* + * login service end point name. + */ + private static final String LOGIN_SERVICE = "/oauthlogin"; + + /* + * logout service end point name. + */ + private static final String LOGOUT_SERVICE = "/oauthlogout"; + + /* + * Default timeout when sending http request to server + */ + private static final int HTTP_TIMEOUT_MS = 30000; + + /* + * Authentication string which contain the Bearer prefix and login token's + * binary representation in hex format. + */ + private final AtomicReference authString = + new AtomicReference(); + + /* + * Access token and its lifetime + */ + private AccessTokenInfo tokenInfo; + + /* + * Expiration time of the access token, in milliseconds since epoch. + */ + private long accessTokenExpireAt; + + /* + * Expiration time of the NoSQL login token, in milliseconds since epoch. + */ + private long loginTokenExpireAt; + + /* + * KV-authenticated principal associated with this provider's login token. + */ + private String loginPrincipal; + + /* Default refresh time before effective token expiry, 10 seconds */ + private static final int REFRESH_AHEAD_SECONDS = 10; + + /* + * logger + */ + private Logger logger; + + /* + * Whether to renew the login token automatically + */ + private volatile boolean autoRenew = true; + + /* + * Host name of the proxy machine which host the login service + */ + private String loginHost; + + /* + * Port number of the proxy machine which host the login service + */ + private int loginPort; + + /* + * Endpoint to reach the authenticating entity (Proxy) + */ + private String endpoint; + + /* + * Base path for security related services + */ + private final static String basePath = KV_SECURITY_PATH; + + /* + * Whether this provider is closed + */ + private volatile boolean isClosed = false; + + /* + * SslContext used by http client + */ + private SslContext sslContext; + + /* + * SSL handshake timeout in milliseconds; + */ + private int sslHandshakeTimeoutMs; + /** + * @hidden + * This is only used for unit test + */ + public static boolean disableSSLHook; + + /* + * A schedule used to periodically invoke the callback + */ + private final ScheduledExecutorService scheduler; + + /* + * Current scheduled refresh task. + */ + private ScheduledFuture refreshTask; + + + public OAuthAccessTokenProvider() { + loginHost = null; + endpoint = null; + loginPort = 0; + logger = null; + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "OAuthTokenRefresher"); + t.setDaemon(true); + return t; + }); + } + + /** + * Returns an access token and its lifetime. + * Implementations decide: + * - How to obtain it (cached, freshly requested, etc.) + * - How to refresh it when expired + * - Whether to store/retrieve refresh tokens + */ + protected abstract AccessTokenInfo getAccessTokenInfo(); + + private synchronized void performLogin(boolean force, Request request) { + /* re-check the authString in case of a race */ + if (isClosed || (!force && authString.get() != null)) { + return; + } + + final AccessTokenInfo newTokenInfo = + validateAccessTokenInfo(getAccessTokenInfo()); + final long accessTokenAcquireTime = System.currentTimeMillis(); + final int timeoutMs = + (request != null) ? request.getTimeoutInternal() : 0; + + try { + /* + * Send request to server for login token + */ + HttpResponse response = + sendRequest(BEARER_PREFIX + newTokenInfo.getAccessToken(), + LOGIN_SERVICE, timeoutMs); + + /* + * login fail + */ + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + throw new InvalidAuthorizationException( + "Fail to login to service: " + response.getOutput()); + } + + if (isClosed) { + return; + } + + /* + * Generate the authentication string using login token + */ + final LoginResult loginResult = + parseJsonResult(response.getOutput()); + try { + validateLoginPrincipal(loginResult.getPrincipal()); + } catch (InvalidAuthorizationException iae) { + final String rejectedToken = loginResult.getToken(); + if (rejectedToken != null && !rejectedToken.isEmpty()) { + logoutSession(BEARER_PREFIX + rejectedToken, timeoutMs); + } + throw iae; + } + authString.set(BEARER_PREFIX + loginResult.getToken()); + tokenInfo = newTokenInfo; + accessTokenExpireAt = accessTokenAcquireTime + + TimeUnit.SECONDS.toMillis( + newTokenInfo.getExpiresInSeconds()); + loginTokenExpireAt = loginResult.getExpireAt(); + /* + * Schedule access token refresh thread + */ + scheduleRefresh(); + + } catch (InvalidAuthorizationException iae) { + throw iae; + } catch (Exception e) { + throw new NoSQLException("Login with OAuth token failed", e); + } + } + + /** + * @hidden + */ + @Override + public String getAuthorizationString(Request request) { + + /* + * Already close + */ + if (isClosed) { + return null; + } + + /* + * If there is no cached auth string, re-authentication to retrieve + * the login token and generate the auth string. + */ + if (authString.get() == null) { + performLogin(false, request); + } + return authString.get(); + } + + /** + * Closes the provider, releasing resources such as a stored login token. + */ + @Override + public synchronized void close() { + + /* + * Already closed + */ + if (isClosed) { + return; + } + + final String logoutAuth = authString.get(); + isClosed = true; + if (!scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + + /* + * Send request for logout + */ + if (logoutAuth != null) { + logoutSession(logoutAuth, 0); + } + + /* + * Clean up + */ + authString.set(null); + tokenInfo = null; + accessTokenExpireAt = 0; + loginTokenExpireAt = 0; + loginPrincipal = null; + } + + private void logoutSession(String logoutAuth, int timeoutMs) { + try { + final HttpResponse response = + sendRequest(logoutAuth, LOGOUT_SERVICE, timeoutMs); + if (response.getStatusCode() != HttpResponseStatus.OK.code() && + logger != null) { + logger.info("Failed to logout OAuth session, response: " + + response.getOutput()); + } + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to logout OAuth session, exception: " + e); + } + } + } + + /** + * Invalidate the cached NoSQL login token. + */ + @Override + public void flushCache() { + if (isClosed) { + return; + } + authString.set(null); + } + + private AccessTokenInfo validateAccessTokenInfo( + AccessTokenInfo accessTokenInfo) { + + if (accessTokenInfo == null || + accessTokenInfo.getAccessToken() == null || + accessTokenInfo.getAccessToken().isEmpty()) { + throw new IllegalArgumentException( + "Invalid access token provided"); + } + return accessTokenInfo; + } + + /** + * Retrieve login token from JSON string. + */ + private LoginResult parseJsonResult(String jsonResult) { + final MapValue mapValue = + JsonUtils.createValueFromJson(jsonResult, null).asMap(); + + /* + * Extract login token, expiration, and authenticated principal from + * JSON result. + */ + return new LoginResult( + mapValue.getString("token"), + mapValue.getLong("expireAt"), + mapValue.contains("principal") ? + mapValue.getString("principal") : null); + } + + private void validateLoginPrincipal(String principal) { + if (principal == null || principal.isEmpty()) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response: principal is missing"); + } + if (loginPrincipal == null) { + loginPrincipal = principal; + return; + } + if (!loginPrincipal.equals(principal)) { + throw new InvalidAuthorizationException( + "Logout required prior to logging in with new user identity."); + } + } + + /* Schedule automatic re-login slightly before expiry */ + private synchronized void scheduleRefresh() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + if (!autoRenew || isClosed || tokenInfo == null || + tokenInfo.getExpiresInSeconds() <= 0 || scheduler.isShutdown()) { + return; + } + final long now = System.currentTimeMillis(); + final long effectiveExpireAt = loginTokenExpireAt > 0 ? + Math.min(accessTokenExpireAt, loginTokenExpireAt) : + accessTokenExpireAt; + final long delay = Math.max( + 1000, + effectiveExpireAt - now - + TimeUnit.SECONDS.toMillis(REFRESH_AHEAD_SECONDS)); + refreshTask = scheduler.schedule(new Runnable() { + @Override + public void run() { + refreshLoginToken(); + } + }, delay, TimeUnit.MILLISECONDS); + } + + private void refreshLoginToken() { + if (!autoRenew || isClosed) { + return; + } + + try { + performLogin(true, null); + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to obtain refreshed token: " + e); + } + } + } + + /** + * Returns the logger, or null if not set. + * + * @return the logger + */ + public Logger getLogger() { + return logger; + } + + /** + * Sets a Logger instance for this provider. + * @param logger the logger + * @return this + */ + public OAuthAccessTokenProvider setLogger(Logger logger) { + this.logger = logger; + return this; + } + + /** + * Returns the endpoint of the authenticating entity + * @return the endpoint + */ + public String getEndpoint() { + return endpoint; + } + + /** + * Sets the endpoint of the authenticating entity + * @param endpoint the endpoint + * @return this + * @throws IllegalArgumentException if the endpoint is not correctly + * formatted + */ + public OAuthAccessTokenProvider setEndpoint(String endpoint) { + URL url = NoSQLHandleConfig.createURL(endpoint, ""); + if (!url.getProtocol().toLowerCase().equals("https")) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires use of https"); + } + final String newLoginHost = url.getHost(); + final int newLoginPort = url.getPort(); + + this.endpoint = endpoint; + this.loginHost = newLoginHost; + this.loginPort = newLoginPort; + return this; + } + + /** + * Sets the SSL context + * @param sslCtx the context + * @return this + */ + public OAuthAccessTokenProvider setSslContext(SslContext sslCtx) { + this.sslContext = sslCtx; + return this; + } + + /** + * Sets the SSL handshake timeout in milliseconds + * @param timeoutMs the timeout in milliseconds + * @return this + */ + public OAuthAccessTokenProvider setSslHandshakeTimeout(int timeoutMs) { + this.sslHandshakeTimeoutMs = timeoutMs; + return this; + } + + /** + * Returns whether the login token is to be automatically renewed. + * + * @return true if auto-renew is set + */ + public boolean isAutoRenew() { + return autoRenew; + } + + /** + * Sets the auto-renew state. If true, automatic renewal of the login + * token is enabled. + * + * @param autoRenew set to true to enable auto-renew + * + * @return this + */ + public OAuthAccessTokenProvider setAutoRenew(boolean autoRenew) { + this.autoRenew = autoRenew; + return this; + } + + /** + * Send HTTPS request to login/logout service location with proper + * authentication information. + */ + private HttpResponse sendRequest(String authHeader, + String serviceName, + int timeoutMs) throws Exception { + HttpClient client = null; + try { + final HttpHeaders headers = new DefaultHttpHeaders(); + headers.set(AUTHORIZATION, authHeader); + client = HttpClient.createMinimalClient + (loginHost, + loginPort, + !disableSSLHook ? sslContext : null, + sslHandshakeTimeoutMs, + serviceName, + logger); + if (timeoutMs == 0) { + timeoutMs = HTTP_TIMEOUT_MS; + } + return HttpRequestUtil.doGetRequest( + client, + NoSQLHandleConfig.createURL(endpoint, basePath + serviceName) + .toString(), + headers, timeoutMs, logger); + } finally { + if (client != null) { + client.shutdown(); + } + } + } + + /** Nested static class to store the access token and its lifetime */ + public static final class AccessTokenInfo { + + private final String accessToken; + private final long expiresInSeconds; + + /** + * Creates access token information. + * + * @param accessToken OAuth access token + * @param expiresInSeconds token lifetime in seconds + */ + public AccessTokenInfo(String accessToken, long expiresInSeconds) { + if (expiresInSeconds < 0) { + throw new IllegalArgumentException( + "Access token lifetime must be non-negative"); + } + this.accessToken = accessToken; + this.expiresInSeconds = expiresInSeconds; + } + + public String getAccessToken() { + return accessToken; + } + + /** + * Returns the access token lifetime in seconds. + * + * @return the access token lifetime in seconds + */ + public long getExpiresInSeconds() { + return expiresInSeconds; + } + + } + + private static final class LoginResult { + + private final String token; + private final long expireAt; + private final String principal; + + private LoginResult(String token, long expireAt, String principal) { + this.token = token; + this.expireAt = expireAt; + this.principal = principal; + } + + private String getToken() { + return token; + } + + private String getPrincipal() { + return principal; + } + + private long getExpireAt() { + return expireAt; + } + } +} diff --git a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java index 6a97f900..56efb397 100644 --- a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java +++ b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java @@ -19,6 +19,8 @@ import oracle.nosql.driver.http.Client; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; +import oracle.nosql.driver.kv.AuthenticationException; +import oracle.nosql.driver.kv.OAuthAccessTokenProvider; import oracle.nosql.driver.ops.GetRequest; import oracle.nosql.driver.ops.Request; import oracle.nosql.driver.values.MapValue; @@ -60,6 +62,32 @@ public void testInvalidAuthorizationExceptionRetry() InvalidAuthorizationException.class)); } + @Test + public void testOAuthAuthenticationExceptionRetry() + throws Exception { + + testHttpClient.authenticationExceptionMode = true; + TestOAuthProvider provider = new TestOAuthProvider(); + TestClient client = getTestClient(provider); + + Request request = new GetRequest().setTableName("foo") + .setKey(new MapValue().put("foo", "bar")); + + /* + * Expect the AuthenticationException for OAuth to be retried once only. + * The second AuthenticationException should be returned immediately, + * not retried until request timeout. + */ + assertThrows(AuthenticationException.class, + () -> client.execute(request)); + assertEquals(2, testHttpClient.execCount.get()); + assertEquals(2, testHttpClient.authenticationExceptionCount.get()); + assertEquals(1, provider.flushCount.get()); + assertEquals(1, + request.getRetryStats() + .getNumExceptions(AuthenticationException.class)); + } + private TestClient getTestClient() { AuthorizationProvider provider = new AuthorizationProvider() { @@ -72,6 +100,10 @@ public String getAuthorizationString(Request request) { public void close() { } }; + return getTestClient(provider); + } + + private TestClient getTestClient(AuthorizationProvider provider) { NoSQLHandleConfig cf = new NoSQLHandleConfig("http://localhost:8080"); cf.setAuthorizationProvider(provider); return new TestClient(null, cf); @@ -95,6 +127,9 @@ public HttpClient createHttpClient(URL url, private static class TestHttpClient extends HttpClient { private final AtomicInteger execCount = new AtomicInteger(0); private final AtomicInteger iaeCount = new AtomicInteger(0); + private final AtomicInteger authenticationExceptionCount = + new AtomicInteger(0); + private boolean authenticationExceptionMode; public TestHttpClient() { super("localhost", 8080, 1, 0, 0, 0, 0, null, 0, "test", null); @@ -104,6 +139,12 @@ public TestHttpClient() { public void runRequest(HttpRequest request, ResponseHandler handler, Channel channel) { + if (authenticationExceptionMode) { + execCount.incrementAndGet(); + authenticationExceptionCount.incrementAndGet(); + throw new AuthenticationException("test"); + } + /* * Simulate an authentication failure scenario where the initial * attempt throws SecurityInfoNotReadyException, and subsequent @@ -133,4 +174,24 @@ public boolean isActive() { }; } } + + private static class TestOAuthProvider extends OAuthAccessTokenProvider { + + private final AtomicInteger flushCount = new AtomicInteger(0); + + @Override + public String getAuthorizationString(Request request) { + return "Bearer Test"; + } + + @Override + public void flushCache() { + flushCount.incrementAndGet(); + } + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + return new AccessTokenInfo("Test", 60); + } + } } diff --git a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java new file mode 100644 index 00000000..4e21786b --- /dev/null +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -0,0 +1,482 @@ +/*- + * Copyright (c) 2011, 2026 Oracle and/or its affiliates. All rights reserved. + * + * Licensed under the Universal Permissive License v 1.0 as shown at + * https://oss.oracle.com/licenses/upl/ + */ + +package oracle.nosql.driver.kv; + +import static oracle.nosql.driver.util.HttpConstants.AUTHORIZATION; +import static oracle.nosql.driver.util.HttpConstants.KV_SECURITY_PATH; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.util.concurrent.atomic.AtomicInteger; + +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.ops.GetRequest; +import oracle.nosql.driver.values.JsonUtils; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +@SuppressWarnings("restriction") +public class OAuthAccessTokenProviderTest { + + private static final String loginPath = KV_SECURITY_PATH + "/oauthlogin"; + private static final String logoutPath = KV_SECURITY_PATH + "/oauthlogout"; + + private static final int port = 1444; + private static final String endpoint = "https://localhost:" + port; + + private static final String oauthAccessToken = "OCI_ACCESS_TOKEN"; + private static final String secondOAuthAccessToken = "OCI_ACCESS_TOKEN_2"; + private static final String loginToken = "OAUTH_LOGIN_TOKEN"; + private static final String reloginToken = "OAUTH_RELOGIN_TOKEN"; + private static final String loginPrincipal = "oauth-data/it@test.com"; + private static final String differentLoginPrincipal = + "oauth-data/other@test.com"; + private static final String authTokenPrefix = "Bearer "; + + private static HttpServer server; + private static final AtomicInteger loginCounter = new AtomicInteger(); + private static final AtomicInteger logoutCounter = new AtomicInteger(); + private static volatile String lastLogoutToken; + private static volatile String reloginPrincipal = loginPrincipal; + private static volatile boolean omitLoginPrincipal; + private static volatile long loginTokenLifetimeMs = 15_000; + private static volatile long loginDelayMs; + + @BeforeClass + public static void staticSetUp() throws Exception { + OAuthAccessTokenProvider.disableSSLHook = true; + server = HttpServer.create(new InetSocketAddress(port), 0); + server.start(); + + server.createContext(loginPath, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) + throws IOException { + final String authString = + exchange.getRequestHeaders().get(AUTHORIZATION).get(0); + assertTrue(authString.startsWith(authTokenPrefix)); + final int count = loginCounter.incrementAndGet(); + if (count == 1) { + assertEquals(authTokenPrefix + oauthAccessToken, + authString); + } else { + assertEquals(authTokenPrefix + secondOAuthAccessToken, + authString); + } + final long delayMs = loginDelayMs; + if (delayMs > 0) { + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IOException("Login handler interrupted", ie); + } + } + if (count == 1) { + generateLoginToken( + loginToken, + omitLoginPrincipal ? null : loginPrincipal, + exchange); + } else { + generateLoginToken(reloginToken, reloginPrincipal, + exchange); + } + } + }); + + server.createContext(logoutPath, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) + throws IOException { + final String authString = + exchange.getRequestHeaders().get(AUTHORIZATION).get(0); + assertTrue(authString.startsWith(authTokenPrefix)); + lastLogoutToken = readTokenFromAuth(authString); + logoutCounter.incrementAndGet(); + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, 0); + exchange.close(); + } + }); + } + + @AfterClass + public static void staticTearDown() throws Exception { + OAuthAccessTokenProvider.disableSSLHook = false; + if (server != null) { + server.stop(0); + } + } + + @Test + public void testBasic() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + final String authString = provider.getAuthorizationString(null); + assertNotNull(authString); + assertTrue(authString.startsWith(authTokenPrefix)); + assertEquals(loginToken, readTokenFromAuth(authString)); + + Thread.sleep(10000); + + final String authReloginString = + provider.getAuthorizationString(null); + assertEquals(reloginToken, + readTokenFromAuth(authReloginString)); + + provider.close(); + assertNull(provider.getAuthorizationString(null)); + } finally { + provider.close(); + } + + tryBadEndpoint("http://localhost"); + tryBadEndpoint("localhost:8080"); + tryBadEndpoint("foo://localhost"); + } + + @Test + public void testDisableAutoRenew() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertNotNull(authString); + assertEquals(loginToken, readTokenFromAuth(authString)); + + Thread.sleep(10000); + + final String sameAuthString = + provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(sameAuthString)); + assertEquals(1, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testLoginTokenExpiryControlsRefresh() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + loginTokenLifetimeMs = 12_000; + TestProvider provider = new TestProvider(60); + provider.setEndpoint(endpoint); + + try { + assertEquals(loginToken, readTokenFromAuth( + provider.getAuthorizationString(null))); + + waitForAuthorizationToken(provider, reloginToken, 5_000); + assertTrue(loginCounter.get() >= 2); + } finally { + loginTokenLifetimeMs = 15_000; + provider.close(); + } + } + + @Test + public void testRefreshFailureRetainsLoginToken() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + FailingRefreshProvider provider = new FailingRefreshProvider(); + provider.setEndpoint(endpoint); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.waitForRefreshAttempt(5_000); + + assertEquals(authString, provider.getAuthorizationString(null)); + assertEquals(1, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testLoginUsesRequestTimeout() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + loginDelayMs = 500; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + final long startNanos = System.nanoTime(); + + try { + provider.getAuthorizationString(new GetRequest().setTimeout(50)); + fail("OAuth login should have observed the request timeout"); + } catch (NoSQLException expected) { + final long elapsedMs = + (System.nanoTime() - startNanos) / 1_000_000; + assertTrue("OAuth login exceeded request timeout: " + elapsedMs, + elapsedMs < loginDelayMs); + } finally { + Thread.sleep(loginDelayMs + 100); + loginDelayMs = 0; + provider.close(); + } + } + + @Test + public void testFlushCacheRelogin() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.flushCache(); + + final String authReloginString = + provider.getAuthorizationString(null); + assertEquals(reloginToken, + readTokenFromAuth(authReloginString)); + assertEquals(2, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testReloginWithDifferentPrincipalFails() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = false; + reloginPrincipal = differentLoginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.flushCache(); + + provider.getAuthorizationString(null); + fail("Relogin with a different principal should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Logout required prior to logging in with new " + + "user identity.")); + } finally { + reloginPrincipal = loginPrincipal; + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(reloginToken, lastLogoutToken); + } + + @Test + public void testLoginWithoutPrincipalFails() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + omitLoginPrincipal = true; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("Login without a principal should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Invalid OAuth login response: principal is missing")); + } finally { + omitLoginPrincipal = false; + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testCloseLogsOutLoginToken() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + omitLoginPrincipal = false; + reloginPrincipal = loginPrincipal; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + final String authString = provider.getAuthorizationString(null); + assertEquals(loginToken, readTokenFromAuth(authString)); + + provider.close(); + + assertNull(provider.getAuthorizationString(null)); + assertEquals(1, logoutCounter.get()); + } + + private void tryBadEndpoint(String ep) { + TestProvider provider = new TestProvider(); + try { + provider.setEndpoint(ep); + fail("Endpoint should have failed: " + ep); + } catch (IllegalArgumentException iae) { + assertNull(provider.getEndpoint()); + } + } + + private static void generateLoginToken(String tokenText, + String principal, + HttpExchange exchange) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos); + OutputStream os = exchange.getResponseBody()) { + + long expireTime = + System.currentTimeMillis() + loginTokenLifetimeMs; + oos.writeShort(1); + oos.writeLong(expireTime); + oos.writeBytes(tokenText); + oos.flush(); + + final String tokenString = + JsonUtils.convertBytesToHex(baos.toByteArray()); + final String jsonString = + "{\"token\":\"" + tokenString + "\"," + + "\"expireAt\":" + expireTime + + (principal != null ? + ",\"principal\":\"" + principal + "\"" : "") + + "}"; + + exchange.sendResponseHeaders(HttpURLConnection.HTTP_OK, + jsonString.length()); + os.write(jsonString.getBytes()); + os.flush(); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to encode", ioe); + } + } + + private static String readTokenFromAuth(String authString) { + final String authEncoded = + authString.substring(authTokenPrefix.length()); + final byte[] token = JsonUtils.convertHexToBytes(authEncoded); + try (ByteArrayInputStream bais = new ByteArrayInputStream(token); + ObjectInputStream ois = new ObjectInputStream(bais)) { + ois.readShort(); + ois.readLong(); + byte[] tokenBytes = new byte[ois.available()]; + ois.read(tokenBytes); + return new String(tokenBytes); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to decode", ioe); + } + } + + private static void waitForAuthorizationToken( + OAuthAccessTokenProvider provider, + String expectedToken, + long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < limit) { + final String authString = provider.getAuthorizationString(null); + if (expectedToken.equals(readTokenFromAuth(authString))) { + return; + } + Thread.sleep(50); + } + fail("Timed out waiting for refreshed OAuth login token"); + } + + private static class TestProvider extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + private final long expiresInSeconds; + + TestProvider() { + this(15); + } + + TestProvider(long expiresInSeconds) { + this.expiresInSeconds = expiresInSeconds; + } + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + if (tokenCounter.incrementAndGet() == 1) { + return new AccessTokenInfo(oauthAccessToken, expiresInSeconds); + } + return new AccessTokenInfo(secondOAuthAccessToken, + expiresInSeconds); + } + } + + private static class FailingRefreshProvider + extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + + @Override + protected AccessTokenInfo getAccessTokenInfo() { + if (tokenCounter.incrementAndGet() == 1) { + return new AccessTokenInfo(oauthAccessToken, 12); + } + throw new IllegalStateException("test refresh failure"); + } + + private void waitForRefreshAttempt(long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (tokenCounter.get() < 2 && + System.currentTimeMillis() < limit) { + Thread.sleep(50); + } + assertTrue("Timed out waiting for refresh callback", + tokenCounter.get() >= 2); + } + } +}