Files
ReadMeABook/src/lib/services/auth/OIDCAuthProvider.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

578 lines
17 KiB
TypeScript

/**
* 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';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('OIDCAuth');
// 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();
// Clean up expired states first
this.cleanupExpiredStates();
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(),
});
// 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) {
logger.error('Failed to initiate login', { error: error instanceof Error ? error.message : String(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();
logger.debug('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) {
// Check if this is the first user - they should bypass approval
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
if (!isFirstUser) {
// Not the first user - create pending user requiring approval
await this.createPendingUser(userinfo.sub, username, email, avatarUrl);
return {
success: false,
requiresApproval: true,
};
}
// First user - continue to create them as approved admin (bypass approval)
}
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) {
logger.error('Callback failed', { error: error instanceof Error ? error.message : String(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) {
logger.error('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) {
logger.info('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 => {
logger.error('Failed to trigger initial jobs', { error: err instanceof Error ? err.message : String(err) });
});
}
}
return {
userInfo: {
id: user.id,
username: user.plexUsername,
email: user.plexEmail || undefined,
avatarUrl: user.avatarUrl || undefined,
isAdmin: user.role === 'admin',
authProvider: 'oidc',
},
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' },
});
logger.info('Triggering initial jobs...');
// Trigger Audible refresh
if (audibleJob) {
await schedulerService.triggerJobNow(audibleJob.id);
logger.info('Triggered Audible refresh job');
} else {
logger.warn('Audible refresh job not found');
}
// Trigger Library scan
if (libraryJob) {
await schedulerService.triggerJobNow(libraryJob.id);
logger.info('Triggered Library scan job');
} else {
logger.warn('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' },
});
logger.info('Initial jobs triggered successfully');
} catch (error) {
logger.error('Error triggering initial jobs', { error: error instanceof Error ? error.message : String(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) {
logger.error('Access validation failed', { error: error instanceof Error ? error.message : String(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);
}
}
}
}