Initial Commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# BlackWall Auth SDK (PHP)
|
||||
|
||||
A distributable PHP SDK for integrating applications with BlackWall OAuth 2.1 and OpenID Connect.
|
||||
|
||||
This package provides:
|
||||
- OAuth authorisation URL generation with PKCE (`S256`)
|
||||
- Code-for-token exchange
|
||||
- Refresh token exchange
|
||||
- UserInfo retrieval
|
||||
- Session helpers for `state` and `code_verifier`
|
||||
- Typed API (`AuthClient`, `TokenSet`) and specific exception classes
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 8.1+
|
||||
- cURL extension (`ext-curl`)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install as a Composer package
|
||||
|
||||
If this repository is published and tagged:
|
||||
|
||||
```bash
|
||||
composer require dixon/blackwall-auth-sdk
|
||||
```
|
||||
|
||||
### 2. Install from local path (during development)
|
||||
|
||||
In the consuming application's `composer.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../test.dixon.cx"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"dixon/blackwall-auth-sdk": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
composer update dixon/blackwall-auth-sdk
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
|
||||
$config = Config::fromArray([
|
||||
'clientId' => 'your-client-id',
|
||||
'authorizeUrl' => 'https://blackwall.dixon.cx/oauth/authorize',
|
||||
'tokenUrl' => 'https://blackwall.dixon.cx/oauth/token',
|
||||
'userInfoUrl' => 'https://blackwall.dixon.cx/oauth/userinfo',
|
||||
'redirectUri' => 'https://your-app.example/callback.php',
|
||||
'scope' => 'openid profile email offline_access',
|
||||
]);
|
||||
|
||||
$client = new AuthClient($config);
|
||||
|
||||
// Step 1: redirect user to provider
|
||||
$auth = $client->buildAuthorisationUrl();
|
||||
header('Location: ' . $auth['url']);
|
||||
exit;
|
||||
```
|
||||
|
||||
For callback handling, see `docs/INTEGRATION_GUIDE.md`.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
A wrapper class is retained for older integrations:
|
||||
|
||||
```php
|
||||
use BlackWallSDK\BlackWallAuth;
|
||||
```
|
||||
|
||||
This wrapper now delegates to `BlackWall\Auth\AuthClient`.
|
||||
|
||||
## Public API
|
||||
|
||||
- `BlackWall\Auth\Config`
|
||||
- `BlackWall\Auth\AuthClient`
|
||||
- `BlackWall\Auth\TokenSet`
|
||||
- `BlackWall\Auth\AuthResult`
|
||||
- `BlackWall\Auth\Http\HttpClientInterface`
|
||||
- `BlackWall\Auth\Http\CurlHttpClient`
|
||||
- `BlackWall\Auth\Exception\*`
|
||||
|
||||
## Security notes
|
||||
|
||||
- Always validate OAuth `state` in callback handlers.
|
||||
- Always use HTTPS in production.
|
||||
- Store refresh tokens securely.
|
||||
- Keep access tokens out of logs and browser-visible output.
|
||||
- Use short session lifetimes where possible.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `src/Auth/` - main SDK classes
|
||||
- `src/Auth/Http/` - HTTP transport abstraction
|
||||
- `src/Auth/Exception/` - typed exceptions
|
||||
- `src/BlackWallAuth.php` - legacy compatibility wrapper
|
||||
- `src/legacy_autoload.php` - fallback non-Composer loader
|
||||
- `public_html/` - demonstration application
|
||||
- `docs/` - integration documentation
|
||||
|
||||
## Licence
|
||||
|
||||
Proprietary (internal distribution by default).
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "dixon/blackwall-auth-sdk",
|
||||
"description": "PHP SDK for integrating with BlackWall OAuth 2.1 and OpenID Connect",
|
||||
"type": "library",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-curl": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BlackWall\\Auth\\": "src/Auth/",
|
||||
"BlackWallSDK\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"BlackWall\\Auth\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit --colors=always"
|
||||
}
|
||||
}
|
||||
Generated
+1691
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
# Integration Guide (British English)
|
||||
|
||||
This guide shows a complete OAuth/OIDC login flow using `BlackWall\Auth\AuthClient`.
|
||||
|
||||
## 1. Start login
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
|
||||
$client = new AuthClient(Config::fromArray([
|
||||
'clientId' => 'your-client-id',
|
||||
'authorizeUrl' => 'https://blackwall.dixon.cx/oauth/authorize',
|
||||
'tokenUrl' => 'https://blackwall.dixon.cx/oauth/token',
|
||||
'userInfoUrl' => 'https://blackwall.dixon.cx/oauth/userinfo',
|
||||
'redirectUri' => 'https://your-app.example/callback.php',
|
||||
'scope' => 'openid profile email offline_access',
|
||||
]));
|
||||
|
||||
if (!isset($_SESSION['user'])) {
|
||||
$auth = $client->buildAuthorisationUrl();
|
||||
header('Location: ' . $auth['url']);
|
||||
exit;
|
||||
}
|
||||
|
||||
echo 'Already signed in.';
|
||||
```
|
||||
|
||||
## 2. Handle callback
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
require __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
|
||||
$client = new AuthClient(Config::fromArray([
|
||||
'clientId' => 'your-client-id',
|
||||
'authorizeUrl' => 'https://blackwall.dixon.cx/oauth/authorize',
|
||||
'tokenUrl' => 'https://blackwall.dixon.cx/oauth/token',
|
||||
'userInfoUrl' => 'https://blackwall.dixon.cx/oauth/userinfo',
|
||||
'redirectUri' => 'https://your-app.example/callback.php',
|
||||
]));
|
||||
|
||||
if (!isset($_GET['code'], $_GET['state'])) {
|
||||
http_response_code(400);
|
||||
echo 'Missing code/state';
|
||||
exit;
|
||||
}
|
||||
|
||||
$client->assertStateMatches((string) $_GET['state']);
|
||||
$tokens = $client->exchangeCodeForTokens((string) $_GET['code']);
|
||||
$user = $client->getUserInfo($tokens->accessToken);
|
||||
|
||||
$_SESSION['user'] = $user;
|
||||
$_SESSION['access_token'] = $tokens->accessToken;
|
||||
$_SESSION['refresh_token'] = $tokens->refreshToken;
|
||||
|
||||
$client->clearPkceSessionState();
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
```
|
||||
|
||||
## 3. Refresh token (optional)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
$tokens = $client->refreshAccessToken($_SESSION['refresh_token']);
|
||||
$_SESSION['access_token'] = $tokens->accessToken;
|
||||
if ($tokens->refreshToken !== null) {
|
||||
$_SESSION['refresh_token'] = $tokens->refreshToken;
|
||||
}
|
||||
```
|
||||
|
||||
## Exceptions
|
||||
|
||||
Catch specific exceptions for clearer handling:
|
||||
|
||||
- `BlackWall\Auth\Exception\StateMismatchException`
|
||||
- `BlackWall\Auth\Exception\TokenExchangeException`
|
||||
- `BlackWall\Auth\Exception\UserInfoException`
|
||||
- `BlackWall\Auth\Exception\TransportException`
|
||||
|
||||
Example:
|
||||
|
||||
```php
|
||||
try {
|
||||
$tokens = $client->exchangeCodeForTokens($code);
|
||||
} catch (\BlackWall\Auth\Exception\TokenExchangeException $e) {
|
||||
error_log('Token exchange failed: ' . $e->getMessage());
|
||||
http_response_code(502);
|
||||
}
|
||||
```
|
||||
|
||||
## Operational guidance
|
||||
|
||||
- Keep provider URLs in environment variables, not hard-coded.
|
||||
- Avoid printing tokens in production pages.
|
||||
- Rotate client secrets for confidential clients.
|
||||
- Validate `state` on every callback request.
|
||||
- Use secure session cookies (`Secure`, `HttpOnly`, `SameSite=Lax` or stricter).
|
||||
@@ -0,0 +1,35 @@
|
||||
# Migration Guide
|
||||
|
||||
## Moving from `BlackWallSDK\BlackWallAuth` to `BlackWall\Auth\AuthClient`
|
||||
|
||||
### Before
|
||||
|
||||
```php
|
||||
$auth = new BlackWallSDK\BlackWallAuth($config);
|
||||
$authData = $auth->getAuthorizationUrl();
|
||||
$tokens = $auth->exchangeCodeForTokens($code);
|
||||
$user = $auth->getUserInfo($tokens['access_token']);
|
||||
```
|
||||
|
||||
### After
|
||||
|
||||
```php
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
|
||||
$client = new AuthClient(Config::fromArray($config));
|
||||
$authData = $client->buildAuthorisationUrl();
|
||||
$tokens = $client->exchangeCodeForTokens($code);
|
||||
$user = $client->getUserInfo($tokens->accessToken);
|
||||
```
|
||||
|
||||
## Key differences
|
||||
|
||||
- Method spelling uses British English in the new API: `buildAuthorisationUrl()`.
|
||||
- Token responses are strongly typed via `TokenSet`.
|
||||
- Errors are represented by specific exception classes.
|
||||
- HTTP transport is swappable via `HttpClientInterface`.
|
||||
|
||||
## Compatibility status
|
||||
|
||||
`BlackWallSDK\BlackWallAuth` remains available and delegates to the new client. Existing applications can upgrade gradually.
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="src/legacy_autoload.php"
|
||||
colors="true"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="BlackWall Auth SDK Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
$autoloadCandidates = [
|
||||
__DIR__ . '/../vendor/autoload.php',
|
||||
__DIR__ . '/../src/legacy_autoload.php',
|
||||
];
|
||||
|
||||
foreach ($autoloadCandidates as $file) {
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
use BlackWallSDK\BlackWallAuth;
|
||||
|
||||
$auth = new BlackWallAuth([
|
||||
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
||||
'authorizeUrl' => 'https://blackwall.dixon.cx/oauth/authorize',
|
||||
'tokenUrl' => 'https://blackwall.dixon.cx/oauth/token',
|
||||
'userInfoUrl' => 'https://blackwall.dixon.cx/oauth/userinfo',
|
||||
'redirectUri' => 'https://test.dixon.cx/callback.php',
|
||||
]);
|
||||
|
||||
if (!isset($_GET['code'], $_GET['state'])) {
|
||||
http_response_code(400);
|
||||
echo 'Missing code/state';
|
||||
exit;
|
||||
}
|
||||
|
||||
if (isset($_SESSION['blackwall_oauth_state']) && $_GET['state'] !== $_SESSION['blackwall_oauth_state']) {
|
||||
http_response_code(400);
|
||||
echo 'Invalid state';
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$tokens = $auth->exchangeCodeForTokens($_GET['code']);
|
||||
$_SESSION['access_token'] = $tokens['access_token'] ?? null;
|
||||
$_SESSION['refresh_token'] = $tokens['refresh_token'] ?? null;
|
||||
|
||||
if (isset($_SESSION['access_token']) && is_string($_SESSION['access_token'])) {
|
||||
$_SESSION['user'] = $auth->getUserInfo($_SESSION['access_token']);
|
||||
}
|
||||
|
||||
unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
|
||||
header('Location: /');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo 'Auth failed: ' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
$autoloadCandidates = [
|
||||
__DIR__ . '/../vendor/autoload.php',
|
||||
__DIR__ . '/../src/legacy_autoload.php',
|
||||
];
|
||||
|
||||
foreach ($autoloadCandidates as $file) {
|
||||
if (is_file($file)) {
|
||||
require_once $file;
|
||||
}
|
||||
}
|
||||
|
||||
use BlackWallSDK\BlackWallAuth;
|
||||
|
||||
$auth = new BlackWallAuth([
|
||||
'clientId' => '94d228c4-d4c6-4479-9b02-793e6a73e3f2',
|
||||
'authorizeUrl' => 'https://blackwall.dixon.cx/oauth/authorize',
|
||||
'tokenUrl' => 'https://blackwall.dixon.cx/oauth/token',
|
||||
'userInfoUrl' => 'https://blackwall.dixon.cx/oauth/userinfo',
|
||||
'redirectUri' => 'https://test.dixon.cx/callback.php',
|
||||
'scope' => 'openid profile email offline_access',
|
||||
]);
|
||||
|
||||
if (isset($_GET['logout'])) {
|
||||
unset(
|
||||
$_SESSION['access_token'],
|
||||
$_SESSION['refresh_token'],
|
||||
$_SESSION['user'],
|
||||
$_SESSION['blackwall_oauth_state'],
|
||||
$_SESSION['blackwall_oauth_code_verifier']
|
||||
);
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_SESSION['user'])) {
|
||||
$authData = $auth->getAuthorizationUrl();
|
||||
header('Location: ' . $authData['url']);
|
||||
exit;
|
||||
}
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo "Authenticated (session)\n\n";
|
||||
echo "User:\n";
|
||||
echo json_encode($_SESSION['user'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||
echo "Tokens (truncated):\n";
|
||||
echo json_encode([
|
||||
'access_token' => isset($_SESSION['access_token']) ? substr((string) $_SESSION['access_token'], 0, 16) . '...' : null,
|
||||
'refresh_token' => isset($_SESSION['refresh_token']) ? substr((string) $_SESSION['refresh_token'], 0, 8) . '...' : null,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n\n";
|
||||
echo "Logout: /?logout=1\n";
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth;
|
||||
|
||||
use BlackWall\Auth\Exception\StateMismatchException;
|
||||
use BlackWall\Auth\Exception\TokenExchangeException;
|
||||
use BlackWall\Auth\Exception\UserInfoException;
|
||||
use BlackWall\Auth\Http\CurlHttpClient;
|
||||
use BlackWall\Auth\Http\HttpClientInterface;
|
||||
|
||||
final class AuthClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly HttpClientInterface $httpClient = new CurlHttpClient()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
|
||||
*/
|
||||
public function buildAuthorisationUrl(array $options = []): array
|
||||
{
|
||||
$state = isset($options['state']) ? (string) $options['state'] : bin2hex(random_bytes(16));
|
||||
$codeVerifier = isset($options['code_verifier']) ? (string) $options['code_verifier'] : $this->randomUrlSafe(43);
|
||||
$codeChallenge = $this->base64Url(hash('sha256', $codeVerifier, true));
|
||||
$scope = isset($options['scope']) ? (string) $options['scope'] : $this->config->defaultScope;
|
||||
$extra = isset($options['extra']) && is_array($options['extra']) ? $options['extra'] : [];
|
||||
$persist = !isset($options['persist']) || (bool) $options['persist'];
|
||||
|
||||
if ($persist && session_status() === PHP_SESSION_ACTIVE) {
|
||||
$_SESSION['blackwall_oauth_state'] = $state;
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = $codeVerifier;
|
||||
}
|
||||
|
||||
$query = array_merge([
|
||||
'response_type' => 'code',
|
||||
'client_id' => $this->config->clientId,
|
||||
'redirect_uri' => $this->config->redirectUri,
|
||||
'scope' => $scope,
|
||||
'state' => $state,
|
||||
'code_challenge' => $codeChallenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
], $extra);
|
||||
|
||||
$url = $this->config->authorizeUrl . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'state' => $state,
|
||||
'code_verifier' => $codeVerifier,
|
||||
'code_challenge' => $codeChallenge,
|
||||
];
|
||||
}
|
||||
|
||||
public function assertStateMatches(string $state): void
|
||||
{
|
||||
$sessionState = $_SESSION['blackwall_oauth_state'] ?? null;
|
||||
if (!is_string($sessionState) || $state !== $sessionState) {
|
||||
throw new StateMismatchException('The OAuth state did not match the session value');
|
||||
}
|
||||
}
|
||||
|
||||
public function exchangeCodeForTokens(string $code, ?string $codeVerifier = null): TokenSet
|
||||
{
|
||||
$verifier = $codeVerifier ?? ($_SESSION['blackwall_oauth_code_verifier'] ?? null);
|
||||
if (!is_string($verifier) || $verifier === '') {
|
||||
throw new TokenExchangeException('Missing code verifier; pass one explicitly or persist it in session.');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->config->redirectUri,
|
||||
'client_id' => $this->config->clientId,
|
||||
'code_verifier' => $verifier,
|
||||
];
|
||||
|
||||
if ($this->config->clientSecret !== null && $this->config->clientSecret !== '') {
|
||||
$payload['client_secret'] = $this->config->clientSecret;
|
||||
}
|
||||
|
||||
$result = $this->httpClient->postForm($this->config->tokenUrl, $payload);
|
||||
$data = json_decode($result['body'], true);
|
||||
|
||||
if ($result['status'] >= 400) {
|
||||
$message = is_array($data) ? json_encode($data) : $result['body'];
|
||||
throw new TokenExchangeException('Token endpoint error (' . $result['status'] . '): ' . $message);
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw new TokenExchangeException('Token endpoint returned invalid JSON');
|
||||
}
|
||||
|
||||
return TokenSet::fromArray($data);
|
||||
}
|
||||
|
||||
public function refreshAccessToken(string $refreshToken): TokenSet
|
||||
{
|
||||
$payload = [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->config->clientId,
|
||||
];
|
||||
|
||||
if ($this->config->clientSecret !== null && $this->config->clientSecret !== '') {
|
||||
$payload['client_secret'] = $this->config->clientSecret;
|
||||
}
|
||||
|
||||
$result = $this->httpClient->postForm($this->config->tokenUrl, $payload);
|
||||
$data = json_decode($result['body'], true);
|
||||
|
||||
if ($result['status'] >= 400) {
|
||||
$message = is_array($data) ? json_encode($data) : $result['body'];
|
||||
throw new TokenExchangeException('Refresh token error (' . $result['status'] . '): ' . $message);
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw new TokenExchangeException('Refresh token response was not valid JSON');
|
||||
}
|
||||
|
||||
return TokenSet::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getUserInfo(string $accessToken): array
|
||||
{
|
||||
if ($this->config->userInfoUrl === null || $this->config->userInfoUrl === '') {
|
||||
throw new UserInfoException('UserInfo URL has not been configured');
|
||||
}
|
||||
|
||||
$result = $this->httpClient->get($this->config->userInfoUrl, [
|
||||
'Authorization' => 'Bearer ' . $accessToken,
|
||||
]);
|
||||
|
||||
$data = json_decode($result['body'], true);
|
||||
if ($result['status'] >= 400) {
|
||||
$message = is_array($data) ? json_encode($data) : $result['body'];
|
||||
throw new UserInfoException('UserInfo endpoint error (' . $result['status'] . '): ' . $message);
|
||||
}
|
||||
|
||||
if (!is_array($data)) {
|
||||
throw new UserInfoException('UserInfo response was not valid JSON');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function exchangeCodeAndFetchUser(string $code, ?string $codeVerifier = null): AuthResult
|
||||
{
|
||||
$tokens = $this->exchangeCodeForTokens($code, $codeVerifier);
|
||||
$user = $this->getUserInfo($tokens->accessToken);
|
||||
|
||||
return new AuthResult($tokens, $user);
|
||||
}
|
||||
|
||||
public function clearPkceSessionState(): void
|
||||
{
|
||||
unset($_SESSION['blackwall_oauth_state'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
}
|
||||
|
||||
private function base64Url(string $binary): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($binary), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function randomUrlSafe(int $length): string
|
||||
{
|
||||
return $this->base64Url(random_bytes($length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth;
|
||||
|
||||
final class AuthResult
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly TokenSet $tokens,
|
||||
public readonly array $user
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth;
|
||||
|
||||
final class Config
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $clientId,
|
||||
public readonly string $authorizeUrl,
|
||||
public readonly string $tokenUrl,
|
||||
public readonly string $redirectUri,
|
||||
public readonly ?string $userInfoUrl = null,
|
||||
public readonly ?string $clientSecret = null,
|
||||
public readonly string $defaultScope = 'openid profile email'
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function fromArray(array $config): self
|
||||
{
|
||||
foreach (['clientId', 'authorizeUrl', 'tokenUrl', 'redirectUri'] as $required) {
|
||||
if (empty($config[$required]) || !is_string($config[$required])) {
|
||||
throw new \InvalidArgumentException("{$required} is required");
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
clientId: $config['clientId'],
|
||||
authorizeUrl: rtrim($config['authorizeUrl'], '/'),
|
||||
tokenUrl: rtrim($config['tokenUrl'], '/'),
|
||||
redirectUri: $config['redirectUri'],
|
||||
userInfoUrl: isset($config['userInfoUrl']) ? rtrim((string) $config['userInfoUrl'], '/') : null,
|
||||
clientSecret: isset($config['clientSecret']) ? (string) $config['clientSecret'] : null,
|
||||
defaultScope: isset($config['scope']) ? (string) $config['scope'] : 'openid profile email'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class AuthException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class StateMismatchException extends AuthException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class TokenExchangeException extends AuthException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class TransportException extends AuthException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Exception;
|
||||
|
||||
class UserInfoException extends AuthException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Http;
|
||||
|
||||
use BlackWall\Auth\Exception\TransportException;
|
||||
|
||||
final class CurlHttpClient implements HttpClientInterface
|
||||
{
|
||||
public function __construct(private readonly int $timeoutSeconds = 20)
|
||||
{
|
||||
}
|
||||
|
||||
public function postForm(string $url, array $fields, array $headers = []): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new TransportException('Unable to initialise cURL for POST request');
|
||||
}
|
||||
|
||||
$headerLines = $this->normaliseHeaders(array_merge([
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
'Accept' => 'application/json',
|
||||
], $headers));
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query($fields, '', '&', PHP_QUERY_RFC3986),
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false) {
|
||||
throw new TransportException($error !== '' ? $error : 'Unknown cURL POST error');
|
||||
}
|
||||
|
||||
return ['status' => $status, 'body' => $body];
|
||||
}
|
||||
|
||||
public function get(string $url, array $headers = []): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
throw new TransportException('Unable to initialise cURL for GET request');
|
||||
}
|
||||
|
||||
$headerLines = $this->normaliseHeaders(array_merge([
|
||||
'Accept' => 'application/json',
|
||||
], $headers));
|
||||
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_HTTPHEADER => $headerLines,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($body === false) {
|
||||
throw new TransportException($error !== '' ? $error : 'Unknown cURL GET error');
|
||||
}
|
||||
|
||||
return ['status' => $status, 'body' => $body];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
* @return string[]
|
||||
*/
|
||||
private function normaliseHeaders(array $headers): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($headers as $name => $value) {
|
||||
$result[] = sprintf('%s: %s', $name, $value);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Http;
|
||||
|
||||
interface HttpClientInterface
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
* @return array{status:int, body:string}
|
||||
*/
|
||||
public function postForm(string $url, array $fields, array $headers = []): array;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
* @return array{status:int, body:string}
|
||||
*/
|
||||
public function get(string $url, array $headers = []): array;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth;
|
||||
|
||||
final class TokenSet
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $accessToken,
|
||||
public readonly ?string $refreshToken,
|
||||
public readonly ?string $idToken,
|
||||
public readonly string $tokenType,
|
||||
public readonly ?int $expiresIn,
|
||||
public readonly ?string $scope,
|
||||
public readonly array $raw
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
if (empty($payload['access_token']) || !is_string($payload['access_token'])) {
|
||||
throw new \InvalidArgumentException('Token payload does not include access_token');
|
||||
}
|
||||
|
||||
return new self(
|
||||
accessToken: $payload['access_token'],
|
||||
refreshToken: isset($payload['refresh_token']) && is_string($payload['refresh_token']) ? $payload['refresh_token'] : null,
|
||||
idToken: isset($payload['id_token']) && is_string($payload['id_token']) ? $payload['id_token'] : null,
|
||||
tokenType: isset($payload['token_type']) && is_string($payload['token_type']) ? $payload['token_type'] : 'Bearer',
|
||||
expiresIn: isset($payload['expires_in']) ? (int) $payload['expires_in'] : null,
|
||||
scope: isset($payload['scope']) && is_string($payload['scope']) ? $payload['scope'] : null,
|
||||
raw: $payload
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWallSDK;
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
|
||||
/**
|
||||
* Backwards-compatible wrapper for existing integrations.
|
||||
*
|
||||
* Prefer using BlackWall\Auth\AuthClient directly for new projects.
|
||||
*/
|
||||
class BlackWallAuth
|
||||
{
|
||||
private AuthClient $client;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$this->client = new AuthClient(Config::fromArray($config));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $opts
|
||||
* @return array{url:string,state:string,code_verifier:string,code_challenge:string}
|
||||
*/
|
||||
public function getAuthorizationUrl(array $opts = []): array
|
||||
{
|
||||
return $this->client->buildAuthorisationUrl($opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function exchangeCodeForTokens(string $code, ?string $codeVerifier = null): array
|
||||
{
|
||||
return $this->client->exchangeCodeForTokens($code, $codeVerifier)->raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): array
|
||||
{
|
||||
return $this->client->refreshAccessToken($refreshToken)->raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getUserInfo(string $accessToken): array
|
||||
{
|
||||
return $this->client->getUserInfo($accessToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Auth/Config.php';
|
||||
require_once __DIR__ . '/Auth/TokenSet.php';
|
||||
require_once __DIR__ . '/Auth/AuthResult.php';
|
||||
require_once __DIR__ . '/Auth/Http/HttpClientInterface.php';
|
||||
require_once __DIR__ . '/Auth/Http/CurlHttpClient.php';
|
||||
require_once __DIR__ . '/Auth/Exception/AuthException.php';
|
||||
require_once __DIR__ . '/Auth/Exception/TokenExchangeException.php';
|
||||
require_once __DIR__ . '/Auth/Exception/UserInfoException.php';
|
||||
require_once __DIR__ . '/Auth/Exception/StateMismatchException.php';
|
||||
require_once __DIR__ . '/Auth/Exception/TransportException.php';
|
||||
require_once __DIR__ . '/Auth/AuthClient.php';
|
||||
require_once __DIR__ . '/BlackWallAuth.php';
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Tests;
|
||||
|
||||
use BlackWall\Auth\AuthClient;
|
||||
use BlackWall\Auth\Config;
|
||||
use BlackWall\Auth\Exception\StateMismatchException;
|
||||
use BlackWall\Auth\Exception\TokenExchangeException;
|
||||
use BlackWall\Auth\Exception\UserInfoException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class AuthClientTest extends TestCase
|
||||
{
|
||||
private Config $config;
|
||||
private FakeHttpClient $http;
|
||||
private AuthClient $client;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
@session_start();
|
||||
}
|
||||
|
||||
$_SESSION = [];
|
||||
|
||||
$this->config = Config::fromArray([
|
||||
'clientId' => 'client-123',
|
||||
'authorizeUrl' => 'https://provider.example/oauth/authorize',
|
||||
'tokenUrl' => 'https://provider.example/oauth/token',
|
||||
'userInfoUrl' => 'https://provider.example/oauth/userinfo',
|
||||
'redirectUri' => 'https://app.example/callback.php',
|
||||
'scope' => 'openid profile email',
|
||||
'clientSecret' => 'secret-abc',
|
||||
]);
|
||||
|
||||
$this->http = new FakeHttpClient();
|
||||
$this->client = new AuthClient($this->config, $this->http);
|
||||
}
|
||||
|
||||
public function testBuildAuthorisationUrlPersistsStateAndVerifier(): void
|
||||
{
|
||||
$result = $this->client->buildAuthorisationUrl();
|
||||
|
||||
self::assertArrayHasKey('url', $result);
|
||||
self::assertArrayHasKey('state', $result);
|
||||
self::assertArrayHasKey('code_verifier', $result);
|
||||
self::assertArrayHasKey('code_challenge', $result);
|
||||
self::assertSame($result['state'], $_SESSION['blackwall_oauth_state']);
|
||||
self::assertSame($result['code_verifier'], $_SESSION['blackwall_oauth_code_verifier']);
|
||||
self::assertStringContainsString('code_challenge_method=S256', $result['url']);
|
||||
}
|
||||
|
||||
public function testAssertStateMatchesThrowsOnMismatch(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_state'] = 'expected';
|
||||
|
||||
$this->expectException(StateMismatchException::class);
|
||||
$this->client->assertStateMatches('actual');
|
||||
}
|
||||
|
||||
public function testExchangeCodeForTokensReturnsTypedTokenSet(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => json_encode([
|
||||
'access_token' => 'access-1',
|
||||
'refresh_token' => 'refresh-1',
|
||||
'id_token' => 'id-1',
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'scope' => 'openid profile email',
|
||||
], JSON_THROW_ON_ERROR),
|
||||
]);
|
||||
|
||||
$tokens = $this->client->exchangeCodeForTokens('code-1');
|
||||
|
||||
self::assertSame('access-1', $tokens->accessToken);
|
||||
self::assertSame('refresh-1', $tokens->refreshToken);
|
||||
self::assertSame('id-1', $tokens->idToken);
|
||||
self::assertSame(3600, $tokens->expiresIn);
|
||||
self::assertCount(1, $this->http->postCalls);
|
||||
self::assertSame('authorization_code', $this->http->postCalls[0]['fields']['grant_type']);
|
||||
self::assertSame('verifier-123', $this->http->postCalls[0]['fields']['code_verifier']);
|
||||
}
|
||||
|
||||
public function testExchangeCodeForTokensThrowsWhenVerifierMissing(): void
|
||||
{
|
||||
$this->expectException(TokenExchangeException::class);
|
||||
$this->client->exchangeCodeForTokens('code-2');
|
||||
}
|
||||
|
||||
public function testExchangeCodeForTokensThrowsOnEndpointError(): void
|
||||
{
|
||||
$_SESSION['blackwall_oauth_code_verifier'] = 'verifier-123';
|
||||
|
||||
$this->http->queuePost([
|
||||
'status' => 400,
|
||||
'body' => '{"error":"invalid_grant"}',
|
||||
]);
|
||||
|
||||
$this->expectException(TokenExchangeException::class);
|
||||
$this->expectExceptionMessage('Token endpoint error (400)');
|
||||
$this->client->exchangeCodeForTokens('bad-code');
|
||||
}
|
||||
|
||||
public function testRefreshAccessTokenCallsRefreshGrant(): void
|
||||
{
|
||||
$this->http->queuePost([
|
||||
'status' => 200,
|
||||
'body' => '{"access_token":"access-2","refresh_token":"refresh-2","token_type":"Bearer"}',
|
||||
]);
|
||||
|
||||
$tokens = $this->client->refreshAccessToken('refresh-1');
|
||||
|
||||
self::assertSame('access-2', $tokens->accessToken);
|
||||
self::assertSame('refresh_token', $this->http->postCalls[0]['fields']['grant_type']);
|
||||
self::assertSame('refresh-1', $this->http->postCalls[0]['fields']['refresh_token']);
|
||||
self::assertSame('client-123', $this->http->postCalls[0]['fields']['client_id']);
|
||||
}
|
||||
|
||||
public function testGetUserInfoReturnsPayload(): void
|
||||
{
|
||||
$this->http->queueGet([
|
||||
'status' => 200,
|
||||
'body' => '{"sub":"user-1","name":"Example User"}',
|
||||
]);
|
||||
|
||||
$user = $this->client->getUserInfo('access-3');
|
||||
|
||||
self::assertSame('user-1', $user['sub']);
|
||||
self::assertCount(1, $this->http->getCalls);
|
||||
self::assertSame('Bearer access-3', $this->http->getCalls[0]['headers']['Authorization']);
|
||||
}
|
||||
|
||||
public function testGetUserInfoThrowsOnErrorResponse(): void
|
||||
{
|
||||
$this->http->queueGet([
|
||||
'status' => 401,
|
||||
'body' => '{"error":"invalid_token"}',
|
||||
]);
|
||||
|
||||
$this->expectException(UserInfoException::class);
|
||||
$this->expectExceptionMessage('UserInfo endpoint error (401)');
|
||||
$this->client->getUserInfo('expired-token');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlackWall\Auth\Tests;
|
||||
|
||||
use BlackWall\Auth\Http\HttpClientInterface;
|
||||
|
||||
final class FakeHttpClient implements HttpClientInterface
|
||||
{
|
||||
/** @var list<array{status:int,body:string}> */
|
||||
private array $postQueue = [];
|
||||
|
||||
/** @var list<array{status:int,body:string}> */
|
||||
private array $getQueue = [];
|
||||
|
||||
/** @var list<array{url:string,fields:array<string,string>,headers:array<string,string>}> */
|
||||
public array $postCalls = [];
|
||||
|
||||
/** @var list<array{url:string,headers:array<string,string>}> */
|
||||
public array $getCalls = [];
|
||||
|
||||
/**
|
||||
* @param array{status:int,body:string} $response
|
||||
*/
|
||||
public function queuePost(array $response): void
|
||||
{
|
||||
$this->postQueue[] = $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status:int,body:string} $response
|
||||
*/
|
||||
public function queueGet(array $response): void
|
||||
{
|
||||
$this->getQueue[] = $response;
|
||||
}
|
||||
|
||||
public function postForm(string $url, array $fields, array $headers = []): array
|
||||
{
|
||||
/** @var array<string, string> $fields */
|
||||
/** @var array<string, string> $headers */
|
||||
$this->postCalls[] = ['url' => $url, 'fields' => $fields, 'headers' => $headers];
|
||||
|
||||
if ($this->postQueue === []) {
|
||||
throw new \RuntimeException('No fake POST response queued');
|
||||
}
|
||||
|
||||
return array_shift($this->postQueue);
|
||||
}
|
||||
|
||||
public function get(string $url, array $headers = []): array
|
||||
{
|
||||
/** @var array<string, string> $headers */
|
||||
$this->getCalls[] = ['url' => $url, 'headers' => $headers];
|
||||
|
||||
if ($this->getQueue === []) {
|
||||
throw new \RuntimeException('No fake GET response queued');
|
||||
}
|
||||
|
||||
return array_shift($this->getQueue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user