feat: add jellyfin/emby quick connect authentication
Implements a quick connect authentication flow for jellyfin and emby servers. fix #1595
This commit is contained in:
@@ -3984,6 +3984,85 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
/auth/jellyfin/quickconnect/initiate:
|
||||
post:
|
||||
summary: Initiate Jellyfin Quick Connect
|
||||
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: Quick Connect session initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: '123456'
|
||||
secret:
|
||||
type: string
|
||||
example: 'abc123def456'
|
||||
'500':
|
||||
description: Failed to initiate Quick Connect
|
||||
/auth/jellyfin/quickconnect/check:
|
||||
get:
|
||||
summary: Check Quick Connect authorization status
|
||||
description: Checks if the Quick Connect code has been authorized by the user.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: query
|
||||
name: secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The secret returned from the initiate endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Authorization status returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
example: false
|
||||
'404':
|
||||
description: Quick Connect session not found or expired
|
||||
/auth/jellyfin/quickconnect/authenticate:
|
||||
post:
|
||||
summary: Authenticate with Quick Connect
|
||||
description: Completes the Quick Connect authentication flow and creates a user session.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'403':
|
||||
description: Quick Connect not authorized or access denied
|
||||
'500':
|
||||
description: Authentication failed
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectInitiateResponse {
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectStatusResponse {
|
||||
Authenticated: boolean;
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
AppName: string;
|
||||
AppVersion: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
@@ -216,6 +233,62 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
||||
try {
|
||||
const response = await this.post<QuickConnectInitiateResponse>(
|
||||
'/QuickConnect/Initiate'
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuickConnect(
|
||||
secret: string
|
||||
): Promise<QuickConnectStatusResponse> {
|
||||
try {
|
||||
const response = await this.get<QuickConnectStatusResponse>(
|
||||
'/QuickConnect/Connect',
|
||||
{ params: { secret } }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async authenticateQuickConnect(
|
||||
secret: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
try {
|
||||
const response = await this.post<JellyfinLoginResponse>(
|
||||
'/Users/AuthenticateWithQuickConnect',
|
||||
{ Secret: secret }
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
return;
|
||||
|
||||
@@ -594,6 +594,177 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.initiateQuickConnect();
|
||||
|
||||
return res.status(200).json({
|
||||
code: response.Code,
|
||||
secret: response.Secret,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Jellyfin quick connect', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to initiate quick connect.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
|
||||
const secret = req.query.secret as string;
|
||||
|
||||
if (!secret || typeof secret !== 'string') {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.checkQuickConnect(secret);
|
||||
|
||||
return res.status(200).json({ authenticated: response.Authenticated });
|
||||
} catch (e) {
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: 'Failed to check Quick Connect status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post(
|
||||
'/jellyfin/quickconnect/authenticate',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { secret?: string };
|
||||
|
||||
if (!body.secret) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
|
||||
!(await userRepository.count())
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Quick Connect is not available during initial setup.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const account = await jellyfinServer.authenticateQuickConnect(
|
||||
body.secret
|
||||
);
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
});
|
||||
|
||||
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
if (user) {
|
||||
logger.info('Quick Connect sign-in from existing user', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
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);
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Set session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error('Quick Connect authentication failed', {
|
||||
label: 'Auth',
|
||||
error: e.message,
|
||||
ip: req.ip,
|
||||
});
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: ApiErrorCode.InvalidCredentials,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
@@ -25,6 +30,8 @@ const messages = defineMessages('components.Login', {
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
quickconnect: 'Quick Connect',
|
||||
quickconnecterror: 'Quick Connect failed. Please try again.',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
@@ -39,6 +46,7 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const [showQuickConnect, setShowQuickConnect] = useState(false);
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
@@ -194,6 +202,36 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
{
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowQuickConnect(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickconnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
{showQuickConnect && (
|
||||
<JellyfinQuickConnectModal
|
||||
onClose={() => setShowQuickConnect(false)}
|
||||
onAuthenticated={() => {
|
||||
setShowQuickConnect(false);
|
||||
revalidate();
|
||||
}}
|
||||
onError={(error) => {
|
||||
toasts.addToast(error, {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
}}
|
||||
mediaServerName={mediaServerFormatValues.mediaServerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
239
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
239
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import axios from 'axios';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
|
||||
title: 'Quick Connect',
|
||||
subtitle: 'Sign in with Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
|
||||
cancel: 'Cancel',
|
||||
tryAgain: 'Try Again',
|
||||
});
|
||||
|
||||
interface JellyfinQuickConnectModalProps {
|
||||
onClose: () => void;
|
||||
onAuthenticated: () => void;
|
||||
onError: (error: string) => void;
|
||||
mediaServerName: string;
|
||||
}
|
||||
|
||||
const JellyfinQuickConnectModal = ({
|
||||
onClose,
|
||||
onAuthenticated,
|
||||
onError,
|
||||
mediaServerName,
|
||||
}: JellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const pollingInterval = useRef<NodeJS.Timeout>();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const authenticateWithQuickConnect = useCallback(
|
||||
async (secret: string) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', {
|
||||
secret,
|
||||
});
|
||||
if (!isMounted.current) return;
|
||||
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
let errorMessage = intl.formatMessage(messages.errorMessage);
|
||||
|
||||
switch (error?.response?.data?.message) {
|
||||
case ApiErrorCode.InvalidCredentials:
|
||||
errorMessage = 'Quick Connect authorization failed';
|
||||
break;
|
||||
}
|
||||
|
||||
onError(errorMessage);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onAuthenticated, onClose, onError, intl]
|
||||
);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(secret: string) => {
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'/api/v1/auth/jellyfin/quickconnect/check',
|
||||
{
|
||||
params: { secret },
|
||||
}
|
||||
);
|
||||
|
||||
if (!isMounted.current) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.authenticated) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
|
||||
await authenticateWithQuickConnect(secret);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setIsExpired(true);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[authenticateWithQuickConnect]
|
||||
);
|
||||
|
||||
const initiateQuickConnect = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsExpired(false);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/api/v1/auth/jellyfin/quickconnect/initiate'
|
||||
);
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCode(response.data.code);
|
||||
setIsLoading(false);
|
||||
|
||||
startPolling(response.data.secret);
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
onError(intl.formatMessage(messages.errorMessage));
|
||||
}
|
||||
}, [startPolling, onError, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
initiateQuickConnect();
|
||||
}, [initiateQuickConnect]);
|
||||
|
||||
const handleTryAgain = () => {
|
||||
initiateQuickConnect();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={onClose}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.cancel)}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: handleTryAgain,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">
|
||||
{intl.formatMessage(messages.instructions, {
|
||||
mediaServerName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.errorMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinQuickConnectModal;
|
||||
Reference in New Issue
Block a user