diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 949351c378..f485424fcc 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -1,5 +1,6 @@ { "clientId": "6919275e-142a-48d8-be6b-93594cbd4626", + "sessionSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", "vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M", "vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs", "main": { diff --git a/seerr-api.yml b/seerr-api.yml index 2280324f88..9600375ccd 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -706,10 +706,18 @@ components: example: A Label PublicSettings: type: object + required: + - initialized + - plexClientIdentifier properties: initialized: type: boolean example: false + plexClientIdentifier: + type: string + format: uuid + description: Instance Plex OAuth client identifier + example: 6919275e-142a-48d8-be6b-93594cbd4626 MovieResult: type: object required: diff --git a/server/index.ts b/server/index.ts index 1ee4722c3d..cd34d7d523 100644 --- a/server/index.ts +++ b/server/index.ts @@ -203,7 +203,7 @@ app server.use( '/api', session({ - secret: settings.clientId, + secret: settings.sessionSecret, resave: false, saveUninitialized: false, cookie: { diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index ea08d4e61d..3dfa619205 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -48,6 +48,7 @@ export interface PublicSettingsResponse { emailEnabled: boolean; newPlexLogin: boolean; youtubeUrl: string; + plexClientIdentifier: string; } export interface CacheItem { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 779413ca66..38bf3e58d8 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -1,7 +1,7 @@ import { MediaServerType } from '@server/constants/server'; import { Permission } from '@server/lib/permissions'; import { runMigrations } from '@server/lib/settings/migrator'; -import { randomUUID } from 'crypto'; +import { randomBytes, randomUUID } from 'crypto'; import fs from 'fs/promises'; import { mergeWith } from 'lodash'; import path from 'path'; @@ -211,6 +211,7 @@ interface FullPublicSettings extends PublicSettings { userEmailRequired: boolean; newPlexLogin: boolean; youtubeUrl: string; + plexClientIdentifier: string; } export interface NotificationAgentConfig { @@ -360,6 +361,7 @@ export type JobId = export interface AllSettings { clientId: string; + sessionSecret?: string; vapidPublic: string; vapidPrivate: string; main: MainSettings; @@ -387,6 +389,7 @@ class Settings { constructor(initialSettings?: AllSettings) { this.data = { clientId: randomUUID(), + sessionSecret: '', vapidPrivate: '', vapidPublic: '', main: { @@ -713,6 +716,7 @@ class Settings { this.data.notifications.agents.email.options.userEmailRequired, newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, + plexClientIdentifier: this.data.clientId, }; } @@ -752,6 +756,10 @@ class Settings { return this.data.clientId; } + get sessionSecret(): string { + return this.data.sessionSecret!; + } + get vapidPublic(): string { return this.data.vapidPublic; } @@ -821,6 +829,10 @@ class Settings { this.data.clientId = randomUUID(); change = true; } + if (!this.data.sessionSecret) { + this.data.sessionSecret = randomBytes(32).toString('hex'); + change = true; + } if (!this.data.vapidPublic || !this.data.vapidPrivate) { const vapidKeys = webpush.generateVAPIDKeys(); this.data.vapidPrivate = vapidKeys.privateKey; diff --git a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx index 9fcbe3cc3a..9940828299 100644 --- a/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx @@ -91,7 +91,9 @@ const UserLinkedAccountsSettings = () => { const linkPlexAccount = async () => { setError(null); try { - const authToken = await plexOAuth.login(); + const authToken = await plexOAuth.login( + settings.currentSettings.plexClientIdentifier + ); await axios.post( `/api/v1/user/${user?.id}/settings/linked-accounts/plex`, { diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index f6c9d3870c..f2ed116756 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -31,6 +31,7 @@ const defaultSettings = { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + plexClientIdentifier: '', }; export const SettingsContext = React.createContext({ diff --git a/src/hooks/usePlexLogin.ts b/src/hooks/usePlexLogin.ts index a169290c41..08756f82ec 100644 --- a/src/hooks/usePlexLogin.ts +++ b/src/hooks/usePlexLogin.ts @@ -1,3 +1,4 @@ +import useSettings from '@app/hooks/useSettings'; import PlexOAuth from '@app/utils/plex'; import { useState } from 'react'; @@ -11,11 +12,14 @@ function usePlexLogin({ onError?: (err: string) => void; }) { const [loading, setLoading] = useState(false); + const { currentSettings } = useSettings(); const getPlexLogin = async () => { setLoading(true); try { - const authToken = await plexOAuth.login(); + const authToken = await plexOAuth.login( + currentSettings.plexClientIdentifier + ); setLoading(false); onAuthToken(authToken); } catch (e) { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index df17109b72..0e0459adc8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -256,6 +256,7 @@ CoreApp.getInitialProps = async (initialProps) => { emailEnabled: false, newPlexLogin: true, youtubeUrl: '', + plexClientIdentifier: '', }; if (ctx.res) { diff --git a/src/utils/plex.ts b/src/utils/plex.ts index 701cc5fdac..0c6a3941f0 100644 --- a/src/utils/plex.ts +++ b/src/utils/plex.ts @@ -20,19 +20,6 @@ export interface PlexPin { code: string; } -const uuidv4 = (): string => { - return ((1e7).toString() + -1e3 + -4e3 + -8e3 + -1e11).replace( - /[018]/g, - function (c) { - return ( - parseInt(c) ^ - (window.crypto.getRandomValues(new Uint8Array(1))[0] & - (15 >> (parseInt(c) / 4))) - ).toString(16); - } - ); -}; - class PlexOAuth { private plexHeaders?: PlexHeaders; @@ -41,18 +28,17 @@ class PlexOAuth { private authToken?: string; - public initializeHeaders(): void { - if (!window) { + public initializeHeaders(plexClientIdentifier: string): void { + if (typeof window === 'undefined') { throw new Error( 'Window is not defined. Are you calling this in the browser?' ); } - let clientId = localStorage.getItem('plex-client-id'); - if (!clientId) { - const uuid = uuidv4(); - localStorage.setItem('plex-client-id', uuid); - clientId = uuid; + if (!plexClientIdentifier) { + throw new Error( + 'Plex client identifier missing. Reload the page and try again.' + ); } const browser = Bowser.getParser(window.navigator.userAgent); @@ -60,7 +46,7 @@ class PlexOAuth { Accept: 'application/json', 'X-Plex-Product': 'Seerr', 'X-Plex-Version': 'Plex OAuth', - 'X-Plex-Client-Identifier': clientId, + 'X-Plex-Client-Identifier': plexClientIdentifier, 'X-Plex-Model': 'Plex OAuth', 'X-Plex-Platform': browser.getBrowserName(), 'X-Plex-Platform-Version': browser.getBrowserVersion() || 'Unknown', @@ -93,9 +79,14 @@ class PlexOAuth { this.openPopup({ title: 'Plex Auth', w: 600, h: 700 }); } - public async login(): Promise { - this.initializeHeaders(); - await this.getPin(); + public async login(plexClientIdentifier: string): Promise { + try { + this.initializeHeaders(plexClientIdentifier); + await this.getPin(); + } catch (e) { + this.closePopup(); + throw e; + } if (!this.plexHeaders || !this.pin) { throw new Error('Unable to call login if class is not initialized.'); @@ -117,12 +108,16 @@ class PlexOAuth { code: this.pin.code, }; - if (this.popup) { - this.popup.location.href = `https://app.plex.tv/auth/#!?${this.encodeData( - params - )}`; + if (!this.popup || this.popup.closed) { + throw new Error( + 'Unable to open the Plex login window. Please allow popups for this site and try again.' + ); } + this.popup.location.href = `https://app.plex.tv/auth/#!?${this.encodeData( + params + )}`; + return this.pinPoll(); } @@ -145,7 +140,7 @@ class PlexOAuth { this.authToken = response.data.authToken as string; this.closePopup(); resolve(this.authToken); - } else if (!response.data?.authToken && !this.popup?.closed) { + } else if (this.popup && !this.popup.closed) { setTimeout(executePoll, 1000, resolve, reject); } else { reject(new Error('Popup closed without completing login')); @@ -173,7 +168,7 @@ class PlexOAuth { w: number; h: number; }): Window | void { - if (!window) { + if (typeof window === 'undefined') { throw new Error( 'Window is undefined. Are you running this in the browser?' );