UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

390 lines 17.7 kB
"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