diff --git a/.gitignore b/.gitignore index 95246b0..56cb570 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -ProcessIndieAuth/vendor/mf2/mf2/tests/ +vendor/mf2/mf2/tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c4523d..44947af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added missing token-revocation-endpoint template +- Added `profile_name` and `profile_photo_url` fields to user template and editable profile +- Added support for clients requesting profile information ## [0.2.0] - 2022-07-04 ### Changed diff --git a/ProcessIndieAuth.module.php b/ProcessIndieAuth.module.php index a084c6d..09cbaeb 100644 --- a/ProcessIndieAuth.module.php +++ b/ProcessIndieAuth.module.php @@ -31,7 +31,7 @@ public static function getModuleInfo(): array { return [ 'title' => 'IndieAuth', - 'version' => '020', + 'version' => '021', 'author' => 'gRegor Morrill, https://gregorlove.com/', 'summary' => 'Use your domain name as an IndieAuth provider', 'href' => 'https://indieauth.com/', @@ -88,6 +88,11 @@ public function ___install(): void $templates = file_get_contents(__DIR__ . '/data/templates.json'); $this->importTemplates($templates); + $fields = file_get_contents(__DIR__ . '/data/fields.json'); + $this->importFields($fields); + + $this->addProfileFields(); + # attempt to set up the indieauth-metadata page $endpoint = $this->pages->get('template=indieauth-metadata-endpoint'); if ($endpoint instanceof NullPage) { @@ -145,7 +150,7 @@ public function ___install(): void $this->regenerateTokenSecret(); - $this->message('To complete installation, ensure the template files indieauth-metadata-endpoint.php, authorization-endpoint.php, and token-endpoint.php are put in the /site/templates/ directory.'); + $this->message('To complete installation, please follow the Setup directions in the readme file.'); } public function ___uninstall(): void @@ -180,6 +185,15 @@ public function ___uninstall(): void } } + # attempt to un-publish the token-revocation-endpoint page + $endpoint = $this->pages->get('template=token-revocation-endpoint'); + if (!($endpoint instanceof NullPage)) { + $endpoint->addStatus(Page::statusUnpublished); + if ($endpoint->save()) { + $this->message(sprintf('Unpublished page: %s', $endpoint->url)); + } + } + $this->uninstallPage(); } @@ -297,7 +311,7 @@ public function ___execute(): array public function ___executeTokenSecret() { $this->regenerateTokenSecret(); - $this->session->redirect($this->page->url, false); + $this->session->redirect($this->page->url, 302); } public function ___executeAuthorization(): array @@ -308,7 +322,12 @@ public function ___executeAuthorization(): array # missing part of the IndieAuth session if (!($request && $client)) { - $this->session->redirect($this->wire('config')->urls->admin, false); + $this->session->redirect($this->wire('config')->urls->admin, 302); + } + + if (!$this->user->hasRole('indieauth')) { + $this->message('Sorry, your account does not have the IndieAuth access role on this site.'); + $this->session->redirect($this->config->urls->admin, 302); } if ($input->requestMethod('GET')) { @@ -325,15 +344,7 @@ public function ___executeAuthorization(): array ); } - $scopes = []; - if (array_key_exists('scope', $request)) { - $scopes = array_filter( - array_map( - 'trim', - explode(' ', $request['scope']) - ) - ); - } + $scopes = $this->spaceSeparatedToArray($request['scope'] ?? ''); if (!$scopes) { $this->headline('Authenticate'); @@ -370,8 +381,7 @@ public function ___executeAuthorization(): array 'iss' => $this->urls->httpRoot, ]); - $this->session->removeAllFor('IndieAuth'); - $this->session->redirect($url, false); + $this->session->redirect($url, 302); } } @@ -427,7 +437,7 @@ public function ___executeRevoke(): array } $this->message('Token has been revoked'); - $this->session->redirect($this->page->url, false); + $this->session->redirect($this->page->url, 302); } } @@ -578,15 +588,16 @@ public function authorizationEndpoint(): void $this->session->setFor('IndieAuth', 'request', $request); $this->session->setFor('IndieAuth', 'client', $client); + $this->session->setFor('IndieAuth', 'user_id', $this->user->id); if ($user->isLoggedIn()) { $moduleID = $this->modules->getModuleID($this); $admin = $this->pages->get('process=' . $moduleID); - $this->session->redirect($admin->url . 'authorization', false); + $this->session->redirect($admin->url . 'authorization', 302); } # redirct to ProcessWire login - $this->session->redirect($this->wire('config')->urls->admin, false); + $this->session->redirect($this->wire('config')->urls->admin, 302); } elseif ($input->requestMethod('POST')) { $request = $input->post()->getArray(); @@ -797,6 +808,8 @@ private function redeemAuthorizationCode(array $request): void $client_id = $decoded['client_id'] ?? ''; $scope = $decoded['scope'] ?? ''; + $scopes = $this->spaceSeparatedToArray($decoded['scope'] ?? ''); + # authorization_code has scope(s), generate an access token if ($scope) { $token_data = $this->addToken($decoded); @@ -815,6 +828,11 @@ private function redeemAuthorizationCode(array $request): void $token_data ); + if (in_array('profile', $scopes)) { + $response = $this->addProfileToResponse($response); + } + + $this->session->removeAllFor('IndieAuth'); $this->log->save('indieauth', sprintf('Granted access token to %s with scope "%s"', $client_id, $scope)); $this->httpResponse($response, 200); } @@ -822,6 +840,12 @@ private function redeemAuthorizationCode(array $request): void # authentication-only response $me = $this->wire('urls')->httpRoot; $response = compact('me'); + + if (in_array('profile', $scopes)) { + $response = $this->addProfileToResponse($response); + } + + $this->session->removeAllFor('IndieAuth'); $this->log->save('indieauth', sprintf('Signed in to %s as %s', $request['client_id'], $me)); $this->httpResponse($response, 200); } @@ -970,6 +994,10 @@ private function verifyCode(array $request): array } } + # canonize URLs + $request['client_id'] = Server::canonizeUrl($request['client_id']); + $request['redirect_uri'] = Server::canonizeUrl($request['redirect_uri']); + # verify client_id $original_client_id = $decoded['client_id'] ?? ''; if ($request['client_id'] !== $original_client_id) { @@ -1007,7 +1035,7 @@ protected function loginSuccess(HookEvent $event): void $moduleID = $this->modules->getModuleID($this); $admin = $this->pages->get('process=' . $moduleID); - $this->session->redirect($admin->url . 'authorization', false); + $this->session->redirect($admin->url . 'authorization', 302); } } @@ -1370,13 +1398,43 @@ private function cancelRequest(): void 'error_description' => 'authorization request cancelled by the user', 'state' => $request['state'], ]); - $this->session->redirect($url, false); + $this->session->redirect($url, 302); + } + + /** + * When `profile` scope is requested, add profile to the response body + * If the user's `profile_name` is not set, profile will not be included + * in the response body. + * + * TODO: optionally add profile photo + * @see https://indieauth.spec.indieweb.org/#profile-information + */ + private function addProfileToResponse(array $response): array + { + $user_id = $this->session->getFor('IndieAuth', 'user_id'); + $user = $this->users->get($user_id); + + if (!$user->get('profile_name')) { + $this->log->save('indieauth', 'Missing profile name in user account'); + return $response; + } + + return array_merge( + $response, + [ + 'profile' => [ + 'name' => $user->get('profile_name'), + 'url' => $this->wire('urls')->httpRoot, + 'photo' => $user->get('profile_photo_url'), + ], + ] + ); } private function redirectHttpResponse(string $redirect_uri, array $queryParams): void { $url = Server::buildUrlWithQueryString($redirect_uri, $queryParams); - $this->session->redirect($url, false); + $this->session->redirect($url, 302); } private function httpResponse($response, int $http_status = 400, array $headers = []): void @@ -1452,6 +1510,20 @@ private function getClientInfo(string $url): array return $info; } + private function spaceSeparatedToArray(string $input): array + { + if (!$input) { + return []; + } + + return array_filter( + array_map( + 'trim', + explode(' ', $input) + ) + ); + } + private function secondsToString(int $input): string { $minute = 60; @@ -1483,7 +1555,6 @@ private function secondsToString(int $input): string * Perform a redirect with an error message * @param string $message * @param string $url - * @access public */ private function handleErrorRedirect(string $message = '', string $url = ''): void { @@ -1496,7 +1567,7 @@ private function handleErrorRedirect(string $message = '', string $url = ''): vo } $this->error($message); - $this->session->redirect($url, false); + $this->session->redirect($url, 302); } private function regenerateTokenSecret(): void @@ -1547,11 +1618,70 @@ private function importTemplates($json): void } } + /** + * Import templates from JSON or array + * @param string|array $json + * @see https://processwire.com/talk/topic/9007-utility-to-help-generate-module-install-function/?do=findComment&comment=86995 + */ + private function importFields($json): void + { + $data = is_array($json) ? $json : wireDecodeJSON($json); + + foreach ($data as $name => $field_data) { + # get rid of the ID so it doesn't conflict + unset($field_data['id']); + + # field doesn't exist already; create it + if (!$this->fields->get($name)) { + $field = new Field(); + $field->name = $name; + + # import the data for the field + $field->setImportData($field_data); + $field->save(); + + $this->message(sprintf('Added field: %s', $name)); + } else { + $this->message(sprintf('Skipped existing field: %s', $name)); + } + } + } + + private function addProfileFields(): void + { + # attempt to add profile fields to user template + $fieldgroup = $this->templates->get('user')->fieldgroup; + $fields_to_add = [ + 'profile_name', + 'profile_photo_url', + ]; + + foreach ($fields_to_add as $name) { + if (!$fieldgroup->has($name)) { + $fieldgroup->add($name); + if ($fieldgroup->save()) { + $this->message(sprintf('Added field: %s', $name)); + } + } + } + + # attempt to make new profile fields editable by user + $profileFields = $this->modules->get('ProcessProfile')->profileFields; + if ($missing_fields = array_diff($fields_to_add, $profileFields)) { + foreach ($missing_fields as $name) { + $profileFields[] = $name; + } + $this->modules->saveConfig('ProcessProfile', compact('profileFields')); + $this->message('Updated user profile fields', Notice::debug); + } + } + private function installRole(): void { $role = $this->roles->get('indieauth'); if ($role instanceof NullPage) { $this->roles->add('indieauth'); + $this->message('Added role: indieauth'); } } diff --git a/README.md b/README.md index d3cf68c..fbc6e9f 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,13 @@ IndieAuth is an identity layer on top of OAuth 2.0. It can be used to obtain acc * ProcessWire 3 ## Installation -* Create directory `/site/modules/ProcessIndieAuth` -* Upload the plugin files to that directory -* Install the module from the ProcessWire admin +The recommended method is to use the ProcessWire admin area’s module interface. If you prefer to install manually, see below. + +Navigate to Modules > New. In the Module Class Name field, enter `ProcessIndieAuth`. + +Continue with the [Setup](#setup) steps. + +## Setup * Copy the template files from `/extras/templates` into your `/site/templates` directory * Verify that the plugin installed pages: * IndieAuth Metadata Endpoint @@ -37,6 +41,15 @@ This should result in three `` elements in the source HTML: ``` +## Installation from Github +If you prefer to manually install: + +* Create directory `/site/modules/ProcessIndieAuth` +* Upload the plugin files to that directory +* Install the module from the ProcessWire admin + +Continue with the [Setup](#setup) steps. + ## Changelog * [Changelog](CHANGELOG.md) diff --git a/data/fields.json b/data/fields.json new file mode 100644 index 0000000..0a6281e --- /dev/null +++ b/data/fields.json @@ -0,0 +1,57 @@ +{ + "profile_name": { + "name": "profile_name", + "label": "Profile Name", + "flags": 0, + "type": "FieldtypeText", + "collapsed": 0, + "minlength": 0, + "maxlength": 2048, + "showCount": 0, + "size": 0, + "textformatters": "", + "showIf": "", + "themeInputSize": "", + "themeInputWidth": "", + "themeOffset": "", + "themeBorder": "", + "themeColor": "", + "themeBlank": "", + "columnWidth": 100, + "required": "", + "requiredAttr": "", + "requiredIf": "", + "stripTags": "", + "placeholder": "", + "pattern": "" + }, + "profile_photo_url": { + "name": "profile_photo_url", + "label": "Profile Photo URL", + "flags": 0, + "type": "FieldtypeURL", + "textformatters": "", + "noRelative": 0, + "allowIDN": 0, + "allowQuotes": 0, + "addRoot": 0, + "collapsed": 0, + "showIf": "", + "themeInputSize": "", + "themeInputWidth": "", + "themeOffset": "", + "themeBorder": "", + "themeColor": "", + "themeBlank": "", + "columnWidth": 100, + "required": "", + "requiredAttr": "", + "requiredIf": "", + "minlength": 0, + "maxlength": 1024, + "showCount": 0, + "size": 0, + "placeholder": "", + "pattern": "" + } +} \ No newline at end of file diff --git a/data/templates.json b/data/templates.json index acb298e..e0e9214 100644 --- a/data/templates.json +++ b/data/templates.json @@ -189,5 +189,70 @@ "fieldgroupContexts": { "title": [] } + }, + "token-revocation-endpoint": { + "name": "token-revocation-endpoint", + "fieldgroups_id": "token-revocation-endpoint", + "flags": 0, + "cache_time": 0, + "useRoles": 0, + "noInherit": 0, + "childrenTemplatesID": 0, + "sortfield": "", + "noChildren": "", + "noParents": "", + "childTemplates": [], + "parentTemplates": [], + "allowPageNum": 0, + "allowChangeUser": 0, + "redirectLogin": 0, + "urlSegments": 0, + "https": 0, + "slashUrls": 1, + "slashPageNum": 0, + "slashUrlSegments": 0, + "altFilename": "", + "guestSearchable": 0, + "pageClass": "", + "childNameFormat": "", + "pageLabelField": "", + "noGlobal": 0, + "noMove": 0, + "noTrash": 0, + "noSettings": 0, + "noChangeTemplate": 0, + "noShortcut": 0, + "noUnpublish": 0, + "noLang": 0, + "compile": 3, + "nameContentTab": 0, + "noCacheGetVars": "", + "noCachePostVars": "", + "useCacheForUsers": 0, + "cacheExpire": 0, + "cacheExpirePages": [], + "cacheExpireSelector": "", + "label": "", + "tags": "", + "titleNames": 0, + "noPrependTemplateFile": 0, + "noAppendTemplateFile": 0, + "prependFile": "", + "appendFile": "", + "pagefileSecure": false, + "tabContent": "", + "tabChildren": "", + "nameLabel": "", + "contentType": "", + "errorAction": 0, + "connectedFieldID": null, + "ns": "ProcessWire", + "_exportMode": true, + "fieldgroupFields": [ + "title" + ], + "fieldgroupContexts": { + "title": [] + } } } \ No newline at end of file diff --git a/views/authorization.php b/views/authorization.php index 624d4c2..95372c4 100644 --- a/views/authorization.php +++ b/views/authorization.php @@ -18,6 +18,10 @@ ); } $display_scopes .= PHP_EOL . ''; + +if (in_array('profile', $scopes)) { + $display_scopes .= PHP_EOL . '

If you select profile, the app will be provided the name and photo from your profile.

'; +} ?>