Compare commits

...
3 Commits
Author SHA1 Message Date
Gregory Schier dac6cfe6a7 fix(auth-digest): keep the probe from carrying anything that could authorize
Forwarding the request's headers to the probe was a mistake. A request
authenticated by a cookie or an API key header would have had the probe carry
that credential, and the probe reuses the original method: a DELETE would then
perform the deletion it was only supposed to ask permission for, and the signed
request would perform it again.

There is no telling a routing header from a credential by looking at it, and no
denylist can cover custom key headers, so the probe now carries nothing but the
method and URL. That is also what auth-ntlm does. Reaching an endpoint that
routes on a header is worth less than not mutating data twice.

Also replaces the test server's credential regex with a scan. A repeated
character class in front of the `=` backtracks quadratically over a long run of
the characters it accepts, whichever characters those are.
2026-09-10 08:47:37 -07:00
Gregory Schier 5f1773ce93 fix(auth-digest): answer the first challenge that is answerable in full
Challenge selection only asked whether the algorithm was supported, then the
qop was checked much later while building the header. A server offering a
strong challenge with a qop we can't answer, followed by a weaker one we can,
would pick the first and fail — even though it had offered something usable.
Missing nonces had the same shape. Selection now asks the whole question at
once, so an unusable challenge is passed over rather than chosen and failed.

Credentials are normalized to NFC before hashing, which RFC 7616 §4 requires:
a name typed as a combining sequence has to digest the same as its precomposed
spelling, or it authenticates on one keyboard and not another.

The probe now carries the request's own headers, minus the credentials and
anything describing a body it isn't sending. It exists to reach the same
endpoint the real request will, and endpoints behind a gateway can route on a
header.

Also narrows a regex in the test server, where a character class held both `*`
and the name run and backtracked quadratically on a long run of asterisks.
2026-09-09 23:33:57 -07:00
Gregory SchierandClaude Opus 5 8d0be06ed9 feat(auth): add HTTP Digest authentication plugin
Digest can't be precomputed the way Basic can: the response hash covers a
server-issued nonce, so the challenge has to be provoked first. The plugin
follows auth-ntlm's shape — send an unauthenticated probe of the same method
and URL, read WWW-Authenticate off the 401, and return the Authorization
header — with RFC 7616 math in place of NTLM's.

Challenge parsing is its own module because the header is harder than it
looks: challenges and their parameters are both comma-separated, values may be
quoted or bare, and a server may offer several schemes and several realms at
once. The parser splits on commas outside quoted strings and re-groups the
pieces into challenges, which also lets the error message name what the server
actually offered when Digest isn't among them.

Covers MD5, MD5-sess, SHA-256 and SHA-256-sess, and both qop modes plus the
qop-less RFC 2069 form. auth-int is chosen only when Yaak hands over the body,
since its digest must cover the exact bytes sent — bodies that are streamed or
over the size cap arrive as null and would hash to something the server never
saw, so those fall back to auth.

The realm field is only load-bearing when a server offers more than one realm,
so it sits under Advanced alongside the equivalent NTLM fields.

Closes https://yaak.app/feedback/posts/native-support-for-http-digest-authentication

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:17:53 -07:00
11 changed files with 1244 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -29,7 +29,7 @@
<li>Git-friendly plain-text project storage</li>
<li>Environment variables and template functions</li>
<li>Request chaining and dynamic values</li>
<li>OAuth 2.0, Bearer, Basic, API Key, AWS, JWT, and NTLM authentication</li>
<li>OAuth 2.0, Bearer, Basic, Digest, API Key, AWS, JWT, and NTLM authentication</li>
<li>Import from cURL, Postman, Insomnia, and OpenAPI</li>
<li>Extensible plugin system</li>
</ul>
+9
View File
@@ -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",
@@ -5474,6 +5475,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
@@ -15977,6 +15982,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",
+1
View File
@@ -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",
+23 -5
View File
@@ -76,7 +76,12 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
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<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: [
"WebSocket requests aren't available in the browser yet",
"websocket",
],
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_data: [
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
@@ -298,8 +310,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_function_config: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_tokens_to_string: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
+56
View File
@@ -0,0 +1,56 @@
# Digest Authentication
An HTTP Digest Authentication plugin that implements
[RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616), with fallback to the
older [RFC 2617](https://datatracker.ietf.org/doc/html/rfc2617) and
[RFC 2069](https://datatracker.ietf.org/doc/html/rfc2069) behaviour that many
servers still speak.
## Overview
Digest Authentication proves you know a password without ever putting it on the
wire. The server issues a one-time `nonce`, and the client answers with a hash
over the credentials, the nonce and the request itself.
## How it works
Because the digest is computed over a server-issued nonce, the challenge has to
be fetched before the real request can be signed. On each send, the plugin:
1. Sends an unauthenticated probe of the same method and URL, carrying no
headers of its own so it cannot authorize anything
2. Reads the `WWW-Authenticate: Digest …` challenge from the `401` response
3. Computes the response hash and returns the `Authorization` header
## Configuration
- **Username**: Username or user identifier
- **Password**: Password or authentication token
- **Realm** (advanced): Only needed when the server offers more than one realm.
Leave it empty and the first challenge the server prefers is used.
## Supported challenges
| Feature | Supported |
| ---------- | ------------------------------------------------------------- |
| Algorithm | `MD5`, `MD5-sess`, `SHA-256`, `SHA-256-sess` |
| `qop` | `auth`, `auth-int`, and challenges that omit `qop` (RFC 2069) |
| `opaque` | Echoed back when the server sends one |
| `userhash` | Declined with `userhash=false` |
`auth-int` is used when the server offers it and Yaak has the request body in
hand. Bodies that are streamed from disk or above the size Yaak passes to auth
plugins aren't available to hash, so those requests use `auth` instead.
Credentials are normalized to Unicode NFC before hashing, per RFC 7616 §4.
Usernames outside ASCII are sent as an RFC 5987 extended value (`username*`).
## Troubleshooting
- **Server did not offer Digest authentication**: The endpoint answered the probe
with a different scheme, or with no `WWW-Authenticate` header at all. Check the
URL, and whether the endpoint requires auth in the first place.
- **Unsupported Digest algorithm**: The server asked for an algorithm this plugin
doesn't implement, such as `SHA-512-256`.
- **401 Unauthorized**: Verify the username and password. If the server offers
several realms, set the Realm field to the one your account belongs to.
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@yaak/auth-digest",
"displayName": "Digest Authentication",
"version": "0.1.0",
"private": true,
"description": "Authenticate requests using HTTP Digest authentication",
"repository": {
"type": "git",
"url": "https://github.com/mountain-loop/yaak.git",
"directory": "plugins/auth-digest"
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
}
}
+294
View File
@@ -0,0 +1,294 @@
import { createHash } from "node:crypto";
/** A single challenge from a `WWW-Authenticate` header. */
export interface AuthChallenge {
scheme: string;
params: Record<string, string>;
/** The `token68` form (`NTLM TlRMTVNT…`), which carries no parameters. */
token68?: string;
}
export interface DigestChallenge {
realm: string;
nonce: string;
opaque?: string;
qop?: string[];
/** Echoed back verbatim, so it must keep the server's own spelling. */
algorithm?: string;
stale: boolean;
userhash: boolean;
}
export interface DigestAuthorizationOptions {
username: string;
password: string;
method: string;
uri: string;
body: string | null;
challenge: DigestChallenge;
cnonce: string;
nc: number;
}
const TOKEN = "[!#$%&'*+\\-.^_`|~0-9A-Za-z]+";
const PARAM_RE = new RegExp(`^(${TOKEN})\\s*=\\s*([\\s\\S]*)$`);
const SCHEME_RE = new RegExp(`^(${TOKEN})(?:\\s+([\\s\\S]*))?$`);
const TOKEN68_RE = /^[A-Za-z0-9\-._~+/]+=*$/;
const SUPPORTED_ALGORITHMS = ["MD5", "MD5-sess", "SHA-256", "SHA-256-sess"];
const SUPPORTED_QOPS = ["auth", "auth-int"];
/**
* Split a header value on commas that aren't inside a quoted string. Both
* challenges and their parameters are comma-separated, so this yields a flat
* list that {@link parseChallenges} re-groups.
*/
function splitOnCommas(value: string): string[] {
const parts: string[] = [];
let current = "";
let quoted = false;
for (let i = 0; i < value.length; i++) {
const char = value[i]!;
if (quoted && char === "\\" && i + 1 < value.length) {
current += char + value[++i]!;
} else if (char === '"') {
quoted = !quoted;
current += char;
} else if (char === "," && !quoted) {
parts.push(current);
current = "";
} else {
current += char;
}
}
parts.push(current);
return parts.map((p) => p.trim()).filter((p) => p !== "");
}
function unquote(value: string): string {
const trimmed = value.trim();
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1).replace(/\\([\s\S])/g, "$1");
}
return trimmed;
}
export function parseChallenges(headerValues: string[]): AuthChallenge[] {
const challenges: AuthChallenge[] = [];
for (const headerValue of headerValues) {
let current: AuthChallenge | null = null;
for (const part of splitOnCommas(headerValue)) {
const param = PARAM_RE.exec(part);
if (param != null && current != null) {
current.params[param[1]!.toLowerCase()] = unquote(param[2]!);
continue;
}
const scheme = SCHEME_RE.exec(part);
if (scheme == null) continue;
current = { scheme: scheme[1]!, params: {} };
challenges.push(current);
const rest = scheme[2]?.trim();
if (rest == null || rest === "") continue;
if (TOKEN68_RE.test(rest)) {
current.token68 = rest;
continue;
}
const firstParam = PARAM_RE.exec(rest);
if (firstParam != null) {
current.params[firstParam[1]!.toLowerCase()] = unquote(firstParam[2]!);
}
}
}
return challenges;
}
export function toDigestChallenge(params: Record<string, string>): DigestChallenge {
const qop = params.qop
?.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean);
return {
realm: params.realm ?? "",
nonce: params.nonce ?? "",
opaque: params.opaque,
qop: qop == null || qop.length === 0 ? undefined : qop,
algorithm: params.algorithm,
stale: params.stale?.toLowerCase() === "true",
userhash: params.userhash?.toLowerCase() === "true",
};
}
/**
* `MD5`, `MD5-sess`, `SHA-256` and `SHA-256-sess`, tolerating the `SHA256`
* spelling some servers use. Returns null for anything else.
*/
function resolveAlgorithm(algorithm: string | undefined): { hash: string; sess: boolean } | null {
const value = (algorithm ?? "MD5").trim().toLowerCase();
const sess = value.endsWith("-sess");
const base = (sess ? value.slice(0, -"-sess".length) : value).replace(/-/g, "");
if (base === "md5") return { hash: "md5", sess };
if (base === "sha256") return { hash: "sha256", sess };
return null;
}
function unsupportedAlgorithmError(algorithm: string | undefined): Error {
return new Error(
`Unsupported Digest algorithm: ${algorithm ?? "MD5"}. ` +
`Supported algorithms are ${SUPPORTED_ALGORITHMS.join(", ")}`,
);
}
function unsupportedQopError(qop: string[]): Error {
return new Error(
`Unsupported Digest qop: ${qop.join(", ")}. Supported values are ${SUPPORTED_QOPS.join(" and ")}`,
);
}
/**
* Everything that would stop this challenge from being answered, or null if it
* can be. Selection asks the whole question at once so a challenge that fails
* on any count is passed over for the next one the server offered, rather than
* chosen and then failed on later.
*/
function challengeProblem(challenge: DigestChallenge): Error | null {
if (resolveAlgorithm(challenge.algorithm) == null) {
return unsupportedAlgorithmError(challenge.algorithm);
}
if (challenge.nonce === "") {
return new Error('Digest challenge is missing the required "nonce" parameter');
}
if (challenge.qop != null && !challenge.qop.some((q) => SUPPORTED_QOPS.includes(q))) {
return unsupportedQopError(challenge.qop);
}
return null;
}
/**
* Pick the challenge to answer. Servers list challenges strongest-first
* (RFC 7616 §3.7), so the first one we can compute is the one to use.
*/
export function selectDigestChallenge(
challenges: AuthChallenge[],
realm?: string,
): DigestChallenge {
const digestChallenges = challenges.filter((c) => c.scheme.toLowerCase() === "digest");
if (digestChallenges.length === 0) {
const offered = challenges.map((c) => c.scheme).join(", ");
throw new Error(
offered === ""
? "Server did not offer Digest authentication (no WWW-Authenticate header in the response)"
: `Server did not offer Digest authentication. It offered: ${offered}`,
);
}
const inRealm =
realm == null || realm === ""
? digestChallenges
: digestChallenges.filter((c) => c.params.realm === realm);
if (inRealm.length === 0) {
const offered = digestChallenges.map((c) => JSON.stringify(c.params.realm ?? "")).join(", ");
throw new Error(`Server did not offer a Digest realm named "${realm}". It offered: ${offered}`);
}
const candidates = inRealm.map((c) => toDigestChallenge(c.params));
const answerable = candidates.find((c) => challengeProblem(c) == null);
if (answerable == null) throw challengeProblem(candidates[0]!);
return answerable;
}
/**
* Prefer `auth-int` only when the body is in hand, since its digest covers the
* exact bytes sent. A body offered as `null` is either an empty one or one Yaak
* didn't hand over (too large, or streamed from a file), and the two are
* indistinguishable from here. When `auth-int` is all the server offers it is
* still used, hashing the empty body: that is exactly right for the empty case
* and no worse than refusing outright for the other.
*/
function selectQop(qop: string[], body: string | null): "auth" | "auth-int" {
if (qop.includes("auth-int") && (body != null || !qop.includes("auth"))) return "auth-int";
if (qop.includes("auth")) return "auth";
throw unsupportedQopError(qop);
}
function quote(value: string): string {
return `"${value.replace(/(["\\])/g, "\\$1")}"`;
}
/** RFC 5987 `ext-value`, used for usernames that a quoted-string can't carry. */
function encodeExtended(value: string): string {
const encoded = encodeURIComponent(value).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `UTF-8''${encoded}`;
}
export function buildDigestAuthorization(options: DigestAuthorizationOptions): string {
const { method, uri, body, challenge, cnonce, nc } = options;
// RFC 7616 §4 hashes credentials in Normalization Form C, so a name typed as
// a combining sequence digests the same as its precomposed spelling.
const username = options.username.normalize("NFC");
const password = options.password.normalize("NFC");
const algorithm = resolveAlgorithm(challenge.algorithm);
if (algorithm == null) throw unsupportedAlgorithmError(challenge.algorithm);
const hash = (value: string) => createHash(algorithm.hash).update(value, "utf8").digest("hex");
const qop = challenge.qop == null ? null : selectQop(challenge.qop, body);
const ncHex = nc.toString(16).padStart(8, "0");
const secret = hash(`${username}:${challenge.realm}:${password}`);
const ha1 = algorithm.sess ? hash(`${secret}:${challenge.nonce}:${cnonce}`) : secret;
const ha2 =
qop === "auth-int" ? hash(`${method}:${uri}:${hash(body ?? "")}`) : hash(`${method}:${uri}`);
// Without qop the server speaks RFC 2069, where the client contributes nothing
// to the digest and so must not send cnonce, nc or qop back.
const response =
qop == null
? hash(`${ha1}:${challenge.nonce}:${ha2}`)
: hash(`${ha1}:${challenge.nonce}:${ncHex}:${cnonce}:${qop}:${ha2}`);
const params: string[] = [];
params.push(
/^[\x20-\x7E]*$/.test(username)
? `username=${quote(username)}`
: `username*=${encodeExtended(username)}`,
);
params.push(`realm=${quote(challenge.realm)}`);
params.push(`uri=${quote(uri)}`);
if (challenge.algorithm != null) params.push(`algorithm=${challenge.algorithm}`);
params.push(`nonce=${quote(challenge.nonce)}`);
if (qop != null) {
params.push(`nc=${ncHex}`);
params.push(`cnonce=${quote(cnonce)}`);
params.push(`qop=${qop}`);
}
params.push(`response=${quote(response)}`);
if (challenge.opaque != null) params.push(`opaque=${quote(challenge.opaque)}`);
if (challenge.userhash) params.push("userhash=false");
return `Digest ${params.join(", ")}`;
}
/** The origin-form request-target the digest is computed over. */
export function requestTarget(url: string): string {
const absolute = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(url) ? url : `http://${url}`;
const parsed = new URL(absolute);
return `${parsed.pathname}${parsed.search}`;
}
+75
View File
@@ -0,0 +1,75 @@
import { randomBytes } from "node:crypto";
import type { PluginDefinition } from "@yaakapp/api";
import {
buildDigestAuthorization,
parseChallenges,
requestTarget,
selectDigestChallenge,
} from "./digest";
export const plugin: PluginDefinition = {
authentication: {
name: "digest",
label: "Digest Auth",
shortLabel: "Digest",
args: [
{
type: "text",
name: "username",
label: "Username",
optional: true,
},
{
type: "text",
name: "password",
label: "Password",
optional: true,
password: true,
},
{
type: "accordion",
label: "Advanced",
inputs: [
{
type: "text",
name: "realm",
label: "Realm",
optional: true,
description: "Only needed when the server offers more than one realm",
},
],
},
],
async onApply(ctx, { values, method, url, body }) {
const username = values.username ? String(values.username) : "";
const password = values.password ? String(values.password) : "";
const realm = values.realm ? String(values.realm) : undefined;
// Digest needs a server-issued nonce, so the challenge has to be provoked
// before the real request can be signed. The probe carries nothing but the
// method and URL: a cookie or an API key header would let it authorize the
// very operation it is only meant to ask permission for, and there is no
// telling a routing header from a credential by looking at it.
const { httpResponse } = await ctx.httpRequest.send({ httpRequest: { method, url } });
const headerValues = httpResponse.headers
.filter((h) => h.name.toLowerCase() === "www-authenticate")
.map((h) => h.value);
const challenge = selectDigestChallenge(parseChallenges(headerValues), realm);
const value = buildDigestAuthorization({
username,
password,
method,
uri: requestTarget(url),
body,
challenge,
cnonce: randomBytes(16).toString("hex"),
nc: 1,
});
return { setHeaders: [{ name: "Authorization", value }] };
},
},
};
+390
View File
@@ -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");
});
});
+375
View File
@@ -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);
});
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}