feat(http-auth): resolve profiles by UUID and allow multi-auth triggers
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,12 +4,27 @@ import { assertHttpStatus } from "./http-pages-store.js";
|
||||
|
||||
const MAX_NAME_LENGTH = 128;
|
||||
const NAME_RE = /^[A-Za-z0-9._-]+$/;
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ALLOWED_TYPES = new Set(["bearer", "basic", "header"]);
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} id
|
||||
* @returns {string}
|
||||
*/
|
||||
export function assertAuthId(id) {
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) {
|
||||
const err = new Error("invalid auth id");
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return id.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} name
|
||||
* @returns {string}
|
||||
@@ -218,10 +233,11 @@ function publicAuth(row, { includeConfig = true } = {}) {
|
||||
|
||||
/**
|
||||
* Internal: full config including literals (for runtime auth checks).
|
||||
* @param {string} name
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthInternal(name) {
|
||||
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
|
||||
export async function getHttpAuthInternal(id) {
|
||||
const authId = assertAuthId(id);
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -235,11 +251,11 @@ export async function getHttpAuthInternal(name) {
|
||||
|
||||
/**
|
||||
* Return only plaintext literal credential fields (not KV refs or encrypted secrets).
|
||||
* @param {string} name
|
||||
* @returns {Promise<{ name: string, type: string, literals: Record<string, string> } | null>}
|
||||
* @param {string} id
|
||||
* @returns {Promise<{ id: string, name: string, type: string, literals: Record<string, string> } | null>}
|
||||
*/
|
||||
export async function revealHttpAuthLiterals(name) {
|
||||
const internal = await getHttpAuthInternal(name);
|
||||
export async function revealHttpAuthLiterals(id) {
|
||||
const internal = await getHttpAuthInternal(id);
|
||||
if (!internal) return null;
|
||||
/** @type {Record<string, string>} */
|
||||
const literals = {};
|
||||
@@ -248,7 +264,12 @@ export async function revealHttpAuthLiterals(name) {
|
||||
const v = cfg[key];
|
||||
if (typeof v === "string") literals[key] = v;
|
||||
}
|
||||
return { name: internal.name, type: internal.type, literals };
|
||||
return {
|
||||
id: internal.id,
|
||||
name: internal.name,
|
||||
type: internal.type,
|
||||
literals,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHttpAuths() {
|
||||
@@ -256,24 +277,23 @@ export async function listHttpAuths() {
|
||||
return rows.map((r) => publicAuth(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
export async function getHttpAuthByName(name) {
|
||||
const row = await db("http_auths").where({ name: assertAuthName(name) }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
export async function getHttpAuthById(id) {
|
||||
const row = await db("http_auths").where({ id }).first();
|
||||
let authId;
|
||||
try {
|
||||
authId = assertAuthId(id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const row = await db("http_auths").where({ id: authId }).first();
|
||||
return row ? publicAuth(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* id?: string | null,
|
||||
* name: string,
|
||||
* type: string,
|
||||
* config?: unknown,
|
||||
@@ -282,6 +302,7 @@ export async function getHttpAuthById(id) {
|
||||
* }} opts
|
||||
*/
|
||||
export async function upsertHttpAuth({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
config,
|
||||
@@ -290,7 +311,26 @@ export async function upsertHttpAuth({
|
||||
}) {
|
||||
const authName = assertAuthName(name);
|
||||
const authType = assertAuthType(type);
|
||||
const existing = await db("http_auths").where({ name: authName }).first();
|
||||
|
||||
/** @type {Record<string, unknown> | null} */
|
||||
let existing = null;
|
||||
if (id != null && String(id).length > 0) {
|
||||
const authId = assertAuthId(id);
|
||||
existing = await db("http_auths").where({ id: authId }).first();
|
||||
if (!existing) {
|
||||
const err = new Error("auth not found");
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const nameClash = await db("http_auths").where({ name: authName }).first();
|
||||
if (nameClash && (!existing || nameClash.id !== existing.id)) {
|
||||
const err = new Error(`auth name "${authName}" already exists`);
|
||||
err.statusCode = 409;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prevConfig = existing ? parseConfig(existing.config) : {};
|
||||
const normalized = normalizeAuthConfig(authType, config, {
|
||||
keepLiteralsFrom: prevConfig,
|
||||
@@ -315,18 +355,19 @@ export async function upsertHttpAuth({
|
||||
await db("http_auths")
|
||||
.where({ id: existing.id })
|
||||
.update({
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
unauthorized_status: unauthStatus,
|
||||
unauthorized_response: unauthResponse,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(existing.id);
|
||||
return getHttpAuthById(/** @type {string} */ (existing.id));
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const newId = randomUUID();
|
||||
await db("http_auths").insert({
|
||||
id,
|
||||
id: newId,
|
||||
name: authName,
|
||||
type: authType,
|
||||
config: configJson,
|
||||
@@ -335,7 +376,7 @@ export async function upsertHttpAuth({
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
return getHttpAuthById(id);
|
||||
return getHttpAuthById(newId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +384,7 @@ export async function upsertHttpAuth({
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function deleteHttpAuth(id) {
|
||||
const n = await db("http_auths").where({ id }).del();
|
||||
const authId = assertAuthId(id);
|
||||
const n = await db("http_auths").where({ id: authId }).del();
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
@@ -77,38 +77,47 @@ export async function resolveCredentialValue(field, ctx) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize trigger.auth into an inline auth mechanism object.
|
||||
* @param {unknown} authField
|
||||
* @returns {Promise<{
|
||||
* @typedef {{
|
||||
* type: string,
|
||||
* config: Record<string, unknown>,
|
||||
* unauthorized_status?: number | null,
|
||||
* unauthorized_response?: string | null,
|
||||
* label: string,
|
||||
* } | null>}
|
||||
* }} AuthMechanism
|
||||
*/
|
||||
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");
|
||||
/**
|
||||
* Resolve one auth entry (auth profile id UUID, or inline object).
|
||||
* @param {unknown} entry
|
||||
* @returns {Promise<AuthMechanism | null>}
|
||||
*/
|
||||
export async function resolveAuthMechanism(entry) {
|
||||
if (entry == null || entry === false) return null;
|
||||
|
||||
if (typeof entry === "string") {
|
||||
try {
|
||||
const named = await getHttpAuthInternal(entry);
|
||||
if (!named) {
|
||||
log.warn({ id: entry }, "http auth: profile id not found");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: named.type,
|
||||
config: named.config,
|
||||
unauthorized_status: named.unauthorized_status,
|
||||
unauthorized_response: named.unauthorized_response,
|
||||
label: named.name,
|
||||
};
|
||||
} catch (err) {
|
||||
log.warn({ err, id: entry }, "http auth: invalid profile id");
|
||||
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);
|
||||
if (typeof entry === "object" && !Array.isArray(entry)) {
|
||||
const obj = /** @type {Record<string, unknown>} */ (entry);
|
||||
if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) {
|
||||
return resolveAuthMechanism(obj.id);
|
||||
}
|
||||
try {
|
||||
const type = assertAuthType(obj.type);
|
||||
@@ -116,6 +125,7 @@ export async function resolveAuthMechanism(authField) {
|
||||
const config = { ...obj };
|
||||
delete config.type;
|
||||
delete config.name;
|
||||
delete config.id;
|
||||
return {
|
||||
type,
|
||||
config,
|
||||
@@ -133,18 +143,64 @@ export async function resolveAuthMechanism(authField) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for mermaid / summary (sync, no DB).
|
||||
* Normalize trigger.auth (array of auth ids / inline objects) into mechanisms.
|
||||
* Empty / null / false → no auth. Any entry that fails to resolve is skipped;
|
||||
* if the field was non-empty but nothing resolves, returns [] (caller treats as unauthorized).
|
||||
* @param {unknown} authField
|
||||
* @returns {Promise<AuthMechanism[]>}
|
||||
*/
|
||||
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;
|
||||
export async function resolveAuthMechanisms(authField) {
|
||||
if (authField == null || authField === false) return [];
|
||||
if (!Array.isArray(authField) || authField.length === 0) return [];
|
||||
|
||||
/** @type {AuthMechanism[]} */
|
||||
const out = [];
|
||||
for (const entry of authField) {
|
||||
const mech = await resolveAuthMechanism(entry);
|
||||
if (mech) out.push(mech);
|
||||
}
|
||||
return "auth";
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if any mechanism accepts the request (OR).
|
||||
* @param {import("fastify").FastifyRequest} req
|
||||
* @param {AuthMechanism[]} mechanisms
|
||||
* @param {{ owner: string, workflowKey: string }} ctx
|
||||
*/
|
||||
export async function checkAnyHttpAuth(req, mechanisms, ctx) {
|
||||
for (const mechanism of mechanisms) {
|
||||
if (await checkHttpAuth(req, mechanism, ctx)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for mermaid / summary (sync). Prefer resolved display names when provided.
|
||||
* @param {unknown} authField
|
||||
* @param {Map<string, string> | Record<string, string>} [nameById]
|
||||
*/
|
||||
export function authLabel(authField, nameById) {
|
||||
if (authField == null || authField === false) return null;
|
||||
if (!Array.isArray(authField) || authField.length === 0) return null;
|
||||
const lookup =
|
||||
nameById instanceof Map
|
||||
? (id) => nameById.get(id)
|
||||
: nameById
|
||||
? (id) => nameById[id]
|
||||
: () => undefined;
|
||||
const parts = authField.map((entry) => {
|
||||
if (typeof entry === "string") return lookup(entry) ?? entry;
|
||||
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
||||
const o = /** @type {Record<string, unknown>} */ (entry);
|
||||
if (typeof o.id === "string" && o.id && !o.type) {
|
||||
return lookup(o.id) ?? o.id;
|
||||
}
|
||||
if (typeof o.type === "string" && o.type) return o.type;
|
||||
}
|
||||
return "auth";
|
||||
});
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
assertAuthId,
|
||||
assertAuthName,
|
||||
assertAuthType,
|
||||
listHttpAuths,
|
||||
getHttpAuthById,
|
||||
getHttpAuthByName,
|
||||
upsertHttpAuth,
|
||||
deleteHttpAuth,
|
||||
revealHttpAuthLiterals,
|
||||
@@ -18,28 +18,28 @@ export default async function httpAuthsPlugin(fastify) {
|
||||
return { auths: await listHttpAuths() };
|
||||
});
|
||||
|
||||
fastify.get("/http-auths/:name/reveal", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
fastify.get("/http-auths/:id/reveal", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
assertAuthName(name);
|
||||
assertAuthId(id);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const revealed = await revealHttpAuthLiterals(name);
|
||||
const revealed = await revealHttpAuthLiterals(id);
|
||||
if (!revealed) {
|
||||
return reply.code(404).send({ error: "auth not found" });
|
||||
}
|
||||
return revealed;
|
||||
});
|
||||
|
||||
fastify.get("/http-auths/:name", async (req, reply) => {
|
||||
const { name } = /** @type {{ name: string }} */ (req.params);
|
||||
fastify.get("/http-auths/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
assertAuthName(name);
|
||||
assertAuthId(id);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const auth = await getHttpAuthByName(name);
|
||||
const auth = await getHttpAuthById(id);
|
||||
if (!auth) {
|
||||
return reply.code(404).send({ error: "auth not found" });
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export default async function httpAuthsPlugin(fastify) {
|
||||
|
||||
fastify.put("/http-auths", async (req, reply) => {
|
||||
const body = /** @type {{
|
||||
id?: string | null,
|
||||
name?: string,
|
||||
type?: string,
|
||||
config?: unknown,
|
||||
@@ -57,6 +58,9 @@ export default async function httpAuthsPlugin(fastify) {
|
||||
try {
|
||||
assertAuthName(String(body.name ?? ""));
|
||||
assertAuthType(body.type);
|
||||
if (body.id != null && String(body.id).length > 0) {
|
||||
assertAuthId(String(body.id));
|
||||
}
|
||||
if (
|
||||
body.unauthorized_response != null &&
|
||||
String(body.unauthorized_response).length > 0
|
||||
@@ -69,6 +73,7 @@ export default async function httpAuthsPlugin(fastify) {
|
||||
);
|
||||
}
|
||||
const auth = await upsertHttpAuth({
|
||||
id: body.id != null && String(body.id).length > 0 ? String(body.id) : null,
|
||||
name: String(body.name),
|
||||
type: String(body.type),
|
||||
config: body.config,
|
||||
@@ -83,6 +88,11 @@ export default async function httpAuthsPlugin(fastify) {
|
||||
|
||||
fastify.delete("/http-auths/:id", async (req, reply) => {
|
||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||
try {
|
||||
assertAuthId(id);
|
||||
} catch (err) {
|
||||
return reply.code(err.statusCode ?? 400).send({ error: err.message });
|
||||
}
|
||||
const existing = await getHttpAuthById(id);
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: "auth not found" });
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
getHttpAuthInternal,
|
||||
} from "../http-auths-store.js";
|
||||
import {
|
||||
authLabel,
|
||||
checkAnyHttpAuth,
|
||||
checkHttpAuth,
|
||||
coerceCredentialString,
|
||||
resolveAuthMechanism,
|
||||
resolveAuthMechanisms,
|
||||
resolveCredentialValue,
|
||||
resolveUnauthorizedSpec,
|
||||
sendHttpPageOrJson,
|
||||
@@ -176,11 +179,11 @@ const secret = await upsertSecret({
|
||||
config: { token: { secret: "does_not_exist_xyz" } },
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
);
|
||||
assert(!missingSec, "missing secret fails closed");
|
||||
}
|
||||
|
||||
// --- named profile ---
|
||||
// --- named profile (by id) ---
|
||||
const profile = await upsertHttpAuth({
|
||||
name: "webhook-smoke",
|
||||
type: "bearer",
|
||||
@@ -188,9 +191,10 @@ const profile = await upsertHttpAuth({
|
||||
unauthorized_status: 403,
|
||||
unauthorized_response: "deny-smoke",
|
||||
});
|
||||
assert(typeof profile.id === "string" && profile.id.length > 0, "profile has id");
|
||||
{
|
||||
const mech = await resolveAuthMechanism("webhook-smoke");
|
||||
assert(mech?.label === "webhook-smoke", "named profile");
|
||||
const mech = await resolveAuthMechanism(profile.id);
|
||||
assert(mech?.label === "webhook-smoke", "profile by id");
|
||||
const ok = await checkHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mech,
|
||||
@@ -202,9 +206,68 @@ const profile = await upsertHttpAuth({
|
||||
assert(pageName === "deny-smoke", "profile unauth page");
|
||||
}
|
||||
|
||||
// --- rename keeps id ---
|
||||
{
|
||||
const renamed = await upsertHttpAuth({
|
||||
id: profile.id,
|
||||
name: "webhook-renamed",
|
||||
type: "bearer",
|
||||
config: { token: { keep: true } },
|
||||
unauthorized_status: 403,
|
||||
unauthorized_response: "deny-smoke",
|
||||
});
|
||||
assert(renamed.id === profile.id, "rename keeps id");
|
||||
assert(renamed.name === "webhook-renamed", "rename updates name");
|
||||
const mech = await resolveAuthMechanism(profile.id);
|
||||
assert(mech?.label === "webhook-renamed", "resolve uses new name label");
|
||||
const ok = await checkHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mech,
|
||||
ctx,
|
||||
);
|
||||
assert(ok, "credentials survive rename");
|
||||
}
|
||||
|
||||
// --- multi-auth OR ---
|
||||
const basicProfile = await upsertHttpAuth({
|
||||
name: "basic-smoke",
|
||||
type: "basic",
|
||||
config: { user: "bob", password: "p@ss" },
|
||||
});
|
||||
{
|
||||
const mechs = await resolveAuthMechanisms([profile.id, basicProfile.id]);
|
||||
assert(mechs.length === 2, "resolve two mechanisms");
|
||||
assert(
|
||||
authLabel([profile.id, basicProfile.id], {
|
||||
[profile.id]: "webhook-renamed",
|
||||
[basicProfile.id]: "basic-smoke",
|
||||
}) === "webhook-renamed|basic-smoke",
|
||||
"authLabel",
|
||||
);
|
||||
const viaBearer = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: "Bearer named-token" }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(viaBearer, "OR accepts bearer");
|
||||
const encoded = Buffer.from("bob:p@ss").toString("base64");
|
||||
const viaBasic = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: `Basic ${encoded}` }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(viaBasic, "OR accepts basic");
|
||||
const neither = await checkAnyHttpAuth(
|
||||
mockReq({ authorization: "Bearer wrong" }),
|
||||
mechs,
|
||||
ctx,
|
||||
);
|
||||
assert(!neither, "OR rejects when none match");
|
||||
}
|
||||
|
||||
// trigger-level override
|
||||
{
|
||||
const mech = await getHttpAuthInternal("webhook-smoke");
|
||||
const mech = await getHttpAuthInternal(profile.id);
|
||||
const { status, pageName } = resolveUnauthorizedSpec(
|
||||
{ unauthorized: { status: 401, response: "deny-smoke" } },
|
||||
mech,
|
||||
@@ -226,7 +289,7 @@ await validateWorkflowHttpTriggers({
|
||||
type: "HTTP",
|
||||
method: "POST",
|
||||
path: "/x",
|
||||
auth: "webhook-smoke",
|
||||
auth: [profile.id, basicProfile.id],
|
||||
response: "deny-smoke",
|
||||
},
|
||||
],
|
||||
@@ -235,12 +298,38 @@ await validateWorkflowHttpTriggers({
|
||||
let threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: "no-such-profile" }],
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: profile.id }],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "unknown auth fails validation");
|
||||
assert(threw, "non-array auth fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [{ type: "HTTP", path: "/x", auth: ["webhook-renamed"] }],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "name string fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
await validateWorkflowHttpTriggers({
|
||||
triggers: [
|
||||
{
|
||||
type: "HTTP",
|
||||
path: "/x",
|
||||
auth: ["00000000-0000-4000-8000-000000000000"],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assert(threw, "unknown auth id fails validation");
|
||||
|
||||
threw = false;
|
||||
try {
|
||||
@@ -280,6 +369,7 @@ assert(threw, "unknown page fails validation");
|
||||
|
||||
// cleanup
|
||||
await deleteHttpAuth(profile.id);
|
||||
await deleteHttpAuth(basicProfile.id);
|
||||
await deleteHttpPage(page.id);
|
||||
await deleteSecret(secret.id);
|
||||
const leftover = (await listSecrets({ owner })).find(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Validate HTTP trigger auth / response fields on workflow save.
|
||||
*/
|
||||
import { assertAuthType, getHttpAuthByName } from "./http-auths-store.js";
|
||||
import {
|
||||
assertAuthId,
|
||||
assertAuthType,
|
||||
getHttpAuthById,
|
||||
} from "./http-auths-store.js";
|
||||
import { getHttpPageByName, assertHttpResponsePage } from "./http-pages-store.js";
|
||||
import { authLabel } from "./http-trigger-auth.js";
|
||||
|
||||
@@ -24,51 +28,80 @@ function assertCredentialFieldShape(field, label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} auth
|
||||
* @param {unknown} entry
|
||||
* @param {string} path
|
||||
*/
|
||||
async function validateAuthField(auth) {
|
||||
if (auth == null || auth === false) return;
|
||||
|
||||
if (typeof auth === "string") {
|
||||
const named = await getHttpAuthByName(auth);
|
||||
async function validateAuthEntry(entry, path) {
|
||||
if (typeof entry === "string") {
|
||||
try {
|
||||
assertAuthId(entry);
|
||||
} catch {
|
||||
const err = new Error(`${path} must be an auth profile UUID`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
const named = await getHttpAuthById(entry);
|
||||
if (!named) {
|
||||
const err = new Error(`unknown auth profile "${auth}"`);
|
||||
const err = new Error(`unknown auth profile id "${entry}"`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof auth === "object" && !Array.isArray(auth)) {
|
||||
const obj = /** @type {Record<string, unknown>} */ (auth);
|
||||
if (typeof obj.name === "string" && obj.name.length > 0 && !obj.type) {
|
||||
await validateAuthField(obj.name);
|
||||
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
|
||||
const obj = /** @type {Record<string, unknown>} */ (entry);
|
||||
if (typeof obj.id === "string" && obj.id.length > 0 && !obj.type) {
|
||||
await validateAuthEntry(obj.id, path);
|
||||
return;
|
||||
}
|
||||
const type = assertAuthType(obj.type);
|
||||
if (type === "bearer") {
|
||||
assertCredentialFieldShape(obj.token, "auth.token");
|
||||
assertCredentialFieldShape(obj.token, `${path}.token`);
|
||||
} else if (type === "basic") {
|
||||
assertCredentialFieldShape(obj.user, "auth.user");
|
||||
assertCredentialFieldShape(obj.user, `${path}.user`);
|
||||
if (obj.password != null && obj.password !== "") {
|
||||
assertCredentialFieldShape(obj.password, "auth.password");
|
||||
assertCredentialFieldShape(obj.password, `${path}.password`);
|
||||
}
|
||||
} else if (type === "header") {
|
||||
if (typeof obj.header !== "string" || obj.header.length === 0) {
|
||||
const err = new Error("auth.header must be a non-empty string");
|
||||
const err = new Error(`${path}.header must be a non-empty string`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
assertCredentialFieldShape(obj.value, "auth.value");
|
||||
assertCredentialFieldShape(obj.value, `${path}.value`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const err = new Error("auth must be a profile name or an auth object");
|
||||
const err = new Error(
|
||||
`${path} must be an auth profile UUID or an inline auth object`,
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* auth is an array of auth profile UUIDs and/or inline auth objects (OR).
|
||||
* null / false / [] = no auth.
|
||||
* @param {unknown} auth
|
||||
*/
|
||||
async function validateAuthField(auth) {
|
||||
if (auth == null || auth === false) return;
|
||||
|
||||
if (!Array.isArray(auth)) {
|
||||
const err = new Error(
|
||||
"auth must be an array of auth profile UUIDs and/or inline auth objects",
|
||||
);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
for (let i = 0; i < auth.length; i++) {
|
||||
await validateAuthEntry(auth[i], `auth[${i}]`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} pageName
|
||||
* @param {string} label
|
||||
|
||||
@@ -13,4 +13,5 @@ triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /dev-zte-sms
|
||||
auth: basic-auth
|
||||
auth:
|
||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
||||
|
||||
@@ -36,4 +36,5 @@ triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /send-gmail
|
||||
auth: basic-auth
|
||||
auth:
|
||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: time to ntfy example
|
||||
description: |
|
||||
this workflow will send a message to ntfy with the current time
|
||||
enabled: false
|
||||
scripts:
|
||||
- script: plugin/get-current-time
|
||||
config:
|
||||
@@ -12,3 +13,5 @@ triggers:
|
||||
- type: HTTP
|
||||
method: POST
|
||||
path: /time-to-ntfy
|
||||
auth:
|
||||
- 0f78d6d7-bd44-45d7-a826-f51c027b767f
|
||||
|
||||
Reference in New Issue
Block a user