Skip to content

Commit b374adf

Browse files
committed
feat: add jellyfin/emby quick connect authentication
Implements a quick connect authentication flow for jellyfin and emby servers. fix #1595
1 parent f4fe166 commit b374adf

File tree

5 files changed

+601
-1
lines changed

5 files changed

+601
-1
lines changed

seerr-api.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3984,6 +3984,85 @@ paths:
39843984
required:
39853985
- username
39863986
- password
3987+
/auth/jellyfin/quickconnect/initiate:
3988+
post:
3989+
summary: Initiate Jellyfin Quick Connect
3990+
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
3991+
security: []
3992+
tags:
3993+
- auth
3994+
responses:
3995+
'200':
3996+
description: Quick Connect session initiated
3997+
content:
3998+
application/json:
3999+
schema:
4000+
type: object
4001+
properties:
4002+
code:
4003+
type: string
4004+
example: '123456'
4005+
secret:
4006+
type: string
4007+
example: 'abc123def456'
4008+
'500':
4009+
description: Failed to initiate Quick Connect
4010+
/auth/jellyfin/quickconnect/check:
4011+
get:
4012+
summary: Check Quick Connect authorization status
4013+
description: Checks if the Quick Connect code has been authorized by the user.
4014+
security: []
4015+
tags:
4016+
- auth
4017+
parameters:
4018+
- in: query
4019+
name: secret
4020+
required: true
4021+
schema:
4022+
type: string
4023+
description: The secret returned from the initiate endpoint
4024+
responses:
4025+
'200':
4026+
description: Authorization status returned
4027+
content:
4028+
application/json:
4029+
schema:
4030+
type: object
4031+
properties:
4032+
authenticated:
4033+
type: boolean
4034+
example: false
4035+
'404':
4036+
description: Quick Connect session not found or expired
4037+
/auth/jellyfin/quickconnect/authenticate:
4038+
post:
4039+
summary: Authenticate with Quick Connect
4040+
description: Completes the Quick Connect authentication flow and creates a user session.
4041+
security: []
4042+
tags:
4043+
- auth
4044+
requestBody:
4045+
required: true
4046+
content:
4047+
application/json:
4048+
schema:
4049+
type: object
4050+
properties:
4051+
secret:
4052+
type: string
4053+
required:
4054+
- secret
4055+
responses:
4056+
'200':
4057+
description: Successfully authenticated
4058+
content:
4059+
application/json:
4060+
schema:
4061+
$ref: '#/components/schemas/User'
4062+
'403':
4063+
description: Quick Connect not authorized or access denied
4064+
'500':
4065+
description: Authentication failed
39874066
/auth/local:
39884067
post:
39894068
summary: Sign in using a local account

server/api/jellyfin.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
4444
AccessToken: string;
4545
}
4646

47+
export interface QuickConnectInitiateResponse {
48+
Secret: string;
49+
Code: string;
50+
DateAdded: string;
51+
}
52+
53+
export interface QuickConnectStatusResponse {
54+
Authenticated: boolean;
55+
Secret: string;
56+
Code: string;
57+
DeviceId: string;
58+
DeviceName: string;
59+
AppName: string;
60+
AppVersion: string;
61+
DateAdded: string;
62+
}
63+
4764
export interface JellyfinUserListResponse {
4865
users: JellyfinUserResponse[];
4966
}
@@ -212,6 +229,62 @@ class JellyfinAPI extends ExternalAPI {
212229
}
213230
}
214231

232+
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
233+
try {
234+
const response = await this.post<QuickConnectInitiateResponse>(
235+
'/QuickConnect/Initiate'
236+
);
237+
238+
return response;
239+
} catch (e) {
240+
logger.error(
241+
`Something went wrong while initiating Quick Connect: ${e.message}`,
242+
{ label: 'Jellyfin API', error: e.response?.status }
243+
);
244+
245+
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
246+
}
247+
}
248+
249+
public async checkQuickConnect(
250+
secret: string
251+
): Promise<QuickConnectStatusResponse> {
252+
try {
253+
const response = await this.get<QuickConnectStatusResponse>(
254+
'/QuickConnect/Connect',
255+
{ params: { secret } }
256+
);
257+
258+
return response;
259+
} catch (e) {
260+
logger.error(
261+
`Something went wrong while getting Quick Connect status: ${e.message}`,
262+
{ label: 'Jellyfin API', error: e.response?.status }
263+
);
264+
265+
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
266+
}
267+
}
268+
269+
public async authenticateQuickConnect(
270+
secret: string
271+
): Promise<JellyfinLoginResponse> {
272+
try {
273+
const response = await this.post<JellyfinLoginResponse>(
274+
'/Users/AuthenticateWithQuickConnect',
275+
{ Secret: secret }
276+
);
277+
return response;
278+
} catch (e) {
279+
logger.error(
280+
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
281+
{ label: 'Jellyfin API', error: e.response?.status }
282+
);
283+
284+
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
285+
}
286+
}
287+
215288
public setUserId(userId: string): void {
216289
this.userId = userId;
217290
return;

server/routes/auth.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,177 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
594594
}
595595
});
596596

597+
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
598+
try {
599+
const hostname = getHostname();
600+
const jellyfinServer = new JellyfinAPI(
601+
hostname ?? '',
602+
undefined,
603+
undefined
604+
);
605+
606+
const response = await jellyfinServer.initiateQuickConnect();
607+
608+
return res.status(200).json({
609+
code: response.Code,
610+
secret: response.Secret,
611+
});
612+
} catch (error) {
613+
logger.error('Error initiating Jellyfin quick connect', {
614+
label: 'Auth',
615+
errorMessage: error.message,
616+
});
617+
return next({
618+
status: 500,
619+
message: 'Failed to initiate quick connect.',
620+
});
621+
}
622+
});
623+
624+
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
625+
const secret = req.query.secret as string;
626+
627+
if (!secret || typeof secret !== 'string') {
628+
return next({
629+
status: 400,
630+
message: 'Secret required',
631+
});
632+
}
633+
634+
try {
635+
const hostname = getHostname();
636+
const jellyfinServer = new JellyfinAPI(
637+
hostname ?? '',
638+
undefined,
639+
undefined
640+
);
641+
642+
const response = await jellyfinServer.checkQuickConnect(secret);
643+
644+
return res.status(200).json({ authenticated: response.Authenticated });
645+
} catch (e) {
646+
return next({
647+
status: e.statusCode || 500,
648+
message: 'Failed to check Quick Connect status',
649+
});
650+
}
651+
});
652+
653+
authRoutes.post(
654+
'/jellyfin/quickconnect/authenticate',
655+
async (req, res, next) => {
656+
const settings = getSettings();
657+
const userRepository = getRepository(User);
658+
const body = req.body as { secret?: string };
659+
660+
if (!body.secret) {
661+
return next({
662+
status: 400,
663+
message: 'Secret required',
664+
});
665+
}
666+
667+
if (
668+
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
669+
!(await userRepository.count())
670+
) {
671+
return next({
672+
status: 403,
673+
message: 'Quick Connect is not available during initial setup.',
674+
});
675+
}
676+
677+
try {
678+
const hostname = getHostname();
679+
const jellyfinServer = new JellyfinAPI(
680+
hostname ?? '',
681+
undefined,
682+
undefined
683+
);
684+
685+
const account = await jellyfinServer.authenticateQuickConnect(
686+
body.secret
687+
);
688+
689+
let user = await userRepository.findOne({
690+
where: { jellyfinUserId: account.User.Id },
691+
});
692+
693+
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
694+
'base64'
695+
);
696+
697+
if (user) {
698+
logger.info('Quick Connect sign-in from existing user', {
699+
label: 'API',
700+
ip: req.ip,
701+
jellyfinUsername: account.User.Name,
702+
userId: user.id,
703+
});
704+
705+
user.jellyfinAuthToken = account.AccessToken;
706+
user.jellyfinDeviceId = deviceId;
707+
user.avatar = getUserAvatarUrl(user);
708+
await userRepository.save(user);
709+
} else if (!settings.main.newPlexLogin) {
710+
logger.warn(
711+
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
712+
{
713+
label: 'API',
714+
ip: req.ip,
715+
jellyfinUserId: account.User.Id,
716+
jellyfinUsername: account.User.Name,
717+
}
718+
);
719+
return next({
720+
status: 403,
721+
message: 'Access denied.',
722+
});
723+
} else {
724+
logger.info(
725+
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
726+
{
727+
label: 'API',
728+
ip: req.ip,
729+
jellyfinUsername: account.User.Name,
730+
}
731+
);
732+
733+
user = new User({
734+
email: account.User.Name,
735+
jellyfinUsername: account.User.Name,
736+
jellyfinUserId: account.User.Id,
737+
jellyfinDeviceId: deviceId,
738+
permissions: settings.main.defaultPermissions,
739+
userType:
740+
settings.main.mediaServerType === MediaServerType.JELLYFIN
741+
? UserType.JELLYFIN
742+
: UserType.EMBY,
743+
});
744+
user.avatar = getUserAvatarUrl(user);
745+
await userRepository.save(user);
746+
}
747+
748+
// Set session
749+
if (req.session) {
750+
req.session.userId = user.id;
751+
}
752+
753+
return res.status(200).json(user?.filter() ?? {});
754+
} catch (e) {
755+
logger.error('Quick Connect authentication failed', {
756+
label: 'Auth',
757+
error: e.message,
758+
ip: req.ip,
759+
});
760+
return next({
761+
status: e.statusCode || 500,
762+
message: ApiErrorCode.InvalidCredentials,
763+
});
764+
}
765+
}
766+
);
767+
597768
authRoutes.post('/local', async (req, res, next) => {
598769
const settings = getSettings();
599770
const userRepository = getRepository(User);

0 commit comments

Comments
 (0)