Created
September 4, 2022 12:14
-
-
Save Zegnat/116b29ed0ef0e3f346583c48388ecc41 to your computer and use it in GitHub Desktop.
IndieAuth Ticket Auth testing page, created July 2021
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php declare(strict_types = 1); | |
define('MINTOKEN_CURL_TIMEOUT', 4); | |
define('MINTOKEN_SQLITE_PATH', 'tokens.db'); // Created with schema.sql | |
// Support functions. Mostly stripped from other projects. | |
if (!file_exists(MINTOKEN_SQLITE_PATH)) { | |
header('HTTP/1.1 500 Internal Server Error'); | |
header('Content-Type: text/plain;charset=UTF-8'); | |
exit('The token endpoint is not ready for use.'); | |
} | |
function connectToDatabase(): PDO | |
{ | |
static $pdo; | |
if (!isset($pdo)) { | |
$pdo = new PDO('sqlite:' . MINTOKEN_SQLITE_PATH, null, null, [ | |
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, | |
PDO::ATTR_EMULATE_PREPARES => false, | |
]); | |
} | |
return $pdo; | |
} | |
function initCurl(string $url)/* : resource */ | |
{ | |
$curl = curl_init(); | |
curl_setopt($curl, CURLOPT_URL, $url); | |
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); | |
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); | |
curl_setopt($curl, CURLOPT_MAXREDIRS, 8); | |
curl_setopt($curl, CURLOPT_TIMEOUT_MS, round(MINTOKEN_CURL_TIMEOUT * 1000)); | |
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT_MS, 2000); | |
// curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2); | |
return $curl; | |
} | |
function discoverTicketEndpoint(string $url): ?string | |
{ | |
$curl = initCurl($url); | |
$headers = []; | |
$last = ''; | |
curl_setopt($curl, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$headers, &$last): int { | |
$url = curl_getinfo($curl, CURLINFO_EFFECTIVE_URL); | |
if ($url !== $last) { | |
$headers = []; | |
} | |
$len = strlen($header); | |
$header = explode(':', $header, 2); | |
if (count($header) === 2) { | |
$name = strtolower(trim($header[0])); | |
if (!array_key_exists($name, $headers)) { | |
$headers[$name] = [trim($header[1])]; | |
} else { | |
$headers[$name][] = trim($header[1]); | |
} | |
} | |
$last = $url; | |
return $len; | |
}); | |
$body = curl_exec($curl); | |
if (curl_getinfo($curl, CURLINFO_HTTP_CODE) !== 200 || curl_errno($curl) !== 0) { | |
return null; | |
} | |
curl_close($curl); | |
$endpoint = null; | |
if (array_key_exists('link', $headers)) { | |
foreach ($headers['link'] as $link) { | |
$found = preg_match('@^\s*<([^>]*)>\s*;(.*?;)?\srel="([^"]*?\s+)?ticket_endpoint(\s+[^"]*?)?"@', $link, $match); | |
if ($found === 1) { | |
$endpoint = $match[1]; | |
break; | |
} | |
} | |
} | |
if ($endpoint === null) { | |
libxml_use_internal_errors(true); | |
$dom = new DOMDocument(); | |
$dom->loadHTML(mb_convert_encoding($body, 'HTML-ENTITIES', 'UTF-8')); | |
$xpath = new DOMXPath($dom); | |
$nodes = $xpath->query('//*[contains(concat(" ", normalize-space(@rel), " "), " ticket_endpoint ") and @href][1]/@href'); | |
if ($nodes->length === 0) { | |
return null; | |
} | |
$endpoint = $nodes->item(0)->value; | |
$bases = $xpath->query('//base[@href][1]/@href'); | |
if ($bases->length !== 0) { | |
$last = resolveUrl($last, $bases->item(0)->value); | |
} | |
} | |
return resolveUrl($last, $endpoint); | |
} | |
function sendTicket(string $subject, string $endpoint): void { | |
// Create ticket | |
$ticket = bin2hex(random_bytes(64)); | |
$token = bin2hex(random_bytes(64)); | |
$db = connectToDatabase(); | |
$statement = $db->prepare('INSERT INTO tokens (ticket, token, subject, ticket_endpoint) VALUES (?, ?, ?, ?)'); | |
$statement->execute([$ticket, $token, $subject, $endpoint]); | |
// Post it | |
$curl = initCurl($endpoint); | |
curl_setopt($curl, CURLOPT_HTTPHEADER, array( | |
'Content-Type: application/x-www-form-urlencoded' | |
)); | |
curl_setopt($curl, CURLOPT_POST, true); | |
curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query([ | |
'ticket' => $ticket, | |
'resource' => 'https://vanderven.se/', | |
'subject' => $subject | |
])); | |
curl_exec($curl); | |
curl_close($curl); | |
} | |
function redeemTicket(string $ticket): ?array { | |
$db = connectToDatabase(); | |
$statement = $db->prepare('UPDATE tokens SET redeemed = CURRENT_TIMESTAMP WHERE redeemed IS NULL AND datetime(created, "+5 minutes") > CURRENT_TIMESTAMP AND ticket = ?'); | |
$statement->execute([$ticket]); | |
if ($statement->rowCount() === 0) { | |
return null; | |
} else { | |
return retrieveTicket($ticket); | |
} | |
} | |
function retrieveTicket(string $ticket): ?array { | |
$db = connectToDatabase(); | |
$statement = $db->prepare('SELECT *, strftime("%s", redeemed, "+3 day") - strftime("%s", CURRENT_TIMESTAMP) AS expires_in FROM tokens WHERE datetime(redeemed, "+3 days") > CURRENT_TIMESTAMP AND ticket = ?'); | |
$statement->execute([$ticket]); | |
$data = $statement->fetch(PDO::FETCH_ASSOC); | |
return false === $data ? null : $data; | |
} | |
function retrieveToken(string $token): ?array { | |
$db = connectToDatabase(); | |
$statement = $db->prepare('SELECT *, datetime(redeemed, "+3 days") > CURRENT_TIMESTAMP AS active FROM tokens WHERE token = ?'); | |
$statement->execute([$token]); | |
$data = $statement->fetch(PDO::FETCH_ASSOC); | |
return false === $data ? null : $data; | |
} | |
$method = filter_input(INPUT_SERVER, 'REQUEST_METHOD', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^[!#$%&\'*+.^_`|~0-9a-z-]+$@i']]); | |
if ($method === 'GET') { | |
$bearer_regexp = '@^Bearer [0-9a-f]+$@'; | |
$authorization = filter_input(INPUT_SERVER, 'HTTP_AUTHORIZATION', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]) | |
?? filter_input(INPUT_SERVER, 'REDIRECT_HTTP_AUTHORIZATION', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]); | |
if ($authorization === null && function_exists('apache_request_headers')) { | |
$headers = array_change_key_case(apache_request_headers(), CASE_LOWER); | |
if (isset($headers['authorization'])) { | |
$authorization = filter_var($headers['authorization'], FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => $bearer_regexp]]); | |
} | |
} | |
if ($authorization === false) { | |
header('HTTP/1.1 401 Unauthorized'); | |
header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is malformed"'); | |
exit(); | |
} elseif ($authorization !== null) { | |
$token = retrieveToken(substr($authorization, 7)); | |
if ($token === null) { | |
header('HTTP/1.1 401 Unauthorized'); | |
header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is unknown"'); | |
exit(); | |
} elseif ($token['active'] === '0') { | |
header('HTTP/1.1 401 Unauthorized'); | |
header('WWW-Authenticate: Bearer, error="invalid_token", error_description="The access token is revoked"'); | |
exit(); | |
} else { | |
header('HTTP/1.1 200 OK'); | |
header('Content-Type: application/json;charset=UTF-8'); | |
exit(json_encode([ | |
'me' => $token['subject'], | |
'client_id' => $token['ticket_endpoint'], | |
'scope' => '', | |
], JSON_PRETTY_PRINT)); | |
} | |
} | |
} elseif ('POST' === $method) { | |
$ticket = filter_input(INPUT_POST, 'action', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^ticket$@']]); | |
$exchange = filter_input(INPUT_POST, 'grant_type', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^ticket$@']]); | |
if (is_string($ticket)) { | |
// Someone is requesting a ticket. | |
$me = filter_input(INPUT_POST, 'me', FILTER_VALIDATE_URL); | |
if (is_string($me)) { | |
$ticketEndpoint = discoverTicketEndpoint($me); | |
if (is_string($ticketEndpoint)) { | |
sendTicket($me, $ticketEndpoint); | |
header('HTTP/1.1 302 Found'); | |
header('Location: https://vanderven.se/martijn/'); | |
exit(); | |
} else { | |
header('HTTP/1.1 400 Bad Request'); | |
exit('No ticket endpoint found.'); | |
} | |
} | |
} elseif (is_string($exchange)) { | |
// Someone is redeeming a ticket | |
$ticket = filter_input(INPUT_POST, 'ticket', FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '@^[0-9a-fA-F]+$@']]); | |
if (is_string($ticket)) { | |
if (null !== $token = redeemTicket($ticket)) { | |
header('Content-Type: application/json'); | |
exit(json_encode([ | |
'access_token' => $token['token'], | |
'token_type' => 'Bearer', | |
'me' => $token['subject'], | |
'expires_in' => $token['expires_in'], | |
], JSON_PRETTY_PRINT)); | |
} else { | |
header('HTTP/1.1 400 Bad Request'); | |
exit('No sorry.'); | |
} | |
} | |
} | |
} | |
/** | |
* The following wall of code is dangerous. There be dragons. | |
* Taken from the mf2-php project, which is pledged to the public domain under CC0. | |
*/ | |
function parseUriToComponents(string $uri): array | |
{ | |
$result = [ | |
'scheme' => null, | |
'authority' => null, | |
'path' => null, | |
'query' => null, | |
'fragment' => null, | |
]; | |
$u = @parse_url($uri); | |
if (array_key_exists('scheme', $u)) { | |
$result['scheme'] = $u['scheme']; | |
} | |
if (array_key_exists('host', $u)) { | |
if (array_key_exists('user', $u)) { | |
$result['authority'] = $u['user']; | |
} | |
if (array_key_exists('pass', $u)) { | |
$result['authority'] .= ':' . $u['pass']; | |
} | |
if (array_key_exists('user', $u) || array_key_exists('pass', $u)) { | |
$result['authority'] .= '@'; | |
} | |
$result['authority'] .= $u['host']; | |
if (array_key_exists('port', $u)) { | |
$result['authority'] .= ':' . $u['port']; | |
} | |
} | |
if (array_key_exists('path', $u)) { | |
$result['path'] = $u['path']; | |
} | |
if (array_key_exists('query', $u)) { | |
$result['query'] = $u['query']; | |
} | |
if (array_key_exists('fragment', $u)) { | |
$result['fragment'] = $u['fragment']; | |
} | |
return $result; | |
} | |
function resolveUrl(string $baseURI, string $referenceURI): string | |
{ | |
$target = [ | |
'scheme' => null, | |
'authority' => null, | |
'path' => null, | |
'query' => null, | |
'fragment' => null, | |
]; | |
$base = parseUriToComponents($baseURI); | |
if ($base['path'] == null) { | |
$base['path'] = '/'; | |
} | |
$reference = parseUriToComponents($referenceURI); | |
if ($reference['scheme']) { | |
$target['scheme'] = $reference['scheme']; | |
$target['authority'] = $reference['authority']; | |
$target['path'] = removeDotSegments($reference['path']); | |
$target['query'] = $reference['query']; | |
} else { | |
if ($reference['authority']) { | |
$target['authority'] = $reference['authority']; | |
$target['path'] = removeDotSegments($reference['path']); | |
$target['query'] = $reference['query']; | |
} else { | |
if ($reference['path'] == '') { | |
$target['path'] = $base['path']; | |
if ($reference['query']) { | |
$target['query'] = $reference['query']; | |
} else { | |
$target['query'] = $base['query']; | |
} | |
} else { | |
if (substr($reference['path'], 0, 1) == '/') { | |
$target['path'] = removeDotSegments($reference['path']); | |
} else { | |
$target['path'] = mergePaths($base, $reference); | |
$target['path'] = removeDotSegments($target['path']); | |
} | |
$target['query'] = $reference['query']; | |
} | |
$target['authority'] = $base['authority']; | |
} | |
$target['scheme'] = $base['scheme']; | |
} | |
$target['fragment'] = $reference['fragment']; | |
$result = ''; | |
if ($target['scheme']) { | |
$result .= $target['scheme'] . ':'; | |
} | |
if ($target['authority']) { | |
$result .= '//' . $target['authority']; | |
} | |
$result .= $target['path']; | |
if ($target['query']) { | |
$result .= '?' . $target['query']; | |
} | |
if ($target['fragment']) { | |
$result .= '#' . $target['fragment']; | |
} elseif ($referenceURI == '#') { | |
$result .= '#'; | |
} | |
return $result; | |
} | |
function mergePaths(array $base, array $reference): string | |
{ | |
if ($base['authority'] && $base['path'] == null) { | |
$merged = '/' . $reference['path']; | |
} else { | |
if (($pos=strrpos($base['path'], '/')) !== false) { | |
$merged = substr($base['path'], 0, $pos + 1) . $reference['path']; | |
} else { | |
$merged = $base['path']; | |
} | |
} | |
return $merged; | |
} | |
function removeLeadingDotSlash(string &$input): void | |
{ | |
if (substr($input, 0, 3) == '../') { | |
$input = substr($input, 3); | |
} elseif (substr($input, 0, 2) == './') { | |
$input = substr($input, 2); | |
} | |
} | |
function removeLeadingSlashDot(string &$input): void | |
{ | |
if (substr($input, 0, 3) == '/./') { | |
$input = '/' . substr($input, 3); | |
} else { | |
$input = '/' . substr($input, 2); | |
} | |
} | |
function removeOneDirLevel(string &$input, string &$output): void | |
{ | |
if (substr($input, 0, 4) == '/../') { | |
$input = '/' . substr($input, 4); | |
} else { | |
$input = '/' . substr($input, 3); | |
} | |
$output = substr($output, 0, strrpos($output, '/')); | |
} | |
function removeLoneDotDot(string &$input): void | |
{ | |
if ($input == '.') { | |
$input = substr($input, 1); | |
} else { | |
$input = substr($input, 2); | |
} | |
} | |
function moveOneSegmentFromInput(string &$input, string &$output): void | |
{ | |
if (substr($input, 0, 1) != '/') { | |
$pos = strpos($input, '/'); | |
} else { | |
$pos = strpos($input, '/', 1); | |
} | |
if ($pos === false) { | |
$output .= $input; | |
$input = ''; | |
} else { | |
$output .= substr($input, 0, $pos); | |
$input = substr($input, $pos); | |
} | |
} | |
function removeDotSegments(string $path): string | |
{ | |
$input = $path; | |
$output = ''; | |
$step = 0; | |
while ($input) { | |
$step++; | |
if (substr($input, 0, 3) == '../' || substr($input, 0, 2) == './') { | |
removeLeadingDotSlash($input); | |
} elseif (substr($input, 0, 3) == '/./' || $input == '/.') { | |
removeLeadingSlashDot($input); | |
} elseif (substr($input, 0, 4) == '/../' || $input == '/..') { | |
removeOneDirLevel($input, $output); | |
} elseif ($input == '.' || $input == '..') { | |
removeLoneDotDot($input); | |
} else { | |
moveOneSegmentFromInput($input, $output); | |
} | |
} | |
return $output; | |
} | |
?><!doctype html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1"> | |
<title>Request a Ticket</title> | |
<style> | |
html, body { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
color: #000; | |
background-color: #F0F0F0; | |
} | |
body { | |
font-size: 1.2rem; | |
max-width: 40rem; | |
padding: 4rem 2rem; | |
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; | |
line-height: 1.6; | |
} | |
body * { | |
box-sizing: inherit; | |
} | |
h1, p { | |
margin: 0 0 1rem; | |
} | |
form { | |
background-color: #FFF; | |
padding: 1rem; | |
margin: 0 -1rem 1rem; | |
} | |
form + p, form + p + p { | |
font-size: 1rem; | |
} | |
label { | |
font-weight: bold; | |
} | |
input, button { | |
display: block; | |
font-size: inherit; | |
font-family: inherit; | |
line-height: inherit; | |
} | |
input { | |
width: 100%; | |
} | |
</style> | |
</head> | |
<body> | |
<h1>Request a Ticket</h1> | |
<p>If you would like to let me know who you are when visiting my website, maybe to gain access to some more priviledged information than is shared publicly, you can do so by supplying an IndieAuth Acces Token in an <code>Authorization</code> HTTP header.</p> | |
<p>To get a token to send along with your request <a href="https://indieweb.org/IndieAuth_Ticket_Auth">the IndieAuth Ticket Auth extension</a> is used. The form on this page lets you request a ticket.</p> | |
<form method="post"> | |
<label for="me">Your homepage URL</label> | |
<input type="url" name="me" id="me" aria-describedby="info"> | |
<p id="info">The page must advertise an IndieAuth Ticket Auth endpoint to receive a ticket.</p> | |
<button type="submit" name="action" value="ticket">Request Ticket</button> | |
</form> | |
<p>Your ticket endpoint has 5 minutes from the time of request to redeem a bearer token with the provided ticket. A bearer token retrieved in this way only has a limited validity and expires in 3 days. Mostly because all of Ticket Auth is a bit of an experiment at the moment.</p> | |
<p>You can check if your token is still valid by sending <a href="https://indieauth.spec.indieweb.org/#access-token-verification-request">an IndieAuth Access Token Verification Request</a>. This is the only way, as sending an expired token with a normal web request is treated the same as not sending a token at all and will result in a normal valid page load.</p> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
CREATE TABLE tokens ( | |
ticket TEXT NOT NULL UNIQUE, | |
token TEXT UNIQUE, | |
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, | |
redeemed TIMESTAMP, | |
resource TEXT DEFAULT "https://vanderven.se/", | |
subject TEXT, | |
ticket_endpoint TEXT | |
); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment