Skip to content

Commit a6dd228

Browse files
feat(playground): support digest auth (BRU-3769) (#49)
Digest is now a selectable auth mode with the RFC 2617 challenge/response implemented browser-side (MD5 via js-md5, probe + single retry with credentials omitted to suppress the native login dialog). Digest failures surface as response-pane errors instead of silent unauthenticated sends. Header output is byte-compatible with the desktop app.
1 parent dec50bb commit a6dd228

20 files changed

Lines changed: 1206 additions & 23 deletions

File tree

‎package-lock.json‎

Lines changed: 11 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/bruno-api-docs/e2e/components/environments/env-editor.component.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,11 @@ export class EnvEditorComponent extends BaseComponent {
2828
cardFor(name: string): Locator {
2929
return this.cardItems.filter({ has: this.page.getByRole('checkbox', { name: `Enable ${name}` }) });
3030
}
31+
32+
async addVariable(name: string, value: string): Promise<void> {
33+
const index = (await this.nameInputs.count()) - 1;
34+
await this.nameInputs.nth(index).fill(name);
35+
await this.valueInputs.nth(index).fill(value);
36+
await this.cardItems.nth(index + 1).waitFor({ state: 'visible' });
37+
}
3138
}

‎packages/bruno-api-docs/e2e/components/playground.component.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,20 +79,25 @@ export class PlaygroundComponent extends BaseComponent {
7979
}
8080

8181
async openSidebarItem(name: string): Promise<void> {
82+
await this.ensureSidebarOpen();
8283
await this.sidebarItem(name).click();
8384
}
8485

8586
scriptTab(id: string): Locator {
8687
return this.page.getByTestId(`scripts-tabs-tab-${id}`);
8788
}
8889

90+
folderSettingsTab(id: string): Locator {
91+
return this.page.getByTestId(`folder-settings-tabs-tab-${id}`);
92+
}
93+
8994
async selectScriptTab(id: string): Promise<void> {
9095
await this.scriptTab(id).click();
9196
}
9297

9398
async openTreeItem(names: string[]): Promise<void> {
9499
for (const name of names) {
95-
await this.sidebarItem(name).click();
100+
await this.openSidebarItem(name);
96101
}
97102
}
98103

@@ -105,14 +110,19 @@ export class PlaygroundComponent extends BaseComponent {
105110
}
106111

107112
async openRequest(name: string): Promise<void> {
108-
await this.sidebarItem(name).click();
113+
await this.openSidebarItem(name);
109114
await this.view.waitFor({ state: 'visible' });
110115
}
111116

117+
async ensureSidebarOpen(): Promise<void> {
118+
await this.runner.waitFor({ state: 'visible' });
119+
if (await this.sidebarPanel.isVisible()) return;
120+
await this.sidebarToggle.click();
121+
await this.sidebarPanel.waitFor({ state: 'visible' });
122+
}
123+
112124
async openEnvironments(): Promise<void> {
113-
if (!(await this.gear.isVisible())) {
114-
await this.sidebarToggle.click();
115-
}
125+
await this.ensureSidebarOpen();
116126
await this.gear.click();
117127
}
118128

‎packages/bruno-api-docs/e2e/components/playground/auth.component.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ export class RequestAuthComponent extends BaseComponent {
1313
return this.page.getByTestId(`auth-mode-select-${value}`);
1414
}
1515

16+
field(name: string): Locator {
17+
return this.page.getByTestId(`auth-${name}`);
18+
}
19+
1620
async open(): Promise<void> {
1721
await this.modeSelect.click();
1822
}

‎packages/bruno-api-docs/e2e/components/playground/response-pane.component.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ import type { ResponseBodyFormat } from '../../../src/utils/response';
1111
export class ResponsePaneComponent extends BaseComponent {
1212
readonly bodyEditor = new CodeEditorComponent(this.page, 'response-body-editor');
1313
readonly sendButton = this.page.getByTestId('query-bar-send');
14+
readonly status = this.page.getByTestId('response-status');
15+
readonly bodyPanel = this.page.getByTestId('response-tabs-panel-response');
16+
readonly errorBanner = this.bodyPanel.getByTestId('error-banner');
17+
readonly errorTitle = this.bodyPanel.getByTestId('error-title');
18+
readonly errorMessage = this.bodyPanel.getByTestId('error-message');
1419
readonly formatSelector = this.page.getByTestId('response-format-selector');
1520
// The leading icon in the selector trigger (the eye when preview is on, else the format's icon).
1621
readonly formatSelectorIcon = this.page.getByTestId('response-format-selector-trigger-icon');
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import crypto from 'node:crypto';
2+
import { test as base } from '@playwright/test';
3+
import type { Page, Route } from '@playwright/test';
4+
5+
export interface DigestMockOptions {
6+
username?: string;
7+
password?: string;
8+
realm?: string;
9+
qop?: 'auth' | null;
10+
exposeChallenge?: boolean;
11+
sendChallenge?: boolean;
12+
challengeOverride?: string;
13+
}
14+
15+
export interface RecordedRequest {
16+
method: string;
17+
authorization: string | null;
18+
postData: string | null;
19+
}
20+
21+
const md5 = (input: string) => crypto.createHash('md5').update(input).digest('hex');
22+
23+
const parseAuthHeader = (header: string): Record<string, string> => {
24+
const fields: Record<string, string> = {};
25+
header.replace(/^Digest\s+/i, '').split(',').forEach((pair) => {
26+
const idx = pair.indexOf('=');
27+
if (idx === -1) return;
28+
fields[pair.substring(0, idx).trim().toLowerCase()] = pair.substring(idx + 1).trim().replace(/"/g, '');
29+
});
30+
return fields;
31+
};
32+
33+
export class DigestMock {
34+
readonly requests: RecordedRequest[] = [];
35+
private readonly issuedNonces = new Set<string>();
36+
37+
private readonly username: string;
38+
private readonly password: string;
39+
private readonly realm: string;
40+
private readonly qop: 'auth' | null;
41+
private readonly exposeChallenge: boolean;
42+
private readonly sendChallenge: boolean;
43+
private readonly challengeOverride?: string;
44+
45+
constructor(options: DigestMockOptions = {}) {
46+
this.username = options.username ?? 'user';
47+
this.password = options.password ?? 'pass';
48+
this.realm = options.realm ?? 'digest-lab';
49+
this.qop = options.qop === undefined ? 'auth' : options.qop;
50+
this.exposeChallenge = options.exposeChallenge ?? true;
51+
this.sendChallenge = options.sendChallenge ?? true;
52+
this.challengeOverride = options.challengeOverride;
53+
}
54+
55+
async install(page: Page, pattern = '**://localhost:8081/**'): Promise<void> {
56+
await page.route(pattern, (route) => this.handle(route));
57+
}
58+
59+
private async handle(route: Route): Promise<void> {
60+
const request = route.request();
61+
62+
if (request.method() === 'OPTIONS') {
63+
return route.fulfill({
64+
status: 204,
65+
headers: {
66+
'Access-Control-Allow-Origin': '*',
67+
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
68+
'Access-Control-Allow-Headers': request.headers()['access-control-request-headers'] ?? 'authorization, content-type'
69+
}
70+
});
71+
}
72+
73+
const authorization = request.headers()['authorization'] ?? null;
74+
this.requests.push({ method: request.method(), authorization, postData: request.postData() });
75+
76+
if (authorization && this.verify(authorization, request.method(), new URL(request.url()))) {
77+
return route.fulfill({
78+
status: 200,
79+
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
80+
body: JSON.stringify({ authenticated: true })
81+
});
82+
}
83+
84+
return this.challenge(route);
85+
}
86+
87+
private challenge(route: Route): Promise<void> {
88+
const nonce = crypto.randomBytes(16).toString('hex');
89+
this.issuedNonces.add(nonce);
90+
const overrideNonce = this.challengeOverride && /nonce="([^"]+)"/.exec(this.challengeOverride);
91+
if (overrideNonce) this.issuedNonces.add(overrideNonce[1]);
92+
const digestChallenge
93+
= `Digest realm="${this.realm}", nonce="${nonce}", opaque="deadbeef", algorithm=MD5`
94+
+ (this.qop ? ', qop="auth"' : '');
95+
const headers: Record<string, string> = {
96+
'Access-Control-Allow-Origin': '*',
97+
'Content-Type': 'application/json'
98+
};
99+
if (this.sendChallenge) {
100+
headers['WWW-Authenticate'] = this.challengeOverride ?? digestChallenge;
101+
}
102+
if (this.exposeChallenge) {
103+
headers['Access-Control-Expose-Headers'] = 'WWW-Authenticate';
104+
}
105+
return route.fulfill({ status: 401, headers, body: JSON.stringify({ challenged: true }) });
106+
}
107+
108+
private verify(authorization: string, method: string, url: URL): boolean {
109+
const fields = parseAuthHeader(authorization);
110+
if (!fields.nonce || !this.issuedNonces.has(fields.nonce)) return false;
111+
const uri = url.pathname + url.search;
112+
const ha1 = md5(`${this.username}:${this.realm}:${this.password}`);
113+
const ha2 = md5(`${method}:${uri}`);
114+
const expected = fields.qop
115+
? md5(`${ha1}:${fields.nonce}:${fields.nc}:${fields.cnonce}:auth:${ha2}`)
116+
: md5(`${ha1}:${fields.nonce}:${ha2}`);
117+
return fields.response === expected && fields.username === this.username;
118+
}
119+
}
120+
121+
export const test = base.extend<{
122+
digestMock: (options?: DigestMockOptions) => Promise<DigestMock>;
123+
}>({
124+
digestMock: async ({ page }, use) => {
125+
await use(async (options?: DigestMockOptions) => {
126+
const mock = new DigestMock(options);
127+
await mock.install(page);
128+
return mock;
129+
});
130+
}
131+
});
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { mergeTests } from '@playwright/test';
22
import { test as pagesTest } from './pages.fixture';
3+
import { test as digestMockTest } from './digest-mock.fixture';
34

45
/**
56
* Entry point for the test harness — specs import everything (`test`, `expect`)
@@ -8,5 +9,5 @@ import { test as pagesTest } from './pages.fixture';
89
* `mergeTests` combines the fixtures from every `*.fixture.ts` file in this folder
910
* into one `test`.
1011
*/
11-
export const test = mergeTests(pagesTest);
12+
export const test = mergeTests(pagesTest, digestMockTest);
1213
export { expect } from '@playwright/test';

‎packages/bruno-api-docs/e2e/tests/collection-settings/collection-settings.spec.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,12 @@ test.describe('collection settings', () => {
5454
await expect(collectionSettings.authField('placement')).toBeVisible();
5555
});
5656

57-
test('does not offer Digest or AWS Signature v4 as selectable auth modes', async ({ collectionSettings }) => {
57+
test('offers Digest but not AWS Signature v4 as selectable auth modes', async ({ collectionSettings }) => {
5858
await collectionSettings.openTab('auth');
5959
await collectionSettings.authMode.click();
6060

6161
await expect(collectionSettings.authModeOption('basic')).toBeVisible();
62-
await expect(collectionSettings.authModeOption('digest')).toHaveCount(0);
62+
await expect(collectionSettings.authModeOption('digest')).toBeVisible();
6363
await expect(collectionSettings.authModeOption('awsv4')).toHaveCount(0);
6464
});
6565

0 commit comments

Comments
 (0)