feat(auth): add HTTP Digest authentication plugin (#628)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-13 09:15:06 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent cb7ed9264d
commit 522b898620
12 changed files with 1257 additions and 6 deletions
+294
View File
@@ -0,0 +1,294 @@
import { createHash } from "node:crypto";
/** A single challenge from a `WWW-Authenticate` header. */
export interface AuthChallenge {
scheme: string;
params: Record<string, string>;
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
token68?: string;
}
export interface DigestChallenge {
realm: string;
nonce: string;
opaque?: string;
qop?: string[];
/** Echoed back verbatim, so it must keep the server's own spelling. */
algorithm?: string;
stale: boolean;
userhash: boolean;
}
export interface DigestAuthorizationOptions {
username: string;
password: string;
method: string;
uri: string;
body: string | null;
challenge: DigestChallenge;
cnonce: string;
nc: number;
}
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
const SUPPORTED_QOPS = ["auth", "auth-int"];
/**
* Split a header value on commas that aren't inside a quoted string. Both
* challenges and their parameters are comma-separated, so this yields a flat
* list that {@link parseChallenges} re-groups.
*/
function splitOnCommas(value: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < value.length; i++) {
const char = value[i]!;
if (quoted && char === "\\" && i + 1 < value.length) {
current += char + value[++i]!;
} else if (char === '"') {
quoted = !quoted;
current += char;
} else if (char === "," && !quoted) {
parts.push(current);
current = "";
} else {
current += char;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter((p) => p !== "");
}
function unquote(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
}
return trimmed;
}
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
const challenges: AuthChallenge[] = [];
for (const headerValue of headerValues) {
let current: AuthChallenge | null = null;
for (const part of splitOnCommas(headerValue)) {
const param = PARAM_RE.exec(part);
if (param != null && current != null) {
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
continue;
}
const scheme = SCHEME_RE.exec(part);
if (scheme == null) continue;
current = { scheme: scheme[1]!, params: {} };
challenges.push(current);
const rest = scheme[2]?.trim();
if (rest == null || rest === "") continue;
if (TOKEN68_RE.test(rest)) {
current.token68 = rest;
continue;
}
const firstParam = PARAM_RE.exec(rest);
if (firstParam != null) {
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
}
}
}
return challenges;
}
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
const qop = params.qop
?.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean);
return {
realm: params.realm ?? "",
nonce: params.nonce ?? "",
opaque: params.opaque,
qop: qop == null || qop.length === 0 ? undefined : qop,
algorithm: params.algorithm,
stale: params.stale?.toLowerCase() === "true",
userhash: params.userhash?.toLowerCase() === "true",
};
}
/**
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
* spelling some servers use. Returns null for anything else.
*/
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
const value = (algorithm ?? "MD5").trim().toLowerCase();
const sess = value.endsWith("-sess");
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
if (base === "md5") return { hash: "md5", sess };
if (base === "sha256") return { hash: "sha256", sess };
return null;
}
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
return new Error(
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
);
}
function unsupportedQopError(qop: string[]): Error {
return new Error(
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
);
}
/**
* Everything that would stop this challenge from being answered, or null if it
* can be. Selection asks the whole question at once so a challenge that fails
* on any count is passed over for the next one the server offered, rather than
* chosen and then failed on later.
*/
function challengeProblem(challenge: DigestChallenge): Error | null {
if (resolveAlgorithm(challenge.algorithm) == null) {
return unsupportedAlgorithmError(challenge.algorithm);
}
if (challenge.nonce === "") {
return new Error('Digest challenge is missing the required "nonce" parameter');
}
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
return unsupportedQopError(challenge.qop);
}
return null;
}
/**
* Pick the challenge to answer. Servers list challenges strongest-first
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
*/
export function selectDigestChallenge(
challenges: AuthChallenge[],
realm?: string,
): DigestChallenge {
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
if (digestChallenges.length === 0) {
const offered = challenges.map((c) => c.scheme).join(", ");
throw new Error(
offered === ""
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
: `Server did not offer Digest authentication. It offered: ${offered}`,
);
}
const inRealm =
realm == null || realm === ""
? digestChallenges
: digestChallenges.filter((c) => c.params.realm === realm);
if (inRealm.length === 0) {
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
}
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
const answerable = candidates.find((c) => challengeProblem(c) == null);
if (answerable == null) throw challengeProblem(candidates[0]!);
return answerable;
}
/**
* Prefer `auth-int` only when the body is in hand, since its digest covers the
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
* didn't hand over (too large, or streamed from a file), and the two are
* indistinguishable from here. When `auth-int` is all the server offers it is
* still used, hashing the empty body: that is exactly right for the empty case
* and no worse than refusing outright for the other.
*/
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
if (qop.includes("auth")) return "auth";
throw unsupportedQopError(qop);
}
function quote(value: string): string {
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
function encodeExtended(value: string): string {
const encoded = encodeURIComponent(value).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `UTF-8''${encoded}`;
}
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
const { method, uri, body, challenge, cnonce, nc } = options;
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
// a combining sequence digests the same as its precomposed spelling.
const username = options.username.normalize("NFC");
const password = options.password.normalize("NFC");
const algorithm = resolveAlgorithm(challenge.algorithm);
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
const ncHex = nc.toString(16).padStart(8, "0");
const secret = hash(`${username}:${challenge.realm}:${password}`);
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
const ha2 =
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
// Without qop the server speaks RFC 2069, where the client contributes nothing
// to the digest and so must not send cnonce, nc or qop back.
const response =
qop == null
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
const params: string[] = [];
params.push(
/^[\x20-\x7E]*$/.test(username)
? `username=${quote(username)}`
: `username*=${encodeExtended(username)}`,
);
params.push(`realm=${quote(challenge.realm)}`);
params.push(`uri=${quote(uri)}`);
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
params.push(`nonce=${quote(challenge.nonce)}`);
if (qop != null) {
params.push(`nc=${ncHex}`);
params.push(`cnonce=${quote(cnonce)}`);
params.push(`qop=${qop}`);
}
params.push(`response=${quote(response)}`);
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
if (challenge.userhash) params.push("userhash=false");
return `Digest ${params.join(", ")}`;
}
/** The origin-form request-target the digest is computed over. */
export function requestTarget(url: string): string {
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
const parsed = new URL(absolute);
return `${parsed.pathname}${parsed.search}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { randomBytes } from "node:crypto";
import type { PluginDefinition } from "@yaakapp/api";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
} from "./digest";
export const plugin: PluginDefinition = {
authentication: {
name: "digest",
label: "Digest Auth",
shortLabel: "Digest",
args: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "text",
name: "realm",
label: "Realm",
optional: true,
description: "Only needed when the server offers more than one realm",
},
],
},
],
async onApply(ctx, { values, method, url, body }) {
const username = values.username ? String(values.username) : "";
const password = values.password ? String(values.password) : "";
const realm = values.realm ? String(values.realm) : undefined;
// Digest needs a server-issued nonce, so the challenge has to be provoked
// before the real request can be signed. The probe carries nothing but the
// method and URL: a cookie or an API key header would let it authorize the
// very operation it is only meant to ask permission for, and there is no
// telling a routing header from a credential by looking at it.
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
const headerValues = httpResponse.headers
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.map((h) => h.value);
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
const value = buildDigestAuthorization({
username,
password,
method,
uri: requestTarget(url),
body,
challenge,
cnonce: randomBytes(16).toString("hex"),
nc: 1,
});
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};