Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for Digest authorization #4339

Merged
merged 21 commits into from
Oct 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/hoppscotch-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"chalk": "5.3.0",
"commander": "12.1.0",
"isolated-vm": "5.0.1",
"js-md5": "0.8.3",
"lodash-es": "4.17.21",
"qs": "6.13.0",
"verzod": "0.2.3",
Expand Down
32 changes: 19 additions & 13 deletions packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,19 +370,7 @@ describe("hopp test [options] <file_path_or_id>", () => {
);

describe("Request variables", () => {
test("Picks active request variables and ignores inactive entries", async () => {
const COLL_PATH = getTestJsonFilePath(
"request-vars-coll.json",
"collection"
);

const args = `test ${COLL_PATH}`;

const { error } = await runCLI(args);
expect(error).toBeNull();
});

test("Supports the usage of request variables along with environment variables", async () => {
test("Picks active request variables and ignores inactive entries alongside the usage of environment variables", async () => {
const env = {
...process.env,
secretBasicAuthPasswordEnvVar: "password",
Expand Down Expand Up @@ -430,6 +418,24 @@ describe("hopp test [options] <file_path_or_id>", () => {
expect(error).toBeNull();
});
});

describe("Digest Authorization type", () => {
test("Successfully translates the authorization information to headers/query params and sends it along with the request", async () => {
const COLL_PATH = getTestJsonFilePath(
"digest-auth-coll.json",
"collection"
);
const ENVS_PATH = getTestJsonFilePath(
"digest-auth-envs.json",
"environment"
);

const args = `test ${COLL_PATH} -e ${ENVS_PATH}`;
const { error } = await runCLI(args);

expect(error).toBeNull();
});
});
});

describe("Test `hopp test <file_path_or_id> --delay <delay_in_ms>` command:", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"v": 3,
"name": "Digest Auth - collection",
"folders": [],
"requests": [
{
"v": "8",
"id": "cm0dm70cw000687bnxi830zz7",
"auth": {
"authType": "digest",
"authActive": true,
"username": "<<username>>",
"password": "<<password>>",
"realm": "",
"nonce": "",
"algorithm": "MD5",
"qop": "auth",
"nc": "",
"cnonce": "",
"opaque": "",
"disableRetry": false
},
"body": {
"body": null,
"contentType": null
},
"name": "digest-auth-headers",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<url>>",
"testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});",
"preRequestScript": "",
"responses": {},
"requestVariables": []
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"v": 3,
"name": "Digest Auth (failure state) - collection",
"folders": [],
"requests": [
{
"v": "8",
"id": "cm0dm70cw000687bnxi830zz7",
"auth": {
"authType": "digest",
"authActive": true,
"username": "<<username>>",
"password": "<<password>>",
"realm": "",
"nonce": "",
"algorithm": "MD5",
"qop": "auth",
"nc": "",
"cnonce": "",
"opaque": "",
"disableRetry": true
},
"body": {
"body": null,
"contentType": null
},
"name": "digest-auth-headers",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<url>>",
"testScript": "pw.test(\"Status code is not 200\", ()=> { pw.expect(pw.response.status).not.toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).not.toBeType('string');});",
"preRequestScript": "",
"responses": {},
"requestVariables": []
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"v": 3,
"name": "Digest Auth (success state) - collection",
"folders": [],
"requests": [
{
"v": "8",
"id": "cm0dm70cw000687bnxi830zz7",
"auth": {
"authType": "digest",
"authActive": true,
"username": "<<username>>",
"password": "<<password>>",
"realm": "",
"nonce": "",
"algorithm": "MD5",
"qop": "auth",
"nc": "",
"cnonce": "",
"opaque": "",
"disableRetry": false
},
"body": {
"body": null,
"contentType": null
},
"name": "digest-auth-headers",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<url>>",
"testScript": "pw.test(\"Status code is 200\", ()=> { pw.expect(pw.response.status).toBe(200);}); \n pw.test(\"Receives the www-authenticate header\", ()=> { pw.expect(pw.response.headers['www-authenticate']).toBeType('string');});",
"preRequestScript": "",
"responses": {},
"requestVariables": []
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"v": 1,
"id": "cm0dsn3v70004p4qk3l9b7sjm",
"name": "Digest Auth - environments",
"variables": [
{
"key": "username",
"value": "admin",
"secret": true
},
{
"key": "password",
"value": "admin",
"secret": true
},
{
"key": "url",
"value": "https://test.insightres.org/digest/"
}
]
}
139 changes: 139 additions & 0 deletions packages/hoppscotch-cli/src/utils/auth/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import axios from "axios";
import { md5 } from "js-md5";

import { exceptionColors } from "../getters";

export interface DigestAuthParams {
username: string;
password: string;
realm: string;
nonce: string;
endpoint: string;
method: string;
algorithm: string;
qop: string;
nc?: string;
opaque?: string;
cnonce?: string; // client nonce (optional but typically required in qop='auth')
}

export interface DigestAuthInfo {
realm: string;
nonce: string;
qop: string;
opaque?: string;
algorithm: string;
}

// Utility function to parse Digest auth header values
const parseDigestAuthHeader = (
header: string
): { [key: string]: string } | null => {
const matches = header.match(/([a-z0-9]+)="([^"]+)"/gi);
if (!matches) return null;

const authParams: { [key: string]: string } = {};
matches.forEach((match) => {
const parts = match.split("=");
authParams[parts[0]] = parts[1].replace(/"/g, "");
});

return authParams;
};

// Function to generate Digest Auth Header
export const generateDigestAuthHeader = async (params: DigestAuthParams) => {
const {
username,
password,
realm,
nonce,
endpoint,
method,
algorithm = "MD5",
qop,
nc = "00000001",
opaque,
cnonce,
} = params;

const uri = endpoint.replace(/(^\w+:|^)\/\//, "");

// Generate client nonce if not provided
const generatedCnonce = cnonce || md5(`${Math.random()}`);

// Step 1: Hash the username, realm, and password
const ha1 = md5(`${username}:${realm}:${password}`);

// Step 2: Hash the method and URI
const ha2 = md5(`${method}:${uri}`);

// Step 3: Compute the response hash
const response = md5(
`${ha1}:${nonce}:${nc}:${generatedCnonce}:${qop}:${ha2}`
);

// Build the Digest header
let authHeader = `Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", algorithm="${algorithm}", response="${response}", qop=${qop}, nc=${nc}, cnonce="${generatedCnonce}"`;

if (opaque) {
authHeader += `, opaque="${opaque}"`;
}

return authHeader;
};

export const fetchInitialDigestAuthInfo = async (
url: string,
method: string,
disableRetry: boolean
): Promise<DigestAuthInfo> => {
try {
const initialResponse = await axios.request({
url,
method,
validateStatus: () => true, // Allow handling of all status codes
});

// Check if the response status is 401 (which is expected in Digest Auth flow)
if (initialResponse.status === 401 && !disableRetry) {
const authHeader = initialResponse.headers["www-authenticate"];

if (authHeader) {
const authParams = parseDigestAuthHeader(authHeader);
if (
authParams &&
authParams.realm &&
authParams.nonce &&
authParams.qop
) {
return {
realm: authParams.realm,
nonce: authParams.nonce,
qop: authParams.qop,
opaque: authParams.opaque,
algorithm: authParams.algorithm,
};
}
}
throw new Error(
"Failed to parse authentication parameters from WWW-Authenticate header"
);
} else if (initialResponse.status === 401 && disableRetry) {
throw new Error(
`401 Unauthorized received. Retry is disabled as specified, so no further attempts will be made.`
);
} else {
throw new Error(`Unexpected response: ${initialResponse.status}`);
}
} catch (error) {
const errMsg = error instanceof Error ? error.message : error;

console.error(
exceptionColors.FAIL(
`\n Error fetching initial digest auth info: ${errMsg} \n`
)
);
throw error; // Re-throw the error to handle it further up the chain if needed
}
};
Loading