#!/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>&lt;link rel=authorization_endpoint href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'&gt;</code>
       </ul>
    ");
}


?>