Support webserver-level HTTP Basic Auth in front of CiviCRM
This commit is contained in:
@@ -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,7 +272,12 @@ 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
|
||||||
|
// serially so we get a clean "fix this first" signal at the top.
|
||||||
|
const webserver = await probeWebserverReachable();
|
||||||
|
checks.push(webserver);
|
||||||
|
|
||||||
|
if (webserver.ok) {
|
||||||
const probes = await Promise.all([
|
const probes = await Promise.all([
|
||||||
probeContactGet(),
|
probeContactGet(),
|
||||||
probeRelationshipType(),
|
probeRelationshipType(),
|
||||||
@@ -232,6 +286,13 @@ export async function GET(req: Request) {
|
|||||||
probeChecksumApi(),
|
probeChecksumApi(),
|
||||||
]);
|
]);
|
||||||
checks.push(...probes);
|
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) {
|
||||||
|
|||||||
@@ -9,11 +9,16 @@
|
|||||||
* 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 res = await fetch(url, {
|
const headers: Record<string, string> = {
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
"X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`,
|
||||||
"X-Civi-Key": process.env.CIVI_SITE_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, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
body,
|
body,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user