diff --git a/server/api/embyconnect.ts b/server/api/embyconnect.ts new file mode 100644 index 000000000..cb275cbf4 --- /dev/null +++ b/server/api/embyconnect.ts @@ -0,0 +1,210 @@ +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 localUserExchangeResponse = await this.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; + } + + private async localAuthExchange( + accessKey: string, + userId: string, + deviceId?: string + ): Promise { + try { + 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}`, + { + 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.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..9f739fae1 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -1,10 +1,14 @@ /* 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 { 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'; +import * as EmailValidator from 'email-validator'; export interface JellyfinUserResponse { Name: string; @@ -94,6 +98,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 +117,7 @@ class JellyfinAPI extends ExternalAPI { }, } ); + this.deviceId = deviceId; } public async login( @@ -169,8 +175,28 @@ class JellyfinAPI extends ExternalAPI { if (networkErrorCodes.has(e.code) || status === 404) { throw new ApiError(status, ApiErrorCode.InvalidUrl); } + } + + const settings = getSettings(); + + if ( + settings.main.mediaServerType === MediaServerType.EMBY && + Username && + EmailValidator.validate(Username) + ) { + try { + const connectApi = new EmbyConnectAPI({ + ClientIP: ClientIP, + DeviceId: this.deviceId, + }); - throw new ApiError(status, ApiErrorCode.InvalidCredentials); + return await connectApi.authenticateConnectUser(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); } }