UNPKG

openam-agent-custom

Version:

Customized ForgeRock AM Policy Agent for Node.js from Zoltan Tarcsay

552 lines (472 loc) 17.7 kB
import * as BodyParser from 'body-parser'; import * as cookie from 'cookie'; import { EventEmitter } from 'events'; import { RequestHandler, Response, Router } from 'express'; import { NextFunction } from 'express-serve-static-core'; import * as fs from 'fs'; import * as Handlebars from 'handlebars'; import { IncomingMessage, ServerResponse } from 'http'; import { resolve } from 'path'; import * as ShortId from 'shortid'; import { Logger } from 'winston'; import * as XMLBuilder from 'xmlbuilder'; import { AmClient } from '../amclient/am-client'; import { AmPolicyDecision } from '../amclient/am-policy-decision'; import { AmPolicyDecisionRequest } from '../amclient/am-policy-decision-request'; import { AmServerInfo } from '../amclient/am-server-info'; import { Cache } from '../cache/cache'; import { InMemoryCache } from '../cache/in-memory-cache'; import { InvalidSessionError } from '../error/invalid-session-error'; import { Shield } from '../shield/shield'; import { baseUrl, sendResponse } from '../utils/http-utils'; import { createLogger } from '../utils/logger'; import { parseXml } from '../utils/xml-utils'; import { EvaluationErrorDetails, PolicyAgentOptions } from './policy-agent-options'; const pkg = require('../../package.json'); export const SESSION_EVENT = 'session'; export const CDSSO_PATH = '/agent/cdsso'; export const NOTIFICATION_PATH = '/agent/notifications'; /** * Policy Agent * * @example * import express from 'express'; * import {PolicyAgent, CookieShield} from '@forgerock/openam-agent'; * * const config = { * serverUrl: 'http://openam.example.com:8080/openam', * appUrl: 'http://app.example.com:8080', * notificationsEnabled: true, * username: 'my-agent', * password: 'changeit', * realm: '/', * logLevel: 'info', * errorPage: ({status, message, details}) => `<html><body><h1>${status} - ${message }</h1></body></html>` * }; * * const agent = new PolicyAgent(config); * const app = express(); * * app.use(agent.shield(new CookieShield())); * app.use(agent.notifications); * * app.listen(8080); */ export class PolicyAgent extends EventEmitter { public readonly id = ShortId.generate(); public amClient: AmClient; public logger: Logger; public sessionCache: Cache; private serverInfo?: Promise<AmServerInfo>; private agentSession?: Promise<{ tokenId: string }>; private errorTemplate: (details: EvaluationErrorDetails) => string; private cdssoPath = CDSSO_PATH; private notificationPath = NOTIFICATION_PATH; constructor(readonly options: PolicyAgentOptions) { super(); const { openAMClient, serverUrl, privateIP, logger, logLevel, sessionCache, logAsJson } = options; this.logger = logger || createLogger(logLevel, this.id, { json: logAsJson }); this.amClient = openAMClient || new AmClient(serverUrl, privateIP); this.sessionCache = sessionCache || new InMemoryCache({ expireAfterSeconds: 300, logger }); this.errorTemplate = options.errorPage || this.getDefaultErrorTemplate(); this.registerSessionExpiryHandler(); this.registerShutdownHandler(); this.logger.info('Agent initialized.'); } /** * Returns the cached AM server info (cookie name & domain list) */ getServerInfo(): Promise<AmServerInfo> { if (!this.serverInfo) { this.serverInfo = this.amClient.getServerInfo(); } return this.serverInfo; } /** * Returns a cached agent session */ getAgentSession(): Promise<{ tokenId: string }> { if (!this.agentSession) { this.agentSession = this.authenticateAgent(); } return this.agentSession; } /** * Creates a new agent session */ authenticateAgent() { const { username, password, realm } = this.options; if (!username || !password) { throw new Error('PolicyAgent: agent username and password must be set'); } return this.amClient .authenticate(username, password, realm) .then(res => { this.logger.info(`PolicyAgent: agent session created – ${res.tokenId}`); return res; }); } /** * Retry sending a request a specified number of times. If the response status is 401, renew the agent session */ async reRequest<T = any>(request: () => Promise<T>, attemptLimit = 1, name: string = 'reRequest'): Promise<T> { let attemptCount = 0; while (attemptCount < attemptLimit) { try { return await request(); } catch (err) { attemptCount++; this.logger.debug(`PolicyAgent: ${name} - caught error ${err.message}`); this.logger.info(`PolicyAgent: ${name} - retrying request - attempt ${attemptCount} of ${attemptLimit}`); // renew agent session on 401 response if (err instanceof InvalidSessionError || err.statusCode === 401 || err.code === 401 || (err.response && err.response.status === 401)) { this.agentSession = this.authenticateAgent(); await this.agentSession; } else if (attemptCount === attemptLimit) { throw err; } } } } async validateSession(sessionId: string): Promise<any> { try { return await this.sessionCache.get(sessionId); } catch (err) { this.logger.info(err); } const res = await this.amClient.validateSession(sessionId); if (res.valid) { this.logger.info(`PolicyAgent: session ${sessionId} is valid; saving to cache`); this.sessionCache.put(sessionId, res); if (this.options.notificationsEnabled) { this.registerSessionListener(sessionId); } } else { this.logger.info(`PolicyAgent: session ${sessionId} is invalid`); } return res; } /** * Sets the session cookie on the response in a set-cookie header */ async setSessionCookie(res: Response, sessionId: string): Promise<void> { const { cookieName } = await this.getServerInfo(); res.append('Set-Cookie', cookie.serialize(cookieName, sessionId, { path: '/' })); } /** * Gets the session ID from the session cookie in the request */ async getSessionIdFromRequest(req: IncomingMessage): Promise<string> { const { cookieName } = await this.getServerInfo(); const cookies = cookie.parse(req.headers.cookie || ''); const sessionId = cookies[ cookieName ]; if (sessionId) { this.logger.info(`PolicyAgent: found sessionId ${sessionId} in request cookie ${cookieName}`); } else { this.logger.info(`PolicyAgent: missing session ID in request cookie ${cookieName}`); } return sessionId; } /** * Fetches the user profile for a given username (uid) and saves it to the sessionCache. */ async getUserProfile(userId: string, realm: string, sessionId: string): Promise<any> { try { const cached = await this.sessionCache.get(sessionId); if (cached && cached.dn) { return cached; } } catch (err) { this.logger.info(err); } this.logger.info('PolicyAgent: profile data is missing from cache - fetching from OpenAM'); const { cookieName } = await this.getServerInfo(); await this.getAgentSession(); const profile = this.amClient.getProfile(userId, realm, sessionId, cookieName); this.sessionCache.put(sessionId, { ...profile, valid: true }); return profile; } /** * Gets policy decisions from OpenAM. The application name specified in the agent config. */ async getPolicyDecision(data: AmPolicyDecisionRequest): Promise<AmPolicyDecision[]> { const { cookieName } = await this.getServerInfo(); const { tokenId } = await this.getAgentSession(); return this.reRequest<AmPolicyDecision[]>( () => this.amClient.getPolicyDecision(data, tokenId, cookieName, this.options.realm), 5, 'getPolicyDecision' ); } /** * Initializes the shield and returns a middleware function that evaluates the shield. * * @example * const agent = new PolicyAgent(config); * const cookieShield = new CookieShield({getProfiles: true}); * * // Express * const app = express(); * app.use(agent.shield(cookieShield)); * app.listen(3000); * * // Vanilla Node.js * const server = http.createServer(function (req, res) { * var middleware = agent.shield(shield); * * if (req.url.match(/some\/path$/) { * middleware(req, res, function () { * res.writeHead(200); * res.write('Hello ' + req.session.data.username); * res.end(); * }); * } * }); * server.listen(3000); */ shield(shield: Shield): RequestHandler { return async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => { try { const session = await shield.evaluate(req, res, this); req[ 'session' ] = { ...req[ 'session' ], ...session }; next(); } catch (err) { this.logger.info('PolicyAgent#shield: evaluation error (%s)', err.message); if (this.options.letClientHandleErrors) { next(err); return; } // only send the response if it hasn't been sent yet if (res.headersSent) { return; } const body = this.errorTemplate({ status: err.statusCode, message: err.message, details: err.stack, pkg }); sendResponse(res, err.statusCode || 500, body, { 'Content-Type': 'text/html' }); } }; } /** * Express.js Router factory which handles CDSSO (parses the LARES data and sets the session cookie) * * Note that in order for CDSSO to work, you must have the following: * - An agent profile in OpenAM of type "WebAgent" with all alternative app URLs listed in the "Agent Root URL for * CDSSO" (agentRootURL) property * - The cdsso middleware mounted to the express application * - A CookieShield mounted to a path with the cdsso option set to true * @example * const openamAgent = require('openam-agent'), * agent = new openamAgent.PolicyAgent({...}), * app = require('express')(); * * app.use(agent.cdsso('/my/cdsso/path')); * app.get('/', new openamAgent.CookieShield(cdsso: true)); */ cdsso(path = CDSSO_PATH) { this.cdssoPath = path; const router = Router(); const fail = (err: Error, res: Response) => { this.logger.error(err.message, err); const body = this.errorTemplate({ status: 401, message: 'Unauthorized', details: err.stack, pkg }); res.status(403).send(body); }; router.post(path, BodyParser.urlencoded({ extended: false }), async (req, res) => { if (!(req.body && req.body.LARES)) { fail(new Error('PolicyAgent: missing LARES'), res); return; } this.logger.info('PolicyAgent: found LARES data; validating CDSSO Assertion.'); try { const sessionId = await this.getSessionIdFromLARES(req.body.LARES); this.logger.info(`PolicyAgent: CDSSO Assertion validated. Setting cookie for session ${sessionId}`); await this.setSessionCookie(res, sessionId); res.redirect(req.query.goto || '/'); } catch (err) { fail(err, res); } }); return router; } /** * Parses the LARES response (CDSSO Assertion) and returns the Session ID if valid */ async getSessionIdFromLARES(lares: string): Promise<string> { const buffer = Buffer.from(lares, 'base64'); const doc: any = await parseXml(buffer.toString()); const assertion = doc[ 'lib:AuthnResponse' ][ 'saml:Assertion' ][ 0 ]; const conditions = assertion[ 'saml:Conditions' ][ 0 ]; const nameId = assertion[ 'saml:AuthenticationStatement' ][ 0 ][ 'saml:Subject' ][ 0 ][ 'saml:NameIdentifier' ][ 0 ]; const now = new Date(); const notBefore = new Date(conditions.$.NotBefore); const notOnOrAfter = new Date(conditions.$.NotOnOrAfter); // check Issuer if (assertion.$.Issuer !== this.options.serverUrl + '/cdcservlet') { throw new Error('Unknown issuer: ' + assertion.$.Issuer); } // check AuthnResponse dates if (now < notBefore || now >= notOnOrAfter) { throw new Error(`The CDSSO Assertion is not in date: ${notBefore} - ${notOnOrAfter}`); } return nameId._; } /** * Returns a regular login URL */ getLoginUrl(req: IncomingMessage): string { return this.amClient.getLoginUrl(baseUrl(req) + req.url, this.options.realm); } /** * Returns a CDSSO login URL */ getCDSSOUrl(req: IncomingMessage): string { const target = baseUrl(req) + CDSSO_PATH + '?goto=' + encodeURIComponent(req.url || ''); return this.amClient.getCDSSOUrl(target, this.options.appUrl || ''); } /** * A express router factory for the notification receiver endpoint. It can be used as a middleware for your express * application. It adds a single route: /agent/notifications which can be used to receive notifications from OpenAM. * When a notification is received, its contents will be parsed and handled by one of the handler functions. * * @example * var app = require('express')(), * agent = require('openam-agent').policyAgent(options); * * app.use(agent.notifications('/my/notification/path')); */ notifications(path = NOTIFICATION_PATH) { this.notificationPath = path; this.options.notificationsEnabled = true; const router = Router(); router.post(path, BodyParser.text({ type: 'text/xml' }), async (req, res) => { this.logger.debug(`PolicyAgent: notification received: \n ${req.body}`); res.send(); try { const xml = await parseXml(req.body); const { svcid } = xml.NotificationSet.$; if (svcid === 'session') { this.sessionNotification(xml.NotificationSet); } else { this.logger.error(`PolicyAgent: unknown notification type ${svcid}`); } } catch (err) { this.logger.error(`PolicyAgent: ${err.message}`, err); } }); return router; } /** * Parses notifications in a notification set and emits a 'session' event for each. CookieShield instances listen * on this event to delete any destroyed cookies from the agent's session cache. * @fires 'session' */ sessionNotification(notificationSet: any): void { notificationSet.Notification.forEach(async notification => { const xml = await parseXml(notification); this.emit(SESSION_EVENT, xml.SessionNotification.Session[ 0 ].$); }); } /** * Cleans up after the agent (closes the cache and logs out the agent) */ async destroy() { // destroy the session if (this.agentSession) { const { tokenId } = await this.getAgentSession(); this.logger.info(`PolicyAgent: destroying agent session ${tokenId}`); const { cookieName } = await this.getServerInfo(); try { await this.amClient.logout(tokenId, cookieName, this.options.realm); } catch { // ignore } } // destroy the cache try { await this.sessionCache.quit(); } catch { // ignore } } /** * Constructs a RequestSet document containing a AddSessionListener node for sessionId, and sends it to the * SessionService. */ protected registerSessionListener(sessionId: string): Promise<void> { return this.reRequest(async () => { const { tokenId } = await this.getAgentSession(); const sessionRequest = XMLBuilder .create({ SessionRequest: { '@vers': '1.0', '@reqid': ShortId.generate(), '@requester': Buffer.from(`token: ${tokenId}`).toString('base64') } }) .ele('AddSessionListener') .ele({ 'URL': this.options.appUrl + this.notificationPath, 'SessionID': sessionId }) .end(); const requestSet = XMLBuilder .create({ RequestSet: { '@vers': '1.0', '@svcid': 'Session', '@reqid': ShortId.generate() } }) .ele('Request') .cdata(sessionRequest) .end(); const res = await this.validateSession(tokenId); // this hack is needed because the SessionService is stupid and returns 200 even if there is an error... if (!res.valid) { throw new InvalidSessionError(); } await this.amClient.sessionServiceRequest(requestSet); this.logger.info('PolicyAgent: registered session listener for %s', sessionId); }, 5, 'registerSessionListener'); } /** * Registers a handler for expired session events to remove any expired sessions from the cache */ protected registerSessionExpiryHandler() { this.on(SESSION_EVENT, session => { if (session.state === 'destroyed') { this.logger.info('PolicyAgent: removing destroyed session from cache: %s', session.sid); this.sessionCache.remove(session.sid); } }); } /** * Registers a process exit hook to call destroy() before exiting * Shutdown-handler registers hooks when it's required, which causes the tests to hang */ protected registerShutdownHandler() { if (process.env.NODE_ENV === 'test') { return; } require('shutdown-handler').on('exit', async (event: { preventDefault: () => void }) => { event.preventDefault(); await this.destroy(); process.exit(); }); } /** * Compiles the default error page with Handlebars.js */ protected getDefaultErrorTemplate(): (options: any) => string { return Handlebars.compile(fs.readFileSync(resolve(__dirname, '../templates/error.handlebars')).toString()); } }