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
This commit is contained in:
207
server/api/embyconnect.ts
Normal file
207
server/api/embyconnect.ts
Normal file
@@ -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<ConnectAuthResponse> {
|
||||
try {
|
||||
const textResponse = await this.post<string>(
|
||||
'/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<LinkedServer[]> {
|
||||
try {
|
||||
const textResponse = await this.get<string>(
|
||||
`/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<LocalUserAuthExchangeResponse> {
|
||||
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;
|
||||
@@ -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<string, string>)
|
||||
: 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(
|
||||
|
||||
@@ -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<JellyfinLoginResponse> {
|
||||
const connectApi = new EmbyConnectAPI({
|
||||
ClientIP: ClientIP,
|
||||
DeviceId: this.deviceId,
|
||||
});
|
||||
return await connectApi.authenticateConnectUser(Username, Password);
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
|
||||
Reference in New Issue
Block a user