Skip to content

Add TwoLeggedOAuth2 (OAuth2 client credentials) authentication - #6

Draft
Adrew-Kirts wants to merge 1 commit into
upstream-mainfrom
webmecanik-4.x-two-legged-oauth2
Draft

Add TwoLeggedOAuth2 (OAuth2 client credentials) authentication#6
Adrew-Kirts wants to merge 1 commit into
upstream-mainfrom
webmecanik-4.x-two-legged-oauth2

Conversation

@Adrew-Kirts

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

Copy link
Copy Markdown

Important

Do not delete this branch or the 4.0.0-patch tag. Portail consumes this code straight from this repository — composer.json pins mautic/api-library: "4.0.0-patch", which resolves to commit 0a2d5886 on webmecanik-4.x-two-legged-oauth2. Deleting the tag breaks every composer install in Portail immediately, including CI and image builds.

This PR is intended to be reviewed and then closed, not merged — merging would offer to delete the head branch, and the branch is where this code lives.

Supersedes #5, which GitHub auto-closed when its head branch was renamed to webmecanik-4.x-two-legged-oauth2. Same commit (0a2d5886), same one-file diff; CI was green there (CS & PHPSTAN pass, PHPUnit 315 tests pass).

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>
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