tududi/backend/modules/inbox/inboxProcessingService.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

376 lines
9.9 KiB
JavaScript

/**
* Inbox Item Processing Service
* Handles text analysis and suggestion generation for inbox items
*/
const nlp = require('compromise');
// Helper constants
const AUXILIARY_VERBS = [
'be',
'is',
'am',
'are',
'was',
'were',
'being',
'been',
'have',
'has',
'had',
'having',
'does',
'did',
'doing',
'will',
'would',
'shall',
'should',
'may',
'might',
'can',
'could',
'must',
'ought',
];
/**
* Check if a word is an action verb using NLP
* @param {string} word - Word to check
* @returns {boolean} True if the word is an action verb
*/
const isActionVerb = (word) => {
if (!word || typeof word !== 'string') return false;
try {
const doc = nlp(word.toLowerCase());
const verbs = doc.verbs();
if (verbs.length === 0) return false;
// Check if it's an action verb (not auxiliary/linking verbs when used alone)
const text = verbs.text().toLowerCase();
// Allow "do" when it's part of an action phrase like "do something"
if (text === 'do') {
// Check the original word context to see if it's followed by a noun/action
return true; // For now, allow "do" - could refine this logic later
}
return !AUXILIARY_VERBS.includes(text);
} catch (error) {
console.error('Error checking verb:', error);
return false;
}
};
/**
* Tokenize text handling quoted strings properly
* @param {string} text - Text to tokenize
* @returns {string[]} Array of tokens
*/
const tokenizeText = (text) => {
const MAX_TEXT_LENGTH = 10000;
const tokens = [];
let currentToken = '';
let inQuotes = false;
let i = 0;
const textLength = Math.min(text.length, MAX_TEXT_LENGTH);
while (i < textLength) {
const char = text[i];
if (char === '"' && (i === 0 || text[i - 1] === '+')) {
// Start of a quoted string after +
inQuotes = true;
currentToken += char;
} else if (char === '"' && inQuotes) {
// End of quoted string
inQuotes = false;
currentToken += char;
} else if (char === ' ' && !inQuotes) {
// Space outside quotes - end current token
if (currentToken) {
tokens.push(currentToken);
currentToken = '';
}
} else {
// Regular character
currentToken += char;
}
i++;
}
// Add final token
if (currentToken) {
tokens.push(currentToken);
}
return tokens;
};
/**
* Parse hashtags from text (consecutive groups anywhere)
* @param {string} text - Text to parse
* @returns {string[]} Array of hashtag names
*/
const parseHashtags = (text) => {
const trimmedText = text.trim();
const matches = [];
// Split text into words
const words = trimmedText.split(/\s+/);
if (words.length === 0) return matches;
// Find all consecutive groups of tags/projects
let i = 0;
while (i < words.length) {
// Check if current word starts a tag/project group
if (words[i].startsWith('#') || words[i].startsWith('+')) {
// Found start of a group, collect all consecutive tags/projects
let groupEnd = i;
while (
groupEnd < words.length &&
(words[groupEnd].startsWith('#') ||
words[groupEnd].startsWith('+'))
) {
groupEnd++;
}
// Process all hashtags in this group
for (let j = i; j < groupEnd; j++) {
if (words[j].startsWith('#')) {
const tagName = words[j].substring(1);
if (
tagName &&
/^[a-zA-Z0-9_-]+$/.test(tagName) &&
!matches.includes(tagName)
) {
matches.push(tagName);
}
}
}
// Skip to end of this group
i = groupEnd;
} else {
i++;
}
}
return matches;
};
/**
* Parse project references from text (consecutive groups anywhere)
* @param {string} text - Text to parse
* @returns {string[]} Array of project names
*/
const parseProjectRefs = (text) => {
const trimmedText = text.trim();
const matches = [];
// Tokenize the text handling quoted strings properly
const tokens = tokenizeText(trimmedText);
// Find consecutive groups of tags/projects
let i = 0;
while (i < tokens.length) {
// Check if current token starts a tag/project group
if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) {
// Found start of a group, collect all consecutive tags/projects
let groupEnd = i;
while (
groupEnd < tokens.length &&
(tokens[groupEnd].startsWith('#') ||
tokens[groupEnd].startsWith('+'))
) {
groupEnd++;
}
// Process all project references in this group
for (let j = i; j < groupEnd; j++) {
if (tokens[j].startsWith('+')) {
let projectName = tokens[j].substring(1);
// Handle quoted project names
if (
projectName.startsWith('"') &&
projectName.endsWith('"')
) {
projectName = projectName.slice(1, -1);
}
if (projectName && !matches.includes(projectName)) {
matches.push(projectName);
}
}
}
// Skip to end of this group
i = groupEnd;
} else {
i++;
}
}
return matches;
};
/**
* Clean text by removing tags and project references (consecutive groups anywhere)
* @param {string} text - Text to clean
* @returns {string} Cleaned text
*/
const cleanTextFromTagsAndProjects = (text) => {
const trimmedText = text.trim();
const tokens = tokenizeText(trimmedText);
const cleanedTokens = [];
let i = 0;
while (i < tokens.length) {
// Check if current token starts a tag/project group
if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) {
// Skip this entire consecutive group
while (
i < tokens.length &&
(tokens[i].startsWith('#') || tokens[i].startsWith('+'))
) {
i++;
}
} else {
// Keep regular tokens
cleanedTokens.push(tokens[i]);
i++;
}
}
return cleanedTokens.join(' ').trim();
};
/**
* Check if text starts with an action verb using NLP
* @param {string} text - Text to analyze
* @returns {boolean} True if starts with verb
*/
const startsWithVerb = (text) => {
if (!text.trim()) return false;
try {
const firstWord = text.trim().split(/\s+/)[0];
if (!firstWord) return false;
return isActionVerb(firstWord);
} catch (error) {
console.error('Error checking if text starts with verb:', error);
return false;
}
};
/**
* Check if text contains a URL
* @param {string} text - Text to check
* @returns {boolean} True if contains URL
*/
const containsUrl = (text) => {
const urlRegex = /https?:\/\/[^\s]+/i;
return urlRegex.test(text);
};
/**
* Generate suggestion for an inbox item
* @param {string} content - Original content
* @param {string[]} tags - Parsed tags
* @param {string[]} projects - Parsed projects
* @param {string} cleanedContent - Cleaned content
* @returns {object} Suggestion object
*/
const generateSuggestion = (content, tags, projects, cleanedContent) => {
const hasProject = projects.length > 0;
const hasBookmarkTag = tags.some((tag) => tag.toLowerCase() === 'bookmark');
const textStartsWithVerb = startsWithVerb(cleanedContent);
const hasUrl = containsUrl(content);
// Detect URLs even without a project (for bookmark tag display)
if (hasUrl && !hasProject) {
return { type: null, reason: 'url_detected' };
}
if (!hasProject) {
return { type: null, reason: null };
}
// Suggest note for bookmark items with project (explicit bookmark tag)
if (hasBookmarkTag) {
return {
type: 'note',
reason: 'bookmark_tag',
};
}
// Suggest note for URLs with project (auto-bookmark)
if (hasUrl) {
return {
type: 'note',
reason: 'url_detected',
};
}
// Suggest task for items with project that start with a verb
if (textStartsWithVerb) {
return {
type: 'task',
reason: 'verb_detected',
};
}
return { type: null, reason: null };
};
/**
* Process inbox item content and generate metadata
* @param {string} content - Inbox item content
* @returns {object} Processing results
*/
const processInboxItem = (content) => {
// Parse the content
const tags = parseHashtags(content);
const projects = parseProjectRefs(content);
const cleanedContent = cleanTextFromTagsAndProjects(content);
// Generate suggestion
const suggestion = generateSuggestion(
content,
tags,
projects,
cleanedContent
);
return {
parsed_tags: tags,
parsed_projects: projects,
cleaned_content: cleanedContent,
suggested_type: suggestion.type,
suggested_reason: suggestion.reason,
};
};
module.exports = {
// Core processing functions
processInboxItem,
// Text analysis functions
isActionVerb,
startsWithVerb,
containsUrl,
// Parsing functions
parseHashtags,
parseProjectRefs,
cleanTextFromTagsAndProjects,
tokenizeText,
// Suggestion generation
generateSuggestion,
};