iOS (Swift)
Custom scheme 필수
Underscore (_) 포함 scheme iOS 거부. myapp:// 또는 com.example.myapp:// ✅. 콜백은 universal link 가 아닌 custom scheme 권장 (applinks: 호스트 충돌 시 가짜 missingCode 발생).
Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>com.example.myapp</string></array>
</dict>
</array>
<!-- first-try app-to-app handoff probing -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>onepass</string>
<string>https</string>
</array>Associated Domains (universal link 병행 시):
applinks:<your-app-host> <!-- IdP 호스트(api.1pass.dev)와 충돌 금지 -->SDK 빠른 시작 (권장)
공식 Swift SDK LogiAuth 를 SPM 의존성으로 추가합니다.
// Package.swift
.package(url: "https://github.com/dcode-co/logi-auth-swift.git", from: "0.2.0"),
.target(name: "MyApp", dependencies: [.product(name: "LogiAuth", package: "logi-auth-swift")]),import LogiAuth
LogiAuth.configure(LogiAuthConfig(
clientId: "logi_xxx",
redirectURI: URL(string: "myapp://oauth/1pass/callback")!,
scopes: ["openid", "profile:basic", "email"]
))
let result = try await LogiAuth.signIn()
// result.idToken / result.accessToken / result.refreshToken// SwiftUI .onOpenURL — required for app-to-app handoff
WindowGroup {
ContentView()
.onOpenURL { url in _ = LogiAuth.handle(url) }
}메서드: signIn() / handle(_:) / refresh() / signOut() / currentRefreshToken(). 에러: LogiAuthError (.userCancelled, .handoffTimeout, .alreadyInProgress 등).
First-Try App-to-App (Naver/Kakao 패턴)
UIApplication.open(url, options: [.universalLinksOnly: true]) 로 IdP 앱 설치 시 직접 진입, 실패 시 ASWebAuthenticationSession fallback. SDK 가 내장 처리. WHY: Apple TN3155 — universal link 직접 open 만 IdP 앱으로 라우팅, Safari 경유는 항상 웹.
수동 통합 (SDK 미사용)
import AuthenticationServices
import CryptoKit
final class LogiOAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
static let shared = LogiOAuth()
let base = "https://api.1pass.dev"
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: "openid profile:basic 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 if let nserr = err as NSError?,
nserr.domain == ASWebAuthenticationSessionError.errorDomain,
nserr.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
cont.resume(throwing: AuthError.userCancelled)
} else {
cont.resume(throwing: err ?? AuthError.userCancelled)
}
}
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)",
].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: "")
}
}Anti-Pattern: ASWAS-only (no first-try)
ASWebAuthenticationSession 만 쓰면 IdP 앱이 설치돼 있어도 항상 웹뷰 — UX 저하 + 인앱 브라우저 함정. 반드시 first-try UIApplication.open(.universalLinksOnly: true) → ASWAS fallback.
Refresh Token (Keychain)
iOS Keychain 기본값은 iCloud sync. ThisDeviceOnly 변형 필수.
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)
SecItemDelete([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service
] as CFDictionary)
let status = SecItemAdd([
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecValueData: data,
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 | Background refresh | iCloud sync |
|---|---|---|
AfterFirstUnlockThisDeviceOnly ✅ 권장 | ✅ | ❌ |
WhenUnlockedThisDeviceOnly | ❌ | ❌ |
* (suffix 없음) | — | ⚠️ on (토큰엔 ❌) |
민감 작업 biometric
let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet,
nil
)!
SecItemAdd([
kSecClass: kSecClassGenericPassword,
kSecAttrService: "logi.high_assurance_token",
kSecValueData: data,
kSecAttrAccessControl: accessControl
] as CFDictionary, nil).biometryCurrentSet = 등록 biometric 변경 시 키 무효화. Background refresh 불가 — high-assurance 직전에만.
Device Bootstrap (device_secret)
- 첫 호출:
POST /api/v1/devices{device_uuid, platform}→ 응답device_secret1회 노출 - 이후:
{device_uuid, platform, device_secret}→ 검증 후 새 PAK. secret 누락/불일치 = 401
struct BootstrapResponse: Decodable {
let access_token: String
let device_secret: String?
}
if let secret = response.device_secret {
try LogiKeychain.save(secret, service: "logi.device_secret")
}
let secret = LogiKeychain.load(service: "logi.device_secret")!
let body = ["device_uuid": uuid, "platform": "ios", "device_secret": secret]⚠️ UserDefaults 저장 금지. refresh_token 과 별도 service 식별자 사용.
canonical_sub & linked_subs (Account Merge)
struct LogiClaims: Decodable {
let sub: String
let canonicalSub: String?
let isCanonical: Bool?
let linkedSubs: [String]?
let previouslyAnonymous: Bool?
let email: String?
let emailVerified: Bool?
let anonymous: Bool?
enum CodingKeys: String, CodingKey {
case sub
case canonicalSub = "canonical_sub"
case isCanonical = "is_canonical"
case linkedSubs = "linked_subs"
case previouslyAnonymous = "previously_anonymous"
case email
case emailVerified = "email_verified"
case anonymous
}
var effectiveSub: String { canonicalSub ?? sub }
}func handleLogiSession(_ claims: LogiClaims) {
let canonical = claims.effectiveSub
if let currentLocal = localStore.userId, currentLocal != canonical {
localStore.migrateUser(from: currentLocal, to: canonical)
}
localStore.userId = canonical
if claims.previouslyAnonymous == true {
localStore.previouslyAnonymous = true
}
}
if claims.isCanonical == true, let linked = claims.linkedSubs, !linked.isEmpty {
for absorbed in linked {
localStore.reassignData(from: absorbed, to: claims.sub)
}
}
if claims.anonymous == false, localStore.wasAnonymous {
syncEngine.runPromotionUpload()
localStore.wasAnonymous = false
}Lookup 시 항상 effectiveSub 사용. Keychain 자격증명은 통합 후에도 유효 (다음 회전 시 claims 갱신).
트러블슈팅
| 증상 | 원인 | 처방 |
|---|---|---|
missingCode 가짜 에러 | applinks: 가 IdP 호스트 클레임 | custom scheme 로 분리 또는 SDK ≥0.1.2 |
| Scheme 거부 | _ 포함 | underscore 제거 |
| Background refresh 실패 | WhenUnlocked* 또는 biometric 적용 | AfterFirstUnlockThisDeviceOnly |
| 토큰 다른 기기에 복제 | ThisDeviceOnly 누락 | suffix 추가 |
| Device PAK 401 | device_secret 누락/불일치 | bootstrap 재실행 (anonymous 는 재로그인) |
| IdP 앱 미진입 | first-try 누락 | UIApplication.open(.universalLinksOnly:true) → ASWAS fallback |