Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
57.45% |
54 / 94 |
|
50.00% |
8 / 16 |
CRAP | |
0.00% |
0 / 1 |
| AccessTokenEntity | |
57.45% |
54 / 94 |
|
50.00% |
8 / 16 |
123.07 | |
0.00% |
0 / 1 |
| __construct | |
72.73% |
16 / 22 |
|
0.00% |
0 / 1 |
9.30 | |||
| __toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toString | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setIssuer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getIssuer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| addClaim | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getClaims | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setPrivateKey | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| setPrivateKeyFromConfig | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| getApproval | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| isOwnerOnly | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setJwtConfiguration | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setApprovalFromClientScopesUser | |
36.84% |
7 / 19 |
|
0.00% |
0 / 1 |
19.34 | |||
| confirmClientUsable | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
3.79 | |||
| convertToJWT | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
2.00 | |||
| getUserIdentifierForJwt | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\OAuth\Entity; |
| 4 | |
| 5 | use DateTimeImmutable; |
| 6 | use InvalidArgumentException; |
| 7 | use Lcobucci\JWT\Configuration; |
| 8 | use Lcobucci\JWT\Signer\Key\InMemory; |
| 9 | use Lcobucci\JWT\Signer\Rsa\Sha256; |
| 10 | use Lcobucci\JWT\Token; |
| 11 | use League\OAuth2\Server\CryptKey; |
| 12 | use League\OAuth2\Server\CryptKeyInterface; |
| 13 | use League\OAuth2\Server\Entities\AccessTokenEntityInterface; |
| 14 | use League\OAuth2\Server\Entities\ScopeEntityInterface; |
| 15 | use League\OAuth2\Server\Entities\Traits\EntityTrait; |
| 16 | use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; |
| 17 | use League\OAuth2\Server\Exception\OAuthServerException; |
| 18 | use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance; |
| 19 | use MediaWiki\Extension\OAuth\Backend\Utils; |
| 20 | use MediaWiki\MediaWikiServices; |
| 21 | use MediaWiki\User\User; |
| 22 | use MediaWiki\WikiMap\WikiMap; |
| 23 | use Throwable; |
| 24 | |
| 25 | /** |
| 26 | * @method ClientEntity getClient() |
| 27 | */ |
| 28 | class 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 | } |