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:
-
loginWithApple
: This method is called when the mobile app sends a request to sign in with Apple. It expects several parameters, includingcode
,first_name
,last_name
,device_name
, anduse_bundle_id
. Thecode
is the authorization code received from Apple after the user signs in using their Apple ID. -
validate
: Before proceeding, the request data is validated to ensure it contains the required parameters in the expected format. -
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
, andredirect
parameters for theapple
driver of Socialite. -
Socialite::driver('apple')->stateless()->user()
: This line uses Laravel Socialite to retrieve the user details from the Apple authorization server. Thestateless()
method is used so the nonce and state parameters are not set or stored in the session, which could lead to issues. -
User::firstOrCreate()
: With the obtained user details, this code checks if the user already exists in the database based on the Appleprovider_id
. If the user exists, it returns the existing user; otherwise, it creates a new user record. -
createToken()
: Once the user is determined (either existing or new), a new API token is generated for the user using thecreateToken()
method. This token will be used for subsequent authenticated requests from the mobile app. -
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:
-
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. -
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. -
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.
-
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. -
Auth::login()
: Once the user is retrieved or created, the user is logged in using Laravel'sAuth::login()
method. This allows the user to access protected routes on the web platform. -
Redirect: After successful authentication, the user is redirected to the appropriate page. In this case, it is redirected to the
profile.index
route. -
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:
-
auth/apple/redirect
andauth/apple/callback
: These routes handle Sign-in with Apple on the web platform. Theredirect
route initiates the Apple authentication process, while thecallback
route handles the response from Apple and completes the authentication process. -
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.. -
/auth/apple
: This route is responsible for Sign-in with Apple on the mobile app (both iOS and Android). It triggers theloginWithApple
method in theAuthController
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
- Apple Developer Documentation - Processing Changes for Sign in with Apple Accounts
- Sign in with apple Flutter Package
- Socialite Providers - Apple Documentation
- Apple Developer Forum Thread
- Flutter Sign-in with Apple Example on Glitch (node.js)
- Sign-in with Apple Flutter Package Example on GitHub
- What the Heck is Sign-in with Apple? (Okta)
- Generating a Client Secret for Sign-in with Apple on Each Request