phplaraveldigital-signature

Message: Verification with Apple failed, error: `{error: invalid_client}` in Laravel PHP


I'm trying to verify Apple Sign-In in my Laravel project, but I'm encountering an error. Can someone help me? I've even included screenshots of the key id and service id (with sensitive information modified).

{success: false, message: Failed to verify with Apple, error: {error: invalid_client}}

my .env file

APPLE_CLIENT_ID=com.example.apple
APPLE_TEAM_ID=NR88888888
APPLE_KEY_ID=RJ85222222

Here’s my function (I had to trim some parts because StackOverflow doesn't allow posting the entire function).

function loginWithApple(Request $request) 
{
    try 
    {
        $authCode = $request->input('apple_authorization_code');
        if (empty($authCode)) 
        {
            return response()->json([
                'success' => false,
                'message' => 'Authorization code is required'
            ], 400);
        }

        // Load and validate private key file
        $path = storage_path('appleKeys/applePrivateKey/AuthKey_RJ85222222.p8');
        if (!file_exists($path)) 
        {
            Log::error('Apple Login: Private key file not found', ['path' => $path]);
            throw new \Exception('Apple private key file not found at: ' . $path);
        }

        $privateKeyContent = file_get_contents($path);
        if (!$privateKeyContent) 
        {
            Log::error('Apple Login: Failed to read private key file');
            throw new \Exception('Failed to read private key file');
        }

        // Ensure private key format
        $privateKey = openssl_pkey_get_private($privateKeyContent);
        if (!$privateKey) 
        {
            Log::error('Apple Login: Invalid private key format', [
                'openssl_error' => openssl_error_string()
            ]);
            throw new \Exception('Invalid private key format: ' . openssl_error_string());
        }

        // Generate client secret (JWT)
        $timestamp = time();
        $header = [
            'alg' => 'ES256',
            'kid' => env('APPLE_KEY_ID')
        ];
        
        $payload = [
            'iss' => env('APPLE_TEAM_ID'),
            'iat' => $timestamp,
            'exp' => $timestamp + 86400 * 180, // 180 days
            'aud' => 'https://appleid.apple.com',
            'sub' => env('APPLE_CLIENT_ID'),
        ];

        // Base64Url encode header and payload
        $base64Header = rtrim(strtr(base64_encode(json_encode($header)), '+/', '-_'), '=');
        $base64Payload = rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');

        // Create signature
        $signature = '';
        if (!openssl_sign(
            $base64Header . '.' . $base64Payload,
            $signature,
            $privateKey,
            OPENSSL_ALGO_SHA256
        )) {
            Log::error('Apple Login: Failed to create signature', [
                'openssl_error' => openssl_error_string()
            ]);
            throw new \Exception('Failed to create signature: ' . openssl_error_string());
        }

        $base64Signature = rtrim(strtr(base64_encode($signature), '+/', '-_'), '=');
        $clientSecret = $base64Header . '.' . $base64Payload . '.' . $base64Signature;

        // Send request to Apple for token
        $requestData = [
            'client_id' => env('APPLE_CLIENT_ID'),
            'client_secret' => $clientSecret,
            'code' => $authCode,
            'grant_type' => 'authorization_code'
        ];

        Log::debug('Apple Login: Sending request to Apple', [
            'url' => 'https://appleid.apple.com/auth/token',
            'client_id' => env('APPLE_CLIENT_ID'),
            'code' => $authCode,
            'code_length' => strlen($authCode),
        ]);

        // $response = Http::asForm()->post('https://appleid.apple.com/auth/token', $requestData);
        $response = Http::withHeaders([
            'Content-Type' => 'application/x-www-form-urlencoded'
        ])->post('https://appleid.apple.com/auth/token', $requestData);

        if (!$response->successful()) 
        {
            Log::error('Apple Login: Failed response from Apple', [
                'status' => $response->status(),
                'error' => $response->json(),
            ]);
            return response()->json([
                'success' => false,
                'message' => 'Failed to verify with Apple',
                'error' => $response->json()
            ], 400);
        }

        $data = $response->json();
        $idToken = $data['id_token'];
        $tokenParts = explode('.', $idToken);
        
        if (count($tokenParts) != 3) {
            return response()->json([
                'success' => false,
                'message' => 'Invalid token format'
            ], 400);
        }
    } 
    catch (\Exception $e) 
    {
        Log::error('Apple Sign In Error', [
            'error' => $e->getMessage(),
            'trace' => $e->getTraceAsString()
        ]);       
    }
}

key id service id


Solution

  • This problem is tricky, As indicated here and here, the problem is actually in the signature generated by openSSL. You can read more about there, but in my case I just used the Firebase JWT library.

    So for Apple Sign In, we need to use ES256 (ECDSA with SHA-256) for JWT signing. Here's a simpler and more reliable implementation using the firebase/php-jwt library, which supports ES256:

    make sure to install the Firebase JWT library

    composer require firebase/php-jwt

    then update

        use Firebase\JWT\JWT;
        use Illuminate\Http\Request;
        use Illuminate\Support\Facades\Http;
        use Illuminate\Support\Facades\Log; 
        
           // YOUR PROBLEM CAN BE SOLVED HERE 
    
           // Create client secret JWT
                $clientSecret = JWT::encode([
                    'iss' => env('APPLE_TEAM_ID'),
                    'iat' => time(),
                    'exp' => time() + 86400 * 180,  // 180 days
                    'aud' => 'https://appleid.apple.com',
                    'sub' => env('APPLE_CLIENT_ID'),
                ], $privateKey, 'ES256', env('APPLE_KEY_ID'));
        
                // Exchange auth code for tokens
                $response = Http::asForm()->post('https://appleid.apple.com/auth/token', [
                    'client_id' => env('APPLE_CLIENT_ID'),
                    'client_secret' => $clientSecret,
                    'code' => $authCode,
                    'grant_type' => 'authorization_code'
                ]);
    

    Here is a full refactored function

     use Firebase\JWT\JWT;
     use Illuminate\Http\Request;
     use Illuminate\Support\Facades\Http;
     use Illuminate\Support\Facades\Log;
    
    
    function loginWithApple(Request $request) {
        try {
            // Validate request
            $authCode = $request->input('apple_authorization_code');
            if (empty($authCode)) {
                return response()->json([
                    'success' => false,
                    'message' => 'Authorization code is required'
                ], 400);
            }
    
            // Load private key from Laravel storage location
            $privateKeyPath = storage_path('appleKeys/applePrivateKey/AuthKey_' . env('APPLE_KEY_ID') . '.p8');
            $privateKey = file_get_contents($privateKeyPath);
            if (!$privateKey) {
                throw new Exception('Could not read private key');
            }
    
            // Create client secret JWT
            $clientSecret = JWT::encode([
                'iss' => env('APPLE_TEAM_ID'),
                'iat' => time(),
                'exp' => time() + 86400 * 180,  // 180 days
                'aud' => 'https://appleid.apple.com',
                'sub' => env('APPLE_CLIENT_ID'),
            ], $privateKey, 'ES256', env('APPLE_KEY_ID'));
    
            // Exchange auth code for tokens
            $response = Http::asForm()->post('https://appleid.apple.com/auth/token', [
                'client_id' => env('APPLE_CLIENT_ID'),
                'client_secret' => $clientSecret,
                'code' => $authCode,
                'grant_type' => 'authorization_code'
            ]);
    
            if (!$response->successful()) {
                Log::error('Apple Token Exchange Error', [
                    'status' => $response->status(),
                    'error' => $response->json()
                ]);
                return response()->json([
                    'success' => false,
                    'message' => 'Failed to verify with Apple',
                    'error' => $response->json()
                ], 400);
            }
    
            // Get user data from ID token
            $data = $response->json();
            $idToken = $data['id_token'];
            $tokenParts = explode('.', $idToken);
            
            if (count($tokenParts) != 3) {
                throw new Exception('Invalid token format');
            }
    
            // Decode payload
            $payload = base64_decode(str_pad(strtr($tokenParts[1], '-_', '+/'), strlen($tokenParts[1]) % 4, '=', STR_PAD_RIGHT));
            $userData = json_decode($payload, true);
    
            if (!$userData) {
                throw new Exception('Failed to decode token payload');
            }
    
            // Verify token expiry
            if (time() > $userData['exp']) {
                throw new Exception('Token has expired');
            }
    
            // Return user data
            $user = $request->input('user') ? json_decode($request->input('user'), true) : null;
            
            return response()->json([
                'success' => true,
                'message' => 'Apple Sign In successful',
                'user_data' => [
                    'apple_user_id' => $userData['sub'],
                    'email' => $userData['email'] ?? null,
                    'email_verified' => $userData['email_verified'] ?? false,
                    'name' => $user['name'] ?? null
                ]
            ]);
    
        } catch (Exception $e) {
            Log::error('Apple Sign In Error', [
                'error' => $e->getMessage(),
                'trace' => $e->getTraceAsString()
            ]);
            return response()->json([
                'success' => false,
                'message' => 'Authentication failed',
                'error' => $e->getMessage()
            ], 500);
        }
    }