#!/usr/bin/php-cgi -dcgi.force_redirect=0
<?php
# encoding: utf-8
# api: cgi
# type: output
# category: auth
# title: IndieAuth authorization_endpoint
# description: Verifies a remote user id (URL) against active fossil login
# version: 0.6
# state: beta
# depends: php:curl, php:sqlite
# doc: https://www.w3.org/TR/indieauth/#authentication,
# https://indieweb.org/authorization-endpoint
# config: -
# access: auth
#
# Minimal implementation of IndieAuth login endpoint. Runs as fossil cgi
# extension to verify currently logged in user. Confirms IndieAuth/OAuth
# request, and keeps login tokens in `fx_auth` table.
#
# This might progress into a /token endpoint, ideally enabling /micropub
# for the ticket system later on. Which is why the fx_auth/token table
# is somewhat of a blob collection in itself.
#
#
# ## SETUP
#
# So this requires installation in fossils extroot: (internal cgi scripts),
# and might not work in chroot jails (due to php-cgi shebang). Copy the script
# into ext/, preferrably without .php or .cgi extension, and chmod +x it.
#
# User accounts are required to list an url in `homepages` or `info` column.
# Each needs at least one, so requests can't be used to impersonate.
#
# Afterwards configure your homepage to allow its use as IndieAuth id:
# <link rel=authorization_endpoint href="http://fossil.domain/ext/auth">
#
#
# ## TOKEN
#
# The fx_auth table contains individual columns now to record previous
# authorization requests. Most of which is unnecessary to keep after the
# initial request. This is largely for debugging.
#
# The /token endpoint will use the same entries however, and upgrade from
# `code` to `token` on request. Which the /micropub handler then verifies.
#
#
# ## PROTOCOL
#
# Authorization request:
# ?me=https://user.example.org/
# &client_id=http://app.example.com/
# &redirect_uri=http://app.example.com/login/callback
# &state=1234567890
# &response_type=code
# &scope=profile+create+update+delete
# &code_challenge=Bse64x123..
# &code_challenge_method=S256
# Action:
# ยท verify user exists, is logged in, and me= parameter is whitelisted
# ยท confirm per button press, or direct unauthenticated user to /login page
# ยท generate an arbitrary code, record in fx_auth table/json blob
# Response:
# Location: https://app.example.com/login/callback?code=...&state=1234567890
#
# Verification request:
# ?code=$1y$.....
# &client_id=http://app.example.com/
# &redirect_uri=http://app.example.com/login/callback
# &code_verifier=raw-pre-sha256-cnt
# Response:
# { "me": "https://user.example.org/", "scope": "...", "access_token": "..." }
#
#
if ($_REQUEST["dbg"]) {
error_reporting(E_ALL); ini_set("display_errors", 1);
}
#-- database (== fossil repo)
function db($sql="", $params=[]) {
static $db;
if (empty($db)) {
$db = new PDO("sqlite:$_SERVER[FOSSIL_REPOSITORY]");
#$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_WARNING);
create_table();
}
if ($params) {
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
else {
return $db->query($sql);
}
}
function create_table() {
db("CREATE TABLE IF NOT EXISTS `fx_auth` ( -- IndieAuth token table
`code` TEXT, -- OAuth authorization code
`type` TEXT, -- one of id,code,token,revoked
`scope` TEXT, -- requested permissions (create,update,delete)
`caps` TEXT, -- fossil permissions (askmnw3C)
`login` TEXT, -- fossil user account
`me` TEXT, -- https://userwebid.example.org/
`client_id` TEXT, -- https://remoteapp.example.com/
`redirect_uri` TEXT, -- https://app/login/callback
`state` TEXT, -- remote session id (12345..)
`code_challenge` TEXT, -- pre-hashed secret for later token req
`code_challenge_m` TEXT,-- hash method for secret
`mtime` INT, -- token entry last modified
`expires` INT -- code valid until timestamp
)");
}
#-- fossil HTML output
function page_html($html) {
header("Content-Type: text/html; charset=utf-8");
$svg = <<<SVG
<svg style='float:left; margin-right: 30pt;' width="280" height="268" version="1.1" viewBox="0 0 56.048 53.779" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="linearGradient2066" x1="63.409" x2="36.085" y1="109.16" y2="82.295" gradientTransform="matrix(3.7795 0 0 3.7795 -57.385 -274.06)" gradientUnits="userSpaceOnUse">
<stop stop-color="#7aba0f" offset="0"/>
<stop stop-color="#bfe47f" offset="1"/>
</linearGradient>
<linearGradient id="linearGradient1185" x1="52.613" x2="47.211" y1="84.095" y2="93.583" gradientUnits="userSpaceOnUse">
<stop stop-color="#fff" stop-opacity=".9" offset="0"/>
<stop stop-color="#f9faf9" stop-opacity=".5" offset="1"/>
</linearGradient>
<filter id="filter1275" x="-.024063" y="-.023937" width="1.0481" height="1.0479" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="0.12728711"/>
</filter>
</defs>
<g transform="translate(-15.183 -72.511)">
<g transform="matrix(1.6139 0 0 1.5192 51.943 -52.074)">
<path d="m7.8389 85.485-3.8743 6.0476c-23.226-3.1157-33.318 9.3314-8.599 22.962-24.906-4.8059-27.229-30.998 12.473-29.01z" fill="#fe230f"/>
<path d="m4.7884 90.276-2.5066 4.8015c-21.825-2.5224-22.803 6.515-7.0408 19.345-14.144-5.3082-27.76-25.757 9.5474-24.147z" fill="#fe5d0f"/>
<path d="m2.399 94.78-2.1773 3.8912c-18.675-1.7607-14.894 3.7679-5.0765 15.772-7.8444-5.7192-23.168-22.385 7.2538-19.663z" fill="#fea70f"/>
</g>
<path transform="matrix(.26458 0 0 .26458 15.183 72.511)" d="m169.4 3.3359c-24.036 0.16796-48.072 0.33592-72.107 0.50391-9.4009 42.186-18.802 84.372-28.203 126.56 11.159 17.745 24.195 38.517 37.898 54.234 0.52981-18.818 1.06-37.635 1.5898-56.453 7.7311-3.2461 15.462-6.4922 23.193-9.7383 8.084 3.3861 16.168 6.7721 24.252 10.158-0.40041 22.532 2.4212 45.099 0.67383 67.592-3.1046 4.8346 4.9086 1.9182 7.7676 2.7148 14.439 0.00756 28.878 0.01783 43.316 0.02539-12.794-65.199-25.587-130.4-38.381-195.6zm-34.188 28.412c17.222 0.54857 30.629 19.534 24.859 35.994-4.6736 17.401-27.745 25.447-42.223 14.717-15.248-9.6074-16.115-34.025-1.584-44.688 4.8603-3.8773 11.053-6.0331 17.27-6.0176 0.56296-0.021376 1.1222-0.023555 1.6777-0.005859zm-75.895 142.51c-1.9066 8.556-3.8141 17.112-5.7207 25.668l52.961-0.01172c0.11707-4.1582 0.23449-8.3164 0.35157-12.475-15.189-3.0869-32.389-7.5965-47.592-13.182zm95.695 24.646c1.2368 0 0.37266 0 0 0z" fill="url(#linearGradient2066)" stroke="#4a5848" stroke-width="6.6709"/>
<ellipse cx="50.446" cy="88.237" rx="6.3477" ry="6.3811" fill="url(#linearGradient1185)" filter="url(#filter1275)" opacity=".9"/>
</g>
</svg>
<!--svg height=270 width=215 style='float:left; margin-right: 30pt;' viewBox='0 0 42.967861 53.77858'> <g transform='translate(-26.926707,-72.244048)' id='layer1'>
<path id='path828' d='m 58.667032,73.126526 c -6.35947,0.04444 -12.71895,0.08888 -19.078418,0.133327 -3.853683,17.29335 -7.707367,34.586687 -11.56105,51.880037 4.67086,-10e-4 9.341719,-0.002 14.012578,-0.003 0.17811,-6.32623 0.35623,-12.65246 0.53434,-18.97869 2.04552,-0.85886 4.09104,-1.71773 6.13657,-2.57659 2.13889,0.8959 4.27777,1.79179 6.41666,2.68769 -0.10594,5.96161 0.64059,11.93241 0.17825,17.88368 -0.82143,1.27915 1.29889,0.50748 2.05533,0.71827 3.82023,0.002 7.64045,0.005 11.46067,0.007 -3.38498,-17.25073 -6.76995,-34.501457 -10.15493,-51.752194 z m -9.48934,7.518922 c 4.76639,-0.180988 8.59732,5.026339 7.02166,9.521985 -1.23655,4.60395 -7.34109,6.7331 -11.17174,3.89414 -4.034474,-2.54196 -4.263284,-9.002574 -0.41875,-11.82358 1.28595,-1.025868 2.92405,-1.596645 4.56883,-1.592545 z m 5.68285,44.224172 c 0.32724,0 0.0986,0 0,0 z'
style='fill:#aeea47;fill-opacity:1;stroke:#4a5848;stroke-width:1.76499999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.99456518' />
</g></svg-->
SVG;
print("<div class='fossil-doc' data-title='IndieAuth'>\n$svg\n$html\n</div>");
}
function missing_param($name) {
die(page_html("<h2>Missing input</h2><p>URL lacks <code>&$name=</code> parameter.<p>Can't process as IndieAuth/OAuth request."));
}
function page_md($text) {
header("Content-Type: text/x-markdown; charset=utf-8");
print($text);
}
function h($s) {
return htmlspecialchars($s, ENT_QUOTES|ENT_HTML5, "UTF-8");
}
#-- test if http://identity/ is whitelisted in user.`homepage`/`info` column
function allowed_identity($user, $url, &$may_autologin) {
# search all fields for urls
preg_match_all(
"~\\b https?://(\S+) (?<![,;|<>'\"])~x",
join(", ", db("SELECT * FROM user WHERE login=?", [$user])[0]),
$allowed
);
$allowed = $allowed[1];
# if list contains "http://autologin/
if (in_array("autologin", $allowed)) {
$may_autologin = $allowed; # should contain callback urls
}
# but verify $me being whitelisted first
foreach ($allowed as $item) {
if (trim_url($item) == trim_url($url)) {
return True;
}
}
}
function trim_url($url) {
# Very crude URL equality test.
# Strip any http:// prefix, trailing slashes, or /home, /index (when referring to fossil instance).
return strtolower(preg_replace("~ ^https?:// | /+(home|index)$ | /+$ ~x", "", $url));
}
function base64_urlencode($raw) {
return strtr(trim(base64_encode($raw), "="), "+/", "-_");
}
#-- load authorization properties by auth code
function get_token_by_code($code) {
return db("SELECT * FROM fx_auth WHERE code=?", [$code]) ?: [[]];
}
function clean_expired_token() {
db("DELETE FROM fx_auth WHERE expires < ?", [time()]);
}
#-- <form> checkboxes for "scope" token request, extended default list and ?scope=โฆ requests
function scope_list($html="") {
$scopes = ["create"=>"checked", "update"=>"checked", "delete"=>"", "technote"=>"checked", "ticket"=>"checked", "wiki"=>"", "chat"=>""];
if (!empty($_REQUEST["scope"]) and preg_match_all("/(\w+)/", $_REQUEST["scope"], $uu)) {
foreach ($uu[1] as $field) { $scopes[$field] = "checked"; }
}
foreach ($scopes as $field=>$checked) {
$html .= "<li style='flex: 1 0 33%; list-style-type: none;'> <label><input type=checkbox $checked name='scope[]' value=$field> $field</label>\n";
}
return $html;
}
#-- initial authorization request
function process_request() {
# input params
$user = $_SERVER["FOSSIL_USER"];
$caps = preg_replace("/[^abcfhikkmnsw3C]/", "", $_SERVER["FOSSIL_CAPABILITIES"]);
$secret = $_SERVER["FOSSIL_NONCE"];
$me = $_REQUEST["me"] or missing_param("me");
$client_id = $_REQUEST["client_id"] or missing_param("client_id");
$redirect_uri = $_REQUEST["redirect_uri"] or missing_param("redirect_uri");
$state = $_REQUEST["state"] ?: "";
$code_challenge = $_REQUEST["code_challenge"] ?: "";
$code_challenge_m = $_REQUEST["code_challenge_method"] ?: "S256";
$response_type = $_REQUEST["response_type"] ?: "id";
$h = "h";
# check if $me is allowed
if (!allowed_identity($user, $me, $may_autologin)) {
return page_html("<h2>Invalid identity</h2> User doesn't have '{$h($me)}' reserved in user table.");
}
# new code // hashing the properties is a bit overkill, any random id would suffice
$code = password_hash("$me/$client_id/$state/$secret", PASSWORD_DEFAULT);
db("
INSERT INTO fx_auth
(`code`, `type`, `caps`, `login`, `me`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_m`, `mtime`, `expires`)
VALUES
( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[$code, $response_type, $caps, $user, $me, $client_id, $redirect_uri, $state, $code_challenge, $code_challenge_m, time(), time()+300]
);
# construct confirmation+redirect url
$url= $redirect_uri
. (strstr($redirect_uri, "?") ? "&" : "?")
. "code=" . urlencode($code) . "&state=" . urlencode($state);
# autologin (meant for upstream ticket bridges, requires both identity and callback urls to be whitelisted)
if ($may_autologin) {
if (in_array(trim_url($redirect_uri), $may_autologin)) {
file_get_contents($url);
die(header("Location: $url"));
}
}
# output page
if ($response_type == "code") {
$scope = scope_list();
}
$html = <<<HTML
<h2>Login request</h2>
<!-- IndieAuth -->
<p><b>{$h($client_id)}</b> has requested login verification for <em>{$h($me)}</em>.</p>
<form action='$_SERVER[PHP_SELF]' method=POST>
<ul style="display: flex; flex-wrap: wrap">
$scope
</ul>
<p>
<input type=hidden name=code value='{$h($code)}'>
<input type=hidden name=redirect_target value='{$h($url)}'>
<input type=submit name=confirm value=Confirm style='border-radius: 5pt; padding: 5pt 25pt; text-shadow: 1pt;'>
</p>
</form>
HTML;
page_html($html);
}
#-- confirm button pressed, do the actual redirect
function confirm() {
if (!empty($_POST["scope"])) {
db("UPDATE fx_auth SET scope=? WHERE code=?", [ implode(" ", $_POST["scope"]), $_POST["code"] ]);
}
die(header("Location: $_POST[redirect_target]"));
}
#-- send ?code= verification response
function verify_code() {
header("Content-Type: application/json");
# input
$grant_type = $_REQUEST["grant_type"];
$code = $_REQUEST["code"];
$client_id = $_REQUEST["client_id"];
$redirect_uri = $_REQUEST["redirect_uri"];
$code_verifier = $_REQUEST["code_verifier"];
# find token
clean_expired_token();
$token = get_token_by_code($code)[0];
$code_challenge = $token["code_challenge"];
# check params
if (empty($code) or empty($client_id) or empty($redirect_uri)) {
$ls = join(", ", array_diff(["code", "client_id", "redirect_uri"], array_keys($_REQUEST)));
die(json_encode(["error" => "invalid_request", "error_description" => "missing parameters ($ls)"]));
}
elseif (empty($token)) {
die(json_encode(["error" => "access_denied", "error_description" => "code '$code' does not exist (possibly expired)"]));
}
elseif (($token["client_id"] != $client_id) or ($token["redirect_uri"] != $redirect_uri)) {
die(json_encode(["error" => "invalid_scope", "error_description" => "code does not match previous params (client_id, redirect_uri)"]));
}
elseif ($code_challenge and base64_urlencode(hash("sha256", $code_verifier, 1)) != $code_challenge) {
die(json_encode(["error" => "unauthorized_client", "error_description" => "code_challenge does not match code_verifier"]));
}
else {
# approved
die(json_encode(["me" => $token["me"], "scope" => $token["scope"], "access_token" => $code]));
}
}
#-- run
if (empty($_POST["redirect_target"]) and !empty($_REQUEST["code"])) { # ?code=โฆ when the remote app verifies the response
verify_code();
}
elseif (empty($_SERVER["FOSSIL_USER"])) { # user must be signed in at this point
page_html("<h2>Not logged in</h2>\n Request can't be authorized, unless you're <a href='../login'>logged in</a>.");
}
elseif (!empty($_REQUEST["me"])) { # ?me=โฆ starts an authorization request
process_request();
}
elseif (!empty($_POST["confirm"])) { # ?redirect_target=โฆ for confirmation button
confirm();
}
elseif ($_POST["reset_db"] and strstr($_SERVER["FOSSIL_CAPABILITIES"], "s")) {
#db("DROP TABLE fx_auth;");
}
else {
db("SELECT 1");
page_html("
<h3>Authorization endpoint</h3>
There was no ?code= or ?me= parameter,<br> so not an actual Indie/OAuth request.
<ul>
<li>The <code>fx_auth</code> table is now configured.
<li>Users still need to register a web address in the info/homepage field. (See <a href='user_config'>ext/user_config</a>.)
<li>And then declare this endpoint on their personal homepage:<br>
<code><link rel=authorization_endpoint href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'></code>
</ul>
");
if (strstr($_SERVER["FOSSIL_CAPABILITIES"], "s")) {
#print "<form action='' method=POST><input type=submit name=reset_db value='reset fx_auth table'></form>";
}
}
?>