193 lines
5.4 KiB
PHP
193 lines
5.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\SocialProvider;
|
|
use Firebase\JWT\JWK;
|
|
use Firebase\JWT\JWT;
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
|
use Throwable;
|
|
|
|
class AppleIdentityTokenVerifier
|
|
{
|
|
private const JWKS_CACHE_KEY = 'social-auth:apple:jwks';
|
|
|
|
public function __construct(private SocialAuthenticationNonce $socialAuthenticationNonce) {}
|
|
|
|
public function userFromToken(string $identityToken, string $nonce): SocialiteUser
|
|
{
|
|
$claims = $this->verifiedClaims($identityToken);
|
|
|
|
$this->validateNonce($claims, $nonce);
|
|
|
|
return (new SocialiteUser)->setRaw($claims)->map([
|
|
'id' => Arr::get($claims, 'sub'),
|
|
'nickname' => null,
|
|
'name' => null,
|
|
'email' => Arr::get($claims, 'email'),
|
|
'avatar' => null,
|
|
]);
|
|
}
|
|
|
|
public function subjectFromToken(string $identityToken): string
|
|
{
|
|
$subject = Arr::get($this->verifiedClaims($identityToken), 'sub');
|
|
|
|
if (! is_string($subject) || $subject === '') {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
|
|
return $subject;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function verifiedClaims(string $identityToken): array
|
|
{
|
|
try {
|
|
$claims = (array) JWT::decode(
|
|
$identityToken,
|
|
JWK::parseKeySet($this->jwkSetForToken($identityToken)),
|
|
);
|
|
|
|
$this->validateStandardClaims($claims);
|
|
|
|
return $claims;
|
|
} catch (HttpExceptionInterface $exception) {
|
|
throw $exception;
|
|
} catch (ConnectionException) {
|
|
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
|
} catch (Throwable) {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function jwkSetForToken(string $identityToken): array
|
|
{
|
|
$keyId = $this->tokenKeyId($identityToken);
|
|
$jwkSet = $this->jwkSet();
|
|
|
|
if ($this->jwkSetContains($jwkSet, $keyId)) {
|
|
return $jwkSet;
|
|
}
|
|
|
|
Cache::forget(self::JWKS_CACHE_KEY);
|
|
|
|
return $this->jwkSet();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function jwkSet(): array
|
|
{
|
|
return Cache::remember(
|
|
self::JWKS_CACHE_KEY,
|
|
now()->addHours(6),
|
|
function (): array {
|
|
$url = (string) config('services.apple.jwks_url');
|
|
|
|
if ($url === '') {
|
|
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
|
}
|
|
|
|
$response = Http::acceptJson()
|
|
->connectTimeout(3)
|
|
->timeout(10)
|
|
->retry([100, 500], throw: false)
|
|
->get($url);
|
|
|
|
$jwkSet = $response->successful() ? $response->json() : null;
|
|
|
|
if (! is_array($jwkSet) || ! isset($jwkSet['keys']) || ! is_array($jwkSet['keys'])) {
|
|
abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE');
|
|
}
|
|
|
|
return $jwkSet;
|
|
},
|
|
);
|
|
}
|
|
|
|
private function tokenKeyId(string $identityToken): string
|
|
{
|
|
$segments = explode('.', $identityToken);
|
|
|
|
if (count($segments) !== 3) {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
|
|
$header = JWT::jsonDecode(JWT::urlsafeB64Decode($segments[0]));
|
|
$keyId = $header->kid ?? null;
|
|
|
|
if (! is_string($keyId) || $keyId === '') {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
|
|
return $keyId;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $jwkSet
|
|
*/
|
|
private function jwkSetContains(array $jwkSet, string $keyId): bool
|
|
{
|
|
return collect($jwkSet['keys'] ?? [])->contains(
|
|
fn (mixed $key): bool => is_array($key) && ($key['kid'] ?? null) === $keyId,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $claims
|
|
*/
|
|
private function validateStandardClaims(array $claims): void
|
|
{
|
|
$issuer = (string) config('services.apple.issuer');
|
|
$tokenIssuer = Arr::get($claims, 'iss');
|
|
|
|
if (! is_string($tokenIssuer) || $issuer === '' || ! hash_equals($issuer, $tokenIssuer)) {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
|
|
$allowedClientIds = collect(explode(',', (string) config('services.apple.client_ids')))
|
|
->map(fn (string $clientId): string => trim($clientId))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
$tokenAudiences = array_filter(
|
|
Arr::wrap(Arr::get($claims, 'aud')),
|
|
fn (mixed $audience): bool => is_string($audience),
|
|
);
|
|
|
|
if ($allowedClientIds === []) {
|
|
abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED');
|
|
}
|
|
|
|
if (array_intersect($allowedClientIds, $tokenAudiences) === []) {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $claims
|
|
*/
|
|
private function validateNonce(array $claims, string $nonce): void
|
|
{
|
|
if (! $this->socialAuthenticationNonce->consume(
|
|
SocialProvider::Apple,
|
|
$nonce,
|
|
Arr::get($claims, 'nonce'),
|
|
)) {
|
|
abort(401, 'SOCIAL_TOKEN_INVALID');
|
|
}
|
|
}
|
|
}
|