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
- New Xcode project → App → SwiftUI → name
SIWALab. Bundle IDcom.example.siwalab. - Project → Signing & Capabilities → + Capability → Sign in with Apple.
- No additional
Info.plistkeys 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.
- 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.
- Force-quit & relaunch: the signed-in state persists (Keychain).
- 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.notFoundand the revocation observer / state check signs out cleanly.) - Second sign-in (same user): tap SIWA again. Notice
credential.emailis nil this time — Apple only sends it once. Your code merges, so the previously-saved email is retained.
Stretch
- Real server verification: replace
MockAuthAPIwith a Vapor or Express endpoint that fetches Apple’s JWKS, verifies the signature, exchanges theauthorizationCodefor a refresh token. Cache JWKS for 24h. - Background credential check: add a Background App Refresh task (Chapter 6.3 covered this) that calls
checkCredentialStateperiodically. - Passkey fallback: present a passkey-based sign-in alongside SIWA for users without an Apple ID (Chapter 9.6 covers passkeys).
- 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. - Account deletion: in your real backend, when the user deletes their account, call Apple’s
/auth/revoketo 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-submapping); existing users get.transferredstate otherwise.
Phase 7 complete. Phase 8 (Testing & Quality) covers unit, snapshot, UI, and CI testing patterns.