UNPKG

@authx/http-proxy-resource

Version:

The AuthX proxy for resources is a flexible HTTP proxy designed to sit in front of a resource.

242 lines 10 kB
import { EventEmitter } from "events"; import { createServer } from "http"; import httpProxy from "http-proxy"; const { createProxyServer } = httpProxy; import { isEqual, isSuperset } from "@authx/scopes"; import { AuthXKeyCache } from "./AuthXKeyCache.js"; export { AuthXKeyCache } from "./AuthXKeyCache.js"; import { validateAuthorizationHeader, NotAuthorizedError, } from "./validateAuthorizationHeader.js"; import { TokenDataCache } from "./TokenDataCache.js"; export { validateAuthorizationHeader, NotAuthorizedError, } from "./validateAuthorizationHeader.js"; export default class AuthXResourceProxy extends EventEmitter { _config; _proxy; _closed = true; _closing = false; _cache; _tokenDataCache; server; constructor(config) { super(); this._config = config; this._cache = new AuthXKeyCache(config); this._cache.on("error", (error, ...args) => this.emit("error", error, ...args)); this._cache.on("ready", (...args) => this.emit("ready", ...args)); this._proxy = createProxyServer({}); this._proxy.on("error", (error, ...args) => this.emit("error", error, ...args)); this.server = createServer(this._callback); this.server.on("listening", () => { this._closed = false; this._cache.start(); }); this.server.on("close", () => { this._closing = false; this._closed = true; this._cache.stop(); }); const revocableTokenCacheDuration = config.revocableTokenCacheDuration ?? 60; this._tokenDataCache = new TokenDataCache({ authxUrl: config.authxUrl, fetchFunc: fetch, timeSource: Date.now, tokenExpirySeconds: revocableTokenCacheDuration, tokenRefreshSeconds: revocableTokenCacheDuration / 2, }); this._tokenDataCache.on("error", (error) => this.emit("error", error)); } _callback = async (request, response) => { const meta = { request: request, response: response, rule: undefined, behavior: undefined, message: "Request received.", authorizationId: undefined, authorizationSubject: undefined, authorizationScopes: undefined, }; // Emit meta on request start. this.emit("request.start", meta); // Emit meta again on request finish. response.on("finish", () => { this.emit("request.finish", meta); }); let warning = ""; function send(data) { if (warning) { response.setHeader("Warning", `299 @authx/http-proxy-resource ${warning}`); } if (request.complete) { response.end(data); } else { request.on("end", () => response.end(data)); request.resume(); } } // Serve the readiness URL. if (request.url === (this._config.readinessEndpoint || "/_ready")) { if (this._closed || this._closing || !this._cache.keys) { response.setHeader("Cache-Control", "no-cache"); response.statusCode = 503; meta.message = "Request handled by readiness endpoint: NOT READY."; send("NOT READY"); return; } response.setHeader("Cache-Control", "no-cache"); response.statusCode = 200; meta.message = "Request handled by readiness endpoint: READY."; send("READY"); return; } const keys = this._cache.keys; if (!keys) { response.setHeader("Cache-Control", "no-cache"); response.statusCode = 503; meta.message = "Unable to find keys."; send(); return; } // Proxy for (const rule of this._config.rules) { if (!rule.test(request)) { continue; } let scopes = null; // Extract scopes from the authorization header. const authorizationHeader = request.headers.authorization; if (authorizationHeader) { try { const { authorizationId, authorizationSubject, authorizationScopes } = await validateAuthorizationHeader(this._config.authxUrl, keys, authorizationHeader, this._tokenDataCache); scopes = authorizationScopes; meta.authorizationId = authorizationId; meta.authorizationSubject = authorizationSubject; meta.authorizationScopes = authorizationScopes; } catch (error) { if (error instanceof NotAuthorizedError) { warning = error.message; } else { response.setHeader("Cache-Control", "no-cache"); response.statusCode = 500; meta.message = error.message; meta.rule = rule; send(); this.emit("request.error", error, meta); return; } } } // Set scopes on the request. if (scopes) { request.headers["X-OAuth-Scopes"] = scopes.join(" "); response.setHeader("X-OAuth-Scopes", scopes.join(" ")); } else { delete request.headers["X-OAuth-Scopes"]; response.removeHeader("X-OAuth-Scopes"); } // Call the custom behavior function. const behavior = typeof rule.behavior === "function" ? rule.behavior(request, response) : rule.behavior; // If behavior is undefined, then the custom behavior function will handle // responding to the request. if (!behavior) { meta.message = "Request handled by custom behavior function." + (warning ? ` (${warning})` : ""); meta.rule = rule; return; } if (behavior.requireScopes) { request.headers["X-OAuth-Required-Scopes"] = behavior.requireScopes.join(" "); response.setHeader("X-OAuth-Required-Scopes", behavior.requireScopes.join(" ")); // There is no valid token. if (!scopes) { response.setHeader("Cache-Control", "no-cache"); response.statusCode = 401; meta.message = "Restricting access." + (warning ? ` (${warning})` : ""); meta.rule = rule; meta.behavior = behavior; send(); return; } // The token is valid, but lacks required scopes. if (!isEqual(scopes, behavior.requireScopes) && !isSuperset(scopes, behavior.requireScopes)) { response.setHeader("Cache-Control", "no-cache"); response.statusCode = 403; meta.message = "Restricting access." + (warning ? ` (${warning})` : ""); meta.rule = rule; meta.behavior = behavior; send(); return; } } // Strip the token from the proxied request. if (!behavior.sendTokenToTarget || !scopes) { delete request.headers.authorization; } // Proxy the request. meta.message = "Request proxied." + (warning ? ` (${warning})` : ""); meta.rule = rule; meta.behavior = behavior; this.emit("request.proxy", meta); this._proxy.web(request, response, behavior.proxyOptions, (error) => { if (!response.headersSent) { const code = error.code; const statusCode = typeof code === "string" && /INVALID/.test(code) ? 502 : code === "ECONNRESET" || code === "ENOTFOUND" || code === "ECONNREFUSED" ? 504 : 500; response.setHeader("Cache-Control", "no-cache"); response.writeHead(statusCode); response.end(); } this.emit("request.error", error, meta); }); return; } response.setHeader("Cache-Control", "no-cache"); response.statusCode = 404; meta.message = "No rules matched requested URL." + (warning ? ` (${warning})` : ""); send(); this.emit("request.error", new Error(`No rules matched requested URL "${request.url}".`), meta); }; async listen(options) { if (!this._closed) { throw new Error("Proxy cannot listen because it not closed."); } if (this._closing) { throw new Error("Proxy cannot listen because it is closing."); } return new Promise((resolve) => { this.server.once("listening", resolve); this.server.listen(options); }); } async close(delay = 0) { if (this._closing) { throw new Error("Proxy cannot close because it is already closing."); } this._closing = true; // Close the proxy. return new Promise((resolve) => { setTimeout(() => { this.server.close(() => { resolve(); }); }, delay); }); } } //# sourceMappingURL=index.js.map