feat: google login
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Services\AppleOAuthTokenClient;
|
||||
use App\Services\SocialIdentityResolver;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
|
||||
class AuthenticateSocialUser
|
||||
{
|
||||
public function __construct(
|
||||
private SocialIdentityResolver $identityResolver,
|
||||
private AppleOAuthTokenClient $appleOAuthTokenClient,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\User;
|
||||
use App\Services\AppleOAuthTokenClient;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
|
||||
class RevokeSocialAuthorizations
|
||||
{
|
||||
public function __construct(private AppleOAuthTokenClient $appleOAuthTokenClient) {}
|
||||
|
||||
public function execute(User $user): void
|
||||
{
|
||||
$appleAccount = $user->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SocialProvider: string
|
||||
{
|
||||
case Google = 'google';
|
||||
case Apple = 'apple';
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\RevokeSocialAuthorizations;
|
||||
use App\Http\Requests\EmailVerificationNotificationRequest;
|
||||
use App\Http\Requests\ForgotPasswordRequest;
|
||||
use App\Http\Requests\LoginRequest;
|
||||
@@ -295,8 +296,11 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, RevenueCatService $revenueCat): JsonResponse
|
||||
{
|
||||
public function destroy(
|
||||
Request $request,
|
||||
RevenueCatService $revenueCat,
|
||||
RevokeSocialAuthorizations $revokeSocialAuthorizations,
|
||||
): JsonResponse {
|
||||
$user = $request->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);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\AuthenticateSocialUser;
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Http\Requests\MobileSocialLoginRequest;
|
||||
use App\Http\Requests\MobileSocialNonceRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\SocialAuthenticationNonce;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MobileSocialAuthController extends Controller
|
||||
{
|
||||
public function nonce(
|
||||
MobileSocialNonceRequest $request,
|
||||
SocialAuthenticationNonce $socialAuthenticationNonce,
|
||||
): JsonResponse {
|
||||
$provider = SocialProvider::from($request->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class MobileSocialLoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class MobileSocialNonceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => ['required', Rule::enum(SocialProvider::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Database\Factories\SocialAccountFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SocialAccount extends Model
|
||||
{
|
||||
/** @use HasFactory<SocialAccountFactory> */
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'provider',
|
||||
'provider_refresh_token',
|
||||
'provider_user_id',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'provider_refresh_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'provider' => SocialProvider::class,
|
||||
'provider_refresh_token' => 'encrypted',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -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<UserFactory> */
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class AppleIdentityTokenVerifier
|
||||
{
|
||||
private const JWKS_CACHE_KEY = 'social-auth:apple:jwks';
|
||||
|
||||
public function __construct(private SocialAuthenticationNonce $socialAuthenticationNonce) {}
|
||||
|
||||
public function userFromToken(string $identityToken, string $nonce): SocialiteUser
|
||||
{
|
||||
$claims = $this->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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class AppleOAuthTokenClient
|
||||
{
|
||||
public function __construct(private AppleIdentityTokenVerifier $identityTokenVerifier) {}
|
||||
|
||||
public function exchangeAuthorizationCode(string $authorizationCode, string $expectedSubject): string
|
||||
{
|
||||
try {
|
||||
$response = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SocialAuthenticationNonce
|
||||
{
|
||||
public function issue(SocialProvider $provider): string
|
||||
{
|
||||
$nonce = bin2hex(random_bytes(32));
|
||||
|
||||
Cache::put($this->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Support\Arr;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
class SocialIdentityResolver
|
||||
{
|
||||
public function __construct(
|
||||
private SocialiteFactory $socialite,
|
||||
private AppleIdentityTokenVerifier $appleIdentityTokenVerifier,
|
||||
private SocialAuthenticationNonce $socialAuthenticationNonce,
|
||||
) {}
|
||||
|
||||
public function resolve(SocialProvider $provider, string $identityToken, ?string $nonce): SocialiteUser
|
||||
{
|
||||
try {
|
||||
return match ($provider) {
|
||||
SocialProvider::Google => $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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+375
-1
@@ -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",
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<SocialAccount>
|
||||
*/
|
||||
class SocialAccountFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'provider' => fake()->randomElement(SocialProvider::cases()),
|
||||
'provider_user_id' => fake()->uuid(),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function configureDeletionAppleOAuthClient(): void
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if ($key === false || ! openssl_pkey_export($key, $privateKey)) {
|
||||
throw new RuntimeException('Unable to generate an Apple OAuth test key.');
|
||||
}
|
||||
|
||||
config()->set([
|
||||
'services.apple.client_id' => 'com.meal.daily',
|
||||
'services.apple.issuer' => 'https://appleid.apple.com',
|
||||
'services.apple.key_id' => 'APPLEKEY1',
|
||||
'services.apple.private_key' => $privateKey,
|
||||
'services.apple.private_key_base64' => null,
|
||||
'services.apple.revoke_url' => 'https://appleid.apple.com/auth/revoke',
|
||||
'services.apple.team_id' => 'TEAMID1234',
|
||||
]);
|
||||
}
|
||||
|
||||
it('deletes the authenticated account and related data', function () {
|
||||
config()->set('filesystems.default', 's3');
|
||||
|
||||
@@ -81,3 +107,79 @@ it('requires authentication to delete an account', function () {
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
it('revokes the Apple refresh token before deleting the account', function () {
|
||||
configureDeletionAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/revoke' => Http::response(status: 200),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => 'apple-refresh-token',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 'ACCOUNT_DELETED');
|
||||
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
$data = $request->data();
|
||||
|
||||
return $request->url() === 'https://appleid.apple.com/auth/revoke'
|
||||
&& $request->isForm()
|
||||
&& ($data['client_id'] ?? null) === 'com.meal.daily'
|
||||
&& ($data['token'] ?? null) === 'apple-refresh-token'
|
||||
&& ($data['token_type_hint'] ?? null) === 'refresh_token'
|
||||
&& is_string($data['client_secret'] ?? null);
|
||||
});
|
||||
$this->assertModelMissing($user);
|
||||
$this->assertModelMissing($socialAccount);
|
||||
});
|
||||
|
||||
it('keeps the account when Apple token revocation fails', function () {
|
||||
configureDeletionAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/revoke' => Http::response([
|
||||
'error' => 'invalid_client',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => 'apple-refresh-token',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertServiceUnavailable()
|
||||
->assertJsonPath('code', 'SOCIAL_REVOCATION_FAILED');
|
||||
|
||||
$this->assertModelExists($user);
|
||||
$this->assertModelExists($socialAccount);
|
||||
expect($socialAccount->fresh()->provider_refresh_token)->toBe('apple-refresh-token');
|
||||
});
|
||||
|
||||
it('keeps an Apple account that has no refresh token', function () {
|
||||
$user = User::factory()->create();
|
||||
$socialAccount = SocialAccount::factory()->for($user)->create([
|
||||
'provider' => SocialProvider::Apple,
|
||||
'provider_refresh_token' => null,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$this->deleteJson('/api/auth/me')
|
||||
->assertConflict()
|
||||
->assertJsonPath('code', 'SOCIAL_REVOCATION_TOKEN_MISSING');
|
||||
|
||||
$this->assertModelExists($user);
|
||||
$this->assertModelExists($socialAccount);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\SocialProvider;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Services\AppleIdentityTokenVerifier;
|
||||
use App\Services\AppleOAuthTokenClient;
|
||||
use App\Services\SocialAuthenticationNonce;
|
||||
use App\Services\SocialIdentityResolver;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request as ClientRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Mockery\MockInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('ip:127.0.0.1');
|
||||
});
|
||||
|
||||
function bindResolvedSocialIdentity(array $attributes, int $times = 1): void
|
||||
{
|
||||
$identity = SocialiteUser::fake(array_merge([
|
||||
'email_verified' => true,
|
||||
], $attributes));
|
||||
$resolver = Mockery::mock(SocialIdentityResolver::class);
|
||||
$resolver->shouldReceive('resolve')->times($times)->andReturn($identity);
|
||||
|
||||
app()->instance(SocialIdentityResolver::class, $resolver);
|
||||
}
|
||||
|
||||
function socialLoginPayload(array $overrides = []): array
|
||||
{
|
||||
return array_merge([
|
||||
'deviceName' => 'Bowli iOS',
|
||||
'idToken' => 'provider-id-token',
|
||||
'intent' => 'register',
|
||||
'locale' => 'fr-FR',
|
||||
'nonce' => str_repeat('a', 64),
|
||||
'provider' => SocialProvider::Google->value,
|
||||
'termsAccepted' => true,
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
function bindAppleTokenExchange(string $subject, array $refreshTokens): void
|
||||
{
|
||||
$client = Mockery::mock(AppleOAuthTokenClient::class);
|
||||
$client->shouldReceive('exchangeAuthorizationCode')
|
||||
->times(count($refreshTokens))
|
||||
->withArgs(fn (string $authorizationCode, string $expectedSubject): bool => $authorizationCode !== ''
|
||||
&& $expectedSubject === $subject)
|
||||
->andReturn(...$refreshTokens);
|
||||
|
||||
app()->instance(AppleOAuthTokenClient::class, $client);
|
||||
}
|
||||
|
||||
function configureAppleOAuthClient(): string
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'curve_name' => 'prime256v1',
|
||||
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
||||
]);
|
||||
|
||||
if ($key === false || ! openssl_pkey_export($key, $privateKey)) {
|
||||
throw new RuntimeException('Unable to generate an Apple OAuth test key.');
|
||||
}
|
||||
|
||||
config()->set([
|
||||
'services.apple.client_id' => 'com.meal.daily',
|
||||
'services.apple.issuer' => 'https://appleid.apple.com',
|
||||
'services.apple.key_id' => 'APPLEKEY1',
|
||||
'services.apple.private_key' => $privateKey,
|
||||
'services.apple.private_key_base64' => null,
|
||||
'services.apple.team_id' => 'TEAMID1234',
|
||||
]);
|
||||
|
||||
return $privateKey;
|
||||
}
|
||||
|
||||
it('creates a verified mobile account from a Google identity', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-user-123',
|
||||
'name' => 'Camille Martin',
|
||||
'email' => 'Camille@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertOk()
|
||||
->assertJsonPath('user.email', 'camille@example.com')
|
||||
->assertJsonPath('user.emailVerified', true)
|
||||
->assertJsonPath('user.locale', 'fr')
|
||||
->assertJsonPath('user.name', 'camille.martin')
|
||||
->assertJsonStructure(['token']);
|
||||
|
||||
$user = User::where('email', 'camille@example.com')->firstOrFail();
|
||||
|
||||
expect($user->password)->toBeNull()
|
||||
->and($user->terms_accepted_at)->not->toBeNull();
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'provider' => SocialProvider::Google->value,
|
||||
'provider_user_id' => 'google-user-123',
|
||||
'user_id' => $user->getKey(),
|
||||
]);
|
||||
$this->assertDatabaseHas('personal_access_tokens', [
|
||||
'name' => 'Bowli iOS',
|
||||
'tokenable_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'not-a-social-password',
|
||||
])
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('reuses the same account on subsequent social logins', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-returning-user',
|
||||
'name' => 'Returning User',
|
||||
'email' => 'returning@example.com',
|
||||
], 2);
|
||||
|
||||
$firstUserId = $this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertOk()
|
||||
->json('user.id');
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'locale' => 'en-US',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $firstUserId)
|
||||
->assertJsonPath('user.locale', 'en');
|
||||
|
||||
expect(User::count())->toBe(1)
|
||||
->and(SocialAccount::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('links a verified social identity to an existing email account', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
$password = $user->password;
|
||||
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'google-existing-user',
|
||||
'name' => 'Different Provider Name',
|
||||
'email' => 'existing@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertOk()
|
||||
->assertJsonPath('user.id', $user->getKey());
|
||||
|
||||
expect($user->fresh()->password)->toBe($password)
|
||||
->and($user->socialAccounts()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the Apple name received only during the first authorization', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'apple-user-123',
|
||||
'name' => null,
|
||||
'email' => 'relay@privaterelay.appleid.com',
|
||||
], 2);
|
||||
bindAppleTokenExchange('apple-user-123', [
|
||||
'apple-refresh-token-1',
|
||||
'apple-refresh-token-2',
|
||||
]);
|
||||
|
||||
$payload = socialLoginPayload([
|
||||
'authorizationCode' => 'authorization-code-1',
|
||||
'name' => 'Alex Dupont',
|
||||
'nonce' => str_repeat('a', 64),
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('user.name', 'alex.dupont');
|
||||
|
||||
unset($payload['name']);
|
||||
$payload['authorizationCode'] = 'authorization-code-2';
|
||||
$payload['intent'] = 'login';
|
||||
$payload['termsAccepted'] = false;
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', $payload)
|
||||
->assertOk()
|
||||
->assertJsonPath('user.name', 'alex.dupont');
|
||||
|
||||
$socialAccount = SocialAccount::query()->firstOrFail();
|
||||
|
||||
expect($socialAccount->provider_refresh_token)->toBe('apple-refresh-token-2')
|
||||
->and($socialAccount->getRawOriginal('provider_refresh_token'))->not->toBe('apple-refresh-token-2')
|
||||
->and($socialAccount->toArray())->not->toHaveKey('provider_refresh_token');
|
||||
});
|
||||
|
||||
it('requires an authorization code for Apple authentication', function () {
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('authorizationCode');
|
||||
});
|
||||
|
||||
it('does not create an unknown account from the login intent', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'unknown-google-user',
|
||||
'email' => 'unknown@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'intent' => 'login',
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('code', 'SOCIAL_ACCOUNT_NOT_FOUND');
|
||||
|
||||
expect(User::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('requires terms acceptance before creating a social account', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'terms-google-user',
|
||||
'email' => 'terms@example.com',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'termsAccepted' => false,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonPath('code', 'TERMS_ACCEPTANCE_REQUIRED');
|
||||
|
||||
expect(User::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects an identity whose provider email is not verified', function () {
|
||||
bindResolvedSocialIdentity([
|
||||
'id' => 'unverified-google-user',
|
||||
'email' => 'unverified@example.com',
|
||||
'email_verified' => false,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'SOCIAL_EMAIL_NOT_VERIFIED');
|
||||
});
|
||||
|
||||
it('returns a safe error for an invalid provider token', function () {
|
||||
$this->mock(
|
||||
SocialIdentityResolver::class,
|
||||
function (MockInterface $mock): void {
|
||||
$mock->shouldReceive('resolve')
|
||||
->once()
|
||||
->andThrow(new HttpException(401, 'SOCIAL_TOKEN_INVALID'));
|
||||
},
|
||||
);
|
||||
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload())
|
||||
->assertUnauthorized()
|
||||
->assertJsonPath('code', 'SOCIAL_TOKEN_INVALID');
|
||||
});
|
||||
|
||||
it('validates nonce input before resolving the identity', function () {
|
||||
$this->postJson('/api/auth/mobile/social', socialLoginPayload([
|
||||
'nonce' => null,
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('nonce');
|
||||
});
|
||||
|
||||
it('issues provider-specific nonces', function () {
|
||||
Cache::flush();
|
||||
|
||||
$googleNonce = $this->postJson('/api/auth/mobile/social/nonce', [
|
||||
'provider' => SocialProvider::Google->value,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['nonce'])
|
||||
->json('nonce');
|
||||
$appleNonce = $this->postJson('/api/auth/mobile/social/nonce', [
|
||||
'provider' => SocialProvider::Apple->value,
|
||||
])
|
||||
->assertOk()
|
||||
->json('nonce');
|
||||
|
||||
expect($googleNonce)->toBeString()->toHaveLength(64)
|
||||
->and($appleNonce)->toBeString()->toHaveLength(64)
|
||||
->and($googleNonce)->not->toBe($appleNonce);
|
||||
});
|
||||
|
||||
it('uses a nonce only with the provider it was issued for', function () {
|
||||
Cache::flush();
|
||||
$nonceService = app(SocialAuthenticationNonce::class);
|
||||
$nonce = $nonceService->issue(SocialProvider::Google);
|
||||
|
||||
expect($nonceService->consume(SocialProvider::Apple, $nonce, $nonce))->toBeFalse()
|
||||
->and($nonceService->consume(SocialProvider::Google, $nonce, $nonce))->toBeTrue()
|
||||
->and($nonceService->consume(SocialProvider::Google, $nonce, $nonce))->toBeFalse();
|
||||
});
|
||||
|
||||
it('requires the issued nonce in the Google identity token', function () {
|
||||
Cache::flush();
|
||||
config()->set('services.google.client_id', 'google-web-client-id');
|
||||
|
||||
$nonceService = app(SocialAuthenticationNonce::class);
|
||||
$nonce = $nonceService->issue(SocialProvider::Google);
|
||||
$identity = SocialiteUser::fake([
|
||||
'email' => 'google@example.com',
|
||||
'email_verified' => true,
|
||||
'nonce' => $nonce,
|
||||
]);
|
||||
$provider = Mockery::mock(AbstractProvider::class);
|
||||
$provider->shouldReceive('userFromToken')->twice()->andReturn($identity);
|
||||
$socialite = Mockery::mock(SocialiteFactory::class);
|
||||
$socialite->shouldReceive('driver')->with('google')->twice()->andReturn($provider);
|
||||
$resolver = new SocialIdentityResolver(
|
||||
$socialite,
|
||||
app(AppleIdentityTokenVerifier::class),
|
||||
$nonceService,
|
||||
);
|
||||
|
||||
expect($resolver->resolve(SocialProvider::Google, 'google-id-token', $nonce))
|
||||
->toBe($identity);
|
||||
|
||||
try {
|
||||
$resolver->resolve(SocialProvider::Google, 'google-id-token', $nonce);
|
||||
$this->fail('The Google nonce should only be accepted once.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
});
|
||||
|
||||
it('issues a single-use nonce and verifies Apple JWT claims', function () {
|
||||
Cache::flush();
|
||||
config()->set('services.apple.client_ids', 'com.meal.daily');
|
||||
|
||||
$key = openssl_pkey_new([
|
||||
'digest_alg' => 'sha256',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
]);
|
||||
openssl_pkey_export($key, $privateKey);
|
||||
$details = openssl_pkey_get_details($key);
|
||||
$jwk = [
|
||||
'alg' => 'RS256',
|
||||
'e' => rtrim(strtr(base64_encode($details['rsa']['e']), '+/', '-_'), '='),
|
||||
'kid' => 'apple-test-key',
|
||||
'kty' => 'RSA',
|
||||
'n' => rtrim(strtr(base64_encode($details['rsa']['n']), '+/', '-_'), '='),
|
||||
'use' => 'sig',
|
||||
];
|
||||
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/keys' => Http::response(['keys' => [$jwk]]),
|
||||
]);
|
||||
|
||||
$verifier = app(AppleIdentityTokenVerifier::class);
|
||||
$nonce = app(SocialAuthenticationNonce::class)->issue(SocialProvider::Apple);
|
||||
$token = JWT::encode([
|
||||
'aud' => 'com.meal.daily',
|
||||
'email' => 'apple@example.com',
|
||||
'email_verified' => 'true',
|
||||
'exp' => now()->addMinutes(5)->timestamp,
|
||||
'iat' => now()->timestamp,
|
||||
'iss' => 'https://appleid.apple.com',
|
||||
'nonce' => $nonce,
|
||||
'sub' => 'apple-subject',
|
||||
], $privateKey, 'RS256', 'apple-test-key');
|
||||
|
||||
$identity = $verifier->userFromToken($token, $nonce);
|
||||
|
||||
expect($identity->getId())->toBe('apple-subject')
|
||||
->and($identity->getEmail())->toBe('apple@example.com');
|
||||
|
||||
try {
|
||||
$verifier->userFromToken($token, $nonce);
|
||||
$this->fail('The Apple nonce should only be accepted once.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_TOKEN_INVALID');
|
||||
}
|
||||
});
|
||||
|
||||
it('exchanges the Apple authorization code for a verified refresh token', function () {
|
||||
configureAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/token' => Http::response([
|
||||
'access_token' => 'apple-access-token',
|
||||
'expires_in' => 3600,
|
||||
'id_token' => 'returned-identity-token',
|
||||
'refresh_token' => 'apple-refresh-token',
|
||||
'token_type' => 'Bearer',
|
||||
]),
|
||||
]);
|
||||
|
||||
$identityTokenVerifier = Mockery::mock(AppleIdentityTokenVerifier::class);
|
||||
$identityTokenVerifier->shouldReceive('subjectFromToken')
|
||||
->once()
|
||||
->with('returned-identity-token')
|
||||
->andReturn('apple-user-123');
|
||||
$client = new AppleOAuthTokenClient($identityTokenVerifier);
|
||||
|
||||
expect($client->exchangeAuthorizationCode('authorization-code', 'apple-user-123'))
|
||||
->toBe('apple-refresh-token');
|
||||
|
||||
Http::assertSent(function (ClientRequest $request): bool {
|
||||
$data = $request->data();
|
||||
$clientSecret = $data['client_secret'] ?? null;
|
||||
|
||||
if (! is_string($clientSecret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$segments = explode('.', $clientSecret);
|
||||
|
||||
if (count($segments) !== 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = (array) JWT::jsonDecode(JWT::urlsafeB64Decode($segments[0]));
|
||||
$claims = (array) JWT::jsonDecode(JWT::urlsafeB64Decode($segments[1]));
|
||||
|
||||
return $request->url() === 'https://appleid.apple.com/auth/token'
|
||||
&& $request->isForm()
|
||||
&& ($data['client_id'] ?? null) === 'com.meal.daily'
|
||||
&& ($data['code'] ?? null) === 'authorization-code'
|
||||
&& ($data['grant_type'] ?? null) === 'authorization_code'
|
||||
&& ($header['alg'] ?? null) === 'ES256'
|
||||
&& ($header['kid'] ?? null) === 'APPLEKEY1'
|
||||
&& ($claims['aud'] ?? null) === 'https://appleid.apple.com'
|
||||
&& ($claims['iss'] ?? null) === 'TEAMID1234'
|
||||
&& ($claims['sub'] ?? null) === 'com.meal.daily';
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid Apple authorization code', function () {
|
||||
configureAppleOAuthClient();
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://appleid.apple.com/auth/token' => Http::response([
|
||||
'error' => 'invalid_grant',
|
||||
], 400),
|
||||
]);
|
||||
|
||||
$identityTokenVerifier = Mockery::mock(AppleIdentityTokenVerifier::class);
|
||||
$client = new AppleOAuthTokenClient($identityTokenVerifier);
|
||||
|
||||
try {
|
||||
$client->exchangeAuthorizationCode('expired-code', 'apple-user-123');
|
||||
$this->fail('The expired Apple authorization code should be rejected.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(401)
|
||||
->and($exception->getMessage())->toBe('SOCIAL_AUTHORIZATION_CODE_INVALID');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user