refactor (OAuth): a simpler & more efficient auth system

This commit is contained in:
LuanRT
2021-12-18 12:03:44 -03:00
parent d0de164722
commit 7e86bb15e0
4 changed files with 123 additions and 118 deletions

View File

@@ -650,19 +650,17 @@ async function start() {
const creds = fs.existsSync(creds_path) && JSON.parse(fs.readFileSync(creds_path).toString()) || {};
const youtube = await new Innertube();
// Only triggered when signing-in.
youtube.on('auth', (data) => {
youtube.ev.on('auth', (data) => {
if (data.status === 'AUTHORIZATION_PENDING') {
console.info(`Hello!\nOn your phone or computer, go to ${data.verification_url} and enter the code ${data.code}`);
} else if (data.status === 'SUCCESS') {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Successfully signed-in, enjoy!');
}
});
// Triggered whenever the access token is refreshed.
youtube.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify({ access_token: data.access_token, refresh_token: data.refresh_token, expires: data.expires }));
youtube.ev.on('update-credentials', (data) => {
fs.writeFileSync(creds_path, JSON.stringify(data.credentials));
console.info('Credentials updated!', data);
});

View File

@@ -6,8 +6,8 @@ const Utils = require('./Utils');
const Constants = require('./Constants');
async function engage(session, engagement_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged in');
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (engagement_type) {
case 'like/like':
@@ -37,11 +37,11 @@ async function engage(session, engagement_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${engagement_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, id: args.video_id, data })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status
@@ -49,7 +49,7 @@ async function engage(session, engagement_type, args = {}) {
}
async function browse(session, action_type) {
if (!session.logged_in) throw new Error('You are not logged in');
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) { // TODO: Handle more actions
@@ -62,11 +62,11 @@ async function browse(session, action_type) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/browse${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -78,13 +78,13 @@ async function search(session, args = {}) {
if (!args.query) throw new Error('No query was provided');
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/search${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify({
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
}), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
JSON.stringify({
context: session.context,
params: Utils.encodeFilter(args.options.period, args.options.duration, args.options.order),
query: args.query
}), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -93,8 +93,8 @@ async function search(session, args = {}) {
}
async function notifications(session, action_type, args = {}) {
if (!session.logged_in) throw new Error('You are not logged in');
if (!session.logged_in) throw new Error('You are not signed-in');
let data;
switch (action_type) {
@@ -119,11 +119,11 @@ async function notifications(session, action_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/notification/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
if (action_type === 'modify_channel_preference') return { success: true, status_code: response.status };
return {
success: true,
status_code: response.status,
@@ -158,10 +158,10 @@ async function livechat(session, action_type, args = {}) {
default:
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/${action_type}${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session, params: args.params })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -171,10 +171,10 @@ async function livechat(session, action_type, args = {}) {
async function getVideoInfo(session, args = {}) {
let response;
!args.is_desktop && (response = await Axios.get(`${Constants.URLS.YT_WATCH_PAGE}?v=${args.id}&t=8s&pbj=1`, Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: false })).catch((error) => error)) ||
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
(response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/player${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(Constants.VIDEO_INFO_REQBODY(args.id, session.sts, session.context)), Constants.INNERTUBE_REQOPTS({ session, id: args.id, desktop: true })).catch((error) => error));
if (response instanceof Error) throw new Error(`Could not get video info: ${response.message}`);
return response.data;
@@ -196,10 +196,10 @@ async function getContinuation(session, info = {}) {
data.captionsRequested = false;
}
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
const response = await Axios.post(`${Constants.URLS.YT_BASE_URL}/youtubei/v1/next${session.logged_in && session.cookie.length < 1 ? '' : `?key=${session.key}`}`,
JSON.stringify(data), Constants.INNERTUBE_REQOPTS({ session })).catch((error) => error);
if (response instanceof Error) return { success: false, status_code: response.response.status, message: response.message };
return {
success: true,
status_code: response.status,
@@ -207,4 +207,4 @@ async function getContinuation(session, info = {}) {
};
}
module.exports = { engage, browse, search, notifications, livechat, getVideoInfo, getContinuation };
module.exports = { engage, browse, search, notifications, livechat, getContinuation };

View File

@@ -14,9 +14,8 @@ const EventEmitter = require('events');
const TimeToSeconds = require('time-to-seconds');
const CancelToken = Axios.CancelToken;
class Innertube extends EventEmitter {
class Innertube {
constructor(cookie) {
super();
this.cookie = cookie || '';
this.retry_count = 0;
return this.init();
@@ -46,6 +45,8 @@ class Innertube extends EventEmitter {
this.auth_apisid = Utils.getStringBetweenStrings(this.cookie, 'PAPISID=', ';');
this.auth_apisid = Utils.generateSidAuth(this.auth_apisid);
}
this.ev = new EventEmitter();
} else {
this.retry_count += 1;
if (this.retry_count >= 10) throw new Error('Could not retrieve Innertube data');
@@ -59,54 +60,43 @@ class Innertube extends EventEmitter {
return this;
}
signIn(credentials = {}) {
signIn(auth_info = {}) {
return new Promise(async (resolve, reject) => {
const oauth = new OAuth(credentials);
if (credentials.access_token && credentials.refresh_token) {
let token_validity = await oauth.checkTokenValidity(credentials.expires);
if (token_validity === 'VALID') {
this.access_token = credentials.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.refreshAccessToken(credentials.refresh_token);
oauth.on('refresh-token', (data) => {
this.access_token = data.access_token;
this.refresh_token = credentials.refresh_token;
this.logged_in = true;
const oauth = new OAuth(auth_info);
if (auth_info.access_token) {
const is_valid = await oauth.isTokenValid(auth_info.expires);
const expiration_date = new Date(new Date().getTime() + data.expires * 1000);
if (!is_valid) {
const new_tokens = await oauth.refreshAccessToken(auth_info.refresh_token);
auth_info.refresh_token = new_tokens.credentials.refresh_token;
auth_info.access_token = new_tokens.credentials.access_token;
this.emit('update-credentials', {
access_token: data.access_token,
refresh_token: credentials.refresh_token,
expires: expiration_date,
status: data.status
});
resolve();
this.ev.emit('update-credentials', {
credentials: new_tokens.credentials,
status: new_tokens.status
});
}
this.access_token = auth_info.access_token;
this.refresh_token = auth_info.refresh_token;
this.logged_in = true;
resolve();
} else {
oauth.on('auth', (data) => {
if (data.status === 'SUCCESS') {
this.access_token = data.access_token;
this.refresh_token = data.refresh_token;
this.access_token = data.credentials.access_token;
this.refresh_token = data.credentials.refresh_token;
this.logged_in = true;
const expiration_date = new Date(new Date().getTime() + data.expires * 1000);
this.emit('auth', {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires: expiration_date,
this.ev.emit('auth', {
credentials: data.credentials,
status: data.status
});
resolve();
} else {
this.emit('auth', data);
this.ev.emit('auth', data);
}
});
}
@@ -117,16 +107,16 @@ class Innertube extends EventEmitter {
const response = await Actions.search(this, { query, options });
if (!response.success) throw new Error(`Could not search on YouTube: ${response.message}`);
let content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
let search_response = {};
const content = response.data.contents.twoColumnSearchResultsRenderer.primaryContents.sectionListRenderer.contents[0].itemSectionRenderer.contents;
const search = {};
search_response.search_metadata = {};
search_response.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search_response.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search_response.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search_response.videos = content.map((data) => {
search.search_metadata = {};
search.search_metadata.query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.originalQuery.simpleText : query;
search.search_metadata.corrected_query = content[0].showingResultsForRenderer ? content[0].showingResultsForRenderer.correctedQueryEndpoint.searchEndpoint.query : query;
search.search_metadata.estimated_results = parseInt(response.data.estimatedResults);
search.videos = content.map((data) => {
if (!data.videoRenderer) return;
let video = data.videoRenderer;
const video = data.videoRenderer;
return {
title: video.title.runs[0].text,
description: video.detailedMetadataSnippets && video.detailedMetadataSnippets[0].snippetText.runs.map((item) => item.text).join('') || 'N/A',
@@ -152,7 +142,7 @@ class Innertube extends EventEmitter {
}
};
}).filter((video_block) => video_block !== undefined);
return search_response;
return search;
}
async getDetails(id) {
@@ -432,8 +422,8 @@ class Innertube extends EventEmitter {
response.data.on('data', (chunk) => {
downloaded_size += chunk.length;
let size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
let percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
const size = (selected_format.contentLength / 1024 / 1024).toFixed(2);
const percentage = Math.floor((downloaded_size / selected_format.contentLength) * 100);
stream.emit('progress', { chunk_size: chunk.length, downloaded_size: (downloaded_size / 1024 / 1024).toFixed(2), percentage, size, raw_data: { chunk_size: chunk.length, downloaded: downloaded_size, size: response.headers['content-length'] } });
});

View File

@@ -7,7 +7,7 @@ const EventEmitter = require('events');
const Uuid = require('uuid');
class OAuth extends EventEmitter {
constructor(creds) {
constructor(auth_info) {
super();
this.refresh_interval = 5;
@@ -22,7 +22,7 @@ class OAuth extends EventEmitter {
this.auth_script_regex = /<script id=\"base-js\" src=\"(.*?)\" nonce=".*?"><\/script>/;
this.identity_regex = /var .+?=\"(?<id>.+?)\",.+?=\"(?<secret>.+?)\"/;
if (creds.access_token != undefined && creds.refresh_token != undefined) return;
if (auth_info.access_token) return;
this.requestAuthCode();
}
@@ -60,24 +60,6 @@ class OAuth extends EventEmitter {
this.waitForAuth(response.data.device_code);
}
async getClientIdentity() {
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we get the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, 'setQuery("");', '{useGaiaSandbox:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
waitForAuth(device_code) {
const data = {
client_id: this.client_id,
@@ -90,7 +72,7 @@ class OAuth extends EventEmitter {
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('auth', {
error: 'Could not get auth token.',
error: 'Could not get authentication token.',
status: 'FAILED'
});
@@ -116,12 +98,15 @@ class OAuth extends EventEmitter {
default:
}
} else {
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
this.emit('auth', {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
credentials: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
expires: expiration_date,
},
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
}
@@ -139,25 +124,57 @@ class OAuth extends EventEmitter {
};
const response = await Axios.post(this.oauth_token_url, JSON.stringify(data), Constants.OAUTH.HEADERS).catch((error) => error);
if (response instanceof Error)
return this.emit('refresh-token', {
if (response instanceof Error) {
this.emit('auth', {
error: 'Could not refresh access token.',
status: 'FAILED'
});
this.emit('refresh-token', {
access_token: response.data.access_token,
return {
credentials: {
access_token: this.auth_info.access_token,
refresh_token: this.auth_info.refresh_token,
expires: this.auth_info.expires
},
status: 'FAILED'
};
}
const expiration_date = new Date(new Date().getTime() + response.data.expires_in * 1000);
return {
credentials: {
refresh_token: refresh_token,
access_token: response.data.access_token,
expires: expiration_date
},
token_type: response.data.token_type,
expires: response.data.expires_in,
scope: response.data.scope,
status: 'SUCCESS'
});
};
}
async checkTokenValidity(expiration_date) {
async isTokenValid(expiration_date) {
const timestamp = new Date(expiration_date).getTime();
const is_valid = new Date().getTime() < timestamp;
return is_valid && 'VALID' || 'INVALID';
return is_valid;
}
async getClientIdentity() {
// The first request is made to get the auth script url, hard-coding it isn't viable as it changes overtime.
const yttv_response = await Axios.get(`${Constants.URLS.YT_BASE_URL}/tv`, Constants.OAUTH.HEADERS).catch((error) => error);
if (yttv_response instanceof Error) throw new Error(`Could not extract client identity: ${yttv_response.message}`);
// Here we get the script and extract the necessary data to proceed with the auth flow.
const url_body = this.auth_script_regex.exec(yttv_response.data)[1];
const script_url = `${Constants.URLS.YT_BASE_URL}/${url_body}`;
const response = await Axios.get(script_url, Constants.DEFAULT_HEADERS).catch((error) => error);
if (response instanceof Error) throw new Error(`Could not extract client identity: ${response.message}`);
const identity_function = Utils.getStringBetweenStrings(response.data, 'setQuery("");', '{useGaiaSandbox:');
const client_identity = identity_function.replace(/\n/g, '').match(this.identity_regex);
return client_identity.groups;
}
}