signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
210 lines • 8.29 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.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