diff --git a/seerr-api.yml b/seerr-api.yml index bf9d8827..918f92f8 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -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 diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index c5f79258..817a22f1 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -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 { + try { + const response = await this.post( + '/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 { + try { + const response = await this.get( + '/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 { + try { + const response = await this.post( + '/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; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 9f670f3c..b42723d6 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -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); diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 1cbd81f8..2ab33264 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -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 = ({ 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 = ({ ); }} + { +
+ +
+ } + + {showQuickConnect && ( + setShowQuickConnect(false)} + onAuthenticated={() => { + setShowQuickConnect(false); + revalidate(); + }} + onError={(error) => { + toasts.addToast(error, { + autoDismiss: true, + appearance: 'error', + }); + }} + mediaServerName={mediaServerFormatValues.mediaServerName} + /> + )} ); }; diff --git a/src/components/Login/JellyfinQuickConnectModal.tsx b/src/components/Login/JellyfinQuickConnectModal.tsx new file mode 100644 index 00000000..082c6512 --- /dev/null +++ b/src/components/Login/JellyfinQuickConnectModal.tsx @@ -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(''); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const pollingInterval = useRef(); + 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 ( + + + {isLoading && ( +
+ +
+ )} + + {!isLoading && !hasError && !isExpired && ( +
+

+ {intl.formatMessage(messages.instructions, { + mediaServerName, + })} +

+ +
+
+ + {code} + +
+
+ +
+
+ +
+ {intl.formatMessage(messages.waitingForAuth)} +
+
+ )} + + {hasError && ( +
+
+

+ {intl.formatMessage(messages.error)} +

+

+ {intl.formatMessage(messages.errorMessage)} +

+
+
+ )} + + {isExpired && ( +
+
+

+ {intl.formatMessage(messages.expired)} +

+

+ {intl.formatMessage(messages.expiredMessage)} +

+
+
+ )} +
+
+ ); +}; + +export default JellyfinQuickConnectModal;