"use strict";
/*
 * Copyright 2017 Scott Bender <scott@scottbender.net>
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0

 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const lodash_1 = __importDefault(require("lodash"));
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const signalk_schema_1 = require("@signalk/signalk-schema");
const server_api_1 = require("@signalk/server-api");
const ms_1 = __importDefault(require("ms"));
const body_parser_1 = __importDefault(require("body-parser"));
const cookie_parser_1 = __importDefault(require("cookie-parser"));
const crypto_1 = require("crypto");
const debug_1 = require("./debug");
const login_rate_limiter_1 = require("./login-rate-limiter");
const security_1 = require("./security");
// requestResponse is still CommonJS
/* eslint-disable @typescript-eslint/no-require-imports */
const { createRequest, updateRequest, findRequest, filterRequests } = require('./requestResponse');
/* eslint-enable @typescript-eslint/no-require-imports */
const oidc_1 = require("./oidc");
const constants_1 = require("./constants");
const debug = (0, debug_1.createDebug)('signalk-server:tokensecurity');
const CONFIG_PLUGINID = 'sk-simple-token-security-config';
const passwordSaltRounds = 10;
const permissionDeniedMessage = "You do not have permission to view this resource, <a href='/admin/#/login'>Please Login</a>";
const skPrefix = '/signalk/v1';
const skAuthPrefix = `${skPrefix}/auth`;
// Cookie to hold login info for webapps to use
const BROWSER_LOGININFO_COOKIE_NAME = 'skLoginInfo';
const LOGIN_FAILED_MESSAGE = 'Invalid username/password';
const VALID_PERMISSIONS = new Set(['readonly', 'readwrite', 'admin']);
// Dummy hash for timing attack prevention - pre-generated bcrypt hash
const DUMMY_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012';
function isNever(expiration) {
    return expiration?.toUpperCase() === 'NEVER';
}
function tokenSecurityFactory(app, config) {
    const strategy = {};
    const { expiration = 'NEVER' } = config;
    let { users = [], immutableConfig = false, allowDeviceAccessRequests = true, allowNewUserRegistration = true } = config;
    const { allow_readonly = false, secretKey = process.env.SECRETKEY || (0, crypto_1.randomBytes)(256).toString('hex'), devices = [], acls = [] } = config;
    /**
     * Derive a domain-specific secret from the master key.
     * Used to provide isolated secrets for different subsystems
     * without exposing the JWT signing key.
     */
    function deriveSecret(domain) {
        return (0, crypto_1.createHash)('sha256').update(secretKey).update(domain).digest('hex');
    }
    /**
     * Crypto service for OIDC state encryption.
     * Provides derived secret - tokensecurity knows nothing about OIDC internals.
     */
    const oidcCryptoService = {
        getStateEncryptionSecret: () => deriveSecret('signalk-oidc')
    };
    /**
     * User service for external authentication providers (OIDC, etc.).
     * Abstracts user storage so auth providers don't need to know about
     * the underlying storage mechanism (currently array, could be SQLite etc).
     */
    const externalUserService = {
        async findUserByProvider(lookup) {
            // Currently only OIDC is supported
            if (lookup.provider === 'oidc') {
                const { sub, issuer } = lookup.criteria;
                const user = options.users.find((u) => u.oidc?.sub === sub && u.oidc?.issuer === issuer);
                if (user) {
                    return {
                        username: user.username,
                        type: user.type,
                        providerData: user.oidc
                    };
                }
            }
            return null;
        },
        async findUserByUsername(username) {
            const user = options.users.find((u) => u.username === username);
            if (user) {
                return {
                    username: user.username,
                    type: user.type,
                    providerData: user.oidc
                };
            }
            return null;
        },
        async createUser(externalUser) {
            const newUser = {
                username: externalUser.username,
                type: externalUser.type
            };
            if ((0, security_1.isOIDCUserIdentifier)(externalUser.providerData?.oidc)) {
                newUser.oidc = externalUser.providerData.oidc;
            }
            options.users.push(newUser);
            return new Promise((resolve, reject) => {
                (0, security_1.saveSecurityConfig)(app, options, (err) => {
                    if (err) {
                        reject(err);
                    }
                    else {
                        resolve();
                    }
                });
            });
        },
        async updateUser(username, updates) {
            const user = options.users.find((u) => u.username === username);
            if (!user) {
                throw new Error(`User not found: ${username}`);
            }
            if (updates.type) {
                user.type = updates.type;
            }
            if ((0, security_1.isOIDCUserIdentifier)(updates.providerData?.oidc)) {
                user.oidc = updates.providerData.oidc;
            }
            return new Promise((resolve, reject) => {
                (0, security_1.saveSecurityConfig)(app, options, (err) => {
                    if (err) {
                        reject(err);
                    }
                    else {
                        resolve();
                    }
                });
            });
        }
    };
    if (process.env.ADMINUSER) {
        const adminUserParts = process.env.ADMINUSER.split(':');
        if (adminUserParts.length !== 2) {
            console.error('ADMINUSER env parameters must be in username:password format');
            process.exit(-1);
        }
        users = [
            {
                username: adminUserParts[0],
                type: 'admin',
                password: bcryptjs_1.default.hashSync(adminUserParts[1], bcryptjs_1.default.genSaltSync(passwordSaltRounds))
            }
        ];
        immutableConfig = true;
    }
    if (process.env.ALLOW_DEVICE_ACCESS_REQUESTS) {
        allowDeviceAccessRequests =
            process.env.ALLOW_DEVICE_ACCESS_REQUESTS === 'true';
    }
    if (process.env.ALLOW_NEW_USER_REGISTRATION) {
        allowNewUserRegistration =
            process.env.ALLOW_NEW_USER_REGISTRATION === 'true';
    }
    let options = {
        allow_readonly,
        expiration,
        secretKey,
        users,
        devices,
        immutableConfig,
        acls,
        allowDeviceAccessRequests,
        allowNewUserRegistration,
        oidc: config.oidc // Include OIDC config from security.json
    };
    // so that enableSecurity gets the defaults to save
    lodash_1.default.merge(config, options);
    function getConfiguration() {
        return options;
    }
    strategy.getConfiguration = getConfiguration;
    // Parse and cache OIDC configuration eagerly so that invalid config
    // is detected at startup rather than on first request (see #2594)
    let cachedOIDCConfig = null;
    function parseAndCacheOIDCConfig() {
        try {
            cachedOIDCConfig = (0, oidc_1.parseOIDCConfig)(options);
        }
        catch (err) {
            if (err instanceof oidc_1.OIDCError) {
                console.error(`OIDC configuration error [${err.code}]: ${err.message}`);
                console.error('OIDC will be disabled. Fix the configuration and restart.');
            }
            else {
                console.error('Unexpected error parsing OIDC configuration:', err);
                console.error('OIDC will be disabled.');
            }
            cachedOIDCConfig = { ...oidc_1.OIDC_DEFAULTS, enabled: false };
        }
    }
    parseAndCacheOIDCConfig();
    function getOIDCConfig() {
        return cachedOIDCConfig;
    }
    function updateOIDCConfig(newOidcConfig) {
        options.oidc = newOidcConfig;
        parseAndCacheOIDCConfig();
    }
    strategy.updateOIDCConfig = updateOIDCConfig;
    function getSessionCookieOptions(req, rememberMe = false) {
        const configuration = getConfiguration();
        const cookieOptions = {
            sameSite: 'strict',
            secure: req.secure || req.headers['x-forwarded-proto'] === 'https'
        };
        if (rememberMe) {
            const expValue = isNever(configuration.expiration)
                ? '10y'
                : configuration.expiration || '1h';
            cookieOptions.maxAge = (0, ms_1.default)(expValue);
        }
        return cookieOptions;
    }
    function setSessionCookie(res, req, token, username, sessionOptions = {}) {
        const cookieOptions = getSessionCookieOptions(req, sessionOptions.rememberMe);
        // Auth cookie must be httpOnly for security
        const authCookieOptions = { ...cookieOptions, httpOnly: true };
        res.cookie('JAUTHENTICATION', token, authCookieOptions);
        // Login info cookie must NOT be httpOnly so JS can access it
        res.cookie(BROWSER_LOGININFO_COOKIE_NAME, JSON.stringify({ status: 'loggedIn', user: username }), cookieOptions);
    }
    function clearSessionCookie(res) {
        res.clearCookie('JAUTHENTICATION');
        res.clearCookie(BROWSER_LOGININFO_COOKIE_NAME);
    }
    function generateJWT(userId, tokenExpiration, rememberMe) {
        const configuration = getConfiguration();
        const theExpiration = tokenExpiration || configuration.expiration || '1h';
        const payload = { id: userId };
        if (rememberMe) {
            payload.rememberMe = true;
        }
        const jwtOptions = {};
        if (!isNever(theExpiration)) {
            jwtOptions.expiresIn = theExpiration;
        }
        return jsonwebtoken_1.default.sign(payload, configuration.secretKey, jwtOptions);
    }
    function getIsEnabled() {
        // var options = getOptions();
        // return typeof options.enabled !== 'undefined' && options.enabled;
        return true;
    }
    function assertConfigImmutability() {
        if (options.immutableConfig) {
            throw new Error('Configuration is immutable');
        }
    }
    function handlePermissionDenied(req, res) {
        res.status(401);
        if (req.accepts('application/json') && !req.accepts('text/html')) {
            res.set('Content-Type', 'application/json');
            res.json({ error: 'Permission Denied' });
        }
        else {
            res.type('text/plain').send(permissionDeniedMessage);
        }
    }
    function hasAdminAccess(req) {
        const skReq = req;
        return (skReq.skIsAuthenticated === true &&
            skReq.skPrincipal !== undefined &&
            skReq.skPrincipal.permissions === 'admin');
    }
    strategy.hasAdminAccess = hasAdminAccess;
    function writeAuthenticationMiddleware() {
        return function (req, res, next) {
            const skReq = req;
            if (!getIsEnabled()) {
                return next();
            }
            debug('skIsAuthenticated: ' + skReq.skIsAuthenticated);
            if (skReq.skIsAuthenticated) {
                if (skReq.skPrincipal?.permissions === 'admin' ||
                    skReq.skPrincipal?.permissions === 'readwrite') {
                    return next();
                }
            }
            handlePermissionDenied(req, res);
        };
    }
    function adminAuthenticationMiddleware(redirect) {
        return function (req, res, next) {
            const skReq = req;
            if (!getIsEnabled()) {
                return next();
            }
            if (hasAdminAccess(req)) {
                return next();
            }
            if (skReq.skIsAuthenticated && skReq.skPrincipal) {
                if (skReq.skPrincipal.identifier === 'AUTO' && redirect) {
                    res.redirect('/admin/#/login');
                }
                else {
                    handlePermissionDenied(req, res);
                }
            }
            else if (redirect) {
                res.redirect('/admin/#/login');
            }
            else {
                handlePermissionDenied(req, res);
            }
        };
    }
    function setupApp() {
        const rawHttpRateLimits = process.env.HTTP_RATE_LIMITS;
        const parsedParts = typeof rawHttpRateLimits === 'string'
            ? rawHttpRateLimits
                .split(/[\s,]+/)
                .map((p) => p.trim())
                .filter(Boolean)
            : [];
        let loginWindowMs = 10 * 60 * 1000;
        let loginMax = 100;
        for (const part of parsedParts) {
            const eqIndex = part.indexOf('=');
            if (eqIndex === -1) {
                continue;
            }
            const key = part.slice(0, eqIndex).trim().toLowerCase();
            const value = part.slice(eqIndex + 1).trim();
            const parsed = Number.parseInt(value, 10);
            if ((key === 'windowms' || key === 'window') && Number.isFinite(parsed)) {
                loginWindowMs = parsed;
            }
            else if ((key === 'login' || key === 'loginmax') &&
                Number.isFinite(parsed)) {
                loginMax = parsed;
            }
        }
        const loginRateLimiter = (0, login_rate_limiter_1.createLoginRateLimiter)(loginWindowMs, loginMax);
        strategy.loginRateLimiter = loginRateLimiter;
        const loginLimiter = (req, res, next) => {
            const { allowed, retryAfterMs } = loginRateLimiter.check(req.ip ?? '');
            if (!allowed) {
                res.set('Retry-After', String(Math.ceil(retryAfterMs / 1000)));
                res.status(429).json({ message: login_rate_limiter_1.LOGIN_RATE_LIMIT_MESSAGE });
                return;
            }
            next();
        };
        app.use(body_parser_1.default.urlencoded({ extended: true }));
        app.use((0, cookie_parser_1.default)());
        function getSafeDestination(destination) {
            if (typeof destination !== 'string') {
                return '/';
            }
            const dest = destination.trim();
            // Allow only relative redirects. Reject protocol-relative URLs (//evil.com).
            if (!dest.startsWith('/') || dest.startsWith('//')) {
                return '/';
            }
            return dest;
        }
        app.post(['/login', `${skAuthPrefix}/login`], loginLimiter, (req, res) => {
            const name = req.body.username;
            const password = req.body.password;
            const remember = req.body.rememberMe;
            login(name, password, remember)
                .then((reply) => {
                const requestType = req.get('Content-Type');
                if (reply.statusCode === 200 && reply.token && reply.user) {
                    setSessionCookie(res, req, reply.token, reply.user, {
                        rememberMe: remember
                    });
                    if (requestType === 'application/json') {
                        res.json({
                            timeToLive: reply.timeToLive,
                            token: reply.token
                        });
                    }
                    else {
                        res.redirect(getSafeDestination(req.body.destination));
                    }
                }
                else {
                    if (requestType === 'application/json') {
                        res.status(reply.statusCode).send(reply);
                    }
                    else {
                        res.status(reply.statusCode).send(reply.message);
                    }
                }
            })
                .catch((err) => {
                console.log(err);
                res.status(502).send('Login Failure');
            });
        });
        app.use('/', http_authorize(false, true)) //semicolon required
        ;
        [
            '/apps',
            '/appstore',
            '/plugins',
            '/restart',
            '/runDiscovery',
            '/security',
            '/vessel',
            '/providers',
            '/settings',
            '/webapps',
            '/availablePaths',
            '/hasAnalyzer',
            '/skServer/inputTest'
        ].forEach((p) => app.use(`${constants_1.SERVERROUTESPREFIX}${p}`, http_authorize(false)));
        app.put(['/logout', `${skAuthPrefix}/logout`], function (req, res) {
            clearSessionCookie(res);
            res.json('Logout OK');
        });
        // Register OIDC authentication routes
        (0, oidc_1.registerOIDCRoutes)(app, {
            getOIDCConfig,
            setSessionCookie,
            clearSessionCookie,
            generateJWT,
            cryptoService: oidcCryptoService,
            userService: externalUserService
        });
        // Register OIDC admin routes (GET/PUT /security/oidc, POST /security/oidc/test)
        (0, oidc_1.registerOIDCAdminRoutes)(app, {
            allowConfigure: (req) => strategy.allowConfigure(req),
            getSecurityConfig: () => options,
            saveSecurityConfig: (securityConfig, callback) => (0, security_1.saveSecurityConfig)(app, securityConfig, callback),
            updateOIDCConfig
        });
        [
            '/restart',
            '/runDiscovery',
            '/plugins',
            '/appstore',
            '/security',
            '/settings',
            '/backup',
            '/restore',
            '/providers',
            '/vessel',
            '/serialports'
        ].forEach((p) => app.use(`${constants_1.SERVERROUTESPREFIX}${p}`, adminAuthenticationMiddleware(false)));
        app.use('/plugins', adminAuthenticationMiddleware(false));
        //TODO remove after grace period
        app.use('/loginStatus', http_authorize(false, true));
        app.use(`${constants_1.SERVERROUTESPREFIX}/loginStatus`, http_authorize(false, true));
        const no_redir = http_authorize(false);
        app.use('/signalk/v1/api/*', function (req, res, next) {
            no_redir(req, res, next);
        });
        app.use('/signalk/v1/snapshot/*', function (req, res, next) {
            no_redir(req, res, next);
        });
        app.use('/signalk/v2/api/*', function (req, res, next) {
            no_redir(req, res, next);
        });
        app.put('/signalk/v1/*', writeAuthenticationMiddleware());
        app.put('/signalk/v2/*', writeAuthenticationMiddleware());
        app.post('/signalk/v2/*', writeAuthenticationMiddleware());
        app.delete('/signalk/v2/*', writeAuthenticationMiddleware());
    }
    function login(name, password, rememberMe) {
        return new Promise((resolve, reject) => {
            debug('handing login for user: ' + name);
            // Validate input to prevent crashes on malformed requests
            if (typeof name !== 'string' || typeof password !== 'string') {
                // Still run bcrypt to prevent timing attacks on input validation
                bcryptjs_1.default.compare('dummy', DUMMY_HASH, () => {
                    resolve({ statusCode: 401, message: LOGIN_FAILED_MESSAGE });
                });
                return;
            }
            const configuration = getConfiguration();
            const user = configuration.users.find((aUser) => aUser.username === name);
            // Always run bcrypt.compare to prevent timing attacks that reveal
            // whether a username exists. Use a dummy hash if user not found.
            const hashToCompare = user && user.password ? user.password : DUMMY_HASH;
            bcryptjs_1.default.compare(password, hashToCompare, (err, matches) => {
                if (err) {
                    reject(err);
                }
                else if (matches === true && user && user.password) {
                    // Only succeed if user exists AND password matched real hash
                    const payload = { id: user.username };
                    if (rememberMe) {
                        payload.rememberMe = true;
                    }
                    const theExpiration = configuration.expiration || '1h';
                    const jwtOptions = {};
                    if (!isNever(theExpiration)) {
                        jwtOptions.expiresIn = theExpiration;
                    }
                    debug.enabled &&
                        debug(`jwt expiration:${JSON.stringify(jwtOptions)}`);
                    try {
                        const token = jsonwebtoken_1.default.sign(payload, configuration.secretKey, jwtOptions);
                        const timeToLive = !isNever(theExpiration)
                            ? Math.floor((0, ms_1.default)(theExpiration) / 1000)
                            : null;
                        resolve({
                            statusCode: 200,
                            token,
                            user: user.username,
                            timeToLive
                        });
                    }
                    catch (signErr) {
                        resolve({
                            statusCode: 500,
                            message: 'Unable to sign token: ' + signErr.message
                        });
                    }
                }
                else {
                    debug('password did not match');
                    resolve({ statusCode: 401, message: LOGIN_FAILED_MESSAGE });
                }
            });
        });
    }
    strategy.validateConfiguration = (newConfiguration) => {
        const configuration = getConfiguration();
        const theExpiration = newConfiguration.expiration || '1h';
        if (!isNever(theExpiration)) {
            jsonwebtoken_1.default.sign({ dummy: 'payload' }, configuration.secretKey, {
                expiresIn: theExpiration
            });
        }
    };
    strategy.getAuthRequiredString = () => {
        return strategy.allowReadOnly() ? 'forwrite' : 'always';
    };
    strategy.supportsLogin = () => true;
    strategy.login = login;
    strategy.addAdminMiddleware = function (aPath) {
        app.use(aPath, http_authorize(false));
        app.use(aPath, adminAuthenticationMiddleware(false));
    };
    strategy.addAdminWriteMiddleware = function (aPath) {
        app.use(aPath, http_authorize(false));
        app.put(aPath, adminAuthenticationMiddleware(false));
        app.post(aPath, adminAuthenticationMiddleware(false));
    };
    strategy.addWriteMiddleware = function (aPath) {
        app.use(aPath, http_authorize(false));
        app.put(aPath, writeAuthenticationMiddleware());
        app.post(aPath, writeAuthenticationMiddleware());
    };
    strategy.generateToken = function (req, res, next, id, theExpiration) {
        const configuration = getConfiguration();
        const payload = { id: id };
        const token = jsonwebtoken_1.default.sign(payload, configuration.secretKey, {
            expiresIn: theExpiration
        });
        res.type('text/plain').send(token);
    };
    strategy.allowReadOnly = function () {
        const configuration = getConfiguration();
        return configuration.allow_readonly;
    };
    strategy.allowRestart = function (req) {
        return hasAdminAccess(req);
    };
    strategy.allowConfigure = function (req) {
        return hasAdminAccess(req);
    };
    strategy.getLoginStatus = function (req) {
        const skReq = req;
        const configuration = getConfiguration();
        const result = {
            status: skReq.skIsAuthenticated ? 'loggedIn' : 'notLoggedIn',
            readOnlyAccess: configuration.allow_readonly,
            authenticationRequired: true,
            allowNewUserRegistration: configuration.allowNewUserRegistration,
            allowDeviceAccessRequests: configuration.allowDeviceAccessRequests
        };
        if (skReq.skIsAuthenticated && skReq.skPrincipal) {
            result.userLevel = skReq.skPrincipal.permissions;
            result.username = skReq.skPrincipal.identifier;
        }
        if (configuration.users.length === 0) {
            result.noUsers = true;
        }
        // Add OIDC status
        const oidcConfig = getOIDCConfig();
        if (oidcConfig.enabled) {
            result.oidcEnabled = true;
            result.oidcAutoLogin = oidcConfig.autoLogin || false;
            result.oidcLoginUrl = '/signalk/v1/auth/oidc/login';
            if (oidcConfig.providerName) {
                result.oidcProviderName = oidcConfig.providerName;
            }
        }
        return result;
    };
    strategy.getConfig = (aConfig) => {
        // Note: This mutates the input object, matching original JS behavior.
        // Callers may depend on this side effect.
        const mutableConfig = aConfig;
        delete mutableConfig.users;
        delete mutableConfig.secretKey;
        return aConfig;
    };
    strategy.setConfig = (aConfig, newConfig) => {
        assertConfigImmutability();
        newConfig.users = aConfig.users;
        newConfig.devices = aConfig.devices;
        newConfig.secretKey = aConfig.secretKey;
        options = newConfig;
        parseAndCacheOIDCConfig();
        return newConfig;
    };
    strategy.getUsers = (aConfig) => {
        if (aConfig && aConfig.users) {
            return aConfig.users.map((user) => {
                const userData = {
                    userId: user.username,
                    type: user.type,
                    isOIDC: !!user.oidc
                };
                // Include OIDC metadata for OIDC users
                if (user.oidc) {
                    userData.oidc = {
                        issuer: user.oidc.issuer,
                        email: user.oidc.email,
                        name: user.oidc.name
                    };
                }
                return userData;
            });
        }
        else {
            return [];
        }
    };
    function addUser(theConfig, user, callback) {
        assertConfigImmutability();
        if (theConfig.users?.find((u) => u.username === user.userId)) {
            callback(new Error('User already exists'));
            return;
        }
        const newUser = {
            username: user.userId,
            type: user.type
        };
        function finish(finalUser, err) {
            if (!theConfig.users) {
                theConfig.users = [];
            }
            theConfig.users.push(finalUser);
            options = theConfig;
            callback(err, theConfig);
        }
        if (user.password) {
            bcryptjs_1.default.hash(user.password, passwordSaltRounds, (err, hash) => {
                if (err) {
                    callback(err);
                }
                else {
                    newUser.password = hash;
                    finish(newUser, undefined);
                }
            });
        }
        else {
            finish(newUser, undefined);
        }
    }
    strategy.updateUser = (theConfig, username, updates, callback) => {
        assertConfigImmutability();
        const user = theConfig.users.find((aUser) => aUser.username === username);
        if (!user) {
            callback(new Error('user not found'));
            return;
        }
        if (updates.type) {
            user.type = updates.type;
        }
        if (updates.password) {
            bcryptjs_1.default.hash(updates.password, passwordSaltRounds, (err, hash) => {
                if (err) {
                    callback(err);
                }
                else {
                    user.password = hash;
                    callback(null, theConfig);
                }
            });
        }
        else {
            callback(null, theConfig);
        }
        options = theConfig;
    };
    // The addUser interface expects User with 'username', but callers pass objects
    // with 'userId'. We cast to match the interface signature.
    strategy.addUser = addUser;
    strategy.setPassword = (theConfig, username, password, callback) => {
        assertConfigImmutability();
        bcryptjs_1.default.hash(password, passwordSaltRounds, (err, hash) => {
            if (err) {
                callback(err);
            }
            else {
                const user = theConfig.users.find((u) => u.username === username);
                if (user) {
                    user.password = hash;
                }
                options = theConfig;
                callback(null, theConfig);
            }
        });
    };
    strategy.deleteUser = (theConfig, username, callback) => {
        assertConfigImmutability();
        for (let i = theConfig.users.length - 1; i >= 0; i--) {
            if (theConfig.users[i].username === username) {
                theConfig.users.splice(i, 1);
                break;
            }
        }
        options = theConfig;
        callback(null, theConfig);
    };
    strategy.getDevices = (theConfig) => {
        if (theConfig && theConfig.devices) {
            return theConfig.devices;
        }
        else {
            return [];
        }
    };
    strategy.deleteDevice = (theConfig, clientId, callback) => {
        assertConfigImmutability();
        for (let i = theConfig.devices.length - 1; i >= 0; i--) {
            if (theConfig.devices[i].clientId === clientId) {
                theConfig.devices.splice(i, 1);
                break;
            }
        }
        options = theConfig;
        callback(null, theConfig);
    };
    strategy.updateDevice = (theConfig, clientId, updates, callback) => {
        assertConfigImmutability();
        const device = theConfig.devices.find((d) => d.clientId === clientId);
        if (!device) {
            callback(new Error('device not found'));
            return;
        }
        if (updates.permissions) {
            device.permissions = updates.permissions;
        }
        if (updates.description) {
            device.description = updates.description;
        }
        callback(null, theConfig);
        options = theConfig;
    };
    strategy.shouldAllowWrite = function (req, delta) {
        const skReq = req;
        if (skReq.skPrincipal &&
            (skReq.skPrincipal.permissions === 'admin' ||
                skReq.skPrincipal.permissions === 'readwrite')) {
            const context = delta.context === app.selfContext
                ? 'vessels.self'
                : delta.context || 'vessels.self';
            const notAllowed = delta.updates.find((update) => {
                let source = update.$source;
                if (!source) {
                    source = (0, signalk_schema_1.getSourceId)(update.source);
                }
                if ((0, server_api_1.hasValues)(update)) {
                    return update.values.find((valuePath) => {
                        if (valuePath === null ||
                            valuePath === undefined ||
                            typeof valuePath.path !== 'string') {
                            return true;
                        }
                        return (strategy.checkACL(skReq.skPrincipal.identifier, context, valuePath.path, source, 'write') === false);
                    });
                }
                else if ((0, server_api_1.hasMeta)(update)) {
                    return update.meta.find((metaPath) => {
                        if (metaPath === null ||
                            metaPath === undefined ||
                            typeof metaPath.path !== 'string') {
                            return true;
                        }
                        return (strategy.checkACL(skReq.skPrincipal.identifier, context, metaPath.path, source, 'write') === false);
                    });
                }
                return false;
            });
            // true if we did not find anything disallowing the write
            return lodash_1.default.isUndefined(notAllowed);
        }
        return false;
    };
    strategy.shouldAllowPut = function (req, _context, source, thePath) {
        const skReq = req;
        if (skReq.skPrincipal &&
            (skReq.skPrincipal.permissions === 'admin' ||
                skReq.skPrincipal.permissions === 'readwrite')) {
            const context = _context === app.selfContext ? 'vessels.self' : _context;
            return strategy.checkACL(skReq.skPrincipal.identifier, context, thePath, source, 'put');
        }
        return false;
    };
    strategy.anyACLs = () => {
        const configuration = getConfiguration();
        return !!(configuration.acls && configuration.acls.length);
    };
    strategy.filterReadDelta = (principal, delta) => {
        const configuration = getConfiguration();
        if (delta.updates &&
            configuration.acls &&
            configuration.acls.length &&
            principal) {
            const filtered = { ...delta };
            const context = delta.context === app.selfContext
                ? 'vessels.self'
                : delta.context || 'vessels.self';
            filtered.updates = delta.updates
                .map((update) => {
                if ((0, server_api_1.hasValues)(update)) {
                    const res = update.values
                        .map((valuePath) => {
                        return strategy.checkACL(principal.identifier, context, valuePath.path, update.source, 'read')
                            ? valuePath
                            : null;
                    })
                        .filter((vp) => vp !== null);
                    const updatedUpdate = { ...update, values: res };
                    return res.length > 0 ? updatedUpdate : null;
                }
                else if ((0, server_api_1.hasMeta)(update)) {
                    const res = update.meta
                        .map((metaPath) => {
                        return strategy.checkACL(principal.identifier, context, metaPath.path, update.source, 'read')
                            ? metaPath
                            : null;
                    })
                        .filter((mp) => mp !== null);
                    const updatedUpdate = { ...update, meta: res };
                    return res.length > 0 ? updatedUpdate : null;
                }
                return update;
            })
                .filter((update) => update !== null);
            return filtered.updates.length > 0 ? filtered : null;
        }
        else if (!principal) {
            return null;
        }
        else {
            return delta;
        }
    };
    strategy.verifyWS = function (spark) {
        if (!spark.lastTokenVerify) {
            spark.lastTokenVerify = Date.now();
            return;
        }
        if (!getIsEnabled()) {
            return;
        }
        const now = Date.now();
        if (now - spark.lastTokenVerify > 60 * 1000) {
            debug('verify token');
            spark.lastTokenVerify = now;
            strategy.authorizeWS(spark);
        }
    };
    function getAuthorizationFromHeaders(req) {
        if (req.headers) {
            let header = req.headers.authorization;
            if (!header) {
                header = req.headers['x-authorization'];
            }
            // Handle array values (take first element)
            const headerValue = Array.isArray(header) ? header[0] : header;
            if (headerValue && headerValue.startsWith('Bearer ')) {
                return headerValue.substring('Bearer '.length);
            }
            if (headerValue && headerValue.startsWith('JWT ')) {
                return headerValue.substring('JWT '.length);
            }
        }
        return undefined;
    }
    strategy.authorizeWS = function (req) {
        let token = req.token;
        let error;
        let payload;
        if (!getIsEnabled()) {
            return;
        }
        const configuration = getConfiguration();
        if (!token) {
            if (req.query && req.query.token) {
                token = req.query.token;
            }
            else {
                token = getAuthorizationFromHeaders(req);
            }
        }
        if (!token) {
            token = req.cookies && req.cookies.JAUTHENTICATION;
        }
        //
        // `jwt-simple` throws errors if something goes wrong when decoding the JWT.
        //
        if (token) {
            payload = jsonwebtoken_1.default.verify(token, configuration.secretKey);
            if (!payload) {
                error = new security_1.InvalidTokenError('Invalid access token');
            }
            else if (payload.exp && Date.now() / 1000 > payload.exp) {
                //
                // At this point we have decoded and verified the token. Check if it is
                // expired.
                //
                error = new security_1.InvalidTokenError('Expired access token');
            }
        }
        if (error) {
            // Token was provided but is invalid — always reject
            debug(error.message);
            throw error;
        }
        if (!token) {
            if (configuration.allow_readonly) {
                req.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' };
                return;
            }
            else {
                error = new Error('Missing access token');
                debug(error.message);
                throw error;
            }
        }
        //
        // Check if the user/device is still present and allowed in our db. You could tweak
        // this to invalidate a token.
        //
        const principal = getPrincipal(payload);
        if (!principal) {
            error = new security_1.InvalidTokenError(`Invalid identity ${JSON.stringify(payload)}`);
            debug(error.message);
            throw error;
        }
        req.skPrincipal = principal;
        req.skIsAuthenticated = true;
    };
    strategy.checkACL = (id, context, thePath, source, operation) => {
        const configuration = getConfiguration();
        if (!configuration.acls || configuration.acls.length === 0) {
            // no acls, so allow anything
            return true;
        }
        const acl = configuration.acls.find((theAcl) => {
            const pattern = theAcl.context
                .replace(/[\\^$+?()[\]{}|]/g, '\\$&')
                .replace(/\./g, '\\.')
                .replace(/\*/g, '.*');
            const matcher = new RegExp('^' + pattern + '$');
            return matcher.test(context);
        });
        if (acl) {
            const pathPerms = acl.resources.find((p) => {
                let perms;
                if (p.paths) {
                    perms = p.paths.find((aPath) => {
                        const pattern = aPath
                            .replace(/[\\^$+?()[\]{}|]/g, '\\$&')
                            .replace(/\./g, '\\.')
                            .replace(/\*/g, '.*');
                        const matcher = new RegExp('^' + pattern + '$');
                        return matcher.test(thePath);
                    });
                }
                else if (p.sources) {
                    perms = p.sources.find((s) => {
                        const pattern = s
                            .replace(/[\\^$+?()[\]{}|]/g, '\\$&')
                            .replace(/\./g, '\\.')
                            .replace(/\*/g, '.*');
                        const matcher = new RegExp('^' + pattern + '$');
                        return matcher.test(source);
                    });
                }
                return perms;
            });
            if (pathPerms) {
                let perms = pathPerms.permissions.filter((p) => p.subject === id);
                perms = perms.concat(pathPerms.permissions.filter((p) => p.subject === 'any'));
                if (perms.length === 0) {
                    return false;
                }
                return (perms.find((perm) => {
                    if (operation === 'read' &&
                        (perm.permission === 'write' || perm.permission === 'read')) {
                        return true;
                    }
                    else if (operation === 'write' && perm.permission === 'write') {
                        return true;
                    }
                    else if (operation === 'put' && perm.permission === 'put') {
                        return true;
                    }
                    else {
                        return false;
                    }
                }) !== undefined);
            }
        }
        return false;
    };
    strategy.isDummy = () => {
        return false;
    };
    strategy.canAuthorizeWS = () => {
        return true;
    };
    strategy.shouldFilterDeltas = () => {
        const configuration = getConfiguration();
        return !!(configuration.acls && configuration.acls.length > 0);
    };
    function getPrincipal(payload) {
        let principal;
        if (payload.id) {
            const user = options.users.find((theUser) => theUser.username === payload.id);
            if (user) {
                principal = {
                    identifier: user.username,
                    permissions: user.type
                };
            }
        }
        else if (payload.device && options.devices) {
            const device = options.devices.find((aDevice) => aDevice.clientId === payload.device);
            if (device) {
                principal = {
                    identifier: device.clientId,
                    permissions: device.permissions
                };
            }
        }
        return principal;
    }
    function sanitizeLogField(value) {
        return typeof value === 'string'
            ? value.replace(/[\r\n\t]/g, ' ').slice(0, 128)
            : 'unknown';
    }
    function logUnknownUser(jwtPayload) {
        const identity = sanitizeLogField(jwtPayload.id ?? jwtPayload.device);
        console.warn(`unknown user: ${identity}`);
    }
    function logBadToken(token, path, err) {
        // jwt.decode returns null instead of throwing when the token is malformed
        const payload = jsonwebtoken_1.default.decode(token);
        const id = sanitizeLogField(payload?.id);
        const device = sanitizeLogField(payload?.device);
        console.warn(`bad token: ${err.message} (user: ${id}, device: ${device}, path: ${path})`);
    }
    function http_authorize(redirect, forLoginStatus) {
        // debug('http_authorize: ' + redirect)
        return function (req, res, next) {
            const skReq = req;
            let token = skReq.cookies?.JAUTHENTICATION;
            debug(`http_authorize: ${req.path} (forLogin: ${forLoginStatus})`);
            if (!getIsEnabled()) {
                return next();
            }
            const configuration = getConfiguration();
            if (!token) {
                token = getAuthorizationFromHeaders(req);
            }
            if (token) {
                jsonwebtoken_1.default.verify(token, configuration.secretKey, function (err, decoded) {
                    debug('verify');
                    if (!err) {
                        const principal = getPrincipal(decoded);
                        if (principal) {
                            debug('authorized');
                            skReq.skPrincipal = principal;
                            skReq.skIsAuthenticated = true;
                            skReq.userLoggedIn = true;
                            const jwtPayload = decoded;
                            if (jwtPayload.id &&
                                jwtPayload.exp &&
                                jwtPayload.iat &&
                                Date.now() / 1000 >
                                    jwtPayload.iat + (jwtPayload.exp - jwtPayload.iat) / 2) {
                                const newToken = generateJWT(jwtPayload.id, undefined, jwtPayload.rememberMe);
                                setSessionCookie(res, req, newToken, jwtPayload.id, {
                                    rememberMe: jwtPayload.rememberMe
                                });
                                debug('token refreshed for %s', jwtPayload.id);
                            }
                            next();
                            return;
                        }
                        else {
                            logUnknownUser(decoded);
                        }
                    }
                    else {
                        logBadToken(token, req.path, err);
                    }
                    // Token was provided but is invalid/revoked — always reject.
                    // allow_readonly only applies when no token is provided at all.
                    console.warn('force clearing invalid/revoked auth cookie for %s', req.path);
                    clearSessionCookie(res);
                    if (forLoginStatus) {
                        skReq.skIsAuthenticated = false;
                        return next();
                    }
                    res.status(401).send('bad auth token');
                });
            }
            else {
                debug('no token');
                if (configuration.allow_readonly && !forLoginStatus) {
                    skReq.skPrincipal = { identifier: 'AUTO', permissions: 'readonly' };
                    skReq.skIsAuthenticated = true;
                    return next();
                }
                else {
                    skReq.skIsAuthenticated = false;
                    if (forLoginStatus) {
                        next();
                    }
                    else if (redirect) {
                        debug('redirecting to login');
                        res.redirect('/admin/#/login');
                    }
                    else {
                        res.status(401).send('Unauthorized');
                    }
                }
            }
        };
    }
    strategy.getAccessRequestsResponse = () => {
        return filterRequests('accessRequest', 'PENDING');
    };
    function sendAccessRequestsUpdate() {
        app.emit('serverAdminEvent', {
            type: 'ACCESS_REQUEST',
            from: CONFIG_PLUGINID,
            data: strategy.getAccessRequestsResponse()
        });
    }
    strategy.setAccessRequestStatus = (theConfig, identifier, status, body, cb) => {
        const request = findRequest((r) => r.state === 'PENDING' && r.accessIdentifier === identifier);
        if (!request) {
            cb(new Error('not found'));
            return;
        }
        const permissionPart = request.requestedPermissions
            ? request.permissions
            : 'any';
        app.handleMessage(CONFIG_PLUGINID, {
            context: ('vessels.' + app.selfId),
            updates: [
                {
                    values: [
                        {
                            path: `notifications.security.accessRequest.${permissionPart}.${identifier}`,
                            value: {
                                state: 'normal',
                                method: [],
                                message: `The device "${request.accessDescription}" has been ${status}`,
                                timestamp: new Date().toISOString()
                            }
                        }
                    ]
                }
            ]
        });
        let approved;
        if (status === 'approved') {
            if (!request.clientRequest.requestedPermissions &&
                !VALID_PERMISSIONS.has(body.permissions)) {
                cb(new Error('Invalid permissions value'), theConfig);
                return;
            }
            if (request.clientRequest.accessRequest.clientId) {
                const payload = { device: identifier };
                const jwtOptions = {};
                const expiresIn = body.expiration || theConfig.expiration;
                if (!isNever(expiresIn)) {
                    jwtOptions.expiresIn = expiresIn;
                }
                const token = jsonwebtoken_1.default.sign(payload, theConfig.secretKey, jwtOptions);
                const decoded = jsonwebtoken_1.default.decode(token);
                if (!theConfig.devices) {
                    theConfig.devices = [];
                }
                theConfig.devices = theConfig.devices.filter((d) => d.clientId !== identifier);
                theConfig.devices.push({
                    clientId: request.accessIdentifier,
                    permissions: !request.clientRequest.requestedPermissions
                        ? body.permissions
                        : request.permissions,
                    config: body.config,
                    description: request.accessDescription,
                    requestedPermissions: request.clientRequest.requestedPermissions
                        ? 'true'
                        : '',
                    tokenExpiry: decoded?.exp
                });
                request.token = token;
            }
            else {
                theConfig.users.push({
                    username: identifier,
                    password: request.accessPassword,
                    type: body.permissions
                });
            }
            approved = true;
        }
        else if (status === 'denied') {
            approved = false;
        }
        else {
            cb(new Error('Unknown status value'), theConfig);
            return;
        }
        options = theConfig;
        updateRequest(request.requestId, 'COMPLETED', {
            statusCode: 200,
            data: {
                permission: approved ? 'APPROVED' : 'DENIED',
                token: request.token
            }
        })
            .then(() => {
            cb(null, theConfig);
            sendAccessRequestsUpdate();
        })
            .catch((err) => {
            cb(err);
        });
    };
    function validateAccessRequest(request) {
        if (request.userId) {
            return !lodash_1.default.isUndefined(request.password);
        }
        else if (request.clientId) {
            return !lodash_1.default.isUndefined(request.description);
        }
        else {
            return false;
        }
    }
    strategy.requestAccess = (theConfig, clientRequest, sourceIp, updateCb) => {
        return new Promise((resolve, reject) => {
            if (filterRequests('accessRequest', 'PENDING').length >= 100) {
                const err = new Error('Too many pending access requests');
                err.statusCode = 503;
                reject(err);
                return;
            }
            createRequest(app, 'accessRequest', clientRequest, null, sourceIp, updateCb)
                .then((request) => {
                const accessRequest = clientRequest.accessRequest;
                if (!validateAccessRequest(accessRequest)) {
                    updateRequest(request.requestId, 'COMPLETED', { statusCode: 400 })
                        .then(resolve)
                        .catch(reject);
                    return;
                }
                if (accessRequest.permissions !== undefined &&
                    !VALID_PERMISSIONS.has(accessRequest.permissions)) {
                    updateRequest(request.requestId, 'COMPLETED', { statusCode: 400 })
                        .then(resolve)
                        .catch(reject);
                    return;
                }
                request.requestedPermissions = !lodash_1.default.isUndefined(accessRequest.permissions);
                if (!request.requestedPermissions) {
                    request.permissions = 'readonly';
                }
                else {
                    request.permissions = accessRequest.permissions;
                }
                let alertMessage;
                if (accessRequest.clientId) {
                    if (!options.allowDeviceAccessRequests) {
                        updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 })
                            .then(resolve)
                            .catch(reject);
                        return;
                    }
                    if (findRequest((r) => r.state === 'PENDING' &&
                        r.accessIdentifier === accessRequest.clientId)) {
                        updateRequest(request.requestId, 'COMPLETED', {
                            statusCode: 400,
                            message: `A device with clientId '${accessRequest.clientId}' has already requested access`
                        })
                            .then(resolve)
                            .catch(reject);
                        return;
                    }
                    request.accessIdentifier = accessRequest.clientId;
                    request.accessDescription = accessRequest.description;
                    debug(`A device with IP ${request.ip} and CLIENTID ${accessRequest.clientId} has requested access to the server`);
                    alertMessage = `The device "${accessRequest.description}" has requested access to the server`;
                }
                else {
                    if (!options.allowNewUserRegistration) {
                        updateRequest(request.requestId, 'COMPLETED', { statusCode: 403 })
                            .then(resolve)
                            .catch(reject);
                        return;
                    }
                    const existing = options.users.find((user) => user.username === accessRequest.userId);
                    if (existing) {
                        updateRequest(request.requestId, 'COMPLETED', {
                            statusCode: 400,
                            message: 'User already exists'
                        })
                            .then(resolve)
                            .catch(reject);
                        return;
                    }
                    request.accessDescription = 'New User Request';
                    request.accessIdentifier = accessRequest.userId;
                    request.accessPassword = bcryptjs_1.default.hashSync(accessRequest.password, bcryptjs_1.default.genSaltSync(passwordSaltRounds));
                    alertMessage = `${accessRequest.userId} has requested server access`;
                    debug(alertMessage);
                }
                const permissionPart = request.requestedPermissions
                    ? request.permissions
                    : 'any';
                sendAccessRequestsUpdate();
                app.handleMessage(CONFIG_PLUGINID, {
                    context: ('vessels.' + app.selfId),
                    updates: [
                        {
                            values: [
                                {
                                    path: `notifications.security.accessRequest.${permissionPart}.${request.accessIdentifier}`,
                                    value: {
                                        state: 'alert',
                                        method: ['visual', 'sound'],
                                        message: alertMessage,
                                        timestamp: new Date().toISOString()
                                    }
                                }
                            ]
                        }
                    ]
                });
                updateRequest(request.requestId, 'PENDING', { statusCode: 202 })
                    .then((reply) => {
                    resolve(reply);
                })
                    .catch(reject);
            })
                .catch(reject);
        });
    };
    setupApp();
    return strategy;
}
// CommonJS export for backward compatibility
module.exports = tokenSecurityFactory;
//# sourceMappingURL=tokensecurity.js.map