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" +}