Skip to content
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion lib/http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export interface IHttpClientOptions {
*/
timeout: number;
userAgent: string;
/**
* OAuth Bearer access token sent as the Authorization header.
*/
accessToken?: string;
followRedirects?: boolean;
}

Expand Down Expand Up @@ -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);
}
Expand Down
6 changes: 1 addition & 5 deletions lib/musicbrainz-api-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down
49 changes: 42 additions & 7 deletions lib/musicbrainz-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -138,6 +138,11 @@ export interface IMusicBrainzConfig {
appName?: string,
appVersion?: string,

/**
* OAuth Bearer access token for authenticated requests.
*/
accessToken?: string,

/**
* HTTP Proxy
*/
Expand Down Expand Up @@ -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,
};
}
Comment on lines +264 to +271
Comment on lines +264 to +271

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 86c51d5: the constructor now throws immediately when accessToken is set alongside a non-HTTPS baseUrl. Additionally, post() was updated to fast-fail when neither accessToken nor botAccount credentials are present, and when an accessToken is available it submits XML once with Authorization: Bearer and Content-Type: application/xml, bypassing the Digest challenge loop entirely.


protected initHttpClient(): HttpClient {
return new HttpClient(this.getHttpClientOptions());
}

public async restGet<T>(relUrl: string, query: MultiQueryFormData = {}): Promise<T> {
Expand Down Expand Up @@ -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();
Expand Down
208 changes: 208 additions & 0 deletions test/test-musicbrainz-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IArtist>(`/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<IArtist>(`/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}`);
});


Comment on lines +1396 to +1397
Comment on lines +1396 to +1397

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3e0868c, which adds tests covering: (1) a caller-supplied Authorization header is not overwritten by the configured Bearer token; (2) a whitespace-only Authorization value is treated as absent and replaced with Bearer; (3) constructing MusicBrainzApi with accessToken and a non-HTTPS baseUrl throws; (4) post() sends exactly one request with the Bearer token and Content-Type: application/xml; (5) post() throws when neither accessToken nor bot credentials are configured. A follow-up commit (f649ffc) also adds the equivalent ISRC-submission tests for the Node entrypoint.

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<IArtist>(`/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;
Expand Down Expand Up @@ -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 <token>`
*/
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, /<entity>/<mbid>/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;
Expand Down