Files
ReadMeABook/src/lib/services/auth/PlexAuthProvider.ts
T
2026-01-28 11:41:24 -05:00

262 lines
7.0 KiB
TypeScript

/**
* Plex Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getBaseUrl } from '@/lib/utils/url';
import { prisma } from '@/lib/db';
export class PlexAuthProvider implements IAuthProvider {
type: 'plex' = 'plex';
private plexService = getPlexService();
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate Plex OAuth login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
// Request a PIN from Plex
const pin = await this.plexService.requestPin();
// Generate OAuth URL
const baseCallbackUrl = process.env.PLEX_OAUTH_CALLBACK_URL ||
`${getBaseUrl()}/api/auth/plex/callback`;
const oauthUrl = this.plexService.getOAuthUrl(pin.code, pin.id, baseCallbackUrl);
return {
redirectUrl: oauthUrl,
pinId: pin.id.toString(),
};
} catch (error) {
console.error('[PlexAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate Plex authentication');
}
}
/**
* Handle OAuth callback - check PIN status and complete authentication
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { pinId } = params;
if (!pinId) {
return {
success: false,
error: 'Missing PIN ID',
};
}
// Check PIN status
const authToken = await this.plexService.checkPin(parseInt(pinId, 10));
if (!authToken) {
// Still waiting for user authorization
return {
success: false,
error: 'Waiting for user authorization',
};
}
// Get user info from Plex
const plexUser = await this.plexService.getUserInfo(authToken);
if (!plexUser || !plexUser.id || !plexUser.username) {
return {
success: false,
error: 'Failed to get user information from Plex',
};
}
// Verify user has access to configured server
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return {
success: false,
error: 'Plex server is not configured',
};
}
const hasAccess = await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
authToken
);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this Plex server',
};
}
// Check for Plex Home profiles
const homeUsers = await this.plexService.getHomeUsers(authToken);
if (homeUsers.length > 1) {
// Multiple profiles - need profile selection
return {
success: true,
requiresProfileSelection: true,
profiles: homeUsers,
};
}
// No additional profiles - create/update user with main account
const userInfo = await this.createOrUpdateUser(
plexUser.id.toString(),
plexUser.username,
plexUser.email,
plexUser.thumb,
authToken,
null // No home profile
);
// Generate JWT tokens
const tokens = await this.generateTokens(userInfo);
return {
success: true,
user: userInfo,
tokens,
};
} catch (error) {
console.error('[PlexAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Refresh JWT tokens
*/
async refreshToken(refreshToken: string): Promise<AuthTokens | null> {
// JWT refresh is handled by existing JWT utilities
// This method is a placeholder for future implementation
return null;
}
/**
* Validate user has access to the server
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
const plexConfig = await this.configService.getPlexConfig();
if (!plexConfig.serverUrl || !plexConfig.machineIdentifier) {
return false;
}
// Get user's Plex token from database
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || !user.authToken) {
return false;
}
// Decrypt token
const decryptedToken = this.encryptionService.decrypt(user.authToken);
// Verify server access
return await this.plexService.verifyServerAccess(
plexConfig.serverUrl,
plexConfig.machineIdentifier,
decryptedToken
);
} catch (error) {
console.error('[PlexAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
plexId: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
authToken: string,
homeUserId: string | null
): Promise<UserInfo> {
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user in database
const user = await prisma.user.upsert({
where: { plexId },
create: {
plexId,
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
authToken: this.encryptionService.encrypt(authToken),
authProvider: 'plex',
plexHomeUserId: homeUserId,
lastLoginAt: new Date(),
},
});
return {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
};
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo): Promise<AuthTokens> {
const accessToken = generateAccessToken({
sub: userInfo.id,
plexId: userInfo.id, // For backwards compatibility
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
});
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
}