Skip to content

feat: add OAuth Bearer token support to HttpClient#1168

Open
elgeeko1 wants to merge 8 commits into
Borewit:masterfrom
elgeeko1:feat/oauth-bearer-token
Open

feat: add OAuth Bearer token support to HttpClient#1168
elgeeko1 wants to merge 8 commits into
Borewit:masterfrom
elgeeko1:feat/oauth-bearer-token

Conversation

@elgeeko1

Copy link
Copy Markdown

Add OAuth Bearer token support

Summary

This PR adds support for authenticating API requests using an OAuth Bearer access token, as defined by the MusicBrainz OAuth2 spec and RFC 6750.

Motivation

The MusicBrainz API requires OAuth2 Bearer token authentication for write operations (e.g. submitting ISRCs, barcodes, ratings, collections) and for accessing private user data. Previously, this library had no mechanism to pass a Bearer token, limiting applications that already hold an access token obtained through a standard OAuth2 authorization flow.

Changes

lib/musicbrainz-api.ts: adds optional accessToken field to IMusicBrainzConfig and threads it into HttpClient initialization.

lib/http-client.ts: adds optional accessToken field to IHttpClientOptions; injects Authorization: Bearer <token> header on every request when configured, but only if an Authorization header is not already present (allowing callers to override per-request).

README.md: documents the new accessToken config option with an example.

test/test-musicbrainz-api.ts: adds two unit tests covering: (1) token is sent as a Bearer header when configured, and (2) no Authorization header is sent when no token is configured.

Behavior

  • accessToken is optional and fully backward-compatible; existing configurations are unaffected.
  • The token is injected at the HttpClient layer so it applies to all requests uniformly.
  • The header is skipped if Authorization is already set, preserving any future per-request override capability.
  • Per the MusicBrainz spec, Bearer tokens only work over HTTPS; callers are responsible for ensuring a secure base URL.

Testing

Existing tests continue to pass. New tests stub globalThis.fetch with sinon to assert header injection behavior without making live network calls.

- Allows callers to authenticate with the MusicBrainz API using an OAuth
access token, avoiding the need to manage session cookies or digest
auth.
- Token is passed via config and injected as an Authorization.
- Bearer header on every request, only if not already set.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds first-class support for OAuth2 Bearer token authentication by introducing an optional accessToken configuration value and having the HTTP layer automatically inject Authorization: Bearer <token> when configured.

Changes:

  • Added accessToken?: string to IMusicBrainzConfig and passed it into HttpClient initialization.
  • Updated HttpClient to set the Authorization header with a Bearer token when configured (unless an Authorization header is already present).
  • Documented the new config option and added unit tests asserting header injection behavior.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
lib/musicbrainz-api.ts Adds accessToken to config and threads it into HttpClient construction.
lib/http-client.ts Injects Authorization: Bearer ... when accessToken is configured and no Authorization header is present.
README.md Documents accessToken usage in configuration example.
test/test-musicbrainz-api.ts Adds tests for Bearer header injection and absence when not configured.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/musicbrainz-api.ts Outdated
Comment on lines 256 to 262
@@ -252,7 +257,8 @@ export class MusicBrainzApi {
return new HttpClient({
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
});

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.

Good catch, will address this.

Comment thread lib/http-client.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment thread lib/musicbrainz-api.ts Outdated
Comment on lines 256 to 262
@@ -252,7 +257,8 @@ export class MusicBrainzApi {
return new HttpClient({
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 thread lib/http-client.ts Outdated
Comment thread test/test-musicbrainz-api.ts Outdated
Comment on lines +1366 to +1380
it('sends configured accessToken as Authorization Bearer header', 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}`);
});
@Borewit

Borewit commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Dear @elgeeko1,

First of all, sorry for not being more involved so far.

Thank you for your nice contribution. I think there may be a number of functions that have been ignored for quite some time and could also benefit from the OAuth functionality:

describe.skip('Node specific API', function () {
let mbTestApi: MusicBrainzApiNode;
let mbApi: MusicBrainzApiNode;
before(async () => {
mbTestApi = new MusicBrainzApiNode(await makeTestApiConfig());
mbApi = new MusicBrainzApiNode(await makeSearchApiConfig());
// Hack a shared rate-limiter
(mbApi as any).rateLimiter = (mbTestApi as any).rateLimiter;
});
this.timeout(40000); // MusicBrainz has a rate limiter
/**
* https://wiki.musicbrainz.org/Development/Release_Editor_Seeding
*/
describe('User (bot) post form-data API', () => {
it('login & logout', async () => {
for (let n = 1; n <= 2; ++n) {
assert.isTrue(await mbTestApi.login(), `Login ${n}`);
assert.isTrue(await mbTestApi.logout(), `Logout ${n}`);
}
});
describe('Recording', () => {
it('add link', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable);
assert.strictEqual(recording.id, mbid.recording.Formidable);
assert.strictEqual(recording.title, 'Formidable');
await mbTestApi.addUrlToRecording(recording, {
linkTypeId: LinkType.stream_for_free,
text: `https://open.spotify.com/track/${spotify.track.Formidable.id}`
});
});
it('add Spotify-ID', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable);
const editNote = `Unit-test musicbrainz-api (${appUrl}), test augment recording with Spotify URL & ISRC`;
await mbTestApi.addSpotifyIdToRecording(recording, spotify.track.Formidable.id, editNote);
});
it('add Spotify-ID to recording with ISRC', async () => {
// https://test.musicbrainz.org/recording/a75b85bf-63dd-4fe1-8008-d15541b93bac
const recording_id = 'a75b85bf-63dd-4fe1-8008-d15541b93bac';
const recording = await mbTestApi.lookup('recording', recording_id);
const editNote = `Unit-test musicbrainz-api (${appUrl}), test augment recording with Spotify URL & ISRC`;
await mbTestApi.addSpotifyIdToRecording(recording, '3ZDO5YINwfoifRQ3ElshPM', editNote);
});
});
describe('ISRC', () => {
it('add ISRC', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable, ['isrcs']);
assert.strictEqual(recording.id, mbid.recording.Formidable);
assert.strictEqual(recording.title, 'Formidable');
await mbTestApi.addIsrc(recording, 'BET671300161');
});
});
/**
* https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2#ISRC_submission
*/
describe('ISRC submission', () => {
it('add ISRC', async () => {
const xmlMedata = new XmlMetadata();
const xmlRec = xmlMedata.pushRecording('94fb868b-9233-4f9e-966b-e8036bf7461e');
xmlRec.isrcList.pushIsrc('GB5EM1801762');
await mbTestApi.post('recording', xmlMedata);
});
});
});

If you are willing to look into those as well, that would be very welcome.

Maybe as a follow up?

Thanks again!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.

Comment thread lib/musicbrainz-api.ts Outdated
import { RateLimitThreshold } from 'rate-limit-threshold';
import * as mb from './musicbrainz.types.js';
import { HttpClient, type MultiQueryFormData } from "./http-client.js";
import {HttpClient, IHttpClientOptions, type MultiQueryFormData} from "./http-client.js";
Comment thread lib/musicbrainz-api.ts
Comment on lines +256 to +263
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 +1396 to +1397


Comment thread lib/musicbrainz-api.ts Outdated
import { RateLimitThreshold } from 'rate-limit-threshold';
import * as mb from './musicbrainz.types.js';
import { HttpClient, type MultiQueryFormData } from "./http-client.js";
import {HttpClient, IHttpClientOptions, type MultiQueryFormData} from "./http-client.js";

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: IHttpClientOptions (and MultiQueryFormData) are now imported with type to match the repo's conventions and avoid any runtime import side effects.

Comment thread lib/musicbrainz-api.ts
Comment on lines +256 to +263
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,
};
}

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.

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.

@elgeeko1

elgeeko1 commented Jun 1, 2026

Copy link
Copy Markdown
Author

@Borewit I'm happy to look into auth for additional functions. Will follow-up this week.

elgeeko1 added 3 commits June 6, 2026 16:29
- Use type-only import for IHttpClientOptions and MultiQueryFormData
- Throw early when accessToken is configured with a non-HTTPS baseUrl
  (RFC 6750 §5.3: Bearer tokens require TLS)
- post(): require either accessToken or botAccount credentials before
  serializing XML or hitting the network (fail-fast)
- post(): when accessToken is configured, submit XML once with
  'Authorization: Bearer <token>' and Content-Type application/xml
  instead of running the digest-auth challenge loop
- Document submit_isrc / submit_barcode OAuth2 scopes inline
- Caller-provided Authorization header is not overwritten by the
  configured accessToken
- Whitespace-only Authorization header is replaced with the configured
  Bearer token
- Constructing MusicBrainzApi with accessToken and a non-HTTPS baseUrl
  throws
- XML web-service post() submits once with the Bearer token and
  Content-Type application/xml, skipping the Digest challenge
- post() throws when neither accessToken nor botAccount credentials are
  configured
@elgeeko1

elgeeko1 commented Jun 6, 2026

Copy link
Copy Markdown
Author

I think there may be a number of functions that have been ignored for quite some time and could also benefit from the OAuth functionality:

describe.skip('Node specific API', function () {
let mbTestApi: MusicBrainzApiNode;
let mbApi: MusicBrainzApiNode;
before(async () => {
mbTestApi = new MusicBrainzApiNode(await makeTestApiConfig());
mbApi = new MusicBrainzApiNode(await makeSearchApiConfig());
// Hack a shared rate-limiter
(mbApi as any).rateLimiter = (mbTestApi as any).rateLimiter;
});
this.timeout(40000); // MusicBrainz has a rate limiter
/**
* https://wiki.musicbrainz.org/Development/Release_Editor_Seeding
*/
describe('User (bot) post form-data API', () => {
it('login & logout', async () => {
for (let n = 1; n <= 2; ++n) {
assert.isTrue(await mbTestApi.login(), `Login ${n}`);
assert.isTrue(await mbTestApi.logout(), `Logout ${n}`);
}
});
describe('Recording', () => {
it('add link', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable);
assert.strictEqual(recording.id, mbid.recording.Formidable);
assert.strictEqual(recording.title, 'Formidable');
await mbTestApi.addUrlToRecording(recording, {
linkTypeId: LinkType.stream_for_free,
text: `https://open.spotify.com/track/${spotify.track.Formidable.id}`
});
});
it('add Spotify-ID', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable);
const editNote = `Unit-test musicbrainz-api (${appUrl}), test augment recording with Spotify URL & ISRC`;
await mbTestApi.addSpotifyIdToRecording(recording, spotify.track.Formidable.id, editNote);
});
it('add Spotify-ID to recording with ISRC', async () => {
// https://test.musicbrainz.org/recording/a75b85bf-63dd-4fe1-8008-d15541b93bac
const recording_id = 'a75b85bf-63dd-4fe1-8008-d15541b93bac';
const recording = await mbTestApi.lookup('recording', recording_id);
const editNote = `Unit-test musicbrainz-api (${appUrl}), test augment recording with Spotify URL & ISRC`;
await mbTestApi.addSpotifyIdToRecording(recording, '3ZDO5YINwfoifRQ3ElshPM', editNote);
});
});
describe('ISRC', () => {
it('add ISRC', async () => {
const recording = await mbTestApi.lookup('recording', mbid.recording.Formidable, ['isrcs']);
assert.strictEqual(recording.id, mbid.recording.Formidable);
assert.strictEqual(recording.title, 'Formidable');
await mbTestApi.addIsrc(recording, 'BET671300161');
});
});
/**
* https://musicbrainz.org/doc/Development/XML_Web_Service/Version_2#ISRC_submission
*/
describe('ISRC submission', () => {
it('add ISRC', async () => {
const xmlMedata = new XmlMetadata();
const xmlRec = xmlMedata.pushRecording('94fb868b-9233-4f9e-966b-e8036bf7461e');
xmlRec.isrcList.pushIsrc('GB5EM1801762');
await mbTestApi.post('recording', xmlMedata);
});
});
});

The previously-skipped Node specific API block mixes two unrelated authentication flows:

  • Website-form flows: login()/logout(),editEntity(),addUrlToRecording(),addSpotifyIdToRecording() and the form-based addIsrc() all hit MusicBrainz website endpoints (/login, /logout, /edit) that authenticate via CSRF tokens + session cookies + form-encoded username The MusicBrainz docs are explicit that "only ratings, tags, barcodes and ISRCs can be submitted via the API at all; for most data additions you should use the website instead", so these endpoints have no OAuth equivalent.
  • XML web service : post() for ISRC submission does accept Authorization: Bearer, and which is covered by this PR.

I just pushed f649ffc which adds a new top-level describe('Node specific API (OAuth)') suite that exercises the OAuth-eligible XML ISRC-submission path through MusicBrainzApiNode with a stubbed fetch.

A follow-up (beyond the scope of this PR) could be to add new OAuth-eligible methods for submitTags() / submitRatings() / addToCollection() / removeFromCollection(), which the library doesn't expose today.

@elgeeko1

elgeeko1 commented Jun 8, 2026

Copy link
Copy Markdown
Author

I see that CI tests are failing; all tests pass for me locally when setting MBUSER and MBPWD env variables. I'll look into this tonight.

@Borewit

Borewit commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Passed local on my end as well.

@elgeeko1

Copy link
Copy Markdown
Author

@Borewit friendly ping that this PR is ready for review; the CI test failure was a one-line fix.

@elgeeko1 elgeeko1 closed this Jul 19, 2026
@elgeeko1
elgeeko1 deleted the feat/oauth-bearer-token branch July 19, 2026 19:21
@elgeeko1
elgeeko1 restored the feat/oauth-bearer-token branch July 19, 2026 19:21
@elgeeko1 elgeeko1 reopened this Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants