Skip to content

iOS (Swift)

Source: en/integrations/swift.md · Live: https://docs.1pass.dev/en/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 quick start (recommended)
    • L95-L140: ### Session restore, device PAK, sign-out / account deletion (v1.1.0)
  • L141-L147: ## First-Try App-to-App (Naver/Kakao pattern)
  • L148-L228: ## Manual integration (without the SDK)
  • L229-L233: ## Anti-Pattern: ASWAS-only (no first-try)
  • L234-L309: ## Refresh Token (Keychain)
    • L290-L309: ### Biometric for sensitive operations
  • L310-L330: ## Device Bootstrap (device_secret)
  • L331-L385: ## canonical_sub & linked_subs (Account Merge)
  • L386-L399: ## Troubleshooting

iOS (Swift)

⚠️ Warning: 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>

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

Associated Domains (when also using a universal link):

applinks:<your-app-host>

Add the official Swift SDK LogiAuth as an SPM dependency. On top of PKCE, state, and the app-to-app handoff, the SDK verifies the id_token signature (RS256) internally — giving public clients a defense layer without a backend. The SDK path below is the recommended route; the manual integration is a fallback for when you can't use the SDK.

swift
// Package.swift
.package(url: "https://github.com/dcode-co/logi-auth-swift.git", from: "1.1.0"),
// products: LogiAuth (core connector), LogiAuthStorage (token persistence + rotation + device PAK + revoke)
.target(name: "MyApp", dependencies: [
  .product(name: "LogiAuth", package: "logi-auth-swift"),
  .product(name: "LogiAuthStorage", package: "logi-auth-swift"), // when using persistence/PAK/revoke
]),
swift
import LogiAuth

// Once at app startup
LogiAuth.configure(LogiAuthConfig(
  clientId: "logi_xxx",
  redirectURI: URL(string: "myapp://oauth/1pass/callback")!,
  scopes: ["openid", "profile:basic", "email"]
  // issuer defaults to https://api.1pass.dev · tokenIssuer defaults to "https://api.1pass.dev" (the id_token iss claim)
))

let session = try await LogiAuth.signIn()   // -> LogiSession
// session.sub         — verified subject (after RS256 signature + iss/aud/azp/exp/nonce checks)
// session.idToken / session.accessToken / session.refreshToken / session.email
swift
// SwiftUI .onOpenURL — required for app-to-app handoff
WindowGroup {
  ContentView()
    .onOpenURL { url in _ = LogiAuth.handle(url) }
}

Methods: LogiAuth.configure(_:) / LogiAuth.signIn(scopes:) / LogiAuth.handle(_:). Token persistence and rotation live on LogiAuthStorage (persist(_:) / currentRefreshToken() / refresh() / signOut()). Errors: LogiAuthError (.userCancelled, .handoffTimeout, .alreadyInProgress, .idTokenInvalid(code:), etc.).

💡 Tip: id_token verification order (built into the SDK — identical across all SDKs) alg==RS256kid→JWKS → signature (RS256) → iss (=tokenIssuer "https://api.1pass.dev") → aud (must contain your client_id) → azp (required only when aud is multi-valued; value=client_id)exp (60s skew) → iatnonce (when requested) → subat_hash (present-only, v1.0.1). azp is not checked unconditionally — it is conditional on a multi-audience token. at_hash (v1.0.1): when the id_token carries at_hash, it is verified as base64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes]) (OIDC §3.1.3.6) to bind access_token↔id_token (rejected with atHashMismatch). signIn() wires the access_token automatically; standalone verification is verifyIdToken(idToken, jwks:, expected:, accessToken:). v1.0.1 selects the signing key with a kty=="RSA"+use/alg filter, robust to EC-key mix-in (JWKS). For a confidential integration with a backend, the server (e.g. @logi-auth/server) re-verifying is the SoT for audience checks.

Session restore, device PAK, sign-out / account deletion (v1.1.0)

Session restore (cold launch) — revive a session from the stored refresh_token without re-login. refresh()'s result is unverified (LogiAuthResult), so always promote it with LogiAuth.verify — otherwise id_token signature verification is bypassed on the refresh path.

swift
import LogiAuth
import LogiAuthStorage

let store = LogiAuthStorage(clientId: "logi_xxx")   // issuer defaults to api.1pass.dev
if store.currentRefreshToken() != nil {
  let raw = try await store.refresh()             // LogiAuthResult (unverified)
  let session = try await LogiAuth.verify(raw)     // -> verified LogiSession (RS256 + iss/aud/at_hash)
  // use session.sub / session.email
}

Device-bound PAK — the /api/v1/* endpoints require a device-bound PAK (logi_pak_...), not the OAuth JWT. Exchange the JWT for a PAK after sign-in.

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 — Bearer for subsequent /api/v1/* calls. dk.deviceRecordID (when present)

The device_secret the server returns on the first exchange is persisted idempotently to the Keychain (and reused), and concurrent calls are coalesced. When migrating an existing install to the SDK, inject the Keychain service your app already uses via keychainService: to preserve the device credential (otherwise: orphan credential + 409 risk).

Sign-out / account deletionsignOut() only wipes locally. Use revokeRefreshToken() to invalidate the server token and disconnectApp(pak:) to disconnect the RP. The caller owns the ordering — wipe locally only after the server destroy succeeds, so a failure leaves credentials to retry with.

swift
// Sign-out: revoke the server refresh_token, then wipe locally
await store.revokeRefreshToken()
store.signOut()

// Account (connection) deletion: wipe locally only if the server disconnect succeeded
if await store.disconnectApp(pak: pak) {   // true only on 2xx/404 (401/403/5xx/timeout = false)
  await store.revokeRefreshToken()
  store.signOut()
  await deviceKey.reset()                  // destroy device_uuid/secret (actor)
  // + delete the RP's own local data
} else {
  // Server disconnect failed — keep local credentials and retry (wiping locally first makes re-disconnect impossible)
}

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
AlreadyLinkedError / "already linked to another account"Keyed by pairwise sub — the same person receives a different sub per client_idSwitch person-level identification to canonicalSub. Attach existing accounts only via an explicit link on top of an authenticated session. Rails guide: account linking · Sub policy

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

최종 수정:

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