signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
390 lines • 17.7 kB
JavaScript
;
/*
* 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