Initial Commit

This commit is contained in:
David Stone
2024-11-30 18:24:12 -07:00
commit e8f7955c1c
5432 changed files with 1397750 additions and 0 deletions

View File

@ -0,0 +1,281 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
use WP_Ultimo\Dependencies\Jasny\Immutable;
/**
* Single sign-on broker.
*
* The broker lives on the website visited by the user. The broken doesn't have any user credentials stored. Instead it
* will talk to the SSO server in name of the user, verifying credentials and getting user information.
*/
class Broker
{
use Immutable\With;
/**
* URL of SSO server.
* @var string
*/
protected $url;
/**
* My identifier, given by SSO provider.
* @var string
*/
protected $broker;
/**
* My secret word, given by SSO provider.
* @var string
*/
protected $secret;
/**
* @var bool
*/
protected $initialized = \false;
/**
* Session token of the client.
* @var string|null
*/
protected $token;
/**
* Verification code returned by the server.
* @var string|null
*/
protected $verificationCode;
/**
* @var \ArrayAccess<string,mixed>
*/
protected $state;
/**
* @var Curl
*/
protected $curl;
/**
* Class constructor
*
* @param string $url Url of SSO server
* @param string $broker My identifier, given by SSO provider.
* @param string $secret My secret word, given by SSO provider.
*/
public function __construct(string $url, string $broker, string $secret)
{
if (!(bool) \preg_match('~^https?://~', $url)) {
throw new \InvalidArgumentException("Invalid SSO server URL '{$url}'");
}
if ((bool) \preg_match('/\\W/', $broker)) {
throw new \InvalidArgumentException("Invalid broker id '{$broker}': must be alphanumeric");
}
$this->url = $url;
$this->broker = $broker;
$this->secret = $secret;
$this->state = new Cookies();
}
/**
* Get a copy with a different handler for the user state (like cookie or session).
*
* @param \ArrayAccess<string,mixed> $handler
* @return static
*/
public function withTokenIn(\ArrayAccess $handler) : self
{
return $this->withProperty('state', $handler);
}
/**
* Set a custom wrapper for cURL.
*
* @param Curl $curl
* @return static
*/
public function withCurl(Curl $curl) : self
{
return $this->withProperty('curl', $curl);
}
/**
* Get Wrapped cURL.
*/
protected function getCurl() : Curl
{
if (!isset($this->curl)) {
$this->curl = new Curl();
// @codeCoverageIgnore
}
return $this->curl;
}
/**
* Get the broker identifier.
*/
public function getBrokerId() : string
{
return $this->broker;
}
/**
* Get information from cookie.
*/
protected function initialize() : void
{
if ($this->initialized) {
return;
}
$this->token = $this->state[$this->getCookieName('token')] ?? null;
$this->verificationCode = $this->state[$this->getCookieName('verify')] ?? null;
$this->initialized = \true;
}
/**
* @return string|null
*/
protected function getToken() : ?string
{
$this->initialize();
return $this->token;
}
/**
* @return string|null
*/
protected function getVerificationCode() : ?string
{
$this->initialize();
return $this->verificationCode;
}
/**
* Get the cookie name.
* The broker name is part of the cookie name. This resolves issues when multiple brokers are on the same domain.
*/
protected function getCookieName(string $type) : string
{
$brokerName = \preg_replace('/[_\\W]+/', '_', \strtolower($this->broker));
return "sso_{$type}_{$brokerName}";
}
/**
* Generate session id from session key
*
* @throws NotAttachedException
*/
public function getBearerToken() : string
{
$token = $this->getToken();
$verificationCode = $this->getVerificationCode();
if ($verificationCode === null) {
throw new NotAttachedException("The client isn't attached to the SSO server for this broker. " . "Make sure that the '" . $this->getCookieName('verify') . "' cookie is set.");
}
return "SSO-{$this->broker}-{$token}-" . $this->generateChecksum("bearer:{$verificationCode}");
}
/**
* Generate session token.
*/
protected function generateToken() : void
{
$this->token = \base_convert(\bin2hex(\random_bytes(32)), 16, 36);
$this->state[$this->getCookieName('token')] = $this->token;
}
/**
* Clears session token.
*/
public function clearToken() : void
{
unset($this->state[$this->getCookieName('token')]);
unset($this->state[$this->getCookieName('verify')]);
$this->token = null;
$this->verificationCode = null;
}
/**
* Check if we have an SSO token.
*/
public function isAttached() : bool
{
return $this->getVerificationCode() !== null;
}
/**
* Get URL to attach session at SSO server.
*
* @param array<string,mixed> $params
* @return string
*/
public function getAttachUrl(array $params = []) : string
{
if ($this->getToken() === null) {
$this->generateToken();
}
$data = ['broker' => $this->broker, 'token' => $this->getToken(), 'checksum' => $this->generateChecksum('attach')];
return $this->url . "?" . \http_build_query($data + $params);
}
/**
* Verify attaching to the SSO server by providing the verification code.
*/
public function verify(string $code) : void
{
$this->initialize();
if ($this->verificationCode === $code) {
return;
}
if ($this->verificationCode !== null) {
\trigger_error("SSO attach already verified", \E_USER_WARNING);
return;
}
$this->verificationCode = $code;
$this->state[$this->getCookieName('verify')] = $code;
}
/**
* Generate checksum for a broker.
*/
protected function generateChecksum(string $command) : string
{
return \base_convert(\hash_hmac('sha256', $command . ':' . $this->token, $this->secret), 16, 36);
}
/**
* Get the request url for a command
*
* @param string $path
* @param array<string,mixed>|string $params Query parameters
* @return string
*/
protected function getRequestUrl(string $path, $params = '') : string
{
$query = \is_array($params) ? \http_build_query($params) : $params;
$base = $path[0] === '/' ? \preg_replace('~^(\\w+://[^/]+).*~', '$1', $this->url) : \preg_replace('~/[^/]*$~', '', $this->url);
return $base . '/' . \ltrim($path, '/') . ($query !== '' ? '?' . $query : '');
}
/**
* Send an HTTP request to the SSO server.
*
* @param string $method HTTP method: 'GET', 'POST', 'DELETE'
* @param string $path Relative path
* @param array<string,mixed>|string $data Query or post parameters
* @return mixed
* @throws RequestException
*/
public function request(string $method, string $path, $data = '')
{
$url = $this->getRequestUrl($path, $method === 'POST' ? '' : $data);
$headers = ['Accept: application/json', 'Authorization: Bearer ' . $this->getBearerToken()];
['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $body] = $this->getCurl()->request($method, $url, $headers, $method === 'POST' ? $data : '');
return $this->handleResponse($httpCode, $contentType, $body);
}
/**
* Handle the response of the cURL request.
*
* @param int $httpCode HTTP status code
* @param string|null $ctHeader Content-Type header
* @param string $body Response body
* @return mixed
* @throws RequestException
*/
protected function handleResponse(int $httpCode, $ctHeader, string $body)
{
if ($httpCode === 204) {
return null;
}
[$contentType] = \explode(';', $ctHeader, 2);
if ($contentType != 'application/json') {
throw new RequestException("Expected 'application/json' response, got '{$contentType}'", 500, new RequestException($body, $httpCode));
}
try {
$data = \json_decode($body, \true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException $exception) {
throw new RequestException("Invalid JSON response from server", 500, $exception);
}
if ($httpCode >= 400) {
throw new RequestException($data['error'] ?? $body, $httpCode);
}
return $data;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
/**
* Use global $_COOKIE and setcookie() to persist the client token.
*
* @implements \ArrayAccess<string,mixed>
* @codeCoverageIgnore
*/
class Cookies implements \ArrayAccess
{
/** @var int */
protected $ttl;
/** @var string */
protected $path;
/** @var string */
protected $domain;
/** @var bool */
protected $secure;
/**
* Cookies constructor.
*
* @param int $ttl Cookie TTL in seconds
* @param string $path
* @param string $domain
* @param bool $secure
*/
public function __construct(int $ttl = 3600, string $path = '', string $domain = '', bool $secure = \false)
{
$this->ttl = $ttl;
$this->path = $path;
$this->domain = $domain;
$this->secure = $secure;
}
/**
* @inheritDoc
*/
#[\ReturnTypeWillChange]
public function offsetSet($name, $value)
{
$success = \setcookie($name, $value, \time() + $this->ttl, $this->path, $this->domain, $this->secure, \true);
if (!$success) {
throw new \RuntimeException("Failed to set cookie '{$name}'");
}
$_COOKIE[$name] = $value;
}
/**
* @inheritDoc
*/
public function offsetUnset($name) : void
{
\setcookie($name, '', 1, $this->path, $this->domain, $this->secure, \true);
unset($_COOKIE[$name]);
}
/**
* @inheritDoc
*/
#[\ReturnTypeWillChange]
public function offsetGet($name)
{
return $_COOKIE[$name] ?? null;
}
/**
* @inheritDoc
*/
#[\ReturnTypeWillChange]
public function offsetExists($name)
{
return isset($_COOKIE[$name]);
}
}

View File

@ -0,0 +1,56 @@
<?php
/** @noinspection PhpComposerExtensionStubsInspection */
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
/**
* Wrapper for cURL.
*
* @codeCoverageIgnore
*/
class Curl
{
/**
* Curl constructor.
*
* @throws \Exception if curl extension isn't loaded
*/
public function __construct()
{
if (!\extension_loaded('curl')) {
throw new \Exception("cURL extension not loaded");
}
}
/**
* Send an HTTP request to the SSO server.
*
* @param string $method HTTP method: 'GET', 'POST', 'DELETE'
* @param string $url Full URL
* @param string[] $headers HTTP headers
* @param array<string,mixed>|string $data Query or post parameters
* @return array{httpCode:int,contentType:string,body:string}
* @throws RequestException
*/
public function request(string $method, string $url, array $headers, $data = '')
{
$ch = \curl_init($url);
if ($ch === \false) {
throw new \RuntimeException("Failed to initialize a cURL session");
}
if ($data !== [] && $data !== '') {
$post = \is_string($data) ? $data : \http_build_query($data);
\curl_setopt($ch, \CURLOPT_POSTFIELDS, $post);
}
\curl_setopt($ch, \CURLOPT_RETURNTRANSFER, \true);
\curl_setopt($ch, \CURLOPT_CUSTOMREQUEST, $method);
\curl_setopt($ch, \CURLOPT_HTTPHEADER, $headers);
$responseBody = (string) \curl_exec($ch);
if (\curl_errno($ch) != 0) {
throw new RequestException('Server request failed: ' . \curl_error($ch));
}
$httpCode = \curl_getinfo($ch, \CURLINFO_HTTP_CODE);
$contentType = \curl_getinfo($ch, \CURLINFO_CONTENT_TYPE) ?? 'text/html';
return ['httpCode' => $httpCode, 'contentType' => $contentType, 'body' => $responseBody];
}
}

View File

@ -0,0 +1,11 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
/**
* Exception thrown when a request is done while no session is attached
*/
class NotAttachedException extends \RuntimeException
{
}

View File

@ -0,0 +1,11 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
/**
* SSO Request failed.
*/
class RequestException extends \RuntimeException
{
}

View File

@ -0,0 +1,42 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Broker;
/**
* Use global $_SESSION to persist the client token.
*
* @implements \ArrayAccess<string,mixed>
* @codeCoverageIgnore
*/
class Session implements \ArrayAccess
{
/**
* @inheritDoc
*/
public function offsetSet($name, $value) : void
{
$_SESSION[$name] = $value;
}
/**
* @inheritDoc
*/
public function offsetUnset($name) : void
{
unset($_SESSION[$name]);
}
/**
* @inheritDoc
*/
public function offsetGet($name)
{
return $_SESSION[$name] ?? null;
}
/**
* @inheritDoc
*/
public function offsetExists($name) : bool
{
return isset($_SESSION[$name]);
}
}