Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 505
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMWOAuthConsumerRegistration
0.00% covered (danger)
0.00%
0 / 505
0.00% covered (danger)
0.00%
0 / 10
4160
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
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
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 displayRestrictionError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 180
0.00% covered (danger)
0.00%
0 / 1
1122
 addSubtitleLinks
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
90
 formatRow
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 proposeOAuth
0.00% covered (danger)
0.00%
0 / 210
0.00% covered (danger)
0.00%
0 / 1
132
 fillDefaultFields
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\OAuth\Frontend\SpecialPages;
4
5/**
6 * (c) Aaron Schulz 2013, GPL
7 *
8 * @license GPL-2.0-or-later
9 */
10
11use InvalidArgumentException;
12use MediaWiki\Exception\ErrorPageError;
13use MediaWiki\Exception\PermissionsError;
14use MediaWiki\Exception\UserBlockedError;
15use MediaWiki\Extension\OAuth\Backend\Consumer;
16use MediaWiki\Extension\OAuth\Backend\Utils;
17use MediaWiki\Extension\OAuth\Control\ConsumerAccessControl;
18use MediaWiki\Extension\OAuth\Control\ConsumerSubmitControl;
19use MediaWiki\Extension\OAuth\Frontend\Pagers\ListMyConsumersPager;
20use MediaWiki\Extension\OAuth\Frontend\UIUtils;
21use MediaWiki\Html\Html;
22use MediaWiki\HTMLForm\Field\HTMLHiddenField;
23use MediaWiki\HTMLForm\Field\HTMLRestrictionsField;
24use MediaWiki\HTMLForm\HTMLForm;
25use MediaWiki\Json\FormatJson;
26use MediaWiki\Logging\LogEventsList;
27use MediaWiki\Logging\LogPage;
28use MediaWiki\Message\Message;
29use MediaWiki\Permissions\GrantsInfo;
30use MediaWiki\Permissions\GrantsLocalization;
31use MediaWiki\Permissions\PermissionManager;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\Status\Status;
34use MediaWiki\User\User;
35use MediaWiki\Utils\UrlUtils;
36use MediaWiki\WikiMap\WikiMap;
37use MWRestrictions;
38use stdClass;
39use Wikimedia\Rdbms\IDatabase;
40use Wikimedia\Timestamp\TimestampFormat;
41
42/**
43 * Page that has registration request form and consumer update form
44 */
45class SpecialMWOAuthConsumerRegistration extends SpecialPage {
46    public function __construct(
47        private readonly PermissionManager $permissionManager,
48        private readonly GrantsInfo $grantsInfo,
49        private readonly GrantsLocalization $grantsLocalization,
50        private readonly UrlUtils $urlUtils,
51    ) {
52        parent::__construct( 'OAuthConsumerRegistration' );
53    }
54
55    /** @inheritDoc */
56    public function doesWrites() {
57        return true;
58    }
59
60    /** @inheritDoc */
61    public function userCanExecute( User $user ) {
62        return $user->isEmailConfirmed();
63    }
64
65    /** @inheritDoc */
66    public function displayRestrictionError() {
67        throw new PermissionsError( null, [ 'mwoauthconsumerregistration-need-emailconfirmed' ] );
68    }
69
70    /** @inheritDoc */
71    public function execute( $par ) {
72        $this->setHeaders();
73        $this->getOutput()->disallowUserJs();
74        $this->addHelpLink( 'Help:OAuth' );
75
76        if ( !Utils::isCentralWiki() ) {
77            $this->getOutput()->addWikiMsg( 'mwoauth-consumers-central-wiki' );
78            $wiki = WikiMap::getWiki( Utils::getCentralWiki() ?: WikiMap::getCurrentWikiId() );
79            if ( $wiki ) {
80                $this->getOutput()->addHTML( Html::element( 'a', [
81                    // Cross-wiki, so don't localize
82                    'href' => $wiki->getUrl( 'Special:OAuthConsumerRegistration' . ( $par !== null ? "/$par" : '' ) ),
83                ], $this->msg( 'mwoauth-consumers-central-wiki-go', $wiki->getDisplayName() )->text() ) );
84            }
85            return;
86        }
87
88        $this->requireNamedUser( 'mwoauth-named-account-required-reason' );
89
90        $this->checkPermissions();
91
92        $request = $this->getRequest();
93        $user = $this->getUser();
94        $centralUserId = Utils::getCentralIdFromLocalUser( $user );
95
96        // Redirect to HTTPs if attempting to access this page via HTTP.
97        // Proposals and updates to consumers can involve sending new secrets.
98        if ( $this->getConfig()->get( 'MWOAuthSecureTokenTransfer' )
99            && $request->getProtocol() === 'http'
100            && str_starts_with( $this->urlUtils->expand( '/', PROTO_HTTPS ) ?? '', 'https://' )
101        ) {
102            $redirUrl = str_replace( 'http://', 'https://', $request->getFullRequestURL() );
103            $this->getOutput()->redirect( $redirUrl );
104            $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
105            return;
106        }
107
108        $this->getOutput()->addModules( 'mediawiki.special' );
109
110        $block = $user->getBlock();
111        if ( $block ) {
112            throw new UserBlockedError( $block );
113        }
114        $this->checkReadOnly();
115
116        // Format is Special:OAuthConsumerRegistration[/propose/<oauth1a|oauth2>|/list|/update/<consumer key>]
117        $navigation = $par !== null ? explode( '/', $par ) : [];
118        $action = $navigation[0] ?? '';
119        $subPage = $navigation[1] ?? '';
120
121        if ( $this->getConfig()->get( 'MWOAuthReadOnly' ) && $action !== 'list' ) {
122            throw new ErrorPageError( 'mwoauth-error', 'mwoauth-db-readonly' );
123        }
124
125        switch ( $action ) {
126            case 'propose':
127                if ( !$this->permissionManager->userHasRight( $user, 'mwoauthproposeconsumer' ) ) {
128                    throw new PermissionsError( 'mwoauthproposeconsumer' );
129                }
130
131                if ( $subPage === '' ) {
132                    $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-propose-text' );
133                    break;
134                }
135
136                $allWikis = Utils::getAllWikiNames();
137                $showGrants = $this->grantsInfo->getValidGrants();
138                if ( $subPage === 'oauth2' ) {
139                    $this->proposeOAuth( Consumer::OAUTH_VERSION_2, $user, $allWikis, $showGrants );
140                    break;
141                } elseif ( $subPage === 'oauth1a' ) {
142                    $this->proposeOAuth( Consumer::OAUTH_VERSION_1, $user, $allWikis, $showGrants );
143                    break;
144                } else {
145                    $this->getOutput()->redirect( 'Special:OAuthConsumerRegistration/propose' );
146                }
147                break;
148            case 'update':
149                if ( !$this->permissionManager->userHasRight( $user, 'mwoauthupdateownconsumer' ) ) {
150                    throw new PermissionsError( 'mwoauthupdateownconsumer' );
151                }
152
153                $dbr = Utils::getOAuthDB( DB_REPLICA );
154                $cmrAc = ConsumerAccessControl::wrap(
155                Consumer::newFromKey( $dbr, $subPage ), $this->getContext() );
156                if ( !$cmrAc ) {
157                    $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' );
158                    break;
159                } elseif ( $cmrAc->getDAO()->getDeleted()
160                && !$this->permissionManager->userHasRight( $user, 'mwoauthviewsuppressed' )
161                ) {
162                    throw new PermissionsError( 'mwoauthviewsuppressed' );
163                } elseif ( $cmrAc->getDAO()->getUserId() !== $centralUserId ) {
164                    // Do not show private information to other users
165                    $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' );
166                    break;
167                }
168                $oldSecretKey = $cmrAc->getDAO()->getSecretKey();
169
170                $dbw = Utils::getOAuthDB( DB_PRIMARY );
171                $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw );
172                $form = HTMLForm::factory( 'ooui',
173                    $control->registerValidators( [
174                        'info' => [
175                            'type' => 'info',
176                            'raw' => true,
177                            'default' => UIUtils::generateInfoTable( [
178                                'mwoauth-consumer-name' => $cmrAc->getName(),
179                                'mwoauth-consumer-version' => $cmrAc->getVersion(),
180                                'mwoauth-oauth-version' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ?
181                                    $this->msg( 'mwoauth-oauth-version-2' )->text() :
182                                    $this->msg( 'mwoauth-oauth-version-1' )->text(),
183                                'mwoauth-consumer-key' => $cmrAc->getConsumerKey(),
184                            ], $this->getContext() ),
185                        ],
186                        'restrictions' => [
187                            'class' => HTMLRestrictionsField::class,
188                            'required' => true,
189                            'default' => $cmrAc->getDAO()->getRestrictions(),
190                        ],
191                        'resetSecret' => [
192                            'type' => 'check',
193                            'label-message' => 'mwoauthconsumerregistration-resetsecretkey',
194                            'default' => false,
195                        ],
196                        'rsaKey' => [
197                            'type' => 'textarea',
198                            'label-message' => 'mwoauth-consumer-rsakey',
199                            'required' => false,
200                            'default' => $cmrAc->getDAO()->getRsaKey(),
201                            'rows' => 5,
202                        ],
203                        'reason' => [
204                            'type' => 'text',
205                            'label-message' => 'mwoauth-consumer-reason',
206                            'required' => !$cmrAc->getOwnerOnly(),
207                        ],
208                        'consumerKey' => [
209                            'type' => 'hidden',
210                            'default' => $cmrAc->getConsumerKey(),
211                        ],
212                        'changeToken' => [
213                            'type'    => 'hidden',
214                            'default' => $cmrAc->getDAO()->getChangeToken( $this->getContext() ),
215                        ],
216                        'action' => [
217                            'type'    => 'hidden',
218                                'default' => 'update'
219                        ]
220                    ] ),
221                    $this->getContext()
222                );
223                $form->setSubmitCallback(
224                    static function ( array $data ) use ( $control ) {
225                        $control->setInputParameters( $data );
226                        return $control->submit();
227                    }
228                );
229                $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-update-legend' );
230                $form->setSubmitTextMsg( 'mwoauthconsumerregistration-update-submit' );
231                $form->addPreHtml(
232                    $this->msg( 'mwoauthconsumerregistration-update-text' )->parseAsBlock() );
233
234                $status = $form->show();
235                if ( $status instanceof Status && $status->isOK() ) {
236                    /** @var Consumer $cmr */
237                    $cmr = $status->value['result']['consumer'];
238                    $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-updated' );
239                    $curSecretKey = $cmr->getSecretKey();
240                    // token reset?
241                    if ( $oldSecretKey !== $curSecretKey ) {
242                        if ( $cmr->getOwnerOnly() ) {
243                            $accessToken = $status->value['result']['accessToken'];
244                            if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) {
245                                // If we just add raw AT to the page, it would go 3000px wide
246                                $accessToken = Html::element( 'span', [
247                                    'style' => 'overflow-wrap: break-word'
248                                ], (string)$accessToken );
249
250                                $this->getOutput()->addWikiMsg(
251                                    'mwoauthconsumerregistration-secretreset-owner-only-oauth2',
252                                    $cmr->getConsumerKey(),
253                                    Utils::hmacDBSecret( $cmr->getSecretKey() ),
254                                    Message::rawParam( $accessToken )
255                                );
256                            } else {
257                                $this->getOutput()->addWikiMsg(
258                                    'mwoauthconsumerregistration-secretreset-owner-only-oauth1',
259                                    $cmr->getConsumerKey(),
260                                    Utils::hmacDBSecret( $curSecretKey ),
261                                    $accessToken->key,
262                                    Utils::hmacDBSecret( $accessToken->secret )
263                                );
264                            }
265                        } else {
266                            $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-secretreset',
267                                Utils::hmacDBSecret( $curSecretKey ) );
268                        }
269                    }
270                    $this->getOutput()->returnToMain();
271                } else {
272                    $out = $this->getOutput();
273                    // Show all of the status updates
274                    $logPage = new LogPage( 'mwoauthconsumer' );
275                    $out->addHTML( Html::element( 'h2', [], $logPage->getName()->text() ) );
276                    LogEventsList::showLogExtract( $out, 'mwoauthconsumer', '', '', [
277                        'conds'  => [
278                            'ls_field' => 'OAuthConsumer',
279                            'ls_value' => $cmrAc->getConsumerKey(),
280                        ],
281                        'flags'  => LogEventsList::NO_EXTRA_USER_LINKS,
282                    ] );
283                }
284                break;
285            case 'list':
286                $pager = new ListMyConsumersPager( $this, [], $centralUserId );
287                if ( $pager->getNumRows() ) {
288                    $this->getOutput()->addHTML( $pager->getNavigationBar() );
289                    $this->getOutput()->addHTML( $pager->getBody() );
290                    $this->getOutput()->addHTML( $pager->getNavigationBar() );
291                } else {
292                    $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-none" );
293                }
294                # Every 30th view, prune old deleted items
295                if ( mt_rand( 0, 29 ) == 0 ) {
296                    Utils::runAutoMaintenance( Utils::getOAuthDB( DB_PRIMARY ) );
297                }
298                break;
299            default:
300                $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-maintext' );
301        }
302
303        $this->addSubtitleLinks( $action, $subPage );
304
305        $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' );
306    }
307
308    /**
309     * Show navigation links
310     *
311     * @param string $action
312     * @param string $subPage
313     * @return void
314     */
315    protected function addSubtitleLinks( $action, $subPage ) {
316        $listLinks = [];
317        if ( $action === 'propose' && $subPage ) {
318            if ( $subPage === 'oauth1a' ) {
319                $listLinks[] = $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->escaped();
320                $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
321                    $this->getPageTitle( 'propose/oauth2' ),
322                    $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->text()
323                );
324            } elseif ( $subPage === 'oauth2' ) {
325                $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
326                    $this->getPageTitle( 'propose/oauth1a' ),
327                    $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->text()
328                );
329                $listLinks[] = $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->escaped();
330            }
331        } else {
332            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
333                $this->getPageTitle( 'propose/oauth1a' ),
334                $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->text()
335            );
336            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
337                $this->getPageTitle( 'propose/oauth2' ),
338                $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->text()
339            );
340        }
341        if ( $subPage || $action !== 'list' ) {
342            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
343                $this->getPageTitle( 'list' ),
344                $this->msg( 'mwoauthconsumerregistration-list' )->text()
345            );
346        } else {
347            $listLinks[] = $this->msg( 'mwoauthconsumerregistration-list' )->escaped();
348        }
349        if ( $subPage && $action === 'update' ) {
350            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
351                SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$subPage" ),
352                $this->msg( 'mwoauthconsumer-consumer-view' )->text()
353            );
354        }
355
356        $linkHtml = $this->getLanguage()->pipeList( $listLinks );
357
358        $viewall = $this->msg( 'parentheses' )->rawParams(
359            $this->getLinkRenderer()->makeKnownLink(
360                $this->getPageTitle(),
361                $this->msg( 'mwoauthconsumerregistration-main' )->text()
362            )
363        )->escaped();
364
365        $this->getOutput()->setSubtitle(
366            "<strong>" . $this->msg( 'mwoauthconsumerregistration-navigation' )->escaped() .
367            "</strong> [{$linkHtml}] <strong>{$viewall}</strong>" );
368    }
369
370    /**
371     * @param IDatabase $db
372     * @param stdClass $row
373     * @return string
374     */
375    public function formatRow( IDatabase $db, $row ) {
376        $cmrAc = ConsumerAccessControl::wrap(
377            Consumer::newFromRow( $db, $row ), $this->getContext() );
378        $cmrKey = $cmrAc->getConsumerKey();
379
380        $links = [];
381        $links[] = $this->getLinkRenderer()->makeKnownLink(
382            SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$cmrKey" ),
383            $this->msg( 'mwoauthlistconsumers-view' )->text()
384        );
385
386        $links[] = $this->getLinkRenderer()->makeKnownLink(
387            $this->getPageTitle( 'update/' . $cmrKey ),
388            $this->msg( 'mwoauthconsumerregistration-manage' )->text()
389        );
390
391        $links = $this->getLanguage()->pipeList( $links );
392
393        $time = htmlspecialchars( $this->getLanguage()->timeanddate(
394            wfTimestamp( TimestampFormat::MW, $cmrAc->getRegistration() ), true ) );
395
396        $stageKey = Consumer::$stageNames[$cmrAc->getStage()];
397        $encStageKey = htmlspecialchars( $stageKey );
398        // Show last log entry (@TODO: title namespace?)
399        // @TODO: inject DB
400        $logHtml = '';
401        LogEventsList::showLogExtract( $logHtml, 'mwoauthconsumer', '', '', [
402            'conds'  => [
403                'ls_field' => 'OAuthConsumer',
404                'ls_value' => $cmrAc->getConsumerKey(),
405            ],
406            'lim'    => 1,
407            'flags'  => LogEventsList::NO_EXTRA_USER_LINKS,
408        ] );
409
410        $lang = $this->getLanguage();
411        $oauthVersionMessage = $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ?
412            $this->msg( 'mwoauth-oauth-version-2' )->text() :
413            $this->msg( 'mwoauth-oauth-version-1' )->text();
414        $data = [
415            'mwoauthconsumerregistration-name' => $cmrAc->escapeForHtml( $cmrAc->getNameAndVersion() ),
416            'mwoauth-oauth-version' => $cmrAc->escapeForHtml( $oauthVersionMessage ),
417            // Messages: mwoauth-consumer-stage-proposed, mwoauth-consumer-stage-rejected,
418            // mwoauth-consumer-stage-expired, mwoauth-consumer-stage-approved,
419            // mwoauth-consumer-stage-disabled
420            'mwoauthconsumerregistration-stage' =>
421                $this->msg( "mwoauth-consumer-stage-$stageKey" )->escaped(),
422            'mwoauthconsumerregistration-description' => $cmrAc->escapeForHtml(
423                $cmrAc->get( 'description', static function ( $s ) use ( $lang ) {
424                    return $lang->truncateForVisual( $s, 10024 );
425                } )
426            ),
427            'mwoauthconsumerregistration-email' => $cmrAc->escapeForHtml( $cmrAc->getEmail() ),
428            'mwoauthconsumerregistration-consumerkey' => $cmrAc->escapeForHtml( $cmrAc->getConsumerKey() ),
429            'mwoauthconsumerregistration-lastchange' => $logHtml,
430        ];
431
432        $r = "<li class='mw-mwoauthconsumerregistration-{$encStageKey}'>";
433        $r .= "<span class='mw-mwoauth-stage-icon'></span> ";
434        $r .= "<span>$time (<strong>{$links}</strong>)</span>";
435        $r .= "<table class='mw-mwoauthconsumerregistration-body mw-datatable'>";
436        foreach ( $data as $msg => $encValue ) {
437            $r .= '<tr>' .
438                '<th>' . $this->msg( $msg )->escaped() . '</th>' .
439                '<td width=\'90%\'>' . $encValue . '</td>' .
440                '</tr>';
441        }
442        $r .= '</table>';
443
444        $r .= '</li>';
445
446        return $r;
447    }
448
449    /** @inheritDoc */
450    protected function getGroupName() {
451        return 'users';
452    }
453
454    /**
455     * @param int $oauthVersion
456     * @param User $user
457     * @param string[] $allWikis
458     * @param array $showGrants
459     */
460    private function proposeOAuth( int $oauthVersion, User $user, $allWikis, $showGrants ) {
461        if ( !in_array( $oauthVersion, [ Consumer::OAUTH_VERSION_1, Consumer::OAUTH_VERSION_2 ] ) ) {
462            throw new InvalidArgumentException( 'Invalid OAuth version' );
463        }
464        $dbw = Utils::getOAuthDB( DB_PRIMARY );
465        $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw );
466
467        $grantNames = $this->grantsLocalization->getGrantDescriptionsWithClasses(
468            $showGrants, $this->getLanguage() );
469        $formDescriptor = [
470            'oauthVersion' => [
471                'class' => HTMLHiddenField::class,
472                'default' => $oauthVersion,
473            ],
474            'name' => [
475                'type' => 'text',
476                'label-message' => 'mwoauth-consumer-name',
477                'size' => '45',
478                'required' => true
479            ],
480            'version' => [
481                'type' => 'text',
482                'label-message' => 'mwoauth-consumer-version',
483                'required' => true,
484                'default' => "1.0"
485            ],
486            'description' => [
487                'type' => 'textarea',
488                'label-message' => 'mwoauth-consumer-description',
489                'required' => true,
490                'rows' => 5
491            ],
492            'ownerOnly' => [
493                'type' => 'check',
494                'label-message' => [ 'mwoauth-consumer-owner-only', $user->getName() ],
495                'help-message' => [ 'mwoauth-consumer-owner-only-help', $user->getName() ],
496            ],
497            'callbackUrl' => [
498                'type' => 'text',
499                'label-message' => 'mwoauth-consumer-callbackurl',
500                'help-message' => ( $oauthVersion === Consumer::OAUTH_VERSION_2 )
501                    ? 'mwoauth-consumer-callbackurl-help' : null,
502                'required' => true,
503                'hide-if' => [ '!==', 'ownerOnly', '' ],
504            ],
505            'callbackIsPrefix' => [
506                'oauthVersion' => Consumer::OAUTH_VERSION_1,
507                'type' => 'check',
508                'label-message' => 'mwoauth-consumer-callbackisprefix',
509                'hide-if' => [ '!==', 'ownerOnly', '' ],
510            ],
511            'email' => [
512                'type' => 'text',
513                'label-message' => 'mwoauth-consumer-email',
514                'required' => true,
515                'readonly' => true,
516                'default' => $user->getEmail(),
517                'help-message' => 'mwoauth-consumer-email-help',
518            ],
519            'wiki' => [
520                'type' => $allWikis ? 'combobox' : 'select',
521                'options' => [
522                     $this->msg( 'mwoauth-consumer-allwikis' )->escaped() => '*',
523                     $this->msg( 'mwoauth-consumer-wiki-thiswiki', WikiMap::getCurrentWikiId() )
524                         ->escaped() => WikiMap::getCurrentWikiId()
525                 ] + array_flip( $allWikis ),
526                'label-message' => 'mwoauth-consumer-wiki',
527                'required' => true,
528                'default' => '*'
529            ],
530            'oauth2IsConfidential' => [
531                'oauthVersion' => Consumer::OAUTH_VERSION_2,
532                'type' => 'check',
533                'label-message' => 'mwoauth-oauth2-is-confidential',
534                'help-message' => 'mwoauth-oauth2-is-confidential-help',
535                'default' => 1
536            ],
537            'oauth2GrantTypes' => [
538                'oauthVersion' => Consumer::OAUTH_VERSION_2,
539                'type' => 'multiselect',
540                'label-message' => 'mwoauth-oauth2-granttypes',
541                'hide-if' => [ '!==', 'ownerOnly', '' ],
542                'options' => array_filter( [
543                    $this->msg( 'mwoauth-oauth2-granttype-auth-code' )->escaped() => 'authorization_code',
544                    $this->msg( 'mwoauth-oauth2-granttype-refresh-token' )->escaped() => 'refresh_token',
545                    $this->msg( 'mwoauth-oauth2-granttype-client-credentials' )->escaped() => 'client_credentials',
546                ], fn ( $grantType ) => in_array( $grantType, $this->getConfig()->get( 'OAuth2EnabledGrantTypes' ) ) ),
547                'required' => true,
548                'default' => [ 'authorization_code', 'refresh_token' ]
549            ],
550            'granttype' => [
551                'type' => 'radio',
552                'options-messages' => [
553                    'grant-mwoauth-authonly' => 'authonly',
554                    'grant-mwoauth-authonlyprivate' => 'authonlyprivate',
555                    'mwoauth-granttype-normal' => 'normal',
556                ],
557                'label-message' => 'mwoauth-consumer-granttypes',
558                'default' => 'normal',
559            ],
560            // HACK separate field from grants because HTMLFormField cannot position help text on top
561            'grantsHelp' => [
562                'type' => 'info',
563                'default' => '',
564                'help-message' => 'mwoauth-consumer-grantshelp',
565            ],
566            'grants' => [
567                'type' => 'checkmatrix',
568                'label-message' => 'mwoauth-consumer-grantsneeded',
569                'hide-if' => [ '!==', 'granttype', 'normal' ],
570                'columns' => [
571                    $this->msg( 'mwoauth-consumer-required-grant' )->escaped() => 'grant'
572                ],
573                'rows' => array_combine(
574                    $grantNames,
575                    $showGrants
576                ),
577                'tooltips-html' => array_combine(
578                    $grantNames,
579                    array_map(
580                        fn ( $rights ) => Html::rawElement( 'ul', [], implode( '', array_map(
581                            fn ( $right ) => Html::rawElement( 'li', [], $this->msg( "right-$right" )->parse() ),
582                            $rights
583                        ) ) ),
584                        array_intersect_key( $this->grantsInfo->getRightsByGrant(),
585                            array_fill_keys( $showGrants, true ) )
586                    )
587                ),
588                'force-options-on' => array_map(
589                    static function ( $g ) {
590                        return "grant-$g";
591                    },
592                    $this->grantsInfo->getHiddenGrants()
593                ),
594                // different format
595                'validation-callback' => null,
596            ],
597            'restrictions' => [
598                'class' => HTMLRestrictionsField::class,
599                'required' => true,
600                'default' => MWRestrictions::newDefault(),
601            ],
602            'rsaKey' => [
603                'oauthVersion' => Consumer::OAUTH_VERSION_1,
604                'type' => 'textarea',
605                'label-message' => 'mwoauth-consumer-rsakey',
606                'help-message' => 'mwoauth-consumer-rsakey-help',
607                'required' => false,
608                'default' => '',
609                'rows' => 5
610            ],
611            'agreement' => [
612                'type' => 'check',
613                'label-message' => 'mwoauth-consumer-developer-agreement',
614                'required' => true,
615            ],
616            'action' => [
617                'type'    => 'hidden',
618                'default' => 'propose'
619            ]
620        ];
621        $formDescriptor = array_filter( $formDescriptor,
622            static fn ( $field ) => !isset( $field['oauthVersion'] ) || $field['oauthVersion'] === $oauthVersion
623        );
624
625        $form = HTMLForm::factory( 'ooui',
626            $control->registerValidators( $formDescriptor ),
627            $this->getContext()
628        );
629        $form->setSubmitCallback(
630            function ( array $data ) use ( $control ) {
631                // adapt form to controller
632                $data = $this->fillDefaultFields( $data );
633
634                $data['grants'] = FormatJson::encode(
635                    preg_replace( '/^grant-/', '', $data['grants'] )
636                );
637
638                // Force all ownerOnly clients to use client_credentials
639                if ( $data['ownerOnly'] ) {
640                    $data['oauth2GrantTypes'] = [ 'client_credentials' ];
641                }
642
643                $control->setInputParameters( $data );
644                return $control->submit();
645            }
646        );
647        $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-propose-legend' );
648        $form->setSubmitTextMsg( 'mwoauthconsumerregistration-propose-submit' );
649        $form->addPreHtml(
650            // mwoauthconsumerregistration-propose-text-oauth1
651            // mwoauthconsumerregistration-propose-text-oauth2
652            $this->msg( "mwoauthconsumerregistration-propose-text-oauth$oauthVersion" )->parseAsBlock() );
653
654        $status = $form->show();
655        if ( $status instanceof Status && $status->isOK() ) {
656            /** @var Consumer $cmr */
657            $cmr = $status->value['result']['consumer'];
658            if ( $cmr->getOwnerOnly() ) {
659                $accessToken = $status->value['result']['accessToken'];
660                if ( $oauthVersion === Consumer::OAUTH_VERSION_1 ) {
661                    $this->getOutput()->addWikiMsg(
662                        "mwoauthconsumerregistration-created-owner-only-oauth1",
663                        $cmr->getConsumerKey(),
664                        Utils::hmacDBSecret( $cmr->getSecretKey() ),
665                        $accessToken->key,
666                        Utils::hmacDBSecret( $accessToken->secret )
667                    );
668                } else {
669                    // OAuth 2 access tokens are very long
670                    $accessToken = Html::element( 'span', [
671                        'style' => 'overflow-wrap: break-word'
672                    ], (string)$accessToken );
673                    $this->getOutput()->addWikiMsg(
674                        'mwoauthconsumerregistration-created-owner-only-oauth2',
675                        $cmr->getConsumerKey(),
676                        Utils::hmacDBSecret( $cmr->getSecretKey() ),
677                        Message::rawParam( $accessToken )
678                    );
679                }
680            } elseif ( $cmr->getStage() === Consumer::STAGE_APPROVED ) {
681                // mwoauthconsumerregistration-autoapproved-oauth1
682                // mwoauthconsumerregistration-autoapproved-oauth2
683                $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-autoapproved-oauth$oauthVersion",
684                    $cmr->getConsumerKey(),
685                    Utils::hmacDBSecret( $cmr->getSecretKey() )
686                );
687            } else {
688                // mwoauthconsumerregistration-proposed-oauth1
689                // mwoauthconsumerregistration-proposed-oauth2
690                $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-proposed-oauth$oauthVersion",
691                    $cmr->getConsumerKey(),
692                    Utils::hmacDBSecret( $cmr->getSecretKey() ) );
693            }
694            $this->getOutput()->returnToMain();
695        }
696    }
697
698    /**
699     * Used to adapt both OAuth forms to the same structure so SubmitControl::validateFields() doesn't fail
700     *
701     * @param array $form
702     * @return array
703     */
704    private function fillDefaultFields( array $form ): array {
705        // These defaults are taken from the legacy form and are present regardless of OAuth version
706        $defaults = [
707            'callbackIsPrefix' => false,
708            'oauth2IsConfidential' => true,
709            'oauth2GrantTypes'  => [ 'authorization_code', 'refresh_token' ],
710            'granttype' => 'normal',
711            'rsaKey' => '',
712        ];
713
714        $form = array_merge( $defaults, $form );
715
716        // 'callbackUrl' must be present,
717        // otherwise SubmitControl::validateFields() fails.
718        if ( $form['ownerOnly'] && !isset( $form['callbackUrl'] ) ) {
719            $form['callbackUrl'] = '';
720        }
721
722        return $form;
723    }
724}