From bf3ddd7716e7a0cf310ad14a77889d0d68ed900d Mon Sep 17 00:00:00 2001 From: LuanRT Date: Tue, 21 May 2024 18:07:06 -0300 Subject: [PATCH 1/2] refactor!(OAuth2): Rewrite auth module The old one was a mess, full of bugs (ex., tokens being refreshed multiple times) and didn't have proper types. Note that this is a breaking change, as it changes how the credentials are stored. --- src/core/OAuth.ts | 303 ----------------------------------- src/core/OAuth2.ts | 338 ++++++++++++++++++++++++++++++++++++++++ src/core/Session.ts | 50 +++--- src/core/index.ts | 4 +- src/utils/Constants.ts | 15 +- src/utils/HTTPClient.ts | 14 +- src/utils/Utils.ts | 2 +- 7 files changed, 369 insertions(+), 357 deletions(-) delete mode 100644 src/core/OAuth.ts create mode 100644 src/core/OAuth2.ts diff --git a/src/core/OAuth.ts b/src/core/OAuth.ts deleted file mode 100644 index a6e02e771..000000000 --- a/src/core/OAuth.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { Log, Constants } from '../utils/index.js'; -import { OAuthError, Platform } from '../utils/Utils.js'; -import type Session from './Session.js'; - -/** - * Represents the credentials used for authentication. - */ -export interface Credentials { - /** - * Token used to sign in. - */ - access_token: string; - /** - * Token used to get a new access token. - */ - refresh_token: string; - /** - * Access token's expiration date, which is usually 24hrs-ish. - */ - expires: Date; - /** - * Optional client ID. - */ - client_id?: string; - /** - * Optional client secret. - */ - client_secret?: string; -} - -// TODO: actual type info for this. -export type OAuthAuthPendingData = any; - -export type OAuthAuthEventHandler = (data: { - credentials: Credentials; - status: 'SUCCESS'; -}) => any; - -export type OAuthAuthPendingEventHandler = (data: OAuthAuthPendingData) => any; -export type OAuthAuthErrorEventHandler = (err: OAuthError) => any; - -export type OAuthClientIdentity = { - client_id: string; - client_secret: string; -}; - -export default class OAuth { - static TAG = 'OAuth'; - - #identity?: Record; - #session: Session; - #credentials?: Credentials; - #polling_interval = 5; - - constructor(session: Session) { - this.#session = session; - } - - /** - * Starts the auth flow in case no valid credentials are available. - */ - async init(credentials?: Credentials): Promise { - this.#credentials = credentials; - - if (this.validateCredentials()) { - if (!this.has_access_token_expired) - this.#session.emit('auth', { - credentials: this.#credentials, - status: 'SUCCESS' - }); - } else if (!(await this.#loadCachedCredentials())) { - await this.#getUserCode(); - } - } - - async cacheCredentials(): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(JSON.stringify(this.#credentials)); - await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer); - } - - async #loadCachedCredentials(): Promise { - const data = await this.#session.cache?.get('youtubei_oauth_credentials'); - if (!data) return false; - - const decoder = new TextDecoder(); - const credentials = JSON.parse(decoder.decode(data)); - - this.#credentials = { - access_token: credentials.access_token, - refresh_token: credentials.refresh_token, - client_id: credentials.client_id, - client_secret: credentials.client_secret, - expires: new Date(credentials.expires) - }; - - this.#session.emit('auth', { - credentials: this.#credentials, - status: 'SUCCESS' - }); - - return true; - } - - async removeCache(): Promise { - await this.#session.cache?.remove('youtubei_oauth_credentials'); - } - - /** - * Asks the server for a user code and verification URL. - */ - async #getUserCode(): Promise { - this.#identity = await this.#getClientIdentity(); - - const data = { - client_id: this.#identity.client_id, - scope: Constants.OAUTH.SCOPE, - device_id: Platform.shim.uuidv4(), - device_model: Constants.OAUTH.MODEL_NAME - }; - - const response = await this.#session.http.fetch_function(new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE), { - body: JSON.stringify(data), - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - const response_data = await response.json(); - - this.#session.emit('auth-pending', response_data); - this.#polling_interval = response_data.interval; - this.#startPolling(response_data.device_code); - } - - /** - * Polls the authorization server until access is granted by the user. - */ - #startPolling(device_code: string): void { - const poller = setInterval(async () => { - const data = { - ...this.#identity, - code: device_code, - grant_type: Constants.OAUTH.GRANT_TYPE - }; - - try { - const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), { - body: JSON.stringify(data), - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - const response_data = await response.json(); - - if (response_data.error) { - switch (response_data.error) { - case 'access_denied': - this.#session.emit('auth-error', new OAuthError('Access was denied.', { status: 'ACCESS_DENIED' })); - break; - case 'expired_token': - this.#session.emit('auth-error', new OAuthError('The device code has expired, restarting auth flow.', { status: 'DEVICE_CODE_EXPIRED' })); - clearInterval(poller); - this.#getUserCode(); - break; - default: - break; - } - return; - } - - const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000); - - this.#credentials = { - access_token: response_data.access_token, - refresh_token: response_data.refresh_token, - client_id: this.#identity?.client_id, - client_secret: this.#identity?.client_secret, - expires: expiration_date - }; - - this.#session.emit('auth', { - credentials: this.#credentials, - status: 'SUCCESS' - }); - - clearInterval(poller); - } catch (err) { - clearInterval(poller); - return this.#session.emit('auth-error', new OAuthError('Could not obtain user code.', { status: 'FAILED', error: err })); - } - }, this.#polling_interval * 1000); - } - - /** - * Refresh access token if the same has expired. - */ - async refreshIfRequired(): Promise { - if (this.has_access_token_expired) { - await this.#refreshAccessToken(); - } - } - - async #refreshAccessToken(): Promise { - if (!this.#credentials) return; - this.#identity = await this.#getClientIdentity(); - - const data = { - ...this.#identity, - refresh_token: this.#credentials.refresh_token, - grant_type: 'refresh_token' - }; - - const response = await this.#session.http.fetch_function(new URL('/o/oauth2/token', Constants.URLS.YT_BASE), { - body: JSON.stringify(data), - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - const response_data = await response.json(); - const expiration_date = new Date(new Date().getTime() + response_data.expires_in * 1000); - - this.#credentials = { - access_token: response_data.access_token, - refresh_token: response_data.refresh_token || this.#credentials.refresh_token, - client_id: this.#identity.client_id, - client_secret: this.#identity.client_secret, - expires: expiration_date - }; - - this.#session.emit('update-credentials', { - credentials: this.#credentials, - status: 'SUCCESS' - }); - } - - async revokeCredentials(): Promise { - if (!this.#credentials) return; - await this.removeCache(); - return this.#session.http.fetch_function(new URL(`/o/oauth2/revoke?token=${encodeURIComponent(this.#credentials.access_token)}`, Constants.URLS.YT_BASE), { - method: 'post' - }); - } - - /** - * Retrieves client identity from YouTube TV. - */ - async #getClientIdentity(): Promise { - if (this.#credentials?.client_id && this.credentials?.client_secret) { - Log.info(OAuth.TAG, 'Using custom OAuth2 credentials.\n'); - return { - client_id: this.#credentials.client_id, - client_secret: this.credentials.client_secret - }; - } - - const response = await this.#session.http.fetch_function(new URL('/tv', Constants.URLS.YT_BASE), { headers: Constants.OAUTH.HEADERS }); - - const response_data = await response.text(); - const url_body = Constants.OAUTH.REGEX.AUTH_SCRIPT.exec(response_data)?.[1]; - - if (!url_body) - throw new OAuthError('Could not obtain script url.', { status: 'FAILED' }); - - Log.info(OAuth.TAG, `Got YouTubeTV script URL (${url_body})`); - - const script = await this.#session.http.fetch(url_body, { baseURL: Constants.URLS.YT_BASE }); - - const client_identity = (await script.text()) - .replace(/\n/g, '') - .match(Constants.OAUTH.REGEX.CLIENT_IDENTITY); - - const groups = client_identity?.groups as OAuthClientIdentity | null; - - if (!groups) - throw new OAuthError('Could not obtain client identity.', { status: 'FAILED' }); - - Log.info(OAuth.TAG, 'OAuth2 credentials retrieved.\n', groups); - - return groups; - } - - get credentials(): Credentials | undefined { - return this.#credentials; - } - - get has_access_token_expired(): boolean { - const timestamp = this.#credentials ? new Date(this.#credentials.expires).getTime() : -Infinity; - return new Date().getTime() > timestamp; - } - - validateCredentials(): this is this & { credentials: Credentials } { - return this.#credentials && - Reflect.has(this.#credentials, 'access_token') && - Reflect.has(this.#credentials, 'refresh_token') && - Reflect.has(this.#credentials, 'expires') || false; - } -} \ No newline at end of file diff --git a/src/core/OAuth2.ts b/src/core/OAuth2.ts new file mode 100644 index 000000000..d4871510e --- /dev/null +++ b/src/core/OAuth2.ts @@ -0,0 +1,338 @@ +import { OAuth2Error, Platform } from '../utils/Utils.js'; +import { Log, Constants } from '../utils/index.js'; +import type Session from './Session.js'; + +const TAG = 'OAuth2'; + +export type OAuth2ClientID = { + client_id: string; + client_secret: string; +}; + +export type OAuth2Tokens = { + access_token: string; + expiry_date: string; + expires_in?: number; + refresh_token: string; + scope?: string; + token_type?: string; + client?: OAuth2ClientID; +}; + +export type DeviceAndUserCode = { + device_code: string; + expires_in: number; + interval: number; + user_code: string; + verification_url: string; + error_code?: string; +}; + +export type OAuth2AuthEventHandler = (data: { credentials: OAuth2Tokens; }) => void; +export type OAuth2AuthPendingEventHandler = (data: DeviceAndUserCode) => void; +export type OAuth2AuthErrorEventHandler = (err: OAuth2Error) => void; + +export default class OAuth2 { + #session: Session; + + YTTV_URL: URL; + AUTH_SERVER_CODE_URL: URL; + AUTH_SERVER_TOKEN_URL: URL; + AUTH_SERVER_REVOKE_TOKEN_URL: URL; + + client_id: OAuth2ClientID | undefined; + oauth2_tokens: OAuth2Tokens | undefined; + + constructor(session: Session) { + this.#session = session; + this.YTTV_URL = new URL('/tv', Constants.URLS.YT_BASE); + this.AUTH_SERVER_CODE_URL = new URL('/o/oauth2/device/code', Constants.URLS.YT_BASE); + this.AUTH_SERVER_TOKEN_URL = new URL('/o/oauth2/token', Constants.URLS.YT_BASE); + this.AUTH_SERVER_REVOKE_TOKEN_URL = new URL('/o/oauth2/revoke', Constants.URLS.YT_BASE); + } + + async init(tokens?: OAuth2Tokens): Promise { + if (tokens) { + this.setTokens(tokens); + + if (this.shouldRefreshToken()) { + await this.refreshAccessToken(); + } + + this.#session.emit('auth', { credentials: this.oauth2_tokens }); + + return; + } + + const loaded_from_cache = await this.#loadFromCache(); + + if (loaded_from_cache) { + Log.info(TAG, 'Loaded OAuth2 tokens from cache.', this.oauth2_tokens); + return; + } + + if (!this.client_id) + this.client_id = await this.getClientID(); + + // Initialize OAuth2 flow + const device_and_user_code = await this.getDeviceAndUserCode(); + + this.#session.emit('auth-pending', device_and_user_code); + + this.pollForAccessToken(device_and_user_code); + } + + setTokens(tokens: OAuth2Tokens): void { + const tokensMod = tokens; + + // Convert access token remaining lifetime to ISO string + if (tokensMod.expires_in) { + tokensMod.expiry_date = new Date(Date.now() + tokensMod.expires_in * 1000).toISOString(); + delete tokensMod.expires_in; // We don't need this anymore + } + + if (!this.validateTokens(tokensMod)) + throw new OAuth2Error('Invalid tokens provided.'); + + this.oauth2_tokens = tokensMod; + + if (tokensMod.client) { + Log.info(TAG, 'Using provided client id and secret.'); + this.client_id = tokensMod.client; + } + } + + async cacheCredentials(): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(JSON.stringify(this.oauth2_tokens)); + await this.#session.cache?.set('youtubei_oauth_credentials', data.buffer); + } + + async #loadFromCache(): Promise { + const data = await this.#session.cache?.get('youtubei_oauth_credentials'); + if (!data) + return false; + + const decoder = new TextDecoder(); + const credentials = JSON.parse(decoder.decode(data)); + + this.setTokens(credentials); + + this.#session.emit('auth', { credentials }); + + return true; + } + + async removeCache(): Promise { + await this.#session.cache?.remove('youtubei_oauth_credentials'); + } + + async pollForAccessToken(device_and_user_code: DeviceAndUserCode): Promise { + if (!this.client_id) + throw new OAuth2Error('Client ID is missing.'); + + const { device_code, interval } = device_and_user_code; + const { client_id, client_secret } = this.client_id; + + const payload = { + client_id, + client_secret, + code: device_code, + grant_type: 'http://oauth.net/grant_type/device/1.0' + }; + + const connInterval = setInterval(async () => { + const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, { + body: JSON.stringify(payload), + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const response_data = await response.json(); + + if (response_data.error) { + switch (response_data.error) { + case 'access_denied': + this.#session.emit('auth-error', new OAuth2Error('Access was denied.', response_data)); + clearInterval(connInterval); + break; + case 'expired_token': + this.#session.emit('auth-error', new OAuth2Error('The device code has expired.', response_data)); + clearInterval(connInterval); + break; + case 'authorization_pending': + case 'slow_down': + Log.info(TAG, 'Polling for access token...'); + break; + default: + this.#session.emit('auth-error', new OAuth2Error('Server returned an unexpected error.', response_data)); + clearInterval(connInterval); + break; + } + return; + } + + this.setTokens(response_data); + + this.#session.emit('auth', { credentials: this.oauth2_tokens }); + + clearInterval(connInterval); + }, interval * 1000); + } + + async revokeCredentials(): Promise { + if (!this.oauth2_tokens) + throw new OAuth2Error('Access token not found'); + + await this.removeCache(); + + const url = this.AUTH_SERVER_REVOKE_TOKEN_URL; + url.searchParams.set('token', this.oauth2_tokens.access_token); + + return this.#session.http.fetch_function(url, { method: 'POST' }); + } + + async refreshAccessToken(): Promise { + if (!this.client_id) + this.client_id = await this.getClientID(); + + if (!this.oauth2_tokens) + throw new OAuth2Error('No tokens available to refresh.'); + + const { client_id, client_secret } = this.client_id; + const { refresh_token } = this.oauth2_tokens; + + const payload = { + client_id, + client_secret, + refresh_token, + grant_type: 'refresh_token' + }; + + const response = await this.#http.fetch_function(this.AUTH_SERVER_TOKEN_URL, { + body: JSON.stringify(payload), + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) + throw new OAuth2Error(`Failed to refresh access token: ${response.status}`); + + const response_data = await response.json(); + + if (response_data.error_code) + throw new OAuth2Error('Authorization server returned an error', response_data); + + this.oauth2_tokens.access_token = response_data.access_token; + this.oauth2_tokens.expiry_date = new Date(Date.now() + response_data.expires_in * 1000).toISOString(); + + this.#session.emit('update-credentials', { credentials: this.oauth2_tokens }); + } + + async getDeviceAndUserCode(): Promise { + if (!this.client_id) + throw new OAuth2Error('Client ID is missing.'); + + const { client_id } = this.client_id; + + const payload = { + client_id, + scope: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content', + device_id: Platform.shim.uuidv4(), + device_model: 'ytlr::' + }; + + const response = await this.#http.fetch_function(this.AUTH_SERVER_CODE_URL, { + body: JSON.stringify(payload), + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) + throw new OAuth2Error(`Failed to get device/user code: ${response.status}`); + + const response_data = await response.json(); + + if (response_data.error_code) + throw new OAuth2Error('Authorization server returned an error', response_data); + + return response_data; + } + + async getClientID(): Promise { + const yttv_response = await this.#http.fetch_function(this.YTTV_URL, { + headers: { + 'User-Agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version', + 'Referer': 'https://www.youtube.com/tv', + 'Accept-Language': 'en-US' + } + }); + + if (!yttv_response.ok) + throw new OAuth2Error(`Failed to get client ID: ${yttv_response.status}`); + + const yttv_response_data = await yttv_response.text(); + + let script_url_body: RegExpExecArray | null; + + if ((script_url_body = Constants.OAUTH.REGEX.TV_SCRIPT.exec(yttv_response_data)) !== null) { + Log.info(TAG, `Got YouTubeTV script URL (${script_url_body[1]})`); + + const script_response = await this.#http.fetch(script_url_body[1], { baseURL: Constants.URLS.YT_BASE }); + + if (!script_response.ok) + throw new OAuth2Error(`TV script request failed with status code ${script_response.status}`); + + const script_response_data = await script_response.text(); + + const client_identity = script_response_data + .match(Constants.OAUTH.REGEX.CLIENT_IDENTITY); + + if (!client_identity || !client_identity.groups) + throw new OAuth2Error('Could not obtain client ID.'); + + const { client_id, client_secret } = client_identity.groups; + + Log.info(TAG, `Client identity retrieved (clientId=${client_id}, clientSecret=${client_secret}).`); + + return { + client_id, + client_secret + }; + } + + throw new OAuth2Error('Could not obtain script URL.'); + } + + shouldRefreshToken(): boolean { + if (!this.oauth2_tokens) + return false; + return Date.now() > new Date(this.oauth2_tokens.expiry_date).getTime(); + } + + validateTokens(tokens: OAuth2Tokens): boolean { + const propertiesAreValid = ( + Boolean(tokens.access_token) && + Boolean(tokens.expiry_date) && + Boolean(tokens.refresh_token) + ); + + const typesAreValid = ( + typeof tokens.access_token === 'string' && + typeof tokens.expiry_date === 'string' && + typeof tokens.refresh_token === 'string' + ); + + return typesAreValid && propertiesAreValid; + } + + get #http() { + return this.#session.http; + } +} \ No newline at end of file diff --git a/src/core/Session.ts b/src/core/Session.ts index ab11c71a5..e986d27bb 100644 --- a/src/core/Session.ts +++ b/src/core/Session.ts @@ -1,4 +1,4 @@ -import OAuth from './OAuth.js'; +import OAuth2 from './OAuth2.js'; import { Log, EventEmitter, HTTPClient } from '../utils/index.js'; import * as Constants from '../utils/Constants.js'; import * as Proto from '../proto/index.js'; @@ -12,10 +12,7 @@ import { import type { DeviceCategory } from '../utils/Utils.js'; import type { FetchFunction, ICache } from '../types/index.js'; -import type { - Credentials, OAuthAuthErrorEventHandler, - OAuthAuthEventHandler, OAuthAuthPendingEventHandler -} from './OAuth.js'; +import type { OAuth2Tokens, OAuth2AuthErrorEventHandler, OAuth2AuthPendingEventHandler, OAuth2AuthEventHandler } from './OAuth2.js'; export enum ClientType { WEB = 'WEB', @@ -172,7 +169,7 @@ export default class Session extends EventEmitter { #account_index: number; #player?: Player; - oauth: OAuth; + oauth: OAuth2; http: HTTPClient; logged_in: boolean; actions: Actions; @@ -187,23 +184,23 @@ export default class Session extends EventEmitter { this.#player = player; this.http = new HTTPClient(this, cookie, fetch); this.actions = new Actions(this); - this.oauth = new OAuth(this); + this.oauth = new OAuth2(this); this.logged_in = !!cookie; this.cache = cache; } - on(type: 'auth', listener: OAuthAuthEventHandler): void; - on(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void; - on(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void; - on(type: 'update-credentials', listener: OAuthAuthEventHandler): void; + on(type: 'auth', listener: OAuth2AuthEventHandler): void; + on(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void; + on(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void; + on(type: 'update-credentials', listener: OAuth2AuthEventHandler): void; on(type: string, listener: (...args: any[]) => void): void { super.on(type, listener); } - once(type: 'auth', listener: OAuthAuthEventHandler): void; - once(type: 'auth-pending', listener: OAuthAuthPendingEventHandler): void; - once(type: 'auth-error', listener: OAuthAuthErrorEventHandler): void; + once(type: 'auth', listener: OAuth2AuthEventHandler): void; + once(type: 'auth-pending', listener: OAuth2AuthPendingEventHandler): void; + once(type: 'auth-error', listener: OAuth2AuthErrorEventHandler): void; once(type: string, listener: (...args: any[]) => void): void { super.once(type, listener); @@ -390,31 +387,20 @@ export default class Session extends EventEmitter { return { context, api_key: Constants.CLIENTS.WEB.API_KEY, api_version: Constants.CLIENTS.WEB.API_VERSION }; } - async signIn(credentials?: Credentials): Promise { + async signIn(credentials?: OAuth2Tokens): Promise { return new Promise(async (resolve, reject) => { - const error_handler: OAuthAuthErrorEventHandler = (err) => reject(err); + const error_handler: OAuth2AuthErrorEventHandler = (err) => reject(err); - this.once('auth', (data) => { - this.off('auth-error', error_handler); - - if (data.status === 'SUCCESS') { - this.logged_in = true; - resolve(); - } + this.once('auth-error', error_handler); - reject(data); + this.once('auth', () => { + this.off('auth-error', error_handler); + this.logged_in = true; + resolve(); }); - this.once('auth-error', error_handler); - try { await this.oauth.init(credentials); - - if (this.oauth.validateCredentials()) { - await this.oauth.refreshIfRequired(); - this.logged_in = true; - resolve(); - } } catch (err) { reject(err); } diff --git a/src/core/index.ts b/src/core/index.ts index c04552499..42c21c466 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,8 +7,8 @@ export * from './Actions.js'; export { default as Player } from './Player.js'; export * from './Player.js'; -export { default as OAuth } from './OAuth.js'; -export * from './OAuth.js'; +export { default as OAuth2 } from './OAuth2.js'; +export * from './OAuth2.js'; export * as Clients from './clients/index.js'; export * as Endpoints from './endpoints/index.js'; diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 2cb32cfe2..2e6335974 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -16,20 +16,9 @@ export const URLS = Object.freeze({ }) }); export const OAUTH = Object.freeze({ - SCOPE: 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube-paid-content', - GRANT_TYPE: 'http://oauth.net/grant_type/device/1.0', - MODEL_NAME: 'ytlr::', - HEADERS: Object.freeze({ - 'accept': '*/*', - 'origin': 'https://www.youtube.com', - 'user-agent': 'Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version', - 'content-type': 'application/json', - 'referer': 'https://www.youtube.com/tv', - 'accept-language': 'en-US' - }), REGEX: Object.freeze({ - AUTH_SCRIPT: /