Skip to content

iOS (Swift)

ASWebAuthenticationSession + CryptoKit 으로 네이티브 OAuth + PKCE.

swift
import AuthenticationServices
import CryptoKit

final class LogiOAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
    static let shared = LogiOAuth()
    let base = "https://logi.example.com"
    let clientId = "logi_..."
    let redirectScheme = "com.example.myapp"

    func signIn() async throws -> (accessToken: String, refreshToken: String) {
        let verifier = Data((0..<32).map { _ in UInt8.random(in: 0...255) }).base64URL
        let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URL
        let state = UUID().uuidString

        var comps = URLComponents(string: "\(base)/oauth/authorize")!
        comps.queryItems = [
            .init(name: "client_id", value: clientId),
            .init(name: "redirect_uri", value: "\(redirectScheme)://callback"),
            .init(name: "response_type", value: "code"),
            .init(name: "scope", value: "profile email"),
            .init(name: "state", value: state),
            .init(name: "code_challenge", value: challenge),
            .init(name: "code_challenge_method", value: "S256"),
        ]

        let callback = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<URL, Error>) in
            let session = ASWebAuthenticationSession(url: comps.url!, callbackURLScheme: redirectScheme) { url, err in
                if let url { cont.resume(returning: url) } else { cont.resume(throwing: err!) }
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = true
            session.start()
        }

        let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
        guard items.first(where: { $0.name == "state" })?.value == state,
              let code = items.first(where: { $0.name == "code" })?.value
        else { throw URLError(.badServerResponse) }

        var tokenReq = URLRequest(url: URL(string: "\(base)/oauth/token")!)
        tokenReq.httpMethod = "POST"
        tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        tokenReq.httpBody = [
            "grant_type=authorization_code",
            "code=\(code)",
            "redirect_uri=\(redirectScheme)://callback",
            "code_verifier=\(verifier)",
            "client_id=\(clientId)",
            // Public client: client_secret 없음. 백엔드 경유 권장.
        ].joined(separator: "&").data(using: .utf8)

        let (data, _) = try await URLSession.shared.data(for: tokenReq)
        struct Resp: Decodable { let access_token: String; let refresh_token: String }
        let r = try JSONDecoder().decode(Resp.self, from: data)
        return (r.access_token, r.refresh_token)
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes
            .compactMap { ($0 as? UIWindowScene)?.windows.first }.first ?? ASPresentationAnchor()
    }
}

extension Data {
    var base64URL: String {
        base64EncodedString().replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
    }
}

Refresh Token 저장 (Keychain)

토큰은 반드시 Keychain에 저장하고, iCloud sync를 차단해야 합니다. iOS Keychain은 기본값으로 iCloud Keychain에 sync되므로 accessibility를 명시하지 않으면 토큰이 사용자의 다른 기기에도 복제됩니다.

최소 구현 (device-bound)

swift
import Foundation
import Security

enum LogiKeychain {
    private static let defaultService = "logi.refresh_token"

    static func save(_ token: String, service: String = defaultService) throws {
        let data = Data(token.utf8)

        // Delete existing item first (Keychain doesn't have native upsert)
        SecItemDelete([
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service
        ] as CFDictionary)

        let status = SecItemAdd([
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecValueData: data,
            // ⚠️ 핵심: ThisDeviceOnly로 iCloud sync 차단
            // afterFirstUnlock = 재부팅 후 첫 잠금해제 후부터 background에서도 접근 가능
            kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ] as CFDictionary, nil)

        guard status == errSecSuccess else {
            throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
        }
    }

    static func load(service: String = defaultService) -> String? {
        var item: CFTypeRef?
        let status = SecItemCopyMatching([
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ] as CFDictionary, &item)

        guard status == errSecSuccess, let data = item as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    static func delete(service: String = defaultService) {
        SecItemDelete([
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: service
        ] as CFDictionary)
    }
}

Accessibility 옵션 선택

옵션잠금 상태 접근iCloud sync권장 시나리오
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly✅ 첫 잠금해제 후권장 기본값 — background refresh 가능
kSecAttrAccessibleWhenUnlockedThisDeviceOnly민감도 최상, background refresh 포기
kSecAttrAccessibleAfterFirstUnlock⚠️ oniCloud sync 의도된 경우 (드묾)
kSecAttrAccessibleWhenUnlocked⚠️ on❌ 토큰에는 비권장

suffix ThisDeviceOnly가 없으면 iCloud Keychain에 sync됩니다. 토큰은 디바이스 바인딩이 보안 모델의 일부이므로 항상 ThisDeviceOnly 변형을 사용하세요.

민감 작업에 biometric 추가

송금·계정 삭제 같은 민감 작업에는 Face ID / Touch ID를 추가로 요구할 수 있습니다. SecAccessControlCreateWithFlags로 access control object를 만들어 kSecAttrAccessControl 키에 넘기면 됩니다:

swift
import LocalAuthentication

let accessControl = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,  // biometric은 잠금해제 상태에서만
    .biometryCurrentSet,  // 등록된 biometric이 바뀌면 키 무효화
    nil
)!

SecItemAdd([
    kSecClass: kSecClassGenericPassword,
    kSecAttrService: "logi.high_assurance_token",
    kSecValueData: data,
    kSecAttrAccessControl: accessControl
] as CFDictionary, nil)

.biometryAny는 어떤 biometric이든 허용, .biometryCurrentSet은 현재 등록된 biometric이 바뀌면 키 자체를 무효화합니다. logi에서는 high-assurance용 토큰은 .biometryCurrentSet을 권장합니다 — 탈취된 후 공격자가 자기 지문을 추가해도 키 사용 불가.

⚠️ Background refresh와 biometric은 양립 불가

biometric이 걸린 키는 사용자 인증 없이 접근할 수 없으므로, background에서 silent token refresh가 필요한 일반 토큰에는 적용하지 마세요. high-assurance 액션 직전에만 사용하세요.

Device Bootstrap (device_secret)

logi의 device bootstrap 흐름은 dual mode입니다:

  1. 첫 호출 (bootstrap): POST /api/v1/devices with {device_uuid, platform} → 응답에 device_secret 1회 노출 + PAK 발급
  2. 이후 호출 (refresh): 같은 endpoint에 {device_uuid, platform, device_secret} → 서버가 secret digest 검증 후 새 PAK 발급. secret 누락/불일치 시 401.

device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다 — 즉 secret은 anonymous 계정의 유일한 device-bound 자격증명이므로 반드시 안전 저장.

swift
// 첫 호출 응답 처리
struct BootstrapResponse: Decodable {
    let access_token: String
    let device_secret: String?  // bootstrap/legacy grace에서만 존재
}

if let secret = response.device_secret {
    try LogiKeychain.save(secret, service: "logi.device_secret")
}

// 이후 PAK 갱신 시
let secret = LogiKeychain.load(service: "logi.device_secret")!
let body = ["device_uuid": uuid, "platform": "ios", "device_secret": secret]

이 값은 위 Keychain 저장 패턴 그대로 보관하되 별도 service 식별자를 사용해 refresh token과 분리하세요:

swift
// device_secret은 별도 service로 분리
let deviceStore = LogiKeychain.self
// 권장: 두 service 식별자를 분리
//   "logi.device_secret"   → device bootstrap용
//   "logi.refresh_token"   → OAuth refresh token용

device_secret은 절대 UserDefaults에 저장하지 마세요 — 평문 plist로 백업·디바이스 간 공유될 수 있습니다.

📚 더 자세한 비교

4개 플랫폼(iOS/Android/Flutter/RN) 통합 비교는 보안 Best Practices와 각 플랫폼 가이드를 참고하세요.

MIT License · Identity가 제품의 신뢰를 만듭니다.