"use strict";
/*
 * Copyright 2025 Matti Airas
 *
 * 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.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.validateAndMergeUserinfoClaims = validateAndMergeUserinfoClaims;
exports.findOrCreateOIDCUser = findOrCreateOIDCUser;
exports.registerOIDCRoutes = registerOIDCRoutes;
const debug_1 = require("../debug");
const types_1 = require("./types");
const config_1 = require("./config");
const state_1 = require("./state");
const discovery_1 = require("./discovery");
const authorization_1 = require("./authorization");
const token_exchange_1 = require("./token-exchange");
const id_token_validation_1 = require("./id-token-validation");
const permission_mapping_1 = require("./permission-mapping");
const debug = (0, debug_1.createDebug)('signalk-server:oidc-auth');
const skAuthPrefix = '/signalk/v1/auth';
/**
 * Compare two arrays for equality, ignoring order
 */
function arraysEqualIgnoringOrder(arr1, arr2) {
    if (arr1 === arr2)
        return true;
    if (!arr1 || !arr2)
        return arr1 === arr2;
    if (arr1.length !== arr2.length)
        return false;
    const sorted1 = [...arr1].sort();
    const sorted2 = [...arr2].sort();
    return sorted1.every((val, idx) => val === sorted2[idx]);
}
/**
 * Validate and merge userinfo claims into ID token claims.
 *
 * Security considerations:
 * - Validates that userinfo sub matches ID token sub (OIDC Core spec requirement)
 * - Only merges safe claims (email, name, preferred_username, groups)
 * - Does NOT allow userinfo to overwrite security-critical claims (sub, iss, aud, nonce)
 *
 * @param idTokenClaims The validated claims from the ID token
 * @param userinfoClaims The claims from the userinfo endpoint
 * @param groupsAttribute The attribute name for groups (default: 'groups')
 * @throws OIDCError if userinfo sub doesn't match ID token sub
 */
function validateAndMergeUserinfoClaims(idTokenClaims, userinfoClaims, groupsAttribute = 'groups') {
    // Validate that userinfo sub matches ID token sub (OIDC Core spec requirement)
    if (userinfoClaims.sub && userinfoClaims.sub !== idTokenClaims.sub) {
        throw new types_1.OIDCError('Userinfo sub does not match ID token sub', 'INVALID_TOKEN');
    }
    // Only merge specific safe claims - don't allow userinfo to overwrite
    // security-critical claims like sub, iss, aud, nonce, etc.
    const safeClaims = ['email', 'name', 'preferred_username', groupsAttribute];
    for (const claim of safeClaims) {
        if (userinfoClaims[claim] !== undefined) {
            idTokenClaims[claim] = userinfoClaims[claim];
        }
    }
}
/**
 * Validate that a URL is a safe relative path (prevents open redirect attacks)
 */
function isSafeRelativeUrl(url) {
    if (typeof url !== 'string' || !url) {
        return false;
    }
    // Must start with / but not // (which would be protocol-relative URL)
    // Also reject URLs with backslashes or control characters
    const hasControlChars = url.split('').some((char) => {
        const code = char.charCodeAt(0);
        return code >= 0 && code <= 31;
    });
    return (url.startsWith('/') &&
        !url.startsWith('//') &&
        !url.includes('\\') &&
        !hasControlChars);
}
/**
 * Find or create a user from OIDC authentication.
 * This function looks up existing users by OIDC subject+issuer, or creates
 * a new user if auto-creation is enabled.
 *
 * For existing users, permissions are recalculated on each login based on
 * current group memberships. This allows permission changes to take effect
 * when group assignments change in the identity provider.
 */
async function findOrCreateOIDCUser(userInfo, oidcConfig, deps) {
    const issuer = oidcConfig.issuer;
    // Calculate permission based on user's groups
    const mappedPermission = (0, permission_mapping_1.mapGroupsToPermission)(userInfo.groups, oidcConfig);
    // Build OIDC metadata to store with user
    const oidcMetadata = {
        sub: userInfo.sub,
        issuer,
        email: userInfo.email,
        name: userInfo.name,
        groups: userInfo.groups
    };
    // Look for existing user by OIDC sub + issuer
    const user = await deps.userService.findUserByProvider({
        provider: 'oidc',
        criteria: { sub: userInfo.sub, issuer }
    });
    if (user) {
        debug(`OIDC: found existing user ${user.username}`);
        // Check if anything changed
        const existingOidc = user.providerData;
        const previousPermission = user.type;
        const permissionChanged = previousPermission !== mappedPermission;
        const metadataChanged = existingOidc?.email !== oidcMetadata.email ||
            existingOidc?.name !== oidcMetadata.name ||
            !arraysEqualIgnoringOrder(existingOidc?.groups, oidcMetadata.groups);
        if (permissionChanged) {
            debug(`OIDC: updating user ${user.username} permission from ${previousPermission} to ${mappedPermission}`);
        }
        // Only save if something changed
        if (permissionChanged || metadataChanged) {
            try {
                await deps.userService.updateUser(user.username, {
                    type: mappedPermission,
                    providerData: { oidc: oidcMetadata }
                });
                // Update local reference for return
                user.type = mappedPermission;
                user.providerData = oidcMetadata;
            }
            catch (err) {
                console.error('Failed to update OIDC user:', err);
            }
        }
        return user;
    }
    // User not found - check if auto-creation is enabled
    if (!oidcConfig.autoCreateUsers) {
        debug('OIDC: user not found and auto-creation disabled');
        return null;
    }
    // Create new user
    const username = userInfo.preferredUsername || userInfo.email || `oidc-${userInfo.sub}`;
    // Check for username collision with non-OIDC user
    const existingUser = await deps.userService.findUserByUsername(username);
    // Only consider it a collision if the existing user is NOT an OIDC user
    const isCollision = existingUser && !existingUser.providerData;
    const finalUsername = isCollision
        ? `${username}-${userInfo.sub.substring(0, 8)}`
        : username;
    const newUser = {
        username: finalUsername,
        type: mappedPermission,
        providerData: { oidc: oidcMetadata }
    };
    debug(`OIDC: creating new user ${newUser.username} with permission ${newUser.type}`);
    try {
        await deps.userService.createUser(newUser);
    }
    catch (err) {
        console.error('Failed to create OIDC user:', err);
        throw err;
    }
    return newUser;
}
/**
 * Register OIDC authentication routes.
 * This function adds the OIDC login, callback, and status endpoints to the Express app.
 */
function registerOIDCRoutes(app, deps) {
    // OIDC login route - initiates the OIDC flow
    app.get(`${skAuthPrefix}/oidc/login`, async (req, res) => {
        try {
            const oidcConfig = deps.getOIDCConfig();
            if (!(0, config_1.isOIDCEnabled)(oidcConfig)) {
                res.status(500).json({ error: 'OIDC is not configured' });
                return;
            }
            const metadata = await (0, discovery_1.getDiscoveryDocument)(oidcConfig.issuer);
            // Use the configured redirect URI (required by validation)
            const redirectUri = oidcConfig.redirectUri;
            // Store original destination (validated to prevent open redirect attacks)
            const requestedRedirect = req.query.redirect;
            const originalUrl = isSafeRelativeUrl(requestedRedirect)
                ? requestedRedirect
                : '/';
            // Create auth state
            const authState = (0, state_1.createAuthState)(redirectUri, originalUrl);
            // Encrypt and store state in cookie using derived secret
            const stateSecret = deps.cryptoService.getStateEncryptionSecret();
            const encryptedState = (0, state_1.encryptState)(authState, stateSecret);
            res.cookie(types_1.STATE_COOKIE_NAME, encryptedState, {
                httpOnly: true,
                secure: req.secure || req.headers['x-forwarded-proto'] === 'https',
                sameSite: 'lax',
                maxAge: types_1.STATE_MAX_AGE_MS
            });
            // Build and redirect to authorization URL
            const authUrl = (0, authorization_1.buildAuthorizationUrl)(oidcConfig, metadata, authState);
            debug(`OIDC: redirecting to ${authUrl}`);
            res.redirect(authUrl);
        }
        catch (err) {
            console.error('OIDC login error:', err);
            res.status(500).json({
                error: 'OIDC login failed',
                message: err instanceof Error ? err.message : String(err)
            });
        }
    });
    // OIDC callback route - handles the response from the OIDC provider
    app.get(`${skAuthPrefix}/oidc/callback`, async (req, res) => {
        // Helper to redirect to login page with error (better UX for browser-based login)
        const redirectWithError = (message) => {
            const errorParam = encodeURIComponent(message);
            res.redirect(`/admin/#/login?oidcError=true&message=${errorParam}`);
        };
        try {
            const { code, state, error, error_description } = req.query;
            // Check for OIDC error from provider
            if (error) {
                res.clearCookie(types_1.STATE_COOKIE_NAME);
                console.error(`OIDC error: ${error} - ${error_description}`);
                redirectWithError(error_description || error);
                return;
            }
            // Validate required parameters
            if (!code || !state) {
                res.clearCookie(types_1.STATE_COOKIE_NAME);
                redirectWithError('Missing code or state parameter');
                return;
            }
            // Get and validate stored state
            const stateCookie = req.cookies[types_1.STATE_COOKIE_NAME];
            if (!stateCookie) {
                redirectWithError('Session expired. Please try again.');
                return;
            }
            // Decrypt state using derived secret
            const stateSecret = deps.cryptoService.getStateEncryptionSecret();
            let authState;
            try {
                authState = (0, state_1.decryptState)(stateCookie, stateSecret);
                (0, state_1.validateState)(state, authState);
            }
            catch (err) {
                res.clearCookie(types_1.STATE_COOKIE_NAME);
                console.error('OIDC state validation failed:', err);
                redirectWithError(err instanceof types_1.OIDCError
                    ? err.message
                    : 'Session expired or invalid');
                return;
            }
            const oidcConfig = deps.getOIDCConfig();
            if (!(0, config_1.isOIDCEnabled)(oidcConfig)) {
                redirectWithError('OIDC is not configured');
                return;
            }
            const metadata = await (0, discovery_1.getDiscoveryDocument)(oidcConfig.issuer);
            // Exchange code for tokens
            const tokens = await (0, token_exchange_1.exchangeAuthorizationCode)(code, oidcConfig, metadata, authState);
            // Validate ID token signature and claims (including nonce)
            const claims = await (0, id_token_validation_1.validateIdToken)(tokens.idToken, oidcConfig, metadata, authState.nonce);
            // Fetch additional claims from userinfo endpoint
            // ID token typically only contains minimal claims; userinfo has groups, email, etc.
            const userinfoClaims = await (0, token_exchange_1.fetchUserinfo)(tokens.accessToken, metadata, oidcConfig.issuer);
            if (userinfoClaims) {
                validateAndMergeUserinfoClaims(claims, userinfoClaims, oidcConfig.groupsAttribute);
            }
            // Clear state cookie after successful validation
            res.clearCookie(types_1.STATE_COOKIE_NAME);
            // Extract user info from validated claims
            // Use configured groupsAttribute or default to 'groups'
            const groupsAttr = oidcConfig.groupsAttribute || 'groups';
            const rawGroups = claims[groupsAttr];
            // Normalize groups: handle array, single string, or undefined
            let groups;
            if (Array.isArray(rawGroups)) {
                groups = rawGroups;
            }
            else if (typeof rawGroups === 'string' && rawGroups.length > 0) {
                groups = [rawGroups];
            }
            const userInfo = {
                sub: claims.sub,
                email: claims.email,
                name: claims.name,
                preferredUsername: claims.preferred_username,
                groups
            };
            debug(`OIDC: user authenticated: ${userInfo.sub}`);
            // Find or create user
            const user = await findOrCreateOIDCUser(userInfo, oidcConfig, deps);
            if (!user) {
                redirectWithError('User auto-creation is disabled');
                return;
            }
            // Issue local JWT token
            const token = deps.generateJWT(user.username);
            // Set session cookies using the shared helper (fixes security issue!)
            deps.setSessionCookie(res, req, token, user.username, {
                rememberMe: true
            });
            // Redirect to original destination
            res.redirect(authState.originalUrl);
        }
        catch (err) {
            console.error('OIDC callback error:', err);
            redirectWithError(err instanceof Error ? err.message : 'Authentication failed');
        }
    });
    // OIDC status endpoint - returns OIDC configuration status (used by login page)
    app.get(`${skAuthPrefix}/oidc/status`, (_req, res) => {
        try {
            const oidcConfig = deps.getOIDCConfig();
            res.json({
                enabled: (0, config_1.isOIDCEnabled)(oidcConfig),
                issuer: oidcConfig.enabled ? oidcConfig.issuer : undefined,
                loginUrl: oidcConfig.enabled ? `${skAuthPrefix}/oidc/login` : undefined,
                providerName: oidcConfig.enabled ? oidcConfig.providerName : undefined,
                autoLogin: oidcConfig.enabled ? oidcConfig.autoLogin : false
            });
        }
        catch (_err) {
            res.json({ enabled: false });
        }
    });
    // OIDC logout endpoint - clears local session and optionally redirects to provider logout
    app.get(`${skAuthPrefix}/oidc/logout`, async (req, res) => {
        try {
            // Clear local session cookies
            deps.clearSessionCookie(res);
            // Get post-logout redirect URI (validated to prevent open redirect attacks)
            const requestedRedirect = req.query.redirect;
            const postLogoutRedirect = isSafeRelativeUrl(requestedRedirect)
                ? requestedRedirect
                : '/';
            // Check if OIDC is enabled and provider supports RP-initiated logout
            const oidcConfig = deps.getOIDCConfig();
            if (!(0, config_1.isOIDCEnabled)(oidcConfig)) {
                // OIDC not enabled, just redirect locally
                res.redirect(postLogoutRedirect);
                return;
            }
            // Fetch discovery document to check for end_session_endpoint
            let metadata;
            try {
                metadata = await (0, discovery_1.getDiscoveryDocument)(oidcConfig.issuer);
            }
            catch (err) {
                debug('OIDC: failed to fetch discovery document for logout:', err);
                // Fall back to local redirect
                res.redirect(postLogoutRedirect);
                return;
            }
            if (!metadata.end_session_endpoint) {
                // Provider doesn't support RP-initiated logout
                debug('OIDC: provider does not support end_session_endpoint');
                res.redirect(postLogoutRedirect);
                return;
            }
            // Build logout URL with post_logout_redirect_uri
            // Derive origin from configured redirectUri to avoid Host header injection
            const redirectOrigin = new URL(oidcConfig.redirectUri).origin;
            const fullPostLogoutUri = `${redirectOrigin}${postLogoutRedirect}`;
            const logoutUrl = new URL(metadata.end_session_endpoint);
            logoutUrl.searchParams.set('post_logout_redirect_uri', fullPostLogoutUri);
            // Note: We don't send id_token_hint as we don't persist the ID token
            // The provider should still be able to identify the session via cookies
            debug(`OIDC: redirecting to logout URL: ${logoutUrl.toString()}`);
            res.redirect(logoutUrl.toString());
        }
        catch (err) {
            console.error('OIDC logout error:', err);
            // On error, still clear cookies and redirect to home
            deps.clearSessionCookie(res);
            res.redirect('/');
        }
    });
}
//# sourceMappingURL=oidc-auth.js.map