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