mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user