Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
/**
* Auth Provider Interface
* Documentation: documentation/features/audiobookshelf-integration.md
*/
export interface UserInfo {
id: string; // User UUID
plexId?: string; // Plex ID, OIDC subject, or local username
username: string;
email?: string;
avatarUrl?: string;
role?: string; // 'admin' | 'user'
isAdmin?: boolean; // Deprecated: use role instead
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginInitiation {
redirectUrl?: string; // For OAuth/OIDC flows
pinId?: string; // For Plex PIN flow
state?: string; // CSRF state token
}
export interface CallbackParams {
code?: string; // Authorization code
state?: string; // CSRF state
pinId?: string; // Plex PIN
error?: string;
[key: string]: any; // Allow additional params like username, password
}
export interface AuthResult {
success: boolean;
user?: UserInfo;
tokens?: AuthTokens;
error?: string;
requiresApproval?: boolean; // For pending approval flow
requiresProfileSelection?: boolean; // For Plex Home
profiles?: any[]; // Plex Home profiles
isFirstLogin?: boolean; // First user login (initial jobs will run)
}
export interface IAuthProvider {
type: 'plex' | 'oidc' | 'local';
// Auth initiation
initiateLogin(): Promise<LoginInitiation>;
// Auth completion
handleCallback(params: CallbackParams): Promise<AuthResult>;
// Token refresh
refreshToken(refreshToken: string): Promise<AuthTokens | null>;
// Validation
validateAccess(userInfo: UserInfo): Promise<boolean>;
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Local Auth Provider (Username/Password)
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import bcrypt from 'bcrypt';
import {
IAuthProvider,
LoginInitiation,
CallbackParams,
AuthResult,
UserInfo,
AuthTokens,
} from './IAuthProvider';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getConfigService } from '@/lib/services/config.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { prisma } from '@/lib/db';
interface LocalLoginParams extends CallbackParams {
username: string;
password: string;
}
interface RegisterParams {
username: string;
password: string;
}
export class LocalAuthProvider implements IAuthProvider {
type: 'local' = 'local';
private configService = getConfigService();
private encryptionService = getEncryptionService();
/**
* Initiate login (no-op for local auth)
*/
async initiateLogin(): Promise<LoginInitiation> {
// Local auth doesn't need initiation - return empty
return {};
}
/**
* Handle login with username/password
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { username, password } = params as LocalLoginParams;
if (!username || !password) {
return { success: false, error: 'Username and password required' };
}
// Find user
const user = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (!user) {
return { success: false, error: 'Invalid username or password' };
}
// Check registration status
if (user.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (user.registrationStatus === 'rejected') {
return { success: false, error: 'Account has been rejected' };
}
// Verify password
let passwordValid = false;
try {
// Decrypt the stored hash
const decryptedHash = this.encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
console.error('[LocalAuthProvider] Password verification failed:', error);
return { success: false, error: 'Invalid username or password' };
}
if (!passwordValid) {
return { success: false, error: 'Invalid username or password' };
}
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate tokens
console.log('[LocalAuthProvider] Generating tokens for user:', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
authProvider: user.authProvider,
});
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
console.log('[LocalAuthProvider] Tokens generated, returning user data');
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Login failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Register a new user
*/
async register(params: RegisterParams): Promise<AuthResult> {
try {
const { username, password } = params;
// Validate
if (!username || username.length < 3) {
return { success: false, error: 'Username must be at least 3 characters' };
}
if (!password || password.length < 8) {
return { success: false, error: 'Password must be at least 8 characters' };
}
// Check if registration is enabled
const registrationEnabled = await this.configService.get('auth.registration_enabled');
if (registrationEnabled !== 'true') {
return { success: false, error: 'Registration is disabled' };
}
// Check username uniqueness
const existing = await prisma.user.findFirst({
where: {
plexUsername: username,
authProvider: 'local',
},
});
if (existing) {
return { success: false, error: 'Username already taken' };
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Encrypt the hash before storing
const encryptedHash = this.encryptionService.encrypt(passwordHash);
// Determine registration status
const requireApproval = (await this.configService.get('auth.require_admin_approval')) === 'true';
const registrationStatus = requireApproval ? 'pending_approval' : 'approved';
// Check if first user (make admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
// Create user
const user = await prisma.user.create({
data: {
plexId: `local-${username}`,
plexUsername: username,
authToken: encryptedHash,
authProvider: 'local',
role: isFirstUser ? 'admin' : 'user',
isSetupAdmin: isFirstUser,
registrationStatus: isFirstUser ? 'approved' : registrationStatus,
lastLoginAt: new Date(),
},
});
// If requires approval and not first user, return pending status
if (requireApproval && !isFirstUser) {
return {
success: false,
requiresApproval: true,
};
}
// Generate tokens for immediate login
const tokens = await this.generateTokens({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
isAdmin: user.role === 'admin',
});
return {
success: true,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
},
tokens,
};
} catch (error) {
console.error('[LocalAuthProvider] Registration failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Registration failed',
};
}
}
/**
* Generate JWT access and refresh tokens
*/
private async generateTokens(userInfo: UserInfo & { plexId: string }): Promise<AuthTokens> {
const tokenPayload = {
sub: userInfo.id,
plexId: userInfo.plexId,
username: userInfo.username,
role: userInfo.isAdmin ? 'admin' : 'user',
};
console.log('[LocalAuthProvider] JWT token payload:', tokenPayload);
const accessToken = generateAccessToken(tokenPayload);
const refreshToken = generateRefreshToken(userInfo.id);
return {
accessToken,
refreshToken,
};
}
/**
* 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
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'local') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[LocalAuthProvider] Access validation failed:', error);
return false;
}
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* OIDC Auth Provider Implementation
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { Issuer, Client, generators } from 'openid-client';
import {
IAuthProvider,
UserInfo,
AuthTokens,
LoginInitiation,
CallbackParams,
AuthResult,
} from './IAuthProvider';
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 { getSchedulerService } from '@/lib/services/scheduler.service';
import { prisma } from '@/lib/db';
// In-memory storage for OIDC flow state (temporary until callback completes)
// In production, this could be replaced with Redis for multi-instance support
interface OIDCFlowState {
state: string;
nonce: string;
codeVerifier: string;
timestamp: number;
}
const flowStateCache = new Map<string, OIDCFlowState>();
const FLOW_STATE_TTL = 10 * 60 * 1000; // 10 minutes
export class OIDCAuthProvider implements IAuthProvider {
type: 'oidc' = 'oidc';
private configService = getConfigService();
private encryptionService = getEncryptionService();
private client: Client | null = null;
/**
* Get or create OIDC client
*/
private async getClient(): Promise<Client> {
if (this.client) return this.client;
const issuerUrl = await this.configService.get('oidc.issuer_url');
const clientId = await this.configService.get('oidc.client_id');
const clientSecret = await this.configService.get('oidc.client_secret');
if (!issuerUrl || !clientId || !clientSecret) {
throw new Error('OIDC is not fully configured');
}
// Discover OIDC endpoints
const issuer = await Issuer.discover(issuerUrl);
// Create client
this.client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [await this.getRedirectUri()],
response_types: ['code'],
});
return this.client;
}
/**
* Get redirect URI for OAuth callback
*/
private async getRedirectUri(): Promise<string> {
const baseUrl = getBaseUrl();
return `${baseUrl}/api/auth/oidc/callback`;
}
/**
* Initiate OIDC login flow
*/
async initiateLogin(): Promise<LoginInitiation> {
try {
const client = await this.getClient();
const state = generators.state();
const nonce = generators.nonce();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
// Store state in memory cache
flowStateCache.set(state, {
state,
nonce,
codeVerifier,
timestamp: Date.now(),
});
// Clean up expired states
this.cleanupExpiredStates();
// Generate authorization URL
const redirectUrl = client.authorizationUrl({
scope: 'openid profile email groups',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return {
redirectUrl,
state,
};
} catch (error) {
console.error('[OIDCAuthProvider] Failed to initiate login:', error);
throw new Error('Failed to initiate OIDC authentication');
}
}
/**
* Handle OIDC callback
*/
async handleCallback(params: CallbackParams): Promise<AuthResult> {
try {
const { code, state, error } = params;
if (error) {
return {
success: false,
error: `OIDC provider error: ${error}`,
};
}
if (!code || !state) {
return {
success: false,
error: 'Missing authorization code or state',
};
}
// Retrieve stored flow state
const flowState = flowStateCache.get(state);
if (!flowState) {
return {
success: false,
error: 'Invalid or expired state parameter',
};
}
// Clean up state after retrieval
flowStateCache.delete(state);
const client = await this.getClient();
const redirectUri = await this.getRedirectUri();
if (process.env.LOG_LEVEL === 'debug') {
console.debug('[OIDCAuthProvider] Exchanging code for tokens', {
redirectUri,
hasCode: !!code,
hasState: !!state,
stateMatches: state === flowState.state,
});
}
// Exchange code for tokens
const tokenSet = await client.callback(
redirectUri,
{ code, state },
{
code_verifier: flowState.codeVerifier,
nonce: flowState.nonce,
state: flowState.state,
}
);
if (!tokenSet.access_token) {
return {
success: false,
error: 'Failed to obtain access token',
};
}
// Get user info from OIDC provider
const userinfo = await client.userinfo(tokenSet.access_token);
if (!userinfo.sub) {
return {
success: false,
error: 'Invalid user info from OIDC provider',
};
}
// Check access control
const hasAccess = await this.checkAccessControl(userinfo);
if (!hasAccess) {
return {
success: false,
error: 'You do not have access to this application',
};
}
// Map OIDC claims to UserInfo
const username = (userinfo.preferred_username || userinfo.email || userinfo.sub) as string;
const email = userinfo.email as string | undefined;
const avatarUrl = userinfo.picture as string | undefined;
// Check admin role from claims
const isAdminFromClaim = await this.checkAdminClaim(userinfo);
// Check if admin approval required
const accessMethod = await this.configService.get('oidc.access_control_method');
if (accessMethod === 'admin_approval') {
const existingUser = await this.findUserByOIDCSubject(userinfo.sub);
if (!existingUser) {
// Create pending user
await this.createPendingUser(userinfo.sub, username, email, avatarUrl);
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'pending_approval') {
return {
success: false,
requiresApproval: true,
};
}
if (existingUser.registrationStatus === 'rejected') {
return {
success: false,
error: 'Your account has been rejected by an administrator',
};
}
}
// Create or update user
const result = await this.createOrUpdateUser(
userinfo.sub,
username,
email,
avatarUrl,
isAdminFromClaim
);
// Generate JWT tokens
const tokens = await this.generateTokens(result.userInfo);
return {
success: true,
user: result.userInfo,
tokens,
isFirstLogin: result.isFirstLogin,
};
} catch (error) {
console.error('[OIDCAuthProvider] Callback failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Authentication failed',
};
}
}
/**
* Check if user has access to the application
*/
private async checkAccessControl(userinfo: any): Promise<boolean> {
const method = await this.configService.get('oidc.access_control_method');
switch (method) {
case 'open':
return true;
case 'group_claim': {
const claimName = (await this.configService.get('oidc.access_group_claim')) || 'groups';
const requiredGroup = await this.configService.get('oidc.access_group_value');
if (!requiredGroup) {
console.error('[OIDCAuthProvider] Group claim access control enabled but no required group configured');
return false;
}
const userGroups = userinfo[claimName] || [];
if (Array.isArray(userGroups)) {
return userGroups.includes(requiredGroup);
}
return userGroups === requiredGroup;
}
case 'allowed_list': {
const allowedEmailsStr = await this.configService.get('oidc.allowed_emails');
const allowedUsernamesStr = await this.configService.get('oidc.allowed_usernames');
const allowedEmails = allowedEmailsStr ? JSON.parse(allowedEmailsStr) : [];
const allowedUsernames = allowedUsernamesStr ? JSON.parse(allowedUsernamesStr) : [];
return (
allowedEmails.includes(userinfo.email) ||
allowedUsernames.includes(userinfo.preferred_username)
);
}
case 'admin_approval':
// Admin approval check happens in handleCallback
return true;
default:
// If no method specified, default to open access
return true;
}
}
/**
* Check if user should be granted admin role from OIDC claims
*/
private async checkAdminClaim(userinfo: any): Promise<boolean> {
const enabled = await this.configService.get('oidc.admin_claim_enabled');
if (enabled !== 'true') {
return false;
}
const claimName = (await this.configService.get('oidc.admin_claim_name')) || 'groups';
const claimValue = await this.configService.get('oidc.admin_claim_value');
if (!claimValue) {
return false;
}
const userClaims = userinfo[claimName] || [];
if (Array.isArray(userClaims)) {
return userClaims.includes(claimValue);
}
return userClaims === claimValue;
}
/**
* Find user by OIDC subject
*/
private async findUserByOIDCSubject(oidcSubject: string) {
return await prisma.user.findFirst({
where: {
oidcSubject,
authProvider: 'oidc',
},
});
}
/**
* Create pending user (for admin approval flow)
*/
private async createPendingUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined
) {
const providerName = await this.configService.get('oidc.provider_name');
await prisma.user.create({
data: {
plexId: oidcSubject, // Use oidcSubject as unique identifier
plexUsername: username,
plexEmail: email || null,
role: 'user',
isSetupAdmin: false,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'pending_approval',
lastLoginAt: new Date(),
},
});
}
/**
* Create or update user in database
*/
private async createOrUpdateUser(
oidcSubject: string,
username: string,
email: string | undefined,
avatarUrl: string | undefined,
isAdminFromClaim: boolean
): Promise<{ userInfo: UserInfo; isFirstLogin: boolean }> {
const providerName = await this.configService.get('oidc.provider_name');
// 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 || isAdminFromClaim ? 'admin' : 'user';
// Create or update user
const user = await prisma.user.upsert({
where: { plexId: oidcSubject },
create: {
plexId: oidcSubject, // Use oidcSubject as plexId for unique constraint
plexUsername: username,
plexEmail: email || null,
role,
isSetupAdmin: isFirstUser,
avatarUrl: avatarUrl || null,
authProvider: 'oidc',
oidcSubject,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
},
update: {
plexUsername: username,
plexEmail: email || null,
avatarUrl: avatarUrl || null,
oidcProvider: providerName || 'unknown',
registrationStatus: 'approved',
lastLoginAt: new Date(),
// Update role if admin claim is present
...(isAdminFromClaim && { role: 'admin' }),
},
});
// Track if we need to trigger initial jobs
let shouldTriggerJobs = false;
// If this is the first user, trigger initial jobs (Audible refresh + Library scan)
// This happens after OIDC-only setup where no admin was created during wizard
if (isFirstUser) {
console.log('[OIDCAuthProvider] First OIDC user created - triggering initial jobs');
// Check if initial jobs have already been run (avoid duplicate runs)
const initialJobsRun = await this.configService.get('system.initial_jobs_run');
if (initialJobsRun !== 'true') {
shouldTriggerJobs = true;
// Trigger jobs in background (don't block authentication)
this.triggerInitialJobs().catch(err => {
console.error('[OIDCAuthProvider] Failed to trigger initial jobs:', err);
});
}
}
return {
userInfo: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
},
isFirstLogin: isFirstUser && shouldTriggerJobs,
};
}
/**
* Trigger initial jobs (Audible refresh + Library scan) after first OIDC login
* This is called automatically when the first user logs in via OIDC after setup
*/
private async triggerInitialJobs(): Promise<void> {
try {
const schedulerService = getSchedulerService();
// Get scheduled jobs by type
const audibleJob = await prisma.scheduledJob.findFirst({
where: { type: 'audible_refresh' },
});
const libraryJob = await prisma.scheduledJob.findFirst({
where: { type: 'plex_library_scan' },
});
console.log('[OIDCAuthProvider] Triggering initial jobs...');
// Trigger Audible refresh
if (audibleJob) {
await schedulerService.triggerJobNow(audibleJob.id);
console.log('[OIDCAuthProvider] Triggered Audible refresh job');
} else {
console.warn('[OIDCAuthProvider] Audible refresh job not found');
}
// Trigger Library scan
if (libraryJob) {
await schedulerService.triggerJobNow(libraryJob.id);
console.log('[OIDCAuthProvider] Triggered Library scan job');
} else {
console.warn('[OIDCAuthProvider] Library scan job not found');
}
// Mark initial jobs as run
await prisma.configuration.upsert({
where: { key: 'system.initial_jobs_run' },
update: { value: 'true' },
create: { key: 'system.initial_jobs_run', value: 'true' },
});
console.log('[OIDCAuthProvider] Initial jobs triggered successfully');
} catch (error) {
console.error('[OIDCAuthProvider] Error triggering initial jobs:', error);
throw error;
}
}
/**
* 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,
};
}
/**
* 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
*/
async validateAccess(userInfo: UserInfo): Promise<boolean> {
try {
// Check if user exists and is approved
const user = await prisma.user.findUnique({
where: { id: userInfo.id },
});
if (!user || user.authProvider !== 'oidc') {
return false;
}
if (user.registrationStatus === 'pending_approval' || user.registrationStatus === 'rejected') {
return false;
}
return true;
} catch (error) {
console.error('[OIDCAuthProvider] Access validation failed:', error);
return false;
}
}
/**
* Clean up expired flow states
*/
private cleanupExpiredStates(): void {
const now = Date.now();
for (const [state, flowState] of flowStateCache.entries()) {
if (now - flowState.timestamp > FLOW_STATE_TTL) {
flowStateCache.delete(state);
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
/**
* 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,
};
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Auth Provider Factory
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { IAuthProvider } from './IAuthProvider';
import { PlexAuthProvider } from './PlexAuthProvider';
import { OIDCAuthProvider } from './OIDCAuthProvider'; // Phase 3
import { LocalAuthProvider } from './LocalAuthProvider'; // Phase 4
import { getConfigService } from '@/lib/services/config.service';
export type AuthMethod = 'plex' | 'oidc' | 'local';
/**
* Get the appropriate auth provider based on backend mode and auth method
* @param method - Optional override for auth method (useful for multi-auth scenarios)
*/
export async function getAuthProvider(method?: AuthMethod): Promise<IAuthProvider> {
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Plex mode always uses Plex OAuth
if (backendMode === 'plex') {
return new PlexAuthProvider();
}
// Audiobookshelf mode - determine auth method
if (method) {
// Explicit method provided
if (method === 'oidc') {
return new OIDCAuthProvider();
} else if (method === 'local') {
return new LocalAuthProvider();
}
}
// Auto-detect from configuration
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
if (oidcEnabled) {
return new OIDCAuthProvider();
} else if (registrationEnabled) {
return new LocalAuthProvider();
}
// Fallback to Plex (shouldn't happen in normal flow)
return new PlexAuthProvider();
}
// Re-export types
export * from './IAuthProvider';
export { PlexAuthProvider } from './PlexAuthProvider';
export { OIDCAuthProvider } from './OIDCAuthProvider';
export { LocalAuthProvider } from './LocalAuthProvider';