Skip to content

Add TwoLeggedOAuth2 (OAuth2 client credentials) authentication - #5

Closed
Adrew-Kirts wants to merge 1 commit into
upstream-mainfrom
feature/two-legged-oauth2-client-credentials
Closed

Add TwoLeggedOAuth2 (OAuth2 client credentials) authentication#5
Adrew-Kirts wants to merge 1 commit into
upstream-mainfrom
feature/two-legged-oauth2-client-credentials

Conversation

@Adrew-Kirts

@Adrew-Kirts Adrew-Kirts commented Aug 20, 2026

Copy link
Copy Markdown

Why

Mautic has supported the client_credentials grant (2-legged OAuth2) since 4.x, but this library only implements authorization_code and refresh_token — both of which require a user in a browser granting consent. Anything talking to Mautic server-to-server therefore cannot authenticate with the upstream library at all.

Upstream has never shipped it: PR mautic#257 (2021) was abandoned, PR mautic#269 (2022) is still open, and issue mautic#332 (Nov 2024) confirms it is still absent from 3.1.0 and 4.0.0-beta. So it stays a local addition.

Two internal consumers need it — Portail (MauticService) and automation-magento (Model/ApiClient.php) — both through ApiAuth::newAuth($settings, 'TwoLeggedOAuth2').

What

One file: lib/Auth/TwoLeggedOAuth2.php (185 lines), extending the official AbstractAuth.

Base is upstream main, not this fork's master. master is a Mautic-5 sync from January 2026 that also carries Emails::sendCustomToContact and a Contacts::create() query-argument override, neither of which any consumer here uses. Basing on upstream directly keeps the diff to exactly the one class we are missing.

I first cut this from the 4.0.0 tag, but that tag ships a broken functional test suite — 12 failures, every one of them fixed upstream after the tag was cut (AssetsTest ×4 → "Using an existing remote asset"; CategoriesTest → "Replacing with an existing remote file also for the category tests"; CampaignsTest ×2 → "Removing unnecessary campaign creation (duplicate)"; StagesTest ×6 → "Stage weight must be unique"). The 4.0.0..main range touches six files, all under tests/, and zero files under lib/ — so moving the base to main changes no runtime code whatsoever and simply gets a green suite.

The one thing to look at in review

This class deliberately does not override getQueryParameters().

master's version does, appending ?access_token=… whenever a file parameter is present. Combined with the Authorization: Bearer header that prepareRequest() already sets, the token goes out twice. Mautic's OAuth2::getBearerToken() collects tokens from headers, form-encoded body and query string, and throws when it finds more than one; the exception escapes OAuthListener and renders as a generic error-#500 payload.

Reproduced against a Mautic 5.4.1 instance, GET /api/users?limit=1:

tokens sent result
Authorization header only 200
?access_token= only 200
both 500 {"errors":[{"message":"Looks like I encountered an error (error #500)…"}]}

Because the failure needs a file parameter, it only ever showed up on multipart uploads — every other endpoint kept working, which made it look like an instance problem rather than a library one.

The inherited getQueryParameters() returns [] for a POST, so the header is the only token source. Upstream AbstractAuth already builds multipart bodies itself (MultipartStream), so nothing further is required.

requestAccessToken() also clears any held token before requesting a new one, so a stale bearer is never sent to the token endpoint.

Validation

Run against a live Mautic 5.4.1 instance with real API credentials, with this branch in place of master:

  • token request → getActiveUsers (16 users) → getAtmtAvailablesRoles (3 roles, admin role resolved) → checkApiConnection, all OK
  • connection reuse: a second openConnexion served from the in-process pool, 0.0000s
  • POST contacts/importCsv (multipart): 200, Mautic created the import entity — the exact call that returns 500 on master
  • consumer-side unit tests (4) pass against a mocked PSR-18 client, including "an expired stored token is renewed and the fresh token is used for the next call"

Notes for reviewers

  • setup()'s parameter names are load-bearing: ApiAuth::newAuth() resolves arguments by reflecting on them and passes null for anything it cannot match. Renaming one silently breaks callers instead of failing loudly.
  • master is untouched, so automation-magento is unaffected. It does still carry the double-token behaviour on any file upload it performs.

Mautic has supported the client_credentials grant since 4.x, but this library
only implements authorization_code and refresh_token, both of which require a
user in a browser. Server-to-server consumers cannot use either, so they have
been carrying a forked auth class instead. Upstream PR mautic#257 was abandoned and
PR mautic#269 is still open, so this stays a local addition for now.

Based on the official 4.0.0 tag rather than this fork's master, so the diff is
exactly the one class we are missing and nothing else.

Deliberately does NOT override getQueryParameters(). The inherited
implementation appends nothing to the query string of a POST, which is what we
want: the access token travels in the Authorization header only. Sending it in
both the header and the query string makes Mautic's OAuth2::getBearerToken()
find more than one token and reject the request, which surfaces as an opaque
HTTP 500 and only on calls that upload a file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Adrew-Kirts
Adrew-Kirts force-pushed the feature/two-legged-oauth2-client-credentials branch from 44e60e7 to 0a2d588 Compare August 20, 2026 09:16
@Adrew-Kirts
Adrew-Kirts changed the base branch from upstream-4.0.0 to upstream-main August 20, 2026 09:16
@Adrew-Kirts
Adrew-Kirts deleted the feature/two-legged-oauth2-client-credentials branch August 20, 2026 09:31
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.

1 participant