diff --git a/docs/src/api/params.md b/docs/src/api/params.md index 4434bd2db9aa2..84e7a7449e61b 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -571,6 +571,7 @@ Whether to emulate network being offline. Defaults to `false`. Learn more about - `username` <[string]> - `password` <[string]> - `origin` ?<[string]> Restrain sending http credentials on specific origin (scheme://host:port). + - `sendImmediately` ?<[boolean]> Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent from the browser. Credentials for [HTTP authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). If no origin is specified, the username and password are sent to any servers upon unauthorized responses. diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index f29428e2a8674..5e316402247a5 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -332,6 +332,7 @@ scheme.PlaywrightNewRequestParams = tObject({ username: tString, password: tString, origin: tOptional(tString), + sendImmediately: tOptional(tBoolean), })), proxy: tOptional(tObject({ server: tString, @@ -545,6 +546,7 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ username: tString, password: tString, origin: tOptional(tString), + sendImmediately: tOptional(tBoolean), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -623,6 +625,7 @@ scheme.BrowserNewContextParams = tObject({ username: tString, password: tString, origin: tOptional(tString), + sendImmediately: tOptional(tBoolean), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -684,6 +687,7 @@ scheme.BrowserNewContextForReuseParams = tObject({ username: tString, password: tString, origin: tOptional(tString), + sendImmediately: tOptional(tBoolean), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), @@ -2474,6 +2478,7 @@ scheme.AndroidDeviceLaunchBrowserParams = tObject({ username: tString, password: tString, origin: tOptional(tString), + sendImmediately: tOptional(tBoolean), })), deviceScaleFactor: tOptional(tNumber), isMobile: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/fetch.ts b/packages/playwright-core/src/server/fetch.ts index 425d514650371..fa38723645539 100644 --- a/packages/playwright-core/src/server/fetch.ts +++ b/packages/playwright-core/src/server/fetch.ts @@ -158,6 +158,10 @@ export abstract class APIRequestContext extends SdkObject { requestUrl.searchParams.set(name, value); } + const credentials = this._getHttpCredentials(requestUrl); + if (credentials?.sendImmediately) + setBasicAuthorizationHeader(headers, credentials); + const method = params.method?.toUpperCase() || 'GET'; const proxy = defaults.proxy; let agent; @@ -355,9 +359,7 @@ export abstract class APIRequestContext extends SdkObject { const auth = response.headers['www-authenticate']; const credentials = this._getHttpCredentials(url); if (auth?.trim().startsWith('Basic') && credentials) { - const { username, password } = credentials; - const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); - setHeader(options.headers, 'authorization', `Basic ${encoded}`); + setBasicAuthorizationHeader(options.headers, credentials); notifyRequestFinished(); fulfill(this._sendRequest(progress, url, options, postData)); request.destroy(); @@ -729,4 +731,10 @@ function shouldBypassProxy(url: URL, bypass?: string): boolean { }); const domain = '.' + url.hostname; return domains.some(d => domain.endsWith(d)); -} \ No newline at end of file +} + +function setBasicAuthorizationHeader(headers: { [name: string]: string }, credentials: HTTPCredentials) { + const { username, password } = credentials; + const encoded = Buffer.from(`${username || ''}:${password || ''}`).toString('base64'); + setHeader(headers, 'authorization', `Basic ${encoded}`); +} diff --git a/packages/playwright-core/src/server/firefox/ffBrowser.ts b/packages/playwright-core/src/server/firefox/ffBrowser.ts index 0519ff83079d2..ae26d0994a2db 100644 --- a/packages/playwright-core/src/server/firefox/ffBrowser.ts +++ b/packages/playwright-core/src/server/firefox/ffBrowser.ts @@ -344,7 +344,12 @@ export class FFBrowserContext extends BrowserContext { async doSetHTTPCredentials(httpCredentials?: types.Credentials): Promise { this._options.httpCredentials = httpCredentials; - await this._browser.session.send('Browser.setHTTPCredentials', { browserContextId: this._browserContextId, credentials: httpCredentials || null }); + let credentials = null; + if (httpCredentials) { + const { username, password, origin } = httpCredentials; + credentials = { username, password, origin }; + } + await this._browser.session.send('Browser.setHTTPCredentials', { browserContextId: this._browserContextId, credentials }); } async doAddInitScript(source: string) { diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index bcdc20751e56f..f1046d8825116 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -58,6 +58,7 @@ export type Credentials = { username: string; password: string; origin?: string; + sendImmediately?: boolean; }; export type Geolocation = { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 9a09ede72e710..fbba3e7f9a601 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13376,6 +13376,13 @@ export interface BrowserType { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; }; /** @@ -14892,6 +14899,13 @@ export interface AndroidDevice { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; }; /** @@ -15616,6 +15630,13 @@ export interface APIRequest { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; }; /** @@ -16760,6 +16781,13 @@ export interface Browser extends EventEmitter { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; }; /** @@ -17647,6 +17675,13 @@ export interface Electron { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; }; /** @@ -20307,6 +20342,13 @@ export interface HTTPCredentials { * Restrain sending http credentials on specific origin (scheme://host:port). */ origin?: string; + + /** + * Whether to send `Authorization` header with the first API request. By deafult, the credentials are sent only when + * 401 (Unauthorized) response with `WWW-Authenticate` header is received. This option does not affect requests sent + * from the browser. + */ + sendImmediately?: boolean; } export interface Geolocation { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index edc8e2cb7eb35..7e683b9161e93 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -574,6 +574,7 @@ export type PlaywrightNewRequestParams = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, proxy?: { server: string, @@ -597,6 +598,7 @@ export type PlaywrightNewRequestOptions = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, proxy?: { server: string, @@ -953,6 +955,7 @@ export type BrowserTypeLaunchPersistentContextParams = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1025,6 +1028,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1132,6 +1136,7 @@ export type BrowserNewContextParams = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1190,6 +1195,7 @@ export type BrowserNewContextOptions = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1251,6 +1257,7 @@ export type BrowserNewContextForReuseParams = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -1309,6 +1316,7 @@ export type BrowserNewContextForReuseOptions = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -4471,6 +4479,7 @@ export type AndroidDeviceLaunchBrowserParams = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, @@ -4527,6 +4536,7 @@ export type AndroidDeviceLaunchBrowserOptions = { username: string, password: string, origin?: string, + sendImmediately?: boolean, }, deviceScaleFactor?: number, isMobile?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 3f1d75f0d04fa..8ffe531754819 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -454,6 +454,7 @@ ContextOptions: username: string password: string origin: string? + sendImmediately: boolean? deviceScaleFactor: number? isMobile: boolean? hasTouch: boolean? @@ -671,6 +672,7 @@ Playwright: username: string password: string origin: string? + sendImmediately: boolean? proxy: type: object? properties: diff --git a/tests/library/browsercontext-fetch.spec.ts b/tests/library/browsercontext-fetch.spec.ts index 28716da835179..223a57b5d04a9 100644 --- a/tests/library/browsercontext-fetch.spec.ts +++ b/tests/library/browsercontext-fetch.spec.ts @@ -421,6 +421,30 @@ it('should return error with wrong credentials', async ({ context, server }) => expect(response2.status()).toBe(401); }); +it('should support HTTPCredentials.sendImmediately', async ({ contextFactory, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30534' }); + const context = await contextFactory({ + httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), sendImmediately: true } + }); + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + context.request.get(server.EMPTY_PAGE) + ]); + expect(serverRequest.headers.authorization).toBe('Basic ' + Buffer.from('user:pass').toString('base64')); + expect(response.status()).toBe(200); + } + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + context.request.get(server.CROSS_PROCESS_PREFIX + '/empty.html') + ]); + // Not sent to another origin. + expect(serverRequest.headers.authorization).toBe(undefined); + expect(response.status()).toBe(200); + } +}); + it('delete should support post data', async ({ context, server }) => { const [request, response] = await Promise.all([ server.waitForRequest('/simple.json'), diff --git a/tests/library/global-fetch.spec.ts b/tests/library/global-fetch.spec.ts index ce389644830e7..58dec04519b93 100644 --- a/tests/library/global-fetch.spec.ts +++ b/tests/library/global-fetch.spec.ts @@ -154,6 +154,30 @@ it('should support WWW-Authenticate: Basic', async ({ playwright, server }) => { expect(credentials).toBe('user:pass'); }); +it('should support HTTPCredentials.sendImmediately', async ({ playwright, server }) => { + it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/30534' }); + const request = await playwright.request.newContext({ + httpCredentials: { username: 'user', password: 'pass', origin: server.PREFIX.toUpperCase(), sendImmediately: true } + }); + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + request.get(server.EMPTY_PAGE) + ]); + expect(serverRequest.headers.authorization).toBe('Basic ' + Buffer.from('user:pass').toString('base64')); + expect(response.status()).toBe(200); + } + { + const [serverRequest, response] = await Promise.all([ + server.waitForRequest('/empty.html'), + request.get(server.CROSS_PROCESS_PREFIX + '/empty.html') + ]); + // Not sent to another origin. + expect(serverRequest.headers.authorization).toBe(undefined); + expect(response.status()).toBe(200); + } +}); + it('should support global ignoreHTTPSErrors option', async ({ playwright, httpsServer }) => { const request = await playwright.request.newContext({ ignoreHTTPSErrors: true }); const response = await request.get(httpsServer.EMPTY_PAGE);