diff --git a/README.md b/README.md index 595d6b6e..0ac1566e 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ const config = { appVersion: '0.1.0', appContactInfo: 'user@mail.org', + // Optional: OAuth Bearer access token for authenticated requests. + // Requires an HTTPS `baseUrl`. When set, `botAccount` is not needed. + // Submission scopes: `submit_isrc`, `submit_barcode`. + accessToken: 'eyJ...', + // Optional: Proxy settings (default: no proxy server) proxy: { host: 'localhost', diff --git a/lib/http-client.ts b/lib/http-client.ts index 401261eb..f549317e 100644 --- a/lib/http-client.ts +++ b/lib/http-client.ts @@ -17,6 +17,10 @@ export interface IHttpClientOptions { */ timeout: number; userAgent: string; + /** + * OAuth Bearer access token sent as the Authorization header. + */ + accessToken?: string; followRedirects?: boolean; } @@ -70,8 +74,13 @@ export class HttpClient { const url = this._buildUrl(path, options.query); const cookies = await this.getCookies(); - const headers: HeadersInit = new Headers(options.headers); + const headers = new Headers(options.headers); headers.set('User-Agent', this.httpOptions.userAgent); + + const existingAuth = headers.get('Authorization'); + if (this.httpOptions.accessToken && (!existingAuth || existingAuth.trim() === '')) { + headers.set('Authorization', `Bearer ${this.httpOptions.accessToken}`); + } if (cookies !== null) { headers.set('Cookie', cookies); } diff --git a/lib/musicbrainz-api-node.ts b/lib/musicbrainz-api-node.ts index 2dd240d5..dc952303 100644 --- a/lib/musicbrainz-api-node.ts +++ b/lib/musicbrainz-api-node.ts @@ -17,11 +17,7 @@ export * from './http-client.js'; export class MusicBrainzApi extends MusicBrainzApiDefault { protected initHttpClient(): HttpClientNode { - return new HttpClientNode({ - baseUrl: this.config.baseUrl, - timeout: 500, - userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )` - }); + return new HttpClientNode(this.getHttpClientOptions()); } public async login(): Promise { diff --git a/lib/musicbrainz-api.ts b/lib/musicbrainz-api.ts index d981f087..2fd613ce 100644 --- a/lib/musicbrainz-api.ts +++ b/lib/musicbrainz-api.ts @@ -5,7 +5,7 @@ import { DigestAuth } from './digest-auth.js'; import { RateLimitThreshold } from 'rate-limit-threshold'; import * as mb from './musicbrainz.types.js'; -import { HttpClient, type MultiQueryFormData } from "./http-client.js"; +import { HttpClient, type IHttpClientOptions, type MultiQueryFormData } from './http-client.js'; export { XmlMetadata } from './xml/xml-metadata.js'; export { XmlIsrc } from './xml/xml-isrc.js'; @@ -138,6 +138,11 @@ export interface IMusicBrainzConfig { appName?: string, appVersion?: string, + /** + * OAuth Bearer access token for authenticated requests. + */ + accessToken?: string, + /** * HTTP Proxy */ @@ -242,18 +247,31 @@ export class MusicBrainzApi { ..._config } + // RFC 6750 ยง5.3: Bearer tokens require TLS. Reject non-HTTPS baseUrl when + // an accessToken is configured to prevent token leakage over plain HTTP. + if (this.config.accessToken && !/^https:\/\//i.test(this.config.baseUrl)) { + throw new Error( + `MusicBrainzApi: 'accessToken' requires an HTTPS baseUrl (RFC 6750). Received baseUrl="${this.config.baseUrl}".` + ); + } + this.httpClient = this.initHttpClient(); const limits = this.config.rateLimit ?? [15,18]; this.rateLimiter = new RateLimitThreshold(limits[0], limits[1]); } - protected initHttpClient(): HttpClient { - return new HttpClient({ + protected getHttpClientOptions(): IHttpClientOptions { + return { baseUrl: this.config.baseUrl, timeout: 500, - userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )` - }); + userAgent: `${this.config.appName}/${this.config.appVersion} ( ${this.config.appContactInfo} )`, + accessToken: this.config.accessToken, + }; + } + + protected initHttpClient(): HttpClient { + return new HttpClient(this.getHttpClientOptions()); } public async restGet(relUrl: string, query: MultiQueryFormData = {}): Promise { @@ -426,14 +444,31 @@ export class MusicBrainzApi { throw new Error("XML-Post requires the appName & appVersion to be defined"); } + // Require credentials before serializing XML or making a network request. + if (!this.config.accessToken && !this.config.botAccount) { + throw new Error("XML-Post requires either 'accessToken' or 'botAccount' credentials to be configured"); + } + const clientId = `${this.config.appName.replace(/-/g, '.')}-${this.config.appVersion}`; const path = `/ws/2/${entity}/`; - // Get digest challenge + const postData = xmlMetadata.toXml(); + + // With an OAuth Bearer token, HttpClient sets the Authorization header. + // Submit the body once; no digest-challenge retry is needed. + if (this.config.accessToken) { + await this.applyRateLimiter(); + await this.httpClient.post(path, { + query: { client: clientId }, + headers: { 'Content-Type': 'application/xml' }, + body: postData + }); + return; + } + // Get digest challenge let digest = ''; let n = 1; - const postData = xmlMetadata.toXml(); do { await this.applyRateLimiter(); diff --git a/test/test-musicbrainz-api.ts b/test/test-musicbrainz-api.ts index 8a353a9b..8be0e3da 100644 --- a/test/test-musicbrainz-api.ts +++ b/test/test-musicbrainz-api.ts @@ -1356,6 +1356,166 @@ describe('MusicBrainz-api', function () { }); + + describe('OAuth Bearer token', () => { + + afterEach(() => { + sinon.restore(); + }); + + it('sends configured accessToken as Authorization Bearer header (default entrypoint)', async () => { + const accessToken = 'test-access-token'; + const mbApi = new MusicBrainzApi(await makeSearchApiConfig({ + accessToken, + disableRateLimiting: true + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}')); + + await mbApi.restGet(`/artist/${mbid.artist.Stromae}`); + + assert.isTrue(fetchStub.calledOnce); + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + const headers = requestInit.headers as Headers; + assert.strictEqual(headers.get('Authorization'), `Bearer ${accessToken}`); + }); + + + it('sends configured accessToken as Authorization Bearer header (node entrypoint)', async () => { + const accessToken = 'test-access-token'; + const mbApi = new MusicBrainzApiNode(await makeSearchApiConfig({ + accessToken, + disableRateLimiting: true + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}')); + await mbApi.restGet(`/artist/${mbid.artist.Stromae}`); + assert.isTrue(fetchStub.calledOnce); + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + const headers = requestInit.headers as Headers; + assert.strictEqual(headers.get('Authorization'), `Bearer ${accessToken}`); + }); + + + it('does not send Authorization header when accessToken is not configured', async () => { + const mbApi = new MusicBrainzApi(await makeSearchApiConfig({ + disableRateLimiting: true + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}')); + + await mbApi.restGet(`/artist/${mbid.artist.Stromae}`); + + assert.isTrue(fetchStub.calledOnce); + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + const headers = requestInit.headers as Headers; + assert.isFalse(headers.has('Authorization')); + }); + it('does not overwrite a caller-provided non-empty Authorization header', async () => { + const accessToken = 'configured-token'; + const mbApi = new MusicBrainzApi(await makeSearchApiConfig({ + accessToken, + disableRateLimiting: true + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}')); + + // Exercise override behavior via the underlying httpClient so we can + // pass a custom Authorization header directly. + const httpClient = (mbApi as unknown as { httpClient: HttpClient }).httpClient; + await httpClient.get('/ws/2/artist/' + mbid.artist.Stromae, { + headers: { Authorization: 'Bearer caller-supplied-token' } + }); + + assert.isTrue(fetchStub.calledOnce); + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + const headers = requestInit.headers as Headers; + assert.strictEqual( + headers.get('Authorization'), + 'Bearer caller-supplied-token', + 'caller-provided Authorization header must not be overwritten by the configured accessToken' + ); + }); + + it('overrides an empty Authorization header with the configured Bearer token', async () => { + const accessToken = 'configured-token'; + const mbApi = new MusicBrainzApi(await makeSearchApiConfig({ + accessToken, + disableRateLimiting: true + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('{}')); + + const httpClient = (mbApi as unknown as { httpClient: HttpClient }).httpClient; + await httpClient.get('/ws/2/artist/' + mbid.artist.Stromae, { + headers: { Authorization: ' ' } // whitespace-only counts as empty + }); + + assert.isTrue(fetchStub.calledOnce); + const requestInit = fetchStub.firstCall.args[1] as RequestInit; + const headers = requestInit.headers as Headers; + assert.strictEqual(headers.get('Authorization'), `Bearer ${accessToken}`); + }); + + it('throws if accessToken is configured but baseUrl is not HTTPS', async () => { + const base = await makeSearchApiConfig({ + accessToken: 'test-access-token', + baseUrl: 'http://musicbrainz.org', // intentionally non-HTTPS + disableRateLimiting: true + }); + assert.throws( + () => new MusicBrainzApi(base), + /HTTPS baseUrl/ + ); + }); + + it('uses Bearer token for XML web-service post() and skips Digest challenge', async () => { + const accessToken = 'submit-isrc-token'; + const mbApi = new MusicBrainzApi(await makeTestApiConfig({ + accessToken, + disableRateLimiting: true, + // No botAccount required when accessToken is configured + botAccount: undefined + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response('', { status: 200 }) + ); + + const xmlMetadata = new XmlMetadata(); + const xmlRecording = xmlMetadata.pushRecording(mbid.recording.Formidable); + xmlRecording.isrcList.pushIsrc('BET671300161'); + + await mbApi.post('recording', xmlMetadata); + + assert.isTrue(fetchStub.calledOnce, 'fetch should be called exactly once (no Digest retry)'); + const [, requestInit] = fetchStub.firstCall.args; + const headers = (requestInit as RequestInit).headers as Headers; + assert.strictEqual(headers.get('Authorization'), `Bearer ${accessToken}`); + assert.strictEqual(headers.get('Content-Type'), 'application/xml'); + }); + + it('post() throws when neither accessToken nor botAccount credentials are configured', async () => { + const mbApi = new MusicBrainzApi(await makeTestApiConfig({ + disableRateLimiting: true, + botAccount: undefined + })); + + const xmlMetadata = new XmlMetadata(); + xmlMetadata.pushRecording(mbid.recording.Formidable); + + // Stub fetch so an accidental network call would surface as a test failure + const fetchStub = sinon.stub(globalThis, 'fetch').resolves(new Response('', { status: 200 })); + + let thrown: Error | undefined; + try { + await mbApi.post('recording', xmlMetadata); + } catch (err) { + thrown = err as Error; + } + assert.isDefined(thrown, 'post() should have thrown'); + assert.match( + (thrown as Error).message, + /accessToken.*botAccount|botAccount.*accessToken/i + ); + assert.isFalse(fetchStub.called, 'no HTTP request should be made without credentials'); + }); + }); + describe("Rate limiting", () => { let mbTestApiNoLimit: MusicBrainzApi; let mbTestApiLimit: MusicBrainzApi; @@ -1546,6 +1706,54 @@ describe('Cover Art Archive API', function () { }); +/** + * OAuth-authenticated submissions via the XML web service through the Node + * entrypoint. + * + * The endpoints under `/ws/2/...` accept `Authorization: Bearer ` + */ +describe('Node specific API (OAuth)', () => { + + afterEach(() => { + sinon.restore(); + }); + + describe('ISRC submission via XML web service', () => { + + it('uses Bearer token for post() (no botAccount required)', async () => { + const accessToken = 'submit-isrc-token'; + const mbTestApi = new MusicBrainzApiNode(await makeTestApiConfig({ + accessToken, + disableRateLimiting: true, + botAccount: undefined + })); + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response('', { status: 200 }) + ); + + const xmlMetadata = new XmlMetadata(); + const xmlRecording = xmlMetadata.pushRecording('94fb868b-9233-4f9e-966b-e8036bf7461e'); + xmlRecording.isrcList.pushIsrc('GB5EM1801762'); + + await mbTestApi.post('recording', xmlMetadata); + + assert.isTrue(fetchStub.calledOnce, 'fetch should be called exactly once (no Digest retry)'); + const [, requestInit] = fetchStub.firstCall.args; + const headers = (requestInit as RequestInit).headers as Headers; + assert.strictEqual(headers.get('Authorization'), `Bearer ${accessToken}`); + assert.strictEqual(headers.get('Content-Type'), 'application/xml'); + }); + + }); + +}); + +/** + * Skipped: this suite drives the MusicBrainz website form flow + * (/login, /logout, ///edit), which authenticates with + * CSRF tokens, session cookies, and form-encoded username/password. + * These endpoints do not accept OAuth Bearer tokens. + */ describe.skip('Node specific API', function () { let mbTestApi: MusicBrainzApiNode;