@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Make OAuth scope handling more flexible

Summary:
Ref T7303. Currently, our handling of "scope" is fairly rigid and adheres to the spec, but some of these behaviors don't make much sense in practice.

Soften some behaviors and make them more flexible:

**Soft Failure on Unknown Permissions**: If a client asks for a permission we don't know about, just warn that we don't recognize it instead of fataling. In particular, I plan to make `offline_access` and `whoami` implicit. Older clients that request these permissions will still work fine as long as we don't hard-fatal.

**Move `user.whoami` to ALWAYS scope**: Make `whoami` a default permission. We've already done this, in effect; this just formalizes it.

**Tokens no longer expire**: Make `offline_access` (infinite-duration tokens) a default permission. I think the OAuth model doesn't map well to reality. It is common for other providers to issue "temporary" tokens with a duration of multiple years, and the refesh workflow is sort of silly. We can add a "temporary" scope later if we need temporary tokens.

This flow was potentially extra silly with the "log out of Phacility" use case, where we might need to have you log in again before we could log you out, which is bizarre and senseless. Avoid this nonsense.

**Move away from granular permissions**: Users currently get to pick-and-choose which permissions they grant, but this likely rarely/never works in practice and is fairly hostile since applications can't communicate which permissions they need. Applications which can actually operate with only some subset of permissions can make separate requests (e.g., when you activate "cool feature X", it asks for X permission). I think applications that do this are rare; pretty much everything just asks for tons of permissions and everyone grants them.

Making this all-or-nothing is better for well-behaved applications and better for users. It's also slightly better for overzealous applications that ask for more than they need, but whatever. Users can make an informed decision, hopefully, and I plan to let administrators force applications to a subset of permissions once we introduce meaningful scopes.

Test Plan:
- Generated tokens.
- Used tokens.
- Authorized an instance.
- Faked some bogus scopes, got clean authorization.

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T7303

Differential Revision: https://secure.phabricator.com/D15621

+55 -180
-13
src/applications/oauthserver/PhabricatorOAuthServer.php
··· 177 177 return null; 178 178 } 179 179 180 - // TODO: This should probably be reworked; expiration should be an 181 - // exclusive property of the token. For now, this logic reads: tokens for 182 - // authorizations with "offline_access" never expire. 183 - 184 - $is_expired = $token->isExpired(); 185 - if ($is_expired) { 186 - $offline_access = PhabricatorOAuthServerScope::SCOPE_OFFLINE_ACCESS; 187 - $authorization_scope = $authorization->getScope(); 188 - if (empty($authorization_scope[$offline_access])) { 189 - return null; 190 - } 191 - } 192 - 193 180 return $authorization; 194 181 } 195 182
+8 -108
src/applications/oauthserver/PhabricatorOAuthServerScope.php
··· 2 2 3 3 final class PhabricatorOAuthServerScope extends Phobject { 4 4 5 - const SCOPE_OFFLINE_ACCESS = 'offline_access'; 6 - const SCOPE_WHOAMI = 'whoami'; 7 - 8 - public static function getScopesDict() { 9 - return array( 10 - self::SCOPE_OFFLINE_ACCESS => 1, 11 - self::SCOPE_WHOAMI => 1, 12 - ); 13 - } 14 - 15 - public static function getDefaultScope() { 16 - return self::SCOPE_WHOAMI; 17 - } 18 - 19 - public static function getCheckboxControl( 20 - array $current_scopes) { 21 - 22 - $have_options = false; 23 - $scopes = self::getScopesDict(); 24 - $scope_keys = array_keys($scopes); 25 - sort($scope_keys); 26 - $default_scope = self::getDefaultScope(); 27 - 28 - $checkboxes = new AphrontFormCheckboxControl(); 29 - foreach ($scope_keys as $scope) { 30 - if ($scope == $default_scope) { 31 - continue; 32 - } 33 - if (!isset($current_scopes[$scope])) { 34 - continue; 35 - } 36 - 37 - $checkboxes->addCheckbox( 38 - $name = $scope, 39 - $value = 1, 40 - $label = self::getCheckboxLabel($scope), 41 - $checked = isset($current_scopes[$scope])); 42 - $have_options = true; 43 - } 44 - 45 - if ($have_options) { 46 - $checkboxes->setLabel(pht('Scope')); 47 - return $checkboxes; 48 - } 49 - 50 - return null; 51 - } 52 - 53 - private static function getCheckboxLabel($scope) { 54 - $label = null; 55 - switch ($scope) { 56 - case self::SCOPE_OFFLINE_ACCESS: 57 - $label = pht('Make access tokens granted to this client never expire.'); 58 - break; 59 - case self::SCOPE_WHOAMI: 60 - $label = pht('Read access to Conduit method %s.', 'user.whoami'); 61 - break; 62 - } 63 - 64 - return $label; 5 + public static function getScopeMap() { 6 + return array(); 65 7 } 66 8 67 - public static function getScopesFromRequest(AphrontRequest $request) { 68 - $scopes = self::getScopesDict(); 69 - $requested_scopes = array(); 70 - foreach ($scopes as $scope => $bit) { 71 - if ($request->getBool($scope)) { 72 - $requested_scopes[$scope] = 1; 73 - } 74 - } 75 - $requested_scopes[self::getDefaultScope()] = 1; 76 - return $requested_scopes; 77 - } 9 + public static function filterScope(array $scope) { 10 + $valid_scopes = self::getScopeMap(); 78 11 79 - /** 80 - * A scopes list is considered valid if each scope is a known scope 81 - * and each scope is seen only once. Otherwise, the list is invalid. 82 - */ 83 - public static function validateScopesList($scope_list) { 84 - $scopes = explode(' ', $scope_list); 85 - $known_scopes = self::getScopesDict(); 86 - $seen_scopes = array(); 87 - foreach ($scopes as $scope) { 88 - if (!isset($known_scopes[$scope])) { 89 - return false; 90 - } 91 - if (isset($seen_scopes[$scope])) { 92 - return false; 12 + foreach ($scope as $key => $scope_item) { 13 + if (!isset($valid_scopes[$scope_item])) { 14 + unset($scope[$key]); 93 15 } 94 - $seen_scopes[$scope] = 1; 95 16 } 96 17 97 - return true; 98 - } 99 - 100 - /** 101 - * A scopes dictionary is considered valid if each key is a known scope. 102 - * Otherwise, the dictionary is invalid. 103 - */ 104 - public static function validateScopesDict($scope_dict) { 105 - $known_scopes = self::getScopesDict(); 106 - $unknown_scopes = array_diff_key($scope_dict, 107 - $known_scopes); 108 - return empty($unknown_scopes); 109 - } 110 - 111 - /** 112 - * Transforms a space-delimited scopes list into a scopes dict. The list 113 - * should be validated by @{method:validateScopesList} before 114 - * transformation. 115 - */ 116 - public static function scopesListToDict($scope_list) { 117 - $scopes = explode(' ', $scope_list); 118 - return array_fill_keys($scopes, 1); 18 + return $scope; 119 19 } 120 20 121 21 }
+42 -35
src/applications/oauthserver/controller/PhabricatorOAuthServerAuthController.php
··· 14 14 15 15 $server = new PhabricatorOAuthServer(); 16 16 $client_phid = $request->getStr('client_id'); 17 - $scope = $request->getStr('scope'); 18 17 $redirect_uri = $request->getStr('redirect_uri'); 19 18 $response_type = $request->getStr('response_type'); 20 19 ··· 114 113 implode(', ', array('code')))); 115 114 } 116 115 117 - if ($scope) { 118 - if (!PhabricatorOAuthServerScope::validateScopesList($scope)) { 119 - return $this->buildErrorResponse( 120 - 'invalid_scope', 121 - pht('Invalid Scope'), 122 - pht( 123 - 'Request parameter %s specifies an unsupported scope.', 124 - phutil_tag('strong', array(), 'scope'))); 125 - } 126 - $scope = PhabricatorOAuthServerScope::scopesListToDict($scope); 127 - } else { 128 - return $this->buildErrorResponse( 129 - 'invalid_request', 130 - pht('Malformed Request'), 131 - pht( 132 - 'Required parameter %s was not present in the request.', 133 - phutil_tag('strong', array(), 'scope'))); 134 - } 116 + 117 + $requested_scope = $request->getStrList('scope'); 118 + $requested_scope = array_fuse($requested_scope); 119 + 120 + $scope = PhabricatorOAuthServerScope::filterScope($requested_scope); 135 121 136 122 // NOTE: We're always requiring a confirmation dialog to redirect. 137 123 // Partly this is a general defense against redirect attacks, and ··· 142 128 list($is_authorized, $authorization) = $auth_info; 143 129 144 130 if ($request->isFormPost()) { 145 - $scope = PhabricatorOAuthServerScope::getScopesFromRequest($request); 146 - 147 131 if ($authorization) { 148 132 $authorization->setScope($scope)->save(); 149 133 } else { ··· 212 196 213 197 // Here, we're confirming authorization for the application. 214 198 if ($authorization) { 215 - $desired_scopes = array_merge($scope, $authorization->getScope()); 199 + $missing_scope = array_diff_key($scope, $authorization->getScope()); 216 200 } else { 217 - $desired_scopes = $scope; 218 - } 219 - 220 - if (!PhabricatorOAuthServerScope::validateScopesDict($desired_scopes)) { 221 - return $this->buildErrorResponse( 222 - 'invalid_scope', 223 - pht('Invalid Scope'), 224 - pht('The requested scope is invalid, unknown, or malformed.')); 201 + $missing_scope = $scope; 225 202 } 226 203 227 204 $form = id(new AphrontFormView()) ··· 230 207 ->addHiddenInput('response_type', $response_type) 231 208 ->addHiddenInput('state', $state) 232 209 ->addHiddenInput('scope', $request->getStr('scope')) 233 - ->setUser($viewer) 234 - ->appendChild( 235 - PhabricatorOAuthServerScope::getCheckboxControl($desired_scopes)); 210 + ->setUser($viewer); 236 211 237 212 $cancel_msg = pht('The user declined to authorize this application.'); 238 213 $cancel_uri = $this->addQueryParams( ··· 242 217 'error_description' => $cancel_msg, 243 218 )); 244 219 245 - return $this->newDialog() 220 + $dialog = $this->newDialog() 246 221 ->setShortTitle(pht('Authorize Access')) 247 222 ->setTitle(pht('Authorize "%s"?', $name)) 248 223 ->setSubmitURI($request->getRequestURI()->getPath()) ··· 253 228 'access your Phabricator account data, including your primary '. 254 229 'email address?', 255 230 phutil_tag('strong', array(), $name))) 256 - ->appendChild($form->buildLayoutView()) 231 + ->appendForm($form) 257 232 ->addSubmitButton(pht('Authorize Access')) 258 233 ->addCancelButton((string)$cancel_uri, pht('Do Not Authorize')); 234 + 235 + if ($missing_scope) { 236 + $dialog->appendParagraph( 237 + pht( 238 + 'This application has requested these additional permissions. '. 239 + 'Authorizing it will grant it the permissions it requests:')); 240 + foreach ($missing_scope as $scope_key => $ignored) { 241 + // TODO: Once we introduce more scopes, explain them here. 242 + } 243 + } 244 + 245 + $unknown_scope = array_diff_key($requested_scope, $scope); 246 + if ($unknown_scope) { 247 + $dialog->appendParagraph( 248 + pht( 249 + 'This application also requested additional unrecognized '. 250 + 'permissions. These permissions may have existed in an older '. 251 + 'version of Phabricator, or may be from a future version of '. 252 + 'Phabricator. They will not be granted.')); 253 + 254 + $unknown_form = id(new AphrontFormView()) 255 + ->setViewer($viewer) 256 + ->appendChild( 257 + id(new AphrontFormTextControl()) 258 + ->setLabel(pht('Unknown Scope')) 259 + ->setValue(implode(', ', array_keys($unknown_scope))) 260 + ->setDisabled(true)); 261 + 262 + $dialog->appendForm($unknown_form); 263 + } 264 + 265 + return $dialog; 259 266 } 260 267 261 268
+1 -2
src/applications/oauthserver/controller/PhabricatorOAuthServerTokenController.php
··· 154 154 unset($unguarded); 155 155 $result = array( 156 156 'access_token' => $access_token->getToken(), 157 - 'token_type' => 'Bearer', 158 - 'expires_in' => $access_token->getExpiresDuration(), 157 + 'token_type' => 'Bearer', 159 158 ); 160 159 return $response->setContent($result); 161 160 } catch (Exception $e) {
-14
src/applications/oauthserver/storage/PhabricatorOAuthServerAccessToken.php
··· 22 22 ) + parent::getConfiguration(); 23 23 } 24 24 25 - public function isExpired() { 26 - $now = PhabricatorTime::getNow(); 27 - $expires_epoch = $this->getExpiresEpoch(); 28 - return ($now > $expires_epoch); 29 - } 30 - 31 - public function getExpiresEpoch() { 32 - return $this->getDateCreated() + 3600; 33 - } 34 - 35 - public function getExpiresDuration() { 36 - return PhabricatorTime::getNow() - $this->getExpiresEpoch(); 37 - } 38 - 39 25 }
+1 -1
src/applications/people/conduit/UserWhoAmIConduitAPIMethod.php
··· 19 19 } 20 20 21 21 public function getRequiredScope() { 22 - return PhabricatorOAuthServerScope::SCOPE_WHOAMI; 22 + return self::SCOPE_ALWAYS; 23 23 } 24 24 25 25 protected function execute(ConduitAPIRequest $request) {
+3 -7
src/docs/contributor/using_oauthserver.diviner
··· 110 110 NOTE: See "Scopes" section below for more information on what data is 111 111 currently exposed through the OAuth Server. 112 112 113 - = Scopes = 114 - 115 - There are only two scopes supported at this time. 113 + Scopes 114 + ====== 116 115 117 - - **offline_access** - allows an access token to work indefinitely without 118 - expiring. 119 - - **whoami** - allows the client to access the results of Conduit.whoami on 120 - behalf of the resource owner. 116 + //This section has not been written yet.//