Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
183 lines
8.9 KiB
JavaScript
183 lines
8.9 KiB
JavaScript
const express = require('express');
|
|
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
|
|
|
|
module.exports = function(ctx, getAppSession, appSessionCache) {
|
|
const router = express.Router();
|
|
|
|
// Caddy forward_auth gate: checks TOTP session + injects service credentials
|
|
router.get('/auth/gate/:serviceId', ctx.asyncHandler(async (req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
const serviceId = req.params.serviceId;
|
|
|
|
// Check TOTP session first
|
|
if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') {
|
|
const valid = ctx.session.isValid(req);
|
|
if (!valid) return ctx.errorResponse(res, 401, 'Session expired or invalid', { authenticated: false });
|
|
}
|
|
|
|
// Session valid (or TOTP disabled) - inject credentials if premium SSO is active
|
|
let injected = false;
|
|
const ssoEnabled = ctx.licenseManager.hasFeature('sso');
|
|
if (!ssoEnabled) {
|
|
// Free tier: TOTP gate passes but no credential injection
|
|
return res.status(200).json({ authenticated: true, credentialsInjected: false, premiumRequired: true });
|
|
}
|
|
try {
|
|
const services = await ctx.servicesStateManager.read();
|
|
const service = services.find(s => s.id === serviceId);
|
|
|
|
// External services: inject seedhost Basic Auth
|
|
if (service && service.isExternal) {
|
|
const sharedUser = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
|
|
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
|
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
|
|
const password = svcPass || sharedPass;
|
|
if (sharedUser && password) {
|
|
const basicAuth = Buffer.from(`${sharedUser}:${password}`).toString('base64');
|
|
res.setHeader('Authorization', `Basic ${basicAuth}`);
|
|
injected = true;
|
|
if (service.externalUrl) {
|
|
const appCookies = await getAppSession(serviceId, service.externalUrl, sharedUser, password);
|
|
if (appCookies) res.setHeader('X-App-Cookie', appCookies);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-external services: check per-service Basic Auth
|
|
if (!service || !service.isExternal) {
|
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
|
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
|
if (username && password) {
|
|
const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');
|
|
res.setHeader('Authorization', `Basic ${basicAuth}`);
|
|
injected = true;
|
|
if (service && service.url) {
|
|
const appCookies = await getAppSession(serviceId, service.url, username, password);
|
|
if (appCookies) res.setHeader('X-App-Cookie', appCookies);
|
|
if (serviceId === 'plex') {
|
|
const plexCached = appSessionCache.get('plex');
|
|
if (plexCached && plexCached.token) res.setHeader('X-Plex-Token', plexCached.token);
|
|
}
|
|
if (serviceId === 'jellyfin' || serviceId === 'emby') {
|
|
const mediaCached = appSessionCache.get(serviceId);
|
|
if (mediaCached && mediaCached.token) res.setHeader('X-Emby-Token', mediaCached.token);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject API key
|
|
const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
|
const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
|
const apiKey = arrKey || svcKey;
|
|
if (apiKey) { res.setHeader('X-Api-Key', apiKey); injected = true; }
|
|
} catch (e) {
|
|
ctx.log.warn('auth', 'Credential error', { serviceId, error: e.message });
|
|
}
|
|
|
|
res.status(200).json({ authenticated: true, credentialsInjected: injected });
|
|
}, 'auth-gate'));
|
|
|
|
// Return cached app session token for client-side auth (Premium SSO feature)
|
|
router.get('/auth/app-token/:serviceId', ctx.licenseManager.requirePremium('sso'), ctx.asyncHandler(async (req, res) => {
|
|
const { serviceId } = req.params;
|
|
|
|
if (ctx.totpConfig.enabled && ctx.totpConfig.sessionDuration !== 'never') {
|
|
if (!ctx.session.isValid(req)) return ctx.errorResponse(res, 401, 'Not authenticated');
|
|
}
|
|
|
|
// Jellyfin/Emby: separate browser-specific token
|
|
if (serviceId === 'jellyfin' || serviceId === 'emby') {
|
|
const browserCacheKey = `${serviceId}_browser`;
|
|
const browserCached = appSessionCache.get(browserCacheKey);
|
|
if (browserCached && browserCached.exp > Date.now()) {
|
|
if (browserCached.failed) return ctx.errorResponse(res, 500, 'Login recently failed');
|
|
if (browserCached.token) {
|
|
const resp = { token: browserCached.token };
|
|
if (browserCached.tokenData) Object.assign(resp, browserCached.tokenData);
|
|
return res.json(resp);
|
|
}
|
|
}
|
|
try {
|
|
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
|
const password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
|
if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored');
|
|
const service = await ctx.getServiceById(serviceId);
|
|
const baseUrl = service?.url;
|
|
if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL');
|
|
const mediaAuth = buildMediaAuth(APP.DEVICE_IDS.BROWSER);
|
|
const authResp = await ctx.fetchT(`${baseUrl}/Users/AuthenticateByName`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-Emby-Authorization': mediaAuth },
|
|
body: JSON.stringify({ Username: username, Pw: password }),
|
|
}, TIMEOUTS.HTTP_LONG);
|
|
const authData = await authResp.json();
|
|
if (authData.AccessToken) {
|
|
const tokenData = { userId: authData.User?.Id, serverId: authData.ServerId, serverName: authData.User?.ServerName || serviceId };
|
|
appSessionCache.set(browserCacheKey, { token: authData.AccessToken, tokenData, exp: Date.now() + SESSION_TTL.TOKEN_SESSION });
|
|
return res.json({ token: authData.AccessToken, ...tokenData });
|
|
}
|
|
return ctx.errorResponse(res, 500, '[DC-501] Authentication failed');
|
|
} catch (e) {
|
|
ctx.log.warn('auth', 'Browser token error', { serviceId, error: e.message });
|
|
return ctx.errorResponse(res, 500, e.message);
|
|
}
|
|
}
|
|
|
|
// Check cache first
|
|
const cached = appSessionCache.get(serviceId);
|
|
if (cached && cached.exp > Date.now()) {
|
|
if (cached.failed) return ctx.errorResponse(res, 500, '[DC-501] Login recently failed, retrying in a few minutes');
|
|
if (cached.token) {
|
|
const resp = { token: cached.token };
|
|
if (cached.tokenData) Object.assign(resp, cached.tokenData);
|
|
return res.json(resp);
|
|
}
|
|
const m = cached.cookies.match(/^token=(.+)$/);
|
|
if (m) return res.json({ token: m[1] });
|
|
return res.json({ cookies: cached.cookies });
|
|
}
|
|
|
|
// No cache — get fresh session
|
|
try {
|
|
const service = await ctx.getServiceById(serviceId);
|
|
if (!service) return ctx.errorResponse(res, 404, 'Service not found');
|
|
const baseUrl = service.externalUrl || service.url;
|
|
if (!baseUrl) return ctx.errorResponse(res, 404, 'No service URL');
|
|
|
|
let username, password;
|
|
if (service.isExternal) {
|
|
username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
|
|
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
|
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
|
|
password = svcPass || sharedPass;
|
|
} else {
|
|
username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
|
password = await ctx.credentialManager.retrieve(`service.${serviceId}.password`).catch(() => null);
|
|
}
|
|
|
|
if (!username || !password) return ctx.errorResponse(res, 404, '[DC-500] No credentials stored');
|
|
|
|
const appCookies = await getAppSession(serviceId, baseUrl, username, password);
|
|
if (appCookies) {
|
|
const freshCached = appSessionCache.get(serviceId);
|
|
if (freshCached && freshCached.token) {
|
|
const resp = { token: freshCached.token };
|
|
if (freshCached.tokenData) Object.assign(resp, freshCached.tokenData);
|
|
return res.json(resp);
|
|
}
|
|
const m = appCookies.match(/^token=(.+)$/);
|
|
if (m) return res.json({ token: m[1] });
|
|
return res.json({ cookies: appCookies });
|
|
}
|
|
|
|
ctx.errorResponse(res, 500, '[DC-501] Login failed');
|
|
} catch (e) {
|
|
ctx.log.warn('auth', 'App-token error', { error: e.message });
|
|
ctx.errorResponse(res, 500, e.message);
|
|
}
|
|
}, 'auth-app-token'));
|
|
|
|
return router;
|
|
};
|