๐Ÿ” Sign in with Apple

๊ฐ€์žฅ ํ”„๋ผ์ด๋ฒ„์‹œ ์นœํ™”์ ์ธ ๋กœ๊ทธ์ธ. ์ด๋ฉ”์ผ ์ˆจ๊น€, 2FA ์ž๋™, Face ID ์ธ์ฆ!

โœจ Sign in with Apple์ด๋ž€?

Apple ID๋กœ ๊ฐ„ํŽธ ๋กœ๊ทธ์ธํ•ฉ๋‹ˆ๋‹ค. ์ด๋ฉ”์ผ์„ ์ˆจ๊ธธ ์ˆ˜ ์žˆ๊ณ , 2๋‹จ๊ณ„ ์ธ์ฆ์ด ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์†Œ์…œ ๋กœ๊ทธ์ธ์ด ์žˆ๋‹ค๋ฉด ํ•„์ˆ˜ ๊ตฌํ˜„์ž…๋‹ˆ๋‹ค.

๐ŸŽฏ ๊ธฐ๋ณธ ๊ตฌํ˜„

SignInManager.swift
import AuthenticationServices

class SignInManager: NSObject, ASAuthorizationControllerDelegate {
    func signInWithApple() {
        let provider = ASAuthorizationAppleIDProvider()
        let request = provider.createRequest()

        // ์š”์ฒญํ•  ์ •๋ณด (์ด๋ฆ„, ์ด๋ฉ”์ผ)
        request.requestedScopes = [.fullName, .email]

        let controller = ASAuthorizationController(authorizationRequests: [request])
        controller.delegate = self
        controller.presentationContextProvider = self
        controller.performRequests()
    }

    // ์„ฑ๊ณต ์‹œ
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithAuthorization authorization: ASAuthorization
    ) {
        if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
            let userID = credential.user  // ๊ณ ์œ  ID (๋ณ€ํ•˜์ง€ ์•Š์Œ)
            let email = credential.email  // ์ฒซ ๋กœ๊ทธ์ธ ์‹œ๋งŒ ์ œ๊ณต
            let fullName = credential.fullName  // ์ฒซ ๋กœ๊ทธ์ธ ์‹œ๋งŒ ์ œ๊ณต
            let identityToken = credential.identityToken  // JWT ํ† ํฐ

            // ๋ฐฑ์—”๋“œ๋กœ identityToken ์ „์†กํ•˜์—ฌ ๊ฒ€์ฆ
            if let tokenData = identityToken,
               let tokenString = String(data: tokenData, encoding: .utf8) {
                verifyTokenWithBackend(tokenString, userID: userID)
            }

            // UserDefaults์— userID ์ €์žฅ
            UserDefaults.standard.set(userID, forKey: "appleUserID")
        }
    }

    // ์‹คํŒจ ์‹œ
    func authorizationController(
        controller: ASAuthorizationController,
        didCompleteWithError error: Error
    ) {
        print("๋กœ๊ทธ์ธ ์‹คํŒจ: \(error.localizedDescription)")
    }

    func verifyTokenWithBackend(_ token: String, userID: String) {
        // ๋ฐฑ์—”๋“œ API ํ˜ธ์ถœ
    }
}

extension SignInManager: ASAuthorizationControllerPresentationContextProviding {
    func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
        return UIApplication.shared.windows.first!
    }
}

๐ŸŽจ SwiftUI์—์„œ Sign in with Apple ๋ฒ„ํŠผ

SignInButton.swift
import SwiftUI
import AuthenticationServices

struct SignInWithAppleButton: View {
    @Environment(\.colorScheme) var colorScheme

    var body: some View {
        SignInWithAppleButton(
            .signIn,
            onRequest: { request in
                request.requestedScopes = [.fullName, .email]
            },
            onCompletion: { result in
                switch result {
                case .success(let authorization):
                    handleAuthorization(authorization)
                case .failure(let error):
                    print("์—๋Ÿฌ: \(error)")
                }
            }
        )
        .signInWithAppleButtonStyle(
            colorScheme == .dark ? .white : .black
        )
        .frame(height: 50)
    }

    func handleAuthorization(_ authorization: ASAuthorization) {
        if let credential = authorization.credential as? ASAuthorizationAppleIDCredential {
            print("์‚ฌ์šฉ์ž ID: \(credential.user)")
            print("์ด๋ฉ”์ผ: \(credential.email ?? \"์—†์Œ\")")
        }
    }
}

// ์‚ฌ์šฉ ์˜ˆ์‹œ
struct LoginView: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("๋กœ๊ทธ์ธ")
                .font(.largeTitle.bold())

            SignInWithAppleButton()
                .padding(.horizontal)
        }
    }
}

๐Ÿ”„ ์ž๋™ ๋กœ๊ทธ์ธ ์ƒํƒœ ์ฒดํฌ

CredentialCheck.swift
import AuthenticationServices

func checkCredentialState() {
    guard let userID = UserDefaults.standard.string(forKey: "appleUserID") else {
        // ์ €์žฅ๋œ userID ์—†์Œ
        return
    }

    let provider = ASAuthorizationAppleIDProvider()
    provider.getCredentialState(forUserID: userID) { state, error in
        switch state {
        case .authorized:
            print("๋กœ๊ทธ์ธ ์œ ์ง€ ์ค‘")
            // ์ž๋™ ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ

        case .revoked:
            print("๋กœ๊ทธ์ธ ์ทจ์†Œ๋จ")
            // ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ
            UserDefaults.standard.removeObject(forKey: "appleUserID")

        case .notFound:
            print("์‚ฌ์šฉ์ž ์—†์Œ")

        case .transferred:
            print("๋‹ค๋ฅธ ํŒ€์œผ๋กœ ์ด์ „๋จ")

        @unknown default:
            break
        }
    }
}

// ์•ฑ ์‹œ์ž‘ ์‹œ ์ฒดํฌ
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    checkCredentialState()
    return true
}

๐Ÿ“ง ์ด๋ฉ”์ผ ๋ฆด๋ ˆ์ด (Email Relay)

์‚ฌ์šฉ์ž๊ฐ€ "์ด๋ฉ”์ผ ์ˆจ๊ธฐ๊ธฐ"๋ฅผ ์„ ํƒํ•˜๋ฉด Apple์ด ํ”„๋ก์‹œ ์ด๋ฉ”์ผ(`privaterelay@icloud.com`)์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

EmailRelay.swift
func handleCredential(_ credential: ASAuthorizationAppleIDCredential) {
    if let email = credential.email {
        // ์ฒซ ๋กœ๊ทธ์ธ ์‹œ๋งŒ ์ œ๊ณต๋จ
        print("์ด๋ฉ”์ผ: \(email)")

        // privaterelay@icloud.com ํ˜•ํƒœ๋ฉด ํ”„๋ก์‹œ ์ด๋ฉ”์ผ
        if email.contains("privaterelay") {
            print("์‚ฌ์šฉ์ž๊ฐ€ ์ด๋ฉ”์ผ์„ ์ˆจ๊น€")
        }

        // โš ๏ธ ์ค‘์š”: ์ฒซ ๋กœ๊ทธ์ธ ์‹œ๋งŒ ์ œ๊ณต๋˜๋ฏ€๋กœ ๋ฐ˜๋“œ์‹œ ์ €์žฅ!
        UserDefaults.standard.set(email, forKey: "userEmail")
    }
}

๐Ÿ”‘ ๋ฐฑ์—”๋“œ ํ† ํฐ ๊ฒ€์ฆ

BackendVerification.swift
// identityToken์€ JWT ํ˜•์‹์ž…๋‹ˆ๋‹ค
// ๋ฐฑ์—”๋“œ์—์„œ Apple ๊ณต๊ฐœํ‚ค๋กœ ๊ฒ€์ฆํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค

func sendTokenToBackend(_ credential: ASAuthorizationAppleIDCredential) async {
    guard let tokenData = credential.identityToken,
          let token = String(data: tokenData, encoding: .utf8) else {
        return
    }

    // authorizationCode๋„ ๋ฐฑ์—”๋“œ๋กœ ์ „์†ก (refresh token ๋ฐœ๊ธ‰์šฉ)
    guard let codeData = credential.authorizationCode,
          let code = String(data: codeData, encoding: .utf8) else {
        return
    }

    // API ํ˜ธ์ถœ
    let body: [String: Any] = [
        "identityToken": token,
        "authorizationCode": code,
        "user": credential.user
    ]

    // POST to your backend
}

/* ๋ฐฑ์—”๋“œ ๊ฒ€์ฆ (Node.js ์˜ˆ์‹œ)
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: 'https://appleid.apple.com/auth/keys'
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    callback(null, key.publicKey || key.rsaPublicKey);
  });
}

jwt.verify(identityToken, getKey, (err, decoded) => {
  if (err) {
    // ํ† ํฐ ๊ฒ€์ฆ ์‹คํŒจ
  } else {
    // decoded.sub === userID
    // decoded.email === ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ
  }
});
*/

๐Ÿ“ฑ ๊ธฐ์กด ๊ณ„์ • ์—…๊ทธ๋ ˆ์ด๋“œ

AccountUpgrade.swift
import AuthenticationServices

// ๊ธฐ์กด ์ด๋ฉ”์ผ/๋น„๋ฐ€๋ฒˆํ˜ธ ๋กœ๊ทธ์ธ์„ Apple ID๋กœ ์—…๊ทธ๋ ˆ์ด๋“œ
func upgradeToAppleID(existingEmail: String) {
    let provider = ASAuthorizationAppleIDProvider()
    let request = provider.createRequest()
    request.requestedScopes = [.fullName]

    // ๊ธฐ์กด ๊ณ„์ • ์—ฐ๊ฒฐ์„ ์œ„ํ•œ ํžŒํŠธ
    request.user = existingEmail

    let controller = ASAuthorizationController(authorizationRequests: [request])
    // ... delegate ์„ค์ •
    controller.performRequests()
}

๐Ÿšจ ๋กœ๊ทธ์•„์›ƒ & ๊ณ„์ • ์‚ญ์ œ

Revocation.swift
// ๋กœ๊ทธ์•„์›ƒ
func signOut() {
    UserDefaults.standard.removeObject(forKey: "appleUserID")
    UserDefaults.standard.removeObject(forKey: "userEmail")
    // UI๋ฅผ ๋กœ๊ทธ์ธ ํ™”๋ฉด์œผ๋กœ
}

// ๊ณ„์ • ์‚ญ์ œ (๋ฐฑ์—”๋“œ์—์„œ Apple API ํ˜ธ์ถœ ํ•„์š”)
/*
์‚ฌ์šฉ์ž๊ฐ€ "์„ค์ • > Apple ID > ์•”ํ˜ธ ๋ฐ ๋ณด์•ˆ > Apple๋กœ ๋กœ๊ทธ์ธ์„ ์‚ฌ์šฉํ•˜๋Š” ์•ฑ"์—์„œ
์•ฑ์„ ์ œ๊ฑฐํ•˜๋ฉด ๋กœ๊ทธ์ธ์ด ์ทจ์†Œ๋ฉ๋‹ˆ๋‹ค.

๋ฐฑ์—”๋“œ์—์„œ๋Š” Apple์˜ Token Revocation API๋ฅผ ํ˜ธ์ถœํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:
POST https://appleid.apple.com/auth/revoke

์ดํ›„ ์‚ฌ์šฉ์ž ๋ฐ์ดํ„ฐ๋ฅผ ์‚ญ์ œํ•˜๊ณ  ๊ณ„์ •์„ ๋น„ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค.
*/

โš™๏ธ ์•ฑ ์„ค์ • (Capabilities)

Xcode ์„ค์ •
1. Xcode์—์„œ ํ”„๋กœ์ ํŠธ ์„ ํƒ
2. Signing & Capabilities ํƒญ
3. "+ Capability" ํด๋ฆญ
4. "Sign in with Apple" ์ถ”๊ฐ€

5. Apple Developer ์ฝ˜์†”์—์„œ:
   - Identifier์— "Sign in with Apple" ํ™œ์„ฑํ™”
   - Services ID ์ƒ์„ฑ (์›น ๋กœ๊ทธ์ธ์šฉ, ์„ ํƒ)
   - Key ์ƒ์„ฑ (๋ฐฑ์—”๋“œ ๊ฒ€์ฆ์šฉ)

โœ… ํ•„์ˆ˜ ์š”๊ตฌ์‚ฌํ•ญ (App Review)

โš ๏ธ ํ•„์ˆ˜!
์•ฑ์— ๋‹ค๋ฅธ ์†Œ์…œ ๋กœ๊ทธ์ธ(Google, Facebook ๋“ฑ)์ด ์žˆ๋‹ค๋ฉด,
Sign in with Apple์„ ๋ฐ˜๋“œ์‹œ ๊ตฌํ˜„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด App Store ์‹ฌ์‚ฌ์—์„œ ๋ฆฌ์ ๋ฉ๋‹ˆ๋‹ค.

๐Ÿ’ก HIG ์ฒดํฌ๋ฆฌ์ŠคํŠธ
โœ… ๊ณต์‹ Sign in with Apple ๋ฒ„ํŠผ ์‚ฌ์šฉ
โœ… ๋‹ค๋ฅธ ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ๊ณผ ๋™์ผํ•œ ํฌ๊ธฐ/์œ„์น˜
โœ… ์ด๋ฉ”์ผ/์ด๋ฆ„์€ ์ฒซ ๋กœ๊ทธ์ธ ์‹œ๋งŒ ์ œ๊ณต๋จ (์ €์žฅ ํ•„์ˆ˜)
โœ… ํ† ํฐ์€ ๋ฐฑ์—”๋“œ์—์„œ ๊ฒ€์ฆ
โœ… ์•ฑ ์‹œ์ž‘ ์‹œ credential ์ƒํƒœ ์ฒดํฌ

๐Ÿ“ฆ ํ•™์Šต ์ž๋ฃŒ

๐Ÿ’ป
GitHub ํ”„๋กœ์ ํŠธ
๐ŸŽ
Apple HIG ์›๋ฌธ
๐Ÿ“–
Apple ๊ณต์‹ ๋ฌธ์„œ