diff --git a/.env.example b/.env.example index 6e81385..9410991 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,20 @@ RESEND_API_KEY= EXPO_ACCESS_TOKEN= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= + +APPLE_CLIENT_ID=com.meal.daily +# Comma-separated when several Apple bundle or service IDs are accepted. +APPLE_CLIENT_IDS= +APPLE_TEAM_ID= +APPLE_KEY_ID= +# Base64-encoded contents of the Sign in with Apple .p8 private key. +APPLE_PRIVATE_KEY_BASE64= +# Alternative to APPLE_PRIVATE_KEY_BASE64: raw PEM with escaped \n characters. +APPLE_PRIVATE_KEY= + STRAVA_CLIENT_ID= STRAVA_CLIENT_SECRET= diff --git a/app/Actions/AuthenticateSocialUser.php b/app/Actions/AuthenticateSocialUser.php new file mode 100644 index 0000000..5d2d0b1 --- /dev/null +++ b/app/Actions/AuthenticateSocialUser.php @@ -0,0 +1,175 @@ + $data + */ + public function execute(array $data): User + { + $provider = SocialProvider::from((string) $data['provider']); + $identity = $this->identityResolver->resolve( + $provider, + (string) $data['idToken'], + isset($data['nonce']) ? (string) $data['nonce'] : null, + ); + + $this->ensureUsableIdentity($identity); + $email = strtolower(trim((string) $identity->getEmail())); + $providerRefreshToken = $provider === SocialProvider::Apple + ? $this->appleOAuthTokenClient->exchangeAuthorizationCode( + (string) ($data['authorizationCode'] ?? ''), + (string) $identity->getId(), + ) + : null; + + try { + return Cache::lock('social-auth:user:'.hash('sha256', $email), 10) + ->block(5, fn (): User => DB::transaction(function () use ($data, $email, $provider, $providerRefreshToken, $identity): User { + $providerUserId = (string) $identity->getId(); + $socialAccount = SocialAccount::query() + ->where('provider', $provider) + ->where('provider_user_id', $providerUserId) + ->lockForUpdate() + ->first(); + + if ($socialAccount) { + $user = $socialAccount->user; + + abort_if(! $user, 409, 'SOCIAL_ACCOUNT_CONFLICT'); + + if ($providerRefreshToken !== null) { + $socialAccount->update([ + 'provider_refresh_token' => $providerRefreshToken, + ]); + } + + return $this->prepareUserForLogin($user, $data); + } + + $user = User::query() + ->where('email', $email) + ->lockForUpdate() + ->first(); + + if (! $user) { + abort_if($data['intent'] !== 'register', 422, 'SOCIAL_ACCOUNT_NOT_FOUND'); + abort_if(($data['termsAccepted'] ?? false) !== true, 422, 'TERMS_ACCEPTANCE_REQUIRED'); + + $user = User::create([ + 'email' => $email, + 'email_verified_at' => now(), + 'locale' => $data['locale'] ?? 'en', + 'name' => $this->uniqueUsername($this->preferredName($provider, $identity, $data), $email), + 'password' => null, + 'terms_accepted_at' => now(), + ]); + } else { + abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED'); + + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + } + + $user->socialAccounts()->create([ + 'provider' => $provider, + 'provider_refresh_token' => $providerRefreshToken, + 'provider_user_id' => $providerUserId, + ]); + + return $this->prepareUserForLogin($user, $data); + }, 3)); + } catch (LockTimeoutException) { + abort(409, 'SOCIAL_AUTH_IN_PROGRESS'); + } + } + + private function ensureUsableIdentity(SocialiteUser $identity): void + { + $providerUserId = $identity->getId(); + $email = $identity->getEmail(); + $rawIdentity = $identity->getRaw(); + $emailVerified = filter_var( + $rawIdentity['email_verified'] ?? $rawIdentity['verified_email'] ?? false, + FILTER_VALIDATE_BOOL, + ); + + abort_if(! is_string($providerUserId) || trim($providerUserId) === '', 401, 'SOCIAL_TOKEN_INVALID'); + abort_if(! is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false, 401, 'SOCIAL_TOKEN_INVALID'); + abort_if($emailVerified !== true, 401, 'SOCIAL_EMAIL_NOT_VERIFIED'); + } + + /** + * @param array $data + */ + private function prepareUserForLogin(User $user, array $data): User + { + abort_if($user->isSuspended(), 403, 'ACCOUNT_SUSPENDED'); + + if (isset($data['locale']) && $user->locale !== $data['locale']) { + $user->forceFill(['locale' => $data['locale']])->save(); + } + + return $user->fresh(); + } + + /** + * @param array $data + */ + private function preferredName( + SocialProvider $provider, + SocialiteUser $identity, + array $data, + ): ?string { + if ($provider === SocialProvider::Apple && isset($data['name']) && is_string($data['name'])) { + return $data['name']; + } + + return $identity->getName(); + } + + private function uniqueUsername(?string $preferredName, string $email): string + { + $source = filled($preferredName) ? $preferredName : Str::before($email, '@'); + $base = Str::of((string) $source) + ->ascii() + ->lower() + ->replaceMatches('/[^a-z0-9_.-]+/', '.') + ->trim('.-_') + ->limit(24, '') + ->toString(); + + if (mb_strlen($base) < 3) { + $base = 'user'; + } + + if (! User::withTrashed()->where('name', $base)->exists()) { + return $base; + } + + do { + $candidate = $base.'-'.Str::lower(Str::random(6)); + } while (User::withTrashed()->where('name', $candidate)->exists()); + + return $candidate; + } +} diff --git a/app/Actions/RevokeSocialAuthorizations.php b/app/Actions/RevokeSocialAuthorizations.php new file mode 100644 index 0000000..0a47629 --- /dev/null +++ b/app/Actions/RevokeSocialAuthorizations.php @@ -0,0 +1,36 @@ +socialAccounts() + ->where('provider', SocialProvider::Apple) + ->first(); + + if (! $appleAccount) { + return; + } + + try { + $refreshToken = $appleAccount->provider_refresh_token; + } catch (DecryptException) { + abort(503, 'SOCIAL_REVOCATION_FAILED'); + } + + if (! is_string($refreshToken) || $refreshToken === '') { + abort(409, 'SOCIAL_REVOCATION_TOKEN_MISSING'); + } + + $this->appleOAuthTokenClient->revoke($refreshToken); + } +} diff --git a/app/Enums/SocialProvider.php b/app/Enums/SocialProvider.php new file mode 100644 index 0000000..70790f0 --- /dev/null +++ b/app/Enums/SocialProvider.php @@ -0,0 +1,9 @@ +user(); $identityVerificationRequest = $user->identityVerificationRequest()->first(); $storagePaths = collect([$user->avatar_url]) @@ -305,6 +309,8 @@ class AuthController extends Controller ->values() ->all(); + $revokeSocialAuthorizations->execute($user); + try { $revenueCat->deleteCustomer($user); } catch (Throwable $exception) { @@ -388,7 +394,7 @@ class AuthController extends Controller { $user = User::where('email', strtolower((string) $data['email']))->first(); - if (! $user || ! Hash::check((string) $data['password'], $user->password)) { + if (! $user || ! is_string($user->password) || ! Hash::check((string) $data['password'], $user->password)) { return response()->json([ 'code' => 'INVALID_CREDENTIALS', ], 401); diff --git a/app/Http/Controllers/MobileSocialAuthController.php b/app/Http/Controllers/MobileSocialAuthController.php new file mode 100644 index 0000000..bdb0032 --- /dev/null +++ b/app/Http/Controllers/MobileSocialAuthController.php @@ -0,0 +1,42 @@ +validated('provider')); + + return response()->json([ + 'nonce' => $socialAuthenticationNonce->issue($provider), + ]); + } + + public function store( + MobileSocialLoginRequest $request, + AuthenticateSocialUser $authenticateSocialUser, + ): JsonResponse { + $data = $request->validated(); + $user = $authenticateSocialUser->execute($data); + $deviceName = $data['device_name'] ?? null; + $tokenName = is_string($deviceName) && trim($deviceName) !== '' + ? trim($deviceName) + : 'mobile'; + + return response()->json([ + 'token' => $user->createToken($tokenName)->plainTextToken, + 'user' => new UserResource($user), + ]); + } +} diff --git a/app/Http/Requests/MobileSocialLoginRequest.php b/app/Http/Requests/MobileSocialLoginRequest.php new file mode 100644 index 0000000..e4b2f2c --- /dev/null +++ b/app/Http/Requests/MobileSocialLoginRequest.php @@ -0,0 +1,62 @@ +|string> + */ + public function rules(): array + { + return [ + 'authorizationCode' => [ + 'nullable', + Rule::requiredIf($this->input('provider') === SocialProvider::Apple->value), + 'string', + 'max:4096', + ], + 'deviceName' => ['sometimes', 'string', 'max:255'], + 'device_name' => ['sometimes', 'string', 'max:255'], + 'idToken' => ['required', 'string', 'max:10000'], + 'intent' => ['required', Rule::in(['login', 'register'])], + 'locale' => ['sometimes', 'string', Rule::in(config('app.supported_locales', ['fr', 'en']))], + 'name' => ['sometimes', 'nullable', 'string', 'max:255'], + 'nonce' => ['required', 'string', 'size:64', 'regex:/\A[a-f0-9]{64}\z/'], + 'provider' => ['required', Rule::enum(SocialProvider::class)], + 'termsAccepted' => ['required', 'boolean'], + ]; + } + + protected function prepareForValidation(): void + { + if ($this->has('locale')) { + $locale = strtolower(str_replace('_', '-', (string) $this->input('locale'))); + + $this->merge([ + 'locale' => explode('-', $locale)[0], + ]); + } + + if ($this->has('deviceName') && ! $this->has('device_name')) { + $this->merge([ + 'device_name' => $this->input('deviceName'), + ]); + } + } +} diff --git a/app/Http/Requests/MobileSocialNonceRequest.php b/app/Http/Requests/MobileSocialNonceRequest.php new file mode 100644 index 0000000..3c3dc7f --- /dev/null +++ b/app/Http/Requests/MobileSocialNonceRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'provider' => ['required', Rule::enum(SocialProvider::class)], + ]; + } +} diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php new file mode 100644 index 0000000..952e2fd --- /dev/null +++ b/app/Models/SocialAccount.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + /** + * @var list + */ + protected $fillable = [ + 'provider', + 'provider_refresh_token', + 'provider_user_id', + 'user_id', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'provider_refresh_token', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'provider' => SocialProvider::class, + 'provider_refresh_token' => 'encrypted', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f35d9a4..3b18772 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use App\Enums\PhysicalActivityLevel; use App\Enums\UserRole; use App\Enums\UserSex; use App\Enums\WeightGoal; +use Database\Factories\UserFactory; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasAvatar; use Filament\Panel; @@ -29,7 +30,7 @@ use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocalePreference, MustVerifyEmail { - /** @use HasFactory<\Database\Factories\UserFactory> */ + /** @use HasFactory */ use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; protected $attributes = [ @@ -125,6 +126,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(DeviceToken::class); } + public function socialAccounts(): HasMany + { + return $this->hasMany(SocialAccount::class); + } + public function mealPosts(): HasMany { return $this->hasMany(MealPosts::class); diff --git a/app/Services/AppleIdentityTokenVerifier.php b/app/Services/AppleIdentityTokenVerifier.php new file mode 100644 index 0000000..fb99429 --- /dev/null +++ b/app/Services/AppleIdentityTokenVerifier.php @@ -0,0 +1,192 @@ +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 + */ + 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 + */ + 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 + */ + 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 $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 $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 $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'); + } + } +} diff --git a/app/Services/AppleOAuthTokenClient.php b/app/Services/AppleOAuthTokenClient.php new file mode 100644 index 0000000..aa48e0f --- /dev/null +++ b/app/Services/AppleOAuthTokenClient.php @@ -0,0 +1,154 @@ +request() + ->post($this->configuredUrl('token_url'), [ + 'client_id' => $this->clientId(), + 'client_secret' => $this->clientSecret(), + 'code' => $authorizationCode, + 'grant_type' => 'authorization_code', + ]); + } catch (HttpExceptionInterface $exception) { + throw $exception; + } catch (ConnectionException) { + abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE'); + } + + if (! $response->successful()) { + $error = $response->json('error'); + + if ($error === 'invalid_grant') { + abort(401, 'SOCIAL_AUTHORIZATION_CODE_INVALID'); + } + + abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE'); + } + + $refreshToken = $response->json('refresh_token'); + $identityToken = $response->json('id_token'); + + if (! is_string($refreshToken) || $refreshToken === '' || ! is_string($identityToken)) { + abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE'); + } + + $returnedSubject = $this->identityTokenVerifier->subjectFromToken($identityToken); + + if (! hash_equals($expectedSubject, $returnedSubject)) { + abort(401, 'SOCIAL_TOKEN_INVALID'); + } + + return $refreshToken; + } + + public function revoke(string $refreshToken): void + { + try { + $response = $this->request() + ->retry( + [100, 500], + when: fn (Throwable $exception): bool => $exception instanceof ConnectionException + || ($exception instanceof RequestException && $exception->response->serverError()), + throw: false, + ) + ->post($this->configuredUrl('revoke_url'), [ + 'client_id' => $this->clientId(), + 'client_secret' => $this->clientSecret(), + 'token' => $refreshToken, + 'token_type_hint' => 'refresh_token', + ]); + } catch (HttpExceptionInterface $exception) { + throw $exception; + } catch (Throwable) { + abort(503, 'SOCIAL_REVOCATION_FAILED'); + } + + if (! $response->successful()) { + abort(503, 'SOCIAL_REVOCATION_FAILED'); + } + } + + private function clientSecret(): string + { + $teamId = (string) config('services.apple.team_id'); + $keyId = (string) config('services.apple.key_id'); + $privateKey = $this->privateKey(); + $issuer = (string) config('services.apple.issuer'); + + if ($teamId === '' || $keyId === '' || $issuer === '' || $privateKey === '') { + abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED'); + } + + try { + $issuedAt = now()->timestamp; + + return JWT::encode([ + 'aud' => $issuer, + 'exp' => $issuedAt + 300, + 'iat' => $issuedAt, + 'iss' => $teamId, + 'sub' => $this->clientId(), + ], $privateKey, 'ES256', $keyId); + } catch (Throwable) { + abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED'); + } + } + + private function clientId(): string + { + $clientId = (string) config('services.apple.client_id'); + + if ($clientId === '') { + abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED'); + } + + return $clientId; + } + + private function configuredUrl(string $key): string + { + $url = (string) config('services.apple.'.$key); + + if ($url === '') { + abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED'); + } + + return $url; + } + + private function privateKey(): string + { + $base64PrivateKey = (string) config('services.apple.private_key_base64'); + + if ($base64PrivateKey !== '') { + $decodedPrivateKey = base64_decode($base64PrivateKey, true); + + return is_string($decodedPrivateKey) ? $decodedPrivateKey : ''; + } + + return str_replace('\\n', "\n", (string) config('services.apple.private_key')); + } + + private function request(): PendingRequest + { + return Http::asForm() + ->acceptJson() + ->connectTimeout(3) + ->timeout(10); + } +} diff --git a/app/Services/SocialAuthenticationNonce.php b/app/Services/SocialAuthenticationNonce.php new file mode 100644 index 0000000..0274b63 --- /dev/null +++ b/app/Services/SocialAuthenticationNonce.php @@ -0,0 +1,32 @@ +cacheKey($provider, $nonce), true, now()->addMinutes(10)); + + return $nonce; + } + + public function consume(SocialProvider $provider, string $nonce, mixed $tokenNonce): bool + { + $nonceWasIssued = Cache::pull($this->cacheKey($provider, $nonce), false); + + return is_string($tokenNonce) + && hash_equals($nonce, $tokenNonce) + && $nonceWasIssued === true; + } + + private function cacheKey(SocialProvider $provider, string $nonce): string + { + return 'social-auth:'.$provider->value.':nonce:'.hash('sha256', $nonce); + } +} diff --git a/app/Services/SocialIdentityResolver.php b/app/Services/SocialIdentityResolver.php new file mode 100644 index 0000000..ce24133 --- /dev/null +++ b/app/Services/SocialIdentityResolver.php @@ -0,0 +1,63 @@ + $this->googleUserFromToken($identityToken, $nonce ?? ''), + SocialProvider::Apple => $this->appleIdentityTokenVerifier->userFromToken( + $identityToken, + $nonce ?? '', + ), + }; + } catch (HttpExceptionInterface $exception) { + throw $exception; + } catch (GuzzleException) { + abort(503, 'SOCIAL_PROVIDER_UNAVAILABLE'); + } catch (Throwable) { + abort(401, 'SOCIAL_TOKEN_INVALID'); + } + } + + private function googleUserFromToken(string $identityToken, string $nonce): SocialiteUser + { + if (! config('services.google.client_id')) { + abort(503, 'SOCIAL_PROVIDER_NOT_CONFIGURED'); + } + + /** @var AbstractProvider $provider */ + $provider = $this->socialite->driver(SocialProvider::Google->value); + + $identity = $provider->userFromToken($identityToken); + $rawIdentity = method_exists($identity, 'getRaw') ? $identity->getRaw() : []; + + if (! is_array($rawIdentity) || ! $this->socialAuthenticationNonce->consume( + SocialProvider::Google, + $nonce, + Arr::get($rawIdentity, 'nonce'), + )) { + abort(401, 'SOCIAL_TOKEN_INVALID'); + } + + return $identity; + } +} diff --git a/composer.json b/composer.json index 1fc21c6..573af86 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "laravel/octane": "^2.17", "laravel/sanctum": "^4.0", "laravel/scout": "^11.0", + "laravel/socialite": "^5.29", "laravel/tinker": "^3.0", "league/flysystem-aws-s3-v3": "^3.0", "meilisearch/meilisearch-php": "^1.16", diff --git a/composer.lock b/composer.lock index 7253828..97ce1d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "48f13f64a5a0cafcb2332edfa282b407", + "content-hash": "e10458fd8e897ff924dd16ffb9ce715c", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1874,6 +1874,72 @@ }, "time": "2026-08-05T20:49:46+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.0", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.0" + }, + "time": "2026-06-11T17:54:14+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -3280,6 +3346,78 @@ }, "time": "2026-07-21T16:49:22+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.29.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/cd343a5841f02292af119ee607edc71300c9ae4f", + "reference": "cd343a5841f02292af119ee607edc71300c9ae4f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^7.2|^8.0", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-07-01T13:50:23+00:00" + }, { "name": "laravel/tinker", "version": "v3.0.2", @@ -3872,6 +4010,82 @@ ], "time": "2026-07-09T11:49:27+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "reference": "f9c94b088837eb1aae1ad7c4f23eb65cc6993055", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.0|^7.0", + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.11.0" + }, + "time": "2024-12-10T19:59:05+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -5166,6 +5380,56 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "php-http/discovery", "version": "1.20.0", @@ -5320,6 +5584,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.56", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305", + "reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.56" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-08-03T04:36:50+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.3", diff --git a/config/services.php b/config/services.php index a74b68c..4fb1e74 100644 --- a/config/services.php +++ b/config/services.php @@ -26,6 +26,25 @@ return [ 'access_token' => env('EXPO_ACCESS_TOKEN'), ], + 'google' => [ + 'client_id' => env('GOOGLE_CLIENT_ID'), + 'client_secret' => env('GOOGLE_CLIENT_SECRET', ''), + 'redirect' => env('GOOGLE_REDIRECT_URI', ''), + ], + + 'apple' => [ + 'client_id' => env('APPLE_CLIENT_ID'), + 'client_ids' => env('APPLE_CLIENT_IDS') ?: env('APPLE_CLIENT_ID'), + 'issuer' => 'https://appleid.apple.com', + 'jwks_url' => 'https://appleid.apple.com/auth/keys', + 'key_id' => env('APPLE_KEY_ID'), + 'private_key' => env('APPLE_PRIVATE_KEY'), + 'private_key_base64' => env('APPLE_PRIVATE_KEY_BASE64'), + 'revoke_url' => 'https://appleid.apple.com/auth/revoke', + 'team_id' => env('APPLE_TEAM_ID'), + 'token_url' => 'https://appleid.apple.com/auth/token', + ], + 'strava' => [ 'client_id' => env('STRAVA_CLIENT_ID'), 'client_secret' => env('STRAVA_CLIENT_SECRET'), diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php new file mode 100644 index 0000000..b944fd4 --- /dev/null +++ b/database/factories/SocialAccountFactory.php @@ -0,0 +1,28 @@ + + */ +class SocialAccountFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'provider' => fake()->randomElement(SocialProvider::cases()), + 'provider_user_id' => fake()->uuid(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 0109556..7ce10bf 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -16,7 +16,7 @@ return new class extends Migration $table->string('name'); $table->string('email')->unique(); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password')->nullable(); $table->string('role')->default('user'); $table->text('bio')->nullable(); $table->unsignedSmallInteger('height')->nullable(); @@ -44,6 +44,18 @@ return new class extends Migration $table->foreign('suspended_by')->references('id')->on('users')->nullOnDelete(); }); + Schema::create('social_accounts', function (Blueprint $table) { + $table->id(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->string('provider', 32); + $table->string('provider_user_id'); + $table->text('provider_refresh_token')->nullable(); + $table->timestamps(); + + $table->unique(['provider', 'provider_user_id']); + $table->unique(['user_id', 'provider']); + }); + Schema::create('password_reset_tokens', function (Blueprint $table) { $table->string('email')->primary(); $table->string('token'); @@ -65,6 +77,7 @@ return new class extends Migration */ public function down(): void { + Schema::dropIfExists('social_accounts'); Schema::dropIfExists('users'); Schema::dropIfExists('password_reset_tokens'); Schema::dropIfExists('sessions'); diff --git a/routes/api.php b/routes/api.php index b6d3bd2..0d597f4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\FollowController; use App\Http\Controllers\LegalDocumentController; use App\Http\Controllers\MealImageAnalysisController; use App\Http\Controllers\MealPostController; +use App\Http\Controllers\MobileSocialAuthController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationPreferenceController; use App\Http\Controllers\NutritionOnboardingController; @@ -30,6 +31,8 @@ Route::prefix('auth')->group(function (): void { Route::post('login', [AuthController::class, 'login'])->middleware('throttle:auth'); Route::post('mobile/register', [AuthController::class, 'mobileRegister'])->middleware('throttle:auth'); Route::post('mobile/login', [AuthController::class, 'mobileLogin'])->middleware('throttle:auth'); + Route::post('mobile/social/nonce', [MobileSocialAuthController::class, 'nonce'])->middleware('throttle:auth'); + Route::post('mobile/social', [MobileSocialAuthController::class, 'store'])->middleware('throttle:auth'); Route::post('forgot-password', [AuthController::class, 'forgotPassword']) ->middleware('throttle:6,1') ->name('password.email'); diff --git a/tests/Feature/AccountDeletionTest.php b/tests/Feature/AccountDeletionTest.php index 2e5c875..5716112 100644 --- a/tests/Feature/AccountDeletionTest.php +++ b/tests/Feature/AccountDeletionTest.php @@ -1,15 +1,41 @@ '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); +}); diff --git a/tests/Feature/MobileSocialAuthenticationTest.php b/tests/Feature/MobileSocialAuthenticationTest.php new file mode 100644 index 0000000..7afa63c --- /dev/null +++ b/tests/Feature/MobileSocialAuthenticationTest.php @@ -0,0 +1,470 @@ + 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'); + } +});