Initial commit

Sketched out first draft of how the library should work, stubbed
a lot of the smaller utility classes required, and outlined the
main handler functions for the IA Server.
This commit is contained in:
Barnaby Walters 2021-06-06 01:18:44 +02:00
commit 8ab57bee25
10 changed files with 1512 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.DS_Store
vendor

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# taproot/indieauth

24
composer.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "taproot/indieauth",
"description": "PHP PSR-7-compliant IndieAuth Server and Client implementation.",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Barnaby Walters",
"email": "barnaby@waterpigs.co.uk"
}
],
"require": {
"psr/http-message": "^1.0",
"psr/log": "^1.1",
"nyholm/psr7": "^1.4",
"indieauth/client": "^1.1",
"psr/http-client": "^1.0",
"psr/http-server-middleware": "^1.0",
"dflydev/fig-cookies": "^3.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.3"
}
}

1067
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
class ClosureRequestHandler implements RequestHandlerInterface {
protected callable $callable;
protected array $args;
public function __construct(callable $callable) {
$this->callable = $callable;
$this->args = array_slice(func_get_args(), 1);
}
public function handle(ServerRequestInterface $request): ResponseInterface {
return call_user_func_array($this->callable, array_merge([$request], $this->args));
}
}

View File

@ -0,0 +1,92 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Dflydev\FigCookies;
// From https://github.com/indieweb/indieauth-client-php/blob/main/src/IndieAuth/Client.php, thanks aaronpk.
function generateRandomString($numBytes) {
if (function_exists('random_bytes')) {
$bytes = random_bytes($numBytes);
} elseif (function_exists('openssl_random_pseudo_bytes')){
$bytes = openssl_random_pseudo_bytes($numBytes);
} else {
$bytes = '';
for($i=0, $bytes=''; $i < $numBytes; $i++) {
$bytes .= chr(mt_rand(0, 255));
}
}
return bin2hex($bytes);
}
class DoubleSubmitCookieCsrfMiddleware implements MiddlewareInterface {
const READ_METHODS = ['HEAD', 'GET', 'OPTIONS'];
const TTL = 60 * 20;
const ATTRIBUTE = 'csrf';
const DEFAULT_ERROR_RESPONSE_STRING = 'Invalid or missing CSRF token!';
const CSRF_TOKEN_LENGTH = 128;
public string $attribute;
public int $ttl;
public callable $errorResponse;
public int $tokenLength;
public function __construct(string $attribute=self::ATTRIBUTE, int $ttl=self::TTL, $errorResponse=self::DEFAULT_ERROR_RESPONSE_STRING, $tokenLength=self::CSRF_TOKEN_LENGTH) {
$this->attribute = $attribute;
$this->ttl = $ttl;
$this->tokenLength = $tokenLength;
if (!is_callable($errorResponse)) {
if (!$errorResponse instanceof ResponseInterface) {
if (!is_string($errorResponse)) {
$errorResponse = self::DEFAULT_ERROR_RESPONSE_STRING;
}
$errorResponse = new Response(400, ['content-type' => 'text/plain'], $errorResponse);
}
$errorResponse = function (ServerRequestInterface $request) use ($errorResponse) { return $errorResponse; };
}
$this->errorResponse = $errorResponse;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
if (!in_array(strtoupper($request->getMethod()), self::READ_METHODS)) {
// This request is a write method and requires CSRF protection.
if (!$this->isValid($request)) {
return call_user_func($this->errorResponse, $request);
}
}
// Otherwise, generate a new CSRF token, add it to the request attributes, and as a cookie on the response.
$csrfToken = generateRandomString($this->tokenLength);
$request = $request->withAttribute($this->attribute, $csrfToken);
$response = $handler->handle($request);
// Add the new CSRF cookie, restricting its scope to match the current request.
$response = FigCookies\FigResponseCookies::set($response, FigCookies\SetCookie::create($this->attribute)
->withValue($csrfToken)
->withMaxAge($this->ttl)
->withSecure($request->getUri()->getScheme() == 'https')
->withDomain($request->getUri()->getHost())
->withPath($request->getUri()->getPath()));
return $response;
}
protected function isValid(ServerRequestInterface $request) {
if (in_array($this->attribute, $request->getParsedBody())) {
if (in_array($this->attribute, $request->getCookieParams())) {
return hash_equals($request->getParsedBody()[$this->attribute], $request->getCookieParams()[$this->attribute]);
}
}
return false;
}
}

View File

@ -0,0 +1,68 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use DirectoryIterator;
class FilesystemJsonStorage implements TokenStorageInterface {
protected $path;
protected $ttl;
public function __construct(string $path, $ttl=0, $cleanUpNow=false) {
$this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$this->ttl = $ttl;
if ($cleanUpNow) {
$this->cleanUp();
}
}
public function cleanUp($ttl=null): int {
$ttl = $ttl ?? $this->ttl;
$deleted = 0;
// A TTL of 0 means the token should live until deleted, and negative TTLs are invalid.
if ($ttl >= 0) {
foreach (new DirectoryIterator($this->path) as $fileInfo) {
if ($fileInfo->isFile() && time() - max($fileInfo->getMTime(), $fileInfo->getCTime()) > $ttl) {
unlink($fileInfo->getPathname());
$deleted++;
}
}
}
return $deleted;
}
public function get(string $key): ?array {
$path = $this->getPath($key);
if (file_exists($path)) {
$result = json_decode(file_get_contents($path), true);
if (is_array($result)) {
return $result;
}
}
return null;
}
public function put(string $key, array $data): bool {
// Ensure that the containing folder exists.
mkdir($this->path, 0777, true);
return file_put_contents($this->getPath($key), json_encode($data)) !== false;
}
public function delete(string $key): bool {
if (file_exists($this->getPath($key))) {
return unlink($this->getPath($key));
}
return false;
}
public function getPath(string $key): string {
return $this->path . "$key.json";
}
}

View File

@ -0,0 +1,14 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
class NoOpMiddleware implements MiddlewareInterface {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface {
return $handler->handle($request);
}
}

View File

@ -0,0 +1,206 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
use Exception;
use Nyholm\Psr7\Response;
use GuzzleHttp\Psr7\ServerRequest;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use IndieAuth\Client as IndieAuthClient;
use Psr\Http\Server\MiddlewareInterface;
/**
* Development Reference
*
* Specification: https://indieauth.spec.indieweb.org/
* Error responses: https://www.rfc-editor.org/rfc/rfc6749.html#section-5.2
* indieweb/indieauth-client: https://github.com/indieweb/indieauth-client-php
* CSRF protection cheat sheet: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
* Example CSRF protection cookie middleware: https://github.com/zakirullin/csrf-middleware/blob/master/src/CSRF.php
*/
// TODO: maybe move these to a functions file so theyre usable by consumers even when the class isnt loaded.
// Alternatively, make them static methods so they can be autoloaded.
function isIndieAuthAuthorizationCodeRedeemingRequest(ServerRequestInterface $request) {
return strtolower($request->getMethod()) == 'post'
&& array_key_exists('grant_type', $request->getParsedBody())
&& $request->getParsedBody()['grant_type'] == 'authorization_code';
}
function isIndieAuthAuthorizationRequest(ServerRequestInterface $request, $permittedMethods=['get']) {
return in_array(strtolower($request->getMethod()), array_map('strtolower', $permittedMethods))
&& array_key_exists('response_type', $request->getQueryParams())
&& $request->getQueryParams()['response_type'] == 'code';
}
function isAuthorizationApprovalRequest(ServerRequestInterface $request) {
return strtolower($request->getMethod()) == 'post'
&& array_key_exists('taproot_indieauth_action', $request->getParsedBody())
&& $request->getParsedBody()['taproot_indieauth_action'] == 'approve';
}
function buildQueryString(array $parameters) {
$qs = [];
foreach ($parameters as $k => $v) {
$qs[] = urlencode($k) . '=' . urlencode($v);
}
return join('&', $qs);
}
class Server {
const CUSTOMIZE_AUTHORIZATION_CODE = 'customise_authorization_code';
const SHOW_AUTHORIZATION_PAGE = 'show_authorization_page';
const HANDLE_NON_INDIEAUTH_REQUEST = 'handle_non_indieauth_request';
const HANDLE_AUTHENTICATION_REQUEST = 'handle_authentication_request';
const DEFAULT_CSRF_KEY = 'taproot_indieauth_server_csrf';
public $callbacks;
public TokenStorageInterface $authorizationCodeStorage;
public TokenStorageInterface $accessTokenStorage;
public MiddlewareInterface $csrfMiddleware;
public LoggerInterface $logger;
public string $csrfKey;
public function __construct(array $callbacks, $authorizationCodeStorage, $accessTokenStorage, $csrfMiddleware=null, string $csrfKey=self::DEFAULT_CSRF_KEY, LoggerInterface $logger=null) {
$callbacks = array_merge([
self::CUSTOMIZE_AUTHORIZATION_CODE => function (array $code) { return $code; }, // Default to no-op.
self::SHOW_AUTHORIZATION_PAGE => function (ServerRequestInterface $request, array $authenticationResult, string $authenticationRedirect) { }, // TODO: Put the default implementation here.
self::HANDLE_NON_INDIEAUTH_REQUEST => function (ServerRequestInterface $request) { return null; }, // Default to no-op.
], $callbacks);
if (!(array_key_exists(self::HANDLE_AUTHENTICATION_REQUEST, $callbacks) and is_callable($callbacks[self::HANDLE_AUTHENTICATION_REQUEST]))) {
throw new Exception('$callbacks[\'' . self::HANDLE_AUTHENTICATION_REQUEST .'\'] must be present and callable.');
}
$this->callbacks = $callbacks;
if (!$authorizationCodeStorage instanceof TokenStorageInterface) {
if (is_string($authorizationCodeStorage)) {
$authorizationCodeStorage = new FilesystemJsonStorage($authorizationCodeStorage, 600, true);
} else {
throw new Exception('$authorizationCodeStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.');
}
}
$this->authorizationCodeStorage = $authorizationCodeStorage;
if (!$accessTokenStorage instanceof TokenStorageInterface) {
if (is_string($accessTokenStorage)) {
// Create a default access token storage with a TTL of 7 days.
$accessTokenStorage = new FilesystemJsonStorage($accessTokenStorage, 60 * 60 * 24 * 7, true);
} else {
throw new Exception('$accessTokenStorage parameter must be either a string (path) or an instance of Taproot\IndieAuth\TokenStorageInterface.');
}
}
$this->accessTokenStorage = $accessTokenStorage;
$this->csrfKey = $csrfKey;
if (!$csrfMiddleware instanceof MiddlewareInterface) {
// Default to the statless Double-Submit Cookie CSRF Middleware, with default settings.
$csrfMiddleware = new DoubleSubmitCookieCsrfMiddleware($this->csrfKey);
}
$this->csrfMiddleware = $csrfMiddleware;
$this->logger = $logger ?? new NullLogger();
}
public function handleAuthorizationEndpointRequest(ServerRequestInterface $request): ResponseInterface {
// If its a profile information request:
if (isIndieAuthAuthorizationCodeRedeemingRequest($request)) {
// Verify that the authorization code is valid and has not yet been used.
$this->authorizationCodeStorage->get($request->getParsedBody()['code']);
// Verify that it was issued for the same client_id and redirect_uri
// Check that the supplied code_verifier hashes to the stored code_challenge
// If everything checked out, return {"me": "https://example.com"} response
// (a response containing any additional information must contain a valid scope value, and
// be handled by the token_endpoint).
// TODO: according to the spec, it is technically permitted for the authorization endpoint
// to additional provide profile information. Leave it up to the library consumer to decide
// whether to add it or not.
}
// Because the special case above isnt allowed to be CSRF-protected, we have to do some rather silly
// gymnastics here to selectively-CSRF-protect requests which do need it.
return $this->csrfMiddleware->process($request, new ClosureRequestHandler(function (ServerRequestInterface $request) {
// If this is an authorization or approval request (allowing POST requests as well to accommodate
// approval requests and custom auth form submission.
if (isIndieAuthAuthorizationRequest($request, ['get', 'post'])) {
// Build a URL for the authentication flow to redirect to, if it needs to.
// TODO: perhaps filter queryParams to only include the indieauth-relevant params?
$authenticationRedirect = $request->getUri() . '?' . buildQueryString($request->getQueryParams());
$authenticationResult = call_user_func($this->callbacks[self::HANDLE_AUTHENTICATION_REQUEST], $request, $authenticationRedirect);
// If the authentication handler returned a Response, return that as-is.
if ($authenticationResult instanceof Response) {
return $authenticationResult;
} elseif (is_array($authenticationResult)) {
// Check the resulting array for errors.
// The user is logged in.
// If this is a POST request sent from the authorization (i.e. scope-choosing) form:
if (isAuthorizationApprovalRequest($request)) {
// Assemble the data for the authorization code, store it somewhere persistent.
$code = [
];
// Pass it to the auth code customisation callback, if any.
$code = call_user_func($this->callbacks[self::CUSTOMIZE_AUTHORIZATION_CODE], $code, $request);
// Store the authorization code.
$this->authorizationCodeStorage->put($code['code'], $code);
// Return a redirect to the client app.
}
// Otherwise, the user is authenticated and needs to authorize the client app + choose scopes.
// Fetch the client_id URL to find information about the client to present to the user.
// If the authority of the redirect_uri does not match the client_id or one of their redirect URLs, return an error.
// Present the authorization UI.
return call_user_func($this->callbacks[self::SHOW_AUTHORIZATION_PAGE], $request, $authenticationResult, $authenticationRedirect);
}
}
// If the request isnt an IndieAuth Authorization or Code-redeeming request, its either an invalid
// request or something to do with a custom auth handler (e.g. sending a one-time code in an email.)
$nonIndieAuthRequestResult = call_user_func($this->callbacks[self::HANDLE_NON_INDIEAUTH_REQUEST], $request);
if ($nonIndieAuthRequestResult instanceof ResponseInterface) {
return $nonIndieAuthRequestResult;
} else {
return new Response(400, ['content-type' => 'application/json'], json_encode([
'error' => 'invalid_request'
]));
}
}));
}
public function handleTokenEndpointRequest(ServerRequestInterface $request): ResponseInterface {
// This is a request to redeem an authorization_code for an access_token.
// Verify that the authorization code is valid and has not yet been used.
// Verify that it was issued for the same client_id and redirect_uri
// Check that the supplied code_verifier hashes to the stored code_challenge
// If the auth code was issued with no scope, return an error.
// If everything checks out, generate an access token and return it.
}
}

View File

@ -0,0 +1,15 @@
<?php declare(strict_types=1);
namespace Taproot\IndieAuth;
// TODO: document.
interface TokenStorageInterface {
public function cleanUp($ttl=null): int;
public function get(string $key): ?array;
public function put(string $key, array $data): bool;
public function delete(string $key): bool;
}