Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Built on PSR-18 / PSR-17 interfaces
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [OAuth 2.0 Flow](#oauth-20-flow)
- [Features](#features)
- [Typed Service API](#typed-service-api)
- [Groups](#groups)
Expand Down Expand Up @@ -85,7 +86,117 @@ foreach ($groups as $group) {

That's it. The SDK handles authentication, JSON encoding/decoding, and error mapping automatically.

> Get your API token in the CleverReach backend under **Account → Extras → REST API**.
> Get your API Token via the "Test process now" button in the CleverReach backend under **My Account → Interfaces → REST API** OR use the built-in OAuth functions.

---

## OAuth 2.0 Flow

If you are building an app for multiple CleverReach customers, you need to use OAuth 2.0 instead of a rigid, static API token. The SDK provides an `OAuthHelper` to manage the complete authorization flow including strict CSRF validation (state checking) and automatic token-refresh routines.

### 1. Generating Login URL

```php
use CleverReach\SDK\Auth\OAuthHelper;

session_start();

$oauthHelper = new OAuthHelper('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET', 'https://your-domain.com/callback');

// Generate secure state against CSRF
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;

$url = $oauthHelper->getAuthorizationUrl($state, ['receivers:read', 'groups:read']);

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

### 2. Handling the Callback

When CleverReach redirects the user back to your `redirect_uri` (e.g., `callback`), exchange the `code` for fully managed tokens. The SDK saves them per default on disk (`FileTokenStorage`), but you can inject a custom `TokenStorageInterface` to use Redis or Eloquent.

> **Pro Tip:** The SDK does NOT map tokens to users automatically (to remain agnostic). In a multi-user environment, implement the `TokenStorageInterface` to link the stored `Tokens` to the currently logged-in user in your database.

```php
use CleverReach\SDK\Auth\Exceptions\CleverReachAuthException;
use CleverReach\SDK\Auth\OAuthHelper;

session_start();

// Make sure to construct the helper with exactly your credentials
$oauthHelper = new OAuthHelper('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET', 'https://your-domain.com/callback');

try {
$expectedState = $_SESSION['oauth_state'] ?? '';
$receivedState = $_GET['state'] ?? '';
$code = $_GET['code'] ?? '';

// Exchanges the code and validates the returned state against the session value
$tokens = $oauthHelper->exchangeCodeForToken($code, $receivedState, $expectedState);

echo "Login success! Tokens cached.";
} catch (CleverReachAuthException $e) {
die("Authorization failed: " . $e->getMessage());
}
```

### Custom Token Storage (Database/Redis)

For multi-tenant applications, you should write your own storage adapter by implementing the `TokenStorageInterface`. This allows you to persistently store and retrieve the tokens based on your system's user ID.

```php
use CleverReach\SDK\Auth\Storage\TokenStorageInterface;
use CleverReach\SDK\Auth\Tokens;

class MyDatabaseTokenStorage implements TokenStorageInterface {
public function __construct(private int $userId) {}

public function get(): ?Tokens {
// SELECT * FROM oauth_tokens WHERE user_id = $this->userId
// if found, return Tokens::fromArray($dbData);
// else return null;
}

public function set(Tokens $tokens): void {
// UPDATE/INSERT INTO oauth_tokens WHERE user_id = $this->userId
// You can also access Scopes: $tokens->hasScope('receivers:read')
}

public function delete(): void {
// DELETE FROM oauth_tokens WHERE user_id = $this->userId
}
}

// Pass it to the Helper during setup
$storage = new MyDatabaseTokenStorage($_SESSION['user_id']);
$oauthHelper = new OAuthHelper('CLIENT_ID', 'SECRET', 'CALLBACK', $storage);
```

### 3. API Requests with OAuth

Hook the `OAuthHelper` into your client. It acts as a `TokenProvider` and will autonomously fetch or refresh access tokens prior to any endpoint requests.

```php
use CleverReach\SDK\CleverReachClient;

$client = new CleverReachClient(); // Leave token empty
$client->setTokenProvider($oauthHelper);

// SDK handles adding Bearer token.
// If the token is expired, SDK will automatically refresh & retry the API call.
$groups = $client->groups()->all();
```

### Revoking Tokens (Logout)

If a user uninstalls your app or logs out, you should actively revoke the token to invalidate it on the CleverReach servers:

```php
// Deletes the token locally and on the server
$oauthHelper->revokeToken($tokens->getAccessToken());
```

---

Expand Down
11 changes: 11 additions & 0 deletions src/Auth/Exceptions/CleverReachAuthException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace CleverReach\SDK\Auth\Exceptions;

use CleverReach\SDK\Exception\CleverReachException;

class CleverReachAuthException extends CleverReachException
{
}
228 changes: 228 additions & 0 deletions src/Auth/OAuthHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
<?php

declare(strict_types=1);

namespace CleverReach\SDK\Auth;

use CleverReach\SDK\Auth\Exceptions\CleverReachAuthException;
use CleverReach\SDK\Auth\Storage\MemoryTokenStorage;
use CleverReach\SDK\Auth\Storage\TokenStorageInterface;
use Http\Discovery\Psr17FactoryDiscovery;
use Http\Discovery\Psr18ClientDiscovery;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;

final class OAuthHelper implements TokenProviderInterface
{
private readonly TokenStorageInterface $storage;
private readonly string $authBaseUrl;
private readonly ClientInterface $httpClient;
private readonly RequestFactoryInterface $requestFactory;
private readonly StreamFactoryInterface $streamFactory;

public function __construct(
private readonly string $clientId,
private readonly string $clientSecret,
private readonly string $redirectUri,
?TokenStorageInterface $storage = null,
string $authBaseUrl = 'https://rest.cleverreach.com/oauth',
?ClientInterface $httpClient = null,
?RequestFactoryInterface $requestFactory = null,
?StreamFactoryInterface $streamFactory = null
) {
$this->storage = $storage ?? new MemoryTokenStorage();
$this->authBaseUrl = rtrim($authBaseUrl, '/');

$this->httpClient = $httpClient ?? Psr18ClientDiscovery::find();
$this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory();
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
}

/**
* @param array<int, string> $scopes
*/
public function getAuthorizationUrl(?string $state = null, array $scopes = []): string {
if ($state === null || trim($state) === '') {
throw new \InvalidArgumentException('State must not be null or empty.');
}

$params = [
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'response_type' => 'code',
'grant' => 'basic',
'state' => $state,
];

if ($scopes !== []) {
$params['scope'] = implode(' ', $scopes);
}

return $this->authBaseUrl.'/authorize.php?'.http_build_query($params);
}

/**
* Exchanges the authorization code received from the callback for an access token.
* The resulting token is automatically stored in the configured token storage.
*/
public function exchangeCodeForToken(string $code, string $receivedState, string $expectedState): Tokens {
$expectedState = trim($expectedState);
$receivedState = trim($receivedState);

if ($expectedState === '' || $receivedState === '') {
throw new CleverReachAuthException('Invalid state: state must not be empty.');
}

if (!hash_equals($expectedState, $receivedState)) {
throw new CleverReachAuthException('Invalid state: state mismatch.');
}

return $this->doTokenRequest([
'grant_type' => 'authorization_code',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'redirect_uri' => $this->redirectUri,
'code' => $code,
]);
}

/**
* Refreshes the access token using the stored refresh token, or a specific provided token.
* The new token is automatically stored in the configured token storage.
*
* @throws CleverReachAuthException If no refresh token is available
*/
public function refreshAccessToken(?string $refreshToken = null): Tokens {
$tokenToUse = $refreshToken ?? $this->storage->get()?->getRefreshToken();

if ($tokenToUse === null) {
throw new CleverReachAuthException('No refresh token available.');
}

return $this->doTokenRequest([
'grant_type' => 'refresh_token',
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'refresh_token' => $tokenToUse,
]);
}

/**
* Internal generic method to fulfill TokenProviderInterface.
* Always ensures returning a valid access token (refreshes if necessary).
*
* @param bool $forceRefresh If true, skips the expiry check and forces a refresh via API
*/
public function getAccessToken(bool $forceRefresh = false): string {
$tokens = $this->storage->get();

if ($tokens === null) {
throw new CleverReachAuthException('No access token available. Authorization required.');
}

if ($forceRefresh || $tokens->isExpired()) {
$tokens = $this->refreshAccessToken($tokens->getRefreshToken());
}

return $tokens->getAccessToken();
}

public function clearStoredTokens(): void {
$this->storage->delete();
}

/**
* Revokes a specific token at the CleverReach backend.
* Use this when logging out users.
*/
public function revokeToken(string $token): void {
$request = $this->requestFactory
->createRequest('DELETE', $this->authBaseUrl.'/token')
->withHeader('Authorization', 'Bearer '.$token)
;

try {
$this->httpClient->sendRequest($request);
$this->clearStoredTokens();
} catch (ClientExceptionInterface $e) {
throw new CleverReachAuthException('HTTP communication during token revocation failed.', null, null, $e);
}
}

/**
* Executes an OAuth token request directly using the embedded PSR-18 client,
* so that the logic remains strictly separated from `ApiRequestor`.
*
* @param array<string, mixed> $bodyParams
*/
private function doTokenRequest(array $bodyParams): Tokens {
$request = $this->requestFactory
->createRequest('POST', $this->authBaseUrl.'/token.php')
->withHeader('Accept', 'application/json')
->withHeader('Content-Type', 'application/x-www-form-urlencoded')
->withBody($this->streamFactory->createStream(http_build_query($bodyParams)))
;

try {
$response = $this->httpClient->sendRequest($request);
$statusCode = $response->getStatusCode();
$rawBody = (string) $response->getBody();

if ($statusCode >= 400 && $statusCode < 500 && ($bodyParams['grant_type'] ?? '') === 'refresh_token') {
// If a refresh request fails with a 4xx error (e.g. invalid grant),
// the refresh token is permanently broken. Clear it to avoid infinite loops.
$this->clearStoredTokens();
}

try {
$decoded = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
if ($statusCode < 200 || $statusCode >= 300) {
throw new CleverReachAuthException('OAuth request failed with HTTP '.$statusCode.' and non-JSON body.');
}

throw $e;
}

if ($statusCode < 200 || $statusCode >= 300) {
if (is_array($decoded)) {
$errorMsg = $decoded['error_description'] ?? $decoded['error'] ?? 'API request failed';
} else {
$errorMsg = 'OAuth request failed with HTTP '.$statusCode;
}

throw new CleverReachAuthException('OAuth request failed: '.$errorMsg);
}

if (!is_array($decoded) || !isset($decoded['access_token'])) {
throw new CleverReachAuthException('Invalid API response: missing access_token.');
}

$scopes = [];
if (isset($decoded['scope'])) {
if (is_array($decoded['scope'])) {
$scopes = $decoded['scope'];
} elseif (is_string($decoded['scope'])) {
$scopes = explode(' ', $decoded['scope']);
}
}

$tokens = new Tokens(
(string) $decoded['access_token'],
isset($decoded['refresh_token']) ? (string) $decoded['refresh_token'] : null,
isset($decoded['expires_in']) ? time() + (int) $decoded['expires_in'] : null,
$scopes
);

$this->storage->set($tokens);

return $tokens;
} catch (\JsonException $e) {
throw new CleverReachAuthException('Failed to decode CleverReach OAuth response.', null, null, $e);
} catch (ClientExceptionInterface $e) {
throw new CleverReachAuthException('HTTP communication during OAuth failed.', null, null, $e);
}
}
}
Loading