- 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.
278 lines
8.5 KiB
JavaScript
278 lines
8.5 KiB
JavaScript
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);
|
|
}
|