diff --git a/flatpak/app.yaak.Yaak.metainfo.xml b/flatpak/app.yaak.Yaak.metainfo.xml
index 87a9772c..eacb3819 100644
--- a/flatpak/app.yaak.Yaak.metainfo.xml
+++ b/flatpak/app.yaak.Yaak.metainfo.xml
@@ -29,7 +29,7 @@
Git-friendly plain-text project storage
Environment variables and template functions
Request chaining and dynamic values
- OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication
+ OAuth 2.0, Bearer, Basic, Digest, API Key, AWS, JWT, and NTLM authentication
Import from cURL, Postman, Insomnia, and OpenAPI
Extensible plugin system
diff --git a/package-lock.json b/package-lock.json
index 71975f79..7dbd3364 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
+ "plugins/auth-digest",
"plugins/auth-jwt",
"plugins/auth-ntlm",
"plugins/auth-oauth2",
@@ -5841,6 +5842,10 @@
"resolved": "plugins/auth-bearer",
"link": true
},
+ "node_modules/@yaak/auth-digest": {
+ "resolved": "plugins/auth-digest",
+ "link": true
+ },
"node_modules/@yaak/auth-jwt": {
"resolved": "plugins/auth-jwt",
"link": true
@@ -16540,6 +16545,10 @@
"name": "@yaak/auth-bearer",
"version": "0.1.0"
},
+ "plugins/auth-digest": {
+ "name": "@yaak/auth-digest",
+ "version": "0.1.0"
+ },
"plugins/auth-jwt": {
"name": "@yaak/auth-jwt",
"version": "0.1.0",
diff --git a/package.json b/package.json
index db8759e5..c6c38092 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"plugins/auth-aws",
"plugins/auth-basic",
"plugins/auth-bearer",
+ "plugins/auth-digest",
"plugins/auth-jwt",
"plugins/auth-ntlm",
"plugins/auth-oauth2",
diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts
index a8d75094..87a633bf 100644
--- a/packages/platform/src/web/commands.ts
+++ b/packages/platform/src/web/commands.ts
@@ -76,7 +76,12 @@ const HANDLERS: Partial> = {
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
- return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
+ return sendHttpRequest(
+ db,
+ requestId,
+ str(payload, "environmentId"),
+ str(payload, "cookieJarId"),
+ );
},
/* -------------------------------- app ---------------------------------- */
@@ -234,6 +239,7 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
+ { name: "digest", label: "Digest Auth", shortLabel: "Digest" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
@@ -262,10 +268,16 @@ const DECLINED: Partial;
+ /** 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): 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}`;
+}
diff --git a/plugins/auth-digest/src/index.ts b/plugins/auth-digest/src/index.ts
new file mode 100644
index 00000000..67f235d6
--- /dev/null
+++ b/plugins/auth-digest/src/index.ts
@@ -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 }] };
+ },
+ },
+};
diff --git a/plugins/auth-digest/tests/digest.test.ts b/plugins/auth-digest/tests/digest.test.ts
new file mode 100644
index 00000000..1f39de46
--- /dev/null
+++ b/plugins/auth-digest/tests/digest.test.ts
@@ -0,0 +1,390 @@
+import { describe, expect, test } from "vite-plus/test";
+import {
+ buildDigestAuthorization,
+ parseChallenges,
+ requestTarget,
+ selectDigestChallenge,
+ toDigestChallenge,
+} from "../src/digest";
+
+function paramOf(header: string, name: string): string | undefined {
+ return parseChallenges([header])[0]?.params[name];
+}
+
+describe("parseChallenges", () => {
+ test("parses quoted and unquoted parameters", () => {
+ expect(parseChallenges(['Digest realm="test", algorithm=MD5, stale=TRUE'])).toEqual([
+ { scheme: "Digest", params: { realm: "test", algorithm: "MD5", stale: "TRUE" } },
+ ]);
+ });
+
+ test("keeps commas and escapes inside quoted values", () => {
+ expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "qop")).toEqual(
+ "auth,auth-int",
+ );
+ expect(paramOf('Digest qop="auth,auth-int", realm="a \\"quoted\\" realm"', "realm")).toEqual(
+ 'a "quoted" realm',
+ );
+ });
+
+ test("tolerates whitespace around the equals sign", () => {
+ expect(paramOf('Digest realm = "test"', "realm")).toEqual("test");
+ });
+
+ test("splits multiple challenges in a single header", () => {
+ expect(parseChallenges(['Basic realm="a", Digest realm="b", nonce="n"'])).toEqual([
+ { scheme: "Basic", params: { realm: "a" } },
+ { scheme: "Digest", params: { realm: "b", nonce: "n" } },
+ ]);
+ });
+
+ test("collects challenges across repeated headers", () => {
+ expect(parseChallenges(['Digest realm="a"', "Negotiate"]).map((c) => c.scheme)).toEqual([
+ "Digest",
+ "Negotiate",
+ ]);
+ });
+
+ test("captures token68 credentials rather than reading them as parameters", () => {
+ expect(parseChallenges(["NTLM TlRMTVNTUAACAAAAAA=="])).toEqual([
+ { scheme: "NTLM", params: {}, token68: "TlRMTVNTUAACAAAAAA==" },
+ ]);
+ });
+
+ test("lower-cases parameter names", () => {
+ expect(paramOf('Digest Realm="test", NONCE="n"', "realm")).toEqual("test");
+ expect(paramOf('Digest Realm="test", NONCE="n"', "nonce")).toEqual("n");
+ });
+});
+
+describe("toDigestChallenge", () => {
+ test("splits qop and reads the boolean flags", () => {
+ const challenge = toDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop=" auth , AUTH-INT ", stale=true'])[0]!
+ .params,
+ );
+ expect(challenge.qop).toEqual(["auth", "auth-int"]);
+ expect(challenge.stale).toBe(true);
+ expect(challenge.userhash).toBe(false);
+ });
+
+ test("treats a missing qop as absent rather than empty", () => {
+ expect(toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])[0]!.params).qop).toBe(
+ undefined,
+ );
+ });
+
+ test("reads userhash", () => {
+ expect(
+ toDigestChallenge(parseChallenges(['Digest realm="r", nonce="n", userhash=TRUE'])[0]!.params)
+ .userhash,
+ ).toBe(true);
+ });
+});
+
+describe("selectDigestChallenge", () => {
+ const md5 = 'Digest realm="a", nonce="n1", algorithm=MD5';
+ const sha = 'Digest realm="b", nonce="n2", algorithm=SHA-256';
+
+ test("takes the first Digest challenge the server prefers", () => {
+ expect(selectDigestChallenge(parseChallenges([sha, md5])).nonce).toEqual("n2");
+ });
+
+ test("skips challenges whose algorithm is not supported", () => {
+ const unsupported = 'Digest realm="c", nonce="n0", algorithm=SHA-512-256';
+ expect(selectDigestChallenge(parseChallenges([unsupported, md5])).nonce).toEqual("n1");
+ });
+
+ test("skips challenges whose qop cannot be answered", () => {
+ const unanswerable = 'Digest realm="c", nonce="n0", qop="auth-conf"';
+ expect(selectDigestChallenge(parseChallenges([unanswerable, md5])).nonce).toEqual("n1");
+ });
+
+ test("skips challenges that carry no nonce", () => {
+ expect(selectDigestChallenge(parseChallenges(['Digest realm="c"', md5])).nonce).toEqual("n1");
+ });
+
+ test("reports the first challenge's problem when none can be answered", () => {
+ expect(() =>
+ selectDigestChallenge(
+ parseChallenges(['Digest realm="c", nonce="n", qop="auth-conf"', 'Digest realm="d"']),
+ ),
+ ).toThrow("Unsupported Digest qop: auth-conf");
+ });
+
+ test("matches the case-insensitive scheme name", () => {
+ expect(selectDigestChallenge(parseChallenges(['digest realm="a", nonce="n1"'])).nonce).toEqual(
+ "n1",
+ );
+ });
+
+ test("selects by realm when one is given", () => {
+ expect(selectDigestChallenge(parseChallenges([sha, md5]), "a").nonce).toEqual("n1");
+ });
+
+ test("errors when the requested realm is not offered", () => {
+ expect(() => selectDigestChallenge(parseChallenges([sha, md5]), "nope")).toThrow(
+ 'Server did not offer a Digest realm named "nope". It offered: "b", "a"',
+ );
+ });
+
+ test("errors when the server offers no Digest challenge", () => {
+ expect(() => selectDigestChallenge(parseChallenges(['Basic realm="a"', "Negotiate"]))).toThrow(
+ "Server did not offer Digest authentication. It offered: Basic, Negotiate",
+ );
+ });
+
+ test("errors when the response carries no challenge at all", () => {
+ expect(() => selectDigestChallenge(parseChallenges([]))).toThrow(
+ "no WWW-Authenticate header in the response",
+ );
+ });
+
+ test("errors when no offered algorithm is supported", () => {
+ expect(() =>
+ selectDigestChallenge(
+ parseChallenges(['Digest realm="c", nonce="n", algorithm=SHA-512-256']),
+ ),
+ ).toThrow("Unsupported Digest algorithm: SHA-512-256");
+ });
+
+ test("errors when the challenge has no nonce", () => {
+ expect(() => selectDigestChallenge(parseChallenges(['Digest realm="c"']))).toThrow(
+ 'Digest challenge is missing the required "nonce" parameter',
+ );
+ });
+});
+
+// https://datatracker.ietf.org/doc/html/rfc7616#section-3.9.1
+describe("RFC 7616 §3.9.1 worked example", () => {
+ const headers = [
+ 'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=SHA-256, ' +
+ 'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
+ 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
+ 'Digest realm="http-auth@example.org", qop="auth, auth-int", algorithm=MD5, ' +
+ 'nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
+ 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
+ ];
+ const common = {
+ username: "Mufasa",
+ password: "Circle of Life",
+ method: "GET",
+ uri: "/dir/index.html",
+ body: null,
+ cnonce: "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ",
+ nc: 1,
+ };
+
+ test("SHA-256", () => {
+ expect(
+ buildDigestAuthorization({
+ ...common,
+ challenge: selectDigestChallenge(parseChallenges(headers)),
+ }),
+ ).toEqual(
+ 'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
+ 'algorithm=SHA-256, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
+ 'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
+ 'response="753927fa0e85d155564e2e272a28d1802ca10daf4496794697cf8db5856cb6c1", ' +
+ 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
+ );
+ });
+
+ test("MD5", () => {
+ expect(
+ buildDigestAuthorization({
+ ...common,
+ challenge: selectDigestChallenge(parseChallenges([headers[1]!])),
+ }),
+ ).toEqual(
+ 'Digest username="Mufasa", realm="http-auth@example.org", uri="/dir/index.html", ' +
+ 'algorithm=MD5, nonce="7ypf/xlj9XXwfDPEoM4URrv/xwf94BcCAzFZH4GiTo0v", ' +
+ 'nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", qop=auth, ' +
+ 'response="8ca523f5e9506fed4657c9700eebdbec", ' +
+ 'opaque="FQhe/qaU925kfnzjCev0ciny7QMkPqMAFRtzCUYo5tdS"',
+ );
+ });
+});
+
+// https://datatracker.ietf.org/doc/html/rfc2617#section-3.5
+describe("RFC 2617 §3.5 worked example", () => {
+ test("MD5 with qop=auth", () => {
+ const challenge = selectDigestChallenge(
+ parseChallenges([
+ 'Digest realm="testrealm@host.com", qop="auth,auth-int", ' +
+ 'nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"',
+ ]),
+ );
+ expect(
+ buildDigestAuthorization({
+ username: "Mufasa",
+ password: "Circle Of Life",
+ method: "GET",
+ uri: "/dir/index.html",
+ body: null,
+ challenge,
+ cnonce: "0a4f113b",
+ nc: 1,
+ }),
+ ).toContain('response="6629fae49393a05397450978507c4ef1"');
+ });
+});
+
+describe("buildDigestAuthorization", () => {
+ const base = {
+ username: "user",
+ password: "pass",
+ method: "POST",
+ uri: "/api",
+ body: null as string | null,
+ cnonce: "abc123",
+ nc: 1,
+ };
+
+ test("omits the client's contribution when the server offers no qop", () => {
+ const header = buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
+ });
+ expect(header).not.toContain("qop=");
+ expect(header).not.toContain("cnonce=");
+ expect(header).not.toContain("nc=");
+ // MD5(HA1:nonce:HA2), per RFC 2069.
+ expect(header).toContain('response="24644771b8983deed818b83aeb3ac381"');
+ });
+
+ test("omits algorithm when the challenge did not name one", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
+ }),
+ ).not.toContain("algorithm=");
+ });
+
+ test("uses auth-int over the body when the server offers it", () => {
+ const header = buildDigestAuthorization({
+ ...base,
+ body: '{"a":1}',
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
+ ),
+ });
+ expect(header).toContain("qop=auth-int");
+ });
+
+ test("falls back to auth when no body was handed over", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop="auth,auth-int"']),
+ ),
+ }),
+ ).toContain("qop=auth");
+ });
+
+ test("uses auth-int over an empty body when it is the only qop offered", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop="auth-int"']),
+ ),
+ }),
+ ).toContain("qop=auth-int");
+ });
+
+ test("rejects a qop it cannot compute", () => {
+ expect(() =>
+ buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop="auth-conf"']),
+ ),
+ }),
+ ).toThrow("Unsupported Digest qop: auth-conf");
+ });
+
+ test("mixes the cnonce into HA1 for -sess algorithms", () => {
+ const sess = buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5-sess']),
+ ),
+ });
+ const plain = buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop=auth, algorithm=MD5']),
+ ),
+ });
+ expect(sess).toContain("algorithm=MD5-sess");
+ expect(sess).not.toEqual(plain);
+ });
+
+ test("declines userhash when the server advertises it", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ challenge: selectDigestChallenge(
+ parseChallenges(['Digest realm="r", nonce="n", qop=auth, userhash=true']),
+ ),
+ }),
+ ).toContain("userhash=false");
+ });
+
+ test("escapes quotes in the credentials it echoes back", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ username: 'a"b',
+ challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
+ }),
+ ).toContain('username="a\\"b"');
+ });
+
+ test("normalizes credentials to NFC before hashing", () => {
+ const challenge = selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"']));
+ // "Jäsøn" spelled with a combining diaeresis rather than a precomposed "ä".
+ const decomposed = buildDigestAuthorization({
+ ...base,
+ username: "Ja\u0308s\u00f8n",
+ password: "pa\u0308ss",
+ challenge,
+ });
+ const precomposed = buildDigestAuthorization({
+ ...base,
+ username: "J\u00e4s\u00f8n",
+ password: "p\u00e4ss",
+ challenge,
+ });
+ expect(decomposed).toEqual(precomposed);
+ });
+
+ test("sends a non-ASCII username as an RFC 5987 extended value", () => {
+ expect(
+ buildDigestAuthorization({
+ ...base,
+ username: "Jäsøn Doe",
+ challenge: selectDigestChallenge(parseChallenges(['Digest realm="r", nonce="n"'])),
+ }),
+ ).toContain("username*=UTF-8''J%C3%A4s%C3%B8n%20Doe");
+ });
+});
+
+describe("requestTarget", () => {
+ test("keeps the path and query", () => {
+ expect(requestTarget("https://example.org/dir/index.html?a=b&c=d")).toEqual(
+ "/dir/index.html?a=b&c=d",
+ );
+ });
+
+ test("uses a bare slash when there is no path", () => {
+ expect(requestTarget("https://example.org")).toEqual("/");
+ });
+
+ test("handles a URL with no scheme", () => {
+ expect(requestTarget("localhost:8080/thing")).toEqual("/thing");
+ });
+});
diff --git a/plugins/auth-digest/tests/index.test.ts b/plugins/auth-digest/tests/index.test.ts
new file mode 100644
index 00000000..dd700e4c
--- /dev/null
+++ b/plugins/auth-digest/tests/index.test.ts
@@ -0,0 +1,375 @@
+import { createHash } from "node:crypto";
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import type { Context } from "@yaakapp/api";
+import { afterEach, describe, expect, test, vi } from "vite-plus/test";
+import { plugin } from "../src";
+
+function apply(ctx: Context, values: Record, over: Partial = {}) {
+ return plugin.authentication!.onApply(ctx, {
+ values,
+ headers: [],
+ url: "https://example.org/dir/index.html?a=b",
+ method: "GET",
+ body: null,
+ contextId: "ctx",
+ ...over,
+ });
+}
+
+type ApplyArgs = Parameters["onApply"]>[1];
+
+function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
+ ctx: Context;
+ send: ReturnType;
+} {
+ const send = vi.fn().mockResolvedValue({ httpResponse: { headers } });
+ return { ctx: { httpRequest: { send } } as unknown as Context, send };
+}
+
+describe("auth-digest onApply", () => {
+ test("probes with the same method and URL, without credentials or body", async () => {
+ const { ctx, send } = ctxRespondingWith([
+ { name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
+ ]);
+
+ await apply(ctx, { username: "user", password: "pass" }, { method: "POST", body: "hello" });
+
+ expect(send).toHaveBeenCalledWith({
+ httpRequest: { method: "POST", url: "https://example.org/dir/index.html?a=b" },
+ });
+ });
+
+ test("keeps credential-bearing headers off the probe", async () => {
+ const { ctx, send } = ctxRespondingWith([
+ { name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
+ ]);
+
+ await apply(
+ ctx,
+ { username: "user", password: "pass" },
+ {
+ method: "DELETE",
+ headers: [
+ { name: "Cookie", value: "session=abc" },
+ { name: "X-Api-Key", value: "secret" },
+ { name: "Authorization", value: "Bearer stale" },
+ ],
+ },
+ );
+
+ expect(send).toHaveBeenCalledWith({
+ httpRequest: { method: "DELETE", url: "https://example.org/dir/index.html?a=b" },
+ });
+ });
+
+ test("signs the request-target rather than the whole URL", async () => {
+ const { ctx } = ctxRespondingWith([
+ { name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
+ ]);
+
+ const result = await apply(ctx, { username: "user", password: "pass" });
+
+ expect(result.setHeaders?.[0]?.name).toEqual("Authorization");
+ expect(result.setHeaders?.[0]?.value).toContain('uri="/dir/index.html?a=b"');
+ });
+
+ test("uses a fresh cnonce on every apply", async () => {
+ const { ctx } = ctxRespondingWith([
+ { name: "WWW-Authenticate", value: 'Digest realm="r", nonce="n", qop=auth' },
+ ]);
+
+ const first = await apply(ctx, { username: "user", password: "pass" });
+ const second = await apply(ctx, { username: "user", password: "pass" });
+
+ expect(first.setHeaders?.[0]?.value).not.toEqual(second.setHeaders?.[0]?.value);
+ });
+
+ test("treats missing credentials as empty strings", async () => {
+ const { ctx } = ctxRespondingWith([
+ { name: "www-authenticate", value: 'Digest realm="r", nonce="n"' },
+ ]);
+
+ expect((await apply(ctx, {})).setHeaders?.[0]?.value).toContain('username=""');
+ });
+
+ test("fails clearly when the server does not offer Digest", async () => {
+ const { ctx } = ctxRespondingWith([{ name: "WWW-Authenticate", value: 'Basic realm="r"' }]);
+
+ await expect(apply(ctx, { username: "user", password: "pass" })).rejects.toThrow(
+ "Server did not offer Digest authentication. It offered: Basic",
+ );
+ });
+
+ test("selects the realm the user configured", async () => {
+ const { ctx } = ctxRespondingWith([
+ { name: "WWW-Authenticate", value: 'Digest realm="one", nonce="n1"' },
+ { name: "WWW-Authenticate", value: 'Digest realm="two", nonce="n2"' },
+ ]);
+
+ expect(
+ (await apply(ctx, { username: "user", password: "pass", realm: "two" })).setHeaders?.[0]
+ ?.value,
+ ).toContain('nonce="n2"');
+ });
+});
+
+function nextComma(value: string, from: number): number {
+ const index = value.indexOf(",", from);
+ return index < 0 ? value.length : index;
+}
+
+/**
+ * Read `Digest name=value, name="value"` credentials by scanning, rather than
+ * with a global regex: a repeated character class in front of the `=` backtracks
+ * quadratically over a long run of the characters it accepts.
+ */
+function parseCredentials(header: string): Record {
+ const params: Record = {};
+ let i = header.indexOf(" ") + 1;
+
+ while (i < header.length) {
+ const equals = header.indexOf("=", i);
+ if (equals < 0) break;
+
+ const name = header.slice(i, equals).trim().toLowerCase();
+ i = equals + 1;
+
+ let value = "";
+ if (header[i] === '"') {
+ for (i++; i < header.length && header[i] !== '"'; i++) {
+ if (header[i] === "\\") i++;
+ value += header[i];
+ }
+ i++;
+ } else {
+ const end = nextComma(header, i);
+ value = header.slice(i, end).trim();
+ i = end;
+ }
+
+ params[name] = value;
+ i = nextComma(header, i) + 1;
+ }
+
+ return params;
+}
+
+/**
+ * A minimally correct Digest server, hashing inline rather than through the
+ * plugin's own helpers so the round trip can't agree with itself on a mistake.
+ */
+function startDigestServer(config: {
+ username: string;
+ password: string;
+ realm: string;
+ nonce: string;
+ algorithm?: string;
+ qop?: string;
+ /** Offer a second, unrelated realm ahead of the real one. */
+ decoyRealm?: string;
+}): Promise<{ url: string; close: () => Promise }> {
+ const hashName = (config.algorithm ?? "MD5").toLowerCase().startsWith("sha-256")
+ ? "sha256"
+ : "md5";
+ const sess = (config.algorithm ?? "").toLowerCase().endsWith("-sess");
+ const hash = (value: string) => createHash(hashName).update(value, "utf8").digest("hex");
+
+ const server: Server = createServer((req, res) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
+ req.on("end", () => {
+ const authorization = req.headers.authorization;
+ if (authorization == null || !authorization.startsWith("Digest ")) {
+ const challenge = [
+ `Digest realm="${config.realm}"`,
+ `nonce="${config.nonce}"`,
+ `algorithm=${config.algorithm ?? "MD5"}`,
+ config.qop == null ? null : `qop="${config.qop}"`,
+ 'opaque="0p4qu3"',
+ ]
+ .filter(Boolean)
+ .join(", ");
+ res.setHeader(
+ "WWW-Authenticate",
+ config.decoyRealm == null
+ ? [challenge]
+ : [
+ `Digest realm="${config.decoyRealm}", nonce="wrong-nonce", algorithm=MD5`,
+ challenge,
+ ],
+ );
+ res.writeHead(401).end("unauthorized");
+ return;
+ }
+
+ const params = parseCredentials(authorization);
+
+ const secret = hash(`${config.username}:${config.realm}:${config.password}`);
+ const ha1 = sess ? hash(`${secret}:${params.nonce}:${params.cnonce}`) : secret;
+ const ha2 =
+ params.qop === "auth-int"
+ ? hash(`${req.method}:${params.uri}:${hash(Buffer.concat(chunks).toString("utf8"))}`)
+ : hash(`${req.method}:${params.uri}`);
+ const expected =
+ params.qop == null
+ ? hash(`${ha1}:${params.nonce}:${ha2}`)
+ : hash(`${ha1}:${params.nonce}:${params.nc}:${params.cnonce}:${params.qop}:${ha2}`);
+
+ const ok =
+ params.username === config.username &&
+ params.realm === config.realm &&
+ params.nonce === config.nonce &&
+ params.uri === req.url &&
+ params.opaque === "0p4qu3" &&
+ params.response === expected;
+
+ res.writeHead(ok ? 200 : 401).end(ok ? "welcome" : "denied");
+ });
+ });
+
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ resolve({
+ url: `http://127.0.0.1:${port}`,
+ close: () => new Promise((done) => server.close(() => done())),
+ });
+ });
+ });
+}
+
+/** Sends for real, so the plugin sees the headers a live server actually returns. */
+function realContext(): Context {
+ return {
+ httpRequest: {
+ async send({ httpRequest }: { httpRequest: { method?: string; url?: string } }) {
+ const res = await fetch(httpRequest.url!, { method: httpRequest.method });
+ await res.text();
+ return {
+ httpResponse: {
+ headers: [...res.headers].map(([name, value]) => ({ name, value })),
+ },
+ };
+ },
+ },
+ } as unknown as Context;
+}
+
+describe("auth-digest against a live server", () => {
+ let close: (() => Promise) | null = null;
+
+ afterEach(async () => {
+ await close?.();
+ close = null;
+ });
+
+ for (const algorithm of ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"]) {
+ test(`authenticates with algorithm=${algorithm}`, async () => {
+ const server = await startDigestServer({
+ username: "Mufasa",
+ password: "Circle of Life",
+ realm: "http-auth@example.org",
+ nonce: "7ypf/xlj9XXwfDPEoM4URrv",
+ algorithm,
+ qop: "auth",
+ });
+ close = server.close;
+
+ const url = `${server.url}/dir/index.html?a=b`;
+ const result = await plugin.authentication!.onApply(realContext(), {
+ values: { username: "Mufasa", password: "Circle of Life" },
+ headers: [],
+ url,
+ method: "GET",
+ body: null,
+ contextId: "ctx",
+ });
+
+ const res = await fetch(url, {
+ headers: { Authorization: result.setHeaders![0]!.value },
+ });
+ expect([res.status, await res.text()]).toEqual([200, "welcome"]);
+ });
+ }
+
+ test("authenticates a POST body with qop=auth-int", async () => {
+ const body = '{"hello":"world"}';
+ const server = await startDigestServer({
+ username: "user",
+ password: "pass",
+ realm: "api@example.org",
+ nonce: "n0nc3",
+ algorithm: "SHA-256",
+ qop: "auth,auth-int",
+ });
+ close = server.close;
+
+ const url = `${server.url}/submit`;
+ const result = await plugin.authentication!.onApply(realContext(), {
+ values: { username: "user", password: "pass" },
+ headers: [],
+ url,
+ method: "POST",
+ body,
+ contextId: "ctx",
+ });
+
+ expect(result.setHeaders![0]!.value).toContain("qop=auth-int");
+
+ const res = await fetch(url, {
+ method: "POST",
+ body,
+ headers: { Authorization: result.setHeaders![0]!.value },
+ });
+ expect([res.status, await res.text()]).toEqual([200, "welcome"]);
+ });
+
+ test("authenticates against an RFC 2069 server that offers no qop", async () => {
+ const server = await startDigestServer({
+ username: "user",
+ password: "pass",
+ realm: "legacy@example.org",
+ nonce: "old-nonce",
+ });
+ close = server.close;
+
+ const url = `${server.url}/legacy`;
+ const result = await plugin.authentication!.onApply(realContext(), {
+ values: { username: "user", password: "pass" },
+ headers: [],
+ url,
+ method: "GET",
+ body: null,
+ contextId: "ctx",
+ });
+
+ const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
+ expect([res.status, await res.text()]).toEqual([200, "welcome"]);
+ });
+
+ test("picks the configured realm out of several the server offers", async () => {
+ const server = await startDigestServer({
+ username: "user",
+ password: "pass",
+ realm: "second@example.org",
+ nonce: "n0nc3",
+ qop: "auth",
+ decoyRealm: "first@example.org",
+ });
+ close = server.close;
+
+ const url = `${server.url}/multi`;
+ const result = await plugin.authentication!.onApply(realContext(), {
+ values: { username: "user", password: "pass", realm: "second@example.org" },
+ headers: [],
+ url,
+ method: "GET",
+ body: null,
+ contextId: "ctx",
+ });
+
+ const res = await fetch(url, { headers: { Authorization: result.setHeaders![0]!.value } });
+ expect(res.status).toEqual(200);
+ });
+});
diff --git a/plugins/auth-digest/tsconfig.json b/plugins/auth-digest/tsconfig.json
new file mode 100644
index 00000000..4082f16a
--- /dev/null
+++ b/plugins/auth-digest/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig.json"
+}