Base application + authorisation code flow.

This commit is contained in:
flash 2024-07-19 23:51:55 +00:00
commit c507e6d760
16 changed files with 792 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.DS_Store
[Dd]esktop.ini
/.debug
/vendor
/.rng

12
LICENCE Normal file
View file

@ -0,0 +1,12 @@
Copyright (c) 2024 flashwave
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Oatmeal - OAuth2 Tester
I couldn't find a decent tool for testing Hanyuu so I GUESS I'll just have to write it myself them.
It's called Oatmeal because: OAuth2 Tester -> OAT -> Oatmeal.
Please enjoy your stay.

15
composer.json Normal file
View file

@ -0,0 +1,15 @@
{
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"flashwave/index": "dev-master"
},
"autoload": {
"psr-4": {
"Oatmeal\\": "src"
}
},
"config": {
"preferred-install": "dist"
}
}

67
composer.lock generated Normal file
View file

@ -0,0 +1,67 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8be4aa570091b8e36082f761bd734626",
"packages": [
{
"name": "flashwave/index",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://patchii.net/flash/index.git",
"reference": "e4c8ed711e045cffe840ba10a239ede14b0b171f"
},
"require": {
"ext-mbstring": "*",
"php": ">=8.1"
},
"require-dev": {
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.2"
},
"suggest": {
"ext-mysqli": "Support for the Index\\Data\\MariaDB namespace (both mysqlnd and libmysql are supported).",
"ext-sqlite3": "Support for the Index\\Data\\SQLite namespace."
},
"default-branch": true,
"type": "library",
"autoload": {
"files": [
"index.php"
],
"psr-4": {
"Index\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"bsd-3-clause-clear"
],
"authors": [
{
"name": "flashwave",
"email": "packagist@flash.moe",
"homepage": "https://flash.moe",
"role": "mom"
}
],
"description": "Composer package for the common library for my projects.",
"homepage": "https://railgun.sh/index",
"time": "2024-04-10T23:40:14+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "dev",
"stability-flags": {
"flashwave/index": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

31
oatmeal.php Normal file
View file

@ -0,0 +1,31 @@
<?php
namespace Oatmeal;
use Index\Environment;
use Index\Data\DbTools;
define('OAT_STARTUP', microtime(true));
define('OAT_ROOT', __DIR__);
define('OAT_DEBUG', is_file(OAT_ROOT . '/.debug'));
define('OAT_DIR_PUBLIC', OAT_ROOT . '/public');
define('OAT_DIR_SOURCE', OAT_ROOT . '/src');
require_once OAT_ROOT . '/vendor/autoload.php';
Environment::setDebug(OAT_DEBUG);
mb_internal_encoding('utf-8');
date_default_timezone_set('utc');
$oatmeal = new OatmealContext((function() {
$path = OAT_ROOT . '/.rng';
if(!is_file($path))
return 'hey you should really generate a .rng file using head -c 1K </dev/urandom >.rng or something similar!!!';
return file_get_contents($path);
})());
$oatmeal->register(new HomeRoutes);
$oatmeal->register(new AuthzCodeRoutes($oatmeal->getCSRFP()));
$oatmeal->register(new RefreshTokenRoutes);
$oatmeal->register(new ClientCredsRoutes);
$oatmeal->register(new PasswordRoutes);
$oatmeal->register(new DeviceCodeRoutes);

21
public/index.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Oatmeal;
require_once __DIR__ . '/../oatmeal.php';
set_exception_handler(function(\Throwable $ex) {
http_response_code(500);
ob_clean();
if(OAT_DEBUG) {
header('Content-Type: text/plain; charset=utf-8');
echo (string)$ex;
exit;
}
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><h1>Error 500';
exit;
});
$oatmeal->dispatch();

459
src/AuthzCodeRoutes.php Normal file
View file

@ -0,0 +1,459 @@
<?php
namespace Oatmeal;
use Index\XString;
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
use Index\Security\CSRFP;
use Index\Serialisation\UriBase64;
final class AuthzCodeRoutes extends RouteHandler {
public function __construct(
private CSRFP $csrfp
) {}
#[HttpGet('/authorization_code')]
public function getIndex($response): void {
$response->redirect('/authorization_code/authorise');
}
#[HttpGet('/authorization_code/authorise')]
public function getAuthorise(): string {
$state = XString::random(10);
$verifier = XString::random(60);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<style>
div { margin: .5em 0; }
span { display: inline-block; min-width: 200px; text-align: right; }
input[type=text], input[type=url] { min-width: 500px; }
</style>
<h1>Oatmeal / Authorisation code</h1>
<p>
You are intentionally allowed to use data that would be considered invalid by the spec, both because I cannot be bothered to check and because your server implementation should be able to handle those appropriately.
You will be able to enter your token POST endpoint during the next step for further verification.
</p>
<form method=post class=js-authz-form>
<input type=hidden name=csrfp value="{$this->csrfp->createToken()}">
<div>
<label>
<span>Authorise URI (GET):</span>
<input type=url name=authorise_uri required>
</label>
</div>
<div>
<label>
<span>Redirect URI (GET):</span>
<input type=url name=redirect_uri value=https://{$_SERVER['HTTP_HOST']}/authorization_code/callback>
</label>
</div>
<div>
<label>
<span>Client ID:</span>
<input type=text name=client_id required>
</label>
</div>
<div>
<label>
<span>Scope:</span>
<input type=text name=scope required>
</label>
</div>
<div>
<label>
<span>State:</span>
<input type=text name=state value="{$state}">
</label>
</div>
<div>
<span>PKCE:</span>
<label>
<input type=radio name=pkce value=off>
No
</label>
<label>
<input type=radio name=pkce value=S256 checked>
SHA-256
</label>
<label>
<input type=radio name=pkce value=plain>
Plain
</label>
</div>
<div>
<label>
<span>Omit PKCE method if plain:</span>
<input type=checkbox name=pkce_omit>
</label>
</div>
<div>
<label>
<span>PKCE Code Verifier:</span>
<input type=text name=pkce_verifier value="{$verifier}">
</label>
</div>
<div>
<button>Submit</button>
<button type=reset>Reset</button>
</div>
</form>
<a href=/>Return</a>
<script>
(() => {
const form = document.querySelector('.js-authz-form');
form.onsubmit = () => {
sessionStorage.setItem('authorisation_code_state', form.elements['state'].value);
sessionStorage.setItem('authorisation_code_pkce', form.elements['pkce_verifier'].value);
};
})();
</script>
HTML;
}
#[HttpPost('/authorization_code/authorise')]
public function postAuthorise($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$csrfp = (string)$content->getParam('csrfp');
if(!$this->csrfp->verifyToken($csrfp))
return 403;
$authoriseUri = (string)$content->getParam('authorise_uri');
$redirectUri = (string)$content->getParam('redirect_uri');
$clientId = (string)$content->getParam('client_id');
$scope = (string)$content->getParam('scope');
$state = (string)$content->getParam('state');
$pkce = (string)$content->getParam('pkce');
$pkceOmit = !empty($content->getParam('pkce_omit'));
$pkceVerifier = (string)$content->getParam('pkce_verifier');
if(filter_var($authoriseUri, FILTER_VALIDATE_URL) === false) {
$response->setStatusCode(400);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<h1>Oatmeal / Authorisation code</h1>
<p>Provided Authorise URI was not a valid absolute URI.</p>
<a href=/authorization_code>Return</a>
HTML;
}
$query = [
'response_type' => 'code',
'client_id' => $clientId,
'scope' => $scope,
];
if($redirectUri !== '')
$query['redirect_uri'] = $redirectUri;
if($state !== '')
$query['state'] = $state;
if($pkce === 'plain') {
$query['code_challenge'] = $pkceVerifier;
if(!$pkceOmit)
$query['code_challenge_method'] = 'plain';
} elseif($pkce === 'S256') {
$query['code_challenge'] = UriBase64::encode(hash('sha256', $pkceVerifier, true));
$query['code_challenge_method'] = 'S256';
}
$query = Tools::shuffleArray($query);
$authoriseUri .= strpos($authoriseUri, '?') === false ? '?' : '&';
$authoriseUri .= http_build_query($query, '', '&', PHP_QUERY_RFC3986);
$authoriseUri = htmlspecialchars($authoriseUri);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<h1>Oatmeal / Authorisation code</h1>
<p>Constructed the following authorisation URI for you: <a href="{$authoriseUri}">{$authoriseUri}</a></p>
<a href="/authorization_code">Restart</a>
HTML;
}
#[HttpGet('/authorization_code/callback')]
public function getCallback($response, $request) {
$state = htmlspecialchars((string)$request->getParam('state'));
$error = htmlspecialchars((string)$request->getParam('error'));
$errorDescription = htmlspecialchars((string)$request->getParam('error_description'));
$errorUri = htmlspecialchars((string)$request->getParam('error_uri'));
if($error !== '')
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<style>
div { margin: .5em 0; }
span { display: inline-block; min-width: 200px; text-align: right; }
input[type=text] { min-width: 500px; }
</style>
<h1>Oatmeal / Authorisation code</h1>
<p>Authorisation was cancelled with an error code, please verify that this error code is expected for the input you provided.</p>
<div>
<label>
<span>State:</span>
<input type=text value="{$state}" readonly class=js-state-field>
<em class="js-state-info">Checking state value...</em>
</label>
</div>
<div>
<label>
<span>Error code:</span>
<input type=text value="{$error}" readonly>
</label>
</div>
<div>
<label>
<span>Human readable error:</span>
<input type=text value="{$errorDescription}" readonly>
</label>
</div>
<div>
<label>
<span>Error URI:</span>
<input type=text value="{$errorUri}" readonly>
</label>
</div>
<a href=/authorization_code>Restart</a>
<script>
(() => {
const stateInfo = document.querySelector('.js-state-info');
const expectState = sessionStorage.getItem('authorisation_code_state');
if(expectState === null) {
stateInfo.textContent = 'Unable to check, no previous state stored.';
return;
}
const stateField = document.querySelector('.js-state-field');
if(expectState === stateField.value) {
stateInfo.textContent = 'State matches up with the previously used state value!';
stateInfo.style.color = 'green';
} else {
stateInfo.textContent = 'State does not match up with the previously used state value! Keep in mind that if you started another authorisation process, it will have been overwritten.';
stateInfo.style.color = 'red';
}
})();
</script>
HTML;
$code = htmlspecialchars((string)$request->getParam('code'));
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<style>
div { margin: .5em 0; }
span { display: inline-block; min-width: 200px; text-align: right; }
input[type=text], input[type=url], input[type=password] { min-width: 500px; }
</style>
<h1>Oatmeal / Authorisation code</h1>
<p>
Enter all data necessary to complete the access token request, this can vary based on what data you used during the authorisation request and how your application is set up with the service provider.
</p>
<form method=post action=/authorization_code/callback>
<input type=hidden name=csrfp value="{$this->csrfp->createToken()}">
<div>
<label>
<span>Token URI (POST):</span>
<input type=url name=token_uri required>
</label>
</div>
<div>
<label>
<span>Redirect URI (GET) (optional):</span>
<input type=url name=redirect_uri>
</label>
</div>
<div>
<label>
<span>Client ID:</span>
<input type=text name=client_id required>
</label>
</div>
<div>
<label>
<span>Client secret (optional):</span>
<input type=password name=client_secret>
</label>
</div>
<div>
<label>
<span>State:</span>
<input type=text value="{$state}" readonly class=js-state-field>
<em class="js-state-info">Checking state value...</em>
</label>
</div>
<div>
<label>
<span>Code:</span>
<input type=text name=code value="{$code}" readonly>
</label>
</div>
<div>
<label>
<span>PKCE Code Verifier:</span>
<input type=text name=pkce_verifier class=js-pkce-field>
</label>
</div>
<div>
<span>Authentication:</span>
<label>
<input type=radio name=auth value=random checked>
Random
</label>
<label>
<input type=radio name=auth value=header>
Authorization header
</label>
<label>
<input type=radio name=auth value=body>
Request body
</label>
</div>
<div>
<button>Submit</button>
<button type=reset>Reset</button>
<a href=/authorization_code><button type=button>Restart</button></a>
</div>
</form>
<script>
(() => {
const pkceField = document.querySelector('.js-pkce-field');
const pkceVerifier = sessionStorage.getItem('authorisation_code_pkce');
pkceField.value = pkceVerifier ?? '';
const stateInfo = document.querySelector('.js-state-info');
const expectState = sessionStorage.getItem('authorisation_code_state');
if(expectState === null) {
stateInfo.textContent = 'Unable to check, no previous state stored.';
return;
}
const stateField = document.querySelector('.js-state-field');
if(expectState === stateField.value) {
stateInfo.textContent = 'State matches up with the previously used state value!';
stateInfo.style.color = 'green';
} else {
stateInfo.textContent = 'State does not match up with the previously used state value! Keep in mind that if you started another authorisation process, it will have been overwritten.';
stateInfo.style.color = 'red';
}
})();
</script>
HTML;
}
#[HttpPost('/authorization_code/callback')]
public function postCallback($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$csrfp = (string)$content->getParam('csrfp');
if(!$this->csrfp->verifyToken($csrfp))
return 403;
$tokenUri = (string)$content->getParam('token_uri');
if(filter_var($tokenUri, FILTER_VALIDATE_URL) === false) {
$response->setStatusCode(400);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<h1>Oatmeal / Authorisation code</h1>
<p>Provided Token URI was not a valid absolute URI.</p>
<a href="javascript:history.back();">Return</a>
HTML;
}
$clientId = (string)$content->getParam('client_id');
$clientSecret = (string)$content->getParam('client_secret');
$code = (string)$content->getParam('code');
$codeVerifier = (string)$content->getParam('pkce_verifier');
$redirectUri = (string)$content->getParam('redirect_uri');
$auth = (string)$content->getParam('auth');
$headers = [];
$body = [
'grant_type' => 'authorization_code',
'code' => $code,
];
if($clientSecret === '')
$body['client_id'] = $clientId;
elseif($auth === 'body' || ($auth !== 'header' && mt_rand(0, 10) > 5)) {
$body['client_id'] = $clientId;
$body['client_secret'] = $clientSecret;
} else
$headers[] = sprintf('Authorization: Basic %s', base64_encode(sprintf('%s:%s', $clientId, $clientSecret)));
if($codeVerifier !== '')
$body['code_verifier'] = $codeVerifier;
if($redirectUri !== '')
$body['redirect_uri'] = $redirectUri;
$body = Tools::shuffleArray($body);
$response = Tools::fetch($tokenUri, headers: $headers, body: $body);
$tokenUri = htmlspecialchars($tokenUri);
$headers = htmlspecialchars(json_encode($headers, JSON_PRETTY_PRINT));
$body = htmlspecialchars(json_encode($body, JSON_PRETTY_PRINT));
$decoded = json_decode($response);
if($decoded !== null)
$response = json_encode($decoded, JSON_PRETTY_PRINT);
$response = htmlspecialchars($response);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Authorisation code</title>
<style>
div { margin: .5em 0; }
span { display: inline-block; min-width: 200px; text-align: right; }
input[type=text] { min-width: 500px; }
textarea { min-width: 100%; max-width: 100%; min-height: 200px; box-sizing: border-box; }
</style>
<h1>Oatmeal / Authorisation code</h1>
<p>Below is the request and response data from your authorisation code result. If the response contains a refresh_token field, you can move on to the <a href=/refresh_token>Refresh token flow</a>!</p>
<div>
<label>
<span>Token URI (POST):</span>
<input type=text value="{$tokenUri}">
</label>
</div>
<div>
<label>
<span>Headers:</span><br>
<textarea readonly>{$headers}</textarea>
</label>
</div>
<div>
<label>
<span>Request body (sent as application/x-www-form-urlencoded, presented as JSON for consistency):</span><br>
<textarea readonly>{$body}</textarea>
</label>
</div>
<div>
<label>
<span>Response body:</span><br>
<textarea readonly>{$response}</textarea>
</label>
</div>
<a href=/authorization_code>Return</a>
HTML;
}
}

11
src/ClientCredsRoutes.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler};
final class ClientCredsRoutes extends RouteHandler {
#[HttpGet('/client_credentials')]
public function getIndex(): string {
return 'client creds routes go here';
}
}

11
src/DeviceCodeRoutes.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler};
final class DeviceCodeRoutes extends RouteHandler {
#[HttpGet('/device_code')]
public function getIndex(): string {
return 'device code routes go here';
}
}

33
src/HomeRoutes.php Normal file
View file

@ -0,0 +1,33 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler};
final class HomeRoutes extends RouteHandler {
#[HttpGet('/')]
public function getIndex(): string {
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal - OAuth2 Testing Utility</title>
<header>
<h1>Oatmeal</h1>
<p>
Hello and welcome to Oatmeal!
This is a quick and dirty utility for testing your OAuth2 server implementation.
It is tailored to my own Hanyuu project for Flashii ID, however it should do for OAuth2.1 and the Device Code extension.
I'm not going to bother with making this page look nice, the most you'll get it functional Javascript and/or CSS touch ups if really necessary.
</p>
</header>
<h2>Select A Flow</h2>
<ul>
<li><a href=/authorization_code>Authorisation code</a></li>
<li><a href=/refresh_token>Refresh token</a></li>
<li><a href=/client_credentials>Client credentials</a></li>
<li><a href=/password>Password</a></li>
<li><a href=/device_code>Device code</a></li>
</ul>
<footer><i>&copy; <a href=https://flash.moe target=_blank>flashwave</a> 2024 - <a href=https://patchii.net/flash/oatmeal target=_blank>source code</a></i></footer>
HTML;
}
}

33
src/OatmealContext.php Normal file
View file

@ -0,0 +1,33 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpRouter,IRouter,IRouteHandler};
use Index\Security\CSRFP;
class OatmealContext {
private CSRFP $csrfp;
private HttpRouter $router;
public function __construct(string $rng) {
$this->csrfp = new CSRFP($rng, $_SERVER['REMOTE_ADDR'] ?? '::');
$this->router = new HttpRouter;
$this->router->use('/', fn($resp) => $resp->setPoweredBy('Oatmeal'));
}
public function getCSRFP(): CSRFP {
return $this->csrfp;
}
public function register(IRouteHandler $handler): void {
$this->router->register($handler);
}
public function getRouter(): IRouter {
return $this->router;
}
public function dispatch(...$args): void {
$this->router->dispatch(...$args);
}
}

11
src/PasswordRoutes.php Normal file
View file

@ -0,0 +1,11 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler};
final class PasswordRoutes extends RouteHandler {
#[HttpGet('/password')]
public function getIndex(): string {
return 'password routes go here';
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler};
final class RefreshTokenRoutes extends RouteHandler {
#[HttpGet('/refresh_token')]
public function getIndex(): string {
return 'refresh token routes go here';
}
}

64
src/Tools.php Normal file
View file

@ -0,0 +1,64 @@
<?php
namespace Oatmeal;
use RuntimeException;
final class Tools {
public static function shuffleArray(array $source): array {
$target = [];
$keys = array_keys($source);
shuffle($keys);
foreach($keys as $key)
$target[$key] = $source[$key];
return $target;
}
public static function fetch(string $url, array $headers = [], array $params = [], array $body = []): mixed {
if(!empty($params))
$url .= sprintf(
'%s%s',
strpos($url, '?') === false ? '?' : '&',
http_build_query($params, '', '&', PHP_QUERY_RFC3986)
);
$hasBody = !empty($body);
$body = $hasBody ? http_build_query($body, '', '&', PHP_QUERY_RFC3986) : '';
if($hasBody)
$headers[] = 'Content-Type: application/x-www-form-urlencoded';
$req = curl_init($url);
try {
curl_setopt_array($req, [
CURLOPT_AUTOREFERER => false,
CURLOPT_FAILONERROR => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TCP_FASTOPEN => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 15,
CURLOPT_USERAGENT => 'Oatmeal/20240720',
CURLOPT_HTTPHEADER => $headers,
]);
if($hasBody)
curl_setopt_array($req, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
]);
$response = curl_exec($req);
if($response === false)
throw new RuntimeException(curl_error($req));
} finally {
curl_close($req);
}
return $response;
}
}