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

12
dependencies/jasny/immutable/phpcs.xml vendored Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<ruleset name="Jasny">
<description>The Jasny coding standard.</description>
<!-- Include the whole PSR-1 standard -->
<rule ref="PSR1"/>
<!-- Include the whole PSR-2 standard -->
<rule ref="PSR2"/>
<!-- TODO: Add own rules -->
</ruleset>

View File

@ -0,0 +1,10 @@
parameters:
level: 7
paths:
- src
reportUnmatchedIgnoredErrors: false
ignoreErrors:
- /^Variable property access/
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon

View File

@ -0,0 +1,22 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\Immutable;
/**
* Disable dynamic properties.
*/
trait NoDynamicProperties
{
/**
* Magic method called when trying to set a non-existing property.
*
* @param string $property
* @param mixed $value
* @throws \LogicException
*/
public function __set(string $property, $value) : void
{
throw new \LogicException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
}

View File

@ -0,0 +1,154 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\Immutable;
/**
* Trait with the `withProperty` methods that can be used by classes of immutable objects.
*
* {@internal Some lines are expected to be covered, which should be ignored. Added codeCoverageIgnore. }}
*/
trait With
{
/**
* Return a copy with a changed property.
* Returns this object if the resulting object would be the same as the current one.
*
* @param string $property
* @param mixed $value
* @return static
* @throws \BadMethodCallException if property doesn't exist
*/
private function withProperty(string $property, $value)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
if (isset($this->{$property}) && $this->{$property} === $value || !isset($this->{$property}) && $value === null) {
return $this;
}
$clone = clone $this;
$clone->{$property} = $value;
return $clone;
}
/**
* Return a copy with a property unset.
* Returns this object if the resulting object would be the same as the current one.
*
* @param string $property
* @return static
* @throws \BadMethodCallException if property doesn't exist
*/
private function withoutProperty(string $property)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
if (!isset($this->{$property})) {
return $this;
}
$clone = clone $this;
unset($clone->{$property});
return $clone;
}
/**
* Return a copy with an added item for a property.
* Returns this object if the resulting object would be the same as the current one.
*
* @param string $property
* @param string $key
* @param mixed $value
* @return static
* @throws \BadMethodCallException if property doesn't exist
*/
private function withPropertyKey(string $property, string $key, $value)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
// @codeCoverageIgnore
if (!\is_array($this->{$property}) && !$this->{$property} instanceof \ArrayAccess) {
throw new \BadMethodCallException(\sprintf('%s::$%s is not an array', \get_class($this), $property));
}
if (isset($this->{$property}[$key]) && $this->{$property}[$key] === $value) {
return $this;
}
$clone = clone $this;
$clone->{$property}[$key] = $value;
return $clone;
}
/**
* Return a copy with a removed item from a property.
* Returns this object if the resulting object would be the same as the current one.
*
* @param string $property
* @param string $key
* @return static
* @throws \BadMethodCallException if property doesn't exist or isn't an array
*/
private function withoutPropertyKey(string $property, string $key)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
// @codeCoverageIgnore
if (!\is_array($this->{$property}) && !$this->{$property} instanceof \ArrayAccess) {
throw new \BadMethodCallException(\sprintf('%s::$%s is not an array', \get_class($this), $property));
}
if (!isset($this->{$property}[$key])) {
return $this;
}
$clone = clone $this;
unset($clone->{$property}[$key]);
return $clone;
}
/**
* Return a copy with a value added to a sequential array.
*
* @param string $property
* @param mixed $value
* @param mixed $unique Don't add if the array already has a copy of the value.
* @return static
* @throws \BadMethodCallException if property doesn't exist or isn't an array
*/
private function withPropertyItem(string $property, $value, bool $unique = \false)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
if (!\is_array($this->{$property})) {
throw new \BadMethodCallException(\sprintf('%s::$%s is not an array', \get_class($this), $property));
}
if ($unique && \in_array($value, $this->{$property}, \true)) {
return $this;
}
$clone = clone $this;
$clone->{$property}[] = $value;
return $clone;
}
/**
* Return a copy with a value removed from a sequential array.
* Returns this object if the resulting object would be the same as the current one.
*
* @param string $property
* @param mixed $value
* @return static
* @throws \BadMethodCallException if property doesn't exist or isn't an array
*/
private function withoutPropertyItem(string $property, $value)
{
if (!\property_exists($this, $property)) {
throw new \BadMethodCallException(\sprintf('%s has no property "%s"', \get_class($this), $property));
}
if (!\is_array($this->{$property})) {
throw new \BadMethodCallException(\sprintf('%s::$%s is not an array', \get_class($this), $property));
}
$keys = \array_keys($this->{$property}, $value, \true);
if ($keys === []) {
return $this;
}
$clone = clone $this;
$clone->{$property} = \array_values(\array_diff_key($clone->{$property}, \array_fill_keys($keys, null)));
return $clone;
}
}

14
dependencies/jasny/sso/codeception.yml vendored Normal file
View File

@ -0,0 +1,14 @@
actor: Tester
paths:
tests: tests
log: tests/_output
data: tests/_data
support: tests/_support
envs: tests/_envs
bootstrap: _bootstrap.php
settings:
colors: true
memory_limit: 1024M
extensions:
enabled:
- WP_Ultimo\Dependencies\Codeception\Extension\RunFailed

12
dependencies/jasny/sso/phpcs.xml vendored Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0"?>
<ruleset name="Jasny">
<description>The Jasny coding standard.</description>
<!-- Include the whole PSR-1 standard -->
<rule ref="PSR1"/>
<!-- Include the whole PSR-2 standard -->
<rule ref="PSR2"/>
<!-- TODO: Add own rules -->
</ruleset>

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]);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
/**
* Exception that's thrown if request from broker is invalid.
* Should result in an HTTP 4xx response.
*/
class BrokerException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,20 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
interface ExceptionInterface
{
/**
* Gets the Exception message.
*
* @return string
*/
public function getMessage();
/**
* Gets the Exception code.
*
* @return int
*/
public function getCode();
}

View File

@ -0,0 +1,70 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
/**
* Interact with the global session using PHP's session_* functions.
*
* @codeCoverageIgnore
*/
class GlobalSession implements SessionInterface
{
/**
* Options passed to session_start().
* @var array<string,mixed>
*/
protected $options;
/**
* Class constructor.
*
* @param array<string,mixed> $options Options passed to session_start().
*/
public function __construct(array $options = [])
{
$this->options = $options + ['cookie_samesite' => 'None', 'cookie_secure' => \true];
}
/**
* @inheritDoc
*/
public function getId() : string
{
return \session_id();
}
/**
* @inheritDoc
*/
public function start() : void
{
$started = \session_status() !== \PHP_SESSION_ACTIVE ? \session_start($this->options) : \true;
if (!$started) {
$err = \error_get_last() ?? ['message' => 'Failed to start session'];
throw new ServerException($err['message'], 500);
}
// Session shouldn't be empty when resumed.
$_SESSION['_sso_init'] = 1;
}
/**
* @inheritDoc
*/
public function resume(string $id) : void
{
\session_id($id);
$started = \session_start($this->options);
if (!$started) {
$err = \error_get_last() ?? ['message' => 'Failed to start session'];
throw new ServerException($err['message'], 500);
}
if ($_SESSION === []) {
\session_abort();
throw new BrokerException("Session has expired. Client must attach with new token.", 401);
}
}
/**
* @inheritDoc
*/
public function isActive() : bool
{
return \session_status() === \PHP_SESSION_ACTIVE;
}
}

View File

@ -0,0 +1,273 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
use WP_Ultimo\Dependencies\Jasny\Immutable;
use WP_Ultimo\Dependencies\Psr\Http\Message\ServerRequestInterface;
use WP_Ultimo\Dependencies\Psr\Log\LoggerInterface;
use WP_Ultimo\Dependencies\Psr\Log\NullLogger;
use WP_Ultimo\Dependencies\Psr\SimpleCache\CacheInterface;
/**
* Single sign-on server.
* The SSO server is responsible of managing users sessions which are available for brokers.
*/
class Server
{
use Immutable\With;
/**
* Callback to get the secret for a broker.
* @var \Closure
*/
protected $getBrokerInfo;
/**
* Storage for broker session links.
* @var CacheInterface
*/
protected $cache;
/**
* @var LoggerInterface
*/
protected $logger;
/**
* Service to interact with sessions.
* @var SessionInterface
*/
protected $session;
/**
* Class constructor.
*
* @phpstan-param callable(string):?array{secret:string,domains:string[]} $getBrokerInfo
* @phpstan-param CacheInterface $cache
*/
public function __construct(callable $getBrokerInfo, CacheInterface $cache)
{
$this->getBrokerInfo = \Closure::fromCallable($getBrokerInfo);
$this->cache = $cache;
$this->logger = new NullLogger();
$this->session = new GlobalSession();
}
/**
* Get a copy of the service with logging.
*
* @return static
*/
public function withLogger(LoggerInterface $logger) : self
{
return $this->withProperty('logger', $logger);
}
/**
* Get a copy of the service with a custom session service.
*
* @return static
*/
public function withSession(SessionInterface $session) : self
{
return $this->withProperty('session', $session);
}
/**
* Start the session for broker requests to the SSO server.
*
* @throws BrokerException
* @throws ServerException
*/
public function startBrokerSession(?ServerRequestInterface $request = null) : void
{
if ($this->session->isActive()) {
throw new ServerException("Session is already started", 500);
}
$bearer = $this->getBearerToken($request);
[$brokerId, $token, $checksum] = $this->parseBearer($bearer);
$sessionId = $this->cache->get($this->getCacheKey($brokerId, $token));
if ($sessionId === null) {
$this->logger->warning("Bearer token isn't attached to a client session", ['broker' => $brokerId, 'token' => $token]);
throw new BrokerException("Bearer token isn't attached to a client session", 403);
}
$code = $this->getVerificationCode($brokerId, $token, $sessionId);
$this->validateChecksum($checksum, 'bearer', $brokerId, $token, $code);
$this->session->resume($sessionId);
$this->logger->debug("Broker request with session", ['broker' => $brokerId, 'token' => $token, 'session' => $sessionId]);
}
/**
* Get bearer token from Authorization header.
*/
protected function getBearerToken(?ServerRequestInterface $request = null) : string
{
$authorization = $request === null ? $_SERVER['HTTP_AUTHORIZATION'] ?? '' : $request->getHeaderLine('Authorization');
[$type, $token] = \explode(' ', $authorization, 2) + ['', ''];
if ($type !== 'Bearer') {
$this->logger->warning("Broker didn't use bearer authentication: " . ($authorization === '' ? "No 'Authorization' header" : "{$type} authorization used"));
throw new BrokerException("Broker didn't use bearer authentication", 401);
}
return $token;
}
/**
* Get the broker id and token from the bearer token used by the broker.
*
* @return string[]
* @throws BrokerException
*/
protected function parseBearer(string $bearer) : array
{
$matches = null;
if (!(bool) \preg_match('/^SSO-(\\w*+)-(\\w*+)-([a-z0-9]*+)$/', $bearer, $matches)) {
$this->logger->warning("Invalid bearer token", ['bearer' => $bearer]);
throw new BrokerException("Invalid bearer token", 403);
}
return \array_slice($matches, 1);
}
/**
* Generate cache key for linking the broker token to the client session.
*/
protected function getCacheKey(string $brokerId, string $token) : string
{
return "SSO-{$brokerId}-{$token}";
}
/**
* Get the broker secret using the configured callback.
*
* @param string $brokerId
* @return string|null
*/
protected function getBrokerSecret(string $brokerId) : ?string
{
return ($this->getBrokerInfo)($brokerId)['secret'] ?? null;
}
/**
* Generate the verification code based on the token using the server secret.
*/
protected function getVerificationCode(string $brokerId, string $token, string $sessionId) : string
{
return \base_convert(\hash('sha256', $brokerId . $token . $sessionId), 16, 36);
}
/**
* Generate checksum for a broker.
*/
protected function generateChecksum(string $command, string $brokerId, string $token) : string
{
$secret = $this->getBrokerSecret($brokerId);
if ($secret === null) {
$this->logger->warning("Unknown broker", ['broker' => $brokerId, 'token' => $token]);
throw new BrokerException("Broker is unknown or disabled", 403);
}
return \base_convert(\hash_hmac('sha256', $command . ':' . $token, $secret), 16, 36);
}
/**
* Assert that the checksum matches the expected checksum.
*
* @throws BrokerException
*/
protected function validateChecksum(string $checksum, string $command, string $brokerId, string $token, ?string $code = null) : void
{
$expected = $this->generateChecksum($command . ($code !== null ? ":{$code}" : ''), $brokerId, $token);
if ($checksum !== $expected) {
$this->logger->warning("Invalid {$command} checksum", ['expected' => $expected, 'received' => $checksum, 'broker' => $brokerId, 'token' => $token] + ($code !== null ? ['verification_code' => $code] : []));
throw new BrokerException("Invalid {$command} checksum", 403);
}
}
/**
* Validate that the URL has a domain that is allowed for the broker.
*/
public function validateDomain(string $type, string $url, string $brokerId, ?string $token = null) : void
{
$domains = ($this->getBrokerInfo)($brokerId)['domains'] ?? [];
$host = \parse_url($url, \PHP_URL_HOST);
if (!\in_array($host, $domains, \true)) {
$this->logger->warning("Domain of {$type} is not allowed for broker", [$type => $url, 'broker' => $brokerId] + ($token !== null ? ['token' => $token] : []));
throw new BrokerException("Domain of {$type} is not allowed", 400);
}
}
/**
* Attach a client session to a broker session.
* Returns the verification code.
*
* @throws BrokerException
* @throws ServerException
*/
public function attach(?ServerRequestInterface $request = null) : string
{
['broker' => $brokerId, 'token' => $token] = $this->processAttachRequest($request);
$this->session->start();
$this->assertNotAttached($brokerId, $token);
$key = $this->getCacheKey($brokerId, $token);
$cached = $this->cache->set($key, $this->session->getId());
$info = ['broker' => $brokerId, 'token' => $token, 'session' => $this->session->getId()];
if (!$cached) {
$this->logger->error("Failed to attach bearer token to session id due to cache issue", $info);
throw new ServerException("Failed to attach bearer token to session id", 500);
}
$this->logger->info("Attached broker token to session", $info);
return $this->getVerificationCode($brokerId, $token, $this->session->getId());
}
/**
* Assert that the token isn't already attached to a different session.
*/
protected function assertNotAttached(string $brokerId, string $token) : void
{
$key = $this->getCacheKey($brokerId, $token);
$attached = $this->cache->get($key);
if ($attached !== null && $attached !== $this->session->getId()) {
$this->logger->warning("Token is already attached", ['broker' => $brokerId, 'token' => $token, 'attached_to' => $attached, 'session' => $this->session->getId()]);
throw new BrokerException("Token is already attached", 400);
}
}
/**
* Validate attach request and return broker id and token.
*
* @param ServerRequestInterface|null $request
* @return array{broker:string,token:string}
* @throws BrokerException
*/
protected function processAttachRequest(?ServerRequestInterface $request) : array
{
$brokerId = $this->getRequiredQueryParam($request, 'broker');
$token = $this->getRequiredQueryParam($request, 'token');
$checksum = $this->getRequiredQueryParam($request, 'checksum');
$this->validateChecksum($checksum, 'attach', $brokerId, $token);
$origin = $this->getHeader($request, 'Origin');
if ($origin !== '') {
$this->validateDomain('origin', $origin, $brokerId, $token);
}
$referer = $this->getHeader($request, 'Referer');
if ($referer !== '') {
$this->validateDomain('referer', $referer, $brokerId, $token);
}
$returnUrl = $this->getQueryParam($request, 'return_url');
if ($returnUrl !== null) {
$this->validateDomain('return_url', $returnUrl, $brokerId, $token);
}
return ['broker' => $brokerId, 'token' => $token];
}
/**
* Get query parameter from PSR-7 request or $_GET.
*/
protected function getQueryParam(?ServerRequestInterface $request, string $key) : ?string
{
$params = $request === null ? $_GET : $request->getQueryParams();
return $params[$key] ?? null;
}
/**
* Get required query parameter from PSR-7 request or $_GET.
*
* @throws BrokerException if query parameter isn't set
*/
protected function getRequiredQueryParam(?ServerRequestInterface $request, string $key) : string
{
$value = $this->getQueryParam($request, $key);
if ($value === null) {
throw new BrokerException("Missing '{$key}' query parameter", 400);
}
return $value;
}
/**
* Get HTTP Header from PSR-7 request or $_SERVER.
*
* @param ServerRequestInterface $request
* @param string $key
* @return string
*/
protected function getHeader(?ServerRequestInterface $request, string $key) : string
{
return $request === null ? $_SERVER['HTTP_' . \str_replace('-', '_', \strtoupper($key))] ?? '' : $request->getHeaderLine($key);
}
}

View File

@ -0,0 +1,12 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
/**
* Exception that's thrown if something unexpectedly went wrong on the server.
* Should result in an HTTP 5xx response.
*/
class ServerException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,34 @@
<?php
declare (strict_types=1);
namespace WP_Ultimo\Dependencies\Jasny\SSO\Server;
/**
* Interface to start a session.
*/
interface SessionInterface
{
/**
* @see session_id()
*/
public function getId() : string;
/**
* Start a new session.
* @see session_start()
*
* @throws ServerException if session can't be started.
*/
public function start() : void;
/**
* Resume an existing session.
*
* @throws ServerException if session can't be started.
* @throws BrokerException if session is expired
*/
public function resume(string $id) : void;
/**
* Check if a session is active. (status PHP_SESSION_ACTIVE)
* @see session_status()
*/
public function isActive() : bool;
}