UNPKG

signalk-server

Version:

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

210 lines 8.29 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.setFetchFunction = setFetchFunction; exports.resetFetchFunction = resetFetchFunction; exports.clearJwksCache = clearJwksCache; exports.fetchJwks = fetchJwks; exports.validateIdToken = validateIdToken; const types_1 = require("./types"); let joseModule = null; async function getJose() { if (!joseModule) { joseModule = await import('jose'); } return joseModule; } let fetchFn = fetch; /** * Set the fetch function (for testing) */ function setFetchFunction(fn) { fetchFn = fn; } /** * Reset the fetch function to the default */ function resetFetchFunction() { fetchFn = fetch; } const jwksCache = new Map(); const JWKS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour /** * Clear the JWKS cache */ function clearJwksCache() { jwksCache.clear(); } /** * Clear cached JWKS for a specific URI */ function clearJwksCacheForUri(jwksUri) { jwksCache.delete(jwksUri); } /** * Fetch JWKS from the provider * @param metadata Provider metadata containing jwks_uri * @returns The JWKS */ async function fetchJwks(metadata) { const cached = jwksCache.get(metadata.jwks_uri); if (cached && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS) { return cached.jwks; } let response; try { response = await fetchFn(metadata.jwks_uri); } catch (err) { throw new types_1.OIDCError(`Failed to fetch JWKS from ${metadata.jwks_uri}: ${err}`, 'DISCOVERY_FAILED', err instanceof Error ? err : undefined); } if (!response.ok) { throw new types_1.OIDCError(`JWKS fetch failed with status ${response.status}`, 'DISCOVERY_FAILED'); } let jwks; try { jwks = (await response.json()); } catch (err) { throw new types_1.OIDCError('Failed to parse JWKS as JSON', 'DISCOVERY_FAILED', err instanceof Error ? err : undefined); } // Cache the JWKS jwksCache.set(metadata.jwks_uri, { jwks, fetchedAt: Date.now() }); return jwks; } /** * Check if an error is a signature verification failure that might be fixed * by refreshing the JWKS (e.g., key rotation occurred) */ function isSignatureError(err) { if (!(err instanceof Error)) { return false; } const errorName = err.constructor.name; const errorMessage = err.message.toLowerCase(); // jose throws JWSSignatureVerificationFailed for signature issues // Also check for "signature" in message for other cases return (errorName === 'JWSSignatureVerificationFailed' || errorName === 'JWSInvalid' || errorMessage.includes('signature') || errorMessage.includes('no applicable key') || errorMessage.includes('key not found')); } /** * Validate an ID token according to OIDC spec * * Validates: * - Signature using provider's JWKS * - Issuer matches expected issuer * - Audience contains client_id * - Token is not expired (with 5 min clock skew tolerance) * - Nonce matches expected nonce * * If signature verification fails, the JWKS cache is cleared and validation * is retried once. This handles the case where the OIDC provider has rotated * keys since the JWKS was cached. * * @param idToken The ID token string * @param config OIDC configuration * @param metadata Provider metadata * @param expectedNonce The nonce from the auth state * @returns Validated claims from the ID token * @throws OIDCError if validation fails */ async function validateIdToken(idToken, config, metadata, expectedNonce) { // Dynamically import jose (ESM-only module) const jose = await getJose(); // Try to verify, with one retry on signature failure (handles key rotation) let payload; let lastError; for (let attempt = 0; attempt < 2; attempt++) { // Fetch JWKS (will use cache unless cleared) const jwks = await fetchJwks(metadata); // Create JWKS from the fetched keys const keySet = jose.createLocalJWKSet(jwks); // Verify the token signature and decode claims try { const result = await jose.jwtVerify(idToken, keySet, { issuer: config.issuer, audience: config.clientId, clockTolerance: 300 // 5 minutes clock skew tolerance }); payload = result.payload; break; // Success, exit retry loop } catch (err) { lastError = err; // On first attempt, if it's a signature error, clear cache and retry if (attempt === 0 && isSignatureError(err)) { clearJwksCacheForUri(metadata.jwks_uri); continue; // Retry with fresh JWKS } // Not a signature error or second attempt - process the error const errorName = err instanceof Error ? err.constructor.name : ''; const errorMessage = err instanceof Error ? err.message.toLowerCase() : ''; if (errorName === 'JWTExpired') { throw new types_1.OIDCError('ID token has expired', 'INVALID_TOKEN', err instanceof Error ? err : undefined); } if (errorName === 'JWTClaimValidationFailed') { if (errorMessage.includes('iss') || errorMessage.includes('issuer')) { // Decode token (without verification) to show actual issuer in error let actualIssuer = 'unknown'; try { const claims = jose.decodeJwt(idToken); actualIssuer = String(claims.iss ?? 'unknown'); } catch { // Ignore decode errors, use 'unknown' } throw new types_1.OIDCError(`ID token issuer mismatch: expected '${config.issuer}', received '${actualIssuer}'`, 'INVALID_TOKEN', err instanceof Error ? err : undefined); } if (errorMessage.includes('aud') || errorMessage.includes('audience')) { // Decode token (without verification) to show actual audience in error let actualAudience = 'unknown'; try { const claims = jose.decodeJwt(idToken); actualAudience = Array.isArray(claims.aud) ? claims.aud.join(', ') : String(claims.aud ?? 'unknown'); } catch { // Ignore decode errors, use 'unknown' } throw new types_1.OIDCError(`ID token audience mismatch: expected '${config.clientId}', received '${actualAudience}'`, 'INVALID_TOKEN', err instanceof Error ? err : undefined); } } throw new types_1.OIDCError(`ID token validation failed: ${err}`, 'INVALID_TOKEN', err instanceof Error ? err : undefined); } } // If we exit the loop without payload, something went wrong if (!payload) { throw new types_1.OIDCError(`ID token validation failed after retry: ${lastError}`, 'INVALID_TOKEN', lastError instanceof Error ? lastError : undefined); } // Validate nonce if (payload.nonce !== expectedNonce) { throw new types_1.OIDCError(`ID token nonce mismatch: expected ${expectedNonce}, got ${payload.nonce}`, 'INVALID_TOKEN'); } // Validate required claims exist if (typeof payload.sub !== 'string' || !payload.sub) { throw new types_1.OIDCError('ID token missing required "sub" claim', 'INVALID_TOKEN'); } return payload; } //# sourceMappingURL=id-token-validation.js.map