Sign-in with Apple: from client to server-side validation
Ibrahima Ciss
Posted on April 5, 2022
I was working on an upcoming app called Charabia , and I needed some ways to log the user into the app. But I wanted that experience to be really smooth; I don't want to pop up a signup or login form. So I thought that Sign-in with Apple would be a good fit for that, so I added it plus the option to sign in with Google.
The logic is pretty simple. Once you tap on one of these buttons, you authenticate via the client SDK. For Sign in with Apple (or SIWA, I like to call it đ), it's the AuthenticationServices framework that takes care of that, and for Google, I've installed the Google Sign-In Swift package. Once authenticated, both SDK will send you a token or authorization code you can send to your backend server for verification. That's the tricky part because it requires a lot of configuration beforehand in order to get all the different elements necessary for the validation. After a successful verification, depending on the user's existence in the database, we might create the user from the decoded pieces of information contained in the JWT token. If he doesn't exist yet, we create the record in the user table and send him back to the client with an access token he can use for subsequent requests on the RESTful API.
Let's see how to do all that in this article. I've separated this guide into three main sections:
- Client side setup
- Keys creation
- Server-side validation
Excited? Let jump right into it without any further ado.
Client-side setup
Let's first start by creating the app. It's a single app that uses SwiftUI.
For the sake of simplicity, we'll use a single file, the ContentView, to host all of our code (and it shouldn't be long đ
). iOS 14 added a convenient way to sign in with Apple with a view named SignInWithAppleButton
that is directly configurable via its initializer.
SignInWithAppleButton(
label: SignInWithAppleButton.Label,
onRequest: (ASAuthorizationAppleIDRequest) -> Void,
onCompletion: ((Result<ASAuthorization, Error>) -> Void)
)
The onRequest
and onCompletion
arguments are both closures, the first to configure the request and the second to process the result. I'll put these methods in a ViewModel to decouple the view from the logic. It looks like this:
import SwiftUI
import Combine
import AuthenticationServices
final class ViewModel: ObservableObject {
func handle(request: ASAuthorizationAppleIDRequest) {
}
func handle(completion result: Result<ASAuthorization, Error>) {
}
}
struct ContentView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
VStack {
Spacer()
Text("đ Hello SIWA")
.font(.title)
Spacer()
SignInWithAppleButton(
.continue,
onRequest: viewModel.handle(request:),
onCompletion: viewModel.handle(completion:)
)
.frame(height: 48)
}.padding()
}
}
For the authorization request, let ask for user email
and the fullName
.
func handle(request: ASAuthorizationAppleIDRequest) {
request.requestedScopes = [.fullName, .email]
}
Go to the project settings and select your main target; in "Signing & Capabilities" tab, add the "Sign in with Apple" capability.
Now you can run the app. Note that you have to use a physical device to test SIWA; there is an unresolved bug since iOS13 simulators.
Anyway, it should look like this.
Once the button is tapped, you'll have the SIWA popup and you can either use your email or a private one that'll forward to your real email and set a name as well.
Be aware; this popup will appear only once before you accept it via Face-Id or Touch-Id; for subsequent login, you'll have another popup for authorization. Thus, if you want the user pieces of information like his name, you have to handle it the first time the popup is shown. But if you messed up, I will show you how you can later reset the popup and show it like it was the first time.
Now, once we accept (or deny), the handle completion method is fired, and we can send the authorization code provided to our backend for validation.
final class ViewModel: ObservableObject {
func handle(request: ASAuthorizationAppleIDRequest) {
request.requestedScopes = [.fullName, .email]
}
func handle(completion result: Result<ASAuthorization, Error>) {
switch result {
case .success(let authorization):
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.authorizationCode,
let token = String(data: tokenData, encoding: .utf8)
else { print("error"); return }
send(token: token)
case .failure(let error):
print(error.localizedDescription)
}
}
private func send(token: String) {
guard let authData = try? JSONEncoder().encode(["token": token]) else {
return
}
let url = URL(string: "https://yourbackend.example.com/tokensignin")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.uploadTask(with: request, from: authData) { data, response, error in
// Handle response from your backend.
}
task.resume()
}
}
That's all we need to do for our client. That's pretty simple, right. Now let's handle the public and secret keys configuration that'll help us validate the token generated by the client.
Keys Creation and configuration
Now let's create the keys (public and secret) we need to verify the client's authorization code.
- Go to developer.apple.com and log into your account
- Click on âCertificates, IDs & Profilesâ on the left sidebar
- Once the page loaded, go to the âKeysâ menu to create a new key
- Click on the â+â icon, then put the name of the key, I am going to call it
siwatut
- Check the âSign in with Appleâ checkbox and click on the âConfigureâ button
- Itâll redirect you to a key configuration page, and youâll see a select textfield for picking the app you want to create key. Choose you app and click on the âSaveâ button.
- Itâll redirect you back to the key configuration page, now click on the âContinueâ button
- Youâll see a summary of key creation, click on âRegisterâ to create the key
- Now, step really important, you have to click on the âDownloadâ button in order to save the key locally on your computer. I recommend to save it somewhere safe in your disk.
- Rename the private key
AuthKey_keyid.p8
tokey.txt
- Now, open your terminal and go to the location of
key.txt
- Install the JWT Gem with
sudo gem install jwt
- Once done, create a file called
client_secret.rb
to process the private key and open it in your favorite text editor - Put that content in it: ```ruby
require 'jwt'
key_file = 'key.txt'
team_id = 'your-team-id'
client_id = 'dev.ibrahima.siwa-tut'
key_id = 'your-key-id'
ecdsa_key = OpenSSL::PKey::EC.new IO.read key_file
headers = {
'kid' => key_id
}
claims = {
'iss' => team_id,
'iat' => Time.now.to_i,
'exp' => Time.now.to_i + 86400*180,
'aud' => 'https://appleid.apple.com',
'sub' => client_id,
}
token = JWT.encode claims, ecdsa_key, 'ES256', headers
puts token
- `team_id` can be found on the top-right corner when logged into your Apple Developer account
- `client_id` is our appâs bundle identifier
- `key_id` is the private key identifier created at step 9 above
1. Save the file, go back to your terminal and type the command
```bash
ruby client_secret.rb
If everything goes well, it should print a JWT token on the terminal.
Copy and save it somewhere in the meantime, and we will use it in our backend.
Now that we have generated our JWT token, let's process it in our backend to extract the pieces of information we need.
Server-side validation
For the backend, Iâm going to use Laravel PHP framework. You can, of course, use any server-side framework (Express, Django, Rails, etc.) of your choice. I already have a fresh installation of Laravel.
Now I'll copy the token we generate earlier in the .env
, an environment file like so:
SIWA_CLIENT_ID=dev.ibrahima.siwa-tut # your xcode bundle identifer used to generate the token
SIWA_CLIENT_SECRET= # the jwt token generated earlier
SIWA_GRANT_TYPE=authorization_code # used in the validation request
Now let's add an endpoint that the client'll hit. I'll go to the web.php route file, and I create a controller for handling the request.
// web.php
Route::post('tokensignin', [AuthController::class, 'handleSIWALogin']);
// AuthController.php
<?php
namespace App\Http\Controllers;
use App\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;
use Illuminate\Http\JsonResponse;
use GuzzleHttp\Exception\GuzzleException;
class AuthController extends Controller
{
public function handleSIWALogin()
{
$authorizationCode = request()->input('token'); // 1
$body = 'client_id=' . env('SIWA_CLIENT_ID') . '&client_secret=' . env('SIWA_CLIENT_SECRET') . '&code=' . $authorizationCode . '&grant_type=authorization_code';
$client = new Client();
$request = new Request("POST", "https://appleid.apple.com/auth/token", ["Content-Type" => "application/x-www-form-urlencoded"], $body); // 2
try {
$response = $client->send($request); // 3
$data = json_decode($response->getBody(), true);
$payload = json_decode(base64_decode(str_replace('_', '/', str_replace('-', '+', explode('.', $data['id_token'])[1]))), true); // 4
if ($payload['email']) return $this->createOrLogUser($payload); // 5
return $this->respondWithError("Could not authenticate with this token");
} catch (GuzzleException $e) {
return $this->respondWithError($e->getMessage()); // 6
}
}
private function createOrLogUser($payload): JsonResponse
{
// create the user if he's not yet in the database or return him with (if he already exists) with a token and send it back to the client
}
private function respondWithError($message): JsonResponse
{
// return a json response representing an error
}
}
Don't be intimidated by the code above, and it's pretty straightforward. Let's see the different steps needed to verify the token:
- We retrieve the token sent by the client
- We construct a request that'll be sent to Apple's server for authenticating the token. You'll find here the list of parameters
- We send the request
- If the request was successful, we extract the payload with the
json_decode
method in theid_token
field - With the email extracted, we try to log the user or create him in the database, then send the authentication token to the client
- If something is wrong, we return an error. The client should be logged in with the token generated by the backend that'll serve for authentication for the subsequent requests. You can print the authorization code on Xcode for testing purposes and make a post request to apple servers with all your credentials created earlier. The result should look like this:
Resetting Sign in with Apple authorization popup.
As I told you before, the authorization popup where you give your name and decide to share or use a private email will show once. After accepting, there will be another popup for subsequent authorization. I know that you might want to retrieve all the user data like his name for testing, but if you haven't done that the first time, you have no chance to do it later. You can reset the Sign in with Apple authorization popup by following these steps.
- Go to the Settings app of your phone
- Select your iCloud profile at the top
- Go to "Password & Security"
- Go to "Apps using Apple ID"
- Select the concerned app and tap on "Stop using Apple ID" That's it. Next time you'll want to authorize, it'll bring you the original popup giving you the option to share or not your real email and to customize your name.
Final Thoughts
As you can see, validating the authorization code provided by Sign in with Apple isnât difficult, it just requires some setup that can be tedious, I know đ. For more informations, I recommend reading the docs about user verification and token validation from Apple docs:
- Verifying a user
- Token generation and validation Hope you find this article useful. Let me know if you have any questions on Twitter.
Posted on April 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.