mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-15 22:32:04 +02:00
feat(auth): add HTTP Digest authentication plugin (#628)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cb7ed9264d
commit
522b898620
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>, over: Partial<ApplyArgs> = {}) {
|
||||
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<NonNullable<typeof plugin.authentication>["onApply"]>[1];
|
||||
|
||||
function ctxRespondingWith(headers: Array<{ name: string; value: string }>): {
|
||||
ctx: Context;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
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<void> }> {
|
||||
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<void>((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<void>) | 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user