iOS (Swift)
ASWebAuthenticationSession + CryptoKit 으로 네이티브 OAuth + PKCE.
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)
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 | ✅ | ⚠️ on | iCloud sync 의도된 경우 (드묾) |
kSecAttrAccessibleWhenUnlocked | ❌ | ⚠️ on | ❌ 토큰에는 비권장 |
suffix ThisDeviceOnly가 없으면 iCloud Keychain에 sync됩니다. 토큰은 디바이스 바인딩이 보안 모델의 일부이므로 항상 ThisDeviceOnly 변형을 사용하세요.
민감 작업에 biometric 추가
송금·계정 삭제 같은 민감 작업에는 Face ID / Touch ID를 추가로 요구할 수 있습니다. SecAccessControlCreateWithFlags로 access control object를 만들어 kSecAttrAccessControl 키에 넘기면 됩니다:
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입니다:
- 첫 호출 (bootstrap):
POST /api/v1/deviceswith{device_uuid, platform}→ 응답에device_secret1회 노출 + PAK 발급 - 이후 호출 (refresh): 같은 endpoint에
{device_uuid, platform, device_secret}→ 서버가 secret digest 검증 후 새 PAK 발급. secret 누락/불일치 시 401.
device_secret을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다 — 즉 secret은 anonymous 계정의 유일한 device-bound 자격증명이므로 반드시 안전 저장.
// 첫 호출 응답 처리
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과 분리하세요:
// 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와 각 플랫폼 가이드를 참고하세요.