Implementing Sign-in with Apple on Flutter (iOS and Android) and the Web using a Laravel backend

Sign-in with Apple offers a secure and privacy-focused way for users to sign in to your app or website. It works on iOS, Android, and the web, enabling seamless authentication across platforms. In this guide, we will walk you through the implementation of Sign-in with Apple in Flutter for both iOS, Android, and the web, using a Laravel backend to handle the authentication process.

Note

Before proceeding with the implementation of Sign-in with Apple, make sure to follow all the steps mentioned in the integration section of the Sign in with Apple Flutter package documentation.

Please note that this guide serves as a personal reference and assumes you already have a basic setup in place. It focuses on the more undocumented aspects that you might spend hours figuring out on your own. Consider it a condensed version of the insights gained during those hours of exploration.

Introduction

Sign-in with Apple provides a simple and convenient way for users to log in to your app or website without the need for creating separate login credentials. It also ensures the privacy of user data by allowing users to share only the information they want.

Step-by-Step Implementation

Setting Up Laravel Backend

Configure .env File

In your Laravel project, add the following keys to the .env file:

APPLE_TEAM_ID="YOUR-TEAM-ID"
APPLE_KEY_ID="YOUR-KEY-ID"

# This is your bundleid (com.company.app)
APPLE_APP_CLIENT_ID=
APPLE_APP_REDIRECT=

# This is your serviceId (com.company.app.login or whatever you named it)
APPLE_WEB_CLIENT_ID=
APPLE_WEB_REDIRECT=

# Private key, Copy paste the contents of the .p8 file you
# generated when setting up sign in with apple in the developer portal
private_key="CONTENT OF.p8 FILE"

Replace the placeholder values with the actual values provided by your Apple Developer account.

##$# Update config/services.php

Next, update the config/services.php file to include the Apple authentication configuration:

<?php
return [
    // Other service configurations...

    'apple' => [
        'client_id' => env('APPLE_CLIENT_ID'),
        'client_secret' => "***AUTOGENERATED ON THE FLY***",
        'redirect' => env('APPLE_REDIRECT'),
        'team_id' => env('APPLE_TEAM_ID'),
        'key_id' => env('APPLE_KEY_ID'),
        'private_key' => env('APPLE_PRIVATE_KEY'),
    ],
];

Generating client_secret on the fly.

The AppleToken class is a crucial component in the implementation of Sign-in with Apple in your Laravel backend. It is responsible for generating the client_secret required for authenticating with Apple's servers. The client_secret is a JSON Web Token (JWT) that contains specific information about the authentication request and is used to validate the authenticity of the request.

AppleToken Class

The AppleToken class is a service that generates the client_secret required for Sign-in with Apple authentication. It utilizes the Lcobucci\JWT library to create JSON Web Tokens (JWTs) with the necessary claims. The class now has a single method called generateClientSecret(), which takes a boolean parameter $useBundleId. If $useBundleId is true, the method generates the client_secret for iOS (iOS platform); if $useBundleId is false, it generates the client_secret for the web (Android platform).

Here's the AppleToken class code:

<?php

namespace App\Services;

use Carbon\CarbonImmutable;use Lcobucci\JWT\Configuration;

class AppleToken
{
    protected Configuration $jwtConfig;

    public function __construct(Configuration $jwtConfig)
    {
        $this->jwtConfig = $jwtConfig;
    }

    /**
     * Generates the client_secret for Sign-in with Apple on iOS (iOS platform)
     * or on the web (Android platform) based on the value of $useBundleId.
     *
     * @param bool $useBundleId Whether to use the App bundle ID for iOS or the Service ID for the web.
     * @see https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request
     *
     * @return string
     */
    public function generateClientSecret(bool $useBundleId): string
    {
        $now = CarbonImmutable::now();

        $relatedTo = $useBundleId ? env("APPLE_APP_CLIENT_ID") : env("APPLE_WEB_CLIENT_ID");

        $token = $this->jwtConfig->builder()
            ->issuedBy(config('services.apple.team_id'))
            ->issuedAt($now)
            ->expiresAt($now->addHour())
            ->permittedFor('https://appleid.apple.com')
            ->relatedTo($relatedTo)
            ->withHeader('kid', config('services.apple.key_id'))
            ->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());

        return $token->toString();
    }
}

generateClientSecret()

The generateClientSecret() method is responsible for generating the client_secret for Sign-in with Apple on both iOS and the web platforms. It takes a boolean parameter $useBundleId, which determines whether to use the App bundle ID for iOS or the Service ID for the web.

Binding the Configuration Class

To use the AppleToken service, we need to bind the Configuration class with the private key in the boot() method of your AuthServiceProvider. This ensures that the JWT configuration is available for generating the client_secret.

Here's how to bind the configuration class with your private key:

// app/Providers/AuthServiceProvider.php

use Illuminate\Support\ServiceProvider;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Hmac\Sha256;

class AuthServiceProvider extends ServiceProvider
{
    public function boot()
    {
        $this->app->bind(Configuration::class, fn () => Configuration::forSymmetricSigner(
            signer: new Sha256(),
            key: InMemory::plainText(config('services.apple.private_key')),
        ));
    }
}

With this binding in place, the AppleToken class will have access to the necessary configuration and can generate the client_secret as required.


AuthController for Mobile App

This AuthController is responsible for handling authentication for the mobile app, Let's break down the code:

  1. loginWithApple: This method is called when the mobile app sends a request to sign in with Apple. It expects several parameters, including code, first_name, last_name, device_name, and use_bundle_id. The code is the authorization code received from Apple after the user signs in using their Apple ID.

  2. validate: Before proceeding, the request data is validated to ensure it contains the required parameters in the expected format.

  3. Apple Sign-in Platform Selection: Depending on whether the app is running on iOS or Android, the Apple Sign-in configuration is adjusted accordingly. Apple provides different configurations based on the platform, and this code dynamically sets the client_secret, client_id, and redirect parameters for the apple driver of Socialite.

  4. Socialite::driver('apple')->stateless()->user(): This line uses Laravel Socialite to retrieve the user details from the Apple authorization server. The stateless() method is used so the nonce and state parameters are not set or stored in the session, which could lead to issues.

  5. User::firstOrCreate(): With the obtained user details, this code checks if the user already exists in the database based on the Apple provider_id. If the user exists, it returns the existing user; otherwise, it creates a new user record.

  6. createToken(): Once the user is determined (either existing or new), a new API token is generated for the user using the createToken() method. This token will be used for subsequent authenticated requests from the mobile app.

  7. Response: The method returns the generated API token, which can be used by the mobile app for further authenticated API requests.

<?php

namespace App\Http\Controllers\Mobile;

use App\Actions\SocialiteLogin;use App\Http\Controllers\Controller;use App\Models\User;use App\Services\AppleToken;use Illuminate\Http\Request;use Laravel\Socialite\Facades\Socialite;

class AuthController extends Controller
{

    public function loginWithApple(Request $request, SocialiteLogin $socialiteLogin, AppleToken $appleToken)
    {
        $request->validate([
            'code' => ['required'],
            'first_name' => ['nullable', "string"],
            'last_name' => ['nullable', "string"],
            'device_name' => ['required'],
            'use_bundle_id' => ['required', 'boolean'],
        ]);

        // Swap the Apple Socialite Provider settings based on if we are authenticating on iOS or Android
        if ($request->boolean("use_bundle_id")) {
            // When using the "sign in with apple" feature on an iOS devices we need to request the access token
            // with the bundleId of the mobile app (com.company.appname) since this authorization code in the app is generated with that "client id".
            config()->set('services.apple.client_secret', $appleToken->generateClientSecret(useBundleId: true));
            config()->set('services.apple.client_id', env('APPLE_CLIENT_ID_MOBILE_APP'));
            config()->set('services.apple.redirect', env('APPLE_URL'));
        } else {
            // We are authenticating an android app, which uses the web authentication (webview/custom tab in chrome),
            // so we need to use the "service id" (com.company.appname.login) instead of the app bundle id (com.company.appname)
            // Since the authorization_code is generated for that serviceId and not the "app" itself.
            config()->set('services.apple.client_secret', $appleToken->generateClientSecret(useBundleId: false));
            config()->set('services.apple.client_id', env('APPLE_CLIENT_ID'));
            config()->set('services.apple.redirect', env("APPLE_URL_MOBILE"));
        }

        /** @var \Laravel\Socialite\Two\User $socialiteUser */
        $socialiteUser = Socialite::driver("apple")->stateless()->user();

        $user = User::firstOrCreate([
            "provider" => "apple",
            "provider_id" => $socialiteUser->getId(),
        ], [

            'name' => $socialiteUser->getName() ?? "",
            'email' => $socialiteUser->getEmail(),
            'provider_id' => $socialiteUser->getId(),
            'provider' => "apple",
        ]);;

        $token = $user->createToken($request->input("device_name"));

        return [
            "token" => $token->plainTextToken,
        ];
    }
}

SocialLoginController for Web

This SocialLoginController is responsible for handling redirecting the user to the Apple login screen, and the redirect callback when they are finished, we will redirect the user to a deep link inside our app in the callback method. Let's break down the code:

  1. redirect: This method is responsible for redirecting the user to the Apple authentication page. When the user clicks the Sign-in with Apple button, this route will be triggered, and they will be redirected to the Apple authentication page.

  2. handleAppleCallback: This method is executed when the user returns from the Apple authentication page. It retrieves the user details from the Apple authorization server using Laravel Socialite, similar to the mobile app.

  3. Apple Sign-in Platform Selection: Just like in the mobile app case, the code determines whether the authentication is happening on iOS or Android (web-based). Based on this, the configuration is adjusted accordingly.

  4. getOrCreateUserFromSocialite(): This method is used to handle the creation or retrieval of the user from the database based on the user details obtained from Apple.

  5. Auth::login(): Once the user is retrieved or created, the user is logged in using Laravel's Auth::login() method. This allows the user to access protected routes on the web platform.

  6. Redirect: After successful authentication, the user is redirected to the appropriate page. In this case, it is redirected to the profile.index route.

  7. handleAppleCallbackMobileApp: This method is responsible for handling Sign-in with Apple on the mobile app when running on Android. In Android, when the user clicks the Sign-in with Apple button, the web page redirects to a deep link that triggers the mobile app. This method prepares the payload and redirects to the appropriate deep link with the payload.

<?php


namespace App\Http\Controllers\Auth\Socialite;

use App\Actions\SocialiteLogin;use App\Http\Controllers\Controller;use App\Services\AppleToken;use Illuminate\Http\Request;use Illuminate\Support\Facades\Auth;use Laravel\Socialite\Facades\Socialite;use Laravel\Socialite\Two\User;

class SocialLoginController extends Controller
{
    public function redirect($provider)
    {
        return Socialite::driver($provider)->redirect();
    }

    // Used to Sign in with apple on Web
    public function handleAppleCallback(AppleToken $appleToken, SocialiteLogin $socialiteLogin)
    {
        // Generate on-the-fly client_secret from the private key
        // See: https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request
        config()->set('services.apple.client_secret', $appleToken->generateUsingServiceId());

        /** @var User $socialiteUser */
        /** @noinspection PhpPossiblePolymorphicInvocationInspection */
        $socialiteUser = Socialite::driver("apple")->stateless()->user();

        $user = $socialiteLogin->getOrCreateUserFromSocialite("apple", $socialiteUser);

        Auth::login($user);

        return redirect()->route("profile.index");
    }

    // Login with the Mobile app, on android (webview redirects to deeplink in app), not used by iOS app.
    public function handleAppleCallbackMobileApp(Request $request, AppleToken $appleToken)
    {
        $payload = $request->getContent();
        // Change this to whatever your android package name is
        // This can be found in: android/app/src/main/AndroidManifest.xml
        //                                          |       here      |
        // <manifest xmlns:android="[...]" package="com.company.appname">
        $packageName = "com.company.appname";

        return redirect("intent://callback?{$payload}#Intent;package={$packageName};scheme=signinwithapple;end");
    }
}

Register Routes

The final step is to register the routes for handling authentication:

  1. auth/apple/redirect and auth/apple/callback: These routes handle Sign-in with Apple on the web platform. The redirect route initiates the Apple authentication process, while the callback route handles the response from Apple and completes the authentication process.

  2. auth/apple/app-callback: This route handles Sign-in with Apple on the mobile app running on Android. When the user clicks the Sign-in with Apple button on the Android app, this is the route that is set as the redirect_uri, and will forward the callback from apple back to the mobile app as a deep link..

  3. /auth/apple: This route is responsible for Sign-in with Apple on the mobile app (both iOS and Android). It triggers the loginWithApple method in the AuthController we discussed earlier.

Route::post('auth/apple/redirect', [SocialLoginController::class, "redirect"]);
Route::post('auth/apple/callback', [SocialLoginController::class, "handleAppleCallback"]);
Route::post('auth/apple/app-callback', [SocialLoginController::class, "handleAppleCallbackMobileApp"]);
Route::post('/auth/apple', [AuthController::class, "loginWithApple"]);

Implementing Sign-in with Apple in Flutter

Installing Dependencies

First, install the required packages in your Flutter app.

flutter pub add sign_in_with_apple
flutter pub add dio
# Optional, but this package includes a nice Apple button
flutter pub add social_login_buttons

Implementing Sign-in with Apple in Flutter

Here is a stripped-down example of a login page in Flutter:

import 'dart:io';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:social_login_buttons/social_login_buttons.dart';

enum AuthState {
  initial,
  authenticating,
  authenticated,
  error,
  cancelled,
}

class LoginExampleScreen extends ConsumerStatefulWidget {
  const LoginExampleScreen({super.key});

  
  LoginExampleScreenState createState() => LoginExampleScreenState();
}

class LoginExampleScreenState extends ConsumerState<LoginExampleScreen> {
  AuthState _authState = AuthState.initial;

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisAlignment: MainAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: [
            // Example on using Enum states to show different messages.
            if (_authState == AuthState.initial) const Text("Please login"),
            if (_authState == AuthState.error) const Text("An error occurred"),
            if (_authState == AuthState.authenticating) const Text("You are being logged in"),
            if (_authState == AuthState.authenticated) const Text("You are logged in"),
            if (_authState == AuthState.cancelled) const Text("You cancelled the authentication flow"),
            const SizedBox(height: 20),
            SocialLoginButton(
              text: "Logg inn med Apple",
              buttonType: SocialLoginButtonType.apple,
              textColor: Colors.black,
              borderRadius: 8,
              fontSize: 16,
              onPressed: () => _loginWithApple(),
            ),
          ],
        ),
      ),
    );
  }

  _loginWithApple() async {
    try {
      final AuthorizationCredentialAppleID credential = await SignInWithApple.getAppleIDCredential(
        webAuthenticationOptions: WebAuthenticationOptions(
          clientId: "com.company.appname.login", // ServiceId
          redirectUri: Uri.parse("https://your-project.eu-1.sharedwithexpose.com/login/social/apple/app-callback"),
        ),
        scopes: [
          AppleIDAuthorizationScopes.email,
          AppleIDAuthorizationScopes.fullName,
        ],
      );

      var isApple = (Platform.isIOS || Platform.isMacOS) ? true : false;

      var response = await Dio().post(
        "/api/mobile/auth/apple",
        data: {
          "code": credential.authorizationCode,
          "use_bundle_id": isApple,
          if (credential.givenName != null) "first_name": credential.givenName,
          if (credential.familyName != null) "last_name": credential.familyName,
        },
      );

      if (response.statusCode != 200) {
        setState(() {
          _authState = AuthState.error;
        });
        return;
      }

      if (response.data["token"] != null) {
        // We now have a jwt token from our API
        setState(() {
          _authState = AuthState.authenticated;
        });
      }
    } on SignInWithAppleAuthorizationException catch (error) {
      // We can catch specific errors if the sign_in_with_apple library throws this exception.

      switch (error.code) {
        // User cancelled the authentication
        case AuthorizationErrorCode.canceled:
          setState(() {
            _authState = AuthState.cancelled;
          });
          return;

        // Any kind of error occurred
        case AuthorizationErrorCode.invalidResponse:
        case AuthorizationErrorCode.failed:
        case AuthorizationErrorCode.notHandled:
        case AuthorizationErrorCode.notInteractive:
        case AuthorizationErrorCode.unknown:
        default:
          setState(() {
            _authState = AuthState.error;
          });
      }
    } catch (error) {
      // An unhandled error occurred
      setState(() {
        _authState = AuthState.error;
      });
    }
  }
}


Troubleshooting: givenName, familyName, and email are null in AuthorizationCredentialAppleID

You only get the name and email of the user upon their first login.

See: Apple Developer Documentation

You can "reset" this behavior when testing locally by going into your Apple account on https://appleid.apple.com/account/manage and clicking the "Sign in with apple" card (which is not present before you have used your Apple ID to login to a service, so if you don't see it, you won't have this problem). Then, "stop using Apple ID" to essentially delete your "login" for that service.


Bonus: Handling Apple Server-to-Server Notifications

Here is an example of a controller that will process Apple server-to-server messages, this is useful for updating user information, or deleting the user if they revoke their consent.

Note, this is crude, but it shows the general idea and how to decode the incoming messages.

<?php

namespace App\Http\Controllers\Auth\Socialite;

use App\Actions\DeleteUserData;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Lcobucci\JWT\Encoding\CannotDecodeContent;
use Lcobucci\JWT\Encoding\JoseEncoder;
use Lcobucci\JWT\Token\InvalidTokenStructure;
use Lcobucci\JWT\Token\Parser;
use Lcobucci\JWT\Token\UnsupportedHeaderFound;

class AppleServerNotificationController extends Controller
{
    public function __invoke(Request $request)
    {
        try {
            $parser = new Parser(new JoseEncoder());
            $token = $parser->parse($request->input("payload"));
        } catch (CannotDecodeContent|InvalidTokenStructure|UnsupportedHeaderFound $exception) {
            return response()->json(["status" => "Could not parse payload"], 400);
        }

        $decoded = json_decode($token->claims()->get("events"), true);

        if ($decoded == null) {
            return response()->json(["status" => "Failed to decode 'events' in claims"], 400);
        }

        $type = Arr::get($decoded, "type");
        $appleUserId = Arr::get($decoded, "sub");
        $email = Arr::get($decoded, "email");

        $user = User::firstWhere([
            "provider" => "apple",
            "provider_id" => $appleUserId,
        ]);

        if ($user == null) {
            return response()->json(["status" => "User does not exist"], 400);
        }

        switch ($type) {
            case 'email-disabled':
            case 'email-enabled':
                $user->update(["email" => $email]);
                return response()->json(["status" => "Email updated"]);

            case 'consent-revoked':
            case 'account-delete':
                $user->delete();
                return response()->json(["status" => "User deleted"]);

            default:
                return response()->json(["status" => "Unknown event-type '$type'"], 400);
        }
    }
}

Sources and references