From 06b5c753a2674c65e80d4a266835fac2f090f8b1 Mon Sep 17 00:00:00 2001 From: Eddie Kohler Date: Thu, 18 Jan 2024 23:01:37 -0500 Subject: [PATCH] Add capability to be an OAuth *provider* as well as client. OAuth providers rely on $Opt["oAuthIssuer"], and on a preconfigured list of acceptable clients in $Opt["oAuthClients"]. The authorization landing page is kind of wonky still. --- etc/apifunctions.json | 4 + etc/pages.json | 10 ++ lib/jwtparser.php | 16 +- src/conference.php | 2 +- src/helpers.php | 15 +- src/pages/p_authorize.php | 367 ++++++++++++++++++++++++++++++++++++++ src/tokeninfo.php | 1 + 7 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 src/pages/p_authorize.php diff --git a/etc/apifunctions.json b/etc/apifunctions.json index 6aa1076226..40db7e377d 100644 --- a/etc/apifunctions.json +++ b/etc/apifunctions.json @@ -117,6 +117,10 @@ "name": "mentioncompletion", "get": true, "post": true, "function": "Completion_API::mentioncompletion_api" }, + { + "name": "oauthtoken", "post": true, "allow_disabled": true, "check_token": false, + "function": "Authorize_Page::oauthtoken_api" + }, { "name": "paper", "get": true, "function": "Paper_API::run" diff --git a/etc/pages.json b/etc/pages.json index d037cbf48f..e187aeba82 100644 --- a/etc/pages.json +++ b/etc/pages.json @@ -211,6 +211,16 @@ [ "graph/reviewerlameness", false, "graph/procrastination" ], + { "name": "authorize", "print_function": "*Authorize_Page::go", "allow_disabled": true }, + [ "authorize/form/title", 1, "*Authorize_Page::print_form_title" ], + [ "authorize/form/description", 10, "*Authorize_Page::print_form_description" ], + [ "authorize/form/active", 15, "*Authorize_Page::print_form_active" ], + [ "authorize/form/email", 20, "signin/form/email" ], + [ "authorize/form/password", 30, "signin/form/password" ], + [ "authorize/form/actions", 100, "*Authorize_Page::print_form_actions" ], + [ "authorize/form/oauth", 1000, "Signin_Page::print_signin_form_oauth" ], + + { "name": "api", "print_function": "API_Page::go", "allow_disabled": true }, { "name": "assign", "print_function": "Assign_Page::go" }, { "name": "autoassign", "print_function": "Autoassign_Page::go" }, diff --git a/lib/jwtparser.php b/lib/jwtparser.php index fa845e9a0e..bbb0457584 100644 --- a/lib/jwtparser.php +++ b/lib/jwtparser.php @@ -326,6 +326,20 @@ function validate_id_token($payload, $authi, $level = 1) { * @return string */ static function make_plaintext($payload) { $jose = '{"alg":"none","typ":"JWT"}'; - return base64url_encode($jose) . "." . base64url_encode(json_encode_db($payload)) . "."; + $payload = json_encode_db($payload); + return base64url_encode($jose) . "." . base64url_encode($payload) . "."; + } + + /** @param object $payload + * @param string $key + * @param 'HS256'|'HS384'|'HS512' $alg + * @return string */ + static function make_mac($payload, $key, $alg = "HS256") { + assert(isset(self::$hash_alg_map[$alg])); + $jose = '{"alg":"' . $alg . '","typ":"JWT"}'; + $payload = json_encode_db($payload); + $s = base64url_encode($jose) . "." . base64url_encode($payload); + $signature = hash_hmac(self::$hash_alg_map[$alg], $s, $key); + return $s . "." . base64url_encode($signature); } } diff --git a/src/conference.php b/src/conference.php index 36fb0aae6e..9a04987c8f 100644 --- a/src/conference.php +++ b/src/conference.php @@ -5354,7 +5354,7 @@ function call_api_on($uf, $fn, Contact $user, Qrequest $qreq, $prow) { && $method !== "OPTIONS" && !$qreq->valid_token() && (!$uf || ($uf->post ?? false)) - && (!$uf || !($uf->allow_xss ?? false))) { + && (!$uf || ($uf->check_token ?? null) !== false)) { return JsonResult::make_error(403, "<0>Missing credentials"); } else if ($user->is_disabled() && (!$uf || !($uf->allow_disabled ?? false))) { diff --git a/src/helpers.php b/src/helpers.php index 624f03aff8..970d32bac4 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -106,6 +106,8 @@ class JsonResult implements JsonSerializable, ArrayAccess { public $content; /** @var bool */ public $pretty_print; + /** @var bool */ + public $minimal = false; /** @param int|array|\stdClass|\JsonSerializable $a1 * @param ?array $a2 */ @@ -137,6 +139,17 @@ function __construct($a1, $a2 = null) { } } + /** @param int $status + * @param array $content + * @return JsonResult */ + static function make_minimal($status, $content) { + $jr = new JsonResult(null); + $jr->status = $status; + $jr->content = $content; + $jr->minimal = true; + return $jr; + } + /** @param int $status * @param string $ftext * @return JsonResult */ @@ -222,7 +235,7 @@ function offsetUnset($offset) { /** @param ?bool $validated */ function emit($validated = null) { - if ($this->status) { + if ($this->status && !$this->minimal) { if (!isset($this->content["ok"])) { $this->content["ok"] = $this->status <= 299; } diff --git a/src/pages/p_authorize.php b/src/pages/p_authorize.php new file mode 100644 index 0000000000..66725e7b5a --- /dev/null +++ b/src/pages/p_authorize.php @@ -0,0 +1,367 @@ + */ + public $redirect_uri = []; + + /** @var ?string */ + public $nonce; + + public $require; + + /** @param object $x + * @return ?OAuthClient */ + static function make($x) { + $oac = new OAuthClient; + $oac->name = $x->name ?? null; + $oac->title = $x->title ?? null; + $oac->client_id = $x->client_id ?? null; + $oac->client_secret = $x->client_secret ?? null; + if (isset($x->redirect_uri)) { + if (is_string($x->redirect_uri)) { + $oac->redirect_uri[] = $x->redirect_uri; + } else if (is_list($x->redirect_uri)) { + $oac->redirect_uri = $x->redirect_uri; + } + $n = count($oac->redirect_uri); + for ($i = 0; $i !== $n; ) { + $s = $oac->redirect_uri[$i]; + if (is_string($s) + && str_starts_with($s, "https://") + && strpos($s, "#") === false) { + ++$i; + } else { + array_splice($oac->redirect_uri, $i, 1); + --$n; + } + } + } + if (!is_string($oac->client_id) + || empty($oac->redirect_uri)) { + return null; + } + return $oac; + } +} + +class Authorize_Page { + /** @var Conf */ + public $conf; + /** @var Contact */ + public $viewer; + /** @var Qrequest */ + public $qreq; + /** @var ComponentSet */ + public $cs; + /** @var array */ + private $clients = []; + /** @var TokenInfo */ + private $token; + + function __construct(Contact $viewer, Qrequest $qreq, ComponentSet $cs = null) { + $this->conf = $viewer->conf; + $this->viewer = $viewer; + $this->qreq = $qreq; + $this->cs = $cs; + $this->clients = $this->conf->_xtbuild_resolve([], "oAuthClients"); + } + + /** @return ?OAuthClient */ + private function find_client($client_id) { + foreach ($this->clients as $x) { + if (($x->client_id ?? null) === $client_id) + return OAuthClient::make($x); + } + return null; + } + + /** @param array $param + * @return string */ + private function extend_redirect_uri($param) { + $uri = $this->qreq->redirect_uri; + if (($hash = strpos($uri, "#")) !== false) { + $uri = substr($uri, 0, $hash); + } + if (strpos($uri, "?") === false) { + $uri .= "?"; + } else if (!str_ends_with($uri, "&") && !str_ends_with($uri, "?")) { + $uri .= "&"; + } + return $uri . http_build_query($param); + } + + private function handle_request(OAuthClient $client) { + $scope = trim($this->qreq->scope ?? ""); + if (!preg_match('/\A[ !#-\x5b\x5d-\x7e]+\z/', $scope)) { + $this->redirect_error("invalid_scope"); + } + $scope_list = explode(" ", $scope); + if (!in_array("openid", $scope_list)) { + $this->redirect_error("invalid_scope", "Scope `openid` required"); + } + + if ($this->qreq->response_type !== "code") { + $this->redirect_error("unsupported_response_type", "Response type `code` required"); + } + + if ($this->qreq->request !== null) { + $this->redirect_error("request_not_supported"); + } else if ($this->qreq->request_uri !== null) { + $this->redirect_error("request_uri_not_supported"); + } else if ($this->qreq->registration !== null) { + $this->redirect_error("registration_not_supported"); + } + + if (isset($this->qreq->prompt)) { + $prompts = []; + foreach (explode(" ", trim($this->qreq->prompt)) as $p) { + if ($p !== "") + $prompts[] = $p; + } + if (in_array("none", $prompts)) { + $this->redirect_error("interaction_required"); + } + } + + // XXX max_age + // XXX prompt login vs. select_account vs. consent + // XXX record consent for future use? + + $this->token = (new TokenInfo($this->conf, TokenInfo::OAUTHCODE)) + ->set_token_pattern("hcop[36]") + ->set_invalid_after(3600) + ->set_expires_after(86400) + ->change_data("state", $this->qreq->state) + ->change_data("nonce", $this->qreq->nonce) + ->change_data("client_id", $client->client_id) + ->change_data("redirect_uri", $this->qreq->redirect_uri); + $this->token->create(); + + $this->qreq->print_header("Sign in", "authorize", ["action_bar" => "", "hide_title" => true, "body_class" => "body-signin"]); + Signin_Page::print_form_start_for($this->qreq, "=signin"); + $nav = $this->qreq->navigation(); + echo Ht::hidden("redirect", "authorize{$nav->php_suffix}?code=" . urlencode($this->token->salt) . "&authconfirm=1"); + $this->cs->print_members("authorize/form"); + echo ''; + $this->qreq->print_footer(); + } + + function print_form_title() { + echo '

Sign in

'; + } + + function print_form_description() { + + } + + function print_form_active() { + $buttons = []; + $nav = $this->qreq->navigation(); + foreach (Contact::session_users($this->qreq) as $i => $email) { + if ($email === "") { + continue; + } + $url = $nav->base_absolute() . "u/{$i}/authorize{$nav->php_suffix}?code=" . urlencode($this->token->salt) . "&authconfirm=1"; + $buttons[] = Ht::button("Sign in as " . htmlspecialchars($email), ["type" => "submit", "formaction" => $url, "formmethod" => "post", "class" => "mt-2 w-100 flex-grow-1"]); + } + if (!empty($buttons)) { + echo '
', join("", $buttons), '
'; + } + } + + function print_form_actions() { + if (($lt = $this->conf->login_type()) === "none" || $lt === "oauth") { + return; + } + echo ''; + } + + private function handle_authconfirm() { + if (!$this->qreq->code + || !($tok = TokenInfo::find_active($this->qreq->code, TokenInfo::OAUTHCODE, $this->conf)) + || !($client = $this->find_client($tok->data("client_id")))) { + $this->print_error_exit("<0>Invalid or expired authentication request"); + } + '@phan-var-force OAuthClient $client'; + + if ($tok->data("cancelled") + || ($this->qreq->cancel && $tok->data("id_token") === null)) { + $tok->change_data("cancelled", true)->update(); + $this->redirect_error("access_denied"); + } + + if (!$tok->data("id_token")) { + $this->make_authconfirm_jwt($client, $tok); + } + + $this->qreq->redirect_uri = $tok->data("redirect_uri"); + throw new Redirection($this->extend_redirect_uri([ + "code" => $tok->salt, "state" => $tok->data("state") + ])); + } + + private function make_authconfirm_jwt(OAuthClient $client, TokenInfo $tok) { + if (!$this->viewer->has_email() + || $this->viewer->is_actas_user() + || $this->viewer->is_bearer_authorized()) { + $this->print_error_exit("<0>Authentication request failed"); + } + + $payload = [ + "iss" => $this->conf->opt("oAuthIssuer"), + "aud" => $client->client_id, + "exp" => $tok->timeInvalid, + "iat" => Conf::$now + ]; + if (($nonce = $tok->data("nonce")) !== null) { + $payload["nonce"] = $nonce; + } + $payload["email"] = $this->viewer->email; + $payload["email_verified"] = true; // XXX special users? + $payload["given_name"] = $this->viewer->firstName; + $payload["family_name"] = $this->viewer->lastName; + + $jwt = JWTParser::make_mac((object) $payload, $client->client_secret); + + $tok->change_data("id_token", $jwt) + ->set_invalid_after(10 * 60) + ->update(); + } + + /** @param string $error + * @param ?string $error_description + * @return never */ + private function redirect_error($error, $error_description = null) { + $p = ["error" => $error]; + if ($error_description !== null) { + $p["error_description"] = $error_description; + } + if (isset($this->qreq->state)) { + $p["state"] = $this->qreq->state; + } + throw new Redirection($this->extend_redirect_uri($p)); + } + + /** @param string $m + * @return never */ + private function print_error_exit($m) { + if (http_response_code() === 200) { + http_response_code(400); + } + $this->qreq->print_header("Sign in", "authorize", ["action_bar" => "", "body_class" => "body-error"]); + $this->conf->error_msg($m); + $this->qreq->print_footer(); + exit; + } + + function go() { + // handle internal action + if ($this->qreq->authconfirm) { + $this->handle_authconfirm(); + } + + // look up client + if (empty($this->clients) || $this->conf->opt("oAuthIssuer") === null) { + $this->print_error_exit("<0>This site does not support authorization clients"); + } else if (!isset($this->qreq->client_id)) { + $this->print_error_exit("<0>Authorization client missing"); + } + $client = $this->find_client($this->qreq->client_id); + if (!$client) { + http_response_code(404); + $this->print_error_exit("<0>Authorization client not found"); + } + + // `redirect_uri` must be present and match a configured value + if (!isset($this->qreq->redirect_uri)) { + $this->print_error_exit("<0>Authorization parameter redirect_uri missing"); + } else if (!in_array($this->qreq->redirect_uri, $client->redirect_uri)) { + $this->print_error_exit("<0>Invalid authorization parameter redirect_uri"); + } + + // From here on, all errors should be sent to `redirect_uri`. + + if ($this->conf->external_login() + || ((($lt = $this->conf->login_type()) === "none" || $lt === "oauth") + && !$this->conf->oauth_providers())) { + $this->redirect_error("unauthorized_client", "This site does not support authorization clients"); + } else if (!isset($this->qreq->state) + || $this->qreq->response_type !== "code") { + $this->redirect_error("invalid_request"); + } else { + $this->handle_request($client); + } + } + + static function oauthtoken_api(Contact $user, Qrequest $qreq) { + return (new Authorize_Page($user, $qreq))->handle_oauthtoken_api(); + } + + private function handle_oauthtoken_api() { + // reject if not oAuthIssuer + if (($issuer = $this->conf->opt("oAuthIssuer")) === null) { + return JsonResult::make_minimal(400, ["error" => "invalid_request"]); + } + + // look up client + $clids = $clsecrets = []; + $clauth = false; + if (($auth = $this->qreq->header("Authorization"))) { + if (preg_match('/\A\s*Basic\s+(\S+)\s*\z/i', $auth, $m) + && ($d = base64_decode($m[1], true)) !== false + && ($p = strpos($d, ":")) !== false) { + $clauth = true; + $clids[] = substr($d, 0, $p); + $clsecrets[] = substr($d, $p + 1); + } else { + return JsonResult::make_minimal(400, ["error" => "invalid_request"]); + } + } + if (isset($this->qreq->client_id)) { + $clids[] = $this->qreq->client_id; + $clsecrets[] = $this->qreq->client_secret ?? ""; + } + if (count($clids) !== 1) { + return JsonResult::make_minimal(400, ["error" => "invalid_request"]); + } + + if (!($client = $this->find_client($clids[0])) + || ($client->client_secret ?? "") !== $clsecrets[0]) { + header("WWW-Authenticate: Basic realm=\"{$issuer}\""); + return JsonResult::make_minimal(401, ["error" => "invalid_client"]); + } + + // look up code + if (!$this->qreq->code + || !($tok = TokenInfo::find_active($this->qreq->code, TokenInfo::OAUTHCODE, $this->conf)) + || !$tok->data("id_token") + || $tok->data("client_id") !== $this->qreq->client_id + || $tok->data("redirect_uri") !== $this->qreq->redirect_uri) { + return JsonResult::make_minimal(400, ["error" => "invalid_grant"]); + } + + // check grant type + if ($this->qreq->grant_type !== "authorization_code") { + return JsonResult::make_minimal(400, ["error" => "unsupported_grant_type"]); + } + + // return code + header("Cache-Control: no-store"); + return JsonResult::make_minimal(200, ["id_token" => $tok->data("id_token")]); + } +} diff --git a/src/tokeninfo.php b/src/tokeninfo.php index 61d8a1df91..13e61f779b 100644 --- a/src/tokeninfo.php +++ b/src/tokeninfo.php @@ -63,6 +63,7 @@ class TokenInfo { const OAUTHSIGNIN = 6; const BEARER = 7; const JOB = 8; + const OAUTHCODE = 9; const CHF_TIMES = 1; const CHF_DATA = 2;