Skip to content

iOS (Swift)

Source: integrations/swift.md · Live: https://docs.1pass.dev/integrations/swift LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).

📍 Jump-to Index

  • L28-L50: ## Info.plist
  • L51-L140: ## SDK 빠른 시작 (권장)
    • L95-L140: ### 세션 복원 · device PAK · 로그아웃/계정삭제 (v1.1.0)
  • L141-L146: ## First-Try App-to-App (Naver/Kakao 패턴)
  • L147-L227: ## 수동 통합 (SDK 미사용)
  • L228-L232: ## Anti-Pattern: ASWAS-only (no first-try)
  • L233-L308: ## Refresh Token (Keychain)
    • L289-L308: ### 민감 작업 biometric
  • L309-L329: ## Device Bootstrap (device_secret)
  • L330-L384: ## canonical_sub & linked_subs (Account Merge)
  • L385-L398: ## 트러블슈팅

iOS (Swift)

⚠️ Warning: Custom scheme 필수 Underscore (_) 포함 scheme iOS 거부. myapp:// 또는 com.example.myapp:// ✅. 콜백은 universal link 가 아닌 custom scheme 권장 (applinks: 호스트 충돌 시 가짜 missingCode 발생).

Info.plist

xml
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>com.example.myapp</string></array>
  </dict>
</array>

<key>LSApplicationQueriesSchemes</key>
<array>
  <string>onepass</string>
  <string>https</string>
</array>

Associated Domains (universal link 병행 시):

applinks:<your-app-host>

SDK 빠른 시작 (권장)

공식 Swift SDK LogiAuth 를 SPM 의존성으로 추가합니다. SDK 는 PKCE·state·app-to-app 핸드오프에 더해 id_token 서명 검증(RS256)을 내장 — 별도 백엔드 없이 public client 방어층을 제공합니다. 아래 SDK 사용이 권장 경로이고, 수동 통합은 SDK 를 못 쓰는 경우의 fallback 입니다.

swift
// Package.swift
.package(url: "https://github.com/dcode-co/logi-auth-swift.git", from: "1.1.0"),
// products: LogiAuth (핵심 커넥터), LogiAuthStorage (토큰 영속화·회전·device PAK·revoke)
.target(name: "MyApp", dependencies: [
  .product(name: "LogiAuth", package: "logi-auth-swift"),
  .product(name: "LogiAuthStorage", package: "logi-auth-swift"), // 영속화/PAK/revoke 사용 시
]),
swift
import LogiAuth

// 앱 시작 시 1회
LogiAuth.configure(LogiAuthConfig(
  clientId: "logi_xxx",
  redirectURI: URL(string: "myapp://oauth/1pass/callback")!,
  scopes: ["openid", "profile:basic", "email"]
  // issuer 기본값 https://api.1pass.dev · tokenIssuer 기본값 "https://api.1pass.dev" (id_token 의 iss claim)
))

let session = try await LogiAuth.signIn()   // -> LogiSession
// session.sub         — 검증 완료된 subject (RS256 서명 + iss/aud/azp/exp/nonce 검증 후)
// session.idToken / session.accessToken / session.refreshToken / session.email
swift
// SwiftUI .onOpenURL — app-to-app 핸드오프에 필수
WindowGroup {
  ContentView()
    .onOpenURL { url in _ = LogiAuth.handle(url) }
}

메서드: LogiAuth.configure(_:) / LogiAuth.signIn(scopes:) / LogiAuth.handle(_:). 토큰 영속화·회전은 LogiAuthStorage (persist(_:) / currentRefreshToken() / refresh() / signOut()). 에러: LogiAuthError (.userCancelled, .handoffTimeout, .alreadyInProgress, .idTokenInvalid(code:) 등).

💡 Tip: id_token 검증 순서 (SDK 내장 — 전 SDK 동일) alg==RS256kid→JWKS → 서명(RS256) → iss(=tokenIssuer "https://api.1pass.dev") → aud(내 client_id 포함) → azp(aud 가 복수일 때만 필수, 값=client_id)exp(skew 60s) → iatnonce(요청 시) → subat_hash(present-only, v1.0.1). azp 는 무조건 검증이 아니라 멀티 aud 조건부입니다. at_hash (v1.0.1): id_token 에 at_hash 가 있으면 base64url_nopad(SHA256(access_token 의 UTF-8 바이트)[처음 16바이트])(OIDC §3.1.3.6)로 access_token↔id_token 바인딩을 검증(불일치 시 atHashMismatch). signIn() 은 access_token 을 자동 배선하며, 스탠드얼론 검증은 verifyIdToken(idToken, jwks:, expected:, accessToken:). v1.0.1 은 JWKS 에서 kty=="RSA"+use/alg 필터로 서명 키를 선택해 EC 키 혼입에 견고합니다(JWKS). backend 가 있는 confidential 통합이면 서버(@logi-auth/server 등)가 재검증하는 것이 audience 검증의 SoT.

세션 복원 · device PAK · 로그아웃/계정삭제 (v1.1.0)

세션 복원 (cold-launch) — 저장된 refresh_token 으로 재로그인 없이 세션을 되살립니다. refresh() 결과는 미검증(LogiAuthResult)이므로 반드시 LogiAuth.verify 로 승격하세요 — 안 그러면 refresh 경로에서 id_token 서명검증이 뚫립니다.

swift
import LogiAuth
import LogiAuthStorage

let store = LogiAuthStorage(clientId: "logi_xxx")   // issuer 기본값 api.1pass.dev
if store.currentRefreshToken() != nil {
  let raw = try await store.refresh()             // LogiAuthResult (미검증)
  let session = try await LogiAuth.verify(raw)     // -> 검증된 LogiSession (RS256+iss/aud/at_hash)
  // session.sub / session.email 사용
}

device-bound PAK/api/v1/* 서버 엔드포인트는 OAuth JWT 가 아니라 device-bound PAK(logi_pak_...)를 요구합니다. 로그인 후 JWT 를 PAK 로 교환합니다.

swift
import LogiAuthStorage

let deviceKey = LogiDeviceKey(issuer: URL(string: "https://api.1pass.dev")!, clientId: "logi_xxx")
let dk = try await deviceKey.exchange(oauthJWT: session.accessToken)
// dk.pak — 이후 /api/v1/* 호출의 Bearer. dk.deviceRecordID (있으면)

첫 교환에서 서버가 준 device_secret 을 Keychain 에 멱등 영속(재사용)하고 동시 호출은 병합됩니다. 기존 설치를 SDK 로 이관할 땐 앱이 쓰던 Keychain service 를 keychainService: 로 주입해 device 자격을 보존하세요(누락 시 orphan credential + 409 위험).

로그아웃 / 계정 삭제signOut() 은 로컬만 지웁니다. 서버 토큰 무효화는 revokeRefreshToken(), RP 연동 해지는 disconnectApp(pak:). 순서는 호출자가 정합니다 — 서버 파기가 성공한 뒤 로컬을 지워야, 실패 시 재시도할 자격이 남습니다.

swift
// 로그아웃: 서버 refresh_token revoke → 로컬 wipe
await store.revokeRefreshToken()
store.signOut()

// 계정(연동) 삭제: 서버 연동 해지가 성공했을 때만 로컬 파기
if await store.disconnectApp(pak: pak) {   // 2xx·404 만 true (401/403/5xx/timeout=false)
  await store.revokeRefreshToken()
  store.signOut()
  await deviceKey.reset()                  // device_uuid/secret 파기 (actor)
  // + RP 자체 로컬 데이터 삭제
} else {
  // 서버 해지 실패 — 로컬 자격 보존하고 재시도 (로컬만 지우면 재해지 불가)
}

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 미사용)

swift
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 변형 필수.

swift
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)
    }
}
AccessibilityBackground refreshiCloud sync
AfterFirstUnlockThisDeviceOnly ✅ 권장
WhenUnlockedThisDeviceOnly
* (suffix 없음)⚠️ on (토큰엔 ❌)

민감 작업 biometric

swift
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)

  1. 첫 호출: POST /api/v1/devices {device_uuid, platform} → 응답 device_secret 1회 노출
  2. 이후: {device_uuid, platform, device_secret} → 검증 후 새 PAK. secret 누락/불일치 = 401
swift
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)

swift
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 }
}
swift
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 401device_secret 누락/불일치bootstrap 재실행 (anonymous 는 재로그인)
IdP 앱 미진입first-try 누락UIApplication.open(.universalLinksOnly:true) → ASWAS fallback
AlreadyLinkedError / "이미 다른 계정과 연결"pairwise sub 로 키잉 — 같은 사람이 client_id 별로 다른 sub 을 받음사람 단위 식별을 canonicalSub 로 전환. 기존 계정은 로그인된 세션 위 명시적 link 로만 연결. Rails 가이드: account linking · Sub 정책

진단: https://api.1pass.dev/diagnose

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