diff --git a/README.md b/README.md index 8025372b..a75b5d6c 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Database. In addition, a running proxy service is required. See [Information about the proxy](https://docs.oracle.com/en/database/other-databases/nosql-database/24.3/admin/proxy.html) for proxy configuration information. -On-premise authorization requires use of [StoreAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/StoreAccessTokenProvider.html) +On-premise authorization supports [StoreAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/StoreAccessTokenProvider.html) for store credentials and [OAuthAccessTokenProvider](https://oracle.github.io/nosql-java-sdk/oracle/nosql/driver/kv/OAuthAccessTokenProvider.html) for OAuth 2.0 access-token authentication. See the Quickstart example below for code details for connecting on-premise. ### Connecting to the Oracle NoSQL Database Cloud Simulator @@ -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 from an environment variable. +Supplying the token this way keeps the example independent of the identity +provider and avoids placing the bearer token in the command line. A production +application should obtain a usable token in +`OAuthAccessTokenProvider.getAccessToken()` and leave automatic renewal +enabled. The SDK schedules re-login from the NoSQL login-token expiration +returned by the server. + +Run the example using an OAuth access token: + + $ export NOSQL_OAUTH_ACCESS_TOKEN='' + $ 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/driver/pom.xml b/driver/pom.xml index 27114d10..a89f94de 100644 --- a/driver/pom.xml +++ b/driver/pom.xml @@ -88,7 +88,8 @@ cloudsim - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, @@ -106,7 +107,8 @@ onprem - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, @@ -124,7 +126,8 @@ onprem - StoreAccessTokenProviderTest.java, ResourcePrincipalProviderTest.java, + StoreAccessTokenProviderTest.java, OAuthAccessTokenProviderTest.java, + ResourcePrincipalProviderTest.java, ConfigFileTest.java, SignatureProviderTest.java, AuthRetryTest.java, UserProfileProviderTest.java, InstancePrincipalsProviderTest.java, HandleConfigTest.java, JsonTest.java, ValueTest.java, @@ -143,7 +146,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 414e2d80..a5d12bfc 100644 --- a/driver/src/main/java/oracle/nosql/driver/http/Client.java +++ b/driver/src/main/java/oracle/nosql/driver/http/Client.java @@ -84,6 +84,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; @@ -292,9 +293,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(); @@ -384,6 +385,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. @@ -691,12 +697,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 " + @@ -860,6 +864,32 @@ public Result execute(Request kvRequest) { logFine(logger, "Client re-auth on AuthenticationException"); 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 (retriedOAuthAuthentication(kvRequest)) { + kvRequest.setRateLimitDelayedMs(rateDelayedMs); + statsControl.observeError(kvRequest); + logFine(logger, + "Client OAuth re-auth failed with " + + rae.getClass().getName()); + throw rae; + } + ((OAuthAccessTokenProvider) authProvider) + .invalidateAuthorizationString(authString); + kvRequest.addRetryException( + AuthenticationException.class); + kvRequest.incrementRetries(); + exception = rae; + logFine(logger, + "Client retrying OAuth re-auth on " + + rae.getClass().getName()); + continue; } kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); @@ -875,16 +905,31 @@ public Result execute(Request kvRequest) { * failures. This does not include permissions-related errors, * which would be a UnauthorizedException. */ - if (retriedInvalidAuthorizationException(kvRequest)) { + final boolean oauthProvider = + authProvider instanceof OAuthAccessTokenProvider; + if ((oauthProvider && + retriedOAuthAuthentication(kvRequest)) || + (!oauthProvider && + retriedException( + kvRequest, + InvalidAuthorizationException.class))) { /* same as NoSQLException below */ kvRequest.setRateLimitDelayedMs(rateDelayedMs); statsControl.observeError(kvRequest); - logFine(logger, "Client execute InvalidAuthorizationException"); + logFine(logger, + "Client execute InvalidAuthorizationException"); throw iae; } /* flush auth cache and do one retry */ - authProvider.flushCache(); - kvRequest.addRetryException(iae.getClass()); + if (oauthProvider) { + ((OAuthAccessTokenProvider) authProvider) + .invalidateAuthorizationString(authString); + } else { + authProvider.flushCache(); + } + kvRequest.addRetryException( + oauthProvider ? InvalidAuthorizationException.class : + iae.getClass()); kvRequest.incrementRetries(); exception = iae; logFine(logger, @@ -1597,20 +1642,29 @@ 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 boolean retriedOAuthAuthentication(Request request) { + return retriedException(request, AuthenticationException.class) || + retriedException( + request, InvalidAuthorizationException.class); } private void throwIfTransportRetryNotAllowed(Request request, @@ -1644,8 +1698,15 @@ private void handleRetry(RetryableException re, private void logRetries(int numRetries, Throwable exception) { Level level = Level.FINE; if (logger != null) { + final String exceptionDetail = + exception == null ? "" : + ", exception: " + + (authProvider instanceof OAuthAccessTokenProvider && + (exception instanceof AuthenticationException || + exception instanceof InvalidAuthorizationException) ? + exception.getClass().getName() : exception); logger.log(level, "Client, doing retry: " + numRetries + - (exception != null ? ", exception: " + exception : "")); + exceptionDetail); } } diff --git a/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java b/driver/src/main/java/oracle/nosql/driver/http/NoSQLHandleImpl.java index 86231dc1..a48de937 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,18 @@ 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); + } + oatProvider.prepare(config); } else if (ap instanceof SignatureProvider) { SignatureProvider sigProvider = (SignatureProvider) ap; if (sigProvider.getLogger() == null) { @@ -176,6 +180,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..e763aef6 --- /dev/null +++ b/driver/src/main/java/oracle/nosql/driver/kv/OAuthAccessTokenProvider.java @@ -0,0 +1,866 @@ +/*- + * 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.Objects; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpStatusClass; +import io.netty.handler.ssl.SslContext; +import oracle.nosql.driver.AuthorizationProvider; +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.RequestTimeoutException; +import oracle.nosql.driver.SystemException; +import oracle.nosql.driver.httpclient.HttpClient; +import oracle.nosql.driver.ops.Request; +import oracle.nosql.driver.util.HttpRequestUtil; +import oracle.nosql.driver.util.HttpRequestUtil.HttpResponse; +import oracle.nosql.driver.values.FieldValue; +import oracle.nosql.driver.values.JsonUtils; +import oracle.nosql.driver.values.MapValue; + +/** + * On-premises only. + * + *

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

+ * + *

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

+ * + *

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

+ */ +public abstract class OAuthAccessTokenProvider implements AuthorizationProvider { + + + /* + * 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"; + + /* + * 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 = "/logout"; + + /* + * 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<>(); + + /* + * Expiration time of the NoSQL login token, in milliseconds since epoch. + * The server caps this deadline at the earlier of the validated OAuth + * access-token expiration and the configured store session timeout. + */ + private volatile long loginTokenExpireAt; + + /* + * KV-authenticated identity associated with this provider's login token. + */ + private OAuthIdentity authenticatedIdentity; + + /* Default refresh time before NoSQL login-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 final AtomicBoolean isClosed = new AtomicBoolean(false); + + /* + * SslContext used by http client + */ + private SslContext sslContext; + + /* + * SSL handshake timeout in milliseconds; + */ + private int sslHandshakeTimeoutMs; + + /* Handle configuration used to propagate HTTP proxy settings. */ + private NoSQLHandleConfig handleConfig; + + /** + * @hidden + * This is only used for unit test + */ + public static boolean disableSSLHook; + + /* + * A schedule used to periodically invoke the callback + */ + private final ScheduledThreadPoolExecutor scheduler; + + /* + * Current scheduled refresh task. + */ + private ScheduledFuture refreshTask; + + /* Guards auto-renew state transitions and refreshTask. */ + private final Object refreshLock = new Object(); + + /* Invalidates a scheduled refresh that has already started running. */ + private final AtomicLong refreshGeneration = new AtomicLong(); + + + /** + * Creates a provider with automatic re-login enabled. + */ + public OAuthAccessTokenProvider() { + loginHost = null; + endpoint = null; + loginPort = 0; + logger = null; + scheduler = new ScheduledThreadPoolExecutor(1, r -> { + Thread t = new Thread(r, "OAuthTokenRefresher"); + t.setDaemon(true); + return t; + }); + scheduler.setRemoveOnCancelPolicy(true); + } + + /** + * Returns an OAuth access token for a login exchange. + * + *

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

+ * + * @return an OAuth access token + */ + protected abstract String getAccessToken(); + + private synchronized String performLogin(boolean force, + Request request, + long expectedGeneration) { + final String oldAuthorization = authString.get(); + /* re-check the authString in case of a race */ + if (loginAborted(force, expectedGeneration)) { + return oldAuthorization; + } + String accessToken = getAccessToken(); + if (accessToken == null || accessToken.isEmpty()) { + throw new IllegalArgumentException( + "Invalid access token provided"); + } + + if (loginAborted(force, expectedGeneration)) { + return authString.get(); + } + final int timeoutMs = getRemainingTimeoutMs(request); + + try { + /* + * Send request to server for login token + */ + if (loginAborted(force, expectedGeneration)) { + return authString.get(); + } + HttpResponse response = + sendRequest(BEARER_PREFIX + accessToken, + LOGIN_SERVICE, timeoutMs); + + if (loginAborted(force, expectedGeneration)) { + logoutLoginResponse(response, timeoutMs); + return authString.get(); + } + + /* + * login fail + */ + if(HttpStatusClass.SERVER_ERROR.contains(response.getStatusCode())) { + throw new SystemException( + "OAuth login failed with HTTP status " + + response.getStatusCode()); + } + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + throw new InvalidAuthorizationException( + "OAuth login failed with HTTP status " + + response.getStatusCode()); + } + + /* + * Generate the authentication string using login token + */ + final LoginResult loginResult; + try { + loginResult = parseJsonResult(response.getOutput()); + final long expireAt = loginResult.getExpireAt(); + if (expireAt <= System.currentTimeMillis()) { + throw new InvalidAuthorizationException( + "OAuth login response contains an expired login token"); + } + validateAuthenticatedIdentity( + loginResult.getAuthenticatedIdentity()); + } catch (InvalidAuthorizationException iae) { + logoutLoginResponse(response, timeoutMs); + throw iae; + } + final String newAuthorization = + BEARER_PREFIX + loginResult.getToken(); + if (loginAborted(force, expectedGeneration) || + !authString.compareAndSet(oldAuthorization, newAuthorization)) { + logoutSession(newAuthorization, timeoutMs); + return authString.get(); + } + loginTokenExpireAt = loginResult.getExpireAt(); + /* + * Schedule re-login using the server-authoritative session + * expiration. + */ + scheduleRefresh(); + return newAuthorization; + + } catch (NoSQLException nse) { + throw nse; + } catch (Exception e) { + throw new NoSQLException("Login with OAuth token failed", e); + } + } + + private boolean loginAborted(boolean force, long expectedGeneration) { + return isClosed.get() || + (expectedGeneration >= 0 && + (expectedGeneration != refreshGeneration.get() || + !autoRenew)) || + (!force && authString.get() != null); + } + + private int getRemainingTimeoutMs(Request request) { + if (request == null) { + return 0; + } + + final int timeoutMs = request.getTimeoutInternal(); + final long startNanos = request.getStartNanos(); + if (timeoutMs <= 0 || startNanos == 0) { + return timeoutMs; + } + + final long elapsedNanos = System.nanoTime() - startNanos; + if (elapsedNanos <= 0) { + return timeoutMs; + } + final long elapsedMs = TimeUnit.NANOSECONDS.toMillis(elapsedNanos); + if (elapsedMs >= timeoutMs) { + throw new RequestTimeoutException( + timeoutMs, "OAuth login exceeded the request timeout"); + } + return timeoutMs - (int) elapsedMs; + } + + /** + * @hidden + */ + @Override + public String getAuthorizationString(Request request) { + + /* + * Already close + */ + if (isClosed.get()) { + return null; + } + + /* + * If there is no cached auth string, re-authentication to retrieve + * the login token and generate the auth string. + */ + final String authorization = authString.get(); + if (authorization != null) { + return authorization; + } + return performLogin(false, request, -1); + } + + /** + * Closes the provider, releasing resources such as a stored login token. + */ + @Override + public void close() { + + /* + * Already closed + */ + if (!isClosed.compareAndSet(false, true)) { + return; + } + + refreshGeneration.incrementAndGet(); + final String logoutAuth; + synchronized (this) { + logoutAuth = authString.getAndSet(null); + synchronized (refreshLock) { + if (!scheduler.isShutdown()) { + scheduler.shutdownNow(); + } + cancelRefreshTaskLocked(); + } + + loginTokenExpireAt = 0; + authenticatedIdentity = null; + } + + if (logoutAuth != null) { + logoutSession(logoutAuth, 0); + } + } + + 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, HTTP status " + + response.getStatusCode()); + } + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to logout OAuth session, exception type " + + e.getClass().getName()); + } + } + } + + /** + * Invalidate the cached NoSQL login token. + */ + @Override + public void flushCache() { + refreshGeneration.incrementAndGet(); + synchronized (this) { + if (isClosed.get()) { + return; + } + authString.set(null); + cancelRefreshTask(); + loginTokenExpireAt = 0; + } + } + + /** + * Invalidates the cached login token only if it was used by the failed + * request. A newer token installed by another request is preserved. + * + * @hidden + * + * @param failedAuthorization authorization value used by the failed request + * @return true if the cached value was invalidated + */ + public boolean invalidateAuthorizationString(String failedAuthorization) { + if (isClosed.get() || failedAuthorization == null || + !failedAuthorization.equals(authString.get())) { + return false; + } + + refreshGeneration.incrementAndGet(); + synchronized (this) { + if (isClosed.get()) { + return false; + } + if (!authString.compareAndSet(failedAuthorization, null)) { + if (authString.get() != null && loginTokenExpireAt > 0) { + scheduleRefresh(); + } + return false; + } + cancelRefreshTask(); + loginTokenExpireAt = 0; + return true; + } + } + + /** + * Retrieve login token from JSON string. + */ + private LoginResult parseJsonResult(String jsonResult) { + try { + final FieldValue result = + JsonUtils.createValueFromJson(jsonResult, null); + if (!result.isMap()) { + throw new IllegalArgumentException("Expected JSON object"); + } + final MapValue mapValue = result.asMap(); + + /* + * Extract login token, expiration, and authenticated identity from + * JSON result. Do not use the coercive MapValue getters here; the + * endpoint contract requires exact JSON types. + */ + final String token = getRequiredString(mapValue, "token"); + final FieldValue expireAtValue = mapValue.get("expireAt"); + if (expireAtValue == null || + !(expireAtValue.isLong() || expireAtValue.isInteger())) { + throw new IllegalArgumentException("Invalid expireAt"); + } + return new LoginResult( + token, + expireAtValue.getLong(), + parseAuthenticatedIdentity(mapValue)); + } catch (InvalidAuthorizationException iae) { + throw iae; + } catch (RuntimeException re) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response"); + } + } + + private void logoutLoginResponse(HttpResponse response, int timeoutMs) { + if (response.getStatusCode() != HttpResponseStatus.OK.code()) { + return; + } + try { + final MapValue loginResult = + JsonUtils.createValueFromJson( + response.getOutput(), null).asMap(); + final FieldValue tokenValue = loginResult.get("token"); + if (tokenValue != null && tokenValue.isString() && + !tokenValue.getString().trim().isEmpty()) { + final String token = tokenValue.getString(); + logoutSession(BEARER_PREFIX + token, timeoutMs); + } + } catch (RuntimeException re) { + if (logger != null) { + logger.info("Unable to clean up OAuth login response, " + + "exception type " + re.getClass().getName()); + } + } + } + + private OAuthIdentity parseAuthenticatedIdentity(MapValue loginResult) { + final FieldValue identityValue = + loginResult.get("authenticatedIdentity"); + if (identityValue == null) { + return null; + } + try { + if (!identityValue.isMap()) { + throw new IllegalArgumentException("Expected identity object"); + } + final MapValue identity = identityValue.asMap(); + return new OAuthIdentity( + getRequiredString(identity, "type"), + getRequiredString(identity, "issuer"), + getRequiredString(identity, "subjectType"), + getRequiredString(identity, "subjectId")); + } catch (RuntimeException re) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response: authenticated identity is " + + "invalid"); + } + } + + private static String getRequiredString(MapValue value, String fieldName) { + final FieldValue field = value.get(fieldName); + if (field == null || !field.isString() || + field.getString().trim().isEmpty()) { + throw new IllegalArgumentException( + "Missing or invalid " + fieldName); + } + return field.getString(); + } + + private void validateAuthenticatedIdentity(OAuthIdentity identity) { + if (identity == null) { + throw new InvalidAuthorizationException( + "Invalid OAuth login response: authenticated identity is " + + "missing"); + } + if (authenticatedIdentity == null) { + authenticatedIdentity = identity; + return; + } + if (!authenticatedIdentity.equals(identity)) { + throw new InvalidAuthorizationException( + "Logout required prior to logging in with new user identity."); + } + } + + /* Schedule automatic re-login slightly before session expiry. */ + private void scheduleRefresh() { + synchronized (refreshLock) { + scheduleRefreshLocked(); + } + } + + private void scheduleRefreshLocked() { + final long generation = refreshGeneration.incrementAndGet(); + cancelRefreshTaskLocked(); + if (!autoRenew || isClosed.get() || authString.get() == null || + loginTokenExpireAt <= 0 || scheduler.isShutdown()) { + return; + } + final long now = System.currentTimeMillis(); + final long delay = Math.max( + 1000, + loginTokenExpireAt - now - + TimeUnit.SECONDS.toMillis(REFRESH_AHEAD_SECONDS)); + refreshTask = scheduler.schedule(new Runnable() { + @Override + public void run() { + refreshLoginToken(generation); + } + }, delay, TimeUnit.MILLISECONDS); + } + + private void refreshLoginToken(long generation) { + if (!autoRenew || isClosed.get() || + generation != refreshGeneration.get()) { + return; + } + + try { + performLogin(true, null, generation); + } catch (Exception e) { + if (logger != null) { + logger.info("Failed to obtain refreshed token, exception " + + "type " + e.getClass().getName()); + } + } + } + + private void cancelRefreshTask() { + synchronized (refreshLock) { + cancelRefreshTaskLocked(); + } + } + + private void cancelRefreshTaskLocked() { + if (refreshTask != null) { + refreshTask.cancel(false); + refreshTask = null; + } + } + + /** + * 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 (!"https".equalsIgnoreCase(url.getProtocol())) { + 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; + } + + /** + * Internal use only. + *

+ * Completes handle-dependent configuration while preserving an explicitly + * configured OAuth endpoint or SSL context. + * + * @param config the handle configuration + * @return this + * @hidden + */ + public OAuthAccessTokenProvider prepare(NoSQLHandleConfig config) { + final URL serviceURL = config.getServiceURL(); + if (serviceURL == null || + !"https".equalsIgnoreCase(serviceURL.getProtocol())) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires use of https for the " + + "service endpoint"); + } + + if (endpoint == null) { + String serviceEndpoint = serviceURL.toString(); + if (serviceEndpoint.endsWith("/")) { + serviceEndpoint = serviceEndpoint.substring( + 0, serviceEndpoint.length() - 1); + } + setEndpoint(serviceEndpoint); + } + if (sslContext == null) { + sslContext = config.getSslContext(); + } + if (sslHandshakeTimeoutMs == 0) { + sslHandshakeTimeoutMs = config.getSSLHandshakeTimeout(); + } + if (!disableSSLHook && sslContext == null) { + throw new IllegalArgumentException( + "OAuthAccessTokenProvider requires an SSL context"); + } + handleConfig = config; + 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) { + synchronized (refreshLock) { + if (this.autoRenew == autoRenew) { + return this; + } + this.autoRenew = autoRenew; + if (autoRenew) { + scheduleRefreshLocked(); + } else { + refreshGeneration.incrementAndGet(); + cancelRefreshTaskLocked(); + } + } + return this; + } + + /** + * Send HTTPS request to login/logout service location with proper + * authentication information. + */ + private HttpResponse sendRequest(String authHeader, + String serviceName, + int timeoutMs) { + HttpClient client = null; + try { + if (!disableSSLHook && sslContext == null) { + throw new IllegalStateException( + "OAuthAccessTokenProvider requires an SSL context"); + } + final HttpHeaders headers = new DefaultHttpHeaders(); + headers.set(AUTHORIZATION, authHeader); + client = HttpClient.createMinimalClient + (loginHost, + loginPort, + !disableSSLHook ? sslContext : null, + sslHandshakeTimeoutMs, + serviceName, + null); + if (handleConfig != null && + handleConfig.getProxyHost() != null) { + client.configureProxy(handleConfig); + } + if (timeoutMs == 0) { + timeoutMs = HTTP_TIMEOUT_MS; + } + return HttpRequestUtil.doGetRequestOnce( + client, + NoSQLHandleConfig.createURL(endpoint, basePath + serviceName) + .toString(), + headers, timeoutMs, null); + } finally { + if (client != null) { + client.shutdown(); + } + } + } + + private static final class LoginResult { + + private final String token; + private final long expireAt; + private final OAuthIdentity authenticatedIdentity; + + private LoginResult(String token, + long expireAt, + OAuthIdentity authenticatedIdentity) { + this.token = token; + this.expireAt = expireAt; + this.authenticatedIdentity = authenticatedIdentity; + } + + private String getToken() { + return token; + } + + 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/main/java/oracle/nosql/driver/util/HttpRequestUtil.java b/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java index 0ad0a1ba..42010603 100644 --- a/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java +++ b/driver/src/main/java/oracle/nosql/driver/util/HttpRequestUtil.java @@ -30,6 +30,7 @@ import java.util.logging.Logger; import javax.net.ssl.SSLException; +import oracle.nosql.driver.NoSQLException; import oracle.nosql.driver.RequestTimeoutException; import oracle.nosql.driver.httpclient.HttpClient; import oracle.nosql.driver.httpclient.ResponseHandler; @@ -49,6 +50,7 @@ public class HttpRequestUtil { private static final Charset utf8 = StandardCharsets.UTF_8; private static final int DEFAULT_DELAY_MS = 200; + private static final int RETRY_UNTIL_TIMEOUT = -1; /** * Issue HTTP GET request using given HTTP client with retries and general @@ -83,7 +85,32 @@ public static HttpResponse doGetRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, GET, - null /* no payload */, timeoutMs, logger); + null /* no payload */, timeoutMs, logger, + RETRY_UNTIL_TIMEOUT); + } + + /** + * Issues one HTTP GET request without retrying transport failures or + * server-error responses. + * + * @hidden + * + * @param httpClient a HTTP client + * @param uri the request URI + * @param headers HTTP headers of this request + * @param timeoutMs request timeout in milliseconds + * @param logger logger + * @return HTTP response + */ + public static HttpResponse doGetRequestOnce(HttpClient httpClient, + String uri, + HttpHeaders headers, + int timeoutMs, + Logger logger) { + + return doRequest(httpClient, uri, headers, GET, + null /* no payload */, timeoutMs, logger, + 0 /* no retries */); } /** @@ -122,7 +149,7 @@ public static HttpResponse doPostRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, POST, - payload, timeoutMs, logger); + payload, timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } /** @@ -161,7 +188,7 @@ public static HttpResponse doPutRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, PUT, - payload, timeoutMs, logger); + payload, timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } /** @@ -197,7 +224,7 @@ public static HttpResponse doDeleteRequest(HttpClient httpClient, Logger logger) { return doRequest(httpClient, uri, headers, DELETE, null, - timeoutMs, logger); + timeoutMs, logger, RETRY_UNTIL_TIMEOUT); } private static HttpResponse doRequest(HttpClient httpClient, @@ -206,7 +233,8 @@ private static HttpResponse doRequest(HttpClient httpClient, HttpMethod method, byte[] payload, int timeoutMs, - Logger logger) { + Logger logger, + int maxRetries) { final long startTime = System.currentTimeMillis(); int numRetries = 0; @@ -253,6 +281,9 @@ private static HttpResponse doRequest(HttpClient httpClient, * this indicates server internal error. */ if (res.getStatusCode() >= 500) { + if (!canRetry(numRetries, maxRetries)) { + return res; + } logFine(logger, "Remote server temporarily unavailable," + " status code " + res.getStatusCode() + @@ -275,7 +306,6 @@ private static HttpResponse doRequest(HttpClient httpClient, * disconnected. Retry. */ exception = ioe; - ++numRetries; if (ioe instanceof SSLException) { /* disconnect the channel to force a new one */ if (channel != null) { @@ -284,8 +314,14 @@ private static HttpResponse doRequest(HttpClient httpClient, channel.disconnect(); } } else { - delay(); + if (canRetry(numRetries, maxRetries)) { + delay(); + } + } + if (!canRetry(numRetries, maxRetries)) { + throw requestFailed(ioe); } + ++numRetries; continue; } catch (InterruptedException ie) { throw new RuntimeException( @@ -308,6 +344,9 @@ private static HttpResponse doRequest(HttpClient httpClient, name + "message: " + t.getMessage()); exception = t; + if (!canRetry(numRetries, maxRetries)) { + throw requestFailed(t); + } delay(); ++numRetries; continue; @@ -324,6 +363,14 @@ private static HttpResponse doRequest(HttpClient httpClient, exception); } + private static boolean canRetry(int numRetries, int maxRetries) { + return maxRetries == RETRY_UNTIL_TIMEOUT || numRetries < maxRetries; + } + + private static NoSQLException requestFailed(Throwable cause) { + return new NoSQLException("Unable to execute HTTP request", cause); + } + private static FullHttpRequest buildRequest(String requestURI, HttpMethod method, HttpHeaders headers) { diff --git a/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java b/driver/src/test/java/oracle/nosql/driver/iam/AuthRetryTest.java index 6a97f900..722372ae 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; @@ -27,6 +29,7 @@ import java.net.URL; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Logger; import static org.junit.Assert.assertEquals; @@ -60,6 +63,90 @@ public void testInvalidAuthorizationExceptionRetry() InvalidAuthorizationException.class)); } + @Test + public void testOAuthAuthenticationExceptionRetry() + throws Exception { + + testHttpClient.oauthFailures = + new OAuthFailure[] { + OAuthFailure.AUTHENTICATION, + OAuthFailure.AUTHENTICATION + }; + 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.invalidationCount.get()); + assertEquals("Bearer Test-1", provider.lastInvalidated.get()); + assertEquals(0, provider.flushCount.get()); + assertEquals(1, + request.getRetryStats() + .getNumExceptions(AuthenticationException.class)); + } + + @Test + public void testOAuthAlternatingAuthenticationThenAuthorization() { + assertOAuthFailureSequence( + OAuthFailure.AUTHENTICATION, + OAuthFailure.INVALID_AUTHORIZATION, + InvalidAuthorizationException.class); + } + + @Test + public void testOAuthAlternatingAuthorizationThenAuthentication() { + assertOAuthFailureSequence( + OAuthFailure.INVALID_AUTHORIZATION, + OAuthFailure.AUTHENTICATION, + AuthenticationException.class); + } + + @Test + public void testOAuthAuthenticationSubclassRetryIsBounded() { + assertOAuthFailureSequence( + OAuthFailure.AUTHENTICATION_SUBCLASS, + OAuthFailure.INVALID_AUTHORIZATION, + InvalidAuthorizationException.class); + } + + @Test + public void testOAuthAuthorizationSubclassRetryIsBounded() { + assertOAuthFailureSequence( + OAuthFailure.INVALID_AUTHORIZATION_SUBCLASS, + OAuthFailure.AUTHENTICATION, + AuthenticationException.class); + } + + private void assertOAuthFailureSequence( + OAuthFailure first, + OAuthFailure second, + Class expectedClass) { + + testHttpClient.oauthFailures = new OAuthFailure[] { first, second }; + TestOAuthProvider provider = new TestOAuthProvider(); + TestClient client = getTestClient(provider); + Request request = new GetRequest().setTableName("foo") + .setKey(new MapValue().put("foo", "bar")); + + assertThrows(expectedClass, () -> client.execute(request)); + assertEquals(2, testHttpClient.execCount.get()); + assertEquals(1, provider.invalidationCount.get()); + assertEquals("Bearer Test-1", provider.lastInvalidated.get()); + assertEquals("Bearer Test-2", provider.authorization.get()); + assertEquals(0, provider.flushCount.get()); + assertEquals(1, request.getRetryStats().getRetries()); + } + private TestClient getTestClient() { AuthorizationProvider provider = new AuthorizationProvider() { @@ -72,6 +159,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 +186,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 OAuthFailure[] oauthFailures; public TestHttpClient() { super("localhost", 8080, 1, 0, 0, 0, 0, null, 0, "test", null); @@ -104,6 +198,26 @@ public TestHttpClient() { public void runRequest(HttpRequest request, ResponseHandler handler, Channel channel) { + if (oauthFailures != null) { + final int index = execCount.getAndIncrement(); + final OAuthFailure failure = + oauthFailures[Math.min(index, oauthFailures.length - 1)]; + if (failure == OAuthFailure.AUTHENTICATION || + failure == OAuthFailure.AUTHENTICATION_SUBCLASS) { + authenticationExceptionCount.incrementAndGet(); + if (failure == OAuthFailure.AUTHENTICATION_SUBCLASS) { + throw new TestAuthenticationException("test"); + } + throw new AuthenticationException("test"); + } + iaeCount.incrementAndGet(); + if (failure == + OAuthFailure.INVALID_AUTHORIZATION_SUBCLASS) { + throw new TestInvalidAuthorizationException("test"); + } + throw new InvalidAuthorizationException("test"); + } + /* * Simulate an authentication failure scenario where the initial * attempt throws SecurityInfoNotReadyException, and subsequent @@ -133,4 +247,66 @@ public boolean isActive() { }; } } + + private static class TestOAuthProvider extends OAuthAccessTokenProvider { + + private final AtomicReference authorization = + new AtomicReference("Bearer Test-1"); + private final AtomicReference lastInvalidated = + new AtomicReference(); + private final AtomicInteger invalidationCount = new AtomicInteger(0); + private final AtomicInteger flushCount = new AtomicInteger(0); + + @Override + public String getAuthorizationString(Request request) { + return authorization.get(); + } + + @Override + public boolean invalidateAuthorizationString( + String failedAuthorization) { + + invalidationCount.incrementAndGet(); + lastInvalidated.set(failedAuthorization); + return authorization.compareAndSet( + failedAuthorization, "Bearer Test-2"); + } + + @Override + public void flushCache() { + flushCount.incrementAndGet(); + } + + @Override + protected String getAccessToken() { + return "Test"; + } + } + + private enum OAuthFailure { + AUTHENTICATION, + AUTHENTICATION_SUBCLASS, + INVALID_AUTHORIZATION, + INVALID_AUTHORIZATION_SUBCLASS + } + + private static class TestAuthenticationException + extends AuthenticationException { + + private static final long serialVersionUID = 1L; + + private TestAuthenticationException(String message) { + super(message); + } + } + + private static class TestInvalidAuthorizationException + extends InvalidAuthorizationException { + + private static final long serialVersionUID = 1L; + + private TestInvalidAuthorizationException(String message) { + super(message); + } + } } diff --git a/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java new file mode 100644 index 00000000..59b32482 --- /dev/null +++ b/driver/src/test/java/oracle/nosql/driver/kv/OAuthAccessTokenProviderTest.java @@ -0,0 +1,1118 @@ +/*- + * 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.assertFalse; +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.lang.reflect.Field; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import oracle.nosql.driver.InvalidAuthorizationException; +import oracle.nosql.driver.NoSQLHandle; +import oracle.nosql.driver.NoSQLHandleConfig; +import oracle.nosql.driver.NoSQLHandleFactory; +import oracle.nosql.driver.NoSQLException; +import oracle.nosql.driver.SystemException; +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.Before; +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 + "/logout"; + + 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 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 "; + + 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 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; + private static volatile int loginStatus = HttpURLConnection.HTTP_OK; + private static volatile String loginErrorBody = ""; + private static volatile String loginResponseOverride; + private static volatile int logoutStatus = HttpURLConnection.HTTP_OK; + private static volatile String logoutErrorBody = ""; + + @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 (loginStatus != HttpURLConnection.HTTP_OK) { + sendResponse(exchange, loginStatus, loginErrorBody); + return; + } + if (loginResponseOverride != null) { + sendResponse(exchange, HttpURLConnection.HTTP_OK, + loginResponseOverride); + return; + } + if (count == 1) { + generateLoginToken( + loginToken, + omitAuthenticatedIdentity ? null : loginIssuer, + loginSubjectType, + loginSubjectId, + exchange); + } else { + generateLoginToken(reloginToken, reloginIssuer, + reloginSubjectType, + reloginSubjectId, 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(); + sendResponse(exchange, logoutStatus, logoutErrorBody); + } + }); + } + + @AfterClass + public static void staticTearDown() throws Exception { + OAuthAccessTokenProvider.disableSSLHook = false; + if (server != null) { + server.stop(0); + } + } + + @Before + public void resetTestState() { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + resetAuthenticatedIdentity(); + loginTokenLifetimeMs = 15_000; + loginDelayMs = 0; + loginStatus = HttpURLConnection.HTTP_OK; + loginErrorBody = ""; + loginResponseOverride = null; + logoutStatus = HttpURLConnection.HTTP_OK; + logoutErrorBody = ""; + } + + @Test + public void testBasic() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + resetAuthenticatedIdentity(); + 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); + resetAuthenticatedIdentity(); + 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); + resetAuthenticatedIdentity(); + loginTokenLifetimeMs = 12_000; + TestProvider provider = new TestProvider(); + 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); + resetAuthenticatedIdentity(); + loginTokenLifetimeMs = 12_000; + 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); + resetAuthenticatedIdentity(); + 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 testLoginUsesRemainingRequestTimeout() throws Exception { + loginDelayMs = 500; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + final GetRequest request = new GetRequest().setTimeout(200); + request.setStartNanos( + System.nanoTime() - TimeUnit.MILLISECONDS.toNanos(150)); + final long startNanos = System.nanoTime(); + + try { + provider.getAuthorizationString(request); + fail("OAuth login should have observed the remaining timeout"); + } catch (NoSQLException expected) { + final long elapsedMs = + TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startNanos); + assertTrue("OAuth login reused the original timeout: " + + elapsedMs, elapsedMs < 150); + } finally { + Thread.sleep(loginDelayMs + 100); + loginDelayMs = 0; + provider.close(); + } + } + + @Test + public void testLoginUsesConfiguredHttpProxy() throws Exception { + final CountDownLatch proxyAccepted = new CountDownLatch(1); + final AtomicReference proxyFailure = + new AtomicReference(); + final TestProvider provider = new TestProvider(); + + final ServerSocket proxyServer = new ServerSocket(0); + final Thread proxyThread = new Thread(() -> { + try { + final Socket proxySocket = proxyServer.accept(); + proxyAccepted.countDown(); + proxySocket.close(); + } catch (Throwable t) { + proxyFailure.set(t); + } + }, "OAuthProxyTest"); + proxyThread.start(); + + try { + final NoSQLHandleConfig config = + new NoSQLHandleConfig(endpoint) + .setProxyHost("localhost") + .setProxyPort(proxyServer.getLocalPort()); + provider.setEndpoint(endpoint).setAutoRenew(false); + provider.prepare(config); + + try { + provider.getAuthorizationString( + new GetRequest().setTimeout(1_000)); + fail("OAuth login should have connected to the test proxy"); + } catch (NoSQLException expected) { + assertTrue(proxyAccepted.await(5, TimeUnit.SECONDS)); + } + + proxyThread.join(5_000); + assertFalse(proxyThread.isAlive()); + if (proxyFailure.get() != null) { + throw new AssertionError( + "Test proxy failed", proxyFailure.get()); + } + assertEquals(0, loginCounter.get()); + } finally { + provider.close(); + proxyServer.close(); + proxyThread.join(5_000); + } + } + + @Test + public void testFlushCacheRelogin() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + resetAuthenticatedIdentity(); + 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 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; + resetAuthenticatedIdentity(); + reloginIssuer = issuer; + reloginSubjectType = subjectType; + reloginSubjectId = subjectId; + 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 identity should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Logout required prior to logging in with new " + + "user identity.")); + } finally { + resetAuthenticatedIdentity(); + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(reloginToken, lastLogoutToken); + } + + @Test + public void testLoginWithoutAuthenticatedIdentityFails() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + lastLogoutToken = null; + resetAuthenticatedIdentity(); + omitAuthenticatedIdentity = true; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("Login without an authenticated identity should have failed"); + } catch (InvalidAuthorizationException iae) { + assertTrue(iae.getMessage().startsWith( + "Invalid OAuth login response: authenticated identity is " + + "missing")); + } finally { + resetAuthenticatedIdentity(); + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testCloseLogsOutLoginToken() throws Exception { + loginCounter.set(0); + logoutCounter.set(0); + resetAuthenticatedIdentity(); + 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()); + } + + @Test + public void testCallbackCloseStopsLogin() { + ClosingCallbackProvider provider = new ClosingCallbackProvider(); + provider.setEndpoint(endpoint); + + assertNull(provider.getAuthorizationString(null)); + assertEquals(0, loginCounter.get()); + assertEquals(0, logoutCounter.get()); + } + + @Test + public void testMissingAccessTokenRejectedBeforeLogin() { + OAuthAccessTokenProvider provider = + new OAuthAccessTokenProvider() { + @Override + protected String getAccessToken() { + return null; + } + }; + provider.setEndpoint(endpoint); + + try { + provider.getAuthorizationString(null); + fail("A missing access token should be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("access token")); + } finally { + provider.close(); + } + assertEquals(0, loginCounter.get()); + } + + @Test + public void testExpiredLoginTokenRejectedAndLoggedOut() { + loginTokenLifetimeMs = -1_000; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + provider.getAuthorizationString(null); + fail("An expired login token should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains("expired login token")); + } finally { + provider.close(); + } + assertEquals(1, loginCounter.get()); + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testMalformedIdentityRejectedAndLoggedOut() { + final long expireAt = System.currentTimeMillis() + 60_000; + final String encodedToken = encodeLoginToken(loginToken, expireAt); + loginResponseOverride = + "{\"token\":\"" + encodedToken + "\"," + + "\"expireAt\":" + expireAt + "," + + "\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + loginIssuer + "\"," + + "\"subjectType\":\"user\"}}"; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A malformed authenticated identity should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains( + "authenticated identity is invalid")); + } finally { + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testNonPositiveLoginExpiryRejectedAndLoggedOut() { + final String encodedToken = encodeLoginToken(loginToken, 0); + loginResponseOverride = createLoginResponse( + encodedToken, 0, loginIssuer, loginSubjectType, loginSubjectId); + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A non-positive login-token expiration should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains("expired login token")); + } finally { + provider.close(); + } + assertEquals(1, logoutCounter.get()); + assertEquals(loginToken, lastLogoutToken); + } + + @Test + public void testNullLoginTokenRejected() { + final long expireAt = System.currentTimeMillis() + 60_000; + loginResponseOverride = + "{\"token\":null," + + "\"expireAt\":" + expireAt + "," + + "\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + loginIssuer + "\"," + + "\"subjectType\":\"user\"," + + "\"subjectId\":\"" + loginSubjectId + "\"}}"; + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + provider.getAuthorizationString(null); + fail("A null login token should be rejected"); + } catch (InvalidAuthorizationException expected) { + assertTrue(expected.getMessage().contains( + "Invalid OAuth login response")); + } finally { + provider.close(); + } + assertEquals(0, logoutCounter.get()); + } + + @Test + public void testPreconfiguredEndpointStillRequiresHttpsHandle() { + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + final NoSQLHandleConfig config = + new NoSQLHandleConfig("http://localhost:8080") + .setAuthorizationProvider(provider); + + try { + NoSQLHandleFactory.createNoSQLHandle(config); + fail("An OAuth handle using HTTP should have been rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("service endpoint")); + } finally { + provider.close(); + } + } + + @Test + public void testPreconfiguredEndpointReceivesHandleSslContext() + throws Exception { + + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + final NoSQLHandleConfig config = + new NoSQLHandleConfig("https://localhost:1445") + .setAuthorizationProvider(provider); + NoSQLHandle handle = null; + + try { + handle = NoSQLHandleFactory.createNoSQLHandle(config); + assertNotNull(getProviderField(provider, "sslContext")); + assertEquals(endpoint, provider.getEndpoint()); + } finally { + if (handle != null) { + handle.close(); + } else { + provider.close(); + } + } + } + + @Test + public void testConditionalCacheInvalidation() { + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint).setAutoRenew(false); + + try { + final String first = provider.getAuthorizationString(null); + provider.flushCache(); + final String second = provider.getAuthorizationString(null); + + assertFalse(provider.invalidateAuthorizationString(first)); + assertEquals(second, provider.getAuthorizationString(null)); + assertEquals(2, loginCounter.get()); + + assertTrue(provider.invalidateAuthorizationString(second)); + assertNotNull(provider.getAuthorizationString(null)); + assertEquals(3, loginCounter.get()); + } finally { + provider.close(); + } + } + + @Test + public void testCancelledRefreshRemovedFromQueue() throws Exception { + TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + final ScheduledThreadPoolExecutor scheduler = + getScheduler(provider); + assertTrue(scheduler.getRemoveOnCancelPolicy()); + assertEquals(1, scheduler.getQueue().size()); + + provider.flushCache(); + assertTrue(scheduler.getQueue().isEmpty()); + } finally { + provider.close(); + } + } + + @Test + public void testRunningRefreshCancelledBeforeLogin() throws Exception { + loginTokenLifetimeMs = 11_000; + BlockingRefreshProvider provider = new BlockingRefreshProvider(); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + assertTrue(provider.awaitRefreshCallback(5_000)); + + final Thread disableRenewal = + new Thread(() -> provider.setAutoRenew(false)); + disableRenewal.start(); + waitForAutoRenew(provider, false, 5_000); + provider.releaseRefreshCallback(); + disableRenewal.join(5_000); + + assertFalse(disableRenewal.isAlive()); + assertEquals(1, loginCounter.get()); + } finally { + provider.releaseRefreshCallback(); + provider.close(); + } + } + + @Test + public void testConcurrentAutoRenewTogglesKeepRefreshScheduled() + throws Exception { + + loginTokenLifetimeMs = 120_000; + final TestProvider provider = new TestProvider(); + provider.setEndpoint(endpoint); + + try { + assertNotNull(provider.getAuthorizationString(null)); + final CountDownLatch start = new CountDownLatch(1); + final AtomicReference toggleFailure = + new AtomicReference(); + final Runnable toggler = () -> { + try { + if (!start.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "Timed out waiting to start renewal toggles"); + } + for (int i = 0; i < 1_000; i++) { + provider.setAutoRenew(false); + Thread.yield(); + provider.setAutoRenew(true); + } + } catch (Throwable t) { + toggleFailure.compareAndSet(null, t); + } + }; + final Thread first = new Thread(toggler, "OAuthToggleOne"); + final Thread second = new Thread(toggler, "OAuthToggleTwo"); + first.start(); + second.start(); + start.countDown(); + first.join(15_000); + second.join(15_000); + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + if (toggleFailure.get() != null) { + throw new AssertionError( + "Concurrent renewal toggle failed", toggleFailure.get()); + } + assertTrue(provider.isAutoRenew()); + assertEquals(1, getScheduler(provider).getQueue().size()); + } finally { + provider.close(); + } + } + + @Test + public void testOAuthLogsExcludeSensitiveValues() throws Exception { + final TestLogHandler handler = new TestLogHandler(); + final Logger testLogger = createLogger(handler); + final String responseSecret = "REMOTE_RESPONSE_SECRET"; + + loginStatus = HttpURLConnection.HTTP_UNAVAILABLE; + loginErrorBody = responseSecret; + TestProvider failedLogin = new TestProvider(); + failedLogin.setEndpoint(endpoint).setLogger(testLogger); + try { + failedLogin.getAuthorizationString(null); + fail("The OAuth login should have failed"); + } catch (SystemException expected) { + assertFalse(expected.getMessage().contains(responseSecret)); + } finally { + failedLogin.close(); + } + assertEquals(1, loginCounter.get()); + assertLogExcludes(handler, oauthAccessToken, responseSecret); + + resetTestState(); + logoutStatus = HttpURLConnection.HTTP_INTERNAL_ERROR; + logoutErrorBody = responseSecret; + TestProvider failedLogout = new TestProvider(); + failedLogout.setEndpoint(endpoint) + .setLogger(testLogger) + .setAutoRenew(false); + assertNotNull(failedLogout.getAuthorizationString(null)); + failedLogout.close(); + assertLogExcludes(handler, loginToken, responseSecret); + + resetTestState(); + loginTokenLifetimeMs = 12_000; + final String callbackSecret = "CALLBACK_EXCEPTION_SECRET"; + FailingRefreshProvider failedRefresh = + new FailingRefreshProvider(callbackSecret); + failedRefresh.setEndpoint(endpoint).setLogger(testLogger); + try { + assertNotNull(failedRefresh.getAuthorizationString(null)); + failedRefresh.waitForRefreshAttempt(5_000); + } finally { + failedRefresh.close(); + } + assertLogExcludes(handler, oauthAccessToken, callbackSecret); + } + + private void tryBadEndpoint(String ep) { + TestProvider provider = new TestProvider(); + try { + provider.setEndpoint(ep); + fail("Endpoint should have failed: " + ep); + } catch (IllegalArgumentException iae) { + assertNull(provider.getEndpoint()); + } + } + + private static void resetAuthenticatedIdentity() { + omitAuthenticatedIdentity = false; + reloginIssuer = loginIssuer; + reloginSubjectType = loginSubjectType; + reloginSubjectId = loginSubjectId; + } + + private static void generateLoginToken(String tokenText, + String issuer, + String subjectType, + String subjectId, + HttpExchange exchange) { + try (OutputStream os = exchange.getResponseBody()) { + long expireTime = + System.currentTimeMillis() + loginTokenLifetimeMs; + final String jsonString = createLoginResponse( + encodeLoginToken(tokenText, expireTime), expireTime, + issuer, subjectType, subjectId); + + 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 encodeLoginToken(String tokenText, long expireAt) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeShort(1); + oos.writeLong(expireAt); + oos.writeBytes(tokenText); + oos.flush(); + return JsonUtils.convertBytesToHex(baos.toByteArray()); + } catch (IOException ioe) { + throw new IllegalArgumentException("Unable to encode", ioe); + } + } + + private static String createLoginResponse(String encodedToken, + long expireAt, + String issuer, + String subjectType, + String subjectId) { + return "{\"token\":\"" + encodedToken + "\"," + + "\"expireAt\":" + expireAt + + (issuer != null ? + ",\"authenticatedIdentity\":{" + + "\"type\":\"oauth\"," + + "\"issuer\":\"" + issuer + "\"," + + "\"subjectType\":\"" + subjectType + "\"," + + "\"subjectId\":\"" + subjectId + "\"}" : "") + + "}"; + } + + private static void sendResponse(HttpExchange exchange, + int status, + String body) + throws IOException { + + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + if (bytes.length > 0) { + os.write(bytes); + } + } + } + + private static String readTokenFromAuth(String authString) { + final String authEncoded = + authString.substring(authTokenPrefix.length()); + 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 void waitForAutoRenew(OAuthAccessTokenProvider provider, + boolean expected, + long timeoutMs) + throws InterruptedException { + + final long limit = System.currentTimeMillis() + timeoutMs; + while (provider.isAutoRenew() != expected && + System.currentTimeMillis() < limit) { + Thread.sleep(10); + } + assertEquals(expected, provider.isAutoRenew()); + } + + private static ScheduledThreadPoolExecutor getScheduler( + OAuthAccessTokenProvider provider) + throws Exception { + + final Field field = + OAuthAccessTokenProvider.class.getDeclaredField("scheduler"); + field.setAccessible(true); + return (ScheduledThreadPoolExecutor) field.get(provider); + } + + private static Object getProviderField(OAuthAccessTokenProvider provider, + String fieldName) + throws Exception { + + final Field field = + OAuthAccessTokenProvider.class.getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(provider); + } + + private static Logger createLogger(Handler handler) { + final Logger logger = Logger.getLogger( + OAuthAccessTokenProviderTest.class.getName() + "." + + System.nanoTime()); + logger.setUseParentHandlers(false); + logger.setLevel(Level.ALL); + handler.setLevel(Level.ALL); + logger.addHandler(handler); + return logger; + } + + private static void assertLogExcludes(TestLogHandler handler, + String... excludedValues) { + final String messages = handler.getMessages(); + for (String value : excludedValues) { + assertFalse("Log contains sensitive value: " + value, + messages.contains(value)); + } + } + + private static class TestProvider extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + + @Override + protected String getAccessToken() { + if (tokenCounter.incrementAndGet() == 1) { + return oauthAccessToken; + } + return secondOAuthAccessToken; + } + } + + private static class ClosingCallbackProvider + extends OAuthAccessTokenProvider { + + @Override + protected String getAccessToken() { + close(); + return oauthAccessToken; + } + } + + private static class BlockingRefreshProvider + extends OAuthAccessTokenProvider { + + private final AtomicInteger callbackCount = new AtomicInteger(); + private final CountDownLatch refreshCallback = new CountDownLatch(1); + private final CountDownLatch releaseRefresh = new CountDownLatch(1); + + @Override + protected String getAccessToken() { + if (callbackCount.incrementAndGet() == 1) { + return oauthAccessToken; + } + refreshCallback.countDown(); + try { + if (!releaseRefresh.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException( + "Timed out waiting to release refresh callback"); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Refresh callback interrupted", ie); + } + return secondOAuthAccessToken; + } + + private boolean awaitRefreshCallback(long timeoutMs) + throws InterruptedException { + + return refreshCallback.await(timeoutMs, TimeUnit.MILLISECONDS); + } + + private void releaseRefreshCallback() { + releaseRefresh.countDown(); + } + } + + private static class FailingRefreshProvider + extends OAuthAccessTokenProvider { + + private final AtomicInteger tokenCounter = new AtomicInteger(); + private final String failureMessage; + + FailingRefreshProvider() { + this("test refresh failure"); + } + + FailingRefreshProvider(String failureMessage) { + this.failureMessage = failureMessage; + } + + @Override + protected String getAccessToken() { + if (tokenCounter.incrementAndGet() == 1) { + return oauthAccessToken; + } + throw new IllegalStateException(failureMessage); + } + + 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); + } + } + + private static class TestLogHandler extends Handler { + + private final StringBuilder messages = new StringBuilder(); + + @Override + public synchronized void publish(LogRecord record) { + if (isLoggable(record)) { + messages.append(record.getMessage()).append('\n'); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + + private synchronized String getMessages() { + return messages.toString(); + } + } +} diff --git a/examples/src/main/java/Common.java b/examples/src/main/java/Common.java index e48a264f..a24d34aa 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,12 @@ * BasicTableExample https://localhost:443 -useKVProxy -user driver \ * -password Driver.User@01 * + * Run against an OAuth-enabled secure proxy and store. The access token is + * supplied in the NOSQL_OAUTH_ACCESS_TOKEN environment variable: + * java -Djavax.net.ssl.trustStorePassword=123456 \ + * -Djavax.net.ssl.trustStore=driver.trust -cp .:../lib/nosqldriver.jar \ + * BasicTableExample https://localhost:443 -useKVProxy -useOAuth + * * Credential Setup * ---------------- * If you are running against the cloud service, you will need to @@ -101,12 +108,16 @@ 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 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 +194,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 +211,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 +228,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 +262,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 +288,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 +301,36 @@ AuthorizationProvider getAuthProvider() { } } + private OAuthAccessTokenProvider getOAuthProvider() { + final String accessToken = + getRequiredEnvironment(OAUTH_ACCESS_TOKEN_ENV); + + OAuthAccessTokenProvider provider = + new OAuthAccessTokenProvider() { + @Override + protected String getAccessToken() { + return accessToken; + } + }; + + /* + * This example has only one access token. Long-running applications + * should leave automatic renewal enabled and obtain a fresh token in + * getAccessToken(). + */ + 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; + } + /** * 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