Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.45% covered (warning)
57.45%
54 / 94
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AccessTokenEntity
57.45% covered (warning)
57.45%
54 / 94
50.00% covered (danger)
50.00%
8 / 16
123.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
72.73% covered (warning)
72.73%
16 / 22
0.00% covered (danger)
0.00%
0 / 1
9.30
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setIssuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getIssuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addClaim
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getClaims
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPrivateKey
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 setPrivateKeyFromConfig
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getApproval
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isOwnerOnly
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setJwtConfiguration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setApprovalFromClientScopesUser
36.84% covered (danger)
36.84%
7 / 19
0.00% covered (danger)
0.00%
0 / 1
19.34
 confirmClientUsable
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 convertToJWT
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
2.00
 getUserIdentifierForJwt
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Extension\OAuth\Entity;
4
5use DateTimeImmutable;
6use InvalidArgumentException;
7use Lcobucci\JWT\Configuration;
8use Lcobucci\JWT\Signer\Key\InMemory;
9use Lcobucci\JWT\Signer\Rsa\Sha256;
10use Lcobucci\JWT\Token;
11use League\OAuth2\Server\CryptKey;
12use League\OAuth2\Server\CryptKeyInterface;
13use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
14use League\OAuth2\Server\Entities\ScopeEntityInterface;
15use League\OAuth2\Server\Entities\Traits\EntityTrait;
16use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
17use League\OAuth2\Server\Exception\OAuthServerException;
18use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
19use MediaWiki\Extension\OAuth\Backend\Utils;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\User\User;
22use MediaWiki\WikiMap\WikiMap;
23use Throwable;
24
25/**
26 * @method ClientEntity getClient()
27 */
28class AccessTokenEntity implements AccessTokenEntityInterface {
29    use EntityTrait;
30    use TokenEntityTrait;
31
32    /**
33     * User approval of the client
34     *
35     * @var ConsumerAcceptance|false
36     */
37    private $approval;
38
39    private Configuration $jwtConfiguration;
40
41    private string $issuer;
42
43    private array $claims = [];
44
45    /**
46     * @param ClientEntity $clientEntity
47     * @param ScopeEntityInterface[] $scopes
48     * @param string $issuer
49     * @param string|int|null $userIdentifier
50     * @throws OAuthServerException
51     */
52    public function __construct(
53        ClientEntity $clientEntity,
54        array $scopes,
55        string $issuer,
56        $userIdentifier = null
57    ) {
58        $this->approval = $this->setApprovalFromClientScopesUser(
59            $clientEntity, $scopes, $userIdentifier
60        );
61
62        $this->setClient( $clientEntity );
63        $this->setIssuer( $issuer );
64        if ( $clientEntity->getOwnerOnly() ) {
65            if ( $userIdentifier !== null && (string)$userIdentifier !== (string)$clientEntity->getUserId() ) {
66                throw new InvalidArgumentException(
67                    '$userIdentifier must be null, or match the client owner user id,' .
68                    ' for owner-only clients, ' . $userIdentifier . ' given; user id from ClientEntity is '
69                    . $clientEntity->getUserId() . '.'
70                );
71            }
72            foreach ( $clientEntity->getScopes() as $scope ) {
73                $this->addScope( $scope );
74            }
75            $this->setUserIdentifier( (string)$clientEntity->getUserId() );
76        } else {
77            foreach ( $scopes as $scope ) {
78                if ( !in_array( $scope->getIdentifier(), $clientEntity->getGrants() ) ) {
79                    continue;
80                }
81                $this->addScope( $scope );
82            }
83            if ( $userIdentifier !== null ) {
84                $this->setUserIdentifier( $userIdentifier );
85            }
86        }
87
88        $this->confirmClientUsable();
89    }
90
91    public function __toString(): string {
92        return $this->convertToJWT()->toString();
93    }
94
95    public function toString(): string {
96        return $this->__toString();
97    }
98
99    public function setIssuer( string $issuer ): void {
100        $this->issuer = $issuer;
101    }
102
103    public function getIssuer(): string {
104        return $this->issuer;
105    }
106
107    public function addClaim( ClaimEntity $claim ): void {
108        $this->claims[] = $claim;
109    }
110
111    public function getClaims(): array {
112        return $this->claims;
113    }
114
115    /** @inheritDoc */
116    public function setPrivateKey( CryptKeyInterface $privateKey ): void {
117        $key = InMemory::plainText(
118            $privateKey->getKeyContents(),
119            $privateKey->getPassPhrase() ?? ''
120        );
121        $this->setJwtConfiguration( Configuration::forAsymmetricSigner(
122            new Sha256(),
123            $key,
124            $key
125        ) );
126    }
127
128    /**
129     * Set the configured private key
130     */
131    public function setPrivateKeyFromConfig() {
132        $oauthConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'mwoauth' );
133        // Private key to sign the token
134        $privateKey = new CryptKey(
135            $oauthConfig->get( 'OAuth2PrivateKey' ),
136            $oauthConfig->get( 'OAuth2Passphrase' )
137        );
138        $this->setPrivateKey( $privateKey );
139    }
140
141    /**
142     * Get the approval that allows this AT to be created
143     *
144     * @return ConsumerAcceptance|false
145     */
146    public function getApproval() {
147        return $this->approval;
148    }
149
150    public function isOwnerOnly(): bool {
151        return $this->getClient()->getOwnerOnly();
152    }
153
154    /** @internal Exposed for tests only. */
155    public function setJwtConfiguration( Configuration $configuration ): void {
156        $this->jwtConfiguration = $configuration;
157    }
158
159    /**
160     * @param ClientEntity $clientEntity
161     * @param array $scopes
162     * @param string|int|null $userIdentifier
163     * @return ConsumerAcceptance|false
164     */
165    private function setApprovalFromClientScopesUser(
166        ClientEntity $clientEntity, array $scopes, $userIdentifier = null
167    ) {
168        if ( $userIdentifier === null && $clientEntity->getOwnerOnly() ) {
169            $userIdentifier = $clientEntity->getUserId();
170            $scopes = $clientEntity->getScopes();
171        }
172        if ( !$userIdentifier ) {
173            return false;
174        }
175        try {
176            $user = Utils::getLocalUserFromCentralId( $userIdentifier );
177            $approval = $clientEntity->getCurrentAuthorization( $user, WikiMap::getCurrentWikiId() );
178        } catch ( Throwable ) {
179            return false;
180        }
181        if ( !$approval ) {
182            return $approval;
183        }
184
185        $approvedScopes = $approval->getGrants();
186        $notApproved = array_filter(
187            $scopes,
188            static function ( ScopeEntityInterface $scope ) use ( $approvedScopes ) {
189                return !in_array( $scope->getIdentifier(), $approvedScopes, true );
190            }
191        );
192
193        return !$notApproved ? $approval : false;
194    }
195
196    /**
197     * @throws OAuthServerException
198     */
199    private function confirmClientUsable() {
200        $userId = $this->getUserIdentifier() ?? 0;
201        $user = Utils::getLocalUserFromCentralId( $userId );
202        if ( !$user ) {
203            $user = User::newFromId( 0 );
204        }
205
206        if ( !$this->getClient()->isUsableBy( $user ) ) {
207            throw OAuthServerException::accessDenied(
208                'Client ' . $this->getClient()->getIdentifier() .
209                ' is not usable by user with ID ' . $user->getId()
210            );
211        }
212    }
213
214    private function convertToJWT(): Token {
215        // Changes from league/oauth-server's AccessTokenTrait:
216        // - Add support for 'iss' claim (issuedBy())
217        // - Add support for custom claims
218        // - Use different value for 'sub' claim (relatedTo())
219
220        $builder = $this->jwtConfiguration->builder()
221            ->permittedFor( $this->getClient()->getIdentifier() )
222            ->identifiedBy( $this->getIdentifier() )
223            ->issuedAt( new DateTimeImmutable() )
224            ->canOnlyBeUsedAfter( new DateTimeImmutable() )
225            ->expiresAt( $this->getExpiryDateTime() )
226            ->relatedTo( $this->getUserIdentifierForJwt() )
227            ->issuedBy( $this->getIssuer() );
228
229        foreach ( $this->getClaims() as $claim ) {
230            $builder = $builder->withClaim( $claim->getName(), $claim->getValue() );
231        }
232
233        return $builder
234            // Set the scope claim late to prevent it from being overridden.
235            ->withClaim( 'scopes', $this->getScopes() )
236            ->getToken( $this->jwtConfiguration->signer(), $this->jwtConfiguration->signingKey() );
237    }
238
239    private function getUserIdentifierForJwt(): string {
240        $centralId = $this->getUserIdentifier();
241        if (
242            // FIXME is this possible? is it the same as T407655?
243            !$centralId
244            // Owner-only access tokens are valid forever, which makes them impractical for some of the
245            // things other JWTs are used for, and having a different structure helps to differentiate them.
246            // Also, since they are valid forever, and the JWT format might change in the future,
247            // we want to minimize the number of legacy formats we'll have to support forever, so we
248            // go with the already-established legacy format where 'sub' is just a user ID.
249            || $this->isOwnerOnly()
250        ) {
251            return (string)$centralId;
252        } else {
253            // Short-lived non-owner-only access token. Use the new 'sub' format to match
254            // SessionManager::getJwtData().
255            $lookupScope = Utils::getCentralIdLookup()->getScope();
256            return "mw:$lookupScope:$centralId";
257        }
258    }
259}