oihana/php-m2m

Lightweight, OIDC-compliant Machine-to-Machine (M2M) HTTP client for APIs protected by JWT — implements the OAuth 2.0 jwt-bearer flow (RFC 7521 + RFC 7523).

Maintainers

Package info

github.com/BcommeBois/oihana-php-m2m

pkg:composer/oihana/php-m2m

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-05-13 12:01 UTC

This package is auto-updated.

Last update: 2026-05-13 12:04:37 UTC


README

Oihana PHP M2M

Lightweight, OIDC-compliant Machine-to-Machine (M2M) HTTP client for APIs protected by JWT.

Implements the OAuth 2.0 urn:ietf:params:oauth:client-assertion-type:jwt-bearer flow (RFC 7521 + RFC 7523) so a service account can sign a short-lived JWT assertion with its own RSA key, exchange it at the identity provider's token endpoint for an access token, and call a protected API with Authorization: Bearer ….

Designed for Zitadel out of the box, compatible with any RFC 7523-compliant identity provider (Auth0, Keycloak, …) via a configurable token endpoint path.

Latest Version Total Downloads License

✨ Features

  • Two-line setup from a self-sufficient keyfile JSON — no global config, no service container required.
  • Proactive token caching — one IdP exchange per ~59 minutes (configurable safety margin).
  • Reactive 401 retry — handles clock drift, premature revocation, and key rotation with one transparent re-exchange.
  • Strongly typed errorsKeyfileInvalidException distinguishes a dead keyfile from a transient network glitch.
  • IdP-agnostic — defaults to Zitadel's /oauth/v2/token, override $tokenPath for Auth0, Keycloak, …
  • Subclass-friendlycall(), doRequest(), decodeResponse() are protected extension hooks for instrumentation, correlation IDs, custom envelopes.
  • Zero hidden state — pass your own Guzzle client to inject middlewares (logging, retry-on-5xx, telemetry).

📦 Installation

Requires PHP 8.4+

Install via Composer:

composer require oihana/php-m2m

🚀 Quick Start

1. Obtain a keyfile

A keyfile is a JSON document handed to you once by the API administrator at service creation (or key rotation). It is never persisted server-side — store it securely on the calling service's host (file system, secret manager, …).

A keyfile is auto-sufficient: it carries both the cryptographic material (the RSA private key + key id) and the connection metadata (issuer URL, API base URL, OAuth2 scope) so a third-party developer can connect without any further configuration.

{
    "type":        "serviceaccount",
    "keyId":       "303384786189271040",
    "key":         "-----BEGIN RSA PRIVATE KEY-----\n\n-----END RSA PRIVATE KEY-----\n",
    "userId":      "303384547813785600",
    "issuer":      "https://my-org.zitadel.cloud",
    "audience":    "303380000000000000",
    "scope":       "openid profile urn:zitadel:iam:org:project:id:303380000000000000:aud",
    "apiBaseUrl":  "https://api.example.com"
}

See documentation/en/keyfile-format.md for the full field reference.

2. Call the API

use oihana\m2m\M2MApiClient;

$client = M2MApiClient::fromKeyfile( '/secrets/m2m-keyfile.json' ) ;

$widgets = $client->get   ( '/widgets' ) ;
$created = $client->post  ( '/widgets'      , [ 'name' => 'Foo' ] ) ;
$updated = $client->patch ( '/widgets/42'   , [ 'name' => 'Bar' ] ) ;
$client->delete( '/widgets/42' ) ;

That's it. Token acquisition, caching, refresh, and 401 retry are handled internally.

3. Override fields when needed

A typical use case is pointing a staging keyfile at a local API for end-to-end testing:

$client = M2MApiClient::fromKeyfile
(
    keyfilePath : '/secrets/staging-keyfile.json' ,
    apiBaseUrl  : 'http://localhost:8000'         // override apiBaseUrl
) ;

Same for $issuer, $scope, $tokenPath, and the underlying Guzzle $http client.

🌐 Identity provider compatibility

The token endpoint path defaults to /oauth/v2/token (Zitadel convention). Override it for other providers:

Identity provider $tokenPath
Zitadel (default) /oauth/v2/token
Auth0 /oauth/token
Keycloak /realms/{realm}/protocol/openid-connect/token
Generic RFC 7523 whatever your IdP exposes
$client = new M2MApiClient
(
    keyfile   : $keyfile ,
    tokenPath : '/oauth/token'  // Auth0
) ;

🔁 Token lifecycle

  • Proactive refresh — the access token is cached in memory with its expires_in and reused for every call until 60 seconds before its expiration (REFRESH_SAFETY_MARGIN). No exchange happens for ~59 minutes on a typical IdP default.
  • Reactive retry on 401 — if the resource API rejects the cached Bearer (clock drift, key rotation, IdP hiccup), the client invalidates its cache, performs one fresh exchange, and replays the request once. If the retry still fails with 401, a KeyfileInvalidException is thrown.
  • Other failures bubble up — only HTTP 401 triggers the refresh-and-retry. Network errors, 5xx, 403 (denied) bubble up as-is so they are never masked by a useless re-exchange.

See documentation/en/token-lifecycle.md for the full sequence diagram.

⚠️ Error handling

use oihana\m2m\M2MApiClient;
use oihana\m2m\exceptions\KeyfileInvalidException;
use GuzzleHttp\Exception\GuzzleException;

try
{
    $data = $client->get( '/widgets' ) ;
}
catch( KeyfileInvalidException $e )
{
    // Keyfile is no longer accepted by the IdP or the API.
    // Action : re-download a fresh keyfile from the admin UI.
}
catch( GuzzleException $e )
{
    // Network / non-401 failure (timeout, DNS, 5xx, …). Retry later.
}

See documentation/en/error-handling.md.

🧩 Extending the client

call(), doRequest(), and decodeResponse() are protected extension hooks. Subclass M2MApiClient to add per-request instrumentation, correlation IDs, tenant headers, or to support non-JSON envelopes — without rewriting the public verb methods.

class TracingM2MClient extends M2MApiClient
{
    protected function doRequest( string $method , string $path , ?array $body , string $token ) :\GuzzleHttp\Psr7\Response
    {
        $started = microtime( true ) ;
        $response = parent::doRequest( $method , $path , $body , $token ) ;
        $this->logger->info( "M2M $method $path" , [ 'ms' => ( microtime( true ) - $started ) * 1000 ] ) ;
        return $response ;
    }
}

See documentation/en/advanced/extending-the-client.md.

📚 Documentation

Full documentation is available in two languages :

Topics covered:

  • Getting started — install + first call in under 2 minutes.
  • Keyfile format — full field reference, security considerations.
  • Token lifecycle — caching, proactive refresh, reactive retry on 401.
  • Error handling — exception catalogue + recommended recovery actions.
  • Tips & best practices — typed constants from oihana/php-enums + oihana/php-files to avoid magic strings.
  • Advanced — HTTP client injection, subclassing for instrumentation, non-Zitadel IdPs.

🧪 Running the tests

composer install
composer test

The unit suite covers the constructor contract, the fromKeyfile factory, and the override semantics (including the configurable tokenPath). The full token-exchange path is integration-level and exercised end-to-end against a real identity provider.

🛠 Generating the API documentation

composer doc

Generates the phpDocumentor API reference into docs/.

🪪 License

This project is licensed under the Mozilla Public License 2.0 (MPL-2.0).

👤 Author

Marc Alcaraz (@BcommeBois) — Project Founder, Lead Developer

🔗 Related libraries