Collection of themes/skins for the Fossil SCM

โŒˆโŒ‹ โŽ‡ branch:  Fossil Skins Extra


Artifact [8f16498290]

Artifact 8f16498290933b7eab19b3c9e22ea12927c0b276:

  • Executable file extroot/auth — part of check-in [54a2eae9c2] at 2021-04-10 22:45:09 on branch trunk — Add caps and mtime columns, some doc fixes. (user: mario size: 16284)

#!/usr/bin/php-cgi -dcgi.force_redirect=0
<?php
# encoding: utf-8
# api: cgi
# type: form
# 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: -
#
# 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();
}
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>&lt;link rel=authorization_endpoint href='https://$_SERVER[SERVER_NAME]$_SERVER[PHP_SELF]'&gt;</code>
       </ul>
    ");
}


?>