Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.16% covered (warning)
85.16%
155 / 182
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Authorize
85.16% covered (warning)
85.16%
155 / 182
72.73% covered (warning)
72.73%
8 / 11
30.56
0.00% covered (danger)
0.00%
0 / 1
 execute
55.56% covered (warning)
55.56%
20 / 36
0.00% covered (danger)
0.00%
0 / 1
16.11
 setValidScopes
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
2.50
 getParamSettings
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
1 / 1
1
 getSpecialPageQueryParameters
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getApprovalRedirectResponse
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getUselangFromUiLocales
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getLoginRedirectResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getGrantType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGrantClass
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 checkApproval
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
4.25
 getFlatScopes
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\OAuth\Rest\Handler;
4
5use Exception;
6use GuzzleHttp\Psr7\ServerRequest;
7use League\OAuth2\Server\Entities\ScopeEntityInterface;
8use League\OAuth2\Server\Exception\OAuthServerException;
9use League\OAuth2\Server\RequestTypes\AuthorizationRequest;
10use MediaWiki\Exception\MWExceptionHandler;
11use MediaWiki\Extension\OAuth\AuthorizationProvider\Grant\AuthorizationCodeProvider;
12use MediaWiki\Extension\OAuth\Entity\ClientEntity;
13use MediaWiki\Extension\OAuth\Entity\UserEntity;
14use MediaWiki\Extension\OAuth\Response;
15use MediaWiki\Language\LanguageCode;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Rest\Response as RestResponse;
18use MediaWiki\SpecialPage\SpecialPage;
19use MediaWiki\WikiMap\WikiMap;
20use Throwable;
21use Wikimedia\Message\MessageValue;
22use Wikimedia\ParamValidator\ParamValidator;
23
24/**
25 * Handles the oauth2/authorize endpoint, which displays an authorization dialog to the user if
26 * needed (by redirecting to Special:OAuth/approve), and returns an authorization code that can be
27 * traded for the access token.
28 *
29 * Supports some OIDC parameters.
30 * @see https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters
31 */
32class Authorize extends AuthenticationHandler {
33    private const RESPONSE_TYPE_CODE = 'code';
34
35    /** @inheritDoc */
36    public function execute() {
37        $response = new Response();
38
39        try {
40            if ( $this->queuedError ) {
41                throw $this->queuedError;
42            }
43            $request = ServerRequest::fromGlobals()->withQueryParams(
44                $this->getValidatedParams()
45            );
46            // Note: Owner-only clients can only use client_credentials grant
47            // so would be rejected from this endpoint with invalid_client error
48            // automatically, no need for additional checks
49            if ( !$this->user ) {
50                return $this->getLoginRedirectResponse();
51            }
52
53            $authProvider = $this->getAuthorizationProvider();
54            $authProvider->setUser( $this->user );
55            /** @var AuthorizationRequest $authRequest */
56            $authRequest = $authProvider->init( $request );
57            '@phan-var AuthorizationRequest $authRequest';
58            $this->setValidScopes( $authRequest );
59            if ( !$authProvider->needsUserApproval() ) {
60                return $authProvider->authorize( $authRequest, $response );
61            }
62
63            if ( $this->getValidatedParams()['approval_cancel'] ) {
64                throw new OAuthServerException(
65                    wfMessage( 'mwoauth-oauth2-error-user-approval-deny' )->plain(),
66                    401,
67                    'unauthorized_client',
68                    400,
69                    null,
70                    $authRequest->getRedirectUri()
71                );
72            }
73
74            if (
75                $this->getValidatedParams()['approval_pass'] &&
76                $this->checkApproval( $authRequest )
77            ) {
78                $authRequest->setAuthorizationApproved( true );
79                return $authProvider->authorize( $authRequest, $response );
80            }
81
82            return $this->getApprovalRedirectResponse( $authRequest );
83        } catch ( OAuthServerException $ex ) {
84            return $this->errorResponse( $ex, $response );
85        } catch ( Throwable $ex ) {
86            MWExceptionHandler::logException( $ex );
87            return $this->errorResponse(
88                OAuthServerException::serverError( $ex->getMessage() ),
89                $response
90            );
91        }
92    }
93
94    protected function setValidScopes( AuthorizationRequest $authRequest ) {
95        /** @var ClientEntity $client */
96        $client = $authRequest->getClient();
97        '@phan-var ClientEntity $client';
98
99        $scopes = $this->getValidatedParams()['scope'];
100        if ( !$scopes ) {
101            // No scope parameter
102            $authRequest->setScopes(
103                $client->getScopes()
104            );
105            return;
106        }
107        // Trim off any not allowed scopes
108        $allowedScopes = $client->getGrants();
109
110        $authRequest->setScopes( array_filter(
111            $authRequest->getScopes(),
112            static function ( ScopeEntityInterface $scope ) use ( $allowedScopes ) {
113                return in_array( $scope->getIdentifier(), $allowedScopes );
114            }
115        ) );
116    }
117
118    /** @inheritDoc */
119    public function getParamSettings() {
120        return [
121            'response_type' => [
122                self::PARAM_SOURCE => 'query',
123                ParamValidator::PARAM_TYPE => [
124                    self::RESPONSE_TYPE_CODE
125                ],
126                ParamValidator::PARAM_REQUIRED => true,
127                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-response_type' ),
128            ],
129            'client_id' => [
130                self::PARAM_SOURCE => 'query',
131                ParamValidator::PARAM_TYPE => 'string',
132                ParamValidator::PARAM_REQUIRED => true,
133                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-client_id' ),
134            ],
135            'redirect_uri' => [
136                self::PARAM_SOURCE => 'query',
137                ParamValidator::PARAM_TYPE => 'string',
138                ParamValidator::PARAM_REQUIRED => false,
139                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-redirect_uri' ),
140            ],
141            'scope' => [
142                self::PARAM_SOURCE => 'query',
143                ParamValidator::PARAM_TYPE => 'string',
144                ParamValidator::PARAM_REQUIRED => false,
145                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-scope' ),
146            ],
147            'state' => [
148                self::PARAM_SOURCE => 'query',
149                ParamValidator::PARAM_TYPE => 'string',
150                ParamValidator::PARAM_REQUIRED => false,
151                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-state' ),
152            ],
153            'code_challenge' => [
154                self::PARAM_SOURCE => 'query',
155                ParamValidator::PARAM_TYPE => 'string',
156                ParamValidator::PARAM_REQUIRED => false,
157                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-code_challenge' ),
158            ],
159            'code_challenge_method' => [
160                self::PARAM_SOURCE => 'query',
161                ParamValidator::PARAM_TYPE => [
162                    'plain',
163                    'S256'
164                ],
165                ParamValidator::PARAM_REQUIRED => false,
166                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-code_challenge_method' ),
167            ],
168            'approval_cancel' => [
169                self::PARAM_SOURCE => 'query',
170                ParamValidator::PARAM_TYPE => 'string',
171                ParamValidator::PARAM_REQUIRED => false,
172                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-approval_cancel' ),
173            ],
174            'approval_pass' => [
175                self::PARAM_SOURCE => 'query',
176                ParamValidator::PARAM_TYPE => 'string',
177                ParamValidator::PARAM_REQUIRED => false,
178                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-approval_pass' ),
179            ],
180            'display' => [
181                self::PARAM_SOURCE => 'query',
182                ParamValidator::PARAM_TYPE => [ 'page', 'popup', 'touch', 'wap' ],
183                ParamValidator::PARAM_DEFAULT => 'page',
184                ParamValidator::PARAM_REQUIRED => false,
185                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-display' ),
186            ],
187            'ui_locales' => [
188                self::PARAM_SOURCE => 'query',
189                ParamValidator::PARAM_TYPE => 'string',
190                ParamValidator::PARAM_REQUIRED => false,
191                self::PARAM_DESCRIPTION => new MessageValue( 'mwoauth-rest-param-desc-ui_locales' ),
192            ],
193        ];
194    }
195
196    /**
197     * Get generic query parameters to pass to Special:OAuth / Special:Userlogin
198     */
199    private function getSpecialPageQueryParameters(): array {
200        $params = $this->getValidatedParams();
201        return [
202            // use popup mode for 'touch' and 'wap' as we don't have anything better
203            'display' => ( $params['display'] === 'page' ) ? null : 'popup',
204            'uselang' => $this->getUselangFromUiLocales( $params['ui_locales'] ?? null ),
205        ];
206    }
207
208    private function getApprovalRedirectResponse( AuthorizationRequest $authRequest ): RestResponse {
209        $queryParams = [
210            'returnto' => $this->getRequest()->getUri()->getPath(),
211            'returntoquery' => $this->getQueryParamsCgi(),
212            'client_id' => $authRequest->getClient()->getIdentifier(),
213            'oauth_version' => ClientEntity::OAUTH_VERSION_2,
214            'scope' => implode( ' ', array_map( static function ( ScopeEntityInterface $scope ) {
215                return $scope->getIdentifier();
216            }, $authRequest->getScopes() ) ),
217        ] + $this->getSpecialPageQueryParameters();
218        return $this->getResponseFactory()->createTemporaryRedirect(
219            SpecialPage::getTitleFor( 'OAuth', 'approve' )->getFullURL( $queryParams )
220        );
221    }
222
223    /**
224     * Converts the OIDC ui_locales parameter (a space-separated list of BCP47 codes) into a
225     * MediaWiki language code.
226     * @param string|null $uiLocales Value of ui_locales parameter (or null if not given).
227     * @return string|null MediaWiki language code that can be used with the 'uselang' parameter
228     *   (or null if ui_locales was not given or none of its values were valid / known).
229     */
230    private function getUselangFromUiLocales( ?string $uiLocales ): ?string {
231        $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
232        $localeList = $uiLocales ? explode( ' ', $uiLocales ) : [];
233        foreach ( $localeList as $locale ) {
234            if ( !$languageNameUtils->isKnownLanguageTag( $locale ) ) {
235                continue;
236            }
237            // Does not handle most BCP47 tags with a region part (e.g. fr-CA) as LanguageCode
238            // doesn't. Didn't seem worthwhile to fix.
239            $code = LanguageCode::replaceDeprecatedCodes( LanguageCode::bcp47ToInternal( $locale ) );
240            if ( $code ) {
241                return $code;
242            }
243        }
244        return null;
245    }
246
247    private function getLoginRedirectResponse(): RestResponse {
248        return $this->getResponseFactory()->createTemporaryRedirect(
249            SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
250                'returnto' => SpecialPage::getTitleFor( 'OAuth', 'rest_redirect' ),
251                'returntoquery' => wfArrayToCgi( [
252                    'rest_url' => $this->getRequest()->getUri()->__toString(),
253                ] ),
254            ] + $this->getSpecialPageQueryParameters() )
255        );
256    }
257
258    /** @inheritDoc */
259    protected function getGrantType() {
260        return $this->getValidatedParams()['response_type'];
261    }
262
263    /**
264     * @param string $grantType
265     * @return string|false
266     */
267    protected function getGrantClass( $grantType ) {
268        return match ( $grantType ) {
269            static::RESPONSE_TYPE_CODE => AuthorizationCodeProvider::class,
270            default => false,
271        };
272    }
273
274    /**
275     * Check if user has approved the client, and scopes it requested
276     */
277    private function checkApproval( AuthorizationRequest $authRequest ): bool {
278        /** @var ClientEntity $client */
279        $client = $authRequest->getClient();
280        '@phan-var ClientEntity $client';
281
282        /** @var UserEntity $userEntity */
283        $userEntity = $authRequest->getUser();
284        '@phan-var UserEntity $userEntity';
285
286        try {
287            $approval = $client->getCurrentAuthorization(
288                $userEntity,
289                WikiMap::getCurrentWikiId()
290            );
291        } catch ( Exception ) {
292            return false;
293        }
294
295        if ( !$approval ) {
296            return false;
297        }
298
299        // Scopes in OAuth 1.0 are called grants
300        $scopes = $approval->getGrants();
301        $requestedScopes = $this->getFlatScopes( $authRequest->getScopes() );
302        $missing = array_diff( $requestedScopes, $scopes );
303        if ( $missing ) {
304            return false;
305        }
306
307        return true;
308    }
309
310    /**
311     * @param ScopeEntityInterface[] $scopeEntities
312     * @return string[]
313     */
314    private function getFlatScopes( $scopeEntities ) {
315        return array_map( static function ( ScopeEntityInterface $scope ) {
316            return $scope->getIdentifier();
317        }, $scopeEntities );
318    }
319}