509 lines
17 KiB
PHP
509 lines
17 KiB
PHP
<?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('creates an unknown account from the login intent', function () {
|
|
bindResolvedSocialIdentity([
|
|
'id' => 'unknown-google-user',
|
|
'name' => 'Unknown Google User',
|
|
'email' => 'unknown@example.com',
|
|
]);
|
|
|
|
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
|
'intent' => 'login',
|
|
'termsAccepted' => true,
|
|
]))
|
|
->assertOk()
|
|
->assertJsonPath('user.name', 'unknown.google.user')
|
|
->assertJsonStructure(['token']);
|
|
|
|
$user = User::query()->where('email', 'unknown@example.com')->firstOrFail();
|
|
|
|
expect($user->terms_accepted_at)->not->toBeNull()
|
|
->and($user->socialAccounts)->toHaveCount(1);
|
|
|
|
$this->assertDatabaseHas('social_accounts', [
|
|
'provider' => SocialProvider::Google->value,
|
|
'provider_user_id' => 'unknown-google-user',
|
|
'user_id' => $user->getKey(),
|
|
]);
|
|
});
|
|
|
|
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([
|
|
'intent' => 'login',
|
|
'termsAccepted' => false,
|
|
]))
|
|
->assertUnprocessable()
|
|
->assertJsonPath('code', 'TERMS_ACCEPTANCE_REQUIRED');
|
|
|
|
expect(User::count())->toBe(0);
|
|
});
|
|
|
|
it('generates a unique username when the Google name is already taken', function () {
|
|
User::factory()->create([
|
|
'name' => 'camille.martin',
|
|
'email' => 'existing-name@example.com',
|
|
]);
|
|
|
|
bindResolvedSocialIdentity([
|
|
'id' => 'google-duplicate-name',
|
|
'name' => 'Camille Martin',
|
|
'email' => 'new-camille@example.com',
|
|
]);
|
|
|
|
$response = $this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
|
'intent' => 'login',
|
|
'termsAccepted' => true,
|
|
]));
|
|
|
|
$response->assertOk();
|
|
|
|
$generatedName = $response->json('user.name');
|
|
|
|
expect($generatedName)
|
|
->not->toBe('camille.martin')
|
|
->toStartWith('camille.martin-');
|
|
});
|
|
|
|
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');
|
|
}
|
|
});
|