From f5b3b06dfebe30eb1d7a3b2d179652caa0d2c450 Mon Sep 17 00:00:00 2001 From: Hermanus Engelbrecht Date: Sat, 24 Aug 2024 13:36:16 +0200 Subject: [PATCH 1/3] feat(new auth api): add EmbyConnect authentication API Created an Emby Connect API with the required methods to orchestrate a set of calls towards EmbyConnect to build a JellyseerrLoginResponse object. Added an EmbyConnect authentication call to the Jellyfin login() method as the last authentication attempt, but only if the username is an email address. Updated the post() method on the ExternalApi class to handle request bodies that need to be x-www-form-urlencoded. #749 --- server/api/embyconnect.ts | 207 ++++++++++++++++++++++++++++++++++++++ server/api/externalapi.ts | 24 ++++- server/api/jellyfin.ts | 31 +++++- 3 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 server/api/embyconnect.ts diff --git a/server/api/embyconnect.ts b/server/api/embyconnect.ts new file mode 100644 index 000000000..b59f1cbca --- /dev/null +++ b/server/api/embyconnect.ts @@ -0,0 +1,207 @@ +import ExternalAPI from '@server/api/externalapi'; +import { ApiErrorCode } from '@server/constants/error'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { ApiError } from '@server/types/error'; +import { getAppVersion } from '@server/utils/appVersion'; +import { getHostname } from '@server/utils/getHostname'; +import { uniqueId } from 'lodash'; +import type { JellyfinLoginResponse } from './jellyfin'; + +export interface ConnectAuthResponse { + AccessToken: string; + User: { + Id: string; + Name: string; + Email: string; + IsActive: string; + }; +} + +export interface LinkedServer { + Id: string; + Url: string; + Name: string; + SystemId: string; + AccessKey: string; + LocalAddress: string; + UserType: string; + SupporterKey: string; +} + +export interface LocalUserAuthExchangeResponse { + LocalUserId: string; + AccessToken: string; +} + +export interface EmbyConnectOptions { + ClientIP?: string; + DeviceId?: string; +} + +const EMBY_CONNECT_URL = 'https://connect.emby.media'; + +class EmbyConnectAPI extends ExternalAPI { + private ClientIP?: string; + private DeviceId?: string; + + constructor(options: EmbyConnectOptions = {}) { + super( + EMBY_CONNECT_URL, + {}, + { + headers: { + 'X-Application': `Jellyseerr/${getAppVersion()}`, + }, + } + ); + this.ClientIP = options.ClientIP; + this.DeviceId = options.DeviceId; + } + + public async authenticateConnectUser(Email?: string, Password?: string) { + logger.debug( + `Attempting to authenticate via EmbyConnect with email: ${Email}` + ); + + const connectAuthResponse = await this.getConnectUserAccessToken( + Email, + Password + ); + + const linkedServers = await this.getValidServers( + connectAuthResponse.User.Id, + connectAuthResponse.AccessToken + ); + + const matchingServer = this.findMatchingServer(linkedServers); + + const embyServerApi = new EmbyServerApi(getHostname(), this.ClientIP); + const localUserExchangeResponse = await embyServerApi.localAuthExchange( + matchingServer.AccessKey, + connectAuthResponse.User.Id, + this.DeviceId + ); + + return { + User: { + Name: connectAuthResponse.User.Name, + ServerId: matchingServer.SystemId, + ServerName: matchingServer.Name, + Id: localUserExchangeResponse.LocalUserId, + Configuration: { + GroupedFolders: [], + }, + Policy: { + IsAdministrator: false, // This requires an additional EmbyServer API call, skipping for now + }, + }, + AccessToken: localUserExchangeResponse.AccessToken, + } as JellyfinLoginResponse; + } + + private async getConnectUserAccessToken( + Email?: string, + Password?: string + ): Promise { + try { + const textResponse = await this.post( + '/service/user/authenticate', + { nameOrEmail: Email, rawpw: Password }, + {}, + undefined, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); + + return JSON.parse(textResponse) as ConnectAuthResponse; + } catch (e) { + logger.debug(`Failed to authenticate using EmbyConnect: ${e.message}`, { + label: 'EmbyConnect API', + ip: this.ClientIP, + }); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidCredentials); + } + } + + private async getValidServers( + ConnectUserId: string, + AccessToken: string + ): Promise { + try { + const textResponse = await this.get( + `/service/servers`, + { userId: ConnectUserId }, + undefined, + { + headers: { + 'X-Connect-UserToken': AccessToken, + }, + } + ); + + return JSON.parse(textResponse) as LinkedServer[]; + } catch (e) { + logger.error( + `Failed to retrieve EmbyConnect user server list: ${e.message}`, + { + label: 'EmbyConnect API', + ip: this.ClientIP, + } + ); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + } + } + + private findMatchingServer(linkedEmbyServers: LinkedServer[]): LinkedServer { + const settings = getSettings(); + const matchingServer = linkedEmbyServers.find( + (server) => server.SystemId === settings.jellyfin.serverId + ); + + if (!matchingServer) { + throw new Error( + `No matching linked Emby server found for serverId: ${settings.jellyfin.serverId}` + ); + } + + return matchingServer; + } +} + +class EmbyServerApi extends ExternalAPI { + private ClientIP?: string; + constructor(embyHost: string, ClientIP?: string) { + super(embyHost, {}, {}); + this.ClientIP = ClientIP; + } + + async localAuthExchange( + accessKey: string, + userId: string, + deviceId?: string + ): Promise { + try { + return await this.get('/emby/Connect/Exchange', { + format: 'json', + ConnectUserId: userId, + 'X-Emby-Client': 'Jellyseerr', + 'X-Emby-Device-Id': deviceId ?? uniqueId(), + 'X-Emby-Client-Version': getAppVersion(), + 'X-Emby-Device-Name': 'Jellyseerr', + 'X-Emby-Token': accessKey, + }); + } catch (e) { + logger.error(`Failed to do local user auth exchange: ${e.message}`, { + label: 'EmbyConnect.EmbyServer API', + ip: this.ClientIP, + }); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + } + } +} + +export default EmbyConnectAPI; diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 4f0ded026..75140bf05 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -1,6 +1,7 @@ import type { RateLimitOptions } from '@server/utils/rateLimit'; import rateLimit from '@server/utils/rateLimit'; import type NodeCache from 'node-cache'; +import querystring from 'querystring'; // 5 minute default TTL (in seconds) const DEFAULT_TTL = 300; @@ -100,15 +101,28 @@ class ExternalAPI { } const url = this.formatUrl(endpoint, params); + const headers = new Headers({ + ...this.defaultHeaders, + ...(config?.headers || {}), + }); + + const isFormUrlEncoded = headers + .get('Content-Type') + ?.includes('application/x-www-form-urlencoded'); + + const body = data + ? isFormUrlEncoded + ? querystring.stringify(data as Record) + : JSON.stringify(data) + : undefined; + const response = await this.fetch(url, { method: 'POST', ...config, - headers: { - ...this.defaultHeaders, - ...config?.headers, - }, - body: data ? JSON.stringify(data) : undefined, + headers, + body: body, }); + if (!response.ok) { const text = await response.text(); throw new Error( diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index f65503477..7e8475955 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import EmbyConnectAPI from '@server/api/embyconnect'; import ExternalAPI from '@server/api/externalapi'; import { ApiErrorCode } from '@server/constants/error'; import availabilitySync from '@server/lib/availabilitySync'; import logger from '@server/logger'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; +import * as EmailValidator from 'email-validator'; export interface JellyfinUserResponse { Name: string; @@ -94,6 +96,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem { class JellyfinAPI extends ExternalAPI { private userId?: string; + private deviceId?: string; constructor(jellyfinHost: string, authToken?: string, deviceId?: string) { let authHeaderVal: string; @@ -112,6 +115,7 @@ class JellyfinAPI extends ExternalAPI { }, } ); + this.deviceId = deviceId; } public async login( @@ -169,11 +173,36 @@ class JellyfinAPI extends ExternalAPI { if (networkErrorCodes.has(e.code) || status === 404) { throw new ApiError(status, ApiErrorCode.InvalidUrl); } + } - throw new ApiError(status, ApiErrorCode.InvalidCredentials); + if (Username && EmailValidator.validate(Username)) { + try { + return await this.authenticateWithEmbyConnect( + ClientIP, + Username, + Password + ); + } catch (e) { + logger.debug(`Emby Connect authentication failed: ${e}`); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidCredentials); + } + } else { + throw new ApiError(401, ApiErrorCode.InvalidCredentials); } } + private async authenticateWithEmbyConnect( + ClientIP: string | undefined, + Username: string | undefined, + Password: string | undefined + ): Promise { + const connectApi = new EmbyConnectAPI({ + ClientIP: ClientIP, + DeviceId: this.deviceId, + }); + return await connectApi.authenticateConnectUser(Username, Password); + } + public setUserId(userId: string): void { this.userId = userId; return; From 0e588bf3158c7a6473d62d520faeaa7371f835cd Mon Sep 17 00:00:00 2001 From: Hermanus Engelbrecht Date: Tue, 27 Aug 2024 15:27:53 +0200 Subject: [PATCH 2/3] refactor: resolving PR commets This refactor includes a change that adds a conditional arg to the ExternalApi get() method to override the base url. This method was pre-existing and already used in the calls and method. #943 --- server/api/embyconnect.ts | 50 +++++++++++++++++---------------------- server/api/externalapi.ts | 5 ++-- server/api/jellyfin.ts | 33 ++++++++++++-------------- 3 files changed, 40 insertions(+), 48 deletions(-) diff --git a/server/api/embyconnect.ts b/server/api/embyconnect.ts index b59f1cbca..455f4ce66 100644 --- a/server/api/embyconnect.ts +++ b/server/api/embyconnect.ts @@ -60,9 +60,9 @@ class EmbyConnectAPI extends ExternalAPI { } public async authenticateConnectUser(Email?: string, Password?: string) { - logger.debug( - `Attempting to authenticate via EmbyConnect with email: ${Email}` - ); + logger.debug(`Attempting to authenticate via EmbyConnect with email:`, { + Email, + }); const connectAuthResponse = await this.getConnectUserAccessToken( Email, @@ -76,8 +76,7 @@ class EmbyConnectAPI extends ExternalAPI { const matchingServer = this.findMatchingServer(linkedServers); - const embyServerApi = new EmbyServerApi(getHostname(), this.ClientIP); - const localUserExchangeResponse = await embyServerApi.localAuthExchange( + const localUserExchangeResponse = await this.localAuthExchange( matchingServer.AccessKey, connectAuthResponse.User.Id, this.DeviceId @@ -170,36 +169,31 @@ class EmbyConnectAPI extends ExternalAPI { return matchingServer; } -} - -class EmbyServerApi extends ExternalAPI { - private ClientIP?: string; - constructor(embyHost: string, ClientIP?: string) { - super(embyHost, {}, {}); - this.ClientIP = ClientIP; - } - async localAuthExchange( + private async localAuthExchange( accessKey: string, userId: string, deviceId?: string ): Promise { try { - return await this.get('/emby/Connect/Exchange', { - format: 'json', - ConnectUserId: userId, - 'X-Emby-Client': 'Jellyseerr', - 'X-Emby-Device-Id': deviceId ?? uniqueId(), - 'X-Emby-Client-Version': getAppVersion(), - 'X-Emby-Device-Name': 'Jellyseerr', - 'X-Emby-Token': accessKey, - }); + return this.get( + '/emby/Connect/Exchange', + { + format: 'json', + ConnectUserId: userId, + 'X-Emby-Client': 'Jellyseerr', + 'X-Emby-Device-Id': deviceId ?? uniqueId(), + 'X-Emby-Client-Version': getAppVersion(), + 'X-Emby-Device-Name': 'Jellyseerr', + 'X-Emby-Token': accessKey, + }, + undefined, + {}, + getHostname() + ); } catch (e) { - logger.error(`Failed to do local user auth exchange: ${e.message}`, { - label: 'EmbyConnect.EmbyServer API', - ip: this.ClientIP, - }); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); + logger.debug('Failed local user auth exchange'); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidCredentials); } } } diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 75140bf05..45a3ee568 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -47,7 +47,8 @@ class ExternalAPI { endpoint: string, params?: Record, ttl?: number, - config?: RequestInit + config?: RequestInit, + overwriteBaseUrl?: string ): Promise { const cacheKey = this.serializeCacheKey(endpoint, { ...this.params, @@ -58,7 +59,7 @@ class ExternalAPI { return cachedItem; } - const url = this.formatUrl(endpoint, params); + const url = this.formatUrl(endpoint, params, overwriteBaseUrl); const response = await this.fetch(url, { ...config, headers: { diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 7e8475955..9f739fae1 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -2,7 +2,9 @@ import EmbyConnectAPI from '@server/api/embyconnect'; import ExternalAPI from '@server/api/externalapi'; import { ApiErrorCode } from '@server/constants/error'; +import { MediaServerType } from '@server/constants/server'; import availabilitySync from '@server/lib/availabilitySync'; +import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { ApiError } from '@server/types/error'; import { getAppVersion } from '@server/utils/appVersion'; @@ -175,13 +177,20 @@ class JellyfinAPI extends ExternalAPI { } } - if (Username && EmailValidator.validate(Username)) { + const settings = getSettings(); + + if ( + settings.main.mediaServerType === MediaServerType.EMBY && + Username && + EmailValidator.validate(Username) + ) { try { - return await this.authenticateWithEmbyConnect( - ClientIP, - Username, - Password - ); + const connectApi = new EmbyConnectAPI({ + ClientIP: ClientIP, + DeviceId: this.deviceId, + }); + + return await connectApi.authenticateConnectUser(Username, Password); } catch (e) { logger.debug(`Emby Connect authentication failed: ${e}`); throw new ApiError(e.cause?.status, ApiErrorCode.InvalidCredentials); @@ -191,18 +200,6 @@ class JellyfinAPI extends ExternalAPI { } } - private async authenticateWithEmbyConnect( - ClientIP: string | undefined, - Username: string | undefined, - Password: string | undefined - ): Promise { - const connectApi = new EmbyConnectAPI({ - ClientIP: ClientIP, - DeviceId: this.deviceId, - }); - return await connectApi.authenticateConnectUser(Username, Password); - } - public setUserId(userId: string): void { this.userId = userId; return; From a5979933f8a6917a8dbb1dd466cd8537f6c8a8d4 Mon Sep 17 00:00:00 2001 From: Hermanus Engelbrecht Date: Wed, 28 Aug 2024 19:45:20 +0200 Subject: [PATCH 3/3] refactor: resolving PR comments Reverting get() baseUrl overwrite in favour of a vanilla fetch() call #943 --- server/api/embyconnect.ts | 37 +++++++++++++++++++++++-------------- server/api/externalapi.ts | 5 ++--- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/server/api/embyconnect.ts b/server/api/embyconnect.ts index 455f4ce66..cb275cbf4 100644 --- a/server/api/embyconnect.ts +++ b/server/api/embyconnect.ts @@ -176,24 +176,33 @@ class EmbyConnectAPI extends ExternalAPI { deviceId?: string ): Promise { try { - return this.get( - '/emby/Connect/Exchange', + const params = new URLSearchParams({ + format: 'json', + ConnectUserId: userId, + 'X-Emby-Client': 'Jellyseerr', + 'X-Emby-Device-Id': deviceId ?? uniqueId(), + 'X-Emby-Client-Version': getAppVersion(), + 'X-Emby-Device-Name': 'Jellyseerr', + 'X-Emby-Token': accessKey, + }); + + const response = await fetch( + `${getHostname()}/emby/Connect/Exchange?${params}`, { - format: 'json', - ConnectUserId: userId, - 'X-Emby-Client': 'Jellyseerr', - 'X-Emby-Device-Id': deviceId ?? uniqueId(), - 'X-Emby-Client-Version': getAppVersion(), - 'X-Emby-Device-Name': 'Jellyseerr', - 'X-Emby-Token': accessKey, - }, - undefined, - {}, - getHostname() + headers: { + 'Content-Type': 'application/json', + }, + } ); + + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } + + return await response.json(); } catch (e) { logger.debug('Failed local user auth exchange'); - throw new ApiError(e.cause?.status, ApiErrorCode.InvalidCredentials); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } } diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 45a3ee568..75140bf05 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -47,8 +47,7 @@ class ExternalAPI { endpoint: string, params?: Record, ttl?: number, - config?: RequestInit, - overwriteBaseUrl?: string + config?: RequestInit ): Promise { const cacheKey = this.serializeCacheKey(endpoint, { ...this.params, @@ -59,7 +58,7 @@ class ExternalAPI { return cachedItem; } - const url = this.formatUrl(endpoint, params, overwriteBaseUrl); + const url = this.formatUrl(endpoint, params); const response = await this.fetch(url, { ...config, headers: {