From a84d08e64f3de47bf74871326ef0708ecc0ef9fb Mon Sep 17 00:00:00 2001 From: voc0der Date: Fri, 27 Feb 2026 15:09:39 -0500 Subject: [PATCH] feat: add OIDC auth flow with multi-user enforcement --- backend/app.js | 347 +++++++++++++++--- backend/appdata/default.json | 15 + backend/archive.js | 26 +- backend/authentication/auth.js | 176 ++++++++- backend/authentication/oidc.js | 211 +++++++++++ backend/config.js | 15 + backend/consts.js | 52 +++ backend/files.js | 48 ++- backend/package-lock.json | 43 +++ backend/package.json | 1 + backend/subscriptions.js | 59 ++- src/app/app.component.ts | 40 +- src/app/components/login/login.component.html | 94 ++--- src/app/components/login/login.component.ts | 33 +- src/app/posts.services.ts | 48 ++- src/assets/default.json | 17 +- 16 files changed, 1077 insertions(+), 148 deletions(-) create mode 100644 backend/authentication/oidc.js diff --git a/backend/app.js b/backend/app.js index ddc858e..c014bb9 100644 --- a/backend/app.js +++ b/backend/app.js @@ -4,6 +4,7 @@ const { promisify } = require('util'); const http = require('http'); const https = require('https'); const auth_api = require('./authentication/auth'); +const oidc_api = require('./authentication/oidc'); const path = require('path'); const compression = require('compression'); const multer = require('multer'); @@ -637,9 +638,57 @@ async function setPortItemFromENV() { return true; } +function getOIDCMigrateTargetFromEnv() { + return process.env.ytdl_oidc_migrate_videos || process.env.YTDL_OIDC_MIGRATE_VIDEOS || null; +} + +function getScopedFilterByUser(user_uid) { + if (!config_api.getConfigItem('ytdl_multi_user_mode')) return {}; + return {user_uid: user_uid}; +} + +async function migrateUnassignedVideosToConfiguredUser() { + const migrate_target = getOIDCMigrateTargetFromEnv(); + if (!migrate_target) return true; + + const normalized_target = String(migrate_target).trim(); + const safe_uid = auth_api.sanitizeUserUID(normalized_target); + if (!safe_uid) { + throw new Error(`Invalid ytdl_oidc_migrate_videos value '${normalized_target}'. It must be a valid uid.`); + } + + let target_user = await db_api.getRecord('users', {uid: safe_uid}); + if (!target_user) { + target_user = await db_api.getRecord('users', {name: normalized_target}); + } + + if (!target_user) { + throw new Error(`ytdl_oidc_migrate_videos requested migration to '${normalized_target}', but no such user exists.`); + } + + const unassigned_filter = {user_uid: null}; + const unassigned_count = await db_api.getRecords('files', unassigned_filter, true); + if (!unassigned_count) { + throw new Error('No unassigned videos/files exist for ytdl_oidc_migrate_videos. Remove this env variable to continue startup.'); + } + + const migrated = await db_api.updateRecords('files', unassigned_filter, {user_uid: target_user.uid}); + if (!migrated) { + throw new Error(`Failed to migrate unassigned videos/files to user '${target_user.uid}'.`); + } + + logger.info(`Migrated ${unassigned_count} unassigned videos/files to user '${target_user.uid}' from ytdl_oidc_migrate_videos.`); + return true; +} + async function setAndLoadConfig() { - await setConfigFromEnv(); - await loadConfig(); + try { + await setConfigFromEnv(); + await loadConfig(); + } catch (err) { + logger.error(`Startup failed: ${err.message}`); + process.exit(1); + } } async function setConfigFromEnv() { @@ -659,6 +708,19 @@ async function setConfigFromEnv() { async function loadConfig() { loadConfigValues(); + const oidc_enabled = oidc_api.isEnabled(); + if (oidc_enabled && !config_api.getConfigItem('ytdl_multi_user_mode')) { + logger.error('OIDC startup failed: multi-user mode must be enabled when OIDC is enabled.'); + process.exit(1); + } + + try { + await oidc_api.initialize(); + } catch (err) { + logger.error(`OIDC startup failed: ${err.message}`); + process.exit(1); + } + // connect to DB if (!config_api.getConfigItem('ytdl_use_local_db')) await db_api.connectToDB(); @@ -667,6 +729,7 @@ async function loadConfig() { // check migrations await checkMigrations(); + await migrateUnassignedVideosToConfiguredUser(); // now this is done here due to youtube-dl's repo takedown await startYoutubeDL(); @@ -751,7 +814,7 @@ function getEnvConfigItems() { let config_item_keys = Object.keys(config_api.CONFIG_ITEMS); for (let i = 0; i < config_item_keys.length; i++) { let key = config_item_keys[i]; - if (process['env'][key]) { + if (process['env'][key] || process['env'][key.toUpperCase()]) { const config_item = generateEnvVarConfigItem(key); config_items.push(config_item); } @@ -762,7 +825,7 @@ function getEnvConfigItems() { // gets value of a config item and stores it in an object function generateEnvVarConfigItem(key) { - return {key: key, value: process['env'][key]}; + return {key: key, value: process['env'][key] || process['env'][key.toUpperCase()]}; } // youtube-dl functions @@ -785,6 +848,10 @@ app.use(function(req, res, next) { app.use(function(req, res, next) { if (!req.path.includes('/api/')) { next(); + } else if (req.path.includes('/api/auth/oidc/login') || + req.path.includes('/api/auth/oidc/callback') || + req.path.includes('/api/auth/oidc/status')) { + next(); } else if (req.query.apiKey === admin_token) { next(); } else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) { @@ -843,6 +910,13 @@ const authRateLimiter = rateLimit({ app.use('/api', apiRateLimiter); app.use('/api/auth', authRateLimiter); +function isPublicAuthPath(req_path) { + return req_path.includes('/api/auth/register') + || req_path.includes('/api/auth/oidc/login') + || req_path.includes('/api/auth/oidc/callback') + || req_path.includes('/api/auth/oidc/status'); +} + const optionalJwt = async function (req, res, next) { const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); if (multiUserMode && ((req.body && req.body.uuid) || (req.query && req.query.uuid)) && (req.path.includes('/api/getFile') || @@ -862,7 +936,7 @@ const optionalJwt = async function (req, res, next) { res.sendStatus(401); return; } - } else if (multiUserMode && !(req.path.includes('/api/auth/register') && !(req.path.includes('/api/config')) && !req.query.jwt)) { // registration should get passed through + } else if (multiUserMode && !isPublicAuthPath(req.path)) { if (!req.query.jwt) { res.sendStatus(401); return; @@ -1035,10 +1109,14 @@ app.get('/api/getMp4s', optionalJwt, async function(req, res) { app.post('/api/getFile', optionalJwt, async function (req, res) { const uid = req.body.uid; const uuid = req.body.uuid; - - let file = await db_api.getRecord('files', {uid: uid}); - - if (uuid && !file['sharingEnabled']) file = null; + let file = null; + if (req.isAuthenticated()) { + file = await files_api.getVideo(uid, req.user.uid); + } else if (uuid) { + file = await auth_api.getUserVideo(uuid, uid, true); + } else { + file = await files_api.getVideo(uid); + } // check if chat exists for twitch videos if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json'); @@ -1126,7 +1204,10 @@ app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => { var sub = req.body.sub; var user_uid = null; - if (req.isAuthenticated()) user_uid = req.user.uid; + if (req.isAuthenticated()) { + user_uid = req.user.uid; + uuid = req.user.uid; + } const chat_file = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub); @@ -1143,7 +1224,10 @@ app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => { var sub = req.body.sub; var user_uid = null; - if (req.isAuthenticated()) user_uid = req.user.uid; + if (req.isAuthenticated()) { + user_uid = req.user.uid; + uuid = req.user.uid; + } // check if file already exists. if so, send that instead const file_exists_check = await twitch_api.getTwitchChatByFileID(id, type, user_uid, uuid, sub); @@ -1227,9 +1311,7 @@ app.post('/api/incrementViewCount', async (req, res) => { let sub_id = req.body.sub_id; let uuid = req.body.uuid; - if (!uuid && req.isAuthenticated()) { - uuid = req.user.uid; - } + if (req.isAuthenticated()) uuid = req.user.uid; const file_obj = await files_api.getVideo(file_uid, uuid, sub_id); @@ -1342,7 +1424,7 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; - let result_obj = subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid); + let result_obj = await subscriptions_api.unsubscribe(sub_id, deleteMode, user_uid); if (result_obj.success) { res.send({ success: result_obj.success @@ -1358,8 +1440,9 @@ app.post('/api/unsubscribe', optionalJwt, async (req, res) => { app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { let deleteForever = req.body.deleteForever; let file_uid = req.body.file_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; - let success = await files_api.deleteFile(file_uid, deleteForever); + let success = await files_api.deleteFile(file_uid, deleteForever, user_uid); if (success) { res.send({ @@ -1374,13 +1457,14 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => { app.post('/api/getSubscription', optionalJwt, async (req, res) => { let subID = req.body.id; let subName = req.body.name; // if included, subID is optional + let user_uid = req.isAuthenticated() ? req.user.uid : null; // get sub from db let subscription = null; if (subID) { - subscription = await subscriptions_api.getSubscription(subID) + subscription = await subscriptions_api.getSubscription(subID, user_uid) } else if (subName) { - subscription = await subscriptions_api.getSubscriptionByName(subName) + subscription = await subscriptions_api.getSubscriptionByName(subName, user_uid) } if (!subscription) { @@ -1393,7 +1477,8 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => { // get sub videos if (subscription.name) { - var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos; + const sub_files_filter = {sub_id: subscription.id, ...getScopedFilterByUser(user_uid)}; + var parsed_files = await db_api.getRecords('files', sub_files_filter); // subscription.videos; subscription['videos'] = parsed_files; // loop through files for extra processing for (let i = 0; i < parsed_files.length; i++) { @@ -1413,8 +1498,13 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => { app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => { const subID = req.body.subID; + const user_uid = req.isAuthenticated() ? req.user.uid : null; - const sub = subscriptions_api.getSubscription(subID); + const sub = await subscriptions_api.getSubscription(subID, user_uid); + if (!sub) { + res.send({success: false}); + return; + } subscriptions_api.getVideosForSub(sub.id); res.send({ success: true @@ -1423,8 +1513,9 @@ app.post('/api/downloadVideosForSubscription', optionalJwt, async (req, res) => app.post('/api/updateSubscription', optionalJwt, async (req, res) => { const updated_sub = req.body.subscription; + const user_uid = req.isAuthenticated() ? req.user.uid : null; - const success = subscriptions_api.updateSubscription(updated_sub); + const success = await subscriptions_api.updateSubscription(updated_sub, user_uid); res.send({ success: success }); @@ -1434,7 +1525,7 @@ app.post('/api/checkSubscription', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; - const success = subscriptions_api.getVideosForSub(sub_id, user_uid); + const success = await subscriptions_api.getVideosForSub(sub_id, user_uid); res.send({ success: success }); @@ -1444,7 +1535,7 @@ app.post('/api/cancelCheckSubscription', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; - const success = subscriptions_api.cancelCheckSubscription(sub_id, user_uid); + const success = await subscriptions_api.cancelCheckSubscription(sub_id, user_uid); res.send({ success: success }); @@ -1454,7 +1545,7 @@ app.post('/api/cancelSubscriptionCheck', optionalJwt, async (req, res) => { let sub_id = req.body.sub_id; let user_uid = req.isAuthenticated() ? req.user.uid : null; - const success = subscriptions_api.getVideosForSub(sub_id, user_uid); + const success = await subscriptions_api.getVideosForSub(sub_id, user_uid); res.send({ success: success }); @@ -1487,6 +1578,7 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => { let playlist_id = req.body.playlist_id; let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null); let include_file_metadata = req.body.include_file_metadata; + if (req.user && req.user.uid) uuid = req.user.uid; const playlist = await files_api.getPlaylist(playlist_id, uuid); const file_objs = []; @@ -1510,8 +1602,9 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => { app.post('/api/getPlaylists', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const include_categories = req.body.include_categories; + const filter_obj = getScopedFilterByUser(uuid); - let playlists = await db_api.getRecords('playlists', {user_uid: uuid}); + let playlists = await db_api.getRecords('playlists', filter_obj); if (include_categories) { const categories = await categories_api.getCategoriesAsPlaylists(); if (categories) { @@ -1528,11 +1621,25 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => { let playlist_id = req.body.playlist_id; let file_uid = req.body.file_uid; - const playlist = await db_api.getRecord('playlists', {id: playlist_id}); + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const playlist = await files_api.getPlaylist(playlist_id, user_uid); + if (!playlist) { + res.send({ + success: false + }); + return; + } + const file_obj = await files_api.getVideo(file_uid, user_uid); + if (!file_obj) { + res.send({ + success: false + }); + return; + } playlist.uids.push(file_uid); - let success = await files_api.updatePlaylist(playlist); + let success = await files_api.updatePlaylist(playlist, user_uid); res.send({ success: success }); @@ -1548,11 +1655,13 @@ app.post('/api/updatePlaylist', optionalJwt, async (req, res) => { app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { let playlistID = req.body.playlist_id; + const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = null; try { // removes playlist from playlists - await db_api.removeRecord('playlists', {id: playlistID}) + const filter_obj = {id: playlistID, ...getScopedFilterByUser(user_uid)}; + await db_api.removeRecord('playlists', filter_obj) success = true; } catch(e) { @@ -1568,9 +1677,10 @@ app.post('/api/deletePlaylist', optionalJwt, async (req, res) => { app.post('/api/deleteFile', optionalJwt, async (req, res) => { const uid = req.body.uid; const blacklistMode = req.body.blacklistMode; + const user_uid = req.isAuthenticated() ? req.user.uid : null; let wasDeleted = false; - wasDeleted = await files_api.deleteFile(uid, blacklistMode); + wasDeleted = await files_api.deleteFile(uid, blacklistMode, user_uid); res.send(wasDeleted); }); @@ -1582,7 +1692,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => { let text_search = req.body.text_search; let file_type_filter = req.body.file_type_filter; - const filter_obj = {user_uid: uuid}; + const filter_obj = getScopedFilterByUser(uuid); const regex = true; if (text_search) { if (regex) { @@ -1602,7 +1712,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => { for (let i = 0; i < files.length; i++) { let wasDeleted = false; - wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode); + wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode, uuid); if (wasDeleted) { delete_count++; } @@ -1622,30 +1732,42 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => { let file_path_to_download = null; - if (!uuid && req.user) uuid = req.user.uid; + if (req.user && req.user.uid) uuid = req.user.uid; let zip_file_generated = false; if (playlist_id) { zip_file_generated = true; const playlist_files_to_download = []; const playlist = await files_api.getPlaylist(playlist_id, uuid); + if (!playlist) { + res.sendStatus(404); + return; + } for (let i = 0; i < playlist['uids'].length; i++) { const playlist_file_uid = playlist['uids'][i]; const file_obj = await files_api.getVideo(playlist_file_uid, uuid); - playlist_files_to_download.push(file_obj); + if (file_obj) playlist_files_to_download.push(file_obj); } // generate zip file_path_to_download = await utils.createContainerZipFile(playlist['name'], playlist_files_to_download); } else if (sub_id && !uid) { zip_file_generated = true; - const sub = await db_api.getRecord('subscriptions', {id: sub_id}); - const sub_files_to_download = await db_api.getRecords('files', {sub_id: sub_id}); + const sub = await subscriptions_api.getSubscription(sub_id, req.isAuthenticated() ? req.user.uid : null); + if (!sub) { + res.sendStatus(404); + return; + } + const sub_files_to_download = await db_api.getRecords('files', {sub_id: sub_id, ...getScopedFilterByUser(req.isAuthenticated() ? req.user.uid : null)}); // generate zip file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download); } else { const file_obj = await files_api.getVideo(uid, uuid, sub_id) + if (!file_obj) { + res.sendStatus(404); + return; + } file_path_to_download = file_obj.path; } if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download); @@ -1666,7 +1788,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => { app.post('/api/getArchives', optionalJwt, async (req, res) => { const uuid = req.isAuthenticated() ? req.user.uid : null; const sub_id = req.body.sub_id; - const filter_obj = {user_uid: uuid, sub_id: sub_id}; + const filter_obj = {sub_id: sub_id, ...getScopedFilterByUser(uuid)}; const type = req.body.type; // we do this for file types because if type is null, that means get files of all types @@ -1990,7 +2112,7 @@ app.post('/api/generateNewAPIKey', optionalJwt, function (req, res) { app.get('/api/stream', optionalJwt, async (req, res) => { const type = req.query.type; - const uuid = req.query.uuid ? req.query.uuid : (req.user ? req.user.uid : null); + const uuid = req.user ? req.user.uid : (req.query.uuid ? req.query.uuid : null); const sub_id = req.query.sub_id; const mimetype = type === 'audio' ? 'audio/mp3' : 'video/mp4'; var head; @@ -2100,7 +2222,8 @@ app.get('/api/thumbnail/:path', optionalJwt, async (req, res) => { app.post('/api/downloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; const uids = req.body.uids; - let downloads = await db_api.getRecords('download_queue', {user_uid: user_uid}); + const filter_obj = getScopedFilterByUser(user_uid); + let downloads = await db_api.getRecords('download_queue', filter_obj); if (uids) downloads = downloads.filter(download => uids.includes(download['uid'])); @@ -2109,8 +2232,10 @@ app.post('/api/downloads', optionalJwt, async (req, res) => { app.post('/api/download', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const filter_obj = {uid: download_uid, ...getScopedFilterByUser(user_uid)}; - const download = await db_api.getRecord('download_queue', {uid: download_uid}); + const download = await db_api.getRecord('download_queue', filter_obj); if (download) { res.send({download: download}); @@ -2121,24 +2246,37 @@ app.post('/api/download', optionalJwt, async (req, res) => { app.post('/api/clearDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; + const scoped_filter = getScopedFilterByUser(user_uid); const clear_finished = req.body.clear_finished; const clear_paused = req.body.clear_paused; const clear_errors = req.body.clear_errors; let success = true; - if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, user_uid: user_uid, error: null}); - if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, user_uid: user_uid}); - if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, user_uid: user_uid}); + if (clear_finished) success &= await db_api.removeAllRecords('download_queue', {finished: true, ...scoped_filter, error: null}); + if (clear_paused) success &= await db_api.removeAllRecords('download_queue', {paused: true, ...scoped_filter}); + if (clear_errors) success &= await db_api.removeAllRecords('download_queue', {error: {$ne: null}, ...scoped_filter}); res.send({success: success}); }); app.post('/api/clearDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); + if (!owned_download) { + res.send({success: false}); + return; + } const success = await downloader_api.clearDownload(download_uid); res.send({success: success}); }); app.post('/api/pauseDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); + if (!owned_download) { + res.send({success: false}); + return; + } const success = await downloader_api.pauseDownload(download_uid); res.send({success: success}); }); @@ -2146,7 +2284,7 @@ app.post('/api/pauseDownload', optionalJwt, async (req, res) => { app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = true; - const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, user_uid: user_uid}); + const all_running_downloads = await db_api.getRecords('download_queue', {paused: false, finished: false, ...getScopedFilterByUser(user_uid)}); for (let i = 0; i < all_running_downloads.length; i++) { success &= await downloader_api.pauseDownload(all_running_downloads[i]['uid']); } @@ -2155,6 +2293,12 @@ app.post('/api/pauseAllDownloads', optionalJwt, async (req, res) => { app.post('/api/resumeDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); + if (!owned_download) { + res.send({success: false}); + return; + } const success = await downloader_api.resumeDownload(download_uid); res.send({success: success}); }); @@ -2162,7 +2306,7 @@ app.post('/api/resumeDownload', optionalJwt, async (req, res) => { app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => { const user_uid = req.isAuthenticated() ? req.user.uid : null; let success = true; - const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, user_uid: user_uid, error: null}); + const all_paused_downloads = await db_api.getRecords('download_queue', {paused: true, ...getScopedFilterByUser(user_uid), error: null}); for (let i = 0; i < all_paused_downloads.length; i++) { success &= await downloader_api.resumeDownload(all_paused_downloads[i]['uid']); } @@ -2171,12 +2315,24 @@ app.post('/api/resumeAllDownloads', optionalJwt, async (req, res) => { app.post('/api/restartDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); + if (!owned_download) { + res.send({success: false, new_download_uid: null}); + return; + } const new_download = await downloader_api.restartDownload(download_uid); res.send({success: !!new_download, new_download_uid: new_download ? new_download['uid'] : null}); }); app.post('/api/cancelDownload', optionalJwt, async (req, res) => { const download_uid = req.body.download_uid; + const user_uid = req.isAuthenticated() ? req.user.uid : null; + const owned_download = await db_api.getRecord('download_queue', {uid: download_uid, ...getScopedFilterByUser(user_uid)}); + if (!owned_download) { + res.send({success: false}); + return; + } const success = await downloader_api.cancelDownload(download_uid); res.send({success: success}); }); @@ -2342,7 +2498,72 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) { // user authentication +app.get('/api/auth/oidc/status', (req, res) => { + res.send(oidc_api.getStatus()); +}); + +app.get('/api/auth/oidc/login', async (req, res) => { + if (!oidc_api.isEnabled()) { + res.status(404).send('OIDC is disabled.'); + return; + } + + try { + const return_to = req.query.returnTo ? String(req.query.returnTo) : '/home'; + const authorization_url = oidc_api.createAuthorizationURL(return_to); + res.redirect(authorization_url); + } catch (err) { + logger.error(`OIDC login redirect failed: ${err.message}`); + res.sendStatus(500); + } +}); + +app.get('/api/auth/oidc/callback', async (req, res) => { + if (!oidc_api.isEnabled()) { + res.status(404).send('OIDC is disabled.'); + return; + } + + try { + const callback_result = await oidc_api.consumeAuthorizationCallback(req); + const claims = callback_result.claims || {}; + if (!oidc_api.isClaimsAllowed(claims)) { + logger.error('OIDC login rejected: user does not match allowed groups policy.'); + res.sendStatus(403); + return; + } + + const oidc_config = oidc_api.getConfiguration(); + const user_obj = await auth_api.upsertOIDCUser(claims, { + auto_register: oidc_config.auto_register, + admin_claim: oidc_config.admin_claim, + admin_value: oidc_config.admin_value, + groups_claim: oidc_config.groups_claim, + username_claim: oidc_config.username_claim, + display_name_claim: oidc_config.display_name_claim + }); + if (!user_obj) { + res.sendStatus(403); + return; + } + + const auth_response = await auth_api.getAuthResponseObject(user_obj); + const return_to = callback_result.return_to ? callback_result.return_to : '/home'; + const redirect_path = `${getOrigin()}/#/login;oidc_token=${encodeURIComponent(auth_response.token)};redirect=${encodeURIComponent(return_to)}`; + res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=1; Path=/; Max-Age=60; HttpOnly; SameSite=Lax'); + res.redirect(redirect_path); + } catch (err) { + logger.error(`OIDC callback failed: ${err.message}`); + res.sendStatus(401); + } +}); + app.post('/api/auth/register', optionalJwt, async (req, res) => { + if (oidc_api.isEnabled()) { + res.status(403).send('Registration is disabled when OIDC is enabled.'); + return; + } + const userid = req.body.userid; const username = req.body.username; const plaintextPassword = req.body.password; @@ -2375,6 +2596,13 @@ app.post('/api/auth/register', optionalJwt, async (req, res) => { }); }); app.post('/api/auth/login' + , (req, res, next) => { + if (oidc_api.isEnabled()) { + res.status(403).send('Password login is disabled when OIDC is enabled.'); + return; + } + next(); + } , auth_api.passport.authenticate(['local', 'ldapauth'], { session: false }) , auth_api.generateJWT , auth_api.returnAuthResponse @@ -2584,6 +2812,37 @@ app.get('/api/rss', async function (req, res) { // web server +app.use(function(req, res, next) { + if (!oidc_api.isEnabled() || !config_api.getConfigItem('ytdl_multi_user_mode')) { + return next(); + } + + if (req.path.includes('/api/')) { + return next(); + } + + const accept = req.accepts('html', 'json', 'xml'); + if (accept !== 'html') { + return next(); + } + + const ext = path.extname(req.path); + if (ext !== '') { + return next(); + } + + const cookie_header = req.headers.cookie || ''; + const has_bootstrap_cookie = cookie_header.split(';').map(cookie => cookie.trim()).includes('ytdl_oidc_bootstrap=1'); + if (has_bootstrap_cookie) { + res.setHeader('Set-Cookie', 'ytdl_oidc_bootstrap=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax'); + return next(); + } + + const return_to = req.path && req.path !== '/' ? req.path : '/home'; + const redirect_path = `/api/auth/oidc/login?returnTo=${encodeURIComponent(return_to)}`; + return res.redirect(redirect_path); +}); + app.use(function(req, res, next) { //if the request is not html then move along var accept = req.accepts('html', 'json', 'xml'); diff --git a/backend/appdata/default.json b/backend/appdata/default.json index 27e0a7b..c90e28c 100644 --- a/backend/appdata/default.json +++ b/backend/appdata/default.json @@ -74,6 +74,21 @@ "bindCredentials": "secret", "searchBase": "ou=passport-ldapauth", "searchFilter": "(uid={{username}})" + }, + "oidc": { + "enabled": false, + "issuer_url": "", + "client_id": "", + "client_secret": "", + "redirect_uri": "", + "scope": "openid profile email", + "auto_register": true, + "admin_claim": "groups", + "admin_value": "admin", + "group_claim": "groups", + "allowed_groups": "", + "username_claim": "preferred_username", + "display_name_claim": "preferred_username" } }, "Database": { diff --git a/backend/archive.js b/backend/archive.js index be4366e..b35f9b8 100644 --- a/backend/archive.js +++ b/backend/archive.js @@ -2,10 +2,14 @@ const path = require('path'); const fs = require('fs-extra'); const { v4: uuid } = require('uuid'); +const config_api = require('./config'); const db_api = require('./db'); exports.generateArchive = async (type = null, user_uid = null, sub_id = null) => { - const filter = {user_uid: user_uid, sub_id: sub_id}; + const filter = {sub_id: sub_id}; + if (config_api.getConfigItem('ytdl_multi_user_mode')) { + filter['user_uid'] = user_uid; + } if (type) filter['type'] = type; const archive_items = await db_api.getRecords('archives', filter); const archive_item_lines = archive_items.map(archive_item => `${archive_item['extractor']} ${archive_item['id']}`); @@ -14,17 +18,29 @@ exports.generateArchive = async (type = null, user_uid = null, sub_id = null) => exports.addToArchive = async (extractor, id, type, title, user_uid = null, sub_id = null) => { const archive_item = createArchiveItem(extractor, id, type, title, user_uid, sub_id); - const success = await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type}); + const replace_filter = {extractor: extractor, id: id, type: type, sub_id: sub_id}; + if (config_api.getConfigItem('ytdl_multi_user_mode')) { + replace_filter['user_uid'] = user_uid; + } + const success = await db_api.insertRecordIntoTable('archives', archive_item, replace_filter); return success; } exports.removeFromArchive = async (extractor, id, type, user_uid = null, sub_id = null) => { - const success = await db_api.removeAllRecords('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id}); + const filter = {extractor: extractor, id: id, type: type, sub_id: sub_id}; + if (config_api.getConfigItem('ytdl_multi_user_mode')) { + filter['user_uid'] = user_uid; + } + const success = await db_api.removeAllRecords('archives', filter); return success; } exports.existsInArchive = async (extractor, id, type, user_uid, sub_id) => { - const archive_item = await db_api.getRecord('archives', {extractor: extractor, id: id, type: type, user_uid: user_uid, sub_id: sub_id}); + const filter = {extractor: extractor, id: id, type: type, sub_id: sub_id}; + if (config_api.getConfigItem('ytdl_multi_user_mode')) { + filter['user_uid'] = user_uid; + } + const archive_item = await db_api.getRecord('archives', filter); return !!archive_item; } @@ -88,4 +104,4 @@ const createArchiveItem = (extractor, id, type, title = null, user_uid = null, s timestamp: Date.now() / 1000, uid: uuid() } -} \ No newline at end of file +} diff --git a/backend/authentication/auth.js b/backend/authentication/auth.js index 5838971..3d5a766 100644 --- a/backend/authentication/auth.js +++ b/backend/authentication/auth.js @@ -20,6 +20,8 @@ let JWT_EXPIRATION = null; let opts = null; let saltRounds = 10; +const SAFE_UID_PATTERN = /^[A-Za-z0-9._@-]+$/; + exports.initialize = function () { /************************* * Authentication module @@ -127,6 +129,153 @@ exports.registerUser = async (userid, username, plaintextPassword) => { } } +function parseClaimPath(claims, claimPath) { + if (!claims || !claimPath || typeof claimPath !== 'string') return undefined; + const pathParts = claimPath.split('.').filter(part => part !== ''); + if (pathParts.length === 0) return undefined; + + let currentValue = claims; + for (const part of pathParts) { + if (!currentValue || typeof currentValue !== 'object' || !(part in currentValue)) { + return undefined; + } + currentValue = currentValue[part]; + } + return currentValue; +} + +function claimToArray(value) { + if (value === undefined || value === null) return []; + if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(v => v.length > 0); + if (typeof value === 'string' && value.includes(',')) { + return value.split(',').map(v => v.trim()).filter(v => v.length > 0); + } + const normalized = String(value).trim(); + return normalized ? [normalized] : []; +} + +function valueIncludes(expectedValue, sourceValue) { + if (!expectedValue || expectedValue.length === 0) return false; + const expected = String(expectedValue).trim().toLowerCase(); + if (!expected) return false; + return claimToArray(sourceValue).some(entry => entry.toLowerCase() === expected); +} + +function claimValueToString(value) { + if (value === undefined || value === null) return ''; + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return ''; +} + +exports.sanitizeUserUID = (rawUID) => { + const input = claimValueToString(rawUID); + if (!input) return null; + if (input === '.' || input === '..') return null; + if (!SAFE_UID_PATTERN.test(input)) return null; + return input; +} + +function getOIDCIdentityFromClaims(claims, usernameClaim) { + const fallbackClaims = [usernameClaim, 'preferred_username', 'username', 'email', 'sub']; + for (const claimName of fallbackClaims) { + if (!claimName) continue; + const claimValue = parseClaimPath(claims, claimName); + const parsed = claimValueToString(claimValue); + if (parsed) return parsed; + } + return null; +} + +exports.createJWTForUser = function(user_uid) { + const payload = { + exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION, + user: user_uid + }; + return jwt.sign(payload, SERVER_SECRET); +} + +exports.getAuthResponseObject = async function(user) { + const token = exports.createJWTForUser(user.uid); + return { + user: user, + token: token, + permissions: await exports.userPermissions(user.uid), + available_permissions: CONSTS.AVAILABLE_PERMISSIONS + }; +} + +exports.upsertOIDCUser = async (claims, options = {}) => { + const username_claim = options.username_claim || 'preferred_username'; + const display_name_claim = options.display_name_claim || username_claim; + const groups_claim = options.groups_claim || 'groups'; + const admin_claim = options.admin_claim || 'groups'; + const admin_value = options.admin_value || 'admin'; + const auto_register = options.auto_register !== false; + + const oidc_subject = claimValueToString(parseClaimPath(claims, 'sub')); + const login_name = getOIDCIdentityFromClaims(claims, username_claim); + const display_name = claimValueToString(parseClaimPath(claims, display_name_claim)) || login_name; + const uid_to_use = exports.sanitizeUserUID(login_name); + + if (!uid_to_use || !display_name) { + logger.error('OIDC login rejected: Could not derive a valid uid/name from OIDC claims.'); + return null; + } + + const groups = claimToArray(parseClaimPath(claims, groups_claim)); + const admin_claim_value = parseClaimPath(claims, admin_claim); + const role = valueIncludes(admin_value, admin_claim_value) ? 'admin' : 'user'; + + let user_obj = null; + if (oidc_subject) { + user_obj = await db_api.getRecord('users', {oidc_subject: oidc_subject}); + } + if (!user_obj) { + user_obj = await db_api.getRecord('users', {uid: uid_to_use}); + } + if (!user_obj) { + user_obj = await db_api.getRecord('users', {name: display_name}); + } + + if (!user_obj) { + if (!auto_register) { + logger.error(`OIDC login rejected: user '${uid_to_use}' does not exist and auto registration is disabled.`); + return null; + } + user_obj = generateUserObject(uid_to_use, display_name, null, 'oidc'); + user_obj.role = role; + user_obj.oidc_subject = oidc_subject || null; + user_obj.oidc_groups = groups; + const inserted = await db_api.insertRecordIntoTable('users', user_obj); + if (!inserted) { + logger.error(`OIDC login failed: could not create user '${uid_to_use}'.`); + return null; + } + return await db_api.getRecord('users', {uid: uid_to_use}); + } + + if (oidc_subject && user_obj.oidc_subject && user_obj.oidc_subject !== oidc_subject) { + logger.error(`OIDC login rejected: existing user '${user_obj.uid}' is mapped to a different subject.`); + return null; + } + + const updated_user_values = { + name: display_name, + role: role, + auth_method: 'oidc', + oidc_groups: groups + }; + if (oidc_subject) updated_user_values['oidc_subject'] = oidc_subject; + + const updated = await db_api.updateRecord('users', {uid: user_obj.uid}, updated_user_values); + if (!updated) { + logger.error(`OIDC login failed: could not update user '${user_obj.uid}'.`); + return null; + } + return await db_api.getRecord('users', {uid: user_obj.uid}); +} + exports.deleteUser = async (uid) => { let success = false; let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path'); @@ -214,21 +363,14 @@ exports.passport.use(new LdapStrategy(getLDAPConfiguration, * request. *********************************/ exports.generateJWT = function(req, res, next) { - var payload = { - exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION - , user: req.user.uid - }; - req.token = jwt.sign(payload, SERVER_SECRET); + req.token = exports.createJWTForUser(req.user.uid); next(); } exports.returnAuthResponse = async function(req, res) { - res.status(200).json({ - user: req.user, - token: req.token, - permissions: await exports.userPermissions(req.user.uid), - available_permissions: CONSTS.AVAILABLE_PERMISSIONS - }); + const auth_response = await exports.getAuthResponseObject(req.user); + auth_response.token = req.token; + res.status(200).json(auth_response); } /*************************************** @@ -311,7 +453,11 @@ exports.getUserVideos = async function(user_uid, type) { } exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) { - let file = await db_api.getRecord('files', {uid: file_uid}); + const filter_obj = {uid: file_uid}; + if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) { + filter_obj['user_uid'] = user_uid; + } + let file = await db_api.getRecord('files', filter_obj); // prevent unauthorized users from accessing the file info if (file && !file['sharingEnabled'] && requireSharing) file = null; @@ -329,7 +475,11 @@ exports.getUserPlaylists = async function(user_uid) { } exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) { - let playlist = await db_api.getRecord('playlists', {id: playlistID}); + const filter_obj = {id: playlistID}; + if (config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined) { + filter_obj['user_uid'] = user_uid; + } + let playlist = await db_api.getRecord('playlists', filter_obj); // prevent unauthorized users from accessing the file info if (requireSharing && !playlist['sharingEnabled']) playlist = null; diff --git a/backend/authentication/oidc.js b/backend/authentication/oidc.js new file mode 100644 index 0000000..3a23ca5 --- /dev/null +++ b/backend/authentication/oidc.js @@ -0,0 +1,211 @@ +const { Issuer, generators } = require('openid-client'); + +const config_api = require('../config'); +const logger = require('../logger'); + +const AUTH_TX_TTL_MS = 10 * 60 * 1000; +const auth_transactions = new Map(); + +let oidc_issuer = null; +let oidc_client = null; +let initialized = false; + +function parseBool(input, fallback = false) { + if (typeof input === 'boolean') return input; + if (typeof input === 'string') { + const normalized = input.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + return fallback; +} + +function parseCSV(input) { + if (!input) return []; + if (Array.isArray(input)) return input.map(value => String(value).trim()).filter(value => value.length > 0); + return String(input).split(',').map(value => value.trim()).filter(value => value.length > 0); +} + +function normalizeRelativePath(return_to) { + if (!return_to || typeof return_to !== 'string') return '/home'; + const trimmed = return_to.trim(); + if (!trimmed.startsWith('/')) return '/home'; + if (trimmed.startsWith('//')) return '/home'; + return trimmed; +} + +function cleanupTransactions() { + const now = Date.now(); + for (const [state, tx] of auth_transactions.entries()) { + if (!tx || now - tx.created > AUTH_TX_TTL_MS) { + auth_transactions.delete(state); + } + } +} + +function getOIDCConfiguration() { + return { + enabled: parseBool(config_api.getConfigItem('ytdl_oidc_enabled'), false), + issuer_url: config_api.getConfigItem('ytdl_oidc_issuer_url'), + client_id: config_api.getConfigItem('ytdl_oidc_client_id'), + client_secret: config_api.getConfigItem('ytdl_oidc_client_secret'), + redirect_uri: config_api.getConfigItem('ytdl_oidc_redirect_uri'), + scope: config_api.getConfigItem('ytdl_oidc_scope') || 'openid profile email', + auto_register: parseBool(config_api.getConfigItem('ytdl_oidc_auto_register'), true), + admin_claim: config_api.getConfigItem('ytdl_oidc_admin_claim') || 'groups', + admin_value: config_api.getConfigItem('ytdl_oidc_admin_value') || 'admin', + groups_claim: config_api.getConfigItem('ytdl_oidc_group_claim') || 'groups', + allowed_groups: parseCSV(config_api.getConfigItem('ytdl_oidc_allowed_groups')), + username_claim: config_api.getConfigItem('ytdl_oidc_username_claim') || 'preferred_username', + display_name_claim: config_api.getConfigItem('ytdl_oidc_display_name_claim') || 'preferred_username' + }; +} + +function getClaimByPath(claims, claimPath) { + if (!claims || !claimPath || typeof claimPath !== 'string') return undefined; + const pathParts = claimPath.split('.').filter(part => part !== ''); + if (pathParts.length === 0) return undefined; + + let currentValue = claims; + for (const part of pathParts) { + if (!currentValue || typeof currentValue !== 'object' || !(part in currentValue)) return undefined; + currentValue = currentValue[part]; + } + return currentValue; +} + +function claimToArray(claimValue) { + if (claimValue === undefined || claimValue === null) return []; + if (Array.isArray(claimValue)) return claimValue.map(value => String(value).trim()).filter(value => value.length > 0); + if (typeof claimValue === 'string' && claimValue.includes(',')) { + return claimValue.split(',').map(value => value.trim()).filter(value => value.length > 0); + } + const normalized = String(claimValue).trim(); + return normalized ? [normalized] : []; +} + +function ensureOIDCReady() { + if (!initialized || !oidc_client) { + throw new Error('OIDC is not initialized.'); + } +} + +exports.isEnabled = () => { + return getOIDCConfiguration().enabled; +} + +exports.getConfiguration = () => { + return getOIDCConfiguration(); +} + +exports.initialize = async () => { + const oidc_config = getOIDCConfiguration(); + if (!oidc_config.enabled) { + oidc_issuer = null; + oidc_client = null; + initialized = false; + auth_transactions.clear(); + return true; + } + + if (!oidc_config.issuer_url || !oidc_config.client_id || !oidc_config.client_secret || !oidc_config.redirect_uri) { + throw new Error('OIDC is enabled but one or more required settings are missing (issuer_url, client_id, client_secret, redirect_uri).'); + } + + const discovered_issuer = await Issuer.discover(String(oidc_config.issuer_url).trim()); + oidc_issuer = discovered_issuer; + oidc_client = new oidc_issuer.Client({ + client_id: String(oidc_config.client_id).trim(), + client_secret: String(oidc_config.client_secret).trim(), + redirect_uris: [String(oidc_config.redirect_uri).trim()], + response_types: ['code'], + token_endpoint_auth_method: 'client_secret_post' + }); + initialized = true; + logger.info('OIDC authentication initialized successfully.'); + return true; +} + +exports.getStatus = () => { + const oidc_config = getOIDCConfiguration(); + return { + enabled: oidc_config.enabled, + initialized: initialized && !!oidc_client, + auto_register: oidc_config.auto_register + }; +} + +exports.createAuthorizationURL = (return_to = '/home') => { + ensureOIDCReady(); + cleanupTransactions(); + const oidc_config = getOIDCConfiguration(); + const normalized_return_to = normalizeRelativePath(return_to); + const code_verifier = generators.codeVerifier(); + const code_challenge = generators.codeChallenge(code_verifier); + const state = generators.state(); + const nonce = generators.nonce(); + + auth_transactions.set(state, { + code_verifier: code_verifier, + nonce: nonce, + return_to: normalized_return_to, + created: Date.now() + }); + + return oidc_client.authorizationUrl({ + scope: oidc_config.scope, + code_challenge: code_challenge, + code_challenge_method: 'S256', + response_type: 'code', + state: state, + nonce: nonce + }); +} + +exports.consumeAuthorizationCallback = async (req) => { + ensureOIDCReady(); + cleanupTransactions(); + + const params = oidc_client.callbackParams(req); + const state = params.state; + if (!state || !auth_transactions.has(state)) { + throw new Error('OIDC callback rejected: missing or invalid state.'); + } + + const tx = auth_transactions.get(state); + auth_transactions.delete(state); + + const oidc_config = getOIDCConfiguration(); + const redirect_uri = String(oidc_config.redirect_uri).trim(); + + const token_set = await oidc_client.callback(redirect_uri, params, { + state: state, + nonce: tx.nonce, + code_verifier: tx.code_verifier + }); + const id_claims = token_set.claims() || {}; + + let userinfo_claims = {}; + if (token_set.access_token) { + try { + userinfo_claims = await oidc_client.userinfo(token_set.access_token); + } catch (err) { + logger.warn(`OIDC userinfo call failed, falling back to ID token claims. ${err.message}`); + } + } + + return { + claims: Object.assign({}, userinfo_claims || {}, id_claims || {}), + return_to: tx.return_to || '/home' + }; +} + +exports.isClaimsAllowed = (claims) => { + const oidc_config = getOIDCConfiguration(); + const allowed_groups = oidc_config.allowed_groups || []; + if (!allowed_groups.length) return true; + + const groups_value = getClaimByPath(claims, oidc_config.groups_claim || 'groups'); + const user_groups = claimToArray(groups_value).map(group => group.toLowerCase()); + return allowed_groups.some(group => user_groups.includes(String(group).toLowerCase())); +} diff --git a/backend/config.js b/backend/config.js index 43f7d17..0053e31 100644 --- a/backend/config.js +++ b/backend/config.js @@ -264,6 +264,21 @@ const DEFAULT_CONFIG = { "bindCredentials": "secret", "searchBase": "ou=passport-ldapauth", "searchFilter": "(uid={{username}})" + }, + "oidc": { + "enabled": false, + "issuer_url": "", + "client_id": "", + "client_secret": "", + "redirect_uri": "", + "scope": "openid profile email", + "auto_register": true, + "admin_claim": "groups", + "admin_value": "admin", + "group_claim": "groups", + "allowed_groups": "", + "username_claim": "preferred_username", + "display_name_claim": "preferred_username" } }, "Database": { diff --git a/backend/consts.js b/backend/consts.js index 23cb7f6..d3e1e10 100644 --- a/backend/consts.js +++ b/backend/consts.js @@ -229,6 +229,58 @@ exports.CONFIG_ITEMS = { 'key': 'ytdl_ldap_config', 'path': 'YoutubeDLMaterial.Users.ldap_config' }, + 'ytdl_oidc_enabled': { + 'key': 'ytdl_oidc_enabled', + 'path': 'YoutubeDLMaterial.Users.oidc.enabled' + }, + 'ytdl_oidc_issuer_url': { + 'key': 'ytdl_oidc_issuer_url', + 'path': 'YoutubeDLMaterial.Users.oidc.issuer_url' + }, + 'ytdl_oidc_client_id': { + 'key': 'ytdl_oidc_client_id', + 'path': 'YoutubeDLMaterial.Users.oidc.client_id' + }, + 'ytdl_oidc_client_secret': { + 'key': 'ytdl_oidc_client_secret', + 'path': 'YoutubeDLMaterial.Users.oidc.client_secret' + }, + 'ytdl_oidc_redirect_uri': { + 'key': 'ytdl_oidc_redirect_uri', + 'path': 'YoutubeDLMaterial.Users.oidc.redirect_uri' + }, + 'ytdl_oidc_scope': { + 'key': 'ytdl_oidc_scope', + 'path': 'YoutubeDLMaterial.Users.oidc.scope' + }, + 'ytdl_oidc_auto_register': { + 'key': 'ytdl_oidc_auto_register', + 'path': 'YoutubeDLMaterial.Users.oidc.auto_register' + }, + 'ytdl_oidc_admin_claim': { + 'key': 'ytdl_oidc_admin_claim', + 'path': 'YoutubeDLMaterial.Users.oidc.admin_claim' + }, + 'ytdl_oidc_admin_value': { + 'key': 'ytdl_oidc_admin_value', + 'path': 'YoutubeDLMaterial.Users.oidc.admin_value' + }, + 'ytdl_oidc_group_claim': { + 'key': 'ytdl_oidc_group_claim', + 'path': 'YoutubeDLMaterial.Users.oidc.group_claim' + }, + 'ytdl_oidc_allowed_groups': { + 'key': 'ytdl_oidc_allowed_groups', + 'path': 'YoutubeDLMaterial.Users.oidc.allowed_groups' + }, + 'ytdl_oidc_username_claim': { + 'key': 'ytdl_oidc_username_claim', + 'path': 'YoutubeDLMaterial.Users.oidc.username_claim' + }, + 'ytdl_oidc_display_name_claim': { + 'key': 'ytdl_oidc_display_name_claim', + 'path': 'YoutubeDLMaterial.Users.oidc.display_name_claim' + }, // Database 'ytdl_use_local_db': { diff --git a/backend/files.js b/backend/files.js index 24c61fb..4d57f1e 100644 --- a/backend/files.js +++ b/backend/files.js @@ -8,6 +8,10 @@ const archive_api = require('./archive'); const utils = require('./utils') const logger = require('./logger'); +function shouldRestrictToUser(user_uid) { + return config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined; +} + exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => { if (!file_object) file_object = generateFileObject(file_path, type); if (!file_object) { @@ -137,7 +141,8 @@ exports.addMetadataPropertyToDB = async (property_key) => { } exports.createPlaylist = async (playlist_name, uids, user_uid = null) => { - const first_video = await exports.getVideo(uids[0]); + const first_video = await exports.getVideo(uids[0], user_uid); + if (!first_video) return null; const thumbnailToUse = first_video['thumbnailURL']; let new_playlist = { @@ -160,12 +165,16 @@ exports.createPlaylist = async (playlist_name, uids, user_uid = null) => { } exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => { - let playlist = await db_api.getRecord('playlists', {id: playlist_id}); + const playlist_filter = {id: playlist_id}; + if (shouldRestrictToUser(user_uid)) playlist_filter['user_uid'] = user_uid; + let playlist = await db_api.getRecord('playlists', playlist_filter); if (!playlist) { playlist = await db_api.getRecord('categories', {uid: playlist_id}); if (playlist) { - const uids = (await db_api.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid); + const files_filter = {'category.uid': playlist_id}; + if (shouldRestrictToUser(user_uid)) files_filter['user_uid'] = user_uid; + const uids = (await db_api.getRecords('files', files_filter)).map(file => file.uid); playlist['uids'] = uids; playlist['auto'] = true; } @@ -190,17 +199,21 @@ exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = fal return playlist; } -exports.updatePlaylist = async (playlist) => { +exports.updatePlaylist = async (playlist, user_uid = null) => { let playlistID = playlist.id; + const filter_obj = {id: playlistID}; + if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid; const duration = await exports.calculatePlaylistDuration(playlist); playlist.duration = duration; - return await db_api.updateRecord('playlists', {id: playlistID}, playlist); + return await db_api.updateRecord('playlists', filter_obj, playlist); } exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => { - let success = await db_api.updateRecord('playlists', {id: playlist_id}, assignment_obj); + const playlist_filter_obj = {id: playlist_id}; + if (shouldRestrictToUser(user_uid)) playlist_filter_obj['user_uid'] = user_uid; + let success = await db_api.updateRecord('playlists', playlist_filter_obj, assignment_obj); if (!success) { success = await db_api.updateRecord('categories', {uid: playlist_id}, assignment_obj); @@ -221,7 +234,7 @@ exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) const playlist_uids_to_scan = playlist_uids.slice(0, max_playlist_uids_to_scan); for (let i = 0; i < playlist_uids_to_scan.length; i++) { const uid = playlist_uids_to_scan[i]; - const file_obj = await exports.getVideo(uid); + const file_obj = await exports.getVideo(uid, playlist.user_uid); if (file_obj) playlist_file_objs.push(file_obj); } } @@ -229,8 +242,9 @@ exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0); } -exports.deleteFile = async (uid, blacklistMode = false) => { - const file_obj = await exports.getVideo(uid); +exports.deleteFile = async (uid, blacklistMode = false, user_uid = null) => { + const file_obj = await exports.getVideo(uid, user_uid); + if (!file_obj) return false; const type = file_obj.isAudio ? 'audio' : 'video'; const folderPath = path.dirname(file_obj.path); const name = file_obj.id; @@ -316,16 +330,24 @@ exports.deleteFile = async (uid, blacklistMode = false) => { // Video ID is basically just the file name without the base path and file extension - this method helps us get away from that exports.getVideoUIDByID = async (file_id, uuid = null) => { - const file_obj = await db_api.getRecord('files', {id: file_id}); + const filter_obj = {id: file_id}; + if (shouldRestrictToUser(uuid)) filter_obj['user_uid'] = uuid; + const file_obj = await db_api.getRecord('files', filter_obj); return file_obj ? file_obj['uid'] : null; } -exports.getVideo = async (file_uid) => { - return await db_api.getRecord('files', {uid: file_uid}); +exports.getVideo = async (file_uid, user_uid = null, sub_id = null) => { + const filter_obj = {uid: file_uid}; + if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid; + if (sub_id) filter_obj['sub_id'] = sub_id; + return await db_api.getRecord('files', filter_obj); } exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => { - const filter_obj = {user_uid: uuid}; + const filter_obj = {}; + if (config_api.getConfigItem('ytdl_multi_user_mode')) { + filter_obj['user_uid'] = uuid; + } const regex = true; if (text_search) { if (regex) { diff --git a/backend/package-lock.json b/backend/package-lock.json index 22843ad..4c2caa4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -38,6 +38,7 @@ "multer": "^2.0.2", "node-id3": "^0.2.9", "node-schedule": "^2.1.1", + "openid-client": "^5.7.1", "passport": "^0.6.0", "passport-http": "^0.3.0", "passport-jwt": "^4.0.1", @@ -2430,6 +2431,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3232,6 +3242,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3244,6 +3263,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "license": "MIT", @@ -3290,6 +3318,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/p-cancelable": { "version": "2.1.1", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index f13d85c..ec1aad1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -52,6 +52,7 @@ "multer": "^2.0.2", "node-id3": "^0.2.9", "node-schedule": "^2.1.1", + "openid-client": "^5.7.1", "passport": "^0.6.0", "passport-http": "^0.3.0", "passport-jwt": "^4.0.1", diff --git a/backend/subscriptions.js b/backend/subscriptions.js index 25c91be..113a52f 100644 --- a/backend/subscriptions.js +++ b/backend/subscriptions.js @@ -13,6 +13,10 @@ const debugMode = process.env.YTDL_MODE === 'debug'; const db_api = require('./db'); const downloader_api = require('./downloader'); +function shouldRestrictToUser(user_uid) { + return config_api.getConfigItem('ytdl_multi_user_mode') && user_uid !== null && user_uid !== undefined; +} + exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => { const result_obj = { success: false, @@ -100,7 +104,13 @@ async function getSubscriptionInfo(sub) { } exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => { - const sub = await exports.getSubscription(sub_id); + const sub = await exports.getSubscription(sub_id, user_uid); + if (!sub) { + return { + success: false, + error: 'Subscription not found or not owned by the current user.' + }; + } let basePath = null; if (user_uid) basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); @@ -124,12 +134,14 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => { } await killSubDownloads(sub_id, true); - await db_api.removeRecord('subscriptions', {id: id}); - await db_api.removeAllRecords('files', {sub_id: id}); + const remove_sub_filter = {id: id}; + if (shouldRestrictToUser(user_uid)) remove_sub_filter['user_uid'] = user_uid; + await db_api.removeRecord('subscriptions', remove_sub_filter); + await db_api.removeAllRecords('files', {sub_id: id, ...(shouldRestrictToUser(user_uid) ? {user_uid: user_uid} : {})}); // failed subs have no name, on unsubscribe they shouldn't error if (!sub.name) { - return; + return {success: true}; } const appendedBasePath = getAppendedBasePath(sub, basePath); @@ -137,7 +149,8 @@ exports.unsubscribe = async (sub_id, deleteMode, user_uid = null) => { await fs.remove(appendedBasePath); } - await db_api.removeAllRecords('archives', {sub_id: sub.id}); + await db_api.removeAllRecords('archives', {sub_id: sub.id, ...(shouldRestrictToUser(user_uid) ? {user_uid: user_uid} : {})}); + return {success: true}; } exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => { @@ -266,8 +279,8 @@ async function getValidSubscriptionsToCheck() { return valid_subscription_ids; } -exports.getVideosForSub = async (sub_id) => { - const sub = await exports.getSubscription(sub_id); +exports.getVideosForSub = async (sub_id, user_uid = null) => { + const sub = await exports.getSubscription(sub_id, user_uid); if (!sub || sub['downloading']) { return false; } @@ -468,8 +481,12 @@ async function getFilesToDownload(sub, output_jsons) { return files_to_download; } -exports.cancelCheckSubscription = async (sub_id) => { - const sub = await exports.getSubscription(sub_id); +exports.cancelCheckSubscription = async (sub_id, user_uid = null) => { + const sub = await exports.getSubscription(sub_id, user_uid); + if (!sub) { + logger.error('Failed to cancel subscription check, subscription not found.'); + return false; + } if (!sub['downloading'] && !sub['child_process']) { logger.error('Failed to cancel subscription check, verify that it is still running!'); return false; @@ -499,18 +516,26 @@ async function killSubDownloads(sub_id, remove_downloads = false) { exports.getSubscriptions = async (user_uid = null) => { // TODO: fix issue where the downloading property may not match getSubscription() + if (!config_api.getConfigItem('ytdl_multi_user_mode')) { + return await db_api.getRecords('subscriptions'); + } return await db_api.getRecords('subscriptions', {user_uid: user_uid}); } exports.getAllSubscriptions = async () => { const all_subs = await db_api.getRecords('subscriptions'); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode'); - return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode); + if (!multiUserMode) return all_subs; + return all_subs.filter(sub => !!(sub.user_uid)); } -exports.getSubscription = async (subID) => { +exports.getSubscription = async (subID, user_uid = null) => { // stringify and parse because we may override the 'downloading' property - const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID}))); + const filter_obj = {id: subID}; + if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid; + const raw_sub = await db_api.getRecord('subscriptions', filter_obj); + if (!raw_sub) return null; + const sub = JSON.parse(JSON.stringify(raw_sub)); // now with the download_queue, we may need to override 'downloading' const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true); if (!sub['downloading']) sub['downloading'] = current_downloads > 0; @@ -518,11 +543,17 @@ exports.getSubscription = async (subID) => { } exports.getSubscriptionByName = async (subName, user_uid = null) => { + if (!config_api.getConfigItem('ytdl_multi_user_mode')) { + return await db_api.getRecord('subscriptions', {name: subName}); + } return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid}); } -exports.updateSubscription = async (sub) => { - await db_api.updateRecord('subscriptions', {id: sub.id}, sub); +exports.updateSubscription = async (sub, user_uid = null) => { + const filter_obj = {id: sub.id}; + if (shouldRestrictToUser(user_uid)) filter_obj['user_uid'] = user_uid; + const updated = await db_api.updateRecord('subscriptions', filter_obj, sub); + if (!updated) return false; exports.writeSubscriptionMetadata(sub); return true; } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d6c3b00..002f240 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -70,8 +70,9 @@ export class AppComponent implements OnInit, AfterViewInit { } ngOnInit(): void { - if (localStorage.getItem('theme')) { - this.setTheme(localStorage.getItem('theme')); + const storedTheme = this.getStoredTheme(); + if (storedTheme) { + this.setTheme(storedTheme); } this.postsService.open_create_default_admin_dialog.subscribe(open => { @@ -106,8 +107,11 @@ export class AppComponent implements OnInit, AfterViewInit { this.enableDownloadsManager = this.postsService.config['Extra']['enable_downloads_manager']; // sets theme to config default if it doesn't exist - if (!localStorage.getItem('theme')) { + const storedTheme = this.getStoredTheme(); + if (!storedTheme) { this.setTheme(themingExists ? this.defaultTheme : 'default'); + } else { + this.setTheme(storedTheme); } // gets the subscriptions @@ -124,24 +128,44 @@ export class AppComponent implements OnInit, AfterViewInit { // theme stuff + getThemeStorageKey(): string { + if (this.postsService.config && this.postsService.config['Advanced'] && this.postsService.config['Advanced']['multi_user_mode'] && this.postsService.user && this.postsService.user.uid) { + return `theme_${this.postsService.user.uid}`; + } + return 'theme'; + } + + getStoredTheme(): string { + return localStorage.getItem(this.getThemeStorageKey()) || localStorage.getItem('theme'); + } + + setStoredTheme(theme: string): void { + const key = this.getThemeStorageKey(); + localStorage.setItem(key, theme); + if (key !== 'theme') { + localStorage.removeItem('theme'); + } + } + setTheme(theme) { // theme is registered, so set it to the stored cookie variable let old_theme = null; if (this.THEMES_CONFIG[theme]) { - if (localStorage.getItem('theme')) { - old_theme = localStorage.getItem('theme'); + const currentTheme = this.getStoredTheme(); + if (currentTheme) { + old_theme = currentTheme; if (!this.THEMES_CONFIG[old_theme]) { console.log('bad theme found, setting to default'); if (this.defaultTheme === null) { // means it hasn't loaded yet console.error('No default theme detected'); } else { - localStorage.setItem('theme', this.defaultTheme); - old_theme = localStorage.getItem('theme'); // updates old_theme + this.setStoredTheme(this.defaultTheme); + old_theme = this.getStoredTheme(); // updates old_theme } } } - localStorage.setItem('theme', theme); + this.setStoredTheme(theme); this.elementRef.nativeElement.ownerDocument.body.style.backgroundColor = this.THEMES_CONFIG[theme]['background_color']; } else { console.error('Invalid theme: ' + theme); diff --git a/src/app/components/login/login.component.html b/src/app/components/login/login.component.html index 99e70bb..acc3749 100644 --- a/src/app/components/login/login.component.html +++ b/src/app/components/login/login.component.html @@ -1,56 +1,68 @@ - - -
- - User name - - + @if (oidcEnabled) { +
+

Redirecting to your identity provider...

+ + -
- - Password - - -
- - @if (registrationEnabled) { - +
+ } @else { + +
User name - +
Password - - -
-
- - Confirm Password - +
- } -
- @if (selectedTabIndex === 0) { - - } - @if (selectedTabIndex === 1) { - + + @if (selectedTabIndex === 0) { + + } + @if (selectedTabIndex === 1) { + + } } - \ No newline at end of file + diff --git a/src/app/components/login/login.component.ts b/src/app/components/login/login.component.ts index 06bf073..ff509fb 100644 --- a/src/app/components/login/login.component.ts +++ b/src/app/components/login/login.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { PostsService } from 'app/posts.services'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; import { Router } from '@angular/router'; import { filter, take } from 'rxjs/operators'; @@ -25,10 +26,24 @@ export class LoginComponent implements OnInit { registrationPasswordInput = ''; registrationPasswordConfirmationInput = ''; registering = false; + oidcEnabled = false; + oidcRedirecting = false; + returnTo = '/home'; - constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { } + constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router, private route: ActivatedRoute) { } ngOnInit(): void { + const routeReturnTo = this.route.snapshot.queryParamMap.get('returnTo'); + this.returnTo = routeReturnTo && routeReturnTo.startsWith('/') ? routeReturnTo : '/home'; + + const oidcToken = this.route.snapshot.paramMap.get('oidc_token'); + const oidcRedirectPath = this.route.snapshot.paramMap.get('redirect'); + if (oidcToken) { + const redirectPath = oidcRedirectPath && oidcRedirectPath.startsWith('/') ? oidcRedirectPath : this.returnTo; + this.postsService.completeOIDCLogin(oidcToken, redirectPath); + return; + } + if (this.postsService.isLoggedIn && localStorage.getItem('jwt_token') !== 'null') { this.router.navigate(['/home']); } @@ -37,6 +52,12 @@ export class LoginComponent implements OnInit { .subscribe(() => { if (!this.postsService.config['Advanced']['multi_user_mode']) { this.router.navigate(['/home']); + return; + } + this.oidcEnabled = this.postsService.isOIDCEnabled(); + if (this.oidcEnabled) { + this.redirectToOIDC(); + return; } this.registrationEnabled = this.postsService.config['Users'] && this.postsService.config['Users']['allow_registration']; }); @@ -50,7 +71,7 @@ export class LoginComponent implements OnInit { this.postsService.login(this.loginUsernameInput, this.loginPasswordInput).subscribe(res => { this.loggingIn = false; if (res['token']) { - this.postsService.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']); + this.postsService.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions'], this.returnTo); } else { this.openSnackBar('Login failed, unknown error.'); } @@ -114,4 +135,12 @@ export class LoginComponent implements OnInit { }); } + redirectToOIDC() { + if (this.oidcRedirecting) { + return; + } + this.oidcRedirecting = true; + window.location.href = this.postsService.getOIDCLoginURL(this.returnTo || '/home'); + } + } diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 051fca2..060c0b3 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -195,7 +195,9 @@ export class PostsService { this.config = result['YoutubeDLMaterial']; this.titleService.setTitle(this.config['Extra']['title_top']); if (this.config['Advanced']['multi_user_mode']) { - this.checkAdminCreationStatus(); + if (!this.isOIDCEnabled()) { + this.checkAdminCreationStatus(); + } // login stuff if (localStorage.getItem('jwt_token') && localStorage.getItem('jwt_token') !== 'null') { this.token = localStorage.getItem('jwt_token'); @@ -720,7 +722,7 @@ export class PostsService { return this.http.get('https://api.github.com/repos/voc0der/youtubedl-material/releases'); } - afterLogin(user, token, permissions, available_permissions) { + afterLogin(user, token, permissions, available_permissions, redirect_path = '/home') { this.isLoggedIn = true; this.user = user; this.permissions = permissions; @@ -734,8 +736,8 @@ export class PostsService { // needed to re-initialize parts of app after login this.config_reloaded.next(true); - if (this.router.url === '/login') { - this.router.navigate(['/home']); + if (this.router.url.startsWith('/login')) { + this.router.navigateByUrl(redirect_path || '/home'); } } @@ -745,6 +747,30 @@ export class PostsService { return this.http.post(this.path + 'auth/login', body, this.httpOptions); } + completeOIDCLogin(token: string, redirect_path = '/home') { + this.token = token; + this.httpOptions.params = this.httpOptions.params.set('jwt', this.token); + const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions); + call.subscribe(res => { + if (res['token']) { + this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions'], redirect_path); + } + }, () => { + this.sendToLogin(); + this.token = null; + this.resetHttpParams(); + }); + return call; + } + + isOIDCEnabled(): boolean { + return !!(this.config && this.config['Users'] && this.config['Users']['oidc'] && this.config['Users']['oidc']['enabled']); + } + + getOIDCLoginURL(return_to = '/home'): string { + return `${this.path}auth/oidc/login?returnTo=${encodeURIComponent(return_to)}`; + } + // user methods jwtAuth() { const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions); @@ -769,7 +795,7 @@ export class PostsService { this.isLoggedIn = false; this.token = null; localStorage.setItem('jwt_token', null); - if (this.router.url !== '/login') { + if (!this.router.url.startsWith('/login')) { this.router.navigate(['/login']); } @@ -795,11 +821,16 @@ export class PostsService { if (!this.initialized) { this.setInitialized(); } - if (this.router.url === '/login') { + if (this.router.url.startsWith('/login')) { return; } - this.router.navigate(['/login']); + const return_to = this.router.url && this.router.url !== '/login' ? this.router.url : '/home'; + this.router.navigate(['/login'], {queryParams: {returnTo: return_to}}); + + if (this.isOIDCEnabled()) { + return; + } // send login notification this.openSnackBar('You must log in to access this page!'); @@ -843,6 +874,9 @@ export class PostsService { } checkAdminCreationStatus(force_show = false) { + if (this.isOIDCEnabled()) { + return; + } if (!force_show && !this.config['Advanced']['multi_user_mode']) { return; } diff --git a/src/assets/default.json b/src/assets/default.json index c479d6e..931ecad 100644 --- a/src/assets/default.json +++ b/src/assets/default.json @@ -59,6 +59,21 @@ "bindCredentials": "secret", "searchBase": "ou=passport-ldapauth", "searchFilter": "(uid={{username}})" + }, + "oidc": { + "enabled": false, + "issuer_url": "", + "client_id": "", + "client_secret": "", + "redirect_uri": "", + "scope": "openid profile email", + "auto_register": true, + "admin_claim": "groups", + "admin_value": "admin", + "group_claim": "groups", + "allowed_groups": "", + "username_claim": "preferred_username", + "display_name_claim": "preferred_username" } }, "Database": { @@ -76,4 +91,4 @@ "default_downloader": "youtube-dl" } } -} \ No newline at end of file +}