/** * Holds OAuth access tokens for accessing protected Sphere HTTP API endpoints. Refreshes the access * token as needed automatically. */ @ThreadSafe public final class SphereClientCredentials implements ClientCredentials { private final String tokenEndpoint; private final String projectKey; private final String clientId; private final String clientSecret; private final OAuthClient oauthClient; private final Object accessTokenLock = new Object(); @GuardedBy("accessTokenLock") private Optional<ValidationE<AccessToken>> accessTokenResult = Optional.absent(); /** Allows at most one refresh operation running in the background. */ private final ThreadPoolExecutor refreshExecutor = Concurrent.singleTaskExecutor("Sphere-ClientCredentials-refresh"); private final Timer refreshTimer = new Timer("Sphere-ClientCredentials-refreshTimer", /*isDaemon*/ true); /** Creates an instance of ClientCredentials based on config. */ public static SphereClientCredentials createAndBeginRefreshInBackground( SphereClientConfig config, OAuthClient oauthClient) { String tokenEndpoint = Endpoints.tokenEndpoint(config.getAuthHttpServiceUrl()); SphereClientCredentials credentials = new SphereClientCredentials( oauthClient, tokenEndpoint, config.getProjectKey(), config.getClientId(), config.getClientSecret()); credentials.beginRefresh(); return credentials; } private SphereClientCredentials( OAuthClient oauthClient, String tokenEndpoint, String projectKey, String clientId, String clientSecret) { this.oauthClient = oauthClient; this.tokenEndpoint = tokenEndpoint; this.projectKey = projectKey; this.clientId = clientId; this.clientSecret = clientSecret; } public String getAccessToken() { synchronized (accessTokenLock) { Optional<ValidationE<AccessToken>> tokenResult = waitForToken(); if (!tokenResult.isPresent()) { // Shouldn't happen as the timer should refresh the token soon enough. Log.warn("[oauth] Access token expired, blocking until a new one is available."); beginRefresh(); tokenResult = waitForToken(); if (!tokenResult.isPresent()) { throw new SphereClientException("Access token expired immediately after refresh."); } } if (tokenResult.get().isError()) { beginRefresh(); // retry on error (essential to recover from backend errors) throw tokenResult.get().getError(); } return tokenResult.get().getValue().getAccessToken(); } } /** * If there is an access token present, checks whether it's not expired yet and returns it. If the * token already expired, clears the token. Called only from {@link #getAccessToken()} so {@link * #accessTokenLock} is already acquired. */ private Optional<ValidationE<AccessToken>> waitForToken() { while (!accessTokenResult.isPresent()) { try { accessTokenLock.wait(); } catch (InterruptedException e) { } } if (accessTokenResult.get().isError()) { return accessTokenResult; } Optional<Long> remainingMs = accessTokenResult.get().getValue().getRemaniningMs(); if (remainingMs.isPresent()) { // Have some tolerance here so that we don't send tokens with 100ms validity to the server, // expiring "on the way". if (remainingMs.get() <= 2000) { // if the token expired, clear it accessTokenResult = Optional.absent(); } } return accessTokenResult; } /** Asynchronously refreshes the tokens contained in this instance. */ private void beginRefresh() { try { refreshExecutor.execute( new Runnable() { @Override public void run() { Log.debug("[oauth] Refreshing access token."); Tokens tokens; try { tokens = oauthClient .getTokensForClient( tokenEndpoint, clientId, clientSecret, "manage_project:" + projectKey) .get(); } catch (Exception e) { update(null, e); return; } update(tokens, null); } }); } catch (RejectedExecutionException e) { // another refresh is already in progress, ignore this one } } private void update(Tokens tokens, Exception e) { synchronized (accessTokenLock) { try { if (e == null) { AccessToken newToken = new AccessToken( tokens.getAccessToken(), tokens.getExpiresIn(), System.currentTimeMillis()); this.accessTokenResult = Optional.of(ValidationE.<AccessToken>success(newToken)); Log.debug("[oauth] Refreshed access token."); scheduleNextRefresh(tokens); } else { this.accessTokenResult = Optional.of(ValidationE.<AccessToken>error(Util.toSphereException(e))); Log.error("[oauth] Failed to refresh access token.", e); } } finally { accessTokenLock.notifyAll(); } } } private void scheduleNextRefresh(Tokens tokens) { if (!tokens.getExpiresIn().isPresent()) { Log.warn("[oauth] Authorization server did not provide expires_in for the access token."); return; } if (tokens.getExpiresIn().get() * 1000 < Defaults.tokenAboutToExpireMs) { Log.warn( "[oauth] Authorization server returned an access token with a very short validity of " + tokens.getExpiresIn().get() + "s!"); return; } long refreshTimeout = tokens.getExpiresIn().get() * 1000 - Defaults.tokenAboutToExpireMs; Log.debug("[oauth] Scheduling next token refresh " + refreshTimeout / 1000 + "s from now."); refreshTimer.schedule( new TimerTask() { public void run() { beginRefresh(); } }, refreshTimeout); } /** Shuts down internal thread pools. */ public void shutdown() { refreshExecutor.shutdownNow(); refreshTimer.cancel(); } }