Lab 7.4 — Sign in with Apple auth flow

Goal: Build the complete client-side SIWA pipeline: button → credential capture → Keychain-persisted session → credential-state re-check on launch → revocation handling. Server-side validation is mocked (a 30-line local “server” that performs JWT structural validation only; production validation requires fetching Apple’s JWKS — out of scope for a lab).

Time: 60–90 minutes.

Prereqs: Xcode 16+, real iOS 18+ device (Simulator’s SIWA flow is fragile; use a device). Apple ID signed into the device.

Setup

  1. New Xcode project → App → SwiftUI → name SIWALab. Bundle ID com.example.siwalab.
  2. Project → Signing & Capabilities → + Capability → Sign in with Apple.
  3. No additional Info.plist keys required for SIWA itself.

Build

Keychain helper

Keychain.swift:

import Foundation
import Security

enum Keychain {
    static func save(_ data: Data, for key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
        let attrs = query.merging([kSecValueData as String: data]) { $1 }
        SecItemAdd(attrs as CFDictionary, nil)
    }
    static func load(key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        return status == errSecSuccess ? item as? Data : nil
    }
    static func delete(key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

Session model

UserSession.swift:

import Foundation

struct UserSession: Codable, Equatable {
    var userID: String
    var firstName: String?
    var lastName: String?
    var email: String?
    var identityTokenSummary: String  // truncated JWT for display only
    var signedInAt: Date

    var displayName: String {
        [firstName, lastName].compactMap { $0 }.joined(separator: " ").nilIfEmpty
            ?? email
            ?? "Apple User"
    }
}

private extension String {
    var nilIfEmpty: String? { isEmpty ? nil : self }
}

Mock “server” — JWT structural validation

MockAuthAPI.swift:

import Foundation

enum MockAuthAPI {
    /// In production this lives on your server. It would:
    /// 1) Fetch Apple's JWKS from https://appleid.apple.com/auth/keys
    /// 2) Verify the JWT signature against the key matching the `kid` header
    /// 3) Verify iss == "https://appleid.apple.com", aud == your bundle id, exp > now
    /// 4) Return your own session token tied to `sub`
    ///
    /// This mock only checks structural integrity and returns the decoded payload.
    static func signIn(identityToken: Data, authorizationCode: Data) throws -> [String: Any] {
        let jwt = String(decoding: identityToken, as: UTF8.self)
        let parts = jwt.split(separator: ".")
        guard parts.count == 3 else { throw AuthError.malformedToken }
        let payloadSegment = String(parts[1])
        guard let payloadData = base64URLDecode(payloadSegment),
              let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
        else { throw AuthError.malformedToken }
        // Spot-check claims (no signature verification in mock)
        guard let iss = json["iss"] as? String, iss == "https://appleid.apple.com" else {
            throw AuthError.invalidIssuer
        }
        guard let exp = json["exp"] as? TimeInterval, Date(timeIntervalSince1970: exp) > .now else {
            throw AuthError.tokenExpired
        }
        return json
    }

    private static func base64URLDecode(_ s: String) -> Data? {
        var str = s.replacingOccurrences(of: "-", with: "+")
                   .replacingOccurrences(of: "_", with: "/")
        let pad = 4 - str.count % 4
        if pad != 4 { str.append(String(repeating: "=", count: pad)) }
        return Data(base64Encoded: str)
    }
}

enum AuthError: LocalizedError {
    case malformedToken, invalidIssuer, tokenExpired
    var errorDescription: String? {
        switch self {
        case .malformedToken: "Token is not a valid JWT."
        case .invalidIssuer: "Token issuer is not Apple."
        case .tokenExpired: "Token expired."
        }
    }
}

Auth manager

AuthManager.swift:

import AuthenticationServices
import Observation

@Observable
@MainActor
final class AuthManager {
    var session: UserSession?
    var lastError: String?

    private let sessionKey = "siwa.session"
    private var revocationObserver: NSObjectProtocol?

    init() {
        loadSession()
        revocationObserver = NotificationCenter.default.addObserver(
            forName: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
            object: nil, queue: .main
        ) { [weak self] _ in
            Task { @MainActor [weak self] in self?.signOut() }
        }
    }

    deinit {
        if let obs = revocationObserver { NotificationCenter.default.removeObserver(obs) }
    }

    func handle(credential: ASAuthorizationAppleIDCredential) {
        do {
            guard let identityToken = credential.identityToken,
                  let authCode = credential.authorizationCode
            else { throw AuthError.malformedToken }

            let payload = try MockAuthAPI.signIn(identityToken: identityToken, authorizationCode: authCode)
            let userID = (payload["sub"] as? String) ?? credential.user

            // Email + name are only present on first sign-in
            let firstName = credential.fullName?.givenName
            let lastName = credential.fullName?.familyName
            let email = credential.email ?? (payload["email"] as? String)

            // Merge with existing (don't overwrite a previously-saved name with nil)
            var newSession = session ?? UserSession(
                userID: userID,
                firstName: nil, lastName: nil, email: nil,
                identityTokenSummary: "", signedInAt: .now
            )
            newSession.userID = userID
            newSession.firstName = firstName ?? newSession.firstName
            newSession.lastName = lastName ?? newSession.lastName
            newSession.email = email ?? newSession.email
            newSession.identityTokenSummary = String(String(decoding: identityToken, as: UTF8.self).prefix(40)) + "…"
            newSession.signedInAt = .now

            saveSession(newSession)
        } catch {
            lastError = error.localizedDescription
        }
    }

    func checkCredentialState() async {
        guard let session else { return }
        let provider = ASAuthorizationAppleIDProvider()
        let state = try? await provider.credentialState(forUserID: session.userID)
        switch state {
        case .authorized: break
        case .revoked, .notFound, .transferred:
            signOut()
        default: break
        }
    }

    func signOut() {
        Keychain.delete(key: sessionKey)
        session = nil
    }

    private func saveSession(_ s: UserSession) {
        session = s
        if let data = try? JSONEncoder().encode(s) {
            Keychain.save(data, for: sessionKey)
        }
    }

    private func loadSession() {
        guard let data = Keychain.load(key: sessionKey),
              let s = try? JSONDecoder().decode(UserSession.self, from: data)
        else { return }
        session = s
    }
}

UI

ContentView.swift:

import SwiftUI
import AuthenticationServices

struct ContentView: View {
    @State private var auth = AuthManager()

    var body: some View {
        Group {
            if let session = auth.session {
                signedInView(session)
            } else {
                signedOutView
            }
        }
        .padding()
        .task { await auth.checkCredentialState() }
    }

    private var signedOutView: some View {
        VStack(spacing: 24) {
            Image(systemName: "lock.shield.fill")
                .font(.system(size: 64))
                .foregroundStyle(.tint)
            Text("Sign in to continue")
                .font(.title2)
            SignInWithAppleButton(.signIn) { req in
                req.requestedScopes = [.fullName, .email]
            } onCompletion: { result in
                switch result {
                case .success(let authResult):
                    if let cred = authResult.credential as? ASAuthorizationAppleIDCredential {
                        auth.handle(credential: cred)
                    }
                case .failure(let error):
                    auth.lastError = error.localizedDescription
                }
            }
            .signInWithAppleButtonStyle(.black)
            .frame(height: 48)
            if let err = auth.lastError {
                Text(err).font(.caption).foregroundStyle(.red)
            }
        }
    }

    private func signedInView(_ session: UserSession) -> some View {
        VStack(spacing: 16) {
            Image(systemName: "person.circle.fill")
                .font(.system(size: 64))
                .foregroundStyle(.tint)
            Text(session.displayName).font(.title)
            if let email = session.email {
                Text(email).font(.callout).foregroundStyle(.secondary)
            }
            VStack(alignment: .leading, spacing: 4) {
                Label("User ID", systemImage: "key").font(.caption.bold())
                Text(session.userID).font(.caption2.monospaced()).lineLimit(2)
                Label("Token", systemImage: "doc.text").font(.caption.bold()).padding(.top, 8)
                Text(session.identityTokenSummary).font(.caption2.monospaced())
            }
            .padding()
            .background(.thinMaterial, in: .rect(cornerRadius: 12))
            Button("Sign out", role: .destructive) { auth.signOut() }
                .buttonStyle(.bordered)
        }
    }
}

App entry

import SwiftUI

@main
struct SIWALabApp: App {
    var body: some Scene {
        WindowGroup { ContentView() }
    }
}

Test

Run on a real device with your Apple ID signed in.

  1. First sign-in: tap the SIWA button → choose Share My Email or Hide My Email → confirm with Face ID. You should land on the signed-in screen with your name and (relay) email.
  2. Force-quit & relaunch: the signed-in state persists (Keychain).
  3. Revoke via Settings: iOS Settings → Apple ID → Sign-In & Security → Apps Using Apple ID → find your app → Stop Using. Re-foreground the app → after checkCredentialState, you should be signed out automatically. (You can also test by deleting the app on the device; second install gets .notFound and the revocation observer / state check signs out cleanly.)
  4. Second sign-in (same user): tap SIWA again. Notice credential.email is nil this time — Apple only sends it once. Your code merges, so the previously-saved email is retained.

Stretch

  1. Real server verification: replace MockAuthAPI with a Vapor or Express endpoint that fetches Apple’s JWKS, verifies the signature, exchanges the authorizationCode for a refresh token. Cache JWKS for 24h.
  2. Background credential check: add a Background App Refresh task (Chapter 6.3 covered this) that calls checkCredentialState periodically.
  3. Passkey fallback: present a passkey-based sign-in alongside SIWA for users without an Apple ID (Chapter 9.6 covers passkeys).
  4. Sign in with Apple JS: stand up a tiny web page that uses the JS SDK and hits the same mock endpoint — verifies cross-platform identity continuity on the same sub.
  5. Account deletion: in your real backend, when the user deletes their account, call Apple’s /auth/revoke to revoke the refresh token (required by App Store Guideline 5.1.1(v)).

Notes

  • Simulator’s SIWA UX is flaky. Use a real device.
  • The mock validation does not verify the JWT signature. Never ship this to production. Always validate on a server.
  • App Store Guideline 5.1.1(v): apps that let users create accounts must also let them delete the account in-app. For SIWA users, you should call Apple’s revocation endpoint at the same time.
  • App Transfer caveat: if you ever transfer your app to another team, plan a one-time team-transfer migration (Apple gives you the old-sub → new-sub mapping); existing users get .transferred state otherwise.

Phase 7 complete. Phase 8 (Testing & Quality) covers unit, snapshot, UI, and CI testing patterns.