OAuthManager.java
/*
* discogs-java-client - A Java SDK to access the Discogs API
* Copyright © 2025 Andy Miles (andy.miles@amilesend.com)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.amilesend.discogs.connection.auth;
import com.amilesend.client.connection.auth.AuthException;
import com.amilesend.client.connection.auth.AuthManager;
import com.amilesend.client.util.StringUtils;
import com.amilesend.client.util.Validate;
import com.amilesend.client.util.VisibleForTesting;
import com.amilesend.discogs.connection.RandomAlphaNumericStringGenerator;
import com.amilesend.discogs.connection.auth.info.KeySecretAuthInfo;
import com.amilesend.discogs.connection.auth.info.OAuthInfo;
import com.amilesend.discogs.connection.auth.oauth.AccessTokenResponse;
import com.amilesend.discogs.connection.auth.oauth.OAuthReceiver;
import com.amilesend.discogs.connection.auth.oauth.RequestTokenResponse;
import lombok.Builder;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import org.slf4j.event.Level;
import java.awt.*;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import static com.amilesend.client.connection.Connection.Headers.AUTHORIZATION;
import static com.amilesend.client.connection.Connection.Headers.CONTENT_TYPE;
import static com.amilesend.client.connection.Connection.Headers.USER_AGENT;
/**
* Defines the auth manager for OAuth-authenticated requests.
*
* @see AuthManager
* @see OAuthInfo
*/
@Slf4j
public class OAuthManager implements AuthManager<OAuthInfo> {
private static final String REQUEST_TOKEN_URL = "https://api.discogs.com/oauth/request_token";
private static final String AUTHORIZE_URL = "https://www.discogs.com/oauth/authorize";
private static final String ACCESS_TOKEN_URL = "https://api.discogs.com/oauth/access_token";
private static final String APPLICATION_FORM_URL_ENCODED_CONTENT_TYPE =
"application/x-www-form-urlencoded";
private static final RandomAlphaNumericStringGenerator RANDOM_STRING_GENERATOR =
new RandomAlphaNumericStringGenerator();
private static final int NONCE_LENGTH = 12;
private final ReentrantLock lock = new ReentrantLock();
private final OkHttpClient httpClient;
private final OAuthReceiver oAuthReceiver;
private final KeySecretAuthInfo appCredentials;
private final String requestTokenUrl;
private final String authorizeUrl;
private final String accessTokenUrl;
private final String userAgent;
private final AtomicReference<OAuthInfo> oAuthInfo = new AtomicReference<>();
@Builder
private OAuthManager(
@NonNull final OkHttpClient httpClient,
@NonNull final OAuthReceiver oAuthReceiver,
@NonNull final KeySecretAuthInfo appCredentials,
final String requestTokenUrl,
final String authorizeUrl,
final String accessTokenUrl,
final OAuthInfo oAuthToken,
final String userAgent) {
Validate.notBlank(userAgent, "userAgent must not be blank");
this.httpClient = httpClient;
this.oAuthReceiver = oAuthReceiver;
this.appCredentials = appCredentials;
this.requestTokenUrl = Optional.ofNullable(requestTokenUrl)
.filter(StringUtils::isNotBlank)
.orElse(REQUEST_TOKEN_URL);
this.authorizeUrl = Optional.ofNullable(authorizeUrl)
.filter(StringUtils::isNotBlank)
.orElse(AUTHORIZE_URL);
this.accessTokenUrl = Optional.ofNullable(accessTokenUrl)
.filter(StringUtils::isNotBlank)
.orElse(ACCESS_TOKEN_URL);
this.userAgent = userAgent;
this.oAuthInfo.set(oAuthToken);
}
@Override
public OAuthInfo getAuthInfo() {
OAuthInfo info = oAuthInfo.get();
if (Objects.nonNull(info)) {
return info;
}
lock.lock();
try {
info = authenticate();
oAuthInfo.set(info);
return info;
} finally {
lock.unlock();
}
}
@Override
public Request.Builder addAuthentication(@NonNull final Request.Builder requestBuilder) {
final OAuthInfo authInfo = getAuthInfo();
final String authHeaderValue = toAuthHeaderForRequestSigning(appCredentials, authInfo);
return requestBuilder.addHeader(AUTHORIZATION, authHeaderValue);
}
@VisibleForTesting
OAuthInfo authenticate() {
final RequestTokenResponse requestToken = fetchRequestToken(appCredentials, oAuthReceiver.getRedirectUri());
final String authVerifier = fetchAuthVerifier(requestToken);
final AccessTokenResponse accessTokenResponse = fetchAccessToken(appCredentials, requestToken, authVerifier);
return accessTokenResponse.toOAuthInfo();
}
@VisibleForTesting
String fetchAuthVerifier(final RequestTokenResponse requestTokenResponse) {
oAuthReceiver.start();
try {
browse(getAuthCodeUri(requestTokenResponse.getToken()));
return oAuthReceiver.waitForCode();
} finally {
oAuthReceiver.stop();
}
}
@VisibleForTesting
RequestTokenResponse fetchRequestToken(final KeySecretAuthInfo appCredentials, final String callbackUrl) {
final Request request = new Request.Builder()
.url(requestTokenUrl)
.addHeader(AUTHORIZATION, toAuthHeaderValueForRequestToken(appCredentials, callbackUrl))
.addHeader(USER_AGENT, userAgent)
.addHeader(CONTENT_TYPE, APPLICATION_FORM_URL_ENCODED_CONTENT_TYPE)
.get()
.build();
final String requestTokenBodyValue = fetchToken(request);
return RequestTokenResponse.parseBodyResponse(requestTokenBodyValue);
}
@VisibleForTesting
AccessTokenResponse fetchAccessToken(
final KeySecretAuthInfo appCredentials,
final RequestTokenResponse requestTokenResponse,
final String authVerifier) {
final Request request = new Request.Builder()
.url(accessTokenUrl)
.addHeader(AUTHORIZATION,
toAuthHeaderValueForAccessToken(appCredentials, requestTokenResponse, authVerifier))
.addHeader(USER_AGENT, userAgent)
.addHeader(CONTENT_TYPE, APPLICATION_FORM_URL_ENCODED_CONTENT_TYPE)
.post(newEmptyRequestBody())
.build();
final String accessTokenBodyValue = fetchToken(request);
return AccessTokenResponse.parseBodyResponse(accessTokenBodyValue);
}
@VisibleForTesting
String fetchToken(final Request request) {
try {
try (final Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new AuthException("Unsuccessful token request: " + response);
}
return response.body().string();
}
} catch (final AuthException ex) {
throw ex;
} catch (final Exception ex) {
throw new AuthException("Error fetching token: " + ex.getMessage(), ex);
}
}
@VisibleForTesting
void browse(final String url) {
Validate.notBlank(url, "url must not be blank");
// Ask user to open in their browser using copy-paste
printAndLog("Please open the following address in your browser: " + url, Level.INFO);
try {
if (!Desktop.isDesktopSupported()) {
return;
}
final Desktop desktop = Desktop.getDesktop();
if (!desktop.isSupported(Desktop.Action.BROWSE)) {
return;
}
printAndLog("Attempting to open that address in the default browser now...", Level.INFO);
desktop.browse(URI.create(url));
} catch (final IOException | InternalError ex) {
printAndLog("Unable to open browser", Level.WARN, ex);
}
}
@VisibleForTesting
void printAndLog(final String msg, final Level level) {
printAndLog(msg, level, null);
}
@VisibleForTesting
void printAndLog(final String msg, final Level level, final Throwable cause) {
if (Objects.isNull(cause)) {
log.atLevel(level).log(msg);
} else {
log.atLevel(level).log(msg, cause);
}
System.out.println(msg);
}
@VisibleForTesting
String getAuthCodeUri(final String requestToken) {
return new StringBuilder(authorizeUrl)
.append("?oauth_token=")
.append(URLEncoder.encode(requestToken, StandardCharsets.UTF_8))
.toString();
}
@VisibleForTesting
String toAuthHeaderValueForRequestToken(
final KeySecretAuthInfo appCredentials,
final String callbackUrl) {
return toAuthHeaderCommon(appCredentials)
.append("oauth_consumer_key=\"").append(appCredentials.getKey()).append("\", ")
.append("oauth_signature=\"").append(appCredentials.getSecret()).append("&\", ")
.append("oauth_callback=\"").append(callbackUrl).append("\"")
.toString();
}
@VisibleForTesting
String toAuthHeaderValueForAccessToken(
final KeySecretAuthInfo appCredentials,
final RequestTokenResponse requestTokenResponse,
final String authVerifier) {
return toAuthHeaderCommon(appCredentials)
.append("oauth_token=\"").append(requestTokenResponse.getToken()).append("\", ")
.append("oauth_signature=\"").append(appCredentials.getSecret())
.append("&").append(requestTokenResponse.getSecret()).append("\", ")
.append("oauth_verifier=\"").append(authVerifier).append("\"")
.toString();
}
@VisibleForTesting
String toAuthHeaderForRequestSigning(
final KeySecretAuthInfo appCredentials,
final OAuthInfo userTokenInfo) {
return toAuthHeaderCommon(appCredentials)
.append("oauth_token=\"").append(userTokenInfo.getToken()).append("\", ")
.append("oauth_signature=\"").append(appCredentials.getSecret())
.append("&").append(userTokenInfo.getSecret()).append("\"")
.toString();
}
private static StringBuilder toAuthHeaderCommon(final KeySecretAuthInfo appCredentials) {
return new StringBuilder("OAuth ")
.append("oauth_consumer_key=\"").append(appCredentials.getKey()).append("\", ")
.append("oauth_nonce=\"").append(generateNewNonce()).append("\", ")
.append("oauth_signature_method=\"PLAINTEXT\", ")
.append("oauth_timestamp=\"").append(System.currentTimeMillis()).append("\", ");
}
private static String generateNewNonce() {
return RANDOM_STRING_GENERATOR.next(NONCE_LENGTH);
}
private static RequestBody newEmptyRequestBody() {
return RequestBody.create(new byte[0]);
}
}