Compare commits

...

18 Commits

Author SHA1 Message Date
Hermanus Engelbrecht
32d8c07daf refactor: revert changes to lock file by local formatter 2025-09-10 21:01:47 +02:00
Hermanus Engelbrecht
47cc0c6079 Merge remote-tracking branch 'origin' into feature-emby-connect-login 2025-09-10 20:52:18 +02:00
Hermanus Engelbrecht
8de83b5752 chore: address pnpm lock diff 2025-09-10 20:46:03 +02:00
Hermanus Engelbrecht
d9f8d200d9 feat: add Emby Connect Authentication Flow
This flow allows users to authneticate to JS using Emby Connect login details.

#943
2025-09-10 20:03:42 +02:00
Hermanus Engelbrecht
e39ad0acfa feat: emby Connect 2025-09-08 20:35:05 +02:00
Hermanus Engelbrecht
3936f3703f Merge branch 'develop' of github.com:fallenbagel/jellyseerr into feature-emby-connect-login 2025-03-30 08:26:37 +02:00
Hermanus Engelbrecht
23bc1bd44b Merge branch 'develop' of github.com:fallenbagel/jellyseerr into feature-emby-connect-login 2025-03-10 20:52:12 +01:00
Hermanus Engelbrecht
724727483c refactor: changing deviceId prefix from overseerr to jellyseerr 2025-03-01 16:32:26 +01:00
Hermanus Engelbrecht
527bb1c2da Merge branch 'develop' of github.com:fallenbagel/jellyseerr into feature-emby-connect-login 2025-03-01 11:25:41 +01:00
Hermanus Engelbrecht
7ae5eec648 feat(component and api): embyConnect Auth Flow
Adding ability for emby connect auth flow to link to local accounts

#943
2025-02-22 21:59:08 +01:00
Hermanus Engelbrecht
5cc6b0aeb9 feat(component and api): adding emby connect authentication with local account linking
Updated authentication flow to handle local account linking

#943
2025-02-22 20:52:00 +01:00
Hermanus Engelbrecht
7973ad0ecd feat(component and api): adding emby connect authentication with local account linking
Updated authentication flow to handle local account linking

#943
2025-02-22 20:39:28 +01:00
Hermanus Engelbrecht
4c9b5484a8 Merge branch 'develop' of github.com:fallenbagel/jellyseerr into feature-emby-connect-login 2025-02-22 19:20:42 +01:00
Hermanus Engelbrecht
d1cec17e0b feat: fixing merge conflicts, adding user linking for emby connect
#943
2025-02-22 19:11:37 +01:00
Hermanus Engelbrecht
844bfe65a2 feat: merging develop into #943 2025-02-22 09:02:25 +01:00
Hermanus Engelbrecht
a5979933f8 refactor: resolving PR comments
Reverting get() baseUrl overwrite in favour of a vanilla fetch() call

#943
2024-08-28 19:45:20 +02:00
Hermanus Engelbrecht
0e588bf315 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
2024-08-27 15:27:53 +02:00
Hermanus Engelbrecht
f5b3b06dfe 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
2024-08-24 13:36:16 +02:00
6 changed files with 334 additions and 44 deletions

3
.gitignore vendored
View File

@@ -71,3 +71,6 @@ tsconfig.tsbuildinfo
# Config Cache Directory
config/cache
# Mise
mise.toml

211
server/api/embyconnect.ts Normal file
View File

@@ -0,0 +1,211 @@
import ExternalAPI from '@server/api/externalapi';
import { ApiErrorCode } from '@server/constants/error';
import { MediaServerType } from '@server/constants/server';
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()}`,
'Content-Type': 'application/json',
Accept: 'application/json',
...(getSettings().main.mediaServerType === MediaServerType.EMBY &&
{}),
},
}
);
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,
Email: connectAuthResponse.User.Email,
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 response = await this.post<ConnectAuthResponse>(
'/service/user/authenticate',
{ nameOrEmail: Email, rawpw: Password },
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
return response;
} catch (e) {
logger.debug(`Failed to authenticate using EmbyConnect:`, {
label: 'EmbyConnect API',
ip: this.ClientIP,
error: e.message,
});
throw new ApiError(
e.cause?.status ?? 401,
ApiErrorCode.InvalidCredentials
);
}
}
private async getValidServers(
ConnectUserId: string,
AccessToken: string
): Promise<LinkedServer[]> {
try {
const response = await this.get<LinkedServer[]>(`/service/servers`, {
params: { userId: ConnectUserId },
headers: {
'X-Connect-UserToken': AccessToken,
},
});
return response;
} catch (e) {
logger.error(`Failed to retrieve EmbyConnect user server list: `, {
label: 'EmbyConnect API',
ip: this.ClientIP,
error: e.message,
});
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<LocalUserAuthExchangeResponse> {
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', e.cause);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
}
}
export default EmbyConnectAPI;

View File

@@ -3,6 +3,7 @@ import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
import querystring from 'querystring';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
@@ -82,6 +83,7 @@ class ExternalAPI {
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
headers: config?.headers,
...(data ? { data } : {}),
});
@@ -90,7 +92,16 @@ class ExternalAPI {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
const isFormUrlEncoded = (
config?.headers?.['Content-Type'] as string
)?.includes('application/x-www-form-urlencoded');
const body =
data && isFormUrlEncoded
? querystring.stringify(data as Record<string, string>)
: data;
const response = await this.axios.post<T>(endpoint, body, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);

View File

@@ -1,4 +1,5 @@
/* 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';
@@ -7,9 +8,11 @@ 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;
Email?: string;
ServerId: string;
ServerName: string;
Id: string;
@@ -119,6 +122,7 @@ export interface JellyfinItemsReponse {
class JellyfinAPI extends ExternalAPI {
private userId?: string;
private deviceId?: string;
private mediaServerType: MediaServerType;
constructor(
@@ -150,7 +154,7 @@ class JellyfinAPI extends ExternalAPI {
},
}
);
this.deviceId = deviceId ? deviceId : undefined;
this.mediaServerType = settings.main.mediaServerType;
}
@@ -173,6 +177,31 @@ class JellyfinAPI extends ExternalAPI {
);
};
if (
getSettings().main.mediaServerType === MediaServerType.EMBY &&
Username &&
EmailValidator.validate(Username)
) {
try {
const connectApi = new EmbyConnectAPI({
ClientIP: ClientIP,
DeviceId: this.deviceId,
});
return await connectApi.authenticateConnectUser(Username, Password);
} catch (e) {
// Possible local Emby user with email as username
logger.warn(
`Emby Connect authentication failed: ${e}, attempting local Emby server authentication`,
{
label: 'Jellyfin API',
error:
e.cause?.message ?? e.cause?.statusText ?? ApiErrorCode.Unknown,
ip: ClientIP,
}
);
}
}
try {
return await authenticate(true);
} catch (e) {
@@ -245,9 +274,9 @@ class JellyfinAPI extends ExternalAPI {
public async getUsers(): Promise<JellyfinUserListResponse> {
try {
const userReponse = await this.get<JellyfinUserResponse[]>(`/Users`);
const userResponse = await this.get<JellyfinUserResponse[]>(`/Users`);
return { users: userReponse };
return { users: userResponse };
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
@@ -260,10 +289,10 @@ class JellyfinAPI extends ExternalAPI {
public async getUser(): Promise<JellyfinUserResponse> {
try {
const userReponse = await this.get<JellyfinUserResponse>(
const userResponse = await this.get<JellyfinUserResponse>(
`/Users/${this.userId ?? 'Me'}`
);
return userReponse;
return userResponse;
} catch (e) {
logger.error(
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,

View File

@@ -42,7 +42,6 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
user.warnings.push('userEmailRequired');
logger.warn(`User ${user.username} has no valid email address`);
}
return res.status(200).json(user);
});
@@ -416,25 +415,34 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {
const serverType =
settings.main.mediaServerType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN
: ServerType.EMBY;
const userType =
serverType === ServerType.JELLYFIN ? UserType.JELLYFIN : UserType.EMBY;
logger.info(
`Found matching ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN
: ServerType.EMBY
} user; updating user with ${
settings.main.mediaServerType === MediaServerType.JELLYFIN
? ServerType.JELLYFIN
: ServerType.EMBY
}`,
`Found matching ${serverType} user; updating user with ${serverType}`,
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user.userType = userType;
user.avatar = getUserAvatarUrl(user);
user.jellyfinUsername = account.User.Name;
if (
account.User.Email !== undefined &&
user.email !== account.User.Email
) {
user.email = account.User.Email;
}
if (user.username === account.User.Name) {
user.username = '';
}
@@ -455,34 +463,59 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
message: 'Access denied.',
});
} else if (!user) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating new Jellyseerr user',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword = body.password && body.password.length > 0;
if (passedExplicitPassword) {
await user.setPassword(body.password ?? '');
// Handle Emby Connect user with unlinked local account
if (
settings.main.mediaServerType === MediaServerType.EMBY &&
account.User.Email &&
account.User.Email.trim() !== ''
) {
user = await userRepository.findOne({
where: { email: account.User.Email },
});
}
if (user) {
logger.info(
`Sign in attempt from EmbyConnect user with access to the media server, linking users`
);
user.avatar = getUserAvatarUrl(user);
user.jellyfinUserId = account.User.Id;
user.userType = UserType.EMBY;
user.username = account.User.Name;
await userRepository.save(user);
// No user, create new
} else {
logger.info(
'Sign-in attempt from Jellyfin/Emby user with access to the media server; creating new Jellyseerr user',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
user = new User({
email: body.email,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
permissions: settings.main.defaultPermissions,
userType:
settings.main.mediaServerType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
user.avatar = getUserAvatarUrl(user);
//initialize Jellyfin/Emby users with local login
const passedExplicitPassword =
body.password && body.password.length > 0;
if (passedExplicitPassword) {
await user.setPassword(body.password ?? '');
}
await userRepository.save(user);
}
await userRepository.save(user);
}
if (user && user.jellyfinUserId) {

View File

@@ -14,6 +14,7 @@ import * as Yup from 'yup';
const messages = defineMessages('components.Login', {
loginwithapp: 'Login with {appName}',
username: 'Username',
email: 'Email Address',
password: 'Password',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
@@ -125,7 +126,9 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
placeholder={`${intl.formatMessage(
messages.email
)} / ${intl.formatMessage(messages.username)}`}
className="!bg-gray-700/80 placeholder:text-gray-400"
data-form-type="username"
/>