@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<?php
2
3final class PhabricatorOAuthServerAuthController
4 extends PhabricatorOAuthServerController {
5
6 protected function buildApplicationCrumbs() {
7 // We're specifically not putting an "OAuth Server" application crumb
8 // on the auth pages because it doesn't make sense to send users there.
9 return new PHUICrumbsView();
10 }
11
12 public function handleRequest(AphrontRequest $request) {
13 $viewer = $this->getViewer();
14
15 $server = new PhabricatorOAuthServer();
16 $client_phid = $request->getStr('client_id');
17 $redirect_uri = $request->getStr('redirect_uri');
18 $response_type = $request->getStr('response_type');
19
20 // state is an opaque value the client sent us for their own purposes
21 // we just need to send it right back to them in the response!
22 $state = $request->getStr('state');
23
24 if (!$client_phid) {
25 return $this->buildErrorResponse(
26 'invalid_request',
27 pht('Malformed Request'),
28 pht(
29 'Required parameter %s was not present in the request.',
30 phutil_tag('strong', array(), 'client_id')));
31 }
32
33 // We require that users must be able to see an OAuth application
34 // in order to authorize it. This allows an application's visibility
35 // policy to be used to restrict authorized users.
36 try {
37 $client = id(new PhabricatorOAuthServerClientQuery())
38 ->setViewer($viewer)
39 ->withPHIDs(array($client_phid))
40 ->executeOne();
41 } catch (PhabricatorPolicyException $ex) {
42 $ex->setContext(self::CONTEXT_AUTHORIZE);
43 throw $ex;
44 }
45
46 $server->setUser($viewer);
47 $is_authorized = false;
48 $authorization = null;
49 $uri = null;
50 $name = null;
51
52 // one giant try / catch around all the exciting database stuff so we
53 // can return a 'server_error' response if something goes wrong!
54 try {
55 if (!$client) {
56 return $this->buildErrorResponse(
57 'invalid_request',
58 pht('Invalid Client Application'),
59 pht(
60 'Request parameter %s does not specify a valid client application.',
61 phutil_tag('strong', array(), 'client_id')));
62 }
63
64 if ($client->getIsDisabled()) {
65 return $this->buildErrorResponse(
66 'invalid_request',
67 pht('Application Disabled'),
68 pht(
69 'The %s OAuth application has been disabled.',
70 phutil_tag('strong', array(), 'client_id')));
71 }
72
73 $name = $client->getName();
74 $server->setClient($client);
75 if ($redirect_uri) {
76 $client_uri = new PhutilURI($client->getRedirectURI());
77 $redirect_uri = new PhutilURI($redirect_uri);
78 if (!($server->validateSecondaryRedirectURI($redirect_uri,
79 $client_uri))) {
80 return $this->buildErrorResponse(
81 'invalid_request',
82 pht('Invalid Redirect URI'),
83 pht(
84 'Request parameter %s specifies an invalid redirect URI. '.
85 'The redirect URI must be a fully-qualified domain with no '.
86 'fragments, and must have the same domain and at least '.
87 'the same query parameters as the redirect URI the client '.
88 'registered.',
89 phutil_tag('strong', array(), 'redirect_uri')));
90 }
91 $uri = $redirect_uri;
92 } else {
93 $uri = new PhutilURI($client->getRedirectURI());
94 }
95
96 if (empty($response_type)) {
97 return $this->buildErrorResponse(
98 'invalid_request',
99 pht('Invalid Response Type'),
100 pht(
101 'Required request parameter %s is missing.',
102 phutil_tag('strong', array(), 'response_type')));
103 }
104
105 if ($response_type != 'code') {
106 return $this->buildErrorResponse(
107 'unsupported_response_type',
108 pht('Unsupported Response Type'),
109 pht(
110 'Request parameter %s specifies an unsupported response type. '.
111 'Valid response types are: %s.',
112 phutil_tag('strong', array(), 'response_type'),
113 implode(', ', array('code'))));
114 }
115
116
117 $requested_scope = $request->getStrList('scope');
118 $requested_scope = array_fuse($requested_scope);
119
120 $scope = PhabricatorOAuthServerScope::filterScope($requested_scope);
121
122 // NOTE: We're always requiring a confirmation dialog to redirect.
123 // Partly this is a general defense against redirect attacks, and
124 // partly this shakes off anchors in the URI (which are not shaken
125 // by 302'ing).
126
127 $auth_info = $server->userHasAuthorizedClient($scope);
128 list($is_authorized, $authorization) = $auth_info;
129
130 if ($request->isFormPost()) {
131 if ($authorization) {
132 $authorization->setScope($scope)->save();
133 } else {
134 $authorization = $server->authorizeClient($scope);
135 }
136
137 $is_authorized = true;
138 }
139 } catch (Exception $e) {
140 return $this->buildErrorResponse(
141 'server_error',
142 pht('Server Error'),
143 pht(
144 'The authorization server encountered an unexpected condition '.
145 'which prevented it from fulfilling the request.'));
146 }
147
148 // When we reach this part of the controller, we can be in two states:
149 //
150 // 1. The user has not authorized the application yet. We want to
151 // give them an "Authorize this application?" dialog.
152 // 2. The user has authorized the application. We want to give them
153 // a "Confirm Login" dialog.
154
155 if ($is_authorized) {
156
157 // The second case is simpler, so handle it first. The user either
158 // authorized the application previously, or has just authorized the
159 // application. Show them a confirm dialog with a normal link back to
160 // the application. This shakes anchors from the URI.
161
162 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
163 $auth_code = $server->generateAuthorizationCode($uri);
164 unset($unguarded);
165
166 $full_uri = $this->addQueryParams(
167 $uri,
168 array(
169 'code' => $auth_code->getCode(),
170 'scope' => $authorization->getScopeString(),
171 'state' => $state,
172 ));
173
174 if ($client->getIsTrusted()) {
175 // NOTE: See T13099. We currently emit a "Content-Security-Policy"
176 // which includes a narrow "form-action". At the time of writing,
177 // Chrome applies "form-action" to redirects following form submission.
178
179 // This can lead to a situation where a user enters the OAuth workflow
180 // and is prompted for MFA. When they submit an MFA response, the form
181 // can redirect here, and Chrome will block the "Location" redirect.
182
183 // To avoid this, render an interstitial. We only actually need to do
184 // this in Chrome (but do it everywhere for consistency) and only need
185 // to do it if the request is a redirect after a form submission (but
186 // we can't tell if it is or not).
187
188 Javelin::initBehavior(
189 'redirect',
190 array(
191 'uri' => (string)$full_uri,
192 ));
193
194 return $this->newDialog()
195 ->setTitle(pht('Authenticate: %s', $name))
196 ->appendParagraph(
197 pht(
198 'Authorization for "%s" confirmed, redirecting...',
199 phutil_tag('strong', array(), $name)))
200 ->addCancelButton((string)$full_uri, pht('Continue'));
201 }
202
203 // TODO: It would be nice to give the user more options here, like
204 // reviewing permissions, canceling the authorization, or aborting
205 // the workflow.
206
207 $dialog = id(new AphrontDialogView())
208 ->setUser($viewer)
209 ->setTitle(pht('Authenticate: %s', $name))
210 ->appendParagraph(
211 pht(
212 'This application ("%s") is authorized to use your %s '.
213 'credentials. Continue to complete the authentication workflow.',
214 phutil_tag('strong', array(), $name),
215 PlatformSymbols::getPlatformServerName()))
216 ->addCancelButton((string)$full_uri, pht('Continue to Application'));
217
218 return id(new AphrontDialogResponse())->setDialog($dialog);
219 }
220
221 // Here, we're confirming authorization for the application.
222 if ($authorization) {
223 $missing_scope = array_diff_key($scope, $authorization->getScope());
224 } else {
225 $missing_scope = $scope;
226 }
227
228 $form = id(new AphrontFormView())
229 ->addHiddenInput('client_id', $client_phid)
230 ->addHiddenInput('redirect_uri', $redirect_uri)
231 ->addHiddenInput('response_type', $response_type)
232 ->addHiddenInput('state', $state)
233 ->addHiddenInput('scope', $request->getStr('scope'))
234 ->setUser($viewer);
235
236 $cancel_msg = pht('The user declined to authorize this application.');
237 $cancel_uri = $this->addQueryParams(
238 $uri,
239 array(
240 'error' => 'access_denied',
241 'error_description' => $cancel_msg,
242 ));
243
244 $dialog = $this->newDialog()
245 ->setShortTitle(pht('Authorize Access'))
246 ->setTitle(pht('Authorize "%s"?', $name))
247 ->setSubmitURI($request->getRequestURI()->getPath())
248 ->setWidth(AphrontDialogView::WIDTH_FORM)
249 ->appendParagraph(
250 pht(
251 'Do you want to authorize the external application "%s" to '.
252 'access your %s account data, including your primary '.
253 'email address?',
254 phutil_tag('strong', array(), $name),
255 PlatformSymbols::getPlatformServerName()))
256 ->appendForm($form)
257 ->addSubmitButton(pht('Authorize Access'))
258 ->addCancelButton((string)$cancel_uri, pht('Do Not Authorize'));
259
260 if ($missing_scope) {
261 $dialog->appendParagraph(
262 pht(
263 'This application has requested these additional permissions. '.
264 'Authorizing it will grant it the permissions it requests:'));
265 foreach ($missing_scope as $scope_key => $ignored) {
266 // TODO: Once we introduce more scopes, explain them here.
267 }
268 }
269
270 $unknown_scope = array_diff_key($requested_scope, $scope);
271 if ($unknown_scope) {
272 $dialog->appendParagraph(
273 pht(
274 'This application also requested additional unrecognized '.
275 'permissions. These permissions may have existed in an older '.
276 'version of the software, or may be from a future version of '.
277 'the software. They will not be granted.'));
278
279 $unknown_form = id(new AphrontFormView())
280 ->setViewer($viewer)
281 ->appendChild(
282 id(new AphrontFormTextControl())
283 ->setLabel(pht('Unknown Scope'))
284 ->setValue(implode(', ', array_keys($unknown_scope)))
285 ->setDisabled(true));
286
287 $dialog->appendForm($unknown_form);
288 }
289
290 return $dialog;
291 }
292
293
294 private function buildErrorResponse($code, $title, $message) {
295 $viewer = $this->getRequest()->getUser();
296
297 return $this->newDialog()
298 ->setTitle(pht('OAuth: %s', $title))
299 ->appendParagraph($message)
300 ->appendParagraph(
301 pht('OAuth Error Code: %s', phutil_tag('tt', array(), $code)))
302 ->addCancelButton('/', pht('Alas!'));
303 }
304
305
306 private function addQueryParams(PhutilURI $uri, array $params) {
307 $full_uri = clone $uri;
308
309 foreach ($params as $key => $value) {
310 if (strlen($value)) {
311 $full_uri->replaceQueryParam($key, $value);
312 }
313 }
314
315 return $full_uri;
316 }
317
318}