diff --git a/actions/check-product-changes/index.js b/actions/check-product-changes/index.js index 70d308fe..de54aabf 100644 --- a/actions/check-product-changes/index.js +++ b/actions/check-product-changes/index.js @@ -15,6 +15,7 @@ const { StateManager } = require('../lib/state'); const { ObservabilityClient } = require('../lib/observability'); const { getRuntimeConfig } = require('../lib/runtimeConfig'); const { handleActionError } = require('../lib/errorHandler'); +const { checkAndAlertTokenExpiration } = require('../lib/tokenExpirationMonitor'); /** * Entry point for the "Product changes check" action. @@ -63,6 +64,9 @@ async function main(params) { // Mark job as running with TTL to avoid permanent lock on unexpected failures await stateMgr.put('running', 'true', { ttl: 3600 }); + // Check API key expiration once per day + await checkAndAlertTokenExpiration(cfg.adminAuthToken, stateMgr, observabilityClient, logger); + // Core logic activationResult = await poll(cfg, { stateLib: stateMgr, filesLib }, logger); } finally { @@ -96,4 +100,4 @@ async function main(params) { } } -exports.main = main \ No newline at end of file +exports.main = main; \ No newline at end of file diff --git a/actions/lib/observability.js b/actions/lib/observability.js index 11f934e3..d47d9868 100644 --- a/actions/lib/observability.js +++ b/actions/lib/observability.js @@ -1,13 +1,13 @@ class ObservabilityClient { constructor(nativeLogger, options = {}) { - this.activationId = process.env.__OW_ACTIVATION_ID; - this.namespace = process.env.__OW_NAMESPACE; - this.instanceStartTime = Date.now(); - this.options = options; - this.org = options.org; - this.site = options.site; - this.endpoint = options.endpoint; - this.nativeLogger = nativeLogger; + this.activationId = process.env.__OW_ACTIVATION_ID; + this.namespace = process.env.__OW_NAMESPACE; + this.instanceStartTime = Date.now(); + this.options = options; + this.org = options.org; + this.site = options.site; + this.endpoint = options.endpoint; + this.nativeLogger = nativeLogger; } getEndpoints(type) { @@ -19,42 +19,42 @@ class ObservabilityClient { } async #sendRequestToObservability(type, payload) { - try { - const logEndpoint = this.getEndpoints(type); - - if (logEndpoint) { - await fetch(logEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.options.token}`, + try { + const logEndpoint = this.getEndpoints(type); + + if (logEndpoint) { + await fetch(logEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.options.token}`, }, - body: JSON.stringify(payload), - }); - } - } catch (error) { - this.nativeLogger.debug(`[ObservabilityClient] Failed to send to observability endpoint '${type}': ${error.message}`, { error }); + body: JSON.stringify(payload), + }); } + } catch (error) { + this.nativeLogger.debug(`[ObservabilityClient] Failed to send to observability endpoint '${type}': ${error.message}`, { error }); } + } - severityMap = { - 'DEBUG': 1, - 'VERBOSE': 2, - 'INFO': 3, - 'WARNING': 4, - 'ERROR': 5, - 'CRITICAL': 6, - } + severityMap = { + 'DEBUG': 1, + 'VERBOSE': 2, + 'INFO': 3, + 'WARNING': 4, + 'ERROR': 5, + 'CRITICAL': 6, + } - stateToSeverity(state) { - const stateToSeverityMap = { - skipped: 'DEBUG', - completed: 'INFO', - failure: 'ERROR', - }; + stateToSeverity(state) { + const stateToSeverityMap = { + skipped: 'DEBUG', + completed: 'INFO', + failure: 'ERROR', + }; - return this.severityMap[stateToSeverityMap[state]] || this.severityMap['DEBUG']; - } + return this.severityMap[stateToSeverityMap[state]] || this.severityMap['DEBUG']; + } /** * Sends a single activation log entry to the observability endpoint. @@ -62,25 +62,41 @@ class ObservabilityClient { * @returns {Promise} A promise that resolves when the log is sent, or rejects on error. */ async sendActivationResult(result) { - if (!result || typeof result !== 'object') { - return; - } + if (!result || typeof result !== 'object') { + return; + } - let severity = this.stateToSeverity(result.state); + let severity = this.stateToSeverity(result.state); - if (result?.status?.failed > 0) { - severity = this.severityMap['WARNING']; - } + if (result?.status?.failed > 0) { + severity = this.severityMap['WARNING']; + } + + const payload = { + environment: `${this.namespace}`, + timestamp: this.instanceStartTime, + result, + severity, + activationId: this.activationId, + }; + + await this.#sendRequestToObservability('activationResults', payload); + } - const payload = { - environment: `${this.namespace}`, - timestamp: this.instanceStartTime, - result, - severity, - activationId: this.activationId, - }; + async sendApiKeyAlert(alertData) { + const payload = { + environment: this.namespace || 'local-test', + timestamp: Date.now(), + result: { + type: 'api_key_expiration_alert', + daysUntilExpiration: alertData.daysUntilExpiration, + message: alertData.message, + recommendedAction: alertData.recommendedAction + }, + activationId: this.activationId || `token-alert-${Date.now()}` + }; - await this.#sendRequestToObservability('activationResults', payload); + await this.#sendRequestToObservability('activationResults', payload); } } diff --git a/actions/lib/tokenExpirationMonitor.js b/actions/lib/tokenExpirationMonitor.js new file mode 100644 index 00000000..70084b14 --- /dev/null +++ b/actions/lib/tokenExpirationMonitor.js @@ -0,0 +1,56 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you 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 REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const { checkTokenExpiration } = require('./tokenValidator'); + +/** + * Check API key expiration and send alerts if needed + * @param {string} token - The API token to check + * @param {Object} stateMgr - State manager for tracking last check + * @param {Object} observabilityClient - Client for sending alerts + * @param {Object} logger - Logger instance + */ +async function checkAndAlertTokenExpiration(token, stateMgr, observabilityClient, logger) { + const alertThresholdDays = 30; // Alert when token expires in 30 days or less + const checkIntervalMs = 24 * 60 * 60 * 1000; // 24 hours + + const lastTokenCheck = await stateMgr.get('lastTokenCheck'); + const now = new Date(); + const intervalAgo = new Date(now.getTime() - checkIntervalMs); + + if (!lastTokenCheck || new Date(lastTokenCheck.value) < intervalAgo) { + const tokenInfo = checkTokenExpiration(token); + if (tokenInfo.isValid) { + const { daysUntilExpiration } = tokenInfo; + + if (daysUntilExpiration < alertThresholdDays) { + + const message = `AEM Admin API key expires in ${daysUntilExpiration} days`; + + try { + await observabilityClient.sendApiKeyAlert({ + daysUntilExpiration, + message, + recommendedAction: 'Contact AEM project lead to generate new API key' + }); + } catch (alertErr) { + logger.warn('Failed to send API key expiration alert.', alertErr); + } + } + } + await stateMgr.put('lastTokenCheck', now.toISOString()); + } +} + +module.exports = { + checkAndAlertTokenExpiration +}; diff --git a/actions/lib/tokenValidator.js b/actions/lib/tokenValidator.js index 4f75c501..2c515651 100644 --- a/actions/lib/tokenValidator.js +++ b/actions/lib/tokenValidator.js @@ -24,13 +24,13 @@ function decodeJwtPayload(token) { if (parts.length !== 3) { return null; } - + // Decode the payload (second part) const payload = parts[1]; // Add padding if needed for base64 decoding const paddedPayload = payload + '='.repeat((4 - payload.length % 4) % 4); const decoded = Buffer.from(paddedPayload, 'base64url').toString('utf8'); - + return JSON.parse(decoded); } catch { return null; @@ -45,20 +45,20 @@ function decodeJwtPayload(token) { */ function isTokenExpired(token, logger) { const payload = decodeJwtPayload(token); - + if (!payload) { logger?.warn('Unable to decode token payload for expiration check'); return false; // If we can't decode, assume not expired to avoid false positives } - + if (!payload.exp) { logger?.debug('Token does not contain expiration claim (exp)'); return false; // No expiration claim means token doesn't expire } - + const now = Math.floor(Date.now() / 1000); // Current time in seconds const expired = payload.exp < now; - + if (expired) { const expiredDate = new Date(payload.exp * 1000).toISOString(); logger?.warn(`Token expired at ${expiredDate}`); @@ -66,7 +66,7 @@ function isTokenExpired(token, logger) { const expirationDate = new Date(payload.exp * 1000).toISOString(); logger?.debug(`Token expires at ${expirationDate}`); } - + return expired; } @@ -79,7 +79,7 @@ function isTokenExpired(token, logger) { */ function validateAemTokenStructure(token, logger) { const payload = decodeJwtPayload(token); - + if (!payload) { const error = new Error('Invalid JWT token structure - cannot decode payload'); error.statusCode = 400; @@ -87,11 +87,11 @@ function validateAemTokenStructure(token, logger) { logger?.error('Token validation failed: Cannot decode JWT payload'); throw error; } - + // Check required AEM fields const requiredFields = ['iss', 'sub', 'aud', 'roles']; const missingFields = requiredFields.filter(field => !payload[field]); - + if (missingFields.length > 0) { const error = new Error(`Invalid AEM token - missing required fields: ${missingFields.join(', ')}`); error.statusCode = 400; @@ -99,7 +99,7 @@ function validateAemTokenStructure(token, logger) { logger?.error(`Token validation failed: Missing required fields: ${missingFields.join(', ')}`); throw error; } - + // Validate issuer if (payload.iss !== 'https://admin.hlx.page/') { const error = new Error(`Invalid token issuer: expected 'https://admin.hlx.page/', got '${payload.iss}'`); @@ -108,7 +108,7 @@ function validateAemTokenStructure(token, logger) { logger?.error(`Token validation failed: Invalid issuer ${payload.iss}`); throw error; } - + // Validate roles array if (!Array.isArray(payload.roles)) { const error = new Error('Invalid token - roles must be an array'); @@ -117,11 +117,11 @@ function validateAemTokenStructure(token, logger) { logger?.error('Token validation failed: roles is not an array'); throw error; } - + // Check for required admin roles const requiredRoles = ['preview', 'publish']; const hasRequiredRoles = requiredRoles.every(role => payload.roles.includes(role)); - + if (!hasRequiredRoles) { const missingRoles = requiredRoles.filter(role => !payload.roles.includes(role)); const error = new Error(`Insufficient permissions - missing required roles: ${missingRoles.join(', ')}`); @@ -130,13 +130,13 @@ function validateAemTokenStructure(token, logger) { logger?.error(`Token validation failed: Missing required roles: ${missingRoles.join(', ')}`); throw error; } - + logger?.debug('AEM token structure validation passed', { subject: payload.sub, roles: payload.roles, issuer: payload.iss }); - + return payload; } @@ -208,11 +208,11 @@ async function validateAemAdminTokenWithApi(token, org, site, logger) { }; logger?.info(`Validating token against AEM API: ${adminUrl}`); - + // Make a request to check if the token is valid // This endpoint should return 200 if token is valid, 401/403 if invalid await request('token-validation', adminUrl, req, 10000); // 10 second timeout - + logger?.info('AEM_ADMIN_API_AUTH_TOKEN API validation passed'); return true; } catch (error) { @@ -225,13 +225,13 @@ async function validateAemAdminTokenWithApi(token, org, site, logger) { logger?.error('Token API validation failed: Invalid or expired token'); throw authError; } - + // For other errors (network, timeout, etc.), log but don't fail validation logger?.warn('Token API validation failed due to network error, falling back to basic validation:', { message: error.message, code: 'NETWORK_ERROR' }); - + // Return true since basic validation passed return true; } @@ -285,9 +285,37 @@ async function validateConfigTokenWithApi(config, logger) { return validateAemAdminTokenWithApi(config.adminAuthToken, config.org, config.site, logger); } +/** + * Checks token expiration and returns detailed expiration information + * @param {string} token - JWT token to check + * @returns {Object} Expiration information object + */ +function checkTokenExpiration(token) { + try { + const payload = decodeJwtPayload(token); + if (!payload || !payload.exp) { + return { isValid: false, error: 'Invalid token or no expiration claim' }; + } + + const expirationDate = new Date(payload.exp * 1000); + const now = new Date(); + const daysUntilExpiration = Math.ceil((expirationDate - now) / (1000 * 60 * 60 * 24)); + + return { + isValid: true, + expirationDate, + daysUntilExpiration, + isExpired: daysUntilExpiration <= 0 + }; + } catch (error) { + return { isValid: false, error: error.message }; + } +} + module.exports = { validateAemAdminToken, validateAemAdminTokenWithApi, validateConfigToken, - validateConfigTokenWithApi + validateConfigTokenWithApi, + checkTokenExpiration };