Support webserver-level HTTP Basic Auth in front of CiviCRM

This commit is contained in:
Joel Brock
2026-05-09 20:49:37 -07:00
parent 234bec5766
commit 82d7849a30
2 changed files with 96 additions and 22 deletions

View File

@@ -36,12 +36,61 @@ function envSummary(): CheckResult {
}; };
const ok = have.CIVI_BASE_URL && have.CIVI_API_KEY && have.CIVI_SITE_KEY; const ok = have.CIVI_BASE_URL && have.CIVI_API_KEY && have.CIVI_SITE_KEY;
const baseUrl = process.env.CIVI_BASE_URL ?? "(unset)"; const baseUrl = process.env.CIVI_BASE_URL ?? "(unset)";
const basicAuth =
process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS
? `set (${process.env.CIVI_HTTP_AUTH_USER})`
: "not set";
const detail = ok const detail = ok
? `All three env vars present. CIVI_BASE_URL=${baseUrl}` ? `Civi env vars present. CIVI_BASE_URL=${baseUrl}. HTTP Basic Auth: ${basicAuth}.`
: `Missing: ${Object.entries(have).filter(([, v]) => !v).map(([k]) => k).join(", ") || "(none)"}. STUB MODE active.`; : `Missing: ${Object.entries(have).filter(([, v]) => !v).map(([k]) => k).join(", ") || "(none)"}. STUB MODE active.`;
return { check: "env_vars", ok, detail }; return { check: "env_vars", ok, detail };
} }
async function probeWebserverReachable(): Promise<CheckResult> {
// Detects the "Apache 401 in front of Civi" pattern specifically. We GET
// the base URL with the same auth headers we'd send to APIv4. If the
// webserver itself rejects (401 + HTML body), it's an htaccess basic-auth
// wall and we need CIVI_HTTP_AUTH_USER / CIVI_HTTP_AUTH_PASS.
try {
const headers: Record<string, string> = {};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(`${process.env.CIVI_BASE_URL}/civicrm`, {
method: "GET",
headers,
cache: "no-store",
redirect: "manual",
});
if (res.status === 401) {
const auth = res.headers.get("www-authenticate") ?? "";
return {
check: "webserver_reachable",
ok: false,
detail:
`Webserver returned 401 from ${process.env.CIVI_BASE_URL}/civicrm. ` +
`WWW-Authenticate: "${auth}". ` +
`This is an HTTP Basic Auth wall in front of CiviCRM (Apache/htaccess). ` +
`Set CIVI_HTTP_AUTH_USER and CIVI_HTTP_AUTH_PASS in .env.local.`,
};
}
return {
check: "webserver_reachable",
ok: res.status < 500,
detail: `GET /civicrm → HTTP ${res.status}.`,
};
} catch (e) {
return {
check: "webserver_reachable",
ok: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function probeContactGet(): Promise<CheckResult> { async function probeContactGet(): Promise<CheckResult> {
try { try {
const res = await civi("Contact", "get", { const res = await civi("Contact", "get", {
@@ -223,15 +272,27 @@ export async function GET(req: Request) {
const checks: CheckResult[] = [env]; const checks: CheckResult[] = [env];
// Run independent probes in parallel. // Webserver probe first — if this fails, the others will too. We run it
const probes = await Promise.all([ // serially so we get a clean "fix this first" signal at the top.
probeContactGet(), const webserver = await probeWebserverReachable();
probeRelationshipType(), checks.push(webserver);
probeActivityType(),
probeStageOptionGroup(), if (webserver.ok) {
probeChecksumApi(), const probes = await Promise.all([
]); probeContactGet(),
checks.push(...probes); probeRelationshipType(),
probeActivityType(),
probeStageOptionGroup(),
probeChecksumApi(),
]);
checks.push(...probes);
} else {
checks.push({
check: "civi_probes_skipped",
ok: false,
detail: "Skipped Civi-level probes because the webserver itself rejected the request. Fix webserver_reachable above first.",
});
}
// If a sample cid+cs was provided, verify that specific checksum. // If a sample cid+cs was provided, verify that specific checksum.
if (cid && cs) { if (cid && cs) {

View File

@@ -6,14 +6,19 @@
* can be developed without a live CiviCRM instance. * can be developed without a live CiviCRM instance.
* *
* Env vars: * Env vars:
* CIVI_BASE_URL e.g. https://crm.fci.coop * CIVI_BASE_URL e.g. https://crm.fci.coop
* CIVI_API_KEY per-user API key (Civi user "API Key" property) * CIVI_API_KEY per-user API key (Civi user "API Key" property)
* CIVI_SITE_KEY site-wide key (from civicrm.settings.php) * CIVI_SITE_KEY site-wide key (from civicrm.settings.php)
* CIVI_HTTP_AUTH_USER (optional) HTTP Basic Auth username, if the site
* itself sits behind webserver-level basic auth
* (common on staging/dev). When set together with
* CIVI_HTTP_AUTH_PASS, every request adds an
* `Authorization: Basic <base64>` header.
* CIVI_HTTP_AUTH_PASS (optional) HTTP Basic Auth password.
* *
* If you are seeing STUB MODE warnings, set those three. Auth strategy may * Auth strategy may need adjustment depending on your CiviCRM auth extension
* also need adjustment depending on your CiviCRM auth extension (AuthX vs * (AuthX vs stock APIv3-style site_key/api_key). The header style here
* stock APIv3-style site_key/api_key). The header style here matches the * matches the AuthX pattern; classic API3 users may need different headers.
* stock CiviCRM 5+ pattern; AuthX users may need Bearer tokens instead.
*/ */
export interface CiviApiOptions { export interface CiviApiOptions {
@@ -56,13 +61,21 @@ export async function civi<T = unknown>(
const body = new URLSearchParams({ const body = new URLSearchParams({
params: JSON.stringify(params), params: JSON.stringify(params),
}); });
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
};
// Webserver-level HTTP Basic Auth (e.g. site is gated by .htaccess on staging).
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { headers,
"Content-Type": "application/x-www-form-urlencoded",
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
"X-Civi-Key": process.env.CIVI_SITE_KEY!,
},
body, body,
cache: "no-store", cache: "no-store",
}); });