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
@@ -29,7 +29,7 @@
|
|||||||
<li>Git-friendly plain-text project storage</li>
|
<li>Git-friendly plain-text project storage</li>
|
||||||
<li>Environment variables and template functions</li>
|
<li>Environment variables and template functions</li>
|
||||||
<li>Request chaining and dynamic values</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>Import from cURL, Postman, Insomnia, and OpenAPI</li>
|
||||||
<li>Extensible plugin system</li>
|
<li>Extensible plugin system</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Generated
+9
@@ -26,6 +26,7 @@
|
|||||||
"plugins/auth-aws",
|
"plugins/auth-aws",
|
||||||
"plugins/auth-basic",
|
"plugins/auth-basic",
|
||||||
"plugins/auth-bearer",
|
"plugins/auth-bearer",
|
||||||
|
"plugins/auth-digest",
|
||||||
"plugins/auth-jwt",
|
"plugins/auth-jwt",
|
||||||
"plugins/auth-ntlm",
|
"plugins/auth-ntlm",
|
||||||
"plugins/auth-oauth2",
|
"plugins/auth-oauth2",
|
||||||
@@ -5841,6 +5842,10 @@
|
|||||||
"resolved": "plugins/auth-bearer",
|
"resolved": "plugins/auth-bearer",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@yaak/auth-digest": {
|
||||||
|
"resolved": "plugins/auth-digest",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@yaak/auth-jwt": {
|
"node_modules/@yaak/auth-jwt": {
|
||||||
"resolved": "plugins/auth-jwt",
|
"resolved": "plugins/auth-jwt",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -16540,6 +16545,10 @@
|
|||||||
"name": "@yaak/auth-bearer",
|
"name": "@yaak/auth-bearer",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
|
"plugins/auth-digest": {
|
||||||
|
"name": "@yaak/auth-digest",
|
||||||
|
"version": "0.1.0"
|
||||||
|
},
|
||||||
"plugins/auth-jwt": {
|
"plugins/auth-jwt": {
|
||||||
"name": "@yaak/auth-jwt",
|
"name": "@yaak/auth-jwt",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"plugins/auth-aws",
|
"plugins/auth-aws",
|
||||||
"plugins/auth-basic",
|
"plugins/auth-basic",
|
||||||
"plugins/auth-bearer",
|
"plugins/auth-bearer",
|
||||||
|
"plugins/auth-digest",
|
||||||
"plugins/auth-jwt",
|
"plugins/auth-jwt",
|
||||||
"plugins/auth-ntlm",
|
"plugins/auth-ntlm",
|
||||||
"plugins/auth-oauth2",
|
"plugins/auth-oauth2",
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
|||||||
cmd_send_http_request: (payload, db) => {
|
cmd_send_http_request: (payload, db) => {
|
||||||
const requestId = str(payload, "requestId");
|
const requestId = str(payload, "requestId");
|
||||||
if (requestId == null) throw new Error("cmd_send_http_request needs a 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 ---------------------------------- */
|
/* -------------------------------- app ---------------------------------- */
|
||||||
@@ -234,6 +239,7 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
|
|||||||
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
|
||||||
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
|
||||||
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
|
||||||
|
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
|
||||||
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
|
||||||
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
|
||||||
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
|
{ 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_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_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_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.
|
// 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_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_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],
|
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_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||||
cmd_plugins_updates: ["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_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_function_config: [
|
||||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
"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_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_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"],
|
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AnyModel =
|
|||||||
| HttpRequest
|
| HttpRequest
|
||||||
| HttpResponse
|
| HttpResponse
|
||||||
| HttpResponseEvent
|
| HttpResponseEvent
|
||||||
|
| ImportSource
|
||||||
| KeyValue
|
| KeyValue
|
||||||
| Plugin
|
| Plugin
|
||||||
| Settings
|
| Settings
|
||||||
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
|
|||||||
|
|
||||||
export type HttpVersion = "auto" | "http1" | "http2";
|
export type HttpVersion = "auto" | "http1" | "http2";
|
||||||
|
|
||||||
|
export type ImportSource = {
|
||||||
|
model: "import_source";
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
workspaceId: string;
|
||||||
|
importer: string;
|
||||||
|
origin: string;
|
||||||
|
originLabel: string;
|
||||||
|
lastImportedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||||
|
|
||||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user