#!/usr/bin/php-cgi -dcgi.force_redirect=0
<?php
# encoding: utf-8
# api: cgi
# type: auth
# title: IndieAuth authorization_endpoint
# description: Verifies a remote user id (URL) against active fossil login
# version: 0.5
# state: alpha
# depends: php:curl, php:sqlite
# doc: https://www.w3.org/TR/indieauth/#authentication,
# https://indieweb.org/authorization-endpoint
# config: -
#
# 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.
#
# If invoked without any parameters, this fossil-cgi script will initialize
# the `fx_auth` database table.
#
# User accounts are required to list homepages in the `info` column. Each
# needs at least one, so requests can't be used to approve arbitrary urls.
#
# Afterwards configure your homepage to allow its use as IndieAuth id:
# <link rel=authorization_endpoint href="http://fossil.domain/ext/auth.cgi">
#
#
# ## 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. If /token support gets implemented, it might either
# create distinct code entries, or just add "scope": etc. to the existing
# entries - and reuse the auth keys as OAuth Bearer token.
#
#
# ## 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
# Response:
# { "me": "https://user.example.org/" }
#
#
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);
}
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, -- permissions (create,update,delete)
`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
`expires` INT -- code valid until timestamp
)");
}
#-- fossil HTML output
function page_html($html) {
header("Content-Type: text/html; encoding=utf-8");
$svg = <<<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; encoding=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) {
$all = db("SELECT * FROM user WHERE login=?", [$user]);
preg_match_all("~\\b https?://(\S+) (?<![,;|<>'\"])~x", join(", ", $all[0]), $uu); # search all fields for urls
foreach ($uu[1] 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"=>"", "ticket"=>"checked", "wiki"=>"", "forum"=>""];
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"];
$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)) {
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`, `login`, `me`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_m`, `expires`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[$code, $response_type, $user, $me, $client_id, $redirect_uri, $state, $code_challenge, $code_challenge_m, time()+300]
);
# construct confirmation+redirect url
$url = $redirect_uri
. (strstr($redirect_uri, "?") ? "&" : "?")
. "code=" . urlencode($code) . "&state=" . urlencode($state);
# 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"]]));
}
}
#-- 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();
}
else {
create_table();
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 add their homepage in the contact/`info` 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>
");
}
?>