mirror of
https://github.com/code-rhapsodie/oauth2-apple.git
synced 2026-03-24 04:42:08 +01:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bddf4d18c | ||
|
|
d770cdfcc7 | ||
|
|
053c7a1f80 | ||
|
|
e8bd6365bf | ||
|
|
9f99c0dadf | ||
|
|
f4ee42b17c | ||
|
|
e6626c4575 | ||
|
|
3f0f659741 | ||
|
|
badae8f295 |
18
.github/workflows/continuous-integration.yml
vendored
18
.github/workflows/continuous-integration.yml
vendored
@@ -17,13 +17,13 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '7.4'
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
ini-values: memory_limit=-1
|
||||
tools: composer:v2
|
||||
- uses: ramsey/composer-install@v3
|
||||
- name: 'Lint the PHP source code'
|
||||
run: ./vendor/bin/parallel-lint src test
|
||||
run: ./vendor/bin/parallel-lint src tests
|
||||
|
||||
coding-standards:
|
||||
name: Coding Standards
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '7.4'
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
ini-values: memory_limit=-1
|
||||
tools: composer:v2
|
||||
@@ -48,16 +48,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-version:
|
||||
- '5.6'
|
||||
- '7.0'
|
||||
- '7.1'
|
||||
- '7.2'
|
||||
- '7.3'
|
||||
- '7.4'
|
||||
- '8.0'
|
||||
- '8.1'
|
||||
- '8.2'
|
||||
- '8.4'
|
||||
- '8.5'
|
||||
dependencies:
|
||||
- lowest
|
||||
- highest
|
||||
@@ -81,6 +75,4 @@ jobs:
|
||||
dependency-versions: '${{ matrix.dependencies }}'
|
||||
composer-options: '${{ matrix.composer-options }}'
|
||||
- name: Run unit tests
|
||||
run: ./vendor/bin/phpunit --colors=always --coverage-clover build/logs/clover.xml
|
||||
- name: Publish coverage report to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
run: ./vendor/bin/phpunit
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ composer.phar
|
||||
composer.lock
|
||||
.DS_Store
|
||||
.phpunit.result.cache
|
||||
/.phpunit.cache/
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -1,7 +1,7 @@
|
||||
# Changelog
|
||||
All Notable changes to `oauth2-apple` will be documented in this file
|
||||
|
||||
## 0.4.0 - 202X-XX-XX
|
||||
## 0.5.0 - 202X-XX-XX
|
||||
|
||||
### Added
|
||||
- Nothing
|
||||
@@ -18,6 +18,23 @@ All Notable changes to `oauth2-apple` will be documented in this file
|
||||
### Security
|
||||
- Nothing
|
||||
|
||||
## 0.5.0 - 2026-02-25
|
||||
|
||||
### Fixed
|
||||
- Updated code base to php 8
|
||||
|
||||
### Removed
|
||||
- lcobucci/jwt library
|
||||
|
||||
## 0.4.0 - 2026-02-19
|
||||
|
||||
### Added
|
||||
- Allow firebase/php-jwt ^7.0 [#1](https://github.com/code-rhapsodie/oauth2-apple/pull/1)
|
||||
- Tests for php 8.5 [#1](https://github.com/code-rhapsodie/oauth2-apple/pull/1)
|
||||
|
||||
### Removed
|
||||
- Compatibility with PHP 5.6 - 7.0 - 7.1 - 7.2 - 7.3 - 7.4
|
||||
|
||||
## 0.3.0 - 2024-05-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Contributions are **welcome** and will be fully **credited**.
|
||||
|
||||
We accept contributions via Pull Requests on [Github](https://github.com/patrickbussmann/oauth2-apple).
|
||||
We accept contributions via Pull Requests on [Github](https://github.com/code-rhapsodie/oauth2-apple).
|
||||
|
||||
|
||||
## Pull Requests
|
||||
|
||||
20
README.md
20
README.md
@@ -1,14 +1,12 @@
|
||||
# Sign in with Apple ID Provider for OAuth 2.0 Client
|
||||
[](https://github.com/patrickbussmann/oauth2-apple/releases)
|
||||
[](LICENSE.md)
|
||||
[](https://travis-ci.org/patrickbussmann/oauth2-apple)
|
||||
[](https://scrutinizer-ci.com/g/patrickbussmann/oauth2-apple/code-structure)
|
||||
[](https://scrutinizer-ci.com/g/patrickbussmann/oauth2-apple)
|
||||
[](https://codecov.io/gh/patrickbussmann/oauth2-apple)
|
||||
[](https://packagist.org/packages/patrickbussmann/oauth2-apple)
|
||||
[](https://github.com/code-rhapsodie/oauth2-apple/releases)
|
||||
[](LICENSE)
|
||||
[](https://packagist.org/packages/code-rhapsodie/oauth2-apple)
|
||||
|
||||
This package provides Apple ID OAuth 2.0 support for the PHP League's [OAuth 2.0 Client](https://github.com/thephpleague/oauth2-client).
|
||||
|
||||
This package is a fork of [patrickbussmann/oauth2-apple](https://github.com/patrickbussmann/oauth2-apple)
|
||||
|
||||
## Before You Begin
|
||||
|
||||
Here you can find the official Apple documentation:
|
||||
@@ -23,7 +21,7 @@ Maybe Apple changes this sometime.
|
||||
To install, use composer:
|
||||
|
||||
```
|
||||
composer require patrickbussmann/oauth2-apple
|
||||
composer require code-rhapsodie/oauth2-apple
|
||||
```
|
||||
|
||||
## Usage
|
||||
@@ -158,15 +156,15 @@ $ ./vendor/bin/phpunit
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see [CONTRIBUTING](https://github.com/patrickbussmann/oauth2-apple/blob/main/CONTRIBUTING.md) for details.
|
||||
Please see [CONTRIBUTING](https://github.com/code-rhapsodie/oauth2-apple/blob/main/CONTRIBUTING.md) for details.
|
||||
|
||||
|
||||
## Credits
|
||||
|
||||
- [All Contributors](https://github.com/patrickbussmann/oauth2-apple/contributors)
|
||||
- [All Contributors](https://github.com/code-rhapsodie/oauth2-apple/contributors)
|
||||
|
||||
Template for this repository was the [LinkedIn](https://github.com/thephpleague/oauth2-linkedin).
|
||||
|
||||
## License
|
||||
|
||||
The MIT License (MIT). Please see [License File](https://github.com/patrickbussmann/oauth2-apple/blob/main/LICENSE) for more information.
|
||||
The MIT License (MIT). Please see [License File](https://github.com/code-rhapsodie/oauth2-apple/blob/main/LICENSE) for more information.
|
||||
|
||||
29
codecov.yml
29
codecov.yml
@@ -1,29 +0,0 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
|
||||
coverage:
|
||||
precision: 2
|
||||
round: down
|
||||
range: "70...100"
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 0%
|
||||
|
||||
parsers:
|
||||
gcov:
|
||||
branch_detection:
|
||||
conditional: yes
|
||||
loop: yes
|
||||
method: no
|
||||
macro: no
|
||||
|
||||
comment:
|
||||
layout: "reach,diff,flags,tree"
|
||||
behavior: default
|
||||
require_changes: false
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "patrickbussmann/oauth2-apple",
|
||||
"name": "code-rhapsodie/oauth2-apple",
|
||||
"description": "Sign in with Apple OAuth 2.0 Client Provider for The PHP League OAuth2-Client",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
@@ -9,6 +9,9 @@
|
||||
"homepage": "https://github.com/patrickbussmann"
|
||||
}
|
||||
],
|
||||
"replace": {
|
||||
"patrickbussmann/oauth2-apple": "*"
|
||||
},
|
||||
"keywords": [
|
||||
"oauth",
|
||||
"oauth2",
|
||||
@@ -19,16 +22,16 @@
|
||||
"sign-in-with-apple"
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-json": "*",
|
||||
"league/oauth2-client": "^2.0",
|
||||
"firebase/php-jwt": "^5.2 || ^6.0",
|
||||
"lcobucci/jwt": "^3.4 || ^4.0 || ^5.0"
|
||||
"firebase/php-jwt": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^5.7 || ^6.0 || ^9.3",
|
||||
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0 || ^13.0",
|
||||
"mockery/mockery": "^1.3",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"squizlabs/php_codesniffer": "^2.3 || ^3.0",
|
||||
"squizlabs/php_codesniffer": "^3.0 || ^4.0",
|
||||
"composer/semver": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -38,7 +41,7 @@
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"League\\OAuth2\\Client\\Test\\": "test/src/"
|
||||
"League\\OAuth2\\Client\\Test\\": "tests/src/"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
|
||||
50
phpunit.xml
50
phpunit.xml
@@ -1,23 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
|
||||
<coverage>
|
||||
<include>
|
||||
<directory suffix=".php">./</directory>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory suffix=".php">./vendor</directory>
|
||||
<directory suffix=".php">./test</directory>
|
||||
</exclude>
|
||||
<report>
|
||||
<clover outputFile="./build/coverage/log/coverage.xml"/>
|
||||
<html outputDirectory="./build/coverage/html" lowUpperBound="35" highLowerBound="70"/>
|
||||
</report>
|
||||
</coverage>
|
||||
<logging/>
|
||||
<testsuites>
|
||||
<testsuite name="Package Test Suite">
|
||||
<directory suffix=".php">./test/</directory>
|
||||
<exclude>./test/ext/</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
executionOrder="depends,defects"
|
||||
beStrictAboutCoverageMetadata="true"
|
||||
beStrictAboutOutputDuringTests="true"
|
||||
displayDetailsOnPhpunitDeprecations="true"
|
||||
failOnPhpunitDeprecation="true"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true">
|
||||
<testsuites>
|
||||
<testsuite name="default">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<source restrictNotices="true" restrictWarnings="true">
|
||||
<include>
|
||||
<directory>src</directory>
|
||||
</include>
|
||||
</source>
|
||||
|
||||
<coverage>
|
||||
<report>
|
||||
<clover outputFile="./build/coverage/log/coverage.xml"/>
|
||||
<html outputDirectory="./build/coverage/html" lowUpperBound="35" highLowerBound="70"/>
|
||||
</report>
|
||||
</coverage>
|
||||
</phpunit>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\OAuth2\Client\Provider;
|
||||
|
||||
use Exception;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use InvalidArgumentException;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\Signer;
|
||||
use Lcobucci\JWT\Signer\Key;
|
||||
use League\OAuth2\Client\Grant\AbstractGrant;
|
||||
use League\OAuth2\Client\Provider\Exception\AppleAccessDeniedException;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
use League\OAuth2\Client\Token\AccessTokenInterface;
|
||||
use League\OAuth2\Client\Token\AppleAccessToken;
|
||||
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
class Apple extends AbstractProvider
|
||||
@@ -24,30 +25,27 @@ class Apple extends AbstractProvider
|
||||
/**
|
||||
* Default scopes
|
||||
*
|
||||
* @var array
|
||||
* @var array<string>
|
||||
*/
|
||||
public $defaultScopes = ['name', 'email'];
|
||||
public array $defaultScopes = ['name', 'email'];
|
||||
|
||||
/**
|
||||
* @var string the team id
|
||||
* the team id
|
||||
*/
|
||||
protected $teamId;
|
||||
protected string $teamId;
|
||||
|
||||
/**
|
||||
* @var string the key file id
|
||||
* the key file id
|
||||
*/
|
||||
protected $keyFileId;
|
||||
protected string $keyFileId;
|
||||
|
||||
/**
|
||||
* @var string the key file path
|
||||
* the key file path
|
||||
*/
|
||||
protected $keyFilePath;
|
||||
protected string $keyFilePath;
|
||||
|
||||
/**
|
||||
* Constructs Apple's OAuth 2.0 service provider.
|
||||
*
|
||||
* @param array $options
|
||||
* @param array $collaborators
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function __construct(array $options = [], array $collaborators = [])
|
||||
{
|
||||
@@ -67,28 +65,21 @@ class Apple extends AbstractProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an access token from a response.
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
* @return AccessTokenInterface
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant): AccessTokenInterface
|
||||
{
|
||||
return new AppleAccessToken($this->getAppleKeys(), $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] Apple's JSON Web Keys
|
||||
* @return array<string, Key> Apple's JSON Web Keys
|
||||
*/
|
||||
private function getAppleKeys()
|
||||
private function getAppleKeys(): array
|
||||
{
|
||||
$response = $this->httpClient->request('GET', 'https://appleid.apple.com/auth/keys');
|
||||
|
||||
if ($response && $response->getStatusCode() === 200) {
|
||||
if ($response->getStatusCode() === 200) {
|
||||
return JWK::parseKeySet(json_decode($response->getBody()->__toString(), true));
|
||||
}
|
||||
|
||||
@@ -96,35 +87,27 @@ class Apple extends AbstractProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string used to separate scopes.
|
||||
*
|
||||
* @return string
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function getScopeSeparator()
|
||||
protected function getScopeSeparator(): string
|
||||
{
|
||||
return ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Change response mode when scope requires it
|
||||
*
|
||||
* @param array $options
|
||||
*
|
||||
* @return array
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function getAuthorizationParameters(array $options)
|
||||
protected function getAuthorizationParameters(array $options): array
|
||||
{
|
||||
$options = parent::getAuthorizationParameters($options);
|
||||
if (strpos($options['scope'], 'name') !== false || strpos($options['scope'], 'email') !== false) {
|
||||
if (str_contains($options['scope'], 'name') || str_contains($options['scope'], 'email')) {
|
||||
$options['response_mode'] = 'form_post';
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return mixed
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function fetchResourceOwnerDetails(AccessToken $token)
|
||||
{
|
||||
@@ -133,31 +116,25 @@ class Apple extends AbstractProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization url to begin OAuth flow
|
||||
*
|
||||
* @return string
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getBaseAuthorizationUrl()
|
||||
public function getBaseAuthorizationUrl(): string
|
||||
{
|
||||
return 'https://appleid.apple.com/auth/authorize';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token url to retrieve token
|
||||
*
|
||||
* @return string
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getBaseAccessTokenUrl(array $params)
|
||||
public function getBaseAccessTokenUrl(array $params): string
|
||||
{
|
||||
return 'https://appleid.apple.com/auth/token';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get revoke token url to revoke token
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseRevokeTokenUrl(array $params)
|
||||
public function getBaseRevokeTokenUrl(): string
|
||||
{
|
||||
return 'https://appleid.apple.com/auth/revoke';
|
||||
}
|
||||
@@ -165,25 +142,17 @@ class Apple extends AbstractProvider
|
||||
/**
|
||||
* Get provider url to fetch user details
|
||||
*
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getResourceOwnerDetailsUrl(AccessToken $token)
|
||||
public function getResourceOwnerDetailsUrl(AccessToken $token): string
|
||||
{
|
||||
throw new Exception('No Apple ID REST API available yet!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default scopes used by this provider.
|
||||
*
|
||||
* This should not be a complete list of all scopes, but the minimum
|
||||
* required for the provider user interface!
|
||||
*
|
||||
* @return array
|
||||
* @inheritDoc
|
||||
*/
|
||||
protected function getDefaultScopes()
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return $this->defaultScopes;
|
||||
}
|
||||
@@ -191,16 +160,21 @@ class Apple extends AbstractProvider
|
||||
/**
|
||||
* Check a provider response for errors.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array $data Parsed response data
|
||||
* @return void
|
||||
* @inheritDoc
|
||||
* @throws AppleAccessDeniedException
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
protected function checkResponse(ResponseInterface $response, $data): void
|
||||
{
|
||||
if ($response->getStatusCode() >= 400) {
|
||||
$message = $response->getReasonPhrase();
|
||||
if (array_key_exists('error', $data)) {
|
||||
$message = $data['error'];
|
||||
}
|
||||
if (array_key_exists('error_description', $data)) {
|
||||
$message .= ': ' . $data['error_description'];
|
||||
}
|
||||
throw new AppleAccessDeniedException(
|
||||
array_key_exists('error', $data) ? $data['error'] : $response->getReasonPhrase(),
|
||||
$message,
|
||||
array_key_exists('code', $data) ? $data['code'] : $response->getStatusCode(),
|
||||
$response
|
||||
);
|
||||
@@ -209,20 +183,15 @@ class Apple extends AbstractProvider
|
||||
|
||||
/**
|
||||
* Generate a user object from a successful user details request.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
* @return AppleResourceOwner
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
protected function createResourceOwner(array $response, AccessToken $token): AppleResourceOwner
|
||||
{
|
||||
return new AppleResourceOwner(
|
||||
array_merge(
|
||||
['sub' => $token->getResourceOwnerId()],
|
||||
$response,
|
||||
[
|
||||
'email' => isset($token->getValues()['email'])
|
||||
? $token->getValues()['email'] : (isset($response['email']) ? $response['email'] : null),
|
||||
'email' => $token->getValues()['email'] ?? ($response['email'] ?? null),
|
||||
'isPrivateEmail' => $token instanceof AppleAccessToken ? $token->isPrivateEmail() : null
|
||||
]
|
||||
),
|
||||
@@ -233,26 +202,31 @@ class Apple extends AbstractProvider
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getAccessToken($grant, array $options = [])
|
||||
public function getAccessToken($grant, array $options = []): AccessTokenInterface
|
||||
{
|
||||
$configuration = $this->getConfiguration();
|
||||
$time = new \DateTimeImmutable();
|
||||
$time = $time->setTime($time->format('H'), $time->format('i'), $time->format('s'));
|
||||
$expiresAt = $time->modify('+1 Hour');
|
||||
$expiresAt = $expiresAt->setTime($expiresAt->format('H'), $expiresAt->format('i'), $expiresAt->format('s'));
|
||||
$time = time();
|
||||
|
||||
$token = $configuration->builder()
|
||||
->issuedBy($this->teamId)
|
||||
->permittedFor('https://appleid.apple.com')
|
||||
->issuedAt($time)
|
||||
->expiresAt($expiresAt)
|
||||
->relatedTo($this->clientId)
|
||||
->withHeader('alg', 'ES256')
|
||||
->withHeader('kid', $this->keyFileId)
|
||||
->getToken($configuration->signer(), $configuration->signingKey());
|
||||
$payload = [
|
||||
'iss' => $this->teamId,
|
||||
'iat' => $time,
|
||||
'exp' => $time + 3600,
|
||||
'aud' => 'https://appleid.apple.com',
|
||||
'sub' => $this->clientId,
|
||||
];
|
||||
|
||||
$jwt = JWT::encode(
|
||||
$payload,
|
||||
$this->getLocalKey(),
|
||||
'ES256',
|
||||
$this->keyFileId,
|
||||
[
|
||||
'kid' => $this->keyFileId,
|
||||
'alg' => 'ES256',
|
||||
]
|
||||
);
|
||||
|
||||
$options += [
|
||||
'client_secret' => $token->toString()
|
||||
'client_secret' => $jwt
|
||||
];
|
||||
|
||||
return parent::getAccessToken($grant, $options);
|
||||
@@ -260,42 +234,44 @@ class Apple extends AbstractProvider
|
||||
|
||||
/**
|
||||
* Revokes an access or refresh token using a specified token.
|
||||
*
|
||||
* @param string $token
|
||||
* @param string|null $tokenTypeHint
|
||||
* @return \Psr\Http\Message\RequestInterface
|
||||
*/
|
||||
public function revokeAccessToken($token, $tokenTypeHint = null)
|
||||
public function revokeAccessToken(string $token, ?string $tokenTypeHint = null)
|
||||
{
|
||||
$configuration = $this->getConfiguration();
|
||||
$time = new \DateTimeImmutable();
|
||||
$time = $time->setTime($time->format('H'), $time->format('i'), $time->format('s'));
|
||||
$expiresAt = $time->modify('+1 Hour');
|
||||
$expiresAt = $expiresAt->setTime($expiresAt->format('H'), $expiresAt->format('i'), $expiresAt->format('s'));
|
||||
$time = time();
|
||||
|
||||
$clientSecret = $configuration->builder()
|
||||
->issuedBy($this->teamId)
|
||||
->permittedFor('https://appleid.apple.com')
|
||||
->issuedAt($time)
|
||||
->expiresAt($expiresAt)
|
||||
->relatedTo($this->clientId)
|
||||
->withHeader('alg', 'ES256')
|
||||
->withHeader('kid', $this->keyFileId)
|
||||
->getToken($configuration->signer(), $configuration->signingKey());
|
||||
$payload = [
|
||||
'iss' => $this->teamId,
|
||||
'iat' => $time,
|
||||
'exp' => $time + 3600,
|
||||
'aud' => 'https://appleid.apple.com',
|
||||
'sub' => $this->clientId,
|
||||
];
|
||||
|
||||
$clientSecret = JWT::encode(
|
||||
$payload,
|
||||
$this->getLocalKey(),
|
||||
'ES256',
|
||||
$this->keyFileId,
|
||||
[
|
||||
'kid' => $this->keyFileId,
|
||||
'alg' => 'ES256',
|
||||
]
|
||||
);
|
||||
|
||||
$params = [
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $clientSecret->toString(),
|
||||
'token' => $token
|
||||
'client_id' => $this->clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'token' => $token
|
||||
];
|
||||
|
||||
if ($tokenTypeHint !== null) {
|
||||
$params += [
|
||||
'token_type_hint' => $tokenTypeHint
|
||||
];
|
||||
}
|
||||
|
||||
$method = $this->getAccessTokenMethod();
|
||||
$url = $this->getBaseRevokeTokenUrl($params);
|
||||
$method = $this->getAccessTokenMethod();
|
||||
$url = $this->getBaseRevokeTokenUrl();
|
||||
if (property_exists($this, 'optionProvider')) {
|
||||
$options = $this->optionProvider->getAccessTokenOptions(self::METHOD_POST, $params);
|
||||
} else {
|
||||
@@ -306,29 +282,12 @@ class Apple extends AbstractProvider
|
||||
return $this->getParsedResponse($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Configuration
|
||||
*/
|
||||
public function getConfiguration()
|
||||
public function getLocalKey(): string
|
||||
{
|
||||
if (method_exists(Signer\Ecdsa\Sha256::class, 'create')) {
|
||||
return Configuration::forSymmetricSigner(
|
||||
Signer\Ecdsa\Sha256::create(),
|
||||
$this->getLocalKey()
|
||||
);
|
||||
} else {
|
||||
return Configuration::forSymmetricSigner(
|
||||
new Signer\Ecdsa\Sha256(),
|
||||
$this->getLocalKey()
|
||||
);
|
||||
if (!file_exists($this->keyFilePath)) {
|
||||
throw new \InvalidArgumentException('Could not read key file');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Key
|
||||
*/
|
||||
public function getLocalKey()
|
||||
{
|
||||
return InMemory::file($this->keyFilePath);
|
||||
return file_get_contents($this->keyFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,72 @@
|
||||
<?php namespace League\OAuth2\Client\Provider;
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\OAuth2\Client\Provider;
|
||||
|
||||
use League\OAuth2\Client\Tool\ArrayAccessorTrait;
|
||||
|
||||
/**
|
||||
* @property array $response
|
||||
* @property string $uid
|
||||
*/
|
||||
class AppleResourceOwner extends GenericResourceOwner
|
||||
{
|
||||
use ArrayAccessorTrait;
|
||||
|
||||
/**
|
||||
* Raw response
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $response = [];
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $email;
|
||||
private ?string $email;
|
||||
|
||||
/**
|
||||
* @var boolean true when its private relay from apple else the user mail address
|
||||
* true when it's a private relay from apple else the user mail address
|
||||
*/
|
||||
private $isPrivateEmail;
|
||||
private bool $isPrivateEmail;
|
||||
|
||||
/**
|
||||
* Gets resource owner attribute by key. The key supports dot notation.
|
||||
*
|
||||
* @param string $key
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getAttribute($key)
|
||||
public function getAttribute(string $key): mixed
|
||||
{
|
||||
return $this->getValueByKey($this->response, (string) $key);
|
||||
return $this->getValueByKey($this->response, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user first name
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getFirstName()
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
$name = $this->getAttribute('name');
|
||||
if (isset($name)) {
|
||||
if (is_array($name)) {
|
||||
return $name['firstName'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user user id
|
||||
*
|
||||
* @return string|null
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->resourceOwnerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user last name
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getLastName()
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
$name = $this->getAttribute('name');
|
||||
if (isset($name)) {
|
||||
if (is_array($name)) {
|
||||
return $name['lastName'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user email, if available
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getEmail()
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->getAttribute('email');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isPrivateEmail()
|
||||
public function isPrivateEmail(): bool
|
||||
{
|
||||
return (bool) $this->getAttribute('isPrivateEmail');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all of the owner details available as an array.
|
||||
*
|
||||
* @return array
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function toArray()
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->response;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace League\OAuth2\Client\Provider\Exception;
|
||||
|
||||
class AppleAccessDeniedException extends IdentityProviderException
|
||||
|
||||
@@ -8,24 +8,13 @@ use InvalidArgumentException;
|
||||
|
||||
class AppleAccessToken extends AccessToken
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $idToken;
|
||||
protected string $idToken;
|
||||
|
||||
protected string $email;
|
||||
|
||||
protected ?bool $isPrivateEmail = null;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $email;
|
||||
|
||||
/**
|
||||
* @var boolean
|
||||
*/
|
||||
protected $isPrivateEmail;
|
||||
|
||||
/**
|
||||
* Constructs an access token.
|
||||
*
|
||||
* @param Key[] $keys Valid Apple JWT keys
|
||||
* @param array $options An array of options returned by the service provider
|
||||
* in the access token request. The `access_token` option is required.
|
||||
@@ -46,18 +35,9 @@ class AppleAccessToken extends AccessToken
|
||||
try {
|
||||
try {
|
||||
$decoded = JWT::decode($options['id_token'], $key);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
$decodeMethodReflection = new \ReflectionMethod(JWT::class, 'decode');
|
||||
$decodeMethodParameters = $decodeMethodReflection->getParameters();
|
||||
// Backwards compatibility for firebase/php-jwt >=5.2.0 <=5.5.1 supported by PHP 5.6
|
||||
if (array_key_exists(2, $decodeMethodParameters) &&
|
||||
'allowed_algs' === $decodeMethodParameters[2]->getName()
|
||||
) {
|
||||
$decoded = JWT::decode($options['id_token'], $key, ['RS256']);
|
||||
} else {
|
||||
$headers = (object) ['alg' => 'RS256'];
|
||||
$decoded = JWT::decode($options['id_token'], $key, $headers);
|
||||
}
|
||||
} catch (\UnexpectedValueException) {
|
||||
$headers = (object) ['alg' => 'RS256'];
|
||||
$decoded = JWT::decode($options['id_token'], $key, $headers);
|
||||
}
|
||||
break;
|
||||
} catch (\Exception $exception) {
|
||||
@@ -66,9 +46,11 @@ class AppleAccessToken extends AccessToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $decoded) {
|
||||
throw new \Exception('Got no data within "id_token"!');
|
||||
}
|
||||
|
||||
$payload = json_decode(json_encode($decoded), true);
|
||||
|
||||
$options['resource_owner_id'] = $payload['sub'];
|
||||
@@ -85,34 +67,25 @@ class AppleAccessToken extends AccessToken
|
||||
parent::__construct($options);
|
||||
|
||||
if (isset($options['id_token'])) {
|
||||
$this->idToken = $options['id_token'];
|
||||
$this->idToken = (string)$options['id_token'];
|
||||
}
|
||||
|
||||
if (isset($options['email'])) {
|
||||
$this->email = $options['email'];
|
||||
$this->email = (string)$options['email'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getIdToken()
|
||||
public function getIdToken(): string
|
||||
{
|
||||
return $this->idToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getEmail()
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
*/
|
||||
public function isPrivateEmail()
|
||||
public function isPrivateEmail(): ?bool
|
||||
{
|
||||
return $this->isPrivateEmail;
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace League\OAuth2\Client\Test;
|
||||
|
||||
use Lcobucci\JWT\Signature;
|
||||
use Lcobucci\JWT\Signer;
|
||||
|
||||
final class KeyDumpSigner implements Signer
|
||||
{
|
||||
public function getAlgorithmId()
|
||||
{
|
||||
return 'keydump';
|
||||
}
|
||||
|
||||
public function modifyHeader(array &$headers)
|
||||
{
|
||||
$headers['alg'] = $this->getAlgorithmId();
|
||||
}
|
||||
|
||||
public function verify($expected, $payload, $key)
|
||||
{
|
||||
return $expected === $key->contents();
|
||||
}
|
||||
|
||||
public function sign($payload, $key)
|
||||
{
|
||||
return new Signature($key->contents());
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace League\OAuth2\Client\Test;
|
||||
|
||||
use Lcobucci\JWT\Signer;
|
||||
use Lcobucci\JWT\Signer\Key;
|
||||
|
||||
final class KeyDumpSigner implements Signer
|
||||
{
|
||||
public function algorithmId(): string
|
||||
{
|
||||
return 'keydump';
|
||||
}
|
||||
|
||||
public function sign(string $payload, Key $key): string
|
||||
{
|
||||
return $key->contents();
|
||||
}
|
||||
|
||||
public function verify(string $expected, string $payload, Key $key): bool
|
||||
{
|
||||
return $expected === $key->contents();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace League\OAuth2\Client\Test;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Composer\Semver\VersionParser;
|
||||
|
||||
if (!InstalledVersions::satisfies(new VersionParser(), 'lcobucci/jwt', '^1 || ^2 || ^3')) {
|
||||
require_once __DIR__ . '/../ext/KeyDumpSigner8.php';
|
||||
} else {
|
||||
require_once __DIR__ . '/../ext/KeyDumpSigner5.php';
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace League\OAuth2\Client\Test\Provider;
|
||||
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use League\OAuth2\Client\Provider\Apple;
|
||||
use League\OAuth2\Client\Test\KeyDumpSigner;
|
||||
|
||||
/**
|
||||
* Class TestApple
|
||||
* @package League\OAuth2\Client\Test\Provider
|
||||
* @author Patrick Bußmann <patrick.bussmann@bussmann-it.de>
|
||||
*/
|
||||
class TestApple extends Apple
|
||||
{
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getConfiguration()
|
||||
{
|
||||
return Configuration::forSymmetricSigner(
|
||||
new KeyDumpSigner(),
|
||||
InMemory::plainText('private')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function getLocalKey()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
namespace League\OAuth2\Client\Test\Provider;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Key;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use League\OAuth2\Client\Provider\Apple;
|
||||
use League\OAuth2\Client\Provider\AppleResourceOwner;
|
||||
use League\OAuth2\Client\Test\KeyDumpSigner;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
use League\OAuth2\Client\Token\AppleAccessToken;
|
||||
use League\OAuth2\Client\Tool\QueryBuilderTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Mockery as m;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppleTest extends TestCase
|
||||
{
|
||||
@@ -23,7 +19,7 @@ class AppleTest extends TestCase
|
||||
/**
|
||||
* @return Apple
|
||||
*/
|
||||
private function getProvider()
|
||||
private function getProvider(): Apple
|
||||
{
|
||||
return new Apple([
|
||||
'clientId' => 'mock.example',
|
||||
@@ -132,23 +128,27 @@ class AppleTest extends TestCase
|
||||
]);
|
||||
$provider = m::mock($provider);
|
||||
|
||||
$time = time();
|
||||
|
||||
$configuration = Configuration::forSymmetricSigner(
|
||||
new KeyDumpSigner(),
|
||||
Key\InMemory::plainText('private')
|
||||
$payload = [
|
||||
'iss' => 'test-team-id',
|
||||
'iat' => $time,
|
||||
'exp' => $time + 3600,
|
||||
'aud' => 'https://appleid.apple.com',
|
||||
'sub' => 'test-client',
|
||||
];
|
||||
|
||||
$jwt = JWT::encode(
|
||||
$payload,
|
||||
'file://' . __DIR__ . '/../private_key.pem',
|
||||
'ES256',
|
||||
'test',
|
||||
[
|
||||
'kid' => 'test',
|
||||
'alg' => 'ES256',
|
||||
]
|
||||
);
|
||||
|
||||
$time = new \DateTimeImmutable();
|
||||
$expiresAt = $time->modify('+1 Hour');
|
||||
$token = $configuration->builder()
|
||||
->issuedBy('test-team-id')
|
||||
->permittedFor('https://appleid.apple.com')
|
||||
->issuedAt($time)
|
||||
->expiresAt($expiresAt)
|
||||
->relatedTo('test-client')
|
||||
->withHeader('alg', 'RS256')
|
||||
->withHeader('kid', 'test')
|
||||
->getToken($configuration->signer(), $configuration->signingKey());
|
||||
|
||||
$client = m::mock(ClientInterface::class);
|
||||
$client->shouldReceive('request')
|
||||
@@ -161,7 +161,7 @@ class AppleTest extends TestCase
|
||||
'token_type' => 'Bearer',
|
||||
'expires_in' => 3600,
|
||||
'refresh_token' => 'r4a6e8b9c50104b78bc86b0d2649353fa.0.mrwxq.54joUj40j0cpuMANRtRjfg',
|
||||
'id_token' => $token->toString()
|
||||
'id_token' => $jwt
|
||||
])));
|
||||
$provider->setHttpClient($client);
|
||||
|
||||
@@ -385,12 +385,4 @@ class AppleTest extends TestCase
|
||||
$this->assertNull($data->getLastName());
|
||||
$this->assertNull($data->getFirstName());
|
||||
}
|
||||
|
||||
public function testGetConfiguration()
|
||||
{
|
||||
$provider = m::mock(Apple::class)->makePartial();
|
||||
$provider->shouldReceive('getLocalKey')->andReturn(m::mock(Key::class));
|
||||
|
||||
$this->assertInstanceOf(Configuration::class, $provider->getConfiguration());
|
||||
}
|
||||
}
|
||||
21
tests/src/Provider/TestApple.php
Normal file
21
tests/src/Provider/TestApple.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace League\OAuth2\Client\Test\Provider;
|
||||
|
||||
use League\OAuth2\Client\Provider\Apple;
|
||||
|
||||
/**
|
||||
* Class TestApple
|
||||
* @package League\OAuth2\Client\Test\Provider
|
||||
* @author Patrick Bußmann <patrick.bussmann@bussmann-it.de>
|
||||
*/
|
||||
class TestApple extends Apple
|
||||
{
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLocalKey(): string
|
||||
{
|
||||
return 'file://' . __DIR__ . '/../private_key.pem';
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
namespace League\OAuth2\Client\Test\Token;
|
||||
|
||||
use Firebase\JWT\Key;
|
||||
use League\OAuth2\Client\Token\AppleAccessToken;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Mockery as m;
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppleAccessTokenTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function testCreatingAccessToken()
|
||||
{
|
||||
$externalJWTMock = m::mock('overload:Firebase\JWT\JWT');
|
||||
@@ -65,10 +64,8 @@ class AppleAccessTokenTest extends TestCase
|
||||
$this->assertEquals('access_token', $refreshToken->getToken());
|
||||
}
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
#[PreserveGlobalState(false)]
|
||||
#[RunInSeparateProcess]
|
||||
public function testCreatingAccessTokenFailsBecauseNoDecodingIsPossible()
|
||||
{
|
||||
$this->expectException('\Exception');
|
||||
16
tests/src/private_key.pem
Normal file
16
tests/src/private_key.pem
Normal file
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAJ/7C8IWBvfYj01Q
|
||||
+4mldM50B8qPVHIoDEyxNsNXLcYqe3GkkHklKFRKidaxOxx6MBAQz9l2bgfh6Stt
|
||||
NWkeJ3I8oZ73zju/twjqhQhzveST+xwpUP935xgkbYmZVEM9JRj6wQ52pYA2sdkx
|
||||
/5wqvF7oJ7dlsGIga6PbV0/i59kdAgMBAAECgYBuCUmkHGx8irq+LkZk/aXi3tIB
|
||||
FCa8Qil7kqSdJVh5pfy0RMGOYe1kVMSMI+kJhE2Mr1OXOqshxtQPJ5WGENSF2yBq
|
||||
BRbFn/Xeweh1MpXT7no1dhaS0Rfn01ScvgDMc6NkuiTwshSxOhnuaFVVxYPJKFP4
|
||||
aqY6Dp8vSwrISlnPbQJBAMxHtPmIU9uuDq7fYAl+5WK2S4gY5IPl2y0xJIwoN6+q
|
||||
hc+3ZkMIDrcwzzqxcV82jBU7CnnV/CmZ+r99e4jmbAMCQQDIfBicV7YuuBLXlDGe
|
||||
LSCSqP1+V9o2ZVtR0PM4Z4346xJN/rJ4LWz2KexY//nKUMsVIyJuYp1pJpN21K6p
|
||||
8exfAkEAia9bH0TvoIV0iBEunbfVy+6qghSlEPGABLm2tHD294OrpREr78oigP54
|
||||
7kpi65XMXRLqQKwlxbRu+VoORXtpGQJABxTzHZqvkcjoyXqvogHAE84qXismRyOf
|
||||
bS1vWf+2cSOEmwKzNTGNlsh2U9J+9VmTQuTh03piSxOUw+7RWKl2CwJAUdoH4is3
|
||||
qRdiRtRcox8QmIgtFFN5r7ZcFfJO4wjrjM5KbB3mdYoxu5jE8bkR//t5Unz5mqqu
|
||||
1uo+Dq/SJyXi7w==
|
||||
-----END PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user