mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-16 14:51:37 +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,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}`;
|
||||
}
|
||||
@@ -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 }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user