Skip to content

Commit 15b3109

Browse files
authored
fix(auth): resolve Plex OAuth client ID mismatch (#2746)
1 parent 891265f commit 15b3109

10 files changed

Lines changed: 59 additions & 34 deletions

File tree

cypress/config/settings.cypress.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"clientId": "6919275e-142a-48d8-be6b-93594cbd4626",
3+
"sessionSecret": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
34
"vapidPrivate": "tmnslaO8ZWN6bNbSEv_rolPeBTlNxOwCCAHrM9oZz3M",
45
"vapidPublic": "BK_EpP8NDm9waor2zn6_S28o3ZYv4kCkJOfYpO3pt3W6jnPmxrgTLANUBNbbyaNatPnSQ12De9CeqSYQrqWzHTs",
56
"main": {

seerr-api.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,10 +706,18 @@ components:
706706
example: A Label
707707
PublicSettings:
708708
type: object
709+
required:
710+
- initialized
711+
- plexClientIdentifier
709712
properties:
710713
initialized:
711714
type: boolean
712715
example: false
716+
plexClientIdentifier:
717+
type: string
718+
format: uuid
719+
description: Instance Plex OAuth client identifier
720+
example: 6919275e-142a-48d8-be6b-93594cbd4626
713721
MovieResult:
714722
type: object
715723
required:

server/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ app
203203
server.use(
204204
'/api',
205205
session({
206-
secret: settings.clientId,
206+
secret: settings.sessionSecret,
207207
resave: false,
208208
saveUninitialized: false,
209209
cookie: {

server/interfaces/api/settingsInterfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export interface PublicSettingsResponse {
4848
emailEnabled: boolean;
4949
newPlexLogin: boolean;
5050
youtubeUrl: string;
51+
plexClientIdentifier: string;
5152
}
5253

5354
export interface CacheItem {

server/lib/settings/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MediaServerType } from '@server/constants/server';
22
import { Permission } from '@server/lib/permissions';
33
import { runMigrations } from '@server/lib/settings/migrator';
4-
import { randomUUID } from 'crypto';
4+
import { randomBytes, randomUUID } from 'crypto';
55
import fs from 'fs/promises';
66
import { mergeWith } from 'lodash';
77
import path from 'path';
@@ -211,6 +211,7 @@ interface FullPublicSettings extends PublicSettings {
211211
userEmailRequired: boolean;
212212
newPlexLogin: boolean;
213213
youtubeUrl: string;
214+
plexClientIdentifier: string;
214215
}
215216

216217
export interface NotificationAgentConfig {
@@ -360,6 +361,7 @@ export type JobId =
360361

361362
export interface AllSettings {
362363
clientId: string;
364+
sessionSecret?: string;
363365
vapidPublic: string;
364366
vapidPrivate: string;
365367
main: MainSettings;
@@ -387,6 +389,7 @@ class Settings {
387389
constructor(initialSettings?: AllSettings) {
388390
this.data = {
389391
clientId: randomUUID(),
392+
sessionSecret: '',
390393
vapidPrivate: '',
391394
vapidPublic: '',
392395
main: {
@@ -713,6 +716,7 @@ class Settings {
713716
this.data.notifications.agents.email.options.userEmailRequired,
714717
newPlexLogin: this.data.main.newPlexLogin,
715718
youtubeUrl: this.data.main.youtubeUrl,
719+
plexClientIdentifier: this.data.clientId,
716720
};
717721
}
718722

@@ -752,6 +756,10 @@ class Settings {
752756
return this.data.clientId;
753757
}
754758

759+
get sessionSecret(): string {
760+
return this.data.sessionSecret!;
761+
}
762+
755763
get vapidPublic(): string {
756764
return this.data.vapidPublic;
757765
}
@@ -821,6 +829,10 @@ class Settings {
821829
this.data.clientId = randomUUID();
822830
change = true;
823831
}
832+
if (!this.data.sessionSecret) {
833+
this.data.sessionSecret = randomBytes(32).toString('hex');
834+
change = true;
835+
}
824836
if (!this.data.vapidPublic || !this.data.vapidPrivate) {
825837
const vapidKeys = webpush.generateVAPIDKeys();
826838
this.data.vapidPrivate = vapidKeys.privateKey;

src/components/UserProfile/UserSettings/UserLinkedAccountsSettings/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ const UserLinkedAccountsSettings = () => {
9191
const linkPlexAccount = async () => {
9292
setError(null);
9393
try {
94-
const authToken = await plexOAuth.login();
94+
const authToken = await plexOAuth.login(
95+
settings.currentSettings.plexClientIdentifier
96+
);
9597
await axios.post(
9698
`/api/v1/user/${user?.id}/settings/linked-accounts/plex`,
9799
{

src/context/SettingsContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const defaultSettings = {
3131
emailEnabled: false,
3232
newPlexLogin: true,
3333
youtubeUrl: '',
34+
plexClientIdentifier: '',
3435
};
3536

3637
export const SettingsContext = React.createContext<SettingsContextProps>({

src/hooks/usePlexLogin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import useSettings from '@app/hooks/useSettings';
12
import PlexOAuth from '@app/utils/plex';
23
import { useState } from 'react';
34

@@ -11,11 +12,14 @@ function usePlexLogin({
1112
onError?: (err: string) => void;
1213
}) {
1314
const [loading, setLoading] = useState(false);
15+
const { currentSettings } = useSettings();
1416

1517
const getPlexLogin = async () => {
1618
setLoading(true);
1719
try {
18-
const authToken = await plexOAuth.login();
20+
const authToken = await plexOAuth.login(
21+
currentSettings.plexClientIdentifier
22+
);
1923
setLoading(false);
2024
onAuthToken(authToken);
2125
} catch (e) {

src/pages/_app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ CoreApp.getInitialProps = async (initialProps) => {
256256
emailEnabled: false,
257257
newPlexLogin: true,
258258
youtubeUrl: '',
259+
plexClientIdentifier: '',
259260
};
260261

261262
if (ctx.res) {

src/utils/plex.ts

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,6 @@ export interface PlexPin {
2020
code: string;
2121
}
2222

23-
const uuidv4 = (): string => {
24-
return ((1e7).toString() + -1e3 + -4e3 + -8e3 + -1e11).replace(
25-
/[018]/g,
26-
function (c) {
27-
return (
28-
parseInt(c) ^
29-
(window.crypto.getRandomValues(new Uint8Array(1))[0] &
30-
(15 >> (parseInt(c) / 4)))
31-
).toString(16);
32-
}
33-
);
34-
};
35-
3623
class PlexOAuth {
3724
private plexHeaders?: PlexHeaders;
3825

@@ -41,26 +28,25 @@ class PlexOAuth {
4128

4229
private authToken?: string;
4330

44-
public initializeHeaders(): void {
45-
if (!window) {
31+
public initializeHeaders(plexClientIdentifier: string): void {
32+
if (typeof window === 'undefined') {
4633
throw new Error(
4734
'Window is not defined. Are you calling this in the browser?'
4835
);
4936
}
5037

51-
let clientId = localStorage.getItem('plex-client-id');
52-
if (!clientId) {
53-
const uuid = uuidv4();
54-
localStorage.setItem('plex-client-id', uuid);
55-
clientId = uuid;
38+
if (!plexClientIdentifier) {
39+
throw new Error(
40+
'Plex client identifier missing. Reload the page and try again.'
41+
);
5642
}
5743

5844
const browser = Bowser.getParser(window.navigator.userAgent);
5945
this.plexHeaders = {
6046
Accept: 'application/json',
6147
'X-Plex-Product': 'Seerr',
6248
'X-Plex-Version': 'Plex OAuth',
63-
'X-Plex-Client-Identifier': clientId,
49+
'X-Plex-Client-Identifier': plexClientIdentifier,
6450
'X-Plex-Model': 'Plex OAuth',
6551
'X-Plex-Platform': browser.getBrowserName(),
6652
'X-Plex-Platform-Version': browser.getBrowserVersion() || 'Unknown',
@@ -93,9 +79,14 @@ class PlexOAuth {
9379
this.openPopup({ title: 'Plex Auth', w: 600, h: 700 });
9480
}
9581

96-
public async login(): Promise<string> {
97-
this.initializeHeaders();
98-
await this.getPin();
82+
public async login(plexClientIdentifier: string): Promise<string> {
83+
try {
84+
this.initializeHeaders(plexClientIdentifier);
85+
await this.getPin();
86+
} catch (e) {
87+
this.closePopup();
88+
throw e;
89+
}
9990

10091
if (!this.plexHeaders || !this.pin) {
10192
throw new Error('Unable to call login if class is not initialized.');
@@ -117,12 +108,16 @@ class PlexOAuth {
117108
code: this.pin.code,
118109
};
119110

120-
if (this.popup) {
121-
this.popup.location.href = `https://app.plex.tv/auth/#!?${this.encodeData(
122-
params
123-
)}`;
111+
if (!this.popup || this.popup.closed) {
112+
throw new Error(
113+
'Unable to open the Plex login window. Please allow popups for this site and try again.'
114+
);
124115
}
125116

117+
this.popup.location.href = `https://app.plex.tv/auth/#!?${this.encodeData(
118+
params
119+
)}`;
120+
126121
return this.pinPoll();
127122
}
128123

@@ -145,7 +140,7 @@ class PlexOAuth {
145140
this.authToken = response.data.authToken as string;
146141
this.closePopup();
147142
resolve(this.authToken);
148-
} else if (!response.data?.authToken && !this.popup?.closed) {
143+
} else if (this.popup && !this.popup.closed) {
149144
setTimeout(executePoll, 1000, resolve, reject);
150145
} else {
151146
reject(new Error('Popup closed without completing login'));
@@ -173,7 +168,7 @@ class PlexOAuth {
173168
w: number;
174169
h: number;
175170
}): Window | void {
176-
if (!window) {
171+
if (typeof window === 'undefined') {
177172
throw new Error(
178173
'Window is undefined. Are you running this in the browser?'
179174
);

0 commit comments

Comments
 (0)