Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.91% covered (danger)
48.91%
67 / 137
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Authorize
48.91% covered (danger)
48.91%
67 / 137
22.22% covered (danger)
22.22%
2 / 9
100.83
0.00% covered (danger)
0.00%
0 / 1
 execute
24.14% covered (danger)
24.14%
7 / 29
0.00% covered (danger)
0.00%
0 / 1
53.66
 setValidScopes
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getParamSettings
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
1
 getApprovalRedirectResponse
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getLoginRedirectResponse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getGrantKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGrantClass
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 checkApproval
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getFlatScopes
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
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\Extension\OAuth\AuthorizationProvider\Grant\AuthorizationCodeAuthorization;
11use MediaWiki\Extension\OAuth\Entity\ClientEntity;
12use MediaWiki\Extension\OAuth\Entity\UserEntity;
13use MediaWiki\Extension\OAuth\Exception\ClientApprovalDenyException;
14use MediaWiki\Extension\OAuth\Response;
15use MediaWiki\Rest\Response as RestResponse;
16use MediaWiki\SpecialPage\SpecialPage;
17use MediaWiki\User\User;
18use MediaWiki\WikiMap\WikiMap;
19use MWExceptionHandler;
20use Throwable;
21use Wikimedia\ParamValidator\ParamValidator;
22
23/**
24 * Handles the oauth2/authorize endpoint, which displays an authorization dialog to the user if
25 * needed (by redirecting to Special:OAuth/approve), and returns an authorization code that can be
26 * traded for the access token.
27 */
28class Authorize extends AuthenticationHandler {
29    private const RESPONSE_TYPE_CODE = 'code';
30
31    /**
32     * @inheritDoc
33     */
34    public function execute() {
35        $response = new Response();
36
37        try {
38            if ( $this->queuedError ) {
39                throw $this->queuedError;
40            }
41            $request = ServerRequest::fromGlobals()->withQueryParams(
42                $this->getValidatedParams()
43            );
44            // Note: Owner-only clients can only use client_credentials grant
45            // so would be rejected from this endpoint with invalid_client error
46            // automatically, no need for additional checks
47            if ( !$this->user instanceof User || !$this->user->isNamed() ) {
48                return $this->getLoginRedirectResponse();
49            }
50
51            $authProvider = $this->getAuthorizationProvider();
52            $authProvider->setUser( $this->user );
53            /** @var AuthorizationRequest $authRequest */
54            $authRequest = $authProvider->init( $request );
55            $this->setValidScopes( $authRequest );
56            if ( !$authProvider->needsUserApproval() ) {
57                return $authProvider->authorize( $authRequest, $response );
58            }
59
60            if ( $this->getValidatedParams()['approval_cancel'] ) {
61                throw new ClientApprovalDenyException( $authRequest->getRedirectUri() );
62            }
63
64            if (
65                $this->getValidatedParams()['approval_pass'] &&
66                $this->checkApproval( $authRequest )
67            ) {
68                $authRequest->setAuthorizationApproved( true );
69                return $authProvider->authorize( $authRequest, $response );
70            }
71
72            return $this->getApprovalRedirectResponse( $authRequest );
73        } catch ( OAuthServerException $ex ) {
74            return $this->errorResponse( $ex, $response );
75        } catch ( Throwable $ex ) {
76            MWExceptionHandler::logException( $ex );
77            return $this->errorResponse(
78                OAuthServerException::serverError( $ex->getMessage() ),
79                $response
80            );
81        }
82    }
83
84    protected function setValidScopes( AuthorizationRequest &$authRequest ) {
85        /** @var ClientEntity $client */
86        $client = $authRequest->getClient();
87        '@phan-var ClientEntity $client';
88
89        $scopes = $this->getValidatedParams()['scope'];
90        if ( !$scopes ) {
91            // No scope parameter
92            $authRequest->setScopes(
93                $client->getScopes()
94            );
95            return;
96        }
97        // Trim off any not allowed scopes
98        $allowedScopes = $client->getGrants();
99
100        $authRequest->setScopes( array_filter(
101            $authRequest->getScopes(),
102            static function ( ScopeEntityInterface $scope ) use ( $allowedScopes ) {
103                return in_array( $scope->getIdentifier(), $allowedScopes );
104            }
105        ) );
106    }
107
108    /**
109     * @inheritDoc
110     */
111    public function getParamSettings() {
112        return [
113            'response_type' => [
114                self::PARAM_SOURCE => 'query',
115                ParamValidator::PARAM_TYPE => [
116                    self::RESPONSE_TYPE_CODE
117                ],
118                ParamValidator::PARAM_REQUIRED => true,
119            ],
120            'client_id' => [
121                self::PARAM_SOURCE => 'query',
122                ParamValidator::PARAM_TYPE => 'string',
123                ParamValidator::PARAM_REQUIRED => true,
124            ],
125            'redirect_uri' => [
126                self::PARAM_SOURCE => 'query',
127                ParamValidator::PARAM_TYPE => 'string',
128                ParamValidator::PARAM_REQUIRED => false,
129            ],
130            'scope' => [
131                self::PARAM_SOURCE => 'query',
132                ParamValidator::PARAM_TYPE => 'string',
133                ParamValidator::PARAM_REQUIRED => false,
134            ],
135            'state' => [
136                self::PARAM_SOURCE => 'query',
137                ParamValidator::PARAM_TYPE => 'string',
138                ParamValidator::PARAM_REQUIRED => false,
139            ],
140            'code_challenge' => [
141                self::PARAM_SOURCE => 'query',
142                ParamValidator::PARAM_TYPE => 'string',
143                ParamValidator::PARAM_REQUIRED => false,
144            ],
145            'code_challenge_method' => [
146                self::PARAM_SOURCE => 'query',
147                ParamValidator::PARAM_TYPE => [
148                    'plain',
149                    'S256'
150                ],
151                ParamValidator::PARAM_REQUIRED => false,
152            ],
153            'approval_cancel' => [
154                self::PARAM_SOURCE => 'query',
155                ParamValidator::PARAM_TYPE => 'string',
156                ParamValidator::PARAM_REQUIRED => false,
157            ],
158            'approval_pass' => [
159                self::PARAM_SOURCE => 'query',
160                ParamValidator::PARAM_TYPE => 'string',
161                ParamValidator::PARAM_REQUIRED => false,
162            ]
163        ];
164    }
165
166    /**
167     * @param AuthorizationRequest $authRequest
168     * @return RestResponse
169     */
170    private function getApprovalRedirectResponse( AuthorizationRequest $authRequest ) {
171        return $this->getResponseFactory()->createTemporaryRedirect(
172            SpecialPage::getTitleFor( 'OAuth', 'approve' )->getFullURL( [
173                'returnto' => $this->getRequest()->getUri()->getPath(),
174                'returntoquery' => $this->getQueryParamsCgi(),
175                'client_id' => $authRequest->getClient()->getIdentifier(),
176                'oauth_version' => ClientEntity::OAUTH_VERSION_2,
177                'scope' => implode( ' ', array_map( static function ( ScopeEntityInterface $scope ) {
178                    return $scope->getIdentifier();
179                }, $authRequest->getScopes() ) )
180            ] )
181        );
182    }
183
184    private function getLoginRedirectResponse() {
185        return $this->getResponseFactory()->createTemporaryRedirect(
186            SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [
187                'returnto' => SpecialPage::getTitleFor( 'OAuth', 'rest_redirect' ),
188                'returntoquery' => wfArrayToCgi( [
189                    'rest_url' => $this->getRequest()->getUri()->__toString(),
190                ] ),
191            ] )
192        );
193    }
194
195    /**
196     * @return string
197     */
198    protected function getGrantKey() {
199        return 'response_type';
200    }
201
202    /**
203     * @param string $grantKey
204     * @return string|false
205     */
206    protected function getGrantClass( $grantKey ) {
207        switch ( $grantKey ) {
208            case static::RESPONSE_TYPE_CODE:
209                return AuthorizationCodeAuthorization::class;
210            default:
211                return false;
212        }
213    }
214
215    /**
216     * Check if user has approved the client, and scopes it requested
217     *
218     * @param AuthorizationRequest $authRequest
219     * @return bool
220     */
221    private function checkApproval( AuthorizationRequest $authRequest ) {
222        /** @var ClientEntity $client */
223        $client = $authRequest->getClient();
224        '@phan-var ClientEntity $client';
225
226        /** @var UserEntity $userEntity */
227        $userEntity = $authRequest->getUser();
228        '@phan-var UserEntity $userEntity';
229
230        try {
231            $approval = $client->getCurrentAuthorization(
232                $userEntity->getMwUser(),
233                WikiMap::getCurrentWikiId()
234            );
235        } catch ( Exception $ex ) {
236            return false;
237        }
238
239        if ( !$approval ) {
240            return false;
241        }
242
243        // Scopes in OAuth 1.0 are called grants
244        $scopes = $approval->getGrants();
245        $requestedScopes = $this->getFlatScopes( $authRequest->getScopes() );
246        $missing = array_diff( $requestedScopes, $scopes );
247        if ( $missing ) {
248            return false;
249        }
250
251        return true;
252    }
253
254    /**
255     * @param ScopeEntityInterface[] $scopeEntities
256     * @return string[]
257     */
258    private function getFlatScopes( $scopeEntities ) {
259        return array_map( static function ( ScopeEntityInterface $scope ) {
260            return $scope->getIdentifier();
261        }, $scopeEntities );
262    }
263}