Files
daily-meal-api/tests/Feature/MobileSocialAuthenticationTest.php
leonm 2d794ff0c9
CI / 🧪 Tests Laravel (push) Successful in 3m9s
CI / 🐳 Build & Push Images (push) Successful in 47s
feat: auth with google
2026-08-17 15:13:21 +02:00

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');
}
});