tududi/backend/modules/users/service.js
Chris c2e9a1aa21
feat: Add OIDC/SSO authentication support (#1008)
* feat: add OIDC/SSO database schema and models (Phase 1)

Add database foundation for OpenID Connect authentication:

Database Migrations:
- Create oidc_identities table (links users to OIDC accounts)
- Create oidc_state_nonces table (OAuth state/nonce for CSRF protection)
- Create auth_audit_log table (security event logging)
- Make password_digest nullable in users table (allow OIDC-only users)

Models:
- OIDCIdentity: Links users to external OIDC providers
- OIDCStateNonce: Temporary OAuth state management
- AuthAuditLog: Authentication event audit trail

Changes:
- Updated User model to allow null password_digest
- Added model associations in models/index.js
- All migrations tested and verified

Related to #977

* feat: add OIDC core services (Phase 2)

- Install openid-client@^6.2.0 for OIDC protocol support
- Implement providerConfig.js for loading providers from .env
  - Support single provider or numbered providers (OIDC_PROVIDER_1_*, etc.)
  - Auto-provision and admin email domain configuration
  - Provider caching for performance
- Implement stateManager.js for OAuth state/nonce management
  - CSRF protection with 10-minute TTL
  - One-time use state consumption
  - Automatic cleanup of expired states
- Implement auditService.js for authentication event logging
  - Track login success/failure, logout, OIDC linking/unlinking
  - Store IP address, user agent, and metadata
  - Support for event queries and retention cleanup
- Add comprehensive unit tests (60 tests, all passing)
  - providerConfig: 36 tests for env parsing and validation
  - stateManager: 12 tests for state lifecycle and security
  - auditService: 12 tests for event logging and queries

Phase 2 completes the backend core services needed for OIDC authentication.

* feat: implement OIDC authentication flow (Phase 3)

Core OIDC Flow (service.js):
- Provider discovery with issuer caching
- Authorization URL generation with state/nonce
- OAuth callback handling and token exchange
- ID token validation using openid-client
- Token refresh functionality

JIT User Provisioning (provisioningService.js):
- Auto-create users from OIDC claims
- Link existing email accounts to OIDC identities
- Admin role assignment based on email domain rules
- Automatic username generation from email
- Transaction-safe identity creation

Identity Management (oidcIdentityService.js):
- List user's linked OIDC identities
- Link additional providers to existing accounts
- Unlink identities with safety checks
- Prevent unlinking last auth method
- Update identity claims on login

HTTP Layer (controller.js + routes.js):
- GET /api/oidc/providers - List configured providers
- GET /api/oidc/auth/:slug - Initiate OIDC flow
- GET /api/oidc/callback/:slug - Handle OAuth callback
- POST /api/oidc/link/:slug - Link provider to current user
- DELETE /api/oidc/unlink/:id - Unlink identity
- GET /api/oidc/identities - Get user's identities

Integration:
- Register OIDC routes in Express app (public + authenticated)
- Update auth service to reject password login for OIDC-only users
- Audit logging for all OIDC operations
- Session creation on successful authentication

Security:
- State/nonce CSRF protection
- One-time use state consumption
- Transaction-safe user provisioning
- Foreign key constraints enforced

* feat: implement OIDC frontend login flow (Phase 4)

- Created OIDCProviderButtons component for SSO login options
- Created OIDCCallback component for OAuth callback handling
- Updated Login page to fetch and display OIDC providers
- Added /auth/callback/:provider route to App.tsx
- Added i18n translations for OIDC UI elements
- Downgraded openid-client to v5.7.0 (CommonJS compatibility)
- Fixed linting issues in backend OIDC modules

Phase 4 completes the frontend login flow for OIDC/SSO authentication.
Users can now see configured SSO providers on the login page.

* feat: implement OIDC account linking UI (Phase 5)

Add Connected Accounts section to Profile Security tab allowing users to:
- View linked OIDC provider accounts
- Link new SSO providers to their account
- Unlink OIDC identities with validation
- Prevent unlinking last authentication method

Backend changes:
- Add has_password virtual field to User model
- Include has_password in profile API response
- Track whether user has password set for validation

Frontend changes:
- Create oidcService for OIDC API operations
- Create ConnectedAccounts component with link/unlink flows
- Add confirmation dialog before unlinking accounts
- Validate that users cannot unlink their last auth method
- Show warning if user has no password set
- Integrate Connected Accounts into SecurityTab

User experience:
- View all linked SSO provider accounts with email and link date
- Link additional providers via "Link Provider" buttons
- Unlink with two-step confirmation to prevent accidents
- Clear error messages when unlinking would leave no auth method
- Warning message suggesting password setup for OIDC-only users

Fixes #977

* feat: complete OIDC documentation and UI improvements (Phase 6)

This commit completes Phase 6 of the OIDC/SSO implementation with comprehensive
documentation, bug fixes, and UI reorganization.

Documentation:
- Add comprehensive user guide at docs/10-oidc-sso.md with:
  - Setup guides for 6 major providers (Google, Okta, Keycloak, Authentik, PocketID, Azure AD)
  - Configuration examples for single and multiple providers
  - User features documentation (login, account linking, management)
  - Advanced topics (auto-provisioning, admin role assignment, hybrid auth)
  - Comprehensive troubleshooting section
  - Security considerations and best practices
- Update README.md with OIDC/SSO section and quick setup examples

Internationalization:
- Add i18n support to OIDCProviderButtons component
- Add translation keys for all OIDC UI text
- Update English translations with "sign_in_with" key

Bug Fixes:
- Fix oidcService.ts to correctly unwrap API responses
  - Backend returns {providers: [...]} and {identities: [...]}
  - Frontend was expecting plain arrays, causing "map is not a function" error
- Fix initiateOIDCLink to properly handle POST response

UI Improvements:
- Move OIDC/SSO to dedicated tab in profile settings
  - Create new OIDCTab component with green LinkIcon
  - Remove ConnectedAccounts from SecurityTab
  - Add OIDC tab between Security and API Keys tabs
  - Update ProfileSettings with new tab configuration
- Security tab now focuses solely on password management

Testing:
- All linting passes
- All tests pass (82 suites, 1223 tests)

Related to #977

* feat: add OIDC/SSO translations for all 24 languages

Add i18n support for OIDC/SSO features across all supported languages:
- "Sign in with {{provider}}" button text
- "OIDC/SSO" tab label in profile settings
- OIDC authentication flow messages

Translations added for: Arabic, Bulgarian, Danish, German, Greek, Spanish,
Finnish, French, Indonesian, Italian, Japanese, Korean, Dutch, Norwegian,
Polish, Portuguese, Romanian, Russian, Slovenian, Swedish, Turkish,
Ukrainian, Vietnamese, and Chinese.

* fix: resolve 13 CodeQL security alerts

This commit addresses critical security vulnerabilities identified by CodeQL scanning:

**Security Configuration (2 fixes)**
- Fix insecure Helmet configuration - enable CSP and HSTS in production
- Fix clear text cookie transmission - enable secure cookies in production

**Path Injection (3 fixes)**
- Add path validation in users/controller.js to prevent arbitrary file deletion
- Add path validation in users/service.js for avatar operations
- Add path sanitization in attachment-utils.js deleteFileFromDisk function

**Cross-Site Scripting (1 fix)**
- Fix XSS vulnerability in GeneralTab.tsx avatar URL handling
- Add URL sanitization to prevent javascript: protocol attacks

**URL Security (2 fixes)**
- Fix double escaping in url/service.js HTML entity decoding
- Fix incomplete URL sanitization for YouTube domain validation

**Denial of Service (1 fix)**
- Add loop bound protection in inboxProcessingService.js (10k char limit)

**Rate Limiting (3 fixes)**
- Add rate limiting to auth routes (register, verify-email)
- Add rate limiting to task attachment upload/delete endpoints
- Add rate limiting to user avatar upload/delete endpoints

**GitHub Actions Security (1 fix)**
- Add explicit read-only permissions to CI workflow

Note: CSRF middleware (#10) requires frontend changes and is tracked separately.

Relates to PR #1008

* fix: allow test files in path validation for tests

* fix: format long condition in attachment-utils for Prettier compliance

Break the path validation condition across multiple lines to meet Prettier formatting requirements and fix CI linting failure.

* fix: resolve CodeQL security alerts

- Add rate limiting to OIDC authentication routes using authLimiter and authenticatedApiLimiter
- Implement CSRF protection middleware using csrf-sync (skips for API tokens and test environment)
- Add CSRF token endpoint at /api/csrf-token
- Fix incomplete URL scheme validation in GeneralTab to block all dangerous schemes (javascript:, data:, vbscript:, file:)

This addresses 5 high-severity CodeQL security vulnerabilities:
- Missing rate limiting on OIDC auth routes
- Missing CSRF middleware protection
- Incomplete URL sanitization in avatar handling

All 1223 tests passing.

* fix: implement CSRF protection with lusca for CodeQL compliance

Add CSRF protection using lusca.csrf (CodeQL's recommended library) to
protect session-based authentication while supporting hybrid auth patterns.

Implementation:
- Pre-check middleware marks exempt requests (test env, Bearer tokens)
- Lusca CSRF middleware applied with exemption flag check
- Session-based requests require valid x-csrf-token header
- Bearer token requests exempt (don't use cookies)
- Test environment exempt for test execution

This addresses CodeQL security alert js/missing-token-validation while
maintaining support for both cookie-based and token-based authentication.

Related: #977 (OIDC/SSO authentication feature)
2026-04-13 12:17:35 +03:00

544 lines
17 KiB
JavaScript

'use strict';
const usersRepository = require('./repository');
const {
validateFirstDayOfWeek,
validatePassword,
validateFrequency,
validateApiKeyId,
validateApiKeyName,
validateExpiresAt,
validateSidebarSettings,
} = require('./validation');
const { NotFoundError, ValidationError } = require('../../shared/errors');
const { User } = require('../../models');
const {
createApiToken,
revokeApiToken,
deleteApiToken,
serializeApiToken,
} = require('./apiTokenService');
const taskSummaryService = require('../tasks/taskSummaryService');
const { logError } = require('../../services/logService');
const fs = require('fs').promises;
const path = require('path');
const { getConfig } = require('../../config/config');
const config = getConfig();
async function safeDeleteFile(filePath) {
if (!filePath) return;
const uploadDir = path.resolve(config.uploadPath);
const resolvedPath = path.resolve(filePath);
const relativePath = path.relative(uploadDir, resolvedPath);
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
logError('Attempt to delete file outside upload directory:', filePath);
return;
}
await fs.unlink(resolvedPath).catch(() => {});
}
class UsersService {
/**
* List all users with roles.
*/
async listUsers() {
const users = await usersRepository.findAllBasic();
const roles = await usersRepository.findAllRoles();
const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin]));
return users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
surname: u.surname,
role: userIdToRole.get(u.id) ? 'admin' : 'user',
}));
}
/**
* Get user profile.
*/
async getProfile(userId) {
const user = await usersRepository.findProfileById(userId);
if (!user) {
throw new NotFoundError('Profile not found.');
}
// Parse today_settings if it's a string
if (user.today_settings && typeof user.today_settings === 'string') {
try {
user.today_settings = JSON.parse(user.today_settings);
} catch (error) {
logError('Error parsing today_settings:', error);
user.today_settings = null;
}
}
if (user.ui_settings && typeof user.ui_settings === 'string') {
try {
user.ui_settings = JSON.parse(user.ui_settings);
} catch (error) {
logError('Error parsing ui_settings:', error);
user.ui_settings = null;
}
}
return user;
}
/**
* Update user profile.
*/
async updateProfile(userId, data) {
const user = await usersRepository.findByIdWithPassword(userId);
if (!user) {
throw new NotFoundError('Profile not found.');
}
const {
name,
surname,
appearance,
language,
timezone,
first_day_of_week,
avatar_image,
telegram_bot_token,
telegram_allowed_users,
task_intelligence_enabled,
task_summary_enabled,
task_summary_frequency,
auto_suggest_next_actions_enabled,
productivity_assistant_enabled,
next_task_suggestion_enabled,
pomodoro_enabled,
ui_settings,
notification_preferences,
keyboard_shortcuts,
currentPassword,
newPassword,
} = data;
const allowedUpdates = {};
if (name !== undefined) allowedUpdates.name = name;
if (surname !== undefined) allowedUpdates.surname = surname;
if (appearance !== undefined) allowedUpdates.appearance = appearance;
if (language !== undefined) allowedUpdates.language = language;
if (timezone !== undefined) allowedUpdates.timezone = timezone;
if (first_day_of_week !== undefined) {
validateFirstDayOfWeek(first_day_of_week);
allowedUpdates.first_day_of_week = first_day_of_week;
}
if (avatar_image !== undefined)
allowedUpdates.avatar_image = avatar_image;
if (telegram_bot_token !== undefined)
allowedUpdates.telegram_bot_token = telegram_bot_token;
if (telegram_allowed_users !== undefined)
allowedUpdates.telegram_allowed_users = telegram_allowed_users;
if (task_intelligence_enabled !== undefined)
allowedUpdates.task_intelligence_enabled =
task_intelligence_enabled;
if (task_summary_enabled !== undefined)
allowedUpdates.task_summary_enabled = task_summary_enabled;
if (task_summary_frequency !== undefined)
allowedUpdates.task_summary_frequency = task_summary_frequency;
if (auto_suggest_next_actions_enabled !== undefined)
allowedUpdates.auto_suggest_next_actions_enabled =
auto_suggest_next_actions_enabled;
if (productivity_assistant_enabled !== undefined)
allowedUpdates.productivity_assistant_enabled =
productivity_assistant_enabled;
if (next_task_suggestion_enabled !== undefined)
allowedUpdates.next_task_suggestion_enabled =
next_task_suggestion_enabled;
if (pomodoro_enabled !== undefined)
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
if (notification_preferences !== undefined)
allowedUpdates.notification_preferences = notification_preferences;
if (keyboard_shortcuts !== undefined)
allowedUpdates.keyboard_shortcuts = keyboard_shortcuts;
// Handle password change if provided
if (currentPassword && newPassword) {
validatePassword(newPassword, 'newPassword');
const isValidPassword = await User.checkPassword(
currentPassword,
user.password_digest
);
if (!isValidPassword) {
throw new ValidationError(
'Current password is incorrect',
'currentPassword'
);
}
const hashedNewPassword = await User.hashPassword(newPassword);
allowedUpdates.password_digest = hashedNewPassword;
}
await usersRepository.update(user, allowedUpdates);
return usersRepository.findUpdatedProfile(user.id);
}
/**
* Upload avatar.
*/
async uploadAvatar(userId, file) {
if (!file) {
throw new ValidationError('No file uploaded');
}
const user = await usersRepository.findById(userId);
if (!user) {
await safeDeleteFile(file.path);
throw new NotFoundError('User not found');
}
if (user.avatar_image) {
const oldAvatarPath = path.join(
__dirname,
'../../uploads/avatars',
path.basename(user.avatar_image)
);
await safeDeleteFile(oldAvatarPath);
}
const avatarUrl = `/uploads/avatars/${path.basename(file.path)}`;
await usersRepository.update(user, { avatar_image: avatarUrl });
return {
success: true,
avatar_image: avatarUrl,
message: 'Avatar uploaded successfully',
};
}
/**
* Delete avatar.
*/
async deleteAvatar(userId) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found');
}
if (user.avatar_image) {
const avatarPath = path.join(
__dirname,
'../../uploads/avatars',
path.basename(user.avatar_image)
);
await safeDeleteFile(avatarPath);
}
await usersRepository.update(user, { avatar_image: null });
return { success: true, message: 'Avatar removed successfully' };
}
/**
* Change password.
*/
async changePassword(userId, currentPassword, newPassword) {
if (!currentPassword || !newPassword) {
throw new ValidationError(
'Current password and new password are required'
);
}
validatePassword(newPassword, 'newPassword');
const user = await usersRepository.findByIdWithPassword(userId);
if (!user) {
throw new NotFoundError('User not found');
}
const isValidPassword = await User.checkPassword(
currentPassword,
user.password_digest
);
if (!isValidPassword) {
throw new ValidationError(
'Current password is incorrect',
'currentPassword'
);
}
const hashedNewPassword = await User.hashPassword(newPassword);
await usersRepository.update(user, {
password_digest: hashedNewPassword,
});
return { message: 'Password changed successfully' };
}
/**
* List API keys.
*/
async listApiKeys(userId) {
const tokens = await usersRepository.findApiTokens(userId);
return tokens.map(serializeApiToken);
}
/**
* Create API key.
*/
async createApiKey(userId, name, expires_at) {
const validatedName = validateApiKeyName(name);
const expiresAtDate = validateExpiresAt(expires_at);
const { rawToken, tokenRecord } = await createApiToken({
userId,
name: validatedName,
expiresAt: expiresAtDate,
});
return {
token: rawToken,
apiKey: serializeApiToken(tokenRecord),
};
}
/**
* Revoke API key.
*/
async revokeApiKey(userId, keyId) {
const tokenId = validateApiKeyId(keyId);
const token = await revokeApiToken(tokenId, userId);
if (!token) {
throw new NotFoundError('API key not found.');
}
return serializeApiToken(token);
}
/**
* Delete API key.
*/
async deleteApiKey(userId, keyId) {
const tokenId = validateApiKeyId(keyId);
const deleted = await deleteApiToken(tokenId, userId);
if (!deleted) {
throw new NotFoundError('API key not found.');
}
return null;
}
/**
* Toggle task summary.
*/
async toggleTaskSummary(userId) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
const enabled = !user.task_summary_enabled;
await usersRepository.update(user, { task_summary_enabled: enabled });
return {
success: true,
enabled,
message: enabled
? 'Task summary notifications have been enabled.'
: 'Task summary notifications have been disabled.',
};
}
/**
* Update task summary frequency.
*/
async updateTaskSummaryFrequency(userId, frequency) {
const validatedFrequency = validateFrequency(frequency);
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
await usersRepository.update(user, {
task_summary_frequency: validatedFrequency,
});
return {
success: true,
frequency: validatedFrequency,
message: `Task summary frequency has been set to ${validatedFrequency}.`,
};
}
/**
* Send task summary now.
*/
async sendTaskSummaryNow(userId) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
if (!user.telegram_bot_token || !user.telegram_chat_id) {
throw new ValidationError(
'Telegram bot is not properly configured.'
);
}
const success = await taskSummaryService.sendSummaryToUser(user.id);
if (!success) {
throw new ValidationError('Failed to send message to Telegram.');
}
return {
success: true,
message: 'Task summary was sent to your Telegram.',
};
}
/**
* Get task summary status.
*/
async getTaskSummaryStatus(userId) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
return {
success: true,
enabled: user.task_summary_enabled,
frequency: user.task_summary_frequency,
last_run: user.task_summary_last_run,
next_run: user.task_summary_next_run,
};
}
/**
* Update today settings.
*/
async updateTodaySettings(userId, data) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
const {
showMetrics,
projectShowMetrics,
showProductivity,
showNextTaskSuggestion,
showSuggestions,
showDueToday,
showCompleted,
showDailyQuote,
} = data;
const todaySettings = {
projectShowMetrics:
projectShowMetrics !== undefined
? projectShowMetrics
: (user.today_settings?.projectShowMetrics ?? true),
showMetrics:
showMetrics !== undefined
? showMetrics
: user.today_settings?.showMetrics || false,
showProductivity:
showProductivity !== undefined
? showProductivity
: user.today_settings?.showProductivity || false,
showNextTaskSuggestion:
showNextTaskSuggestion !== undefined
? showNextTaskSuggestion
: user.today_settings?.showNextTaskSuggestion || false,
showSuggestions:
showSuggestions !== undefined
? showSuggestions
: user.today_settings?.showSuggestions || false,
showDueToday:
showDueToday !== undefined
? showDueToday
: user.today_settings?.showDueToday || true,
showCompleted:
showCompleted !== undefined
? showCompleted
: user.today_settings?.showCompleted || true,
showProgressBar: true,
showDailyQuote:
showDailyQuote !== undefined
? showDailyQuote
: user.today_settings?.showDailyQuote || true,
};
const profileUpdates = { today_settings: todaySettings };
if (showProductivity !== undefined) {
profileUpdates.productivity_assistant_enabled = showProductivity;
}
if (showNextTaskSuggestion !== undefined) {
profileUpdates.next_task_suggestion_enabled =
showNextTaskSuggestion;
}
await usersRepository.update(user, profileUpdates);
return { success: true, today_settings: todaySettings };
}
/**
* Update sidebar settings.
*/
async updateSidebarSettings(userId, data) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
const { pinnedViewsOrder } = validateSidebarSettings(data);
const sidebarSettings = { pinnedViewsOrder };
await usersRepository.update(user, {
sidebar_settings: sidebarSettings,
});
return { success: true, sidebar_settings: sidebarSettings };
}
/**
* Update UI settings.
*/
async updateUiSettings(userId, data) {
const user = await usersRepository.findById(userId);
if (!user) {
throw new NotFoundError('User not found.');
}
const { project } = data;
const currentSettings =
user.ui_settings && typeof user.ui_settings === 'object'
? user.ui_settings
: { project: { details: {} } };
const newSettings = {
...currentSettings,
project: {
...(currentSettings.project || {}),
...(project || {}),
details: {
...((currentSettings.project &&
currentSettings.project.details) ||
{}),
...((project && project.details) || {}),
},
},
};
await usersRepository.update(user, { ui_settings: newSettings });
return { success: true, ui_settings: newSettings };
}
}
module.exports = new UsersService();