Initial Commit

This commit is contained in:
Andy Dixon
2026-02-10 09:17:22 +00:00
commit 57637840a5
23 changed files with 2837 additions and 0 deletions
+176
View File
@@ -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));
}
}
+17
View File
@@ -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
) {
}
}
+41
View File
@@ -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'
);
}
}
+9
View File
@@ -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
{
}
+9
View File
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace BlackWall\Auth\Exception;
class UserInfoException extends AuthException
{
}
+89
View File
@@ -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;
}
}
+20
View File
@@ -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;
}
+42
View File
@@ -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
);
}
}
+59
View File
@@ -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);
}
}
+16
View File
@@ -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';