Skip to content

iOS (Swift)

Custom scheme required

iOS rejects schemes containing an underscore (_). Use myapp:// or com.example.myapp:// ✅. For the callback, prefer a custom scheme over a universal link (a host conflict with applinks: triggers a spurious missingCode).

Info.plist

xml
<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 (when also using a universal link):

applinks:<your-app-host>   <!-- must not conflict with the IdP host (api.1pass.dev) -->

Add the official Swift SDK LogiAuth as an SPM dependency.

swift
// 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")]),
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
swift
// SwiftUI .onOpenURL — required for app-to-app handoff
WindowGroup {
  ContentView()
    .onOpenURL { url in _ = LogiAuth.handle(url) }
}

Methods: signIn() / handle(_:) / refresh() / signOut() / currentRefreshToken(). Errors: LogiAuthError (.userCancelled, .handoffTimeout, .alreadyInProgress, etc.).

First-Try App-to-App (Naver/Kakao pattern)

Use UIApplication.open(url, options: [.universalLinksOnly: true]) to enter the IdP app directly when it is installed, and fall back to ASWebAuthenticationSession on failure. The SDK handles this internally. Reason: per Apple TN3155, only a direct universal-link open routes to the IdP app; going through Safari always keeps the flow in the browser.

Manual integration (without the 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)

If you use ASWebAuthenticationSession alone, you always get a web view even when the IdP app is installed — degraded UX plus the in-app-browser pitfall. Always first-try UIApplication.open(.universalLinksOnly: true) → ASWAS fallback.

Refresh Token (Keychain)

The iOS Keychain default is iCloud sync. A ThisDeviceOnly variant is required.

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 ✅ recommended
WhenUnlockedThisDeviceOnly
* (no suffix)⚠️ on (❌ for tokens)

Biometric for sensitive operations

swift
let accessControl = SecAccessControlCreateWithFlags(
    nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    .biometryCurrentSet,
    nil
)!

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

.biometryCurrentSet = the key is invalidated when the enrolled biometric changes. Not usable for background refresh — only right before a high-assurance action.

Device Bootstrap (device_secret)

  1. On the first call, POST /api/v1/devices with {device_uuid, platform}, and the response includes device_secret once.
  2. On later calls, send {device_uuid, platform, device_secret}; the server verifies it and returns a new PAK. A missing or mismatched secret returns 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]

⚠️ Do not store it in UserDefaults. Use a service identifier separate from the refresh_token.

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
}

Always use effectiveSub for lookups. Keychain credentials remain valid after a merge (claims refresh on the next rotation).

Troubleshooting

SymptomCauseFix
Spurious missingCode errorapplinks: claims the IdP hostSplit it out into a custom scheme, or use SDK ≥0.1.2
Scheme rejectedContains _Remove the underscore
Background refresh failsWhenUnlocked* or biometric appliedAfterFirstUnlockThisDeviceOnly
Token copied to another deviceThisDeviceOnly missingAdd the suffix
Device PAK 401device_secret missing/mismatchedRe-run bootstrap (anonymous users must re-login)
IdP app not enteredfirst-try missingUIApplication.open(.universalLinksOnly:true) → ASWAS fallback

Diagnostics: https://api.1pass.dev/diagnose

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