and finally the device code flow!

This commit is contained in:
flash 2024-07-20 18:48:31 +00:00
parent 05dd302866
commit 0973cd31c3
3 changed files with 359 additions and 6 deletions

View file

@ -27,4 +27,4 @@ $oatmeal->register(new HomeRoutes);
$oatmeal->register(new AuthzCodeRoutes($oatmeal->getCSRFP())); $oatmeal->register(new AuthzCodeRoutes($oatmeal->getCSRFP()));
$oatmeal->register(new RefreshTokenRoutes($oatmeal->getCSRFP())); $oatmeal->register(new RefreshTokenRoutes($oatmeal->getCSRFP()));
$oatmeal->register(new ClientCredsRoutes($oatmeal->getCSRFP())); $oatmeal->register(new ClientCredsRoutes($oatmeal->getCSRFP()));
$oatmeal->register(new DeviceCodeRoutes); $oatmeal->register(new DeviceCodeRoutes($oatmeal->getCSRFP()));

View file

@ -1,11 +1,364 @@
<?php <?php
namespace Oatmeal; namespace Oatmeal;
use Index\Http\Routing\{HttpGet,RouteHandler}; use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
use Index\Security\CSRFP;
final class DeviceCodeRoutes extends RouteHandler { final class DeviceCodeRoutes extends RouteHandler {
public function __construct(
private CSRFP $csrfp
) {}
#[HttpGet('/device_code')] #[HttpGet('/device_code')]
public function getIndex(): string { public function getIndex($response): void {
return 'device code routes go here'; $response->redirect('/device_code/authorise');
}
#[HttpGet('/device_code/authorise')]
public function getAuthorise(): string {
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Device 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 / Device code</h1>
<p>
Device codes allow you to do authentications on device where its impractical or impossible to enter your password.
I will also be using it as a way to secure do authentication over plain HTTP!
</p>
<form method=post>
<input type=hidden name=csrfp value="{$this->csrfp->createToken()}">
<div>
<label>
<span>Authorise URI (POST):</span>
<input type=url name=device_authorise_uri required>
</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>Scope:</span>
<input type=text name=scope required>
</label>
</div>
<div>
<button>Submit</button>
<button type=reset>Reset</button>
</div>
</form>
<a href=/>Return</a>
HTML;
}
#[HttpPost('/device_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('device_authorise_uri');
if(filter_var($authoriseUri, FILTER_VALIDATE_URL) === false) {
$response->setStatusCode(400);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Device code</title>
<h1>Oatmeal / Device code</h1>
<p>Provided Device Authorise 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');
$scope = (string)$content->getParam('scope');
$headers = [];
$body = ['scope' => $scope];
if($clientSecret === '')
$body['client_id'] = $clientId;
else
$headers[] = sprintf('Authorization: Basic %s', base64_encode(sprintf('%s:%s', $clientId, $clientSecret)));
$body = Tools::shuffleArray($body);
$response = Tools::fetch($authoriseUri, headers: $headers, body: $body);
$authoriseUri = htmlspecialchars($authoriseUri);
$headers = htmlspecialchars(json_encode($headers, JSON_PRETTY_PRINT));
$body = htmlspecialchars(json_encode($body, JSON_PRETTY_PRINT));
$form = '<p style="color: red">Could not display form because the request failed.</p>';
$decoded = json_decode($response);
if($decoded !== null) {
$response = json_encode($decoded, JSON_PRETTY_PRINT);
$vericationUri = htmlspecialchars($decoded->verification_uri ?? '');
$vericationUriComplete = htmlspecialchars($decoded->verification_uri_complete ?? '');
$deviceCode = htmlspecialchars($decoded->device_code ?? '');
$userCode = htmlspecialchars($decoded->user_code ?? '');
if(!is_string($deviceCode))
$form = '<p style="color: red">Could not display form because the <code>device_code</code> is missing from the response.</p>';
elseif(!is_string($userCode))
$form = '<p style="color: red">Could not display form because the <code>user_code</code> is missing from the response.</p>';
elseif(!is_string($vericationUri))
$form = '<p style="color: red">Could not display form because the <code>verification_uri</code> is missing from the response.</p>';
else {
$form = <<<HTML
<p>Here's the form I promised!</p>
<form method=get action=/device_code/token>
<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>User code for verification:</span>
<input type=text value="{$userCode}" readonly>
</label>
</div>
<div>
<label>
<span>Verification URI:</span>
<input type=url value="{$vericationUri}" readonly class=js-vu-field>
<button type=button class=js-vu-button>Open in new tab</button>
</label>
</div>
<div>
<label>
<span>Verification URI (Complete):</span>
<input type=url value="{$vericationUriComplete}" readonly class=js-vuc-field>
<button type=button class=js-vuc-button>Open in new tab</button>
</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>Device code:</span>
<input type=text name=device_code value="{$deviceCode}" required>
</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>
<span>Grant type:</span>
<label>
<input type=radio name=type value=std checked>
Standard (<code>urn:ietf:params:oauth:grant-type:device_code</code>)
</label>
<label>
<input type=radio name=type value=short>
Short, not defined by spec (<code>device_code</code>)
</label>
</div>
<div>
<button>Submit</button>
<button type=reset>Reset</button>
</div>
</form>
<script>
(() => {
const makeButtonClickable = (field, button) => {
if(field.value === '') {
button.disabled = true;
return;
}
button.onclick = () => { window.open(field.value, '_blank'); };
};
makeButtonClickable(document.querySelector('.js-vu-field'), document.querySelector('.js-vu-button'));
makeButtonClickable(document.querySelector('.js-vuc-field'), document.querySelector('.js-vuc-button'));
})();
</script>
HTML;
}
}
$response = htmlspecialchars($response);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Device 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; }
textarea { min-width: 100%; max-width: 100%; min-height: 200px; box-sizing: border-box; }
</style>
<h1>Oatmeal / Device code</h1>
<p>
Below is the request and response data from your device authorisation request.
Even further below is the form you can use to actually request an access token!
Normally you would open <code>verification_uri</code> on a separate device and enter the <code>user_code</code> or just scan the <code>verification_uri_complete</code> as a QR Code or something like that (if present in the response), but you can also just open it in a new tab.
</p>
<div>
<label>
<span>Authorise URI (POST):</span>
<input type=text value="{$authoriseUri}">
</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>
{$form}
<a href=/device_code>Return</a>
HTML;
}
#[HttpGet('/device_code/token')]
public function getToken($response, $request) {
$csrfp = (string)$request->getParam('csrfp');
if(!$this->csrfp->verifyToken($csrfp))
return 403;
$tokenUri = (string)$request->getParam('token_uri');
if(filter_var($tokenUri, FILTER_VALIDATE_URL) === false) {
$response->setStatusCode(400);
return <<<HTML
<!doctype html>
<meta charset=utf-8>
<title>Oatmeal / Device code</title>
<h1>Oatmeal / Device code</h1>
<p>Provided Token URI was not a valid absolute URI.</p>
<a href="javascript:history.back();">Return</a>
HTML;
}
$clientId = (string)$request->getParam('client_id');
$clientSecret = (string)$request->getParam('client_secret');
$deviceCode = (string)$request->getParam('device_code');
$grantType = (string)$request->getParam('type');
$auth = (string)$request->getParam('auth');
$headers = [];
$body = [
'grant_type' => $grantType === 'short' ? 'device_code' : 'urn:ietf:params:oauth:grant-type:device_code',
'device_code' => $deviceCode,
];
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)));
$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 / Device 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 / Device code</h1>
<p>Below is the request and response data from your device 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=/device_code>Return</a>
HTML;
} }
} }

View file

@ -15,8 +15,8 @@ final class HomeRoutes extends RouteHandler {
<p> <p>
Hello and welcome to Oatmeal! Hello and welcome to Oatmeal!
This is a quick and dirty utility for testing your OAuth2 server implementation. 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. It is tailored to my own Hanyuu project for Flashii ID, however it should do for general implementations of 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. I'm not going to bother with making this page look nice, the most you'll get is utilitarian Javascript and/or CSS touch ups if really necessary.
</p> </p>
</header> </header>
<h2>Select A Flow&trade;</h2> <h2>Select A Flow&trade;</h2>