Skip to content
Open
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
17 changes: 17 additions & 0 deletions packages/bruno-cli/src/runner/prepare-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,23 @@ const prepareRequest = async (item = {}, collection = {}) => {
axiosRequest.requestVariables = request.requestVariables;
axiosRequest.oauth2CredentialVariables = getFormattedOauth2Credentials();

// When auth mode is none but the URL contains embedded credentials (user:pass@host),
// set basicAuth so Basic auth is sent preemptively (handled by interpolate-vars.js),
// and set digestConfig so the digest interceptor is registered as a fallback for servers
// that respond with a Digest challenge. The interceptor removes the Basic header and retries
// with Digest when a 401 Digest challenge is received.
if (!axiosRequest.digestConfig && !axiosRequest.basicAuth) {
try {
const parsedUrl = new URL(axiosRequest.url);
if (parsedUrl.username || parsedUrl.password) {
const username = decodeURIComponent(parsedUrl.username);
const password = decodeURIComponent(parsedUrl.password);
axiosRequest.basicAuth = { username, password };
axiosRequest.digestConfig = { username, password };
}
} catch (e) {}
}

return axiosRequest;
};

Expand Down
52 changes: 52 additions & 0 deletions packages/bruno-cli/tests/runner/prepare-request.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,58 @@ describe('prepare-request: prepareRequest', () => {
expect(result.digestConfig).toEqual(expected);
});
});

describe('Authentication via URL-embedded credentials', () => {
it('sets both basicAuth and digestConfig from URL credentials when auth mode is none', async () => {
item.request.url = 'https://admin:password@www.httpfaker.org/api/auth/digest/auth/admin/password';
item.request.auth = { mode: 'none' };

const result = await prepareRequest(item);
expect(result.basicAuth).toEqual({ username: 'admin', password: 'password' });
expect(result.digestConfig).toEqual({ username: 'admin', password: 'password' });
});

it('does not override explicit digestConfig with URL credentials', async () => {
item.request.url = 'https://urluser:urlpass@www.example.com/api/resource';
item.request.auth = {
mode: 'digest',
digest: { username: 'configuser', password: 'configpass' }
};

const result = await prepareRequest(item);
expect(result.digestConfig).toEqual({ username: 'configuser', password: 'configpass' });
expect(result.basicAuth).toBeUndefined();
});

it('does not override explicit basicAuth with URL credentials', async () => {
item.request.url = 'https://urluser:urlpass@www.example.com/api/resource';
item.request.auth = {
mode: 'basic',
basic: { username: 'configuser', password: 'configpass' }
};

const result = await prepareRequest(item);
expect(result.basicAuth).toEqual({ username: 'configuser', password: 'configpass' });
});

it('decodes percent-encoded credentials from URL', async () => {
item.request.url = 'https://user%40domain:p%40ss@www.example.com/api/resource';
item.request.auth = { mode: 'none' };

const result = await prepareRequest(item);
expect(result.basicAuth).toEqual({ username: 'user@domain', password: 'p@ss' });
expect(result.digestConfig).toEqual({ username: 'user@domain', password: 'p@ss' });
});

it('does not set basicAuth or digestConfig when URL has no credentials', async () => {
item.request.url = 'https://www.example.com/api/resource';
item.request.auth = { mode: 'none' };

const result = await prepareRequest(item);
expect(result.digestConfig).toBeUndefined();
expect(result.basicAuth).toBeUndefined();
});
});
});

describe('Request file body mode', () => {
Expand Down
17 changes: 17 additions & 0 deletions packages/bruno-electron/src/ipc/network/prepare-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,23 @@ const prepareRequest = async (item, collection = {}, abortController) => {
axiosRequest.assertions = request.assertions;
axiosRequest.oauth2Credentials = request.oauth2Credentials;

// When auth mode is none but the URL contains embedded credentials (user:pass@host),
// set basicAuth so Basic auth is sent preemptively (handled by interpolate-vars.js),
// and set digestConfig so the digest interceptor is registered as a fallback for servers
// that respond with a Digest challenge. The interceptor removes the Basic header and retries
// with Digest when a 401 Digest challenge is received.
if (!axiosRequest.digestConfig && !axiosRequest.basicAuth) {
try {
const parsedUrl = new URL(axiosRequest.url);
if (parsedUrl.username || parsedUrl.password) {
const username = decodeURIComponent(parsedUrl.username);
const password = decodeURIComponent(parsedUrl.password);
axiosRequest.basicAuth = { username, password };
axiosRequest.digestConfig = { username, password };
}
} catch (e) {}
}

return axiosRequest;
};

Expand Down
43 changes: 37 additions & 6 deletions packages/bruno-requests/src/auth/digestauth-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,25 @@ function md5(input) {
}

export function addDigestInterceptor(axiosInstance, request) {
const { username, password } = request.digestConfig;
let { username, password } = request.digestConfig;

// If credentials are embedded in the URL (https://user:pass@host/path), extract them
// as a fallback. We do NOT strip the URL here — axios needs the credentials intact to
// send Authorization: Basic for servers that accept Basic auth. The URL is stripped
// inside the error handler only when a Digest challenge is confirmed, at which point
// the Basic header is also removed so the retry uses Digest instead.
let urlHasCredentials = false;
try {
const parsedUrl = new URL(request.url);
if (parsedUrl.username || parsedUrl.password) {
urlHasCredentials = true;
if (!isStrPresent(username)) username = decodeURIComponent(parsedUrl.username);
if (!isStrPresent(password)) password = decodeURIComponent(parsedUrl.password);
}
} catch (e) {
// Unparseable URL — continue with existing credentials
}

console.debug('Digest Auth Interceptor Initialized');

if (!isStrPresent(username) || !isStrPresent(password)) {
Expand All @@ -52,11 +70,24 @@ export function addDigestInterceptor(axiosInstance, request) {
}
originalRequest._retry = true;

if (
error.response?.status === 401
&& containsDigestHeader(error.response)
&& !containsAuthorizationHeader(originalRequest)
) {
if (error.response?.status === 401 && containsDigestHeader(error.response)) {
// When URL-embedded credentials were present, axios auto-added Authorization: Basic
// on the first request. Now that we know the server wants Digest, remove that header
// and strip the credentials from the URL so the retry doesn't re-add Basic auth.
if (urlHasCredentials) {
delete originalRequest.headers['Authorization'];
delete originalRequest.headers['authorization'];
try {
const parsedUrl = new URL(originalRequest.url || request.url);
parsedUrl.username = '';
parsedUrl.password = '';
originalRequest.url = parsedUrl.toString();
} catch (e) {}
}

if (containsAuthorizationHeader(originalRequest)) {
return Promise.reject(error);
}
console.debug('Processing Digest Authentication Challenge');
console.debug(error.response.headers['www-authenticate']);

Expand Down
206 changes: 206 additions & 0 deletions packages/bruno-requests/src/auth/digestauth-helper.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,212 @@
const axios = require('axios');
const { addDigestInterceptor } = require('./digestauth-helper');

describe('Digest Auth — URL-embedded credentials', () => {
test('credentials in URL are used for digest when digestConfig is empty', async () => {
const axiosInstance = axios.create();
let callCount = 0;
let capturedAuth;

axiosInstance.defaults.adapter = async (config) => {
callCount += 1;
if (callCount === 1) {
const error = new Error('Unauthorized');
error.config = config;
error.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="testrealm", nonce="abc", qop="auth"' }
};
throw error;
}
capturedAuth = config.headers?.Authorization || config.headers?.authorization;
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

const request = {
method: 'GET',
url: 'https://admin:s3cr3t@www.example.com/api/protected',
headers: {},
digestConfig: { username: '', password: '' }
};

addDigestInterceptor(axiosInstance, request);
const res = await axiosInstance(request);
expect(res.status).toEqual(200);
expect(capturedAuth).toMatch(/^Digest /);
expect(capturedAuth).toMatch(/username="admin"/);
});

test('URL credentials are stripped from the retry request after a Digest challenge', async () => {
const axiosInstance = axios.create();
let callCount = 0;
let firstRequestUrl;
let retryRequestUrl;

axiosInstance.defaults.adapter = async (config) => {
callCount += 1;
if (callCount === 1) {
firstRequestUrl = config.url;
const error = new Error('Unauthorized');
error.config = config;
error.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="r", nonce="n", qop="auth"' }
};
throw error;
}
retryRequestUrl = config.url;
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

const request = {
method: 'GET',
url: 'https://admin:password@www.example.com/api/resource',
headers: {},
digestConfig: { username: '', password: '' }
};

addDigestInterceptor(axiosInstance, request);
await axiosInstance(request);

// First request carries the original URL (with credentials) so axios can send Basic if needed
expect(firstRequestUrl).toBe('https://admin:password@www.example.com/api/resource');
// Retry after Digest challenge must have credentials stripped to prevent re-adding Basic
expect(retryRequestUrl).not.toContain('admin:password@');
expect(retryRequestUrl).toBe('https://www.example.com/api/resource');
});

test('Basic auth with URL credentials is not broken (no 401 path)', async () => {
// Regression: stripping URL credentials at interceptor setup broke servers that accept Basic auth.
// The URL must reach the adapter intact on the first call so axios can send Authorization: Basic.
const axiosInstance = axios.create();
let firstRequestUrl;

axiosInstance.defaults.adapter = async (config) => {
firstRequestUrl = config.url;
// Server accepts Basic auth immediately — no 401
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

const request = {
method: 'GET',
url: 'https://admin:password@www.example.com/api/basic-resource',
headers: {},
digestConfig: { username: 'admin', password: 'password' }
};

addDigestInterceptor(axiosInstance, request);
const res = await axiosInstance(request);

expect(res.status).toEqual(200);
// URL must still contain credentials on the first (and only) call
expect(firstRequestUrl).toBe('https://admin:password@www.example.com/api/basic-resource');
});

test('digestConfig credentials take priority over URL-embedded credentials', async () => {
const axiosInstance = axios.create();
let capturedAuth;
let callCount = 0;

axiosInstance.defaults.adapter = async (config) => {
callCount += 1;
if (callCount === 1) {
const error = new Error('Unauthorized');
error.config = config;
error.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="r", nonce="n", qop="auth"' }
};
throw error;
}
capturedAuth = config.headers?.Authorization || config.headers?.authorization;
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

const request = {
method: 'GET',
url: 'https://wronguser:wrongpass@www.example.com/api/resource',
headers: {},
digestConfig: { username: 'correctuser', password: 'correctpass' }
};

addDigestInterceptor(axiosInstance, request);
const res = await axiosInstance(request);
expect(res.status).toEqual(200);
// Should use digestConfig credentials, not URL ones
expect(capturedAuth).toMatch(/username="correctuser"/);
expect(capturedAuth).not.toMatch(/username="wronguser"/);
});

test('no Authorization: Basic header is sent when URL has embedded credentials', async () => {
const axiosInstance = axios.create();
let firstRequestHeaders;
let callCount = 0;

axiosInstance.defaults.adapter = async (config) => {
callCount += 1;
if (callCount === 1) {
firstRequestHeaders = { ...config.headers };
const error = new Error('Unauthorized');
error.config = config;
error.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="r", nonce="n", qop="auth"' }
};
throw error;
}
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

const request = {
method: 'GET',
url: 'https://admin:password@www.example.com/api/resource',
headers: {},
digestConfig: { username: 'admin', password: 'password' }
};

addDigestInterceptor(axiosInstance, request);
await axiosInstance(request);

// The first request must not carry Authorization: Basic (which axios adds from URL credentials)
const authHeader = firstRequestHeaders?.Authorization || firstRequestHeaders?.authorization || '';
expect(authHeader).not.toMatch(/^Basic /);
});

test('URL with special characters in credentials are decoded correctly', async () => {
const axiosInstance = axios.create();
let capturedAuth;
let callCount = 0;

axiosInstance.defaults.adapter = async (config) => {
callCount += 1;
if (callCount === 1) {
const error = new Error('Unauthorized');
error.config = config;
error.response = {
status: 401,
headers: { 'www-authenticate': 'Digest realm="r", nonce="n", qop="auth"' }
};
throw error;
}
capturedAuth = config.headers?.Authorization || config.headers?.authorization;
return { status: 200, statusText: 'OK', headers: {}, config, data: { ok: true } };
};

// '@' in password must be percent-encoded in URLs as %40
const request = {
method: 'GET',
url: 'https://user%40domain:p%40ss@www.example.com/api/resource',
headers: {},
digestConfig: { username: '', password: '' }
};

addDigestInterceptor(axiosInstance, request);
await axiosInstance(request);
// Decoded username should be used in the digest header
expect(capturedAuth).toMatch(/username="user@domain"/);
});
});

describe('Digest Auth with query params', () => {
test('uri should include path and query string', async () => {
const axiosInstance = axios.create();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
meta {
name: Basic Auth 200
type: http
seq: 1
}

get {
url: https://user:user@httpbin.org/basic-auth/user/user
body: none
auth: inherit
}

assert {
res.status: eq 200
}

settings {
encodeUrl: true
timeout: 0
}
Loading
Loading