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 (
_). Usemyapp://orcom.example.myapp://✅. For the callback, prefer a custom scheme over a universal link (a host conflict withapplinks:triggers a spuriousmissingCode).
Info.plist
<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>SDK quick start (recommended)
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.
// 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
]),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// 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==RS256→kid→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) →iat→nonce(when requested) →sub→at_hash(present-only, v1.0.1).azpis not checked unconditionally — it is conditional on a multi-audience token. at_hash (v1.0.1): when the id_token carriesat_hash, it is verified asbase64url_nopad(SHA256(access_token UTF-8 bytes)[first 16 bytes])(OIDC §3.1.3.6) to bind access_token↔id_token (rejected withatHashMismatch).signIn()wires the access_token automatically; standalone verification isverifyIdToken(idToken, jwks:, expected:, accessToken:). v1.0.1 selects the signing key with akty=="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.
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.
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 deletion — signOut() 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.
// 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)
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.
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 ✅ recommended | ✅ | ❌ |
WhenUnlockedThisDeviceOnly | ❌ | ❌ |
* (no suffix) | — | ⚠️ on (❌ for tokens) |
Biometric for sensitive operations
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)
- On the first call,
POST /api/v1/deviceswith{device_uuid, platform}, and the response includesdevice_secretonce. - 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.
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)
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
}Always use effectiveSub for lookups. Keychain credentials remain valid after a merge (claims refresh on the next rotation).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Spurious missingCode error | applinks: claims the IdP host | Split it out into a custom scheme, or use SDK ≥0.1.2 |
| Scheme rejected | Contains _ | Remove the underscore |
| Background refresh fails | WhenUnlocked* or biometric applied | AfterFirstUnlockThisDeviceOnly |
| Token copied to another device | ThisDeviceOnly missing | Add the suffix |
| Device PAK 401 | device_secret missing/mismatched | Re-run bootstrap (anonymous users must re-login) |
| IdP app not entered | first-try missing | UIApplication.open(.universalLinksOnly:true) → ASWAS fallback |
AlreadyLinkedError / "already linked to another account" | Keyed by pairwise sub — the same person receives a different sub per client_id | Switch 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