Skip to content

Commit

Permalink
OAuth2 integration part 1
Browse files Browse the repository at this point in the history
  • Loading branch information
devoxin committed Jul 11, 2024
1 parent f22d382 commit c0bfb37
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import dev.lavalink.youtube.clients.skeleton.Client;
import dev.lavalink.youtube.http.YoutubeAccessTokenTracker;
import dev.lavalink.youtube.http.YoutubeHttpContextFilter;
import dev.lavalink.youtube.http.YoutubeOauth2Handler;
import dev.lavalink.youtube.track.YoutubeAudioTrack;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
Expand Down Expand Up @@ -59,12 +60,14 @@ public class YoutubeAudioSourceManager implements AudioSourceManager {
private static final Pattern shortHandPattern = Pattern.compile("^" + PROTOCOL_REGEX + "(?:" + DOMAIN_REGEX + "/(?:live|embed|shorts)|" + SHORT_DOMAIN_REGEX + ")/(?<videoId>.*)");

protected final HttpInterfaceManager httpInterfaceManager;

protected final boolean allowSearch;
protected final boolean allowDirectVideoIds;
protected final boolean allowDirectPlaylistIds;
protected final Client[] clients;

protected final SignatureCipherManager cipherManager;
protected YoutubeOauth2Handler oauth2Handler;
protected SignatureCipherManager cipherManager;

public YoutubeAudioSourceManager() {
this(true);
Expand Down Expand Up @@ -124,10 +127,13 @@ public YoutubeAudioSourceManager(boolean allowSearch,
this.clients = clients;
this.cipherManager = new SignatureCipherManager();

YoutubeAccessTokenTracker tokenTracker = new YoutubeAccessTokenTracker(httpInterfaceManager);
YoutubeHttpContextFilter youtubeHttpContextFilter = new YoutubeHttpContextFilter();
youtubeHttpContextFilter.setTokenTracker(tokenTracker);
httpInterfaceManager.setHttpContextFilter(youtubeHttpContextFilter);
this.oauth2Handler = new YoutubeOauth2Handler(httpInterfaceManager);

YoutubeHttpContextFilter contextFilter = new YoutubeHttpContextFilter();
contextFilter.setTokenTracker(new YoutubeAccessTokenTracker(httpInterfaceManager));
contextFilter.setOauth2Handler(oauth2Handler);

httpInterfaceManager.setHttpContextFilter(contextFilter);
}

@Override
Expand All @@ -141,6 +147,22 @@ public void setPlaylistPageCount(int count) {
}
}

/**
* Instructs this source to use Oauth2 integration.
* {@code null} is valid and will kickstart the oauth process.
* Providing a refresh token will likely skip having to authenticate your account prior to making requests,
* as long as the provided token is still valid.
* @param refreshToken The token to use for generating access tokens. Can be null.
*/
public void useOauth2(@Nullable String refreshToken) {
oauth2Handler.setRefreshToken(refreshToken);
}

@Nullable
public String getOauth2RefreshToken() {
return oauth2Handler.getRefreshToken();
}

@Override
@Nullable
public AudioItem loadItem(@NotNull AudioPlayerManager manager, @NotNull AudioReference reference) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,16 @@ public class YoutubeHttpContextFilter extends BaseYoutubeHttpContextFilter {
private static final HttpContextRetryCounter retryCounter = new HttpContextRetryCounter("yt-token-retry");

private YoutubeAccessTokenTracker tokenTracker;
private YoutubeOauth2Handler oauth2Handler;

public void setTokenTracker(@NotNull YoutubeAccessTokenTracker tokenTracker) {
this.tokenTracker = tokenTracker;
}

public void setOauth2Handler(@NotNull YoutubeOauth2Handler oauth2Handler) {
this.oauth2Handler = oauth2Handler;
}

@Override
public void onContextOpen(HttpClientContext context) {
CookieStore cookieStore = context.getCookieStore();
Expand Down Expand Up @@ -56,6 +61,10 @@ public void onRequest(HttpClientContext context,
return;
}

if (oauth2Handler.isOauthFetchContext(context)) {
return;
}

String userAgent = context.getAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED, String.class);

if (userAgent != null) {
Expand All @@ -64,6 +73,8 @@ public void onRequest(HttpClientContext context,
context.removeAttribute(ATTRIBUTE_USER_AGENT_SPECIFIED);
}

oauth2Handler.applyToken(request);

// try {
// URI uri = new URIBuilder(request.getURI())
// .setParameter("key", YoutubeConstants.INNERTUBE_ANDROID_API_KEY)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package dev.lavalink.youtube.http;

import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonParserException;
import com.grack.nanojson.JsonWriter;
import com.sedmelluq.discord.lavaplayer.tools.ExceptionTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpClientTools;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterface;
import com.sedmelluq.discord.lavaplayer.tools.io.HttpInterfaceManager;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.util.EntityUtils;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class YoutubeOauth2Handler {
private static final Logger log = LoggerFactory.getLogger(YoutubeAccessTokenTracker.class);
private static int fetchErrorLogCount = 0;

// no, i haven't leaked anything of mine
// this (i presume) can be found within youtube's page source
// ¯\_(ツ)_/¯
private static final String CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com";
private static final String CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT";
private static final String SCOPES = "http://gdata.youtube.com https://www.googleapis.com/auth/youtube";
private static final String OAUTH_FETCH_CONTEXT_ATTRIBUTE = "yt-oauth";

private final HttpInterfaceManager httpInterfaceManager;

private boolean enabled;
private String refreshToken;

private String tokenType;
private String accessToken;
private long tokenExpires;

public YoutubeOauth2Handler(HttpInterfaceManager httpInterfaceManager) {
this.httpInterfaceManager = httpInterfaceManager;
}

public void setRefreshToken(@Nullable String refreshToken) {
this.refreshToken = refreshToken;
this.tokenExpires = System.currentTimeMillis(); // to trigger an access token refresh

if (refreshToken == null) {
initializeAccessToken();
}
}

@Nullable
public String getRefreshToken() {
return refreshToken;
}

public boolean isOauthFetchContext(HttpClientContext context) {
return context.getAttribute(OAUTH_FETCH_CONTEXT_ATTRIBUTE) == Boolean.TRUE;
}

/**
* Makes a request to YouTube for a device code that users can then authorise to allow
* this source to make requests using an account access token.
* This will begin the oauth flow. If a refresh token is present, {@link #refreshAccessToken()} should
* be used instead.
*/
private void initializeAccessToken() {
JsonObject response = fetchDeviceCode();
String verificationUrl = response.getString("verification_url");
String userCode = response.getString("user_code");
String deviceCode = response.getString("device_code");

log.info("==================================================");
log.info("!!! DO NOT AUTHORISE WITH YOUR MAIN ACCOUNT, USE A BURNER !!!");
log.info("OAUTH INTEGRATION: To give youtube-source access to your account, go to {} and enter code {}", verificationUrl, userCode);
log.info("!!! DO NOT AUTHORISE WITH YOUR MAIN ACCOUNT, USE A BURNER !!!");
log.info("==================================================");

// Should this be a daemon?
new Thread(() -> pollForToken(deviceCode), "youtube-source-token-poller").start();
}

private JsonObject fetchDeviceCode() {
// @formatter:off
String requestJson = JsonWriter.string()
.object()
.value("client_id", CLIENT_ID)
.value("scope", SCOPES)
.value("device_id", UUID.randomUUID().toString().replace("-", ""))
.value("device_model", "ytlr::")
.end()
.done();
// @formatter:on

HttpPost request = new HttpPost("https://www.youtube.com/o/oauth2/device/code");
StringEntity body = new StringEntity(requestJson, ContentType.APPLICATION_JSON);
request.setEntity(body);

try (HttpInterface httpInterface = getHttpInterface();
CloseableHttpResponse response = httpInterface.execute(request)) {
HttpClientTools.assertSuccessWithContent(response, "device code fetch");
return JsonParser.object().from(response.getEntity().getContent());
} catch (IOException | JsonParserException e) {
throw ExceptionTools.toRuntimeException(e);
}
}

private void pollForToken(String deviceCode) {
// @formatter:off
String requestJson = JsonWriter.string()
.object()
.value("client_id", CLIENT_ID)
.value("client_secret", CLIENT_SECRET)
.value("code", deviceCode)
.value("grant_type", "http://oauth.net/grant_type/device/1.0")
.end()
.done();
// @formatter:on

HttpPost request = new HttpPost("https://www.youtube.com/o/oauth2/token");
StringEntity body = new StringEntity(requestJson, ContentType.APPLICATION_JSON);
request.setEntity(body);

while (true) {
try (HttpInterface httpInterface = getHttpInterface();
CloseableHttpResponse response = httpInterface.execute(request)) {
HttpClientTools.assertSuccessWithContent(response, "oauth2 token fetch");
JsonObject parsed = JsonParser.object().from(response.getEntity().getContent());

if (parsed.has("error") && !parsed.isNull("error")) {
String error = parsed.getString("error");

if (error.equals("authorization_pending")) {
long interval = parsed.getLong("interval");
Thread.sleep(Math.max(5000, interval * 1000));
continue;
} else if (error.equals("expired_token")) {
log.error("OAUTH INTEGRATION: The device token has expired. OAuth integration has been canceled.");
} else {
log.error("Unhandled OAuth2 error: {}", error);
}

return;
}

tokenType = parsed.getString("token_type");
accessToken = parsed.getString("access_token");
refreshToken = parsed.getString("refresh_token");
tokenExpires = System.currentTimeMillis() + (parsed.getLong("expires_in") * 1000) - 60000;
log.info("OAUTH INTEGRATION: Token retrieved successfully. Access Token: {}, Refresh Token: {}", accessToken, refreshToken);

enabled = true;
return;
} catch (IOException | JsonParserException | InterruptedException e) {
log.error("Failed to fetch OAuth2 token response", e);
}
}
}

private void refreshAccessToken() {
// SUBTRACT 1 MINUTE FROM TOKEN EXPIRY
}

public void applyToken(HttpUriRequest request) {
if (!enabled || refreshToken == null) {
return;
}

if (System.currentTimeMillis() > tokenExpires) {
try {
refreshAccessToken();
} catch (Throwable t) {
if (fetchErrorLogCount++ <= 3) {
// log fetch errors up to 3 times to avoid spamming logs.
// in theory, we can still make requests without an access token,
// it's just less likely to succeed, but we shouldn't bloat a user's logs
// in the event YT changes something and breaks oauth integration.
// anyway, the chances of each error being different is small i think.
log.error("Refreshing YouTube access token failed", t);
}

// retry in 15 seconds to avoid spamming YouTube with requests.
tokenExpires = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(15);
return;
}
}

// check again to ensure updating worked as expected.
if (accessToken != null && tokenType != null && System.currentTimeMillis() + 60000 < tokenExpires) {
request.setHeader("Authorization", String.format("%s %s", tokenType, accessToken));
}
}

private HttpInterface getHttpInterface() {
HttpInterface httpInterface = httpInterfaceManager.getInterface();
httpInterface.getContext().setAttribute(OAUTH_FETCH_CONTEXT_ATTRIBUTE, true);
return httpInterface;
}
}

0 comments on commit c0bfb37

Please sign in to comment.