Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 525
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMWOAuth
0.00% covered (danger)
0.00%
0 / 525
0.00% covered (danger)
0.00%
0 / 17
10506
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 209
0.00% covered (danger)
0.00%
0 / 1
1806
 showCancelPage
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 outputJWT
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 handleAuthorizationForm
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 1
650
 redirectToREST
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 getRequestValidators
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
12
 getRequestedGrants
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 showError
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 returnToken
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 showResponse
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 useRealNames
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 determineOAuthVersion
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 assertOAuthVersion
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 makePrivacyLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\OAuth\Frontend\SpecialPages;
4
5/**
6 * (c) Chris Steipp, Aaron Schulz 2013, GPL
7 *
8 * @license GPL-2.0-or-later
9 */
10
11use Firebase\JWT\JWT;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\Exception\MWException;
14use MediaWiki\Extension\OAuth\Backend\Consumer;
15use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
16use MediaWiki\Extension\OAuth\Backend\MWOAuthException;
17use MediaWiki\Extension\OAuth\Backend\MWOAuthRequest;
18use MediaWiki\Extension\OAuth\Backend\MWOAuthToken;
19use MediaWiki\Extension\OAuth\Backend\Utils;
20use MediaWiki\Extension\OAuth\Control\ConsumerAcceptanceSubmitControl;
21use MediaWiki\Extension\OAuth\Control\ConsumerAccessControl;
22use MediaWiki\Extension\OAuth\Control\SubmitControl;
23use MediaWiki\Extension\OAuth\Lib\OAuthException;
24use MediaWiki\Extension\OAuth\Lib\OAuthToken;
25use MediaWiki\Extension\OAuth\Lib\OAuthUtil;
26use MediaWiki\Extension\OAuth\UserStatementProvider;
27use MediaWiki\Html\Html;
28use MediaWiki\HTMLForm\HTMLForm;
29use MediaWiki\Json\FormatJson;
30use MediaWiki\Linker\Linker;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\Message\Message;
33use MediaWiki\Permissions\GrantsLocalization;
34use MediaWiki\Request\WebRequest;
35use MediaWiki\Skin\SkinFactory;
36use MediaWiki\SpecialPage\SpecialPage;
37use MediaWiki\SpecialPage\UnlistedSpecialPage;
38use MediaWiki\Status\Status;
39use MediaWiki\Title\Title;
40use MediaWiki\User\User;
41use MediaWiki\Utils\UrlUtils;
42use MediaWiki\WikiMap\WikiMap;
43use OOUI;
44use OOUI\HtmlSnippet;
45use Psr\Log\LoggerInterface;
46
47/**
48 * Page that handles OAuth consumer authorization and token exchange
49 */
50class SpecialMWOAuth extends UnlistedSpecialPage {
51    protected readonly LoggerInterface $logger;
52
53    /** @var int Defaults to OAuth1 */
54    protected $oauthVersion = Consumer::OAUTH_VERSION_1;
55
56    public function __construct(
57        private readonly GrantsLocalization $grantsLocalization,
58        private readonly SkinFactory $skinFactory,
59        private readonly UrlUtils $urlUtils,
60    ) {
61        parent::__construct( 'OAuth' );
62        $this->logger = LoggerFactory::getInstance( 'OAuth' );
63    }
64
65    /** @inheritDoc */
66    public function doesWrites() {
67        return true;
68    }
69
70    /** @inheritDoc */
71    public function getLocalName() {
72        // Force the canonical name when OAuth headers are present,
73        // otherwise SpecialPageFactory redirects and breaks the signature.
74        if ( Utils::hasOAuthHeaders( $this->getRequest() ) ) {
75            return $this->getName();
76        }
77        return parent::getLocalName();
78    }
79
80    /** @inheritDoc */
81    public function execute( $subpage ) {
82        if ( $this->getRequest()->getRawVal( 'display' ) === 'popup' ) {
83            // Replace the default skin with a "micro-skin" that omits most of the interface. (T362706)
84            // In the future, we might allow normal skins to serve this mode too, if they advise that
85            // they support it by setting a skin option, so that colors and fonts could stay consistent.
86            $this->getContext()->setSkin( $this->skinFactory->makeSkin( 'authentication-popup' ) );
87        }
88
89        $this->setHeaders();
90
91        $user = $this->getUser();
92        $request = $this->getRequest();
93
94        $output = $this->getOutput();
95        $output->disallowUserJs();
96
97        $config = $this->getConfig();
98
99        // 'raw' for plaintext, 'html' or 'json'.
100        // For the initiate and token endpoints, 'raw' also handles the formatting required by
101        // https://oauth.net/core/1.0a/#response_parameters
102        // Other than that, there is no reason to use anything but JSON, but the others are
103        // supported for B/C.
104        $format = $request->getVal( 'format', 'raw' );
105        '@phan-var string $format';
106
107        try {
108            if ( $config->get( 'MWOAuthReadOnly' ) &&
109                !in_array( $subpage, [ 'verified', 'grants', 'identify' ] )
110            ) {
111                throw new MWOAuthException( 'mwoauth-db-readonly' );
112            }
113
114            $this->determineOAuthVersion( $request );
115            switch ( $subpage ) {
116                // Return a request token (first leg of 3-legged OAuth 1 handshake).
117                case 'initiate':
118                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
119                    $oauthServer = Utils::newMWOAuthServer();
120                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
121                    $this->logger->debug( __METHOD__ . ": Getting temporary credentials" );
122                    // fetch_request_token does the version, freshness, and sig checks
123                    $token = $oauthServer->fetch_request_token( $oauthRequest );
124                    $this->returnToken( $token, $format );
125                    break;
126
127                // Show an authorization dialog to the user where they can grant permissions
128                // to the app. Used in OAuth 2, by the oauth2/authorize REST endpoint.
129                case 'approve':
130                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_2 );
131                    $format = 'html';
132                    $clientId = $request->getVal( 'client_id', '' );
133                    $this->logger->debug( __METHOD__ . ": doing '$subpage' for OAuth2 with " .
134                        "client_id '$clientId' for '{$user->getName()}'" );
135                    if ( !$user->isNamed() ) {
136                        // Should not happen, as user login status will already be checked at this point
137                        // Just redirect back to REST, it will then redirect to login
138                        $this->redirectToREST();
139                        return;
140                    }
141                    if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) {
142                        $this->showCancelPage( $clientId );
143                    } else {
144                        $this->handleAuthorizationForm(
145                            null, $clientId, true
146                        );
147                    }
148
149                    break;
150
151                // Show an authorization dialog to the user where they can grant permissions
152                // to the app (second leg of 3-legged OAuth 1 handshake).
153                // 'authenticate' might skip user interaction if the grants are
154                // limited and the user already authorized in the past.
155                case 'authorize':
156                case 'authenticate':
157                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
158                    $format = 'html';
159
160                    $requestToken = $request->getVal( 'requestToken',
161                        $request->getVal( 'oauth_token' ) );
162                    $consumerKey = $request->getVal( 'consumerKey',
163                        $request->getVal( 'oauth_consumer_key' ) );
164                    $this->logger->debug( __METHOD__ . ": doing '$subpage' with " .
165                        "'$requestToken' '$consumerKey' for '{$user->getName()}'" );
166
167                    $this->requireNamedUser( 'mwoauth-named-account-required-reason' );
168
169                    // TODO? Test that $requestToken exists in memcache
170                    if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) {
171                        // Show acceptance cancellation confirmation
172                        $this->showCancelPage( $consumerKey );
173                    } else {
174                        // Show form and redirect on submission for authorization
175                        $this->handleAuthorizationForm(
176                            $requestToken, $consumerKey, $subpage === 'authenticate'
177                        );
178                    }
179                    break;
180
181                // Return an access token (third leg of 3-legged OAuth 1 handshake).
182                case 'token':
183                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
184                    $oauthServer = Utils::newMWOAuthServer();
185                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
186
187                    $isRsa = $oauthRequest->get_parameter( "oauth_signature_method" ) === 'RSA-SHA1';
188
189                    // We want to use HTTPS when returning the credentials. But
190                    // for RSA we don't need to return a token secret, so HTTP is ok.
191                    if ( $config->get( 'MWOAuthSecureTokenTransfer' ) && !$isRsa
192                        && $request->detectProtocol() == 'http'
193                        && substr( $this->urlUtils->expand( '/', PROTO_HTTPS ) ?? '', 0, 8 ) === 'https://'
194                    ) {
195                        $redirUrl = str_replace(
196                            'http://', 'https://', $request->getFullRequestURL()
197                        );
198                        $output->redirect( $redirUrl );
199                        $output->addVaryHeader( 'X-Forwarded-Proto' );
200                        break;
201                    }
202
203                    $token = $oauthServer->fetch_access_token( $oauthRequest );
204                    if ( $isRsa ) {
205                        // RSA doesn't use the token secret, so don't return one.
206                        $token->secret = '__unused__';
207                    }
208                    $this->returnToken( $token, $format );
209                    break;
210
211                // Can be used as a return URL for non-web-based OAuth 1 applications which cannot
212                // provide their own return URL. It just displays the parameters that would
213                // normally be passed to the application via the return URL.
214                case 'verified':
215                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
216                    $format = 'html';
217                    $verifier = $request->getVal( 'oauth_verifier' );
218                    $requestToken = $request->getVal( 'oauth_token' );
219                    if ( !$verifier || !$requestToken ) {
220                        throw new MWOAuthException( 'mwoauth-bad-request-missing-params', [
221                            Message::rawParam( Linker::makeExternalLink(
222                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E001',
223                                'E001',
224                                true
225                            ) )
226                        ] );
227                    }
228                    $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
229                    $this->showResponse(
230                        $this->msg( 'mwoauth-verified',
231                            wfEscapeWikiText( $verifier ),
232                            wfEscapeWikiText( $requestToken )
233                        )->parse(),
234                        $format
235                    );
236                    break;
237
238                // Was used to list grants and their descriptions. With grants now in core,
239                // it just redirects to Special:ListGrants.
240                case 'grants':
241                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
242                    // Backwards compatibility
243                    $listGrants = SpecialPage::getTitleFor( 'ListGrants' );
244                    $output->redirect( $listGrants->getFullURL() );
245                    break;
246
247                // Return a JWT with the identity of the user, based on the OAuth signature.
248                // Our homegrown OIDC equivalent for OAuth 1.
249                case 'identify':
250                    $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 );
251                    // we only return JWT, so we assume json
252                    $format = 'json';
253                    $server = Utils::newMWOAuthServer();
254                    $oauthRequest = MWOAuthRequest::fromRequest( $request );
255                    // verify_request throws an exception if anything isn't verified
256                    [ $consumer, $token ] = $server->verify_request( $oauthRequest );
257                    /** @var Consumer $consumer */
258                    /** @var MWOAuthToken $token */
259
260                    $wiki = WikiMap::getCurrentWikiId();
261                    $dbr = Utils::getOAuthDB( DB_REPLICA );
262                    $access = ConsumerAcceptance::newFromToken( $dbr, $token->key );
263                    if ( !$access ) {
264                        throw new MWOAuthException( 'mwoauth-access-token-not-found', [
265                            'consumer' => $consumer->getConsumerKey(),
266                            'consumer_name' => $consumer->getName(),
267                        ] );
268                    }
269                    $localUser = Utils::getLocalUserFromCentralId( $access->getUserId() );
270                    if ( !$localUser || !$localUser->isNamed() ) {
271                        throw new MWOAuthException( 'mwoauth-invalid-authorization-invalid-user', [
272                            Message::rawParam( Linker::makeExternalLink(
273                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008',
274                                'E008',
275                                true
276                            ) ),
277                            'consumer' => $consumer->getConsumerKey(),
278                            'consumer_name' => $consumer->getName(),
279                            'cmra_id' => $access->getId(),
280                        ] );
281                    } elseif ( $localUser->isLocked() ||
282                        ( $config->get( 'BlockDisablesLogin' ) && $localUser->getBlock() )
283                    ) {
284                        throw new MWOAuthException( 'mwoauth-invalid-authorization-blocked-user', [
285                            'consumer' => $consumer->getConsumerKey(),
286                            'consumer_name' => $consumer->getName(),
287                            'user_name' => $localUser->getName(),
288                        ] );
289                    }
290                    // Access token is for this wiki
291                    if ( $access->getWiki() !== '*' && $access->getWiki() !== $wiki ) {
292                        throw new MWOAuthException(
293                            'mwoauth-invalid-authorization-wrong-wiki',
294                            [
295                                'request_wiki' => $wiki,
296                                'consumer' => $consumer->getConsumerKey(),
297                                'consumer_name' => $consumer->getName(),
298                                'consumer_wiki' => $access->getWiki(),
299                            ]
300                        );
301                    } elseif ( !$consumer->isUsableBy( $localUser ) ) {
302                        throw new MWOAuthException( 'mwoauth-invalid-authorization-not-approved', [
303                            'consumer_name' => $consumer->getName(),
304                            'consumer' => $consumer->getConsumerKey(),
305                            'user_name' => $localUser->getName(),
306                        ] );
307                    }
308
309                    // We know the identity of the user who granted the authorization
310                    $this->outputJWT( $localUser, $consumer, $oauthRequest, $format, $access );
311                    break;
312
313                // Redirect to the REST API. A hack to reuse the OAuth 1 codebase and entry
314                // point for OAuth 2 workflows.
315                case 'rest_redirect':
316                    $query = $this->getRequest()->getQueryValues();
317                    if ( !array_key_exists( 'rest_url', $query ) ) {
318                        throw new OAuthException( 'Invalid redirect' );
319                    }
320                    $restUrl = $query['rest_url'];
321                    // make sure there's no way to change the domain
322                    if ( $restUrl[0] !== '/' ) {
323                        $restUrl = '/' . $restUrl;
324                    }
325                    $target = ( $this->urlUtils->getServer( PROTO_CURRENT ) ?? '' ) . $restUrl;
326
327                    $output->redirect( $target );
328                    break;
329
330                case '':
331                    $this->addHelpLink( 'Help:OAuth' );
332                    $output->addWikiMsg( 'mwoauth-nosubpage-explanation' );
333                    break;
334
335                // Typo in app code? Show an error.
336                default:
337                    $format = $request->getVal( 'format', 'html' );
338                    '@phan-var string $format';
339                    $dbr = Utils::getOAuthDB( DB_REPLICA );
340                    $cmrAc = ConsumerAccessControl::wrap(
341                        Consumer::newFromKey(
342                            $dbr,
343                            $request->getVal( 'oauth_consumer_key', null )
344                        ),
345                        $this->getContext()
346                    );
347
348                    if ( !$cmrAc || !$cmrAc->userCanAccess( 'userId' ) ) {
349                        $this->showError(
350                            $this->msg( 'mwoauth-bad-request-invalid-action' )->rawParams(
351                                Linker::makeExternalLink(
352                                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E002',
353                                    'E002',
354                                    true
355                                )
356                            ),
357                            $format
358                        );
359                    } else {
360                        $owner = $cmrAc->getUserName( $this->getUser() );
361                        $this->showError(
362                            $this->msg( 'mwoauth-bad-request-invalid-action-contact',
363                                Utils::getCentralUserTalk( $owner )
364                            )->rawParams( Linker::makeExternalLink(
365                                'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E003',
366                                'E003',
367                                true
368                            ) ),
369                            $format
370                        );
371                    }
372            }
373        } catch ( MWOAuthException $exception ) {
374            $this->logger->warning( __METHOD__ . ": Exception " . $exception->getNormalizedMessage(),
375                [ 'exception' => $exception ] + $exception->getMessageContext() );
376            $this->showError( $this->msg( $exception->getMessageObject() ), $format );
377        } catch ( OAuthException $exception ) {
378            $this->logger->warning( __METHOD__ . ": Exception " . $exception->getMessage(),
379                [ 'exception' => $exception ] );
380            $this->showError(
381                $this->msg( 'mwoauth-oauth-exception', $exception->getMessage() ),
382                $format
383            );
384        }
385
386        $output->addModuleStyles( 'ext.MWOAuth.styles' );
387    }
388
389    /**
390     * @param string $consumerKey
391     * @throws MWOAuthException
392     */
393    protected function showCancelPage( $consumerKey ) {
394        $dbr = Utils::getOAuthDB( DB_REPLICA );
395        $cmrAc = ConsumerAccessControl::wrap(
396            Consumer::newFromKey( $dbr, $consumerKey ),
397            $this->getContext()
398        );
399        if ( !$cmrAc ) {
400            throw new MWOAuthException( 'mwoauth-invalid-consumer-key', [
401                'consumer' => $consumerKey,
402            ] );
403        }
404
405        if ( $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) {
406            // Respond to client with user approval denied error
407            $this->redirectToREST( [
408                'approval_cancel' => 1
409            ] );
410            return;
411        }
412
413        $output = $this->getOutput();
414
415        $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
416        $output->addWikiMsg(
417            'mwoauth-acceptance-cancelled',
418            $cmrAc->getName()
419        );
420        if ( $this->getRequest()->getRawVal( 'display' ) !== 'popup' ) {
421            $output->addReturnTo( Title::newMainPage() );
422        }
423    }
424
425    /**
426     * Make statements about the user, and sign the json with
427     * a key shared with the Consumer.
428     * @param User $user the user who is the subject of this request
429     * @param Consumer $consumer
430     * @param MWOAuthRequest $request
431     * @param string $format the format of the response: raw, json, or html
432     * @param ConsumerAcceptance $access
433     */
434    protected function outputJWT( $user, $consumer, $request, $format, $access ) {
435        $grants = $access->getGrants();
436        $userStatementProvider = UserStatementProvider::factory( $user, $consumer, $grants );
437
438        $statement = $userStatementProvider->getUserStatement();
439        // String value used to associate a Client session with an ID Token, and to mitigate
440        // replay attacks. The value is passed through unmodified from the Authorization Request.
441        $statement['nonce'] = $request->get_parameter( 'oauth_nonce' );
442        $JWT = JWT::encode( $statement, $consumer->secret, 'HS256' );
443        $this->showResponse( $JWT, $format );
444    }
445
446    /**
447     * @param string|null $requestToken
448     * @param string|null $consumerKey
449     * @param bool $authenticate
450     */
451    protected function handleAuthorizationForm( $requestToken, $consumerKey, $authenticate ) {
452        $output = $this->getOutput();
453
454        $output->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() );
455        $user = $this->getUser();
456
457        $oauthServer = Utils::newMWOAuthServer();
458
459        if ( !$consumerKey && $requestToken && $this->oauthVersion === Consumer::OAUTH_VERSION_1 ) {
460            $consumerKey = $oauthServer->getConsumerKey( $requestToken );
461        }
462
463        $cmrAc = ConsumerAccessControl::wrap(
464            Consumer::newFromKey( Utils::getOAuthDB( DB_REPLICA ), $consumerKey ),
465            $this->getContext()
466        );
467
468        if ( !$cmrAc || !$cmrAc->userCanAccess( [ 'name', 'userId', 'grants' ] ) ) {
469            throw new MWOAuthException( 'mwoauthserver-bad-consumer-key', [
470                Message::rawParam( Linker::makeExternalLink(
471                    'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E006',
472                    'E006',
473                    true
474                ) ),
475                'consumer' => $consumerKey,
476            ] );
477        } elseif ( $cmrAc->getDAO()->getOAuthVersion() !== $this->oauthVersion ) {
478            throw new MWOAuthException(
479                'mwoauthserver-bad-consumer-version',
480                [
481                    Utils::getCentralUserTalk( $cmrAc->getUserName() ),
482                    Message::rawParam( Linker::makeExternalLink(
483                        'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E012',
484                        'E012',
485                        true
486                    ) )
487                ]
488            );
489        } elseif ( !$cmrAc->getDAO()->isUsableBy( $user ) ) {
490            throw new MWOAuthException(
491                'mwoauthserver-bad-consumer',
492                [
493                    $cmrAc->getName(),
494                    Utils::getCentralUserTalk( $cmrAc->getUserName() ),
495                ]
496            );
497        }
498
499        $existing = $cmrAc->getDAO()->getCurrentAuthorization( $user, WikiMap::getCurrentWikiId() );
500
501        // If only authentication was requested, and the existing authorization
502        // matches, and the only grants are 'mwoauth-authonly' or 'mwoauth-authonlyprivate',
503        // then don't bother prompting the user about it.
504        if ( $existing && $authenticate &&
505            $existing->getWiki() === $cmrAc->getDAO()->getWiki() &&
506            $existing->getGrants() === $cmrAc->getDAO()->getGrants() &&
507             !array_diff( $existing->getGrants(), SubmitControl::AUTH_ONLY_GRANTS )
508        ) {
509            if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
510                $this->redirectToREST( [
511                    'approval_pass' => true
512                ] );
513            } else {
514                $callback = $cmrAc->getDAO()->authorize(
515                    $user, false, $cmrAc->getDAO()->getGrants(), $requestToken
516                );
517                $output->redirect( $callback );
518            }
519            return;
520        }
521
522        $control = new ConsumerAcceptanceSubmitControl(
523            $this->getContext(), [], Utils::getOAuthDB( DB_PRIMARY ), $this->oauthVersion
524        );
525
526        $form = HTMLForm::factory( 'ooui',
527            $control->registerValidators( $this->getRequestValidators( [
528                'existing' => $existing,
529                'consumerKey' => $consumerKey,
530                'requestToken' => $requestToken
531            ] ) ),
532            $this->getContext()
533        );
534        $form->setSubmitCallback(
535            static function ( array $data, IContextSource $context ) use ( $control ) {
536                if ( $context->getRequest()->getCheck( 'cancel' ) ) {
537                    throw new MWException( 'Received request for a form cancellation.' );
538                }
539                $control->setInputParameters( $data );
540                return $control->submit();
541            }
542        );
543        $form->setId( 'mw-mwoauth-authorize-form' );
544        $form->addHiddenField( 'display', $this->getRequest()->getRawVal( 'display' ) );
545
546        // Possible messages are:
547        // * mwoauth-form-description-allwikis
548        // * mwoauth-form-description-onewiki
549        // * mwoauth-form-description-allwikis-nogrants
550        // * mwoauth-form-description-onewiki-nogrants
551        // * mwoauth-form-description-allwikis-privateinfo
552        // * mwoauth-form-description-onewiki-privateinfo
553        // * mwoauth-form-description-allwikis-privateinfo-norealname
554        // * mwoauth-form-description-onewiki-privateinfo-norealname
555        $msgKey = 'mwoauth-form-description';
556        $params = [
557            $this->getUser()->getName(),
558            $cmrAc->getName(),
559            $cmrAc->getUserName(),
560        ];
561        if ( $cmrAc->getWiki() === '*' ) {
562            $msgKey .= '-allwikis';
563        } else {
564            $msgKey .= '-onewiki';
565            $params[] = $cmrAc->getWikiName();
566        }
567        $grants = $cmrAc->getGrants();
568        if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
569            $grants = $this->getRequestedGrants( $cmrAc );
570        }
571
572        $grantsText = $this->grantsLocalization->getGrantsWikiText( $grants, $this->getLanguage() );
573        if ( $grantsText === "\n" ) {
574            if ( in_array( 'mwoauth-authonlyprivate', $cmrAc->getGrants(), true ) ) {
575                $msgKey .= '-privateinfo';
576                if ( !$this->useRealNames() ) {
577                    // If the wiki does not use real names, don't mention them in the authorization
578                    // dialog to avoid scaring users. The wiki where the authorization dialog is
579                    // shown and the wiki where the user is actually identified might be different;
580                    // there's not much we can do about that here so it is left to the wiki
581                    // administrator to set up the farm in a non-misleading way.
582                    $msgKey .= '-norealname';
583                }
584            } else {
585                $msgKey .= '-nogrants';
586            }
587        } else {
588            $params[] = $grantsText;
589        }
590        $form->addHeaderHtml( $this->msg( $msgKey, $params )->parseAsBlock() );
591        $form->addHeaderHtml( $this->msg( 'mwoauth-form-legal' )->text() );
592
593        $form->suppressDefaultSubmit();
594        $form->addButton( [
595            'name' => 'accept',
596            'value' => $this->msg( 'mwoauth-form-button-approve' )->text(),
597            'id' => 'mw-mwoauth-accept',
598            'attribs' => [
599                'class' => 'mw-mwoauth-authorize-button'
600            ],
601            'flags' => [ 'primary', 'progressive' ],
602        ] );
603        $form->addButton( [
604            'name' => 'cancel',
605            'value' => $this->msg( 'mwoauth-form-button-cancel' )->text(),
606            'attribs' => [
607                'class' => 'mw-mwoauth-authorize-button'
608            ],
609            'framed' => false,
610        ] );
611
612        if ( $this->getRequest()->getRawVal( 'display' ) !== 'popup' ) {
613            // If not in popup mode, duplicate the "Privacy policy" link from the site footer for
614            // easy access, as the overlaid dialog would cover it. In popup mode, the site footer
615            // with the link is easily accessible below the form, so don't duplicate it.
616            // This should be replaced with the app's own privacy policy link eventually (T64686).
617            $form->addFooterHtml( $this->makePrivacyLink() );
618        }
619
620        $out = $this->getOutput();
621        $out->enableOOUI();
622        $out->addModuleStyles( 'ext.MWOAuth.AuthorizeForm' );
623        if ( $this->getRequest()->getRawVal( 'display' ) !== 'popup' ) {
624            $out->addModules( 'ext.MWOAuth.AuthorizeDialog' );
625        }
626
627        $form->prepareForm();
628        $status = $form->tryAuthorizedSubmit();
629
630        $out->addHtml( new OOUI\PanelLayout( [
631            'id' => 'mw-mwoauth-authorize-panel',
632            'expanded' => false,
633            'content' => new HtmlSnippet( $form->getHTML( $status ) ),
634        ] ) );
635
636        if ( $status instanceof Status && $status->isOK() ) {
637            if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
638                $this->redirectToREST( [
639                    'approval_pass' => true
640                ] );
641            } else {
642                // Redirect to callback url
643                $output->redirect( $status->value['result']['callbackUrl'] );
644            }
645        }
646    }
647
648    private function redirectToREST( array $queryAppend = [] ) {
649        $redirectParams = [
650            'returnto' => $this->getRequest()->getText(
651                'returnto', $this->getRequest()->getText( 'returnto' )
652            ),
653            'returntoquery' => wfCgiToArray(
654                $this->getRequest()->getText(
655                    'returntoquery', $this->getRequest()->getText( 'returntoquery' )
656                )
657            )
658        ];
659
660        $expanded = $this->urlUtils->expand( $redirectParams['returnto'] );
661        if ( !$expanded ) {
662            return;
663        }
664
665        $returnToQuery = array_merge(
666            $redirectParams['returntoquery'],
667            $queryAppend
668        );
669        $returnToQuery = wfArrayToCgi( $returnToQuery );
670
671        $output = $this->getOutput();
672        $output->disable();
673        $output->getRequest()->response()->header(
674            "Location: $expanded?$returnToQuery"
675        );
676    }
677
678    /**
679     * @param array $data
680     * @return array[]
681     */
682    private function getRequestValidators( $data = [] ) {
683        $validators = [
684            'action' => [
685                'type'    => 'hidden',
686                'default' => 'accept',
687            ],
688            'confirmUpdate' => [
689                'type'    => 'hidden',
690                'default' => $data['existing'] ? 1 : 0,
691            ],
692            'oauth_version' => [
693                'name' => 'oauth_version',
694                'type' => 'hidden',
695                'default' => $this->oauthVersion
696            ],
697        ];
698        if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) {
699            $validators += [
700                'client_id' => [
701                    'name' => 'client_id',
702                    'type' => 'hidden',
703                    'default' => $this->getRequest()->getText( 'client_id' )
704                ],
705                'scope' => [
706                    'name' => 'scope',
707                    'type' => 'hidden',
708                    'default' => $this->getRequest()->getText( 'scope' )
709                ],
710                'returnto' => [
711                    'name' => 'returnto',
712                    'type'    => 'hidden',
713                    'default' => $this->getRequest()->getText( 'returnto' )
714                ],
715                'returntoquery' => [
716                    'name'    => 'returntoquery',
717                    'type'    => 'hidden',
718                    'default' => $this->getRequest()->getText( 'returntoquery' )
719                ],
720            ];
721        } else {
722            $validators += [
723                'consumerKey' => [
724                    'name'    => 'consumerKey',
725                    'type'    => 'hidden',
726                    'default' => $data['consumerKey']
727                ],
728                'requestToken' => [
729                    'name'    => 'requestToken',
730                    'type'    => 'hidden',
731                    'default' => $data['requestToken'],
732                ],
733            ];
734        }
735
736        return $validators;
737    }
738
739    /**
740     * OAuth 2.0 only
741     * Get only the grants (scopes) that were actually requested (and are allowed)
742     *
743     * @param ConsumerAccessControl $cmrAc
744     * @return string[]
745     */
746    private function getRequestedGrants( $cmrAc ) {
747        $allowed = $cmrAc->getGrants();
748        $requested = explode( ' ', $this->getRequest()->getText( 'scope', '' ) );
749
750        return array_intersect( $requested, $allowed );
751    }
752
753    /**
754     * @param Message $message to return to the user
755     * @param string $format the format of the response: html, raw, or json
756     */
757    private function showError( $message, $format ) {
758        if ( $format == 'raw' ) {
759            $this->showResponse( 'Error: ' . $message->escaped(), 'raw' );
760        } elseif ( $format == 'json' ) {
761            $error = FormatJson::encode( [
762                'error' => $message->getKey(),
763                'message' => $message->text(),
764            ] );
765            $this->showResponse( $error, 'json' );
766        } elseif ( $format == 'html' ) {
767            $this->getOutput()->showErrorPage( 'mwoauth-error', $message );
768        }
769    }
770
771    /**
772     * @param OAuthToken $token
773     * @param string $format the format of the response: html, raw, or json
774     */
775    private function returnToken( OAuthToken $token, $format ) {
776        if ( $format == 'raw' ) {
777            $return = 'oauth_token=' . OAuthUtil::urlencode_rfc3986( $token->key );
778            $return .= '&oauth_token_secret=' . OAuthUtil::urlencode_rfc3986( $token->secret );
779            $return .= '&oauth_callback_confirmed=true';
780            $this->showResponse( $return, 'raw' );
781        } elseif ( $format == 'json' ) {
782            $this->showResponse( FormatJson::encode( $token ), 'json' );
783        } elseif ( $format == 'html' ) {
784            $html = Html::element(
785                'li',
786                [],
787                'oauth_token = ' . OAuthUtil::urlencode_rfc3986( $token->key )
788            );
789            $html .= Html::element(
790                'li',
791                [],
792                'oauth_token_secret = ' . OAuthUtil::urlencode_rfc3986( $token->secret )
793            );
794            $html .= Html::element(
795                'li',
796                [],
797                'oauth_callback_confirmed = true'
798            );
799            $html = Html::rawElement( 'ul', [], $html );
800            $this->showResponse( $html, 'html' );
801        }
802    }
803
804    /**
805     * @param string $data html or string to pass back to the user. Already escaped.
806     * @param string $format the format of the response: raw, json, or html
807     * @param-taint $data escaped
808     */
809    private function showResponse( $data, $format ) {
810        $out = $this->getOutput();
811        if ( $format == 'raw' || $format == 'json' ) {
812            $out->disable();
813            // Cancel output buffering and gzipping if set
814            wfResetOutputBuffers();
815            // We must not allow the output to be Squid cached
816            $response = $this->getRequest()->response();
817            $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
818            $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
819            $response->header( 'Pragma: no-cache' );
820            $response->header( 'Content-length: ' . strlen( $data ) );
821            if ( $format == 'json' ) {
822                $response->header( 'Content-type: application/json' );
823            } else {
824                $response->header( 'Content-type: text/plain' );
825            }
826            print $data;
827        } elseif ( $format == 'html' ) {
828            $out->addHTML( $data );
829        }
830    }
831
832    /**
833     * Check whether the wiki is configured to use/show real names.
834     * We assume that either all or none of the OAuth wikis in a farm use real names.
835     * @return bool
836     */
837    private function useRealNames() {
838        $config = $this->getContext()->getConfig();
839        return !in_array( 'realname', $config->get( 'HiddenPrefs' ), true );
840    }
841
842    /**
843     * Get the requested OAuth version from the request
844     *
845     * @param WebRequest $request
846     * @return int
847     */
848    private function determineOAuthVersion( WebRequest $request ) {
849        $this->oauthVersion = $request->getInt( 'oauth_version', Consumer::OAUTH_VERSION_1 );
850
851        return $this->oauthVersion;
852    }
853
854    /**
855     * @param int $allowed Allowed version
856     * @throws MWOAuthException
857     */
858    private function assertOAuthVersion( $allowed ) {
859        if ( $this->oauthVersion !== $allowed ) {
860            throw new MWOAuthException(
861                'mwoauth-oauth-unsupported-version',
862                [ $this->oauthVersion ]
863            );
864        }
865    }
866
867    private function makePrivacyLink(): string {
868        // If the link description has been disabled in the default language,
869        if ( $this->msg( 'privacy' )->inContentLanguage()->isDisabled() ) {
870            // then it is disabled, for all languages.
871            $title = null;
872        } else {
873            // Otherwise, we display the link for the user, described in their
874            // language (which may or may not be the same as the default language),
875            // but we make the link target be the one site-wide page.
876            $title = Title::newFromText( $this->msg( 'privacypage' )->inContentLanguage()->text() );
877        }
878
879        if ( !$title ) {
880            return '';
881        }
882
883        return $this->getLinkRenderer()->makeKnownLink(
884            $title,
885            $this->msg( 'privacy' )->text()
886        );
887    }
888}