feat(server): implement HTTP auth and page management functionality
- Introduced new HTTP authentication and page management APIs, allowing for the creation, retrieval, updating, and deletion of HTTP auth profiles and pages. - Added validation for auth and page fields to ensure proper configuration and error handling. - Implemented a mechanism for resolving auth credentials from various sources, including inline definitions, KV store, and secrets. - Enhanced workflow validation to include checks for HTTP triggers, ensuring proper auth and response configurations. - Updated the web interface to include new routes for managing HTTP auth profiles and pages, improving user experience and accessibility.
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { kvGet } from "./kv-store.js";
|
||||
import { getSecretPlaintext } from "./secrets-store.js";
|
||||
import { getHttpAuthInternal, assertAuthType } from "./http-auths-store.js";
|
||||
import { getHttpPageByName, contentTypeForMime } from "./http-pages-store.js";
|
||||
import { log } from "./logger.js";
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
*/
|
||||
function safeEqualString(a, b) {
|
||||
const ba = Buffer.from(String(a), "utf8");
|
||||
const bb = Buffer.from(String(b), "utf8");
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a KV JSON value to a string credential. Objects fail closed.
|
||||
* @param {unknown} value
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function coerceCredentialString(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a credential field: literal string, { kv }, or { secret }.
|
||||
* @param {unknown} field
|
||||
* @param {{ owner: string, workflowKey: string }} ctx
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function resolveCredentialValue(field, ctx) {
|
||||
if (field == null) return null;
|
||||
if (typeof field === "string") return field;
|
||||
|
||||
if (typeof field === "object" && !Array.isArray(field)) {
|
||||
const f = /** @type {Record<string, unknown>} */ (field);
|
||||
|
||||
if (typeof f.secret === "string" && f.secret.length > 0) {
|
||||
try {
|
||||
return await getSecretPlaintext(ctx.owner, f.secret);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err, secret: f.secret, owner: ctx.owner },
|
||||
"http auth: failed to resolve secret",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof f.kv === "string" && f.kv.length > 0) {
|
||||
const namespace =
|
||||
typeof f.namespace === "string" && f.namespace.length > 0
|
||||
? f.namespace
|
||||
: ctx.workflowKey;
|
||||
try {
|
||||
const raw = await kvGet(namespace, f.kv);
|
||||
return coerceCredentialString(raw);
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{ err, kv: f.kv, namespace },
|
||||
"http auth: failed to resolve kv",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize trigger.auth into an inline auth mechanism object.
|
||||
* @param {unknown} authField
|
||||
* @returns {Promise<{
|
||||
* type: string,
|
||||
* config: Record<string, unknown>,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* label: string,
|
||||
* } | null>}
|
||||
*/
|
||||
export async function resolveAuthMechanism(authField) {
|
||||
if (authField == null || authField === false) return null;
|
||||
|
||||
if (typeof authField === "string") {
|
||||
const named = await getHttpAuthInternal(authField);
|
||||
if (!named) {
|
||||
log.warn({ name: authField }, "http auth: named profile not found");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: named.type,
|
||||
config: named.config,
|
||||
unauthorized_status: named.unauthorized_status,
|
||||
unauthorized_response: named.unauthorized_response,
|
||||
label: authField,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof authField === "object" && !Array.isArray(authField)) {
|
||||
const obj = /** @type {Record<string, unknown>} */ (authField);
|
||||
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
|
||||
return resolveAuthMechanism(obj.name);
|
||||
}
|
||||
try {
|
||||
const type = assertAuthType(obj.type);
|
||||
/** @type {Record<string, unknown>} */
|
||||
const config = { ...obj };
|
||||
delete config.type;
|
||||
delete config.name;
|
||||
return {
|
||||
type,
|
||||
config,
|
||||
unauthorized_status: null,
|
||||
unauthorized_response: null,
|
||||
label: type,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn({ err }, "http auth: invalid inline auth");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for mermaid / summary (sync, no DB).
|
||||
* @param {unknown} authField
|
||||
*/
|
||||
export function authLabel(authField) {
|
||||
if (authField == null) return null;
|
||||
if (typeof authField === "string") return authField;
|
||||
if (typeof authField === "object" && !Array.isArray(authField)) {
|
||||
const o = /** @type {Record<string, unknown>} */ (authField);
|
||||
if (typeof o.name === "string" && o.name) return o.name;
|
||||
if (typeof o.type === "string" && o.type) return o.type;
|
||||
}
|
||||
return "auth";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {{ type: string, config: Record<string, unknown> }} mechanism
|
||||
* @param {{ owner: string, workflowKey: string }} ctx
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkHttpAuth(req, mechanism, ctx) {
|
||||
const type = mechanism.type;
|
||||
const config = mechanism.config ?? {};
|
||||
|
||||
if (type === "bearer") {
|
||||
const expected = await resolveCredentialValue(config.token, ctx);
|
||||
if (expected == null) return false;
|
||||
const header = req.headers.authorization;
|
||||
if (typeof header !== "string") return false;
|
||||
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
|
||||
if (!m) return false;
|
||||
return safeEqualString(m[1], expected);
|
||||
}
|
||||
|
||||
if (type === "basic") {
|
||||
const expectedUser = await resolveCredentialValue(config.user, ctx);
|
||||
if (expectedUser == null) return false;
|
||||
const expectedPass =
|
||||
(await resolveCredentialValue(config.password, ctx)) ?? "";
|
||||
const header = req.headers.authorization;
|
||||
if (typeof header !== "string") return false;
|
||||
const m = /^Basic\s+(.+)$/i.exec(header.trim());
|
||||
if (!m) return false;
|
||||
let decoded;
|
||||
try {
|
||||
decoded = Buffer.from(m[1], "base64").toString("utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const colon = decoded.indexOf(":");
|
||||
const user = colon === -1 ? decoded : decoded.slice(0, colon);
|
||||
const pass = colon === -1 ? "" : decoded.slice(colon + 1);
|
||||
return safeEqualString(user, expectedUser) && safeEqualString(pass, expectedPass);
|
||||
}
|
||||
|
||||
if (type === "header") {
|
||||
const headerName = config.header;
|
||||
if (typeof headerName !== "string" || headerName.length === 0) return false;
|
||||
const expected = await resolveCredentialValue(config.value, ctx);
|
||||
if (expected == null) return false;
|
||||
const actual = req.headers[headerName.toLowerCase()];
|
||||
if (actual == null) return false;
|
||||
const actualStr = Array.isArray(actual) ? actual[0] : String(actual);
|
||||
return safeEqualString(actualStr, expected);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve unauthorized response settings from trigger + named profile defaults.
|
||||
* @param {Record<string, unknown> | undefined} trigger
|
||||
* @param {{ unauthorized_status?: number | null, unauthorized_response?: string | null } | null} mechanism
|
||||
*/
|
||||
export function resolveUnauthorizedSpec(trigger, mechanism) {
|
||||
const unauth =
|
||||
trigger?.unauthorized &&
|
||||
typeof trigger.unauthorized === "object" &&
|
||||
!Array.isArray(trigger.unauthorized)
|
||||
? /** @type {Record<string, unknown>} */ (trigger.unauthorized)
|
||||
: {};
|
||||
|
||||
let status = 401;
|
||||
if (unauth.status != null) {
|
||||
const n = Number(unauth.status);
|
||||
if (Number.isInteger(n) && n >= 100 && n <= 599) status = n;
|
||||
} else if (mechanism?.unauthorized_status != null) {
|
||||
status = mechanism.unauthorized_status;
|
||||
}
|
||||
|
||||
let pageName = null;
|
||||
if (typeof unauth.response === "string" && unauth.response.length > 0) {
|
||||
pageName = unauth.response;
|
||||
} else if (
|
||||
typeof mechanism?.unauthorized_response === "string" &&
|
||||
mechanism.unauthorized_response.length > 0
|
||||
) {
|
||||
pageName = mechanism.unauthorized_response;
|
||||
}
|
||||
|
||||
return { status, pageName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a named HTTP page or a default JSON body.
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
* @param {number} status
|
||||
* @param {string | null} pageName
|
||||
* @param {unknown} [fallbackBody]
|
||||
*/
|
||||
export async function sendHttpPageOrJson(reply, status, pageName, fallbackBody) {
|
||||
if (pageName) {
|
||||
const page = await getHttpPageByName(pageName);
|
||||
if (page) {
|
||||
const code = status ?? page.status;
|
||||
return reply
|
||||
.code(code)
|
||||
.type(contentTypeForMime(page.mime))
|
||||
.send(page.content);
|
||||
}
|
||||
log.warn({ pageName }, "http page not found; using fallback");
|
||||
}
|
||||
return reply.code(status).send(fallbackBody ?? { error: "unauthorized" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a named success response page (uses page's own status by default).
|
||||
* @param {import("fastify").FastifyReply} reply
|
||||
* @param {string} pageName
|
||||
* @param {unknown} [fallbackBody]
|
||||
*/
|
||||
export async function sendSuccessPage(reply, pageName, fallbackBody) {
|
||||
const page = await getHttpPageByName(pageName);
|
||||
if (page) {
|
||||
return reply
|
||||
.code(page.status)
|
||||
.type(contentTypeForMime(page.mime))
|
||||
.send(page.content);
|
||||
}
|
||||
log.warn({ pageName }, "success page not found; using default JSON");
|
||||
return reply.send(fallbackBody);
|
||||
}
|
||||
Reference in New Issue
Block a user