feat: google login
This commit is contained in:
@@ -1,15 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configureDeletionAppleOAuthClient(): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if ($key === false || ! openssl_pkey_export($key, $privateKey)) {
|
||||
throw new RuntimeException('Unable to generate an Apple OAuth test key.');
|
||||
}
|
||||
|
||||
config()->set([
|
||||
'services.apple.client_id' => 'com.meal.daily',
|
||||
'services.apple.issuer' => 'https://appleid.apple.com',
|
||||
'services.apple.key_id' => 'APPLEKEY1',
|
||||
'services.apple.private_key' => $privateKey,
|
||||
'services.apple.private_key_base64' => null,
|
||||
'services.apple.revoke_url' => 'https://appleid.apple.com/auth/revoke',
|
||||
'services.apple.team_id' => 'TEAMID1234',
|
||||
]);
|
||||
}
|
||||
|
||||
it('deletes the authenticated account and related data', function () {
|
||||
config()->set('filesystems.default', 's3');
|
||||
|
||||
@@ -81,3 +107,79 @@ it('requires authentication to delete an account', function () {
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('revokes the Apple refresh token before deleting the account', function () {
|
||||
configureDeletionAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/revoke' => Http::response(status: 200),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => 'apple-refresh-token',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 'ACCOUNT_DELETED');
|
||||
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
$data = $request->data();
|
||||
|
||||
return $request->url() === 'https://appleid.apple.com/auth/revoke'
|
||||
&& $request->isForm()
|
||||
&& ($data['client_id'] ?? null) === 'com.meal.daily'
|
||||
&& ($data['token'] ?? null) === 'apple-refresh-token'
|
||||
&& ($data['token_type_hint'] ?? null) === 'refresh_token'
|
||||
&& is_string($data['client_secret'] ?? null);
|
||||
});
|
||||
$this->assertModelMissing($user);
|
||||
$this->assertModelMissing($socialAccount);
|
||||
});
|
||||
|
||||
it('keeps the account when Apple token revocation fails', function () {
|
||||
configureDeletionAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/revoke' => Http::response([
|
||||
'error' => 'invalid_client',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => 'apple-refresh-token',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('code', 'SOCIAL_REVOCATION_FAILED');
|
||||
|
||||
$this->assertModelExists($user);
|
||||
$this->assertModelExists($socialAccount);
|
||||
expect($socialAccount->fresh()->provider_refresh_token)->toBe('apple-refresh-token');
|
||||
});
|
||||
|
||||
it('keeps an Apple account that has no refresh token', function () {
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertConflict()
|
||||
->assertJsonPath('code', 'SOCIAL_REVOCATION_TOKEN_MISSING');
|
||||
|
||||
$this->assertModelExists($user);
|
||||
$this->assertModelExists($socialAccount);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Services\AppleIdentityTokenVerifier;
|
||||
use App\Services\AppleOAuthTokenClient;
|
||||
use App\Services\SocialAuthenticationNonce;
|
||||
use App\Services\SocialIdentityResolver;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Mockery\MockInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('ip:127.0.0.1');
|
||||
});
|
||||
|
||||
function bindResolvedSocialIdentity(array $attributes, int $times = 1): void
|
||||
{
|
||||
$identity = SocialiteUser::fake(array_merge([
|
||||
'email_verified' => true,
|
||||
], $attributes));
|
||||
$resolver = Mockery::mock(SocialIdentityResolver::class);
|
||||
$resolver->shouldReceive('resolve')->times($times)->andReturn($identity);
|
||||
|
||||
app()->instance(SocialIdentityResolver::class, $resolver);
|
||||
}
|
||||
|
||||
function socialLoginPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'deviceName' => 'Bowli iOS',
|
||||
'idToken' => 'provider-id-token',
|
||||
'intent' => 'register',
|
||||
'locale' => 'fr-FR',
|
||||
'nonce' => str_repeat('a', 64),
|
||||
'provider' => SocialProvider::Google->value,
|
||||
'termsAccepted' => true,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function bindAppleTokenExchange(string $subject, array $refreshTokens): void
|
||||
{
|
||||
$client = Mockery::mock(AppleOAuthTokenClient::class);
|
||||
$client->shouldReceive('exchangeAuthorizationCode')
|
||||
->times(count($refreshTokens))
|
||||
->withArgs(fn (string $authorizationCode, string $expectedSubject): bool => $authorizationCode !== ''
|
||||
&& $expectedSubject === $subject)
|
||||
->andReturn(...$refreshTokens);
|
||||
|
||||
app()->instance(AppleOAuthTokenClient::class, $client);
|
||||
}
|
||||
|
||||
function configureAppleOAuthClient(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if ($key === false || ! openssl_pkey_export($key, $privateKey)) {
|
||||
throw new RuntimeException('Unable to generate an Apple OAuth test key.');
|
||||
}
|
||||
|
||||
config()->set([
|
||||
'services.apple.client_id' => 'com.meal.daily',
|
||||
'services.apple.issuer' => 'https://appleid.apple.com',
|
||||
'services.apple.key_id' => 'APPLEKEY1',
|
||||
'services.apple.private_key' => $privateKey,
|
||||
'services.apple.private_key_base64' => null,
|
||||
'services.apple.team_id' => 'TEAMID1234',
|
||||
]);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
it('creates a verified mobile account from a Google identity', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-user-123',
|
||||
'name' => 'Camille Martin',
|
||||
'email' => 'Camille@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertOk()
|
||||
->assertJsonPath('user.email', 'camille@example.com')
|
||||
->assertJsonPath('user.emailVerified', true)
|
||||
->assertJsonPath('user.locale', 'fr')
|
||||
->assertJsonPath('user.name', 'camille.martin')
|
||||
->assertJsonStructure(['token']);
|
||||
|
||||
$user = User::where('email', 'camille@example.com')->firstOrFail();
|
||||
|
||||
expect($user->password)->toBeNull()
|
||||
->and($user->terms_accepted_at)->not->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'provider' => SocialProvider::Google->value,
|
||||
'provider_user_id' => 'google-user-123',
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas('personal_access_tokens', [
|
||||
'name' => 'Bowli iOS',
|
||||
'tokenable_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'not-a-social-password',
|
||||
])
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('reuses the same account on subsequent social logins', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-returning-user',
|
||||
'name' => 'Returning User',
|
||||
'email' => 'returning@example.com',
|
||||
], 2);
|
||||
|
||||
$firstUserId = $this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertOk()
|
||||
->json('user.id');
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'locale' => 'en-US',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $firstUserId)
|
||||
->assertJsonPath('user.locale', 'en');
|
||||
|
||||
expect(User::count())->toBe(1)
|
||||
->and(SocialAccount::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('links a verified social identity to an existing email account', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
$password = $user->password;
|
||||
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-existing-user',
|
||||
'name' => 'Different Provider Name',
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $user->getKey());
|
||||
|
||||
expect($user->fresh()->password)->toBe($password)
|
||||
->and($user->socialAccounts()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the Apple name received only during the first authorization', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'apple-user-123',
|
||||
'name' => null,
|
||||
'email' => 'relay@privaterelay.appleid.com',
|
||||
], 2);
|
||||
bindAppleTokenExchange('apple-user-123', [
|
||||
'apple-refresh-token-1',
|
||||
'apple-refresh-token-2',
|
||||
]);
|
||||
|
||||
$payload = socialLoginPayload([
|
||||
'authorizationCode' => 'authorization-code-1',
|
||||
'name' => 'Alex Dupont',
|
||||
'nonce' => str_repeat('a', 64),
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('user.name', 'alex.dupont');
|
||||
|
||||
unset($payload['name']);
|
||||
$payload['authorizationCode'] = 'authorization-code-2';
|
||||
$payload['intent'] = 'login';
|
||||
$payload['termsAccepted'] = false;
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('user.name', 'alex.dupont');
|
||||
|
||||
$socialAccount = SocialAccount::query()->firstOrFail();
|
||||
|
||||
expect($socialAccount->provider_refresh_token)->toBe('apple-refresh-token-2')
|
||||
->and($socialAccount->getRawOriginal('provider_refresh_token'))->not->toBe('apple-refresh-token-2')
|
||||
->and($socialAccount->toArray())->not->toHaveKey('provider_refresh_token');
|
||||
});
|
||||
|
||||
it('requires an authorization code for Apple authentication', function () {
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('authorizationCode');
|
||||
});
|
||||
|
||||
it('does not create an unknown account from the login intent', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'unknown-google-user',
|
||||
'email' => 'unknown@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('code', 'SOCIAL_ACCOUNT_NOT_FOUND');
|
||||
|
||||
expect(User::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('requires terms acceptance before creating a social account', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'terms-google-user',
|
||||
'email' => 'terms@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('code', 'TERMS_ACCEPTANCE_REQUIRED');
|
||||
|
||||
expect(User::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an identity whose provider email is not verified', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'unverified-google-user',
|
||||
'email' => 'unverified@example.com',
|
||||
'email_verified' => false,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'SOCIAL_EMAIL_NOT_VERIFIED');
|
||||
});
|
||||
|
||||
it('returns a safe error for an invalid provider token', function () {
|
||||
$this->mock(
|
||||
SocialIdentityResolver::class,
|
||||
function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->andThrow(new HttpException(401, 'SOCIAL_TOKEN_INVALID'));
|
||||
},
|
||||
);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'SOCIAL_TOKEN_INVALID');
|
||||
});
|
||||
|
||||
it('validates nonce input before resolving the identity', function () {
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'nonce' => null,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('nonce');
|
||||
});
|
||||
|
||||
it('issues provider-specific nonces', function () {
|
||||
Cache::flush();
|
||||
|
||||
$googleNonce = $this->postJson('/api/auth/mobile/social/nonce', [
|
||||
'provider' => SocialProvider::Google->value,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['nonce'])
|
||||
->json('nonce');
|
||||
$appleNonce = $this->postJson('/api/auth/mobile/social/nonce', [
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
])
|
||||
->assertOk()
|
||||
->json('nonce');
|
||||
|
||||
expect($googleNonce)->toBeString()->toHaveLength(64)
|
||||
->and($appleNonce)->toBeString()->toHaveLength(64)
|
||||
->and($googleNonce)->not->toBe($appleNonce);
|
||||
});
|
||||
|
||||
it('uses a nonce only with the provider it was issued for', function () {
|
||||
Cache::flush();
|
||||
$nonceService = app(SocialAuthenticationNonce::class);
|
||||
$nonce = $nonceService->issue(SocialProvider::Google);
|
||||
|
||||
expect($nonceService->consume(SocialProvider::Apple, $nonce, $nonce))->toBeFalse()
|
||||
->and($nonceService->consume(SocialProvider::Google, $nonce, $nonce))->toBeTrue()
|
||||
->and($nonceService->consume(SocialProvider::Google, $nonce, $nonce))->toBeFalse();
|
||||
});
|
||||
|
||||
it('requires the issued nonce in the Google identity token', function () {
|
||||
Cache::flush();
|
||||
config()->set('services.google.client_id', 'google-web-client-id');
|
||||
|
||||
$nonceService = app(SocialAuthenticationNonce::class);
|
||||
$nonce = $nonceService->issue(SocialProvider::Google);
|
||||
$identity = SocialiteUser::fake([
|
||||
'email' => 'google@example.com',
|
||||
'email_verified' => true,
|
||||
'nonce' => $nonce,
|
||||
]);
|
||||
$provider = Mockery::mock(AbstractProvider::class);
|
||||
$provider->shouldReceive('userFromToken')->twice()->andReturn($identity);
|
||||
$socialite = Mockery::mock(SocialiteFactory::class);
|
||||
$socialite->shouldReceive('driver')->with('google')->twice()->andReturn($provider);
|
||||
$resolver = new SocialIdentityResolver(
|
||||
$socialite,
|
||||
app(AppleIdentityTokenVerifier::class),
|
||||
$nonceService,
|
||||
);
|
||||
|
||||
expect($resolver->resolve(SocialProvider::Google, 'google-id-token', $nonce))
|
||||
->toBe($identity);
|
||||
|
||||
try {
|
||||
$resolver->resolve(SocialProvider::Google, 'google-id-token', $nonce);
|
||||
$this->fail('The Google nonce should only be accepted once.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
});
|
||||
|
||||
it('issues a single-use nonce and verifies Apple JWT claims', function () {
|
||||
Cache::flush();
|
||||
config()->set('services.apple.client_ids', 'com.meal.daily');
|
||||
|
||||
$key = openssl_pkey_new([
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
$details = openssl_pkey_get_details($key);
|
||||
$jwk = [
|
||||
'alg' => 'RS256',
|
||||
'e' => rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='),
|
||||
'kid' => 'apple-test-key',
|
||||
'kty' => 'RSA',
|
||||
'n' => rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='),
|
||||
'use' => 'sig',
|
||||
];
|
||||
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/keys' => Http::response(['keys' => [$jwk]]),
|
||||
]);
|
||||
|
||||
$verifier = app(AppleIdentityTokenVerifier::class);
|
||||
$nonce = app(SocialAuthenticationNonce::class)->issue(SocialProvider::Apple);
|
||||
$token = JWT::encode([
|
||||
'aud' => 'com.meal.daily',
|
||||
'email' => 'apple@example.com',
|
||||
'email_verified' => 'true',
|
||||
'exp' => now()->addMinutes(5)->timestamp,
|
||||
'iat' => now()->timestamp,
|
||||
'iss' => 'https://appleid.apple.com',
|
||||
'nonce' => $nonce,
|
||||
'sub' => 'apple-subject',
|
||||
], $privateKey, 'RS256', 'apple-test-key');
|
||||
|
||||
$identity = $verifier->userFromToken($token, $nonce);
|
||||
|
||||
expect($identity->getId())->toBe('apple-subject')
|
||||
->and($identity->getEmail())->toBe('apple@example.com');
|
||||
|
||||
try {
|
||||
$verifier->userFromToken($token, $nonce);
|
||||
$this->fail('The Apple nonce should only be accepted once.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
});
|
||||
|
||||
it('exchanges the Apple authorization code for a verified refresh token', function () {
|
||||
configureAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/token' => Http::response([
|
||||
'access_token' => 'apple-access-token',
|
||||
'expires_in' => 3600,
|
||||
'id_token' => 'returned-identity-token',
|
||||
'refresh_token' => 'apple-refresh-token',
|
||||
'token_type' => 'Bearer',
|
||||
]),
|
||||
]);
|
||||
|
||||
$identityTokenVerifier = Mockery::mock(AppleIdentityTokenVerifier::class);
|
||||
$identityTokenVerifier->shouldReceive('subjectFromToken')
|
||||
->once()
|
||||
->with('returned-identity-token')
|
||||
->andReturn('apple-user-123');
|
||||
$client = new AppleOAuthTokenClient($identityTokenVerifier);
|
||||
|
||||
expect($client->exchangeAuthorizationCode('authorization-code', 'apple-user-123'))
|
||||
->toBe('apple-refresh-token');
|
||||
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
$data = $request->data();
|
||||
$clientSecret = $data['client_secret'] ?? null;
|
||||
|
||||
if (! is_string($clientSecret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$segments = explode('.', $clientSecret);
|
||||
|
||||
if (count($segments) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = (array) JWT::jsonDecode(JWT::urlsafeB64Decode($segments[0]));
|
||||
$claims = (array) JWT::jsonDecode(JWT::urlsafeB64Decode($segments[1]));
|
||||
|
||||
return $request->url() === 'https://appleid.apple.com/auth/token'
|
||||
&& $request->isForm()
|
||||
&& ($data['client_id'] ?? null) === 'com.meal.daily'
|
||||
&& ($data['code'] ?? null) === 'authorization-code'
|
||||
&& ($data['grant_type'] ?? null) === 'authorization_code'
|
||||
&& ($header['alg'] ?? null) === 'ES256'
|
||||
&& ($header['kid'] ?? null) === 'APPLEKEY1'
|
||||
&& ($claims['aud'] ?? null) === 'https://appleid.apple.com'
|
||||
&& ($claims['iss'] ?? null) === 'TEAMID1234'
|
||||
&& ($claims['sub'] ?? null) === 'com.meal.daily';
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid Apple authorization code', function () {
|
||||
configureAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/token' => Http::response([
|
||||
'error' => 'invalid_grant',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$identityTokenVerifier = Mockery::mock(AppleIdentityTokenVerifier::class);
|
||||
$client = new AppleOAuthTokenClient($identityTokenVerifier);
|
||||
|
||||
try {
|
||||
$client->exchangeAuthorizationCode('expired-code', 'apple-user-123');
|
||||
$this->fail('The expired Apple authorization code should be rejected.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_AUTHORIZATION_CODE_INVALID');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user