From f5b3b06dfebe30eb1d7a3b2d179652caa0d2c450 Mon Sep 17 00:00:00 2001 From: Hermanus Engelbrecht Date: Sat, 24 Aug 2024 13:36:16 +0200 Subject: [PATCH] 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 | 33 +++++- 3 files changed, 257 insertions(+), 7 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 00000000..b59f1cbc --- /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 4f0ded02..75140bf0 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 f6550347..7e847595 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,9 +173,34 @@ 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 {