Search by

solvebeam / oauth2-twinfield

remcotolsma

Twinfield OAuth 2.0 provider for the PHP League's OAuth 2.0 Client

Package info

github.com/solvebeam/twinfield-oauth2-php

pkg:composer/solvebeam/oauth2-twinfield

Fund package maintenance!

solvebeam

Statistics

Installs: 71

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v0.1.1 2026-11-12 10:46 UTC

This package is auto-updated.

Last update: 2026-11-12 10:48:24 UTC


README

This package provides Twinfield OAuth 2.0 support for the PHP League's OAuth 2.0 Client.

Twinfield uses OpenID Connect with the authorization code flow. You need a client ID and secret from Twinfield, see the Twinfield documentation.

For the Twinfield API itself, use the Twinfield PHP SDK, which builds on this package.

Installation

composer require solvebeam/oauth2-twinfield

Requires PHP 8.3 or higher.

Usage

Authorization code flow

<?php

use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use SolveBeam\Twinfield\OAuth2\Provider\Twinfield;

session_start();

$provider = new Twinfield([
    'clientId' => 'your-client-id',
    'clientSecret' => 'your-client-secret',
    'redirectUri' => 'https://example.com/callback',
]);

if (isset($_GET['error'])) {
    // The user cancelled, or Twinfield refused the request.
    exit('Login failed: ' . htmlspecialchars((string) $_GET['error']));
}

if (!isset($_GET['code'])) {
    // 1. Send the user to the Twinfield login.
    $nonce = bin2hex(random_bytes(16));
    $url = $provider->getAuthorizationUrl(['nonce' => $nonce]);

    $_SESSION['twinfield_state'] = $provider->getState();
    $_SESSION['twinfield_nonce'] = $nonce;

    header('Location: ' . $url);
    exit;
}

// 2. Twinfield redirects back with a code and the state.
$state = $_SESSION['twinfield_state'] ?? '';
$nonce = $_SESSION['twinfield_nonce'] ?? '';
unset($_SESSION['twinfield_state'], $_SESSION['twinfield_nonce']);

if ($state === '' || !hash_equals($state, (string) ($_GET['state'] ?? ''))) {
    exit('Invalid state.');
}

try {
    // 3. Exchange the code for tokens.
    $token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
} catch (IdentityProviderException $exception) {
    exit('Token request failed: ' . $exception->getMessage());
}

// The ID token must carry the nonce of this login.
$idToken = (string) ($token->getValues()['id_token'] ?? '');
$payload = explode('.', $idToken)[1] ?? '';
$claims = json_decode((string) base64_decode(strtr($payload, '-_', '+/')), true);

if ($nonce === '' || !hash_equals($nonce, (string) ($claims['nonce'] ?? ''))) {
    exit('Invalid nonce.');
}

// 4. Ask Twinfield who the token belongs to and where the API is.
$validation = $provider->getAccessTokenValidation($token);

echo $validation->organisationCode, PHP_EOL;     // EXAMPLE
echo $validation->organisationUserCode, PHP_EOL; // JDOE
echo $validation->clusterUrl, PHP_EOL;           // https://api.accounting.twinfield.com

// Store the refresh token to get new access tokens later.
$refreshToken = $token->getRefreshToken();

Refreshing a token

An access token is valid for about an hour. Use the refresh token to get a new one.

$token = $provider->getAccessToken('refresh_token', ['refresh_token' => $refreshToken]);

// Twinfield may return a new refresh token; keep the old one when it does not.
$refreshToken = $token->getRefreshToken() ?? $refreshToken;

Access token validation

getAccessTokenValidation() calls Twinfield's access token validation endpoint. It returns an AccessTokenValidation with:

Property Claim Description
exp exp Expiry time of the access token, as a Unix timestamp.
organisationId twf.organisationId GUID of the organisation.
organisationCode twf.organisationCode Code of the organisation.
organisationUserCode twf.organisationUserCode Code of the user in the organisation.
clusterUrl twf.clusterUrl Base URL of the cluster to send API calls to.
scopes scope The scopes of the token. hasScope() checks for one.

It throws an UnexpectedResponseException when a claim is missing. That happens when the scopes twf.organisationUser and twf.organisation were not requested at login.

Resource owner

$owner = $provider->getResourceOwner($token);

echo $owner->getId(), PHP_EOL;              // sub
echo $owner->organisationUserCode, PHP_EOL;
echo $owner->organisationId, PHP_EOL;

It throws an UnexpectedResponseException when one of these claims is missing, for the same reason as the access token validation.

Scopes

The Scope enum has all scopes of Twinfield.

Scope::identity() gives the scopes that identify the user and keep the session. Every Twinfield API needs them, so the provider requests them when you pass no scope:

Scope Case Description
openid Scope::OpenId Standard OpenID Connect scope.
twf.user Scope::User Twinfield ID and user code.
twf.organisation Scope::Organisation Organisation code, ID and cluster URL.
twf.organisationUser Scope::OrganisationUser User code in the organisation. Mandatory to log in.
offline_access Scope::OfflineAccess Needed to get a refresh token.

Scope::rest() gives the scopes of the Twinfield REST API, such as readVat and writeArticles. Each REST operation needs one of them on top of the identity scopes, and your client must be allowed to request it. A scope you pass replaces the default, so include the identity scopes:

use SolveBeam\Twinfield\OAuth2\Scope;

$url = $provider->getAuthorizationUrl([
    'scope' => Scope::values(Scope::ReadVat, ...Scope::identity()),
]);

Other providers of Twinfield tokens

AbstractTwinfieldProvider has everything that does not depend on where the user logs in: the access token validation, the resource owner, the scopes and the errors. Extend it for a service that issues Twinfield access tokens in another way. Twinfield Connect does this for logging in without a Twinfield client ID of your own.

Testing

composer test
composer phpcs
composer phpstan

License

The MIT License (MIT). See LICENSE.