@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
JavaScript
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 /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