diff --git a/Examples/Quickstart/Quickstart/Info.plist b/Examples/Quickstart/Quickstart/Info.plist
index 3ea072d31..17fa4f8ae 100644
--- a/Examples/Quickstart/Quickstart/Info.plist
+++ b/Examples/Quickstart/Quickstart/Info.plist
@@ -15,6 +15,8 @@
+ NSFaceIDUsageDescription
+ Quickstart uses Face ID to sign you in.
NSLocalNetworkUsageDescription
Atlantis uses Bonjour Service to discover the Proxyman app on your local network.
NSBonjourServices
@@ -23,4 +25,3 @@
-
diff --git a/Sources/ClerkKit/Core/Auth.swift b/Sources/ClerkKit/Core/Auth.swift
index 7ca5bd0da..f956ada7b 100644
--- a/Sources/ClerkKit/Core/Auth.swift
+++ b/Sources/ClerkKit/Core/Auth.swift
@@ -19,6 +19,7 @@ public struct Auth {
private let signInService: SignInServiceProtocol
private let signUpService: SignUpServiceProtocol
private let sessionService: SessionServiceProtocol
+ private let trustedDevices: TrustedDevices
private let eventEmitter: EventEmitter
private let urlHandlingCoordinator: URLHandlingCoordinator
@@ -28,6 +29,7 @@ public struct Auth {
signInService: SignInServiceProtocol,
signUpService: SignUpServiceProtocol,
sessionService: SessionServiceProtocol,
+ trustedDevices: TrustedDevices,
eventEmitter: EventEmitter,
urlHandlingCoordinator: URLHandlingCoordinator
) {
@@ -36,6 +38,7 @@ public struct Auth {
self.signInService = signInService
self.signUpService = signUpService
self.sessionService = sessionService
+ self.trustedDevices = trustedDevices
self.eventEmitter = eventEmitter
self.urlHandlingCoordinator = urlHandlingCoordinator
}
@@ -305,6 +308,26 @@ public struct Auth {
}
#endif
+ /// Signs in with a locally enrolled trusted-device credential.
+ ///
+ /// The trusted-device domain owns local credential selection, key access, challenge signing,
+ /// and stale local credential cleanup.
+ ///
+ /// - Parameters:
+ /// - id: The trusted-device credential ID to use. When omitted, the available local credential is used.
+ /// - identifierHint: A local-only user identifier hint used to choose a matching credential.
+ /// - reason: The reason shown in the system biometric prompt.
+ /// - Returns: A `SignIn` object representing the trusted-device sign-in attempt.
+ /// - Throws: An error if trusted-device sign-in fails.
+ @discardableResult
+ public func signInWithTrustedDevice(
+ id: String? = nil,
+ identifierHint: String? = nil,
+ reason: String? = nil
+ ) async throws -> SignIn {
+ try await trustedDevices.signIn(id: id, identifierHint: identifierHint, reason: reason)
+ }
+
#if !os(tvOS) && !os(watchOS)
/// Starts Enterprise SSO and returns the prepared sign-in state.
///
diff --git a/Sources/ClerkKit/Core/AuthFlowRegistration.swift b/Sources/ClerkKit/Core/AuthFlowRegistration.swift
new file mode 100644
index 000000000..6cb9cd318
--- /dev/null
+++ b/Sources/ClerkKit/Core/AuthFlowRegistration.swift
@@ -0,0 +1,21 @@
+//
+// AuthFlowRegistration.swift
+// Clerk
+//
+
+import Foundation
+
+package final class AuthFlowRegistration: Sendable {
+ private let unregister: @MainActor @Sendable () -> Void
+
+ package init(unregister: @escaping @MainActor @Sendable () -> Void) {
+ self.unregister = unregister
+ }
+
+ deinit {
+ let unregister = unregister
+ Task { @MainActor in
+ unregister()
+ }
+ }
+}
diff --git a/Sources/ClerkKit/Core/Clerk+Installation.swift b/Sources/ClerkKit/Core/Clerk+Installation.swift
new file mode 100644
index 000000000..06e4c233f
--- /dev/null
+++ b/Sources/ClerkKit/Core/Clerk+Installation.swift
@@ -0,0 +1,58 @@
+//
+// Clerk+Installation.swift
+// Clerk
+//
+
+import Foundation
+
+extension Clerk {
+ @MainActor
+ package static var installationMarkerUserDefaults: UserDefaults = .standard
+ @MainActor
+ package static var trustedDeviceAppIdentifierProvider: () -> String? = {
+ Bundle.main.bundleIdentifier
+ }
+
+ private static let trustedDeviceInstallationMarkerPrefix = "com.clerk.trusted-device-installation-marker"
+
+ @MainActor
+ package func reconcileTrustedDeviceCredentialsForCurrentInstallation() {
+ guard let appIdentifier = Self.trustedDeviceAppIdentifierProvider() else {
+ return
+ }
+
+ let markerKey = Self.trustedDeviceInstallationMarkerKey(
+ for: options.keychainConfig,
+ appIdentifier: appIdentifier
+ )
+ guard Self.installationMarkerUserDefaults.object(forKey: markerKey) as? Bool != true else {
+ return
+ }
+
+ do {
+ try dependencies.trustedDeviceCredentialStore
+ .deleteLocalCredentials(
+ appIdentifier: appIdentifier,
+ keyManager: dependencies.trustedDeviceKeyManager
+ )
+ Self.installationMarkerUserDefaults.set(true, forKey: markerKey)
+ } catch {
+ ClerkLogger.logError(
+ error,
+ message: "Failed to clear trusted-device local credentials for a new app installation."
+ )
+ }
+ }
+
+ private static func trustedDeviceInstallationMarkerKey(
+ for keychainConfig: Options.KeychainConfig,
+ appIdentifier: String
+ ) -> String {
+ [
+ trustedDeviceInstallationMarkerPrefix,
+ keychainConfig.service,
+ keychainConfig.accessGroup ?? "default",
+ appIdentifier,
+ ].joined(separator: ".")
+ }
+}
diff --git a/Sources/ClerkKit/Core/Clerk.swift b/Sources/ClerkKit/Core/Clerk.swift
index 92bdbd54b..9c193e332 100644
--- a/Sources/ClerkKit/Core/Clerk.swift
+++ b/Sources/ClerkKit/Core/Clerk.swift
@@ -101,6 +101,20 @@ public final class Clerk {
session?.user
}
+ /// Whether Clerk currently has a user-backed active session.
+ private var hasActiveUserSession: Bool {
+ user != nil && session?.status == .active
+ }
+
+ /// Whether authentication and any Clerk-owned post-authentication steps are complete.
+ ///
+ /// Use this value when choosing between a root authentication view and authenticated content.
+ /// It becomes `true` when there is a current user, the current session is active, and a
+ /// non-dismissible `AuthView` is no longer completing a post-authentication step.
+ public var isAuthFlowComplete: Bool {
+ hasActiveUserSession && !isAuthFlowPending
+ }
+
/// The current user's membership in the active organization.
public var organizationMembership: OrganizationMembership? {
guard let activeOrganizationId = session?.lastActiveOrganizationId else {
@@ -210,6 +224,34 @@ public final class Clerk {
/// Callback-scoped auth continuation used internally by `AuthView` to resume recovered flows.
package private(set) var callbackContinuation: TransferFlowResult?
+ /// The non-dismissible auth view currently managing authentication.
+ private var authFlowRegistrationId: UUID?
+
+ /// Whether that auth view is completing Clerk-owned post-authentication work.
+ private var isAuthFlowPending = false
+
+ /// The completed authentication result awaiting that view's post-authentication work.
+ package private(set) var pendingAuthFlowCompletion: TransferFlowResult?
+
+ /// The pending completion once its user-backed session is current and ready for post-auth work.
+ package var readyPendingAuthFlowCompletion: TransferFlowResult? {
+ guard let pendingAuthFlowCompletion else { return nil }
+
+ guard let createdSessionId = pendingAuthFlowCompletion.createdSessionId else {
+ return pendingAuthFlowCompletion
+ }
+
+ guard let session,
+ session.user != nil,
+ session.status == .active || session.status == .pending,
+ createdSessionId == session.id
+ else {
+ return nil
+ }
+
+ return pendingAuthFlowCompletion
+ }
+
/// The main entry point for all authentication operations.
///
/// Use this property to perform sign in, sign up, and session management operations.
@@ -221,6 +263,7 @@ public final class Clerk {
signInService: dependencies.signInService,
signUpService: dependencies.signUpService,
sessionService: dependencies.sessionService,
+ trustedDevices: trustedDevices,
eventEmitter: authEventEmitter,
urlHandlingCoordinator: urlHandlingCoordinator
)
@@ -230,6 +273,46 @@ public final class Clerk {
callbackContinuation = result
}
+ package func registerAuthFlow() -> AuthFlowRegistration? {
+ guard !hasActiveUserSession else { return nil }
+
+ let registrationId = UUID()
+ authFlowRegistrationId = registrationId
+ isAuthFlowPending = session?.status == .pending
+
+ return AuthFlowRegistration { [weak self] in
+ self?.unregisterAuthFlow(registrationId: registrationId)
+ }
+ }
+
+ package func markAuthFlowPending() {
+ guard authFlowRegistrationId != nil else { return }
+ isAuthFlowPending = true
+ }
+
+ package func markAuthFlowComplete() {
+ guard authFlowRegistrationId != nil else { return }
+ pendingAuthFlowCompletion = nil
+ isAuthFlowPending = false
+ }
+
+ package func consumePendingAuthFlowCompletion() {
+ pendingAuthFlowCompletion = nil
+ }
+
+ private func holdAuthFlowCompletion(_ result: TransferFlowResult) {
+ guard authFlowRegistrationId != nil else { return }
+ isAuthFlowPending = true
+ pendingAuthFlowCompletion = result
+ }
+
+ private func unregisterAuthFlow(registrationId: UUID) {
+ guard authFlowRegistrationId == registrationId else { return }
+ authFlowRegistrationId = nil
+ pendingAuthFlowCompletion = nil
+ isAuthFlowPending = false
+ }
+
/// The main entry point for organization operations.
///
/// Use this property to create organizations.
@@ -237,6 +320,16 @@ public final class Clerk {
Organizations(organizationService: dependencies.organizationService)
}
+ /// The main entry point for trusted-device credential operations.
+ public var trustedDevices: TrustedDevices {
+ TrustedDevices(
+ trustedDeviceService: dependencies.trustedDeviceService,
+ signInService: dependencies.signInService,
+ keyManager: dependencies.trustedDeviceKeyManager,
+ credentialStore: dependencies.trustedDeviceCredentialStore
+ )
+ }
+
/// Proxy configuration derived from `proxyUrl`, if present.
var proxyConfiguration: ProxyConfiguration? {
dependencies.configurationManager.proxyConfiguration
@@ -290,6 +383,7 @@ extension Clerk {
taskCoordinator = TaskCoordinator()
self.dependencies = dependencies
+ reconcileTrustedDeviceCredentialsForCurrentInstallation()
// Set up session polling and lifecycle management
sessionPollingManager = SessionPollingManager(
@@ -583,6 +677,8 @@ extension Clerk {
await SessionTokenFetcher.shared.reset()
await SessionTokensCache.shared.clear()
+ pendingAuthFlowCompletion = nil
+ isAuthFlowPending = false
client = nil
environment = nil
sessionsByUserId = [:]
@@ -771,7 +867,8 @@ extension Clerk {
_ incoming: Client?,
responseSequence: Int? = nil,
serverDate: Date? = nil,
- clientResponseGeneration: ClientResponseGeneration? = nil
+ clientResponseGeneration: ClientResponseGeneration? = nil,
+ completedAuthFlow: TransferFlowResult? = nil
) {
if let clientResponseGeneration, clientResponseGeneration != self.clientResponseGeneration {
ClerkLogger.debug(
@@ -797,6 +894,9 @@ extension Clerk {
if let serverDate {
lastClientServerFetchDate = serverDate
}
+ if let completedAuthFlow {
+ holdAuthFlowCompletion(completedAuthFlow)
+ }
client = incoming
}
diff --git a/Sources/ClerkKit/Core/TrustedDevices.swift b/Sources/ClerkKit/Core/TrustedDevices.swift
new file mode 100644
index 000000000..5b88dac7d
--- /dev/null
+++ b/Sources/ClerkKit/Core/TrustedDevices.swift
@@ -0,0 +1,608 @@
+//
+// TrustedDevices.swift
+// Clerk
+//
+
+import Foundation
+
+/// The main entry point for trusted-device credential operations.
+@MainActor
+public struct TrustedDevices {
+ private let trustedDeviceService: TrustedDeviceServiceProtocol
+ private let signInService: SignInServiceProtocol
+ private let keyManager: any TrustedDeviceKeyManagerProtocol
+ private let credentialStore: any TrustedDeviceLocalCredentialStoreProtocol
+ private let appIdentifierProvider: @MainActor @Sendable () -> String?
+
+ init(
+ trustedDeviceService: TrustedDeviceServiceProtocol,
+ signInService: SignInServiceProtocol,
+ keyManager: any TrustedDeviceKeyManagerProtocol,
+ credentialStore: any TrustedDeviceLocalCredentialStoreProtocol,
+ appIdentifierProvider: @escaping @MainActor @Sendable () -> String? = {
+ Bundle.main.bundleIdentifier
+ }
+ ) {
+ self.trustedDeviceService = trustedDeviceService
+ self.signInService = signInService
+ self.keyManager = keyManager
+ self.credentialStore = credentialStore
+ self.appIdentifierProvider = appIdentifierProvider
+ }
+
+ /// Lists active trusted-device credentials for the signed-in user.
+ public func list() async throws -> [TrustedDevice] {
+ try await trustedDeviceService.list()
+ }
+
+ /// Returns local trusted-device sign-in availability.
+ ///
+ /// When a Clerk session is active, this also reconciles the local credential with the server.
+ /// Without an active session, this reports whether the local biometric-gated credential can
+ /// be used to start trusted-device sign-in.
+ public func availability(
+ id: String? = nil,
+ identifierHint: String? = nil
+ ) async throws -> TrustedDeviceAvailability {
+ switch try await selectedLocalCredential(id: id, identifierHint: identifierHint, userID: nil) {
+ case .available:
+ .available
+ case let .unavailable(reason):
+ .unavailable(reason)
+ }
+ }
+
+ package func currentUserAvailability() async throws -> TrustedDeviceAvailability {
+ guard let userID = Clerk.shared.user?.id else {
+ return .unavailable(.noLocalCredential)
+ }
+
+ switch try await selectedLocalCredential(id: nil, identifierHint: nil, userID: userID) {
+ case .available:
+ return .available
+ case let .unavailable(reason):
+ return .unavailable(reason)
+ }
+ }
+
+ /// Returns local trusted-device sign-in availability without reconciling with the server.
+ package func localAvailability(
+ id: String? = nil,
+ identifierHint: String? = nil
+ ) throws -> TrustedDeviceAvailability {
+ switch try localCredentialCandidates(id: id, identifierHint: identifierHint, userID: nil) {
+ case .available:
+ .available
+ case let .unavailable(reason):
+ .unavailable(reason)
+ }
+ }
+
+ package func currentUserLocalAvailability() throws -> TrustedDeviceAvailability {
+ guard let userID = Clerk.shared.user?.id else {
+ return .unavailable(.noLocalCredential)
+ }
+
+ switch try localCredentialCandidates(id: nil, identifierHint: nil, userID: userID) {
+ case .available:
+ return .available
+ case let .unavailable(reason):
+ return .unavailable(reason)
+ }
+ }
+
+ /// Enrolls the current app installation as a biometric trusted device.
+ ///
+ /// This requires an active or pending Clerk session. The generated private key stays on the device.
+ /// - Parameters:
+ /// - deviceName: A human-readable device name stored with the trusted-device credential.
+ /// - identifierHint: A local-only user identifier hint for selecting this credential later.
+ /// - reason: The reason shown in the system biometric prompt.
+ /// - policy: The local authentication policy used to protect the generated private key.
+ /// Defaults to requiring biometric availability while allowing device passcode fallback during authentication.
+ @discardableResult
+ public func enroll(
+ deviceName: String? = nil,
+ identifierHint: String? = nil,
+ reason: String? = nil,
+ policy: TrustedDevicePolicy = .biometryOrDevicePasscode
+ ) async throws -> TrustedDevice {
+ guard Clerk.shared.session?.status.allowsTrustedDeviceEnrollment == true else {
+ throw ClerkClientError(message: "Unable to enroll a trusted device without an active or pending Clerk session.")
+ }
+ try ensureTrustedDeviceFeatureEnabled()
+
+ guard let appIdentifier = appIdentifierProvider() else {
+ throw ClerkClientError(message: "Unable to enroll a trusted device without a bundle identifier.")
+ }
+ guard let userID = Clerk.shared.session?.user?.id else {
+ throw ClerkClientError(message: "Unable to enroll a trusted device without a user for the current session.")
+ }
+
+ let localKey = try keyManager.createKey(policy: policy)
+ do {
+ let challenge = try await trustedDeviceService.prepareEnrollment(params: .init(
+ appIdentifier: appIdentifier,
+ name: deviceName,
+ publicKeyJWK: localKey.publicKeyJWK
+ ))
+ let signature = try keyManager.sign(
+ clientData: challenge.clientData,
+ localKeyId: localKey.localKeyId,
+ localizedReason: reason ?? "Use biometrics to enroll this device."
+ )
+ let trustedDevice = try await trustedDeviceService.attemptEnrollment(params: .init(
+ appIdentifier: appIdentifier,
+ name: deviceName,
+ publicKeyJWK: localKey.publicKeyJWK,
+ clientData: signature.clientData,
+ signature: signature.signature
+ ))
+ try await saveLocalCredential(
+ trustedDevice: trustedDevice,
+ localKey: localKey,
+ userID: userID,
+ identifierHint: identifierHint
+ )
+ removeOtherLocalCredentialsForCurrentApp(keeping: trustedDevice)
+ return trustedDevice
+ } catch {
+ try? keyManager.deleteKey(localKeyId: localKey.localKeyId)
+ throw error
+ }
+ }
+
+ /// Revokes a trusted-device credential for the signed-in user.
+ @discardableResult
+ public func revoke(id: String) async throws -> TrustedDevice {
+ let trustedDevice = try await trustedDeviceService.revoke(trustedDeviceId: id)
+ do {
+ if let localCredential = try credentialStore.credential(id: id) {
+ try deleteLocalCredential(localCredential)
+ }
+ } catch {
+ ClerkLogger.logError(
+ error,
+ message: "Failed to delete local trusted-device credential after server revocation. This is non-critical."
+ )
+ }
+ return trustedDevice
+ }
+
+ /// Revokes the available local trusted-device credential for the current signed-in user.
+ @discardableResult
+ package func revokeCurrentDeviceCredential() async throws -> TrustedDevice? {
+ guard Clerk.shared.session?.status.allowsTrustedDeviceEnrollment == true else {
+ throw ClerkClientError(message: "Unable to revoke a trusted device without an active or pending Clerk session.")
+ }
+ guard let userID = Clerk.shared.user?.id else {
+ return nil
+ }
+
+ switch try await selectedLocalCredential(id: nil, identifierHint: nil, userID: userID) {
+ case let .available(localCredential):
+ return try await revoke(id: localCredential.id)
+ case .unavailable:
+ return nil
+ }
+ }
+
+ @discardableResult
+ package func forgetLocalCredentials(deletedUserID: String) throws -> Int {
+ try forgetLocalCredentialsForCurrentApp(userID: deletedUserID)
+ }
+
+ private func forgetLocalCredentialsForCurrentApp(userID: String) throws -> Int {
+ let credentials = try storedLocalCredentialsForCurrentApp().filter { $0.userID == userID }
+
+ for credential in credentials {
+ try deleteLocalCredential(credential)
+ }
+
+ return credentials.count
+ }
+
+ /// Signs in with a locally enrolled biometric trusted-device credential.
+ ///
+ /// - Parameters:
+ /// - id: The trusted-device credential ID to use. When omitted, the available local credential is used. If
+ /// legacy local state contains multiple credentials for this app installation, the newest supported
+ /// credential is used.
+ /// - identifierHint: A local-only user identifier hint used to choose a matching credential.
+ /// - reason: The reason shown in the system biometric prompt.
+ @discardableResult
+ package func signIn(
+ id: String? = nil,
+ identifierHint: String? = nil,
+ reason: String? = nil
+ ) async throws -> SignIn {
+ let localCredential: TrustedDeviceLocalCredential
+ switch try await selectedLocalCredential(id: id, identifierHint: identifierHint, userID: nil) {
+ case let .available(credential):
+ localCredential = credential
+ case .unavailable:
+ throw ClerkClientError(
+ message: "Trusted-device sign-in is unavailable."
+ )
+ }
+ let trustedDeviceId = localCredential.id
+
+ let signIn: SignIn
+ do {
+ signIn = try await signInService.create(params: .init(
+ strategy: .trustedDevice,
+ trustedDeviceId: trustedDeviceId
+ ))
+ } catch {
+ throw handleTrustedDeviceSignInError(error, localCredential: localCredential)
+ }
+
+ let challenge = try trustedDeviceChallenge(from: signIn)
+ let signature = try keyManager.sign(
+ clientData: challenge.clientData,
+ localKeyId: localCredential.localKeyId,
+ localizedReason: reason ?? "Use biometrics to sign in."
+ )
+
+ do {
+ return try await signInService.attemptFirstFactor(
+ signInId: signIn.id,
+ params: .init(
+ strategy: .trustedDevice,
+ trustedDeviceId: trustedDeviceId,
+ clientData: signature.clientData,
+ signature: signature.signature,
+ algorithm: signature.algorithm
+ )
+ )
+ } catch {
+ throw handleTrustedDeviceSignInError(error, localCredential: localCredential)
+ }
+ }
+}
+
+extension TrustedDevices {
+ package func validateLocalCredentialIfPossible(
+ id: String? = nil,
+ identifierHint: String? = nil
+ ) async -> TrustedDeviceValidationResult {
+ if trustedDeviceFeatureUnavailableReason == .environmentUnavailable {
+ return .inconclusive
+ }
+
+ let localCredentials: [TrustedDeviceLocalCredential]
+ do {
+ switch try localCredentialCandidates(id: id, identifierHint: identifierHint, userID: nil) {
+ case let .available(credentials):
+ localCredentials = credentials
+ case let .unavailable(reason):
+ return .invalid(reason)
+ }
+ } catch {
+ return .inconclusive
+ }
+
+ guard Clerk.shared.client != nil else {
+ return .inconclusive
+ }
+
+ var firstUnavailableReason: TrustedDeviceAvailability.UnavailableReason?
+
+ for localCredential in localCredentials {
+ do {
+ let validation = try await trustedDeviceService.validateSignInCredential(trustedDeviceId: localCredential.id)
+ guard validation.valid else {
+ try? deleteLocalCredential(localCredential)
+ firstUnavailableReason = firstUnavailableReason ?? .serverCredentialMissing
+ continue
+ }
+ return .valid
+ } catch {
+ if error.isMissingTrustedDeviceCredential {
+ try? deleteLocalCredential(localCredential)
+ firstUnavailableReason = firstUnavailableReason ?? .serverCredentialMissing
+ continue
+ }
+ if let unavailableReason = error.trustedDeviceValidationUnavailableReason {
+ return .invalid(unavailableReason)
+ }
+ return .inconclusive
+ }
+ }
+
+ return .invalid(firstUnavailableReason ?? .serverCredentialMissing)
+ }
+
+ private var trustedDeviceFeatureUnavailableReason: TrustedDeviceAvailability.UnavailableReason? {
+ guard let nativeSettings = Clerk.shared.environment?.authConfig.nativeSettings else {
+ return .environmentUnavailable
+ }
+ guard nativeSettings.apiEnabled else {
+ return .nativeAPIDisabled
+ }
+ guard nativeSettings.trustedDeviceSignInEnabled else {
+ return .featureDisabled
+ }
+ return nil
+ }
+
+ private func ensureTrustedDeviceFeatureEnabled() throws {
+ guard let reason = trustedDeviceFeatureUnavailableReason else {
+ return
+ }
+
+ switch reason {
+ case .environmentUnavailable:
+ throw ClerkClientError(message: "Unable to use trusted-device sign-in before the Clerk environment is loaded.")
+ case .nativeAPIDisabled:
+ throw ClerkClientError(message: "Unable to use trusted-device sign-in because Native API is disabled.")
+ case .featureDisabled:
+ throw ClerkClientError(message: "Unable to use trusted-device sign-in because it is disabled.")
+ default:
+ throw ClerkClientError(message: "Trusted-device sign-in is unavailable.")
+ }
+ }
+
+ private enum LocalCredentialResult {
+ case available(Value)
+ case unavailable(TrustedDeviceAvailability.UnavailableReason)
+ }
+
+ private func selectedLocalCredential(
+ id: String?,
+ identifierHint: String?,
+ userID: String?
+ ) async throws -> LocalCredentialResult {
+ switch try localCredentialCandidates(id: id, identifierHint: identifierHint, userID: userID) {
+ case let .available(supportedCredentials):
+ guard Clerk.shared.session?.status == .active else {
+ return .available(supportedCredentials[0])
+ }
+
+ guard let activeUserID = Clerk.shared.session?.user?.id else {
+ return .available(supportedCredentials[0])
+ }
+
+ var trustedDevices: [TrustedDevice]?
+ var firstUnavailableReason: TrustedDeviceAvailability.UnavailableReason?
+ let activeUserCredentials = supportedCredentials.filter { $0.userID == activeUserID }
+ guard !activeUserCredentials.isEmpty else {
+ return .unavailable(.noLocalCredential)
+ }
+
+ for credential in activeUserCredentials {
+ let activeUserTrustedDevices: [TrustedDevice]
+ if let trustedDevices {
+ activeUserTrustedDevices = trustedDevices
+ } else {
+ let fetchedTrustedDevices = try await trustedDeviceService.list()
+ trustedDevices = fetchedTrustedDevices
+ activeUserTrustedDevices = fetchedTrustedDevices
+ }
+
+ guard let trustedDevice = activeUserTrustedDevices.first(where: { $0.id == credential.id }) else {
+ try deleteLocalCredential(credential)
+ firstUnavailableReason = firstUnavailableReason ?? .serverCredentialMissing
+ continue
+ }
+
+ guard trustedDevice.status == .active else {
+ try deleteLocalCredential(credential)
+ firstUnavailableReason = firstUnavailableReason ?? .serverCredentialRevoked
+ continue
+ }
+
+ return .available(credential)
+ }
+
+ return .unavailable(firstUnavailableReason ?? .serverCredentialMissing)
+ case let .unavailable(reason):
+ return .unavailable(reason)
+ }
+ }
+
+ private func localCredentialCandidates(
+ id: String?,
+ identifierHint: String?,
+ userID: String?
+ ) throws -> LocalCredentialResult<[TrustedDeviceLocalCredential]> {
+ if let unavailableReason = trustedDeviceFeatureUnavailableReason {
+ return .unavailable(unavailableReason)
+ }
+
+ let localCredentials = try candidateLocalCredentials(id: id, identifierHint: identifierHint, userID: userID)
+ guard !localCredentials.isEmpty else {
+ return .unavailable(.noLocalCredential)
+ }
+
+ let credentialsWithKeys = try localCredentialsWithExistingKeys(from: localCredentials)
+ guard !credentialsWithKeys.isEmpty else {
+ return .unavailable(.localKeyMissing)
+ }
+
+ let supportedCredentials = credentialsWithKeys.filter { keyManager.isSupported(policy: $0.policy) }
+ guard !supportedCredentials.isEmpty else {
+ return .unavailable(.biometricAuthenticationUnavailable)
+ }
+
+ return .available(supportedCredentials)
+ }
+
+ private func candidateLocalCredentials(
+ id: String?,
+ identifierHint: String?,
+ userID: String?
+ ) throws -> [TrustedDeviceLocalCredential] {
+ var credentials = try storedLocalCredentialsForCurrentApp()
+ if let id {
+ credentials = credentials.filter { $0.id == id }
+ }
+ if let userID {
+ credentials = credentials.filter { $0.userID == userID }
+ } else {
+ credentials = credentials.filter { $0.matches(identifierHint: identifierHint) }
+ }
+ return credentials.sorted { lhs, rhs in
+ if lhs.createdAt != rhs.createdAt {
+ return lhs.createdAt > rhs.createdAt
+ }
+ if lhs.updatedAt != rhs.updatedAt {
+ return lhs.updatedAt > rhs.updatedAt
+ }
+ return lhs.id > rhs.id
+ }
+ }
+
+ private func storedLocalCredentialsForCurrentApp() throws -> [TrustedDeviceLocalCredential] {
+ guard let appIdentifier = appIdentifierProvider() else {
+ return []
+ }
+
+ return try credentialStore.all(appIdentifier: appIdentifier)
+ }
+
+ private func localCredentialsWithExistingKeys(
+ from credentials: [TrustedDeviceLocalCredential]
+ ) throws -> [TrustedDeviceLocalCredential] {
+ var credentialsWithKeys: [TrustedDeviceLocalCredential] = []
+
+ for credential in credentials {
+ if try localKeyExists(for: credential) {
+ credentialsWithKeys.append(credential)
+ } else {
+ try deleteLocalCredential(credential)
+ }
+ }
+
+ return credentialsWithKeys
+ }
+
+ private func localKeyExists(for credential: TrustedDeviceLocalCredential) throws -> Bool {
+ do {
+ return try keyManager.hasKey(localKeyId: credential.localKeyId)
+ } catch let error as TrustedDeviceKeyManagerError where error == .keyNotFound {
+ return false
+ }
+ }
+
+ private func deleteLocalCredential(_ credential: TrustedDeviceLocalCredential) throws {
+ try keyManager.deleteKey(localKeyId: credential.localKeyId)
+ try credentialStore.delete(id: credential.id)
+ }
+
+ private func saveLocalCredential(
+ trustedDevice: TrustedDevice,
+ localKey: TrustedDeviceLocalKey,
+ userID: String,
+ identifierHint: String?
+ ) async throws {
+ do {
+ try credentialStore.save(
+ .init(
+ trustedDevice: trustedDevice,
+ localKey: localKey,
+ userID: userID,
+ identifierHint: identifierHint
+ ),
+ deleteReplacedLocalKey: { localKeyId in
+ try keyManager.deleteKey(localKeyId: localKeyId)
+ }
+ )
+ } catch {
+ _ = try? await trustedDeviceService.revoke(trustedDeviceId: trustedDevice.id)
+ throw error
+ }
+ }
+
+ private func removeOtherLocalCredentialsForCurrentApp(keeping trustedDevice: TrustedDevice) {
+ let credentialsToReplace: [TrustedDeviceLocalCredential]
+ do {
+ // The backend replaces active credentials by installation and app identifier, even across users.
+ credentialsToReplace = try storedLocalCredentialsForCurrentApp().filter { $0.id != trustedDevice.id }
+ } catch {
+ ClerkLogger.warning(
+ "Failed to load replaced trusted-device credentials for local cleanup. Error: \(error)"
+ )
+ return
+ }
+
+ for credential in credentialsToReplace {
+ do {
+ try deleteLocalCredential(credential)
+ } catch {
+ ClerkLogger.warning(
+ "Failed to remove replaced trusted-device credential locally. Error: \(error)"
+ )
+ }
+ }
+ }
+
+ private func trustedDeviceChallenge(from signIn: SignIn) throws -> TrustedDeviceChallenge {
+ guard let trustedDeviceChallenge = signIn.firstFactorVerification?.trustedDeviceChallenge else {
+ throw ClerkClientError(message: "Trusted-device sign-in did not return a challenge.")
+ }
+ return trustedDeviceChallenge
+ }
+
+ private func handleTrustedDeviceSignInError(
+ _ error: Error,
+ localCredential: TrustedDeviceLocalCredential
+ ) -> Error {
+ guard error.isMissingTrustedDeviceCredential else {
+ return error
+ }
+
+ try? deleteLocalCredential(localCredential)
+ return ClerkClientError(message: "This device is no longer trusted. Sign in another way to enroll it again.")
+ }
+}
+
+extension Error {
+ fileprivate var isMissingTrustedDeviceCredential: Bool {
+ guard let error = self as? ClerkAPIError else {
+ return false
+ }
+
+ return TrustedDeviceAPIError.missingCredentialCodes.contains(error.code) &&
+ error.meta?["param_name"]?.stringValue == TrustedDeviceAPIError.trustedDeviceIDParamName
+ }
+
+ fileprivate var trustedDeviceValidationUnavailableReason: TrustedDeviceAvailability.UnavailableReason? {
+ guard let error = self as? ClerkAPIError else {
+ return nil
+ }
+
+ switch error.code {
+ case TrustedDeviceAPIError.nativeAPIDisabledCode:
+ return .nativeAPIDisabled
+ case TrustedDeviceAPIError.featureNotEnabledCode:
+ return .featureDisabled
+ default:
+ return nil
+ }
+ }
+}
+
+private enum TrustedDeviceAPIError {
+ static let formResourceNotFoundCode = "form_resource_not_found"
+ static let trustedDeviceNotRegisteredCode = "trusted_device_not_registered"
+ static let trustedDeviceIDParamName = "trusted_device_id"
+ static let nativeAPIDisabledCode = "native_api_disabled"
+ static let featureNotEnabledCode = "feature_not_enabled"
+
+ static let missingCredentialCodes = [
+ formResourceNotFoundCode,
+ trustedDeviceNotRegisteredCode,
+ ]
+}
+
+extension Session.SessionStatus {
+ package var allowsTrustedDeviceEnrollment: Bool {
+ switch self {
+ case .active, .pending:
+ true
+ default:
+ false
+ }
+ }
+}
diff --git a/Sources/ClerkKit/Dependencies/Dependencies.swift b/Sources/ClerkKit/Dependencies/Dependencies.swift
index c7ffff27f..2cc75aec9 100644
--- a/Sources/ClerkKit/Dependencies/Dependencies.swift
+++ b/Sources/ClerkKit/Dependencies/Dependencies.swift
@@ -19,6 +19,12 @@ protocol Dependencies: AnyObject {
/// The keychain storage for secure data persistence.
var keychain: any KeychainStorage { get }
+ /// Manager for local trusted-device private keys.
+ var trustedDeviceKeyManager: any TrustedDeviceKeyManagerProtocol { get }
+
+ /// Store for local trusted-device credential metadata.
+ var trustedDeviceCredentialStore: any TrustedDeviceLocalCredentialStoreProtocol { get }
+
/// The telemetry collector for development diagnostics.
var telemetryCollector: any TelemetryCollectorProtocol { get }
@@ -43,6 +49,9 @@ protocol Dependencies: AnyObject {
/// Service for passkey operations.
var passkeyService: PasskeyServiceProtocol { get }
+ /// Service for trusted-device operations.
+ var trustedDeviceService: TrustedDeviceServiceProtocol { get }
+
/// Service for organization-related operations.
var organizationService: OrganizationServiceProtocol { get }
diff --git a/Sources/ClerkKit/Dependencies/DependencyContainer.swift b/Sources/ClerkKit/Dependencies/DependencyContainer.swift
index b4b2495dd..5751cf598 100644
--- a/Sources/ClerkKit/Dependencies/DependencyContainer.swift
+++ b/Sources/ClerkKit/Dependencies/DependencyContainer.swift
@@ -14,6 +14,8 @@ final class DependencyContainer: Dependencies {
let networkingPipeline: NetworkingPipeline
let keychain: any KeychainStorage
+ let trustedDeviceKeyManager: any TrustedDeviceKeyManagerProtocol
+ let trustedDeviceCredentialStore: any TrustedDeviceLocalCredentialStoreProtocol
let configurationManager: ConfigurationManager
let apiClient: APIClient
let telemetryCollector: any TelemetryCollectorProtocol
@@ -27,6 +29,7 @@ final class DependencyContainer: Dependencies {
let sessionService: SessionServiceProtocol
let magicLinkService: MagicLinkServiceProtocol
let passkeyService: PasskeyServiceProtocol
+ let trustedDeviceService: TrustedDeviceServiceProtocol
let organizationService: OrganizationServiceProtocol
let environmentService: EnvironmentServiceProtocol
let emailAddressService: EmailAddressServiceProtocol
@@ -83,6 +86,8 @@ final class DependencyContainer: Dependencies {
.appendingRequestMiddleware(options.middleware.request)
.appendingResponseMiddleware(options.middleware.response)
keychain = Self.makeKeychainStorage(config: options.keychainConfig)
+ trustedDeviceKeyManager = TrustedDeviceKeyManager()
+ trustedDeviceCredentialStore = TrustedDeviceLocalCredentialStore(keychain: keychain)
magicLinkStore = MagicLinkStore(keychain: keychain)
@@ -96,7 +101,7 @@ final class DependencyContainer: Dependencies {
"Content-Type": "application/x-www-form-urlencoded",
"clerk-api-version": Clerk.apiVersion,
"x-ios-sdk-version": Clerk.sdkVersion,
- "x-mobile": "1",
+ "x-mobile": Self.mobileHeaderValue,
]
}
@@ -111,6 +116,7 @@ final class DependencyContainer: Dependencies {
sessionService = SessionService(apiClient: apiClient)
magicLinkService = MagicLinkService(apiClient: apiClient)
passkeyService = PasskeyService(apiClient: apiClient)
+ trustedDeviceService = TrustedDeviceService(apiClient: apiClient)
organizationService = OrganizationService(apiClient: apiClient)
environmentService = EnvironmentService(apiClient: apiClient)
emailAddressService = EmailAddressService(apiClient: apiClient)
@@ -144,6 +150,14 @@ final class DependencyContainer: Dependencies {
#endif
}
+ static var mobileHeaderValue: String {
+ #if os(macOS) || targetEnvironment(macCatalyst)
+ "0"
+ #else
+ "1"
+ #endif
+ }
+
@MainActor
private static func createTelemetryCollector(
publishableKey: String,
diff --git a/Sources/ClerkKit/Domains/Auth/Factor.swift b/Sources/ClerkKit/Domains/Auth/Factor.swift
index 093a1c3b6..41b8d18d9 100644
--- a/Sources/ClerkKit/Domains/Auth/Factor.swift
+++ b/Sources/ClerkKit/Domains/Auth/Factor.swift
@@ -18,6 +18,9 @@ public struct Factor: Codable, Equatable, Hashable, Sendable {
/// The ID of the Web3 wallet that will be used to sign a message.
public var web3WalletId: String?
+ /// The ID of the trusted-device credential that will be used to sign a challenge.
+ public var trustedDeviceId: String?
+
/// The ID of the enterprise connection that will be used for SSO.
public var enterpriseConnectionId: String?
@@ -38,6 +41,7 @@ public struct Factor: Codable, Equatable, Hashable, Sendable {
emailAddressId: String? = nil,
phoneNumberId: String? = nil,
web3WalletId: String? = nil,
+ trustedDeviceId: String? = nil,
enterpriseConnectionId: String? = nil,
enterpriseConnectionName: String? = nil,
safeIdentifier: String? = nil,
@@ -48,6 +52,7 @@ public struct Factor: Codable, Equatable, Hashable, Sendable {
self.emailAddressId = emailAddressId
self.phoneNumberId = phoneNumberId
self.web3WalletId = web3WalletId
+ self.trustedDeviceId = trustedDeviceId
self.enterpriseConnectionId = enterpriseConnectionId
self.enterpriseConnectionName = enterpriseConnectionName
self.safeIdentifier = safeIdentifier
diff --git a/Sources/ClerkKit/Domains/Auth/FactorStrategy.swift b/Sources/ClerkKit/Domains/Auth/FactorStrategy.swift
index 37cc37027..14f45fb69 100644
--- a/Sources/ClerkKit/Domains/Auth/FactorStrategy.swift
+++ b/Sources/ClerkKit/Domains/Auth/FactorStrategy.swift
@@ -16,6 +16,7 @@ public enum FactorStrategy: Hashable, Codable, Sendable {
case emailLink
case phoneCode
case passkey
+ case trustedDevice
case totp
case backupCode
case ticket
@@ -50,6 +51,8 @@ public enum FactorStrategy: Hashable, Codable, Sendable {
"phone_code"
case .passkey:
"passkey"
+ case .trustedDevice:
+ "trusted_device"
case .totp:
"totp"
case .backupCode:
@@ -86,6 +89,8 @@ public enum FactorStrategy: Hashable, Codable, Sendable {
self = .phoneCode
case "passkey":
self = .passkey
+ case "trusted_device":
+ self = .trustedDevice
case "totp":
self = .totp
case "backup_code":
diff --git a/Sources/ClerkKit/Domains/Auth/SignIn/SignIn+ServiceParams.swift b/Sources/ClerkKit/Domains/Auth/SignIn/SignIn+ServiceParams.swift
index c39ad12e7..85ea9bbd6 100644
--- a/Sources/ClerkKit/Domains/Auth/SignIn/SignIn+ServiceParams.swift
+++ b/Sources/ClerkKit/Domains/Auth/SignIn/SignIn+ServiceParams.swift
@@ -14,6 +14,7 @@ extension SignIn {
let redirectUrl: String?
let ticket: String?
let token: String?
+ let trustedDeviceId: String?
let transfer: Bool?
init(
@@ -22,6 +23,7 @@ extension SignIn {
strategy: FactorStrategy? = nil,
ticket: String? = nil,
token: String? = nil,
+ trustedDeviceId: String? = nil,
redirectUrl: String? = nil,
transfer: Bool? = nil
) {
@@ -32,6 +34,7 @@ extension SignIn {
self.redirectUrl = redirectUrl
self.ticket = ticket
self.token = token
+ self.trustedDeviceId = trustedDeviceId
self.transfer = transfer
}
}
@@ -70,19 +73,31 @@ extension SignIn {
let password: String?
let publicKeyCredential: String?
let token: String?
+ let trustedDeviceId: String?
+ let clientData: String?
+ let signature: String?
+ let algorithm: TrustedDevice.Algorithm?
init(
strategy: FactorStrategy,
code: String? = nil,
password: String? = nil,
publicKeyCredential: String? = nil,
- token: String? = nil
+ token: String? = nil,
+ trustedDeviceId: String? = nil,
+ clientData: String? = nil,
+ signature: String? = nil,
+ algorithm: TrustedDevice.Algorithm? = nil
) {
self.strategy = strategy
self.code = code
self.password = password
self.publicKeyCredential = publicKeyCredential
self.token = token
+ self.trustedDeviceId = trustedDeviceId
+ self.clientData = clientData
+ self.signature = signature
+ self.algorithm = algorithm
}
}
diff --git a/Sources/ClerkKit/Domains/Auth/TransferFlowResult.swift b/Sources/ClerkKit/Domains/Auth/TransferFlowResult.swift
index adeac4c68..5b31f0fbe 100644
--- a/Sources/ClerkKit/Domains/Auth/TransferFlowResult.swift
+++ b/Sources/ClerkKit/Domains/Auth/TransferFlowResult.swift
@@ -34,6 +34,15 @@ public enum TransferFlowResult: Sendable {
}
extension TransferFlowResult {
+ package var flowId: String {
+ switch self {
+ case .signIn(let signIn):
+ signIn.id
+ case .signUp(let signUp):
+ signUp.id
+ }
+ }
+
package var createdSessionId: String? {
switch self {
case .signIn(let signIn):
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevice.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevice.swift
new file mode 100644
index 000000000..5045660c2
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevice.swift
@@ -0,0 +1,234 @@
+//
+// TrustedDevice.swift
+// Clerk
+//
+
+import Foundation
+
+/// A biometric-gated trusted-device credential associated with a user.
+public struct TrustedDevice: Codable, Identifiable, Equatable, Sendable {
+ /// The unique identifier of the trusted-device credential.
+ public var id: String
+
+ /// The resource object name.
+ public var object: String
+
+ /// The platform this credential belongs to.
+ public var platform: Platform
+
+ /// The native app identifier this credential is bound to.
+ public var appIdentifier: String
+
+ /// The user-facing credential name.
+ public var name: String?
+
+ /// The signature algorithm used by the credential.
+ public var algorithm: Algorithm
+
+ /// The credential status.
+ public var status: Status
+
+ /// The date when the credential was created.
+ public var createdAt: Date
+
+ /// The date when the credential was last updated.
+ public var updatedAt: Date
+
+ /// The date when the credential was last used.
+ public var lastUsedAt: Date?
+
+ /// The date when the credential was revoked.
+ public var revokedAt: Date?
+
+ public init(
+ id: String,
+ object: String = "trusted_device",
+ platform: Platform,
+ appIdentifier: String,
+ name: String? = nil,
+ algorithm: Algorithm,
+ status: Status,
+ createdAt: Date,
+ updatedAt: Date,
+ lastUsedAt: Date? = nil,
+ revokedAt: Date? = nil
+ ) {
+ self.id = id
+ self.object = object
+ self.platform = platform
+ self.appIdentifier = appIdentifier
+ self.name = name
+ self.algorithm = algorithm
+ self.status = status
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ self.lastUsedAt = lastUsedAt
+ self.revokedAt = revokedAt
+ }
+}
+
+extension TrustedDevice {
+ /// The platform a trusted-device credential belongs to.
+ public enum Platform: Codable, Equatable, Hashable, Sendable {
+ case iOS
+ case android
+ case unknown(String)
+
+ public var rawValue: String {
+ switch self {
+ case .iOS:
+ "ios"
+ case .android:
+ "android"
+ case let .unknown(value):
+ value
+ }
+ }
+
+ public init(rawValue: String) {
+ switch rawValue {
+ case "ios":
+ self = .iOS
+ case "android":
+ self = .android
+ default:
+ self = .unknown(rawValue)
+ }
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ try self.init(rawValue: container.decode(String.self))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+ }
+
+ /// The credential signing algorithm.
+ public enum Algorithm: Codable, Equatable, Hashable, Sendable {
+ case es256
+ case unknown(String)
+
+ public var rawValue: String {
+ switch self {
+ case .es256:
+ "ES256"
+ case let .unknown(value):
+ value
+ }
+ }
+
+ public init(rawValue: String) {
+ switch rawValue {
+ case "ES256":
+ self = .es256
+ default:
+ self = .unknown(rawValue)
+ }
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ try self.init(rawValue: container.decode(String.self))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+ }
+
+ /// The server-side trusted-device credential status.
+ public enum Status: Codable, Equatable, Hashable, Sendable {
+ case active
+ case revoked
+ case unknown(String)
+
+ public var rawValue: String {
+ switch self {
+ case .active:
+ "active"
+ case .revoked:
+ "revoked"
+ case let .unknown(value):
+ value
+ }
+ }
+
+ public init(rawValue: String) {
+ switch rawValue {
+ case "active":
+ self = .active
+ case "revoked":
+ self = .revoked
+ default:
+ self = .unknown(rawValue)
+ }
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ try self.init(rawValue: container.decode(String.self))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ try container.encode(rawValue)
+ }
+ }
+}
+
+extension TrustedDevice {
+ package struct PrepareEnrollmentParams: Encodable {
+ package let platform: Platform
+ package let appIdentifier: String
+ package let name: String?
+ package let algorithm: Algorithm
+ package let publicKeyJWK: String
+
+ package init(
+ platform: Platform = .iOS,
+ appIdentifier: String,
+ name: String? = nil,
+ algorithm: Algorithm = .es256,
+ publicKeyJWK: String
+ ) {
+ self.platform = platform
+ self.appIdentifier = appIdentifier
+ self.name = name
+ self.algorithm = algorithm
+ self.publicKeyJWK = publicKeyJWK
+ }
+ }
+
+ package struct AttemptEnrollmentParams: Encodable {
+ package let platform: Platform
+ package let appIdentifier: String
+ package let name: String?
+ package let algorithm: Algorithm
+ package let publicKeyJWK: String
+ package let clientData: String
+ package let signature: String
+
+ package init(
+ platform: Platform = .iOS,
+ appIdentifier: String,
+ name: String? = nil,
+ algorithm: Algorithm = .es256,
+ publicKeyJWK: String,
+ clientData: String,
+ signature: String
+ ) {
+ self.platform = platform
+ self.appIdentifier = appIdentifier
+ self.name = name
+ self.algorithm = algorithm
+ self.publicKeyJWK = publicKeyJWK
+ self.clientData = clientData
+ self.signature = signature
+ }
+ }
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceAvailability.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceAvailability.swift
new file mode 100644
index 000000000..8e2931d2f
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceAvailability.swift
@@ -0,0 +1,44 @@
+//
+// TrustedDeviceAvailability.swift
+// Clerk
+//
+
+import Foundation
+
+/// Local availability state for biometric trusted-device sign-in.
+public enum TrustedDeviceAvailability: Equatable, Sendable {
+ case available
+ case unavailable(UnavailableReason)
+
+ /// Whether the SDK has a local credential and key that can be used for trusted-device sign-in.
+ public var isAvailable: Bool {
+ switch self {
+ case .available:
+ true
+ case .unavailable:
+ false
+ }
+ }
+
+ /// The reason trusted-device sign-in is unavailable, if any.
+ public var unavailableReason: UnavailableReason? {
+ switch self {
+ case .available:
+ nil
+ case let .unavailable(reason):
+ reason
+ }
+ }
+
+ public enum UnavailableReason: String, Codable, Equatable, Sendable {
+ case environmentUnavailable
+ case nativeAPIDisabled
+ case featureDisabled
+ case unsupportedPlatform
+ case biometricAuthenticationUnavailable
+ case noLocalCredential
+ case localKeyMissing
+ case serverCredentialMissing
+ case serverCredentialRevoked
+ }
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceChallenge.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceChallenge.swift
new file mode 100644
index 000000000..3c441a8cd
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceChallenge.swift
@@ -0,0 +1,61 @@
+//
+// TrustedDeviceChallenge.swift
+// Clerk
+//
+
+import Foundation
+
+/// A server challenge for trusted-device enrollment or sign-in.
+public struct TrustedDeviceChallenge: Codable, Equatable, Hashable, Sendable {
+ /// The resource object name.
+ public var object: String
+
+ /// The challenge value.
+ public var challenge: String
+
+ /// The unique identifier of the challenge.
+ public var challengeId: String
+
+ /// The trusted-device credential ID for sign-in challenges.
+ public var trustedDeviceId: String?
+
+ /// The exact client data string that must be signed.
+ public var clientData: String
+
+ /// The date when the challenge expires.
+ public var expiresAt: Date
+
+ /// The signature algorithm required for the challenge.
+ public var algorithm: TrustedDevice.Algorithm
+
+ public init(
+ object: String = "trusted_device_challenge",
+ challenge: String,
+ challengeId: String,
+ trustedDeviceId: String? = nil,
+ clientData: String,
+ expiresAt: Date,
+ algorithm: TrustedDevice.Algorithm
+ ) {
+ self.object = object
+ self.challenge = challenge
+ self.challengeId = challengeId
+ self.trustedDeviceId = trustedDeviceId
+ self.clientData = clientData
+ self.expiresAt = expiresAt
+ self.algorithm = algorithm
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ object = try container.decode(String.self, forKey: .object)
+ challenge = try container.decode(String.self, forKey: .challenge)
+ challengeId = try container.decode(String.self, forKey: .challengeId)
+ trustedDeviceId = try container.decodeIfPresent(String.self, forKey: .trustedDeviceId)
+ clientData = try container.decode(String.self, forKey: .clientData)
+ algorithm = try container.decode(TrustedDevice.Algorithm.self, forKey: .algorithm)
+
+ let rawExpiresAt = try container.decode(Double.self, forKey: .expiresAt)
+ expiresAt = Date(timeIntervalSince1970: rawExpiresAt > 10_000_000_000 ? rawExpiresAt / 1000 : rawExpiresAt)
+ }
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceKeyManager.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceKeyManager.swift
new file mode 100644
index 000000000..0efc14931
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceKeyManager.swift
@@ -0,0 +1,487 @@
+//
+// TrustedDeviceKeyManager.swift
+// Clerk
+//
+
+import Foundation
+#if canImport(LocalAuthentication)
+import LocalAuthentication
+#endif
+import Security
+
+extension TrustedDeviceKeyManagerProtocol {
+ @MainActor
+ var isSupported: Bool {
+ isSupported(policy: .biometryOrDevicePasscode)
+ }
+
+ @MainActor
+ func createKey() throws -> TrustedDeviceLocalKey {
+ try createKey(policy: .biometryOrDevicePasscode)
+ }
+
+ @MainActor
+ func sign(clientData: String, localKeyId: String) throws -> TrustedDeviceKeySignature {
+ try sign(clientData: clientData, localKeyId: localKeyId, localizedReason: nil)
+ }
+}
+
+final class TrustedDeviceKeyManager: TrustedDeviceKeyManagerProtocol {
+ private static let applicationTagPrefix = "dev.clerk.trusted_device"
+
+ @MainActor
+ func isSupported(policy: TrustedDevicePolicy) -> Bool {
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ let context = LAContext()
+ var error: NSError?
+ return context.canEvaluatePolicy(Self.localAuthenticationPolicy(for: policy), error: &error)
+ #else
+ return false
+ #endif
+ }
+
+ @MainActor
+ func createKey(policy: TrustedDevicePolicy = .biometryOrDevicePasscode) throws -> TrustedDeviceLocalKey {
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ guard canCreateKey(policy: policy) else {
+ throw TrustedDeviceKeyManagerError.biometricAuthenticationUnavailable
+ }
+
+ let localKeyId = Self.makeLocalKeyId()
+ let accessControl = try Self.makeAccessControl(policy: policy)
+ let attributes = Self.makePrivateKeyAttributes(localKeyId: localKeyId, accessControl: accessControl)
+
+ var error: Unmanaged?
+ guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
+ throw TrustedDeviceKeyManagerError.keyGenerationFailed(Self.errorMessage(from: error))
+ }
+
+ let publicKeyJWK = try Self.publicKeyJWK(for: privateKey)
+ return TrustedDeviceLocalKey(localKeyId: localKeyId, publicKeyJWK: publicKeyJWK, policy: policy)
+ #else
+ throw TrustedDeviceKeyManagerError.unsupportedPlatform
+ #endif
+ }
+
+ @MainActor
+ private func canCreateKey(policy: TrustedDevicePolicy) -> Bool {
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ let context = LAContext()
+ var error: NSError?
+ return context.canEvaluatePolicy(Self.localAuthenticationPolicyForKeyCreation(for: policy), error: &error)
+ #else
+ return false
+ #endif
+ }
+
+ @MainActor
+ func sign(
+ clientData: String,
+ localKeyId: String,
+ localizedReason: String? = nil
+ ) throws -> TrustedDeviceKeySignature {
+ let privateKey = try privateKey(localKeyId: localKeyId, localizedReason: localizedReason)
+ let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256
+ guard SecKeyIsAlgorithmSupported(privateKey, .sign, algorithm) else {
+ throw TrustedDeviceKeyManagerError.unsupportedAlgorithm
+ }
+
+ var error: Unmanaged?
+ guard let signature = SecKeyCreateSignature(
+ privateKey,
+ algorithm,
+ Data(clientData.utf8) as CFData,
+ &error
+ ) as Data? else {
+ throw TrustedDeviceKeyManagerError.signingFailed(Self.errorMessage(from: error))
+ }
+
+ let rawSignature = try Self.rawES256Signature(fromDEREncoded: signature)
+
+ return TrustedDeviceKeySignature(
+ clientData: clientData,
+ signature: Self.base64URLEncodedString(rawSignature)
+ )
+ }
+
+ @MainActor
+ func hasKey(localKeyId: String) throws -> Bool {
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
+ switch status {
+ case errSecSuccess:
+ return true
+ case errSecItemNotFound:
+ return false
+ default:
+ throw KeychainError.unexpectedStatus(status)
+ }
+ }
+
+ @MainActor
+ func deleteKey(localKeyId: String) throws {
+ let status = SecItemDelete(Self.privateKeyQuery(localKeyId: localKeyId) as CFDictionary)
+ switch status {
+ case errSecSuccess, errSecItemNotFound:
+ return
+ default:
+ throw TrustedDeviceKeyManagerError.deletionFailed(status)
+ }
+ }
+
+ private func privateKey(localKeyId: String, localizedReason: String?) throws -> SecKey {
+ var query = Self.privateKeyQuery(localKeyId: localKeyId)
+ query[kSecReturnRef as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ let context = LAContext()
+ if let localizedReason {
+ context.localizedReason = localizedReason
+ }
+ query[kSecUseAuthenticationContext as String] = context
+ #endif
+
+ var privateKey: SecKey?
+ let status = withUnsafeMutablePointer(to: &privateKey) { pointer in
+ pointer.withMemoryRebound(to: CFTypeRef?.self, capacity: 1) { reboundPointer in
+ SecItemCopyMatching(query as CFDictionary, reboundPointer)
+ }
+ }
+
+ switch status {
+ case errSecSuccess:
+ guard let privateKey else {
+ throw TrustedDeviceKeyManagerError.keyNotFound
+ }
+ return privateKey
+ case errSecItemNotFound:
+ throw TrustedDeviceKeyManagerError.keyNotFound
+ default:
+ throw Self.privateKeyLookupError(for: status)
+ }
+ }
+
+ private static func makeLocalKeyId() -> String {
+ "tdlk_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased()
+ }
+
+ package static func accessControlFlags(for policy: TrustedDevicePolicy) -> SecAccessControlCreateFlags {
+ switch policy {
+ case .biometryCurrentSet:
+ [.privateKeyUsage, .biometryCurrentSet]
+ case .biometryAny:
+ [.privateKeyUsage, .biometryAny]
+ case .biometryOrDevicePasscode:
+ [.privateKeyUsage, .userPresence]
+ }
+ }
+
+ package static func makeAccessControl(policy: TrustedDevicePolicy = .biometryOrDevicePasscode) throws -> SecAccessControl {
+ var error: Unmanaged?
+ guard let accessControl = SecAccessControlCreateWithFlags(
+ kCFAllocatorDefault,
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
+ accessControlFlags(for: policy),
+ &error
+ ) else {
+ throw TrustedDeviceKeyManagerError.keyGenerationFailed(errorMessage(from: error))
+ }
+
+ return accessControl
+ }
+
+ package static func makePrivateKeyAttributes(
+ localKeyId: String,
+ accessControl: SecAccessControl
+ ) -> [String: Any] {
+ var attributes: [String: Any] = [
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrKeySizeInBits as String: 256,
+ kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
+ kSecPrivateKeyAttrs as String: [
+ kSecAttrIsPermanent as String: true,
+ kSecAttrApplicationTag as String: applicationTag(localKeyId: localKeyId),
+ kSecAttrAccessControl as String: accessControl,
+ ],
+ ]
+
+ #if os(macOS)
+ attributes[kSecUseDataProtectionKeychain as String] = kCFBooleanTrue
+ #endif
+
+ return attributes
+ }
+
+ package static func privateKeyQuery(localKeyId: String) -> [String: Any] {
+ var query: [String: Any] = [
+ kSecClass as String: kSecClassKey,
+ kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
+ kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
+ kSecAttrApplicationTag as String: applicationTag(localKeyId: localKeyId),
+ ]
+
+ #if os(macOS)
+ query[kSecUseDataProtectionKeychain as String] = kCFBooleanTrue
+ #endif
+
+ return query
+ }
+
+ package static func privateKeyLookupError(for status: OSStatus) -> Error {
+ switch status {
+ case errSecUserCanceled:
+ TrustedDeviceKeyManagerError.biometricAuthenticationCanceled
+ case errSecAuthFailed:
+ TrustedDeviceKeyManagerError.biometricAuthenticationFailed
+ case errSecInteractionNotAllowed:
+ TrustedDeviceKeyManagerError.biometricAuthenticationUnavailable
+ default:
+ KeychainError.unexpectedStatus(status)
+ }
+ }
+
+ package static func publicKeyJWK(fromX963Representation representation: Data) throws -> String {
+ guard representation.count == 65, representation.first == 0x04 else {
+ throw TrustedDeviceKeyManagerError.invalidPublicKey
+ }
+
+ let xCoordinate = representation[1 ..< 33]
+ let yCoordinate = representation[33 ..< 65]
+ return """
+ {"kty":"EC","crv":"P-256","x":"\(base64URLEncodedString(xCoordinate))","y":"\(base64URLEncodedString(yCoordinate))","alg":"ES256"}
+ """
+ }
+
+ package static func base64URLEncodedString(_ data: some DataProtocol) -> String {
+ Data(data)
+ .base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+ }
+
+ package static func rawES256Signature(fromDEREncoded signature: Data) throws -> Data {
+ let bytes = Array(signature)
+ var offset = 0
+
+ guard try readDERByte(bytes, offset: &offset) == 0x30 else {
+ throw invalidES256SignatureError()
+ }
+
+ let sequenceLength = try readDERLength(bytes, offset: &offset)
+ guard sequenceLength == bytes.count - offset else {
+ throw invalidES256SignatureError()
+ }
+
+ let r = try readDERInteger(bytes, offset: &offset)
+ let s = try readDERInteger(bytes, offset: &offset)
+ guard offset == bytes.count else {
+ throw invalidES256SignatureError()
+ }
+
+ var raw = try paddedES256Component(r)
+ try raw.append(paddedES256Component(s))
+ return raw
+ }
+
+ private static func publicKeyJWK(for privateKey: SecKey) throws -> String {
+ guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
+ throw TrustedDeviceKeyManagerError.publicKeyExportFailed("Unable to copy public key.")
+ }
+
+ var error: Unmanaged?
+ guard let representation = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
+ throw TrustedDeviceKeyManagerError.publicKeyExportFailed(errorMessage(from: error))
+ }
+
+ return try publicKeyJWK(fromX963Representation: representation)
+ }
+
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ package static func localAuthenticationPolicy(for policy: TrustedDevicePolicy) -> LAPolicy {
+ switch policy {
+ case .biometryCurrentSet, .biometryAny:
+ .deviceOwnerAuthenticationWithBiometrics
+ case .biometryOrDevicePasscode:
+ .deviceOwnerAuthentication
+ }
+ }
+
+ package static func localAuthenticationPolicyForKeyCreation(for policy: TrustedDevicePolicy) -> LAPolicy {
+ switch policy {
+ case .biometryCurrentSet, .biometryAny, .biometryOrDevicePasscode:
+ .deviceOwnerAuthenticationWithBiometrics
+ }
+ }
+ #endif
+
+ private static func applicationTag(localKeyId: String) -> Data {
+ Data("\(applicationTagPrefix).\(localKeyId)".utf8)
+ }
+
+ private static func readDERByte(_ bytes: [UInt8], offset: inout Int) throws -> UInt8 {
+ guard offset < bytes.count else {
+ throw invalidES256SignatureError()
+ }
+
+ let byte = bytes[offset]
+ offset += 1
+ return byte
+ }
+
+ private static func readDERLength(_ bytes: [UInt8], offset: inout Int) throws -> Int {
+ let first = try readDERByte(bytes, offset: &offset)
+ if first & 0x80 == 0 {
+ return Int(first)
+ }
+
+ let byteCount = Int(first & 0x7F)
+ guard byteCount > 0, byteCount <= MemoryLayout.size, byteCount <= bytes.count - offset else {
+ throw invalidES256SignatureError()
+ }
+
+ var length = 0
+ for _ in 0 ..< byteCount {
+ length = try (length << 8) | Int(readDERByte(bytes, offset: &offset))
+ }
+ return length
+ }
+
+ private static func readDERInteger(_ bytes: [UInt8], offset: inout Int) throws -> [UInt8] {
+ guard try readDERByte(bytes, offset: &offset) == 0x02 else {
+ throw invalidES256SignatureError()
+ }
+
+ let length = try readDERLength(bytes, offset: &offset)
+ guard length > 0, length <= bytes.count - offset else {
+ throw invalidES256SignatureError()
+ }
+
+ let value = Array(bytes[offset ..< offset + length])
+ offset += length
+ return value
+ }
+
+ private static func paddedES256Component(_ bytes: [UInt8]) throws -> Data {
+ guard let first = bytes.first, first & 0x80 == 0 else {
+ throw invalidES256SignatureError()
+ }
+
+ var component = bytes
+ while component.first == 0x00, component.count > 32 {
+ component.removeFirst()
+ }
+ guard !component.isEmpty, component.count <= 32 else {
+ throw invalidES256SignatureError()
+ }
+
+ var padded = Data(repeating: 0x00, count: 32 - component.count)
+ padded.append(contentsOf: component)
+ return padded
+ }
+
+ private static func invalidES256SignatureError() -> TrustedDeviceKeyManagerError {
+ .signingFailed("Security returned an invalid ES256 signature.")
+ }
+
+ private static func errorMessage(from error: Unmanaged?) -> String {
+ guard let error else {
+ return "Unknown Security framework error."
+ }
+ return (error.takeRetainedValue() as Error).localizedDescription
+ }
+}
+
+/// A locally generated private key and its backend-facing public key material.
+package struct TrustedDeviceLocalKey: Equatable {
+ package let localKeyId: String
+ package let publicKeyJWK: String
+ package let algorithm: TrustedDevice.Algorithm
+ package let policy: TrustedDevicePolicy
+
+ package init(
+ localKeyId: String,
+ publicKeyJWK: String,
+ algorithm: TrustedDevice.Algorithm = .es256,
+ policy: TrustedDevicePolicy = .biometryOrDevicePasscode
+ ) {
+ self.localKeyId = localKeyId
+ self.publicKeyJWK = publicKeyJWK
+ self.algorithm = algorithm
+ self.policy = policy
+ }
+}
+
+/// A signed trusted-device challenge payload ready to send to Clerk.
+package struct TrustedDeviceKeySignature: Equatable {
+ package let clientData: String
+ package let signature: String
+ package let algorithm: TrustedDevice.Algorithm
+
+ package init(
+ clientData: String,
+ signature: String,
+ algorithm: TrustedDevice.Algorithm = .es256
+ ) {
+ self.clientData = clientData
+ self.signature = signature
+ self.algorithm = algorithm
+ }
+}
+
+/// Errors produced by local trusted-device key management.
+public enum TrustedDeviceKeyManagerError: Error, Equatable, LocalizedError, Sendable {
+ case unsupportedPlatform
+ case biometricAuthenticationUnavailable
+ case biometricAuthenticationCanceled
+ case biometricAuthenticationFailed
+ case keyGenerationFailed(String)
+ case keyNotFound
+ case invalidPublicKey
+ case publicKeyExportFailed(String)
+ case unsupportedAlgorithm
+ case signingFailed(String)
+ case deletionFailed(OSStatus)
+
+ public var errorDescription: String? {
+ switch self {
+ case .unsupportedPlatform:
+ "Trusted-device biometric sign-in is only available on supported Apple devices."
+ case .biometricAuthenticationUnavailable:
+ "Biometric authentication is not available or not enrolled on this device."
+ case .biometricAuthenticationCanceled:
+ "Biometric authentication was canceled."
+ case .biometricAuthenticationFailed:
+ "Biometric authentication failed."
+ case let .keyGenerationFailed(message):
+ "Unable to create the trusted-device private key. \(message)"
+ case .keyNotFound:
+ "The trusted-device private key was not found."
+ case .invalidPublicKey:
+ "The trusted-device public key is invalid."
+ case let .publicKeyExportFailed(message):
+ "Unable to export the trusted-device public key. \(message)"
+ case .unsupportedAlgorithm:
+ "The trusted-device signing algorithm is not supported."
+ case let .signingFailed(message):
+ "Unable to sign the trusted-device challenge. \(message)"
+ case let .deletionFailed(status):
+ "Unable to delete the trusted-device private key. Security returned status \(status)."
+ }
+ }
+}
+
+package protocol TrustedDeviceKeyManagerProtocol: Sendable {
+ @MainActor func isSupported(policy: TrustedDevicePolicy) -> Bool
+ @MainActor func createKey(policy: TrustedDevicePolicy) throws -> TrustedDeviceLocalKey
+ @MainActor func sign(
+ clientData: String,
+ localKeyId: String,
+ localizedReason: String?
+ ) throws -> TrustedDeviceKeySignature
+ @MainActor func hasKey(localKeyId: String) throws -> Bool
+ @MainActor func deleteKey(localKeyId: String) throws
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStore.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStore.swift
new file mode 100644
index 000000000..47cf53003
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStore.swift
@@ -0,0 +1,437 @@
+//
+// TrustedDeviceLocalCredentialStore.swift
+// Clerk
+//
+
+import Foundation
+
+/// Local metadata that links a Clerk trusted-device credential to its on-device private key.
+package struct TrustedDeviceLocalCredential: Codable, Equatable, Identifiable {
+ package let id: String
+ package let localKeyId: String
+ package let userID: String
+ package let appIdentifier: String
+ package let identifierHint: String?
+ package let policy: TrustedDevicePolicy
+ package let createdAt: Date
+ package let updatedAt: Date
+
+ private enum CodingKeys: String, CodingKey {
+ case id
+ case localKeyId
+ case userID = "userId"
+ case appIdentifier
+ case identifierHint
+ case policy
+ case createdAt
+ case updatedAt
+ }
+
+ package init(
+ id: String,
+ localKeyId: String,
+ userID: String,
+ appIdentifier: String,
+ identifierHint: String? = nil,
+ policy: TrustedDevicePolicy = .biometryOrDevicePasscode,
+ createdAt: Date,
+ updatedAt: Date
+ ) {
+ self.id = id
+ self.localKeyId = localKeyId
+ self.userID = userID
+ self.appIdentifier = appIdentifier
+ self.identifierHint = Self.normalizedIdentifierHint(identifierHint)
+ self.policy = policy
+ self.createdAt = createdAt
+ self.updatedAt = updatedAt
+ }
+
+ package init(
+ trustedDevice: TrustedDevice,
+ localKey: TrustedDeviceLocalKey,
+ userID: String,
+ identifierHint: String? = nil
+ ) {
+ self.init(
+ id: trustedDevice.id,
+ localKeyId: localKey.localKeyId,
+ userID: userID,
+ appIdentifier: trustedDevice.appIdentifier,
+ identifierHint: identifierHint,
+ policy: localKey.policy,
+ createdAt: trustedDevice.createdAt,
+ updatedAt: trustedDevice.updatedAt
+ )
+ }
+
+ package init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ localKeyId = try container.decode(String.self, forKey: .localKeyId)
+ userID = try container.decode(String.self, forKey: .userID)
+ appIdentifier = try container.decode(String.self, forKey: .appIdentifier)
+ identifierHint = try Self.normalizedIdentifierHint(container.decodeIfPresent(String.self, forKey: .identifierHint))
+ policy = try container.decode(TrustedDevicePolicy.self, forKey: .policy)
+ createdAt = try container.decode(Date.self, forKey: .createdAt)
+ updatedAt = try container.decode(Date.self, forKey: .updatedAt)
+ }
+
+ package func matches(identifierHint: String?) -> Bool {
+ guard let normalizedIdentifierHint = Self.normalizedIdentifierHint(identifierHint) else {
+ return true
+ }
+ return self.identifierHint == normalizedIdentifierHint
+ }
+
+ private static func normalizedIdentifierHint(_ identifierHint: String?) -> String? {
+ guard let identifierHint else {
+ return nil
+ }
+ let normalized = identifierHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return normalized.isEmpty ? nil : normalized
+ }
+}
+
+package protocol TrustedDeviceLocalCredentialStoreProtocol: Sendable {
+ @MainActor func all() throws -> [TrustedDeviceLocalCredential]
+ @MainActor func all(appIdentifier: String) throws -> [TrustedDeviceLocalCredential]
+ @MainActor func credential(id: String) throws -> TrustedDeviceLocalCredential?
+ @MainActor func save(
+ _ credential: TrustedDeviceLocalCredential,
+ deleteReplacedLocalKey: (String) throws -> Void
+ ) throws
+ @MainActor func delete(id: String) throws
+ @MainActor func deleteAll() throws
+ @MainActor func deleteLocalCredentials(
+ appIdentifier: String,
+ keyManager: any TrustedDeviceKeyManagerProtocol
+ ) throws
+ @MainActor func deleteAllLocalCredentials(keyManager: any TrustedDeviceKeyManagerProtocol) throws
+}
+
+extension TrustedDeviceLocalCredentialStoreProtocol {
+ @MainActor
+ func save(_ credential: TrustedDeviceLocalCredential) throws {
+ try save(credential, deleteReplacedLocalKey: { _ in })
+ }
+}
+
+final class TrustedDeviceLocalCredentialStore: TrustedDeviceLocalCredentialStoreProtocol {
+ private let keychain: any KeychainStorage
+ private let keychainKey = ClerkKeychainKey.trustedDeviceCredentials.rawValue
+
+ init(keychain: any KeychainStorage) {
+ self.keychain = keychain
+ }
+
+ @MainActor
+ func all() throws -> [TrustedDeviceLocalCredential] {
+ guard let data = try keychain.data(forKey: keychainKey) else {
+ return []
+ }
+ return try Self.metadataDecoder().decode([TrustedDeviceLocalCredential].self, from: data)
+ }
+
+ @MainActor
+ func all(appIdentifier: String) throws -> [TrustedDeviceLocalCredential] {
+ var credentials: [TrustedDeviceLocalCredential] = []
+ for record in try rawCredentialRecords() {
+ guard record[TrustedDeviceLocalCredentialMetadataKey.appIdentifier] as? String == appIdentifier else {
+ continue
+ }
+ guard let credential = try? Self.decodeCredentialRecord(record) else {
+ continue
+ }
+ credentials.append(credential)
+ }
+ return credentials
+ }
+
+ @MainActor
+ func credential(id: String) throws -> TrustedDeviceLocalCredential? {
+ for record in try rawCredentialRecords() {
+ guard record[TrustedDeviceLocalCredentialMetadataKey.id] as? String == id else {
+ continue
+ }
+ return try Self.decodeCredentialRecord(record)
+ }
+
+ return nil
+ }
+
+ @MainActor
+ func save(
+ _ credential: TrustedDeviceLocalCredential,
+ deleteReplacedLocalKey: (String) throws -> Void
+ ) throws {
+ let credentialRecord = try Self.rawCredentialRecord(for: credential)
+ var records: [[String: Any]] = []
+ var replacedLocalKeyIds: [String] = []
+
+ for record in try rawCredentialRecords() {
+ if record[TrustedDeviceLocalCredentialMetadataKey.id] as? String == credential.id {
+ if let localKeyId = record[TrustedDeviceLocalCredentialMetadataKey.localKeyId] as? String,
+ localKeyId != credential.localKeyId
+ {
+ replacedLocalKeyIds.append(localKeyId)
+ }
+ continue
+ }
+ guard record[TrustedDeviceLocalCredentialMetadataKey.appIdentifier] as? String == credential.appIdentifier else {
+ records.append(record)
+ continue
+ }
+ if (try? Self.decodeCredentialRecord(record)) != nil {
+ records.append(record)
+ }
+ }
+
+ records.append(credentialRecord)
+ try persistRawRecords(records)
+
+ for localKeyId in replacedLocalKeyIds {
+ try? deleteReplacedLocalKey(localKeyId)
+ }
+ }
+
+ @MainActor
+ func delete(id: String) throws {
+ try persistRawRecords(rawCredentialRecords().filter {
+ $0[TrustedDeviceLocalCredentialMetadataKey.id] as? String != id
+ })
+ }
+
+ @MainActor
+ func deleteAll() throws {
+ try keychain.deleteItem(forKey: keychainKey)
+ }
+
+ @MainActor
+ func deleteAllLocalCredentials(keyManager: any TrustedDeviceKeyManagerProtocol) throws {
+ try deleteLocalCredentials(keyManager: keyManager, shouldDelete: { _ in
+ true
+ })
+ }
+
+ @MainActor
+ func deleteLocalCredentials(
+ appIdentifier: String,
+ keyManager: any TrustedDeviceKeyManagerProtocol
+ ) throws {
+ try deleteLocalCredentials(
+ keyManager: keyManager,
+ shouldDelete: { $0.appIdentifier == appIdentifier },
+ deleteMalformedLocalCredentials: {
+ try self.deleteMalformedLocalCredentials(appIdentifier: appIdentifier, keyManager: keyManager)
+ }
+ )
+ }
+
+ @MainActor
+ private func deleteLocalCredentials(
+ keyManager: any TrustedDeviceKeyManagerProtocol,
+ shouldDelete: (TrustedDeviceLocalCredential) -> Bool,
+ deleteMalformedLocalCredentials: (() throws -> Void)? = nil
+ ) throws {
+ let credentials: [TrustedDeviceLocalCredential]
+ do {
+ credentials = try all()
+ } catch _ as DecodingError {
+ if let deleteMalformedLocalCredentials {
+ try deleteMalformedLocalCredentials()
+ } else {
+ try deleteAllMalformedLocalCredentials(keyManager: keyManager)
+ }
+ return
+ }
+
+ var remainingCredentials = credentials
+ var keyDeletionError: Error?
+
+ for credential in credentials {
+ guard shouldDelete(credential) else { continue }
+
+ do {
+ try keyManager.deleteKey(localKeyId: credential.localKeyId)
+ } catch {
+ keyDeletionError = keyDeletionError ?? error
+ continue
+ }
+
+ remainingCredentials.removeAll { $0 == credential }
+ try persist(remainingCredentials)
+ }
+
+ if let keyDeletionError {
+ throw keyDeletionError
+ }
+ }
+
+ @MainActor
+ private func deleteMalformedLocalCredentials(
+ appIdentifier: String,
+ keyManager: any TrustedDeviceKeyManagerProtocol
+ ) throws {
+ guard let data = try keychain.data(forKey: keychainKey) else {
+ return
+ }
+
+ let records = try rawCredentialRecords(from: data)
+
+ var deletedRecordIndexes = Set()
+ var keyDeletionError: Error?
+
+ for (index, record) in records.enumerated() {
+ guard record[TrustedDeviceLocalCredentialMetadataKey.appIdentifier] as? String == appIdentifier else {
+ continue
+ }
+
+ guard let localKeyId = record[TrustedDeviceLocalCredentialMetadataKey.localKeyId] as? String else {
+ deletedRecordIndexes.insert(index)
+ try persistRawRecords(records, excludingIndexes: deletedRecordIndexes)
+ continue
+ }
+
+ do {
+ try keyManager.deleteKey(localKeyId: localKeyId)
+ } catch {
+ keyDeletionError = keyDeletionError ?? error
+ continue
+ }
+
+ deletedRecordIndexes.insert(index)
+ try persistRawRecords(records, excludingIndexes: deletedRecordIndexes)
+ }
+
+ if let keyDeletionError {
+ throw keyDeletionError
+ }
+ }
+
+ @MainActor
+ private func deleteAllMalformedLocalCredentials(keyManager: any TrustedDeviceKeyManagerProtocol) throws {
+ guard let data = try keychain.data(forKey: keychainKey) else {
+ return
+ }
+
+ let records: [[String: Any]]
+ do {
+ records = try rawCredentialRecords(from: data)
+ } catch {
+ try deleteAll()
+ return
+ }
+
+ var deletedRecordIndexes = Set()
+ var keyDeletionError: Error?
+ for (index, record) in records.enumerated() {
+ guard let localKeyId = record[TrustedDeviceLocalCredentialMetadataKey.localKeyId] as? String else {
+ deletedRecordIndexes.insert(index)
+ try persistRawRecords(records, excludingIndexes: deletedRecordIndexes)
+ continue
+ }
+
+ do {
+ try keyManager.deleteKey(localKeyId: localKeyId)
+ } catch {
+ keyDeletionError = keyDeletionError ?? error
+ continue
+ }
+
+ deletedRecordIndexes.insert(index)
+ try persistRawRecords(records, excludingIndexes: deletedRecordIndexes)
+ }
+
+ if let keyDeletionError {
+ throw keyDeletionError
+ }
+
+ try deleteAll()
+ }
+
+ @MainActor
+ private func persist(_ credentials: [TrustedDeviceLocalCredential]) throws {
+ if credentials.isEmpty {
+ try deleteAll()
+ return
+ }
+
+ try keychain.set(Self.metadataEncoder().encode(credentials), forKey: keychainKey)
+ }
+
+ @MainActor
+ private func persistRawRecords(_ records: [[String: Any]]) throws {
+ if records.isEmpty {
+ try deleteAll()
+ return
+ }
+
+ try keychain.set(JSONSerialization.data(withJSONObject: records), forKey: keychainKey)
+ }
+
+ @MainActor
+ private func persistRawRecords(
+ _ records: [[String: Any]],
+ excludingIndexes deletedRecordIndexes: Set
+ ) throws {
+ try persistRawRecords(records.enumerated().compactMap { index, record in
+ deletedRecordIndexes.contains(index) ? nil : record
+ })
+ }
+
+ @MainActor
+ private func rawCredentialRecords() throws -> [[String: Any]] {
+ guard let data = try keychain.data(forKey: keychainKey) else {
+ return []
+ }
+
+ return try rawCredentialRecords(from: data)
+ }
+
+ private func rawCredentialRecords(from data: Data) throws -> [[String: Any]] {
+ guard let records = try JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
+ throw DecodingError.dataCorrupted(.init(
+ codingPath: [],
+ debugDescription: "Trusted-device credential metadata is not an array."
+ ))
+ }
+ return records
+ }
+
+ private static func decodeCredentialRecord(_ record: [String: Any]) throws -> TrustedDeviceLocalCredential {
+ try metadataDecoder().decode(
+ TrustedDeviceLocalCredential.self,
+ from: JSONSerialization.data(withJSONObject: record)
+ )
+ }
+
+ private static func rawCredentialRecord(for credential: TrustedDeviceLocalCredential) throws -> [String: Any] {
+ let data = try metadataEncoder().encode(credential)
+ guard let record = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ throw EncodingError.invalidValue(credential, .init(
+ codingPath: [],
+ debugDescription: "Trusted-device credential metadata is not an object."
+ ))
+ }
+ return record
+ }
+
+ private static func metadataEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .millisecondsSince1970
+ return encoder
+ }
+
+ private static func metadataDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .millisecondsSince1970
+ return decoder
+ }
+}
+
+private enum TrustedDeviceLocalCredentialMetadataKey {
+ static let id = "id"
+ static let localKeyId = "localKeyId"
+ static let appIdentifier = "appIdentifier"
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevicePolicy.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevicePolicy.swift
new file mode 100644
index 000000000..4fbf82e8d
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDevicePolicy.swift
@@ -0,0 +1,20 @@
+//
+// TrustedDevicePolicy.swift
+// Clerk
+//
+
+import Foundation
+
+/// The local authentication policy used to protect a trusted-device private key.
+public enum TrustedDevicePolicy: String, Codable, Equatable, Sendable {
+ /// Require a biometric from the currently enrolled set.
+ ///
+ /// Adding or removing Face ID / Touch ID enrollment invalidates the private key.
+ case biometryCurrentSet = "biometry_current_set"
+
+ /// Require biometric authentication, but allow biometric enrollment changes.
+ case biometryAny = "biometry_any"
+
+ /// Require biometric availability, then allow biometric or device passcode authentication.
+ case biometryOrDevicePasscode = "biometry_or_device_passcode"
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceService.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceService.swift
new file mode 100644
index 000000000..d1ffeab7e
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceService.swift
@@ -0,0 +1,79 @@
+//
+// TrustedDeviceService.swift
+// Clerk
+//
+
+import Foundation
+
+protocol TrustedDeviceServiceProtocol: Sendable {
+ @MainActor func list() async throws -> [TrustedDevice]
+ @MainActor func prepareEnrollment(params: TrustedDevice.PrepareEnrollmentParams) async throws -> TrustedDeviceChallenge
+ @MainActor func attemptEnrollment(params: TrustedDevice.AttemptEnrollmentParams) async throws -> TrustedDevice
+ @MainActor func validateSignInCredential(trustedDeviceId: String) async throws -> TrustedDeviceValidation
+ @MainActor func revoke(trustedDeviceId: String) async throws -> TrustedDevice
+}
+
+final class TrustedDeviceService: TrustedDeviceServiceProtocol {
+ private let apiClient: APIClient
+
+ init(apiClient: APIClient) {
+ self.apiClient = apiClient
+ }
+
+ @MainActor
+ func list() async throws -> [TrustedDevice] {
+ let request = Request>(
+ path: "/v1/me/trusted_devices",
+ method: .get,
+ query: [("_clerk_session_id", value: Clerk.shared.session?.id)]
+ )
+
+ return try await apiClient.send(request).value.response
+ }
+
+ @MainActor
+ func prepareEnrollment(params: TrustedDevice.PrepareEnrollmentParams) async throws -> TrustedDeviceChallenge {
+ let request = Request>(
+ path: "/v1/me/trusted_devices/prepare",
+ method: .post,
+ query: [("_clerk_session_id", value: Clerk.shared.session?.id)],
+ body: params
+ )
+
+ return try await apiClient.send(request).value.response
+ }
+
+ @MainActor
+ func attemptEnrollment(params: TrustedDevice.AttemptEnrollmentParams) async throws -> TrustedDevice {
+ let request = Request>(
+ path: "/v1/me/trusted_devices/attempt",
+ method: .post,
+ query: [("_clerk_session_id", value: Clerk.shared.session?.id)],
+ body: params
+ )
+
+ return try await apiClient.send(request).value.response
+ }
+
+ @MainActor
+ func validateSignInCredential(trustedDeviceId: String) async throws -> TrustedDeviceValidation {
+ let request = Request>(
+ path: "/v1/client/trusted_devices/validate",
+ method: .post,
+ body: TrustedDeviceValidation.Params(trustedDeviceId: trustedDeviceId)
+ )
+
+ return try await apiClient.send(request).value.response
+ }
+
+ @MainActor
+ func revoke(trustedDeviceId: String) async throws -> TrustedDevice {
+ let request = Request>(
+ path: "/v1/me/trusted_devices/\(trustedDeviceId)",
+ method: .delete,
+ query: [("_clerk_session_id", value: Clerk.shared.session?.id)]
+ )
+
+ return try await apiClient.send(request).value.response
+ }
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidation.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidation.swift
new file mode 100644
index 000000000..b6fac528b
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidation.swift
@@ -0,0 +1,16 @@
+//
+// TrustedDeviceValidation.swift
+// Clerk
+//
+
+import Foundation
+
+package struct TrustedDeviceValidation: Codable, Equatable {
+ var valid: Bool
+}
+
+extension TrustedDeviceValidation {
+ struct Params: Encodable {
+ var trustedDeviceId: String
+ }
+}
diff --git a/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidationResult.swift b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidationResult.swift
new file mode 100644
index 000000000..b40a27746
--- /dev/null
+++ b/Sources/ClerkKit/Domains/Auth/TrustedDevice/TrustedDeviceValidationResult.swift
@@ -0,0 +1,12 @@
+//
+// TrustedDeviceValidationResult.swift
+// Clerk
+//
+
+import Foundation
+
+package enum TrustedDeviceValidationResult: Equatable {
+ case valid
+ case invalid(TrustedDeviceAvailability.UnavailableReason)
+ case inconclusive
+}
diff --git a/Sources/ClerkKit/Domains/Auth/Verification.swift b/Sources/ClerkKit/Domains/Auth/Verification.swift
index 120cdbafa..129c66808 100644
--- a/Sources/ClerkKit/Domains/Auth/Verification.swift
+++ b/Sources/ClerkKit/Domains/Auth/Verification.swift
@@ -27,6 +27,9 @@ public struct Verification: Codable, Equatable, Hashable, Sendable {
/// The nonce pertaining to the verification.
public var nonce: String?
+ /// The challenge payload for trusted-device sign-in verifications.
+ public var trustedDeviceChallenge: TrustedDeviceChallenge?
+
public init(
status: Verification.Status? = nil,
strategy: FactorStrategy? = nil,
@@ -34,7 +37,8 @@ public struct Verification: Codable, Equatable, Hashable, Sendable {
expireAt: Date? = nil,
error: ClerkAPIError? = nil,
externalVerificationRedirectUrl: String? = nil,
- nonce: String? = nil
+ nonce: String? = nil,
+ trustedDeviceChallenge: TrustedDeviceChallenge? = nil
) {
self.status = status
self.strategy = strategy
@@ -43,6 +47,7 @@ public struct Verification: Codable, Equatable, Hashable, Sendable {
self.error = error
self.externalVerificationRedirectUrl = externalVerificationRedirectUrl
self.nonce = nonce
+ self.trustedDeviceChallenge = trustedDeviceChallenge
}
/// The state of the verification.
diff --git a/Sources/ClerkKit/Domains/Environment/AuthConfig.swift b/Sources/ClerkKit/Domains/Environment/AuthConfig.swift
index f6992372e..33b7e3d41 100644
--- a/Sources/ClerkKit/Domains/Environment/AuthConfig.swift
+++ b/Sources/ClerkKit/Domains/Environment/AuthConfig.swift
@@ -8,5 +8,66 @@ import Foundation
extension Clerk.Environment {
public struct AuthConfig: Codable, Sendable, Equatable {
public var singleSessionMode: Bool
+ public var nativeSettings: NativeSettings
+
+ public init(
+ singleSessionMode: Bool,
+ nativeSettings: NativeSettings = .default
+ ) {
+ self.singleSessionMode = singleSessionMode
+ self.nativeSettings = nativeSettings
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ singleSessionMode = try container.decode(Bool.self, forKey: .singleSessionMode)
+ nativeSettings = try container.decodeIfPresent(NativeSettings.self, forKey: .nativeSettings) ?? .default
+ }
+ }
+}
+
+extension Clerk.Environment.AuthConfig {
+ public struct NativeSettings: Codable, Sendable, Equatable {
+ public var apiEnabled: Bool
+ public var trustedDeviceSignInEnabled: Bool
+ public var trustedDevicePromptAfterSignInEnabled: Bool
+ public var trustedDevicePromptAfterSignUpEnabled: Bool
+
+ public static let `default` = NativeSettings(
+ apiEnabled: false,
+ trustedDeviceSignInEnabled: false,
+ trustedDevicePromptAfterSignInEnabled: false,
+ trustedDevicePromptAfterSignUpEnabled: false
+ )
+
+ public init(
+ apiEnabled: Bool,
+ trustedDeviceSignInEnabled: Bool,
+ trustedDevicePromptAfterSignInEnabled: Bool = false,
+ trustedDevicePromptAfterSignUpEnabled: Bool = false
+ ) {
+ self.apiEnabled = apiEnabled
+ self.trustedDeviceSignInEnabled = trustedDeviceSignInEnabled
+ self.trustedDevicePromptAfterSignInEnabled = trustedDevicePromptAfterSignInEnabled
+ self.trustedDevicePromptAfterSignUpEnabled = trustedDevicePromptAfterSignUpEnabled
+ }
+
+ enum CodingKeys: String, CodingKey {
+ case apiEnabled
+ case trustedDeviceSignInEnabled
+ case trustedDevicePromptAfterSignInEnabled = "trustedDeviceEnrollmentPromptAfterSignInEnabled"
+ case trustedDevicePromptAfterSignUpEnabled = "trustedDeviceEnrollmentPromptAfterSignUpEnabled"
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ apiEnabled = try container.decodeIfPresent(Bool.self, forKey: .apiEnabled) ?? false
+ trustedDeviceSignInEnabled =
+ try container.decodeIfPresent(Bool.self, forKey: .trustedDeviceSignInEnabled) ?? false
+ trustedDevicePromptAfterSignInEnabled =
+ try container.decodeIfPresent(Bool.self, forKey: .trustedDevicePromptAfterSignInEnabled) ?? false
+ trustedDevicePromptAfterSignUpEnabled =
+ try container.decodeIfPresent(Bool.self, forKey: .trustedDevicePromptAfterSignUpEnabled) ?? false
+ }
}
}
diff --git a/Sources/ClerkKit/Mocks/MockDependencyContainer.swift b/Sources/ClerkKit/Mocks/MockDependencyContainer.swift
index 72340bb6a..a8fb968bd 100644
--- a/Sources/ClerkKit/Mocks/MockDependencyContainer.swift
+++ b/Sources/ClerkKit/Mocks/MockDependencyContainer.swift
@@ -15,6 +15,8 @@ import Foundation
final class MockDependencyContainer: Dependencies {
let networkingPipeline: NetworkingPipeline
let keychain: any KeychainStorage
+ let trustedDeviceKeyManager: any TrustedDeviceKeyManagerProtocol
+ let trustedDeviceCredentialStore: any TrustedDeviceLocalCredentialStoreProtocol
let configurationManager: ConfigurationManager
let apiClient: APIClient
let telemetryCollector: any TelemetryCollectorProtocol
@@ -26,6 +28,7 @@ final class MockDependencyContainer: Dependencies {
let sessionService: SessionServiceProtocol
let magicLinkService: MagicLinkServiceProtocol
let passkeyService: PasskeyServiceProtocol
+ let trustedDeviceService: TrustedDeviceServiceProtocol
let organizationService: OrganizationServiceProtocol
let environmentService: EnvironmentServiceProtocol
let emailAddressService: EmailAddressServiceProtocol
@@ -40,6 +43,8 @@ final class MockDependencyContainer: Dependencies {
/// - Parameters:
/// - apiClient: The API client to use (typically a mock for tests/previews).
/// - keychain: Optional keychain storage (defaults to InMemoryKeychain).
+ /// - trustedDeviceKeyManager: Optional trusted-device key manager (defaults to MockTrustedDeviceKeyManager).
+ /// - trustedDeviceCredentialStore: Optional trusted-device credential store.
/// - telemetryCollector: Optional telemetry collector (defaults to NoOpTelemetryCollector).
/// - clientService: Optional custom client service (defaults to MockClientService with Client.mock).
/// - userService: Optional custom user service (defaults to MockUserService).
@@ -48,6 +53,7 @@ final class MockDependencyContainer: Dependencies {
/// - sessionService: Optional custom session service (defaults to MockSessionService).
/// - magicLinkService: Optional custom magic-link service (defaults to MockMagicLinkService).
/// - passkeyService: Optional custom passkey service (defaults to MockPasskeyService).
+ /// - trustedDeviceService: Optional custom trusted-device service (defaults to MockTrustedDeviceService).
/// - organizationService: Optional custom organization service (defaults to MockOrganizationService).
/// - environmentService: Optional custom environment service (defaults to MockEnvironmentService with Clerk.Environment.mock).
/// - emailAddressService: Optional custom email address service (defaults to MockEmailAddressService).
@@ -56,6 +62,8 @@ final class MockDependencyContainer: Dependencies {
init(
apiClient: APIClient,
keychain: (any KeychainStorage)? = nil,
+ trustedDeviceKeyManager: (any TrustedDeviceKeyManagerProtocol)? = nil,
+ trustedDeviceCredentialStore: (any TrustedDeviceLocalCredentialStoreProtocol)? = nil,
telemetryCollector: (any TelemetryCollectorProtocol)? = nil,
clientService: (any ClientServiceProtocol)? = nil,
userService: (any UserServiceProtocol)? = nil,
@@ -64,6 +72,7 @@ final class MockDependencyContainer: Dependencies {
sessionService: (any SessionServiceProtocol)? = nil,
magicLinkService: (any MagicLinkServiceProtocol)? = nil,
passkeyService: (any PasskeyServiceProtocol)? = nil,
+ trustedDeviceService: (any TrustedDeviceServiceProtocol)? = nil,
organizationService: (any OrganizationServiceProtocol)? = nil,
environmentService: (any EnvironmentServiceProtocol)? = nil,
emailAddressService: (any EmailAddressServiceProtocol)? = nil,
@@ -71,7 +80,11 @@ final class MockDependencyContainer: Dependencies {
externalAccountService: (any ExternalAccountServiceProtocol)? = nil
) {
networkingPipeline = NetworkingPipeline()
- self.keychain = keychain ?? InMemoryKeychain()
+ let resolvedKeychain = keychain ?? InMemoryKeychain()
+ self.keychain = resolvedKeychain
+ self.trustedDeviceKeyManager = trustedDeviceKeyManager ?? MockTrustedDeviceKeyManager()
+ self.trustedDeviceCredentialStore =
+ trustedDeviceCredentialStore ?? TrustedDeviceLocalCredentialStore(keychain: resolvedKeychain)
configurationManager = ConfigurationManager()
self.apiClient = apiClient
self.telemetryCollector = telemetryCollector ?? NoOpTelemetryCollector()
@@ -86,6 +99,7 @@ final class MockDependencyContainer: Dependencies {
self.sessionService = sessionService ?? MockSessionService()
self.magicLinkService = magicLinkService ?? MockMagicLinkService()
self.passkeyService = passkeyService ?? MockPasskeyService()
+ self.trustedDeviceService = trustedDeviceService ?? MockTrustedDeviceService()
self.organizationService = organizationService ?? MockOrganizationService()
self.environmentService = environmentService ?? MockEnvironmentService()
self.emailAddressService = emailAddressService ?? MockEmailAddressService()
diff --git a/Sources/ClerkKit/Mocks/MockModels.swift b/Sources/ClerkKit/Mocks/MockModels.swift
index 6e380576d..f388ac949 100644
--- a/Sources/ClerkKit/Mocks/MockModels.swift
+++ b/Sources/ClerkKit/Mocks/MockModels.swift
@@ -431,6 +431,65 @@ extension Passkey {
}
}
+// MARK: TrustedDevice
+
+extension TrustedDevice {
+ public static var mock: TrustedDevice {
+ TrustedDevice(
+ id: "tdc_123",
+ platform: .iOS,
+ appIdentifier: "com.clerk.example",
+ name: "Sean's iPhone",
+ algorithm: .es256,
+ status: .active,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ lastUsedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ }
+}
+
+// MARK: TrustedDeviceChallenge
+
+extension TrustedDeviceChallenge {
+ public static var mock: TrustedDeviceChallenge {
+ TrustedDeviceChallenge(
+ challenge: "mock_challenge",
+ challengeId: "tdch_123",
+ trustedDeviceId: "tdc_123",
+ clientData: "{\"challenge_id\":\"tdch_123\"}",
+ expiresAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ algorithm: .es256
+ )
+ }
+}
+
+// MARK: TrustedDeviceLocalKey
+
+extension TrustedDeviceLocalKey {
+ package static var mock: TrustedDeviceLocalKey {
+ TrustedDeviceLocalKey(
+ localKeyId: "tdlk_mock",
+ publicKeyJWK: "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"x\",\"y\":\"y\",\"alg\":\"ES256\"}"
+ )
+ }
+}
+
+// MARK: TrustedDeviceLocalCredential
+
+extension TrustedDeviceLocalCredential {
+ package static var mock: TrustedDeviceLocalCredential {
+ TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_mock",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ }
+}
+
// MARK: TokenResource
extension TokenResource {
diff --git a/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceKeyManager.swift b/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceKeyManager.swift
new file mode 100644
index 000000000..e66da0940
--- /dev/null
+++ b/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceKeyManager.swift
@@ -0,0 +1,80 @@
+//
+// MockTrustedDeviceKeyManager.swift
+// Clerk
+//
+
+import Foundation
+
+package final class MockTrustedDeviceKeyManager: TrustedDeviceKeyManagerProtocol {
+ package nonisolated(unsafe) var isSupportedValue: Bool
+ package nonisolated(unsafe) var isSupportedForPolicyHandler: ((TrustedDevicePolicy) -> Bool)?
+ package nonisolated(unsafe) var createKeyHandler: ((TrustedDevicePolicy) throws -> TrustedDeviceLocalKey)?
+ package nonisolated(unsafe) var signHandler: ((String, String, String?) throws -> TrustedDeviceKeySignature)?
+ package nonisolated(unsafe) var hasKeyHandler: ((String) throws -> Bool)?
+ package nonisolated(unsafe) var deleteKeyHandler: ((String) throws -> Void)?
+
+ package init(
+ isSupported: Bool = true,
+ isSupportedForPolicy: ((TrustedDevicePolicy) -> Bool)? = nil,
+ createKey: (() throws -> TrustedDeviceLocalKey)? = nil,
+ createKeyWithPolicy: ((TrustedDevicePolicy) throws -> TrustedDeviceLocalKey)? = nil,
+ sign: ((String, String, String?) throws -> TrustedDeviceKeySignature)? = nil,
+ hasKey: ((String) throws -> Bool)? = nil,
+ deleteKey: ((String) throws -> Void)? = nil
+ ) {
+ isSupportedValue = isSupported
+ isSupportedForPolicyHandler = isSupportedForPolicy
+ if let createKeyWithPolicy {
+ createKeyHandler = createKeyWithPolicy
+ } else if let createKey {
+ createKeyHandler = { _ in try createKey() }
+ }
+ signHandler = sign
+ hasKeyHandler = hasKey
+ deleteKeyHandler = deleteKey
+ }
+
+ @MainActor
+ package func isSupported(policy: TrustedDevicePolicy) -> Bool {
+ isSupportedForPolicyHandler?(policy) ?? isSupportedValue
+ }
+
+ @MainActor
+ package func createKey(policy: TrustedDevicePolicy) throws -> TrustedDeviceLocalKey {
+ if let createKeyHandler {
+ return try createKeyHandler(policy)
+ }
+ return .init(
+ localKeyId: TrustedDeviceLocalKey.mock.localKeyId,
+ publicKeyJWK: TrustedDeviceLocalKey.mock.publicKeyJWK,
+ policy: policy
+ )
+ }
+
+ @MainActor
+ package func sign(
+ clientData: String,
+ localKeyId: String,
+ localizedReason: String?
+ ) throws -> TrustedDeviceKeySignature {
+ if let signHandler {
+ return try signHandler(clientData, localKeyId, localizedReason)
+ }
+ return .init(clientData: clientData, signature: "mock-signature")
+ }
+
+ @MainActor
+ package func hasKey(localKeyId: String) throws -> Bool {
+ if let hasKeyHandler {
+ return try hasKeyHandler(localKeyId)
+ }
+ return true
+ }
+
+ @MainActor
+ package func deleteKey(localKeyId: String) throws {
+ if let deleteKeyHandler {
+ try deleteKeyHandler(localKeyId)
+ }
+ }
+}
diff --git a/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceService.swift b/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceService.swift
new file mode 100644
index 000000000..380c064d4
--- /dev/null
+++ b/Sources/ClerkKit/Mocks/MockServices/MockTrustedDeviceService.swift
@@ -0,0 +1,68 @@
+//
+// MockTrustedDeviceService.swift
+// Clerk
+//
+
+import Foundation
+
+package final class MockTrustedDeviceService: TrustedDeviceServiceProtocol {
+ package nonisolated(unsafe) var listHandler: (() async throws -> [TrustedDevice])?
+ package nonisolated(unsafe) var prepareEnrollmentHandler: ((TrustedDevice.PrepareEnrollmentParams) async throws -> TrustedDeviceChallenge)?
+ package nonisolated(unsafe) var attemptEnrollmentHandler: ((TrustedDevice.AttemptEnrollmentParams) async throws -> TrustedDevice)?
+ package nonisolated(unsafe) var validateSignInCredentialHandler: ((String) async throws -> TrustedDeviceValidation)?
+ package nonisolated(unsafe) var revokeHandler: ((String) async throws -> TrustedDevice)?
+
+ package init(
+ list: (() async throws -> [TrustedDevice])? = nil,
+ prepareEnrollment: ((TrustedDevice.PrepareEnrollmentParams) async throws -> TrustedDeviceChallenge)? = nil,
+ attemptEnrollment: ((TrustedDevice.AttemptEnrollmentParams) async throws -> TrustedDevice)? = nil,
+ validateSignInCredential: ((String) async throws -> TrustedDeviceValidation)? = nil,
+ revoke: ((String) async throws -> TrustedDevice)? = nil
+ ) {
+ listHandler = list
+ prepareEnrollmentHandler = prepareEnrollment
+ attemptEnrollmentHandler = attemptEnrollment
+ validateSignInCredentialHandler = validateSignInCredential
+ revokeHandler = revoke
+ }
+
+ @MainActor
+ package func list() async throws -> [TrustedDevice] {
+ if let handler = listHandler {
+ return try await handler()
+ }
+ return [.mock]
+ }
+
+ @MainActor
+ package func prepareEnrollment(params: TrustedDevice.PrepareEnrollmentParams) async throws -> TrustedDeviceChallenge {
+ if let handler = prepareEnrollmentHandler {
+ return try await handler(params)
+ }
+ return .mock
+ }
+
+ @MainActor
+ package func attemptEnrollment(params: TrustedDevice.AttemptEnrollmentParams) async throws -> TrustedDevice {
+ if let handler = attemptEnrollmentHandler {
+ return try await handler(params)
+ }
+ return .mock
+ }
+
+ @MainActor
+ package func validateSignInCredential(trustedDeviceId: String) async throws -> TrustedDeviceValidation {
+ if let handler = validateSignInCredentialHandler {
+ return try await handler(trustedDeviceId)
+ }
+ return .init(valid: true)
+ }
+
+ @MainActor
+ package func revoke(trustedDeviceId: String) async throws -> TrustedDevice {
+ if let handler = revokeHandler {
+ return try await handler(trustedDeviceId)
+ }
+ return .mock
+ }
+}
diff --git a/Sources/ClerkKit/Networking/Middleware/ClerkAuthEventEmitterResponseMiddleware.swift b/Sources/ClerkKit/Networking/Middleware/ClerkAuthEventEmitterResponseMiddleware.swift
index 09365c282..6e183e97d 100644
--- a/Sources/ClerkKit/Networking/Middleware/ClerkAuthEventEmitterResponseMiddleware.swift
+++ b/Sources/ClerkKit/Networking/Middleware/ClerkAuthEventEmitterResponseMiddleware.swift
@@ -13,62 +13,12 @@ struct ClerkAuthEventEmitterResponseMiddleware: ClerkResponseMiddleware {
}
func validate(_: HTTPURLResponse, data: Data, for _: URLRequest) async throws {
- guard let responseObject = Self.decodeResponseObject(from: data) else {
+ guard let event = ClerkAuthResponseDecoder.decodeEvent(from: data) else {
return
}
- switch responseObject {
- case .signUp:
- if let signUp = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
- signUp.status == .complete
- {
- try await runtimeScope.withCurrentClerk {
- $0.auth.send(.signUpCompleted(signUp: signUp))
- }
- }
- case .signIn:
- if let signIn = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
- signIn.status == .complete
- {
- try await runtimeScope.withCurrentClerk {
- $0.auth.send(.signInCompleted(signIn: signIn))
- }
- }
- case .session:
- if let session = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
- session.status == .removed
- {
- try await runtimeScope.withCurrentClerk {
- $0.auth.send(.signedOut(session: session))
- }
- }
- case .other:
- return
+ try await runtimeScope.withCurrentClerk {
+ $0.auth.send(event)
}
}
-
- private static func decodeResponseObject(from data: Data) -> ResponseObject? {
- try? JSONDecoder.clerkDecoder.decode(ResponseEnvelope.self, from: data).response.object
- }
-}
-
-private struct ResponseEnvelope: Decodable {
- let response: ResponseObjectPayload
-}
-
-private struct ResponseObjectPayload: Decodable {
- let object: ResponseObject
-}
-
-private enum ResponseObject: String, Decodable {
- case signIn = "sign_in_attempt"
- case signUp = "sign_up_attempt"
- case session
- case other
-
- init(from decoder: Decoder) throws {
- let container = try decoder.singleValueContainer()
- let rawValue = try container.decode(String.self)
- self = ResponseObject(rawValue: rawValue) ?? .other
- }
}
diff --git a/Sources/ClerkKit/Networking/Middleware/ClerkAuthResponseDecoder.swift b/Sources/ClerkKit/Networking/Middleware/ClerkAuthResponseDecoder.swift
new file mode 100644
index 000000000..15cd7cb9b
--- /dev/null
+++ b/Sources/ClerkKit/Networking/Middleware/ClerkAuthResponseDecoder.swift
@@ -0,0 +1,74 @@
+//
+// ClerkAuthResponseDecoder.swift
+// Clerk
+//
+
+import Foundation
+
+enum ClerkAuthResponseDecoder {
+ static func decodeEvent(from data: Data) -> AuthEvent? {
+ guard let responseObject = try? JSONDecoder.clerkDecoder.decode(ResponseEnvelope.self, from: data).response.object else {
+ return nil
+ }
+
+ switch responseObject {
+ case .signUp:
+ guard let signUp = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
+ signUp.status == .complete
+ else {
+ return nil
+ }
+ return .signUpCompleted(signUp: signUp)
+ case .signIn:
+ guard let signIn = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
+ signIn.status == .complete
+ else {
+ return nil
+ }
+ return .signInCompleted(signIn: signIn)
+ case .session:
+ guard let session = try? JSONDecoder.clerkDecoder.decode(ClientResponse.self, from: data).response,
+ session.status == .removed
+ else {
+ return nil
+ }
+ return .signedOut(session: session)
+ case .other:
+ return nil
+ }
+ }
+
+ static func decodeCompletedAuthFlow(from data: Data) -> TransferFlowResult? {
+ switch decodeEvent(from: data) {
+ case .signInCompleted(let signIn):
+ .signIn(signIn)
+ case .signUpCompleted(let signUp):
+ .signUp(signUp)
+ default:
+ nil
+ }
+ }
+}
+
+extension ClerkAuthResponseDecoder {
+ private struct ResponseEnvelope: Decodable {
+ let response: ResponseObjectPayload
+ }
+
+ private struct ResponseObjectPayload: Decodable {
+ let object: ResponseObject
+ }
+
+ private enum ResponseObject: String, Decodable {
+ case signIn = "sign_in_attempt"
+ case signUp = "sign_up_attempt"
+ case session
+ case other
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ let rawValue = try container.decode(String.self)
+ self = ResponseObject(rawValue: rawValue) ?? .other
+ }
+ }
+}
diff --git a/Sources/ClerkKit/Networking/Middleware/ClerkClientSyncResponseMiddleware.swift b/Sources/ClerkKit/Networking/Middleware/ClerkClientSyncResponseMiddleware.swift
index 8fee81eee..17cf5b7f5 100644
--- a/Sources/ClerkKit/Networking/Middleware/ClerkClientSyncResponseMiddleware.swift
+++ b/Sources/ClerkKit/Networking/Middleware/ClerkClientSyncResponseMiddleware.swift
@@ -15,6 +15,7 @@ struct ClerkClientSyncResponseMiddleware: ClerkResponseMiddleware {
func validate(_ response: HTTPURLResponse, data: Data, for request: URLRequest) async throws {
let responseSequence = request.clerkRequestSequence
let serverDate = response.serverDate
+ let completedAuthFlow = ClerkAuthResponseDecoder.decodeCompletedAuthFlow(from: data)
if let client = Self.decodeClient(from: data) {
try await runtimeScope.withCurrentClerk {
@@ -22,7 +23,8 @@ struct ClerkClientSyncResponseMiddleware: ClerkResponseMiddleware {
client,
responseSequence: responseSequence,
serverDate: serverDate,
- clientResponseGeneration: request.clerkClientResponseGeneration
+ clientResponseGeneration: request.clerkClientResponseGeneration,
+ completedAuthFlow: completedAuthFlow
)
}
} else if hasExplicitNullClientField(in: data) {
diff --git a/Sources/ClerkKit/Storage/Keychain/Clerk+Keychain.swift b/Sources/ClerkKit/Storage/Keychain/Clerk+Keychain.swift
index e7182d07e..9ea4ea5de 100644
--- a/Sources/ClerkKit/Storage/Keychain/Clerk+Keychain.swift
+++ b/Sources/ClerkKit/Storage/Keychain/Clerk+Keychain.swift
@@ -41,8 +41,25 @@ extension Clerk {
@MainActor
static func clearAllKeychainItems(in keychain: any KeychainStorage) {
+ var trustedDeviceCredentialDeletionFailed = false
+
+ do {
+ try TrustedDeviceLocalCredentialStore(keychain: keychain)
+ .deleteAllLocalCredentials(keyManager: TrustedDeviceKeyManager())
+ } catch {
+ trustedDeviceCredentialDeletionFailed = true
+ ClerkLogger.logError(
+ error,
+ message: "Failed to delete trusted-device local credentials. This is non-critical."
+ )
+ }
+
// Iterate over all keychain keys and delete each one
for key in ClerkKeychainKey.allCases {
+ guard key != .trustedDeviceCredentials || !trustedDeviceCredentialDeletionFailed else {
+ continue
+ }
+
do {
try keychain.deleteItem(forKey: key.rawValue)
} catch {
@@ -58,8 +75,25 @@ extension Clerk {
@MainActor
static func clearAllKeychainItemsStrictly(in keychain: any KeychainStorage) throws {
var failures: [String] = []
+ var trustedDeviceCredentialDeletionFailed = false
+
+ do {
+ try TrustedDeviceLocalCredentialStore(keychain: keychain)
+ .deleteAllLocalCredentials(keyManager: TrustedDeviceKeyManager())
+ } catch {
+ trustedDeviceCredentialDeletionFailed = true
+ failures.append(ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+ ClerkLogger.logError(
+ error,
+ message: "Failed to delete trusted-device local credentials during Clerk reconfiguration."
+ )
+ }
for key in ClerkKeychainKey.allCases {
+ guard key != .trustedDeviceCredentials || !trustedDeviceCredentialDeletionFailed else {
+ continue
+ }
+
do {
try keychain.deleteItem(forKey: key.rawValue)
} catch {
diff --git a/Sources/ClerkKit/Storage/Keychain/ClerkKeychainKey.swift b/Sources/ClerkKit/Storage/Keychain/ClerkKeychainKey.swift
index 0c093e3c2..676f75731 100644
--- a/Sources/ClerkKit/Storage/Keychain/ClerkKeychainKey.swift
+++ b/Sources/ClerkKit/Storage/Keychain/ClerkKeychainKey.swift
@@ -59,4 +59,7 @@ enum ClerkKeychainKey: String, CaseIterable {
/// Key for the pending magic-link flow.
case pendingMagicLinkFlow
+
+ /// Key for trusted-device credential metadata.
+ case trustedDeviceCredentials
}
diff --git a/Sources/ClerkKitUI/Components/Auth/AuthNavigation.swift b/Sources/ClerkKitUI/Components/Auth/AuthNavigation.swift
index 47fc9b706..cc337df15 100644
--- a/Sources/ClerkKitUI/Components/Auth/AuthNavigation.swift
+++ b/Sources/ClerkKitUI/Components/Auth/AuthNavigation.swift
@@ -19,8 +19,11 @@ final class AuthNavigation {
/// The navigation path for the auth flow.
var path: [AuthView.Destination] = []
- /// Set to `true` when a session task flow completes and auth navigation should continue.
- private(set) var allTasksComplete = false
+ /// Set to `true` when post-auth steps no longer need in-flow routing.
+ private(set) var postAuthStepsComplete = false
+
+ /// Whether trusted-device enrollment has already been offered in this auth flow.
+ private(set) var trustedDeviceEnrollmentWasOffered = false
/// Creates a new AuthNavigation instance.
init() {}
@@ -98,18 +101,37 @@ final class AuthNavigation {
/// Handles a completed session task by routing to the next task if present.
///
- /// Sets `allTasksComplete` when there are no more pending tasks;
+ /// Marks post-auth steps complete when there are no more pending tasks;
/// otherwise appends the next task start destination to `path`.
@MainActor
func handleSessionTaskCompletion(session: Session?) {
guard let nextTask = nextPendingSessionTask(from: session) else {
- allTasksComplete = true
+ markPostAuthStepsComplete()
return
}
path.append(.sessionTaskStart(task: nextTask))
}
+ @MainActor
+ func markPostAuthStepsComplete() {
+ postAuthStepsComplete = true
+ }
+
+ @MainActor
+ func resetForNewAuthFlow() {
+ path = []
+ postAuthStepsComplete = false
+ trustedDeviceEnrollmentWasOffered = false
+ }
+
+ @MainActor
+ func routeToTrustedDeviceEnrollment() {
+ trustedDeviceEnrollmentWasOffered = true
+ guard !hasTrustedDeviceEnrollmentInPath else { return }
+ path.append(.trustedDeviceEnrollment)
+ }
+
var hasSessionTaskStartInPath: Bool {
path.contains { destination in
if case .sessionTaskStart = destination {
@@ -120,6 +142,10 @@ final class AuthNavigation {
}
}
+ var hasTrustedDeviceEnrollmentInPath: Bool {
+ path.contains(.trustedDeviceEnrollment)
+ }
+
@MainActor
private func handleMissingRequirements(signUp: SignUp) {
if let firstFieldToVerify = signUp.firstFieldToVerify {
diff --git a/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift b/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift
index 3f26dc23d..91a81b766 100644
--- a/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift
+++ b/Sources/ClerkKitUI/Components/Auth/AuthStartView.swift
@@ -27,6 +27,7 @@ struct AuthStartView: View {
@State private var automaticPasskeySignInTaskGeneration = 0
@State private var automaticPasskeySignInRestartID = 0
@State private var automaticPasskeySignInHasStarted = false
+ @State private var trustedDeviceAvailability: TrustedDeviceAvailability?
// MARK: - Configuration
@@ -58,7 +59,7 @@ struct AuthStartView: View {
}
var showOrDivider: Bool {
- !(clerk.environment?.authenticatableSocialProviders ?? []).isEmpty && showIdentifierField
+ hasAlternativeAuthMethods && showIdentifierField
}
var phoneNumberInputIsActive: Bool {
@@ -157,7 +158,18 @@ struct AuthStartView: View {
private var lastUsedAuth: LastUsedAuth? {
guard authState.persistsIdentifiers else { return nil }
- return LastUsedAuth(environment: clerk.environment)
+ return LastUsedAuth(
+ environment: clerk.environment,
+ trustedDeviceSignInIsVisible: shouldShowTrustedDeviceSignIn
+ )
+ }
+
+ private var hasSocialProviders: Bool {
+ !(clerk.environment?.authenticatableSocialProviders ?? []).isEmpty
+ }
+
+ private var hasAlternativeAuthMethods: Bool {
+ hasSocialProviders || shouldShowTrustedDeviceSignIn
}
// MARK: - Display Strings
@@ -237,7 +249,9 @@ struct AuthStartView: View {
TextDivider(string: "or")
}
- socialButtonsSection
+ if hasAlternativeAuthMethods {
+ alternativeAuthMethodsSection
+ }
}
.padding(.bottom, 32)
@@ -281,6 +295,53 @@ struct AuthStartView: View {
restartAutomaticPasskeySignInAfterEnvironmentRefreshIfNeeded()
}
#endif
+ .task(id: trustedDeviceAvailabilityRefreshState) {
+ await refreshTrustedDeviceAvailability()
+ }
+ }
+}
+
+enum AuthStartTrustedDeviceRefreshState: Equatable {
+ case disabled
+ case signedOut(clientID: String?)
+
+ static func state(
+ trustedDeviceFeatureIsEnabled: Bool,
+ activeSessionID: String?,
+ clientID: String?
+ ) -> Self {
+ guard trustedDeviceFeatureIsEnabled else {
+ return .disabled
+ }
+ guard activeSessionID == nil else {
+ return .disabled
+ }
+ return .signedOut(clientID: clientID)
+ }
+}
+
+extension AuthStartView {
+ private var trustedDeviceFeatureIsEnabled: Bool {
+ guard let nativeSettings = clerk.environment?.authConfig.nativeSettings else {
+ return false
+ }
+
+ return nativeSettings.apiEnabled &&
+ nativeSettings.trustedDeviceSignInEnabled
+ }
+
+ private var shouldShowTrustedDeviceSignIn: Bool {
+ trustedDeviceFeatureIsEnabled &&
+ authState.mode != .signUp &&
+ trustedDeviceAvailability?.isAvailable == true
+ }
+
+ private var trustedDeviceAvailabilityRefreshState: AuthStartTrustedDeviceRefreshState {
+ .state(
+ trustedDeviceFeatureIsEnabled: trustedDeviceFeatureIsEnabled,
+ activeSessionID: clerk.session?.status == .active ? clerk.session?.id : nil,
+ clientID: clerk.client?.id
+ )
}
}
@@ -376,6 +437,27 @@ extension AuthStartView {
.simultaneousGesture(TapGesture())
}
+ private var trustedDeviceSignInButton: some View {
+ TrustedDeviceSignInButton {
+ cancelAutomaticPasskeySignIn()
+ await signInWithTrustedDevice()
+ }
+ .lastUsedAuthBadgeOverlay(lastUsedAuth?.showsTrustedDeviceBadge == true)
+ .simultaneousGesture(TapGesture())
+ }
+
+ private var alternativeAuthMethodsSection: some View {
+ VStack(spacing: 16) {
+ if shouldShowTrustedDeviceSignIn {
+ trustedDeviceSignInButton
+ }
+
+ if hasSocialProviders {
+ socialButtonsSection
+ }
+ }
+ }
+
private var socialButtonsSection: some View {
VStack(spacing: 8) {
if lastUsedAuth?.socialProvider != nil || !socialProvidersMinusLastUsed.isEmpty {
@@ -627,6 +709,61 @@ extension AuthStartView {
false
}
}
+
+ private func refreshTrustedDeviceAvailability() async {
+ guard authState.mode != .signUp, trustedDeviceFeatureIsEnabled else {
+ trustedDeviceAvailability = nil
+ return
+ }
+
+ guard clerk.session?.status != .active else {
+ trustedDeviceAvailability = nil
+ return
+ }
+
+ guard let localAvailability = try? clerk.trustedDevices.localAvailability() else {
+ trustedDeviceAvailability = nil
+ return
+ }
+
+ trustedDeviceAvailability = localAvailability
+ guard localAvailability.isAvailable else { return }
+
+ let validationResult = await clerk.trustedDevices.validateLocalCredentialIfPossible()
+ guard !Task.isCancelled else { return }
+
+ switch validationResult {
+ case .valid:
+ trustedDeviceAvailability = .available
+ case let .invalid(reason):
+ trustedDeviceAvailability = .unavailable(reason)
+ case .inconclusive:
+ break
+ }
+ }
+
+ private func signInWithTrustedDevice() async {
+ generalError = nil
+
+ do {
+ let signIn = try await clerk.auth.signInWithTrustedDevice()
+ guard !Task.isCancelled, navigation.path.isEmpty else { return }
+ navigation.setToStepForStatus(signIn: signIn)
+ } catch {
+ if error.isCancellationError {
+ return
+ }
+
+ if error.isUserCancelledError {
+ restartAutomaticPasskeySignInIfNeeded()
+ return
+ }
+
+ generalError = error
+ await refreshTrustedDeviceAvailability()
+ restartAutomaticPasskeySignInIfNeeded()
+ }
+ }
}
#Preview {
diff --git a/Sources/ClerkKitUI/Components/Auth/AuthView.swift b/Sources/ClerkKitUI/Components/Auth/AuthView.swift
index e86707c2a..ddc20a516 100644
--- a/Sources/ClerkKitUI/Components/Auth/AuthView.swift
+++ b/Sources/ClerkKitUI/Components/Auth/AuthView.swift
@@ -52,7 +52,7 @@ import SwiftUI
///
/// var body: some View {
/// Group {
-/// if clerk.user != nil {
+/// if clerk.isAuthFlowComplete {
/// UserProfileView(isDismissible: false)
/// } else {
/// AuthView(isDismissible: false)
@@ -77,6 +77,9 @@ public struct AuthView: View {
/// Error to present to the user.
@State private var error: Error?
+ /// Keeps a non-dismissible auth flow registered for this view's lifetime.
+ @State private var authFlowRegistration: AuthFlowRegistration?
+
/// Rate limiter for verification codes.
@State private var codeLimiter = CodeLimiter()
@@ -112,8 +115,8 @@ public struct AuthView: View {
}
init(
- mode: Mode,
- isDismissible: Bool,
+ mode: Mode = .signInOrUp,
+ isDismissible: Bool = true,
config: AuthConfig
) {
_authState = State(initialValue: AuthState(mode: mode, config: config))
@@ -158,7 +161,10 @@ public struct AuthView: View {
.environment(authState)
.environment(codeLimiter)
.onAppear {
- navigation.routeToSessionTaskStartIfNeeded(session: clerk.session)
+ if !isDismissible, authFlowRegistration == nil {
+ authFlowRegistration = clerk.registerAuthFlow()
+ }
+ routeToSessionTaskStartIfNeeded(session: clerk.session)
if let callbackContinuation = clerk.callbackContinuation {
resumeAuth(callbackContinuation)
}
@@ -170,38 +176,50 @@ public struct AuthView: View {
.task {
for await event in clerk.auth.events {
switch event {
+ case .signInCompleted(let signIn):
+ if isDismissible || authFlowRegistration == nil {
+ await finishAuthFlow(after: .signIn(signIn))
+ }
+ case .signUpCompleted(let signUp):
+ if isDismissible || authFlowRegistration == nil {
+ await finishAuthFlow(after: .signUp(signUp))
+ }
case .signInNeedsContinuation(let signIn):
resumeAuth(.signIn(signIn))
case .signUpNeedsContinuation(let signUp):
resumeAuth(.signUp(signUp))
case .sessionChanged(let oldValue, let newValue):
- guard !navigation.routeToSessionTaskStartIfNeeded(session: newValue) else { break }
- let becameActive = newValue?.status == .active && (oldValue?.status != .active || oldValue?.id != newValue?.id)
- let isHandlingSessionTask = navigation.hasSessionTaskStartInPath
- let sessionSwitched = oldValue?.id != newValue?.id
- if becameActive, isDismissible, !isHandlingSessionTask || sessionSwitched {
- dismiss()
- }
+ routeToExistingSessionTaskStartIfNeeded(oldValue: oldValue, newValue: newValue)
default:
break
}
}
}
- .onChange(of: navigation.allTasksComplete) { _, isComplete in
- guard isComplete else { return }
- if isDismissible {
- dismiss()
+ .task(id: clerk.readyPendingAuthFlowCompletion?.flowId) {
+ guard !isDismissible,
+ let pendingAuthFlowCompletion = clerk.readyPendingAuthFlowCompletion
+ else {
+ return
}
+ await finishAuthFlow(after: pendingAuthFlowCompletion)
}
- .onChange(of: clerk.session?.tasks) { _, _ in
- navigation.routeToSessionTaskStartIfNeeded(session: clerk.session)
+ .onChange(of: navigation.postAuthStepsComplete) { _, isComplete in
+ guard isComplete, let session = clerk.session else { return }
+ finishAuthFlow(with: session)
}
.onChange(of: clerk.user) { _, newUser in
- guard newUser == nil, navigation.hasSessionTaskStartInPath else { return }
+ guard newUser == nil,
+ navigation.hasSessionTaskStartInPath ||
+ navigation.hasTrustedDeviceEnrollmentInPath ||
+ navigation.postAuthStepsComplete ||
+ navigation.trustedDeviceEnrollmentWasOffered
+ else {
+ return
+ }
if isDismissible {
dismiss()
} else {
- navigation.path = []
+ navigation.resetForNewAuthFlow()
}
}
.onChange(of: config) { _, newConfig in
@@ -231,9 +249,11 @@ public struct AuthView: View {
}
extension AuthView {
- /// Whether the dismiss button should be shown, accounting for required session tasks.
+ /// Whether the dismiss button should be shown, accounting for final auth-flow steps.
private var showDismissButton: Bool {
- isDismissible && !navigation.hasSessionTaskStartInPath
+ isDismissible &&
+ !navigation.hasSessionTaskStartInPath &&
+ !navigation.hasTrustedDeviceEnrollmentInPath
}
@ToolbarContentBuilder
@@ -255,6 +275,137 @@ extension AuthView {
clerk.setCallbackContinuation(nil)
}
}
+
+ private func finishAuthFlow(after result: TransferFlowResult) async {
+ guard let session = clerk.session else {
+ if result.createdSessionId == nil {
+ markAuthFlowComplete()
+ }
+ return
+ }
+
+ if let createdSessionId = result.createdSessionId,
+ createdSessionId != session.id
+ {
+ return
+ }
+
+ guard !Task.isCancelled else { return }
+
+ if await presentTrustedDeviceEnrollmentIfNeeded(after: result) {
+ if !isDismissible {
+ clerk.consumePendingAuthFlowCompletion()
+ }
+ return
+ }
+ guard !Task.isCancelled else { return }
+
+ if !isDismissible {
+ clerk.consumePendingAuthFlowCompletion()
+ }
+
+ finishAuthFlow(with: session)
+ }
+
+ private func finishAuthFlow(with session: Session) {
+ if navigation.routeToSessionTaskStartIfNeeded(session: session) {
+ return
+ }
+
+ markAuthFlowComplete()
+
+ if isDismissible {
+ dismiss()
+ }
+ }
+
+ private func routeToExistingSessionTaskStartIfNeeded(oldValue: Session?, newValue: Session?) {
+ if navigation.hasTrustedDeviceEnrollmentInPath {
+ return
+ }
+
+ if !isDismissible,
+ let completion = clerk.pendingAuthFlowCompletion,
+ completion.createdSessionId == nil || completion.createdSessionId == newValue?.id
+ {
+ return
+ }
+
+ if !isDismissible,
+ newValue?.status == .active,
+ !clerk.isAuthFlowComplete
+ {
+ return
+ }
+
+ let shouldAdoptPendingSession = !isDismissible && newValue?.status == .pending
+
+ guard oldValue?.id == newValue?.id ||
+ navigation.hasSessionTaskStartInPath ||
+ shouldAdoptPendingSession
+ else {
+ return
+ }
+
+ routeToSessionTaskStartIfNeeded(session: newValue)
+ }
+
+ private func routeToSessionTaskStartIfNeeded(session: Session?) {
+ guard navigation.routeToSessionTaskStartIfNeeded(session: session) else { return }
+ if !isDismissible {
+ clerk.markAuthFlowPending()
+ }
+ }
+
+ private func markAuthFlowComplete() {
+ guard !isDismissible else { return }
+ clerk.markAuthFlowComplete()
+ }
+
+ @discardableResult
+ private func presentTrustedDeviceEnrollmentIfNeeded(after result: TransferFlowResult) async -> Bool {
+ guard clerk.callbackContinuation == nil,
+ let nativeSettings = clerk.environment?.authConfig.nativeSettings,
+ nativeSettings.apiEnabled,
+ nativeSettings.trustedDeviceSignInEnabled,
+ !navigation.hasSessionTaskStartInPath,
+ !navigation.trustedDeviceEnrollmentWasOffered,
+ let session = clerk.session,
+ let userID = session.user?.id
+ else {
+ return false
+ }
+
+ guard session.status.allowsTrustedDeviceEnrollment else {
+ return false
+ }
+
+ let biometryDisplayName = TrustedDeviceBiometryDisplayName.current()
+ guard biometryDisplayName.isSupported else {
+ return false
+ }
+
+ let promptStore = TrustedDeviceEnrollmentPromptStore()
+ guard result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: userID,
+ nativeSettings: nativeSettings,
+ promptStore: promptStore
+ ) else {
+ return false
+ }
+
+ do {
+ let availability = try await clerk.trustedDevices.currentUserAvailability()
+ guard !availability.isAvailable, availability.canPromptForEnrollment else {
+ return false
+ }
+ promptStore.markPromptSeen(userID: userID)
+ navigation.routeToTrustedDeviceEnrollment()
+ return true
+ } catch {
+ return false
+ }
+ }
}
// MARK: - View Modifiers
@@ -367,6 +518,8 @@ extension AuthView {
mfaType: SessionTaskBackupCodesView.BackupCodesMfaType
)
+ case trustedDeviceEnrollment
+
@MainActor
@ViewBuilder
var view: some View {
@@ -417,6 +570,8 @@ extension AuthView {
backupCodes: backupCodes,
mfaType: mfaType
)
+ case .trustedDeviceEnrollment:
+ TrustedDeviceEnrollmentView()
}
}
}
diff --git a/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift b/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift
index 38d3b0210..3e18d53a2 100644
--- a/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift
+++ b/Sources/ClerkKitUI/Components/Auth/ClerkAccessibilityIdentifiers.swift
@@ -37,6 +37,7 @@ enum ClerkAccessibilityIdentifiers {
enum Security {
static let changePassword = "clerk.userProfile.security.changePassword"
+ static let trustedDeviceToggle = "clerk.userProfile.security.trustedDeviceToggle"
static let addMfa = "clerk.userProfile.security.addMfa"
static let deleteAccount = "clerk.userProfile.security.deleteAccount"
}
@@ -84,6 +85,7 @@ enum ClerkAccessibilityIdentifiers {
static let identifier = "clerk.auth.start.identifier"
static let phoneNumber = "clerk.auth.start.phoneNumber"
static let continueButton = "clerk.auth.start.continue"
+ static let trustedDeviceSignInButton = "clerk.auth.start.trustedDeviceSignIn"
static let identifierSwitcherButton = "clerk.auth.start.identifierSwitcher"
}
diff --git a/Sources/ClerkKitUI/Components/Auth/LastUsedAuth.swift b/Sources/ClerkKitUI/Components/Auth/LastUsedAuth.swift
index 7c99f5b9a..d6c759e23 100644
--- a/Sources/ClerkKitUI/Components/Auth/LastUsedAuth.swift
+++ b/Sources/ClerkKitUI/Components/Auth/LastUsedAuth.swift
@@ -13,15 +13,28 @@ enum LastUsedAuth: Equatable {
case email
case username
case phone
+ case trustedDevice
case social(OAuthProvider)
- init?(environment: Clerk.Environment?) {
- guard let lastAuth = Clerk.shared.client?.lastAuthenticationStrategy,
- (environment?.totalEnabledFirstFactorMethods ?? 0) > 1
- else {
+ init?(
+ environment: Clerk.Environment?,
+ trustedDeviceSignInIsVisible: Bool = false
+ ) {
+ guard let lastAuth = Clerk.shared.client?.lastAuthenticationStrategy else {
return nil
}
+ let visibleMethodCount = (environment?.totalEnabledFirstFactorMethods ?? 0) +
+ (trustedDeviceSignInIsVisible ? 1 : 0)
+ guard visibleMethodCount > 1 else {
+ return nil
+ }
+
+ if trustedDeviceSignInIsVisible, lastAuth == .trustedDevice {
+ self = .trustedDevice
+ return
+ }
+
let providers = environment?.authenticatableSocialProviders ?? []
if let provider = providers.first(where: {
Self.shouldShowBadge(for: [.oauth($0)], lastAuth: lastAuth, environment: environment)
@@ -52,7 +65,7 @@ enum LastUsedAuth: Equatable {
switch self {
case .social(let provider):
provider
- case .email, .username, .phone:
+ case .email, .username, .phone, .trustedDevice:
nil
}
}
@@ -61,7 +74,7 @@ enum LastUsedAuth: Equatable {
switch self {
case .email, .username:
true
- case .phone, .social:
+ case .phone, .trustedDevice, .social:
false
}
}
@@ -70,7 +83,16 @@ enum LastUsedAuth: Equatable {
switch self {
case .phone:
true
- case .email, .username, .social:
+ case .email, .username, .trustedDevice, .social:
+ false
+ }
+ }
+
+ var showsTrustedDeviceBadge: Bool {
+ switch self {
+ case .trustedDevice:
+ true
+ case .email, .username, .phone, .social:
false
}
}
@@ -130,7 +152,7 @@ extension LastUsedAuth {
case .username:
strategies.contains(.password)
&& Set(strategies).isDisjoint(with: [.emailCode, .phoneCode])
- case .social:
+ case .trustedDevice, .social:
false
}
}
@@ -161,7 +183,7 @@ extension LastUsedAuth {
"phone"
case .username:
"username"
- case .social:
+ case .trustedDevice, .social:
nil
}
}
diff --git a/Sources/ClerkKitUI/Components/Auth/SignUp/LegalConsentView.swift b/Sources/ClerkKitUI/Components/Auth/SignUp/LegalConsentView.swift
index 2c41ac931..7c324cfcd 100644
--- a/Sources/ClerkKitUI/Components/Auth/SignUp/LegalConsentView.swift
+++ b/Sources/ClerkKitUI/Components/Auth/SignUp/LegalConsentView.swift
@@ -74,7 +74,7 @@ struct LegalConsentView: View {
.labelsHidden()
.font(theme.fonts.body)
.foregroundStyle(theme.colors.foreground)
- .tint(theme.colors.primary)
+ .tint(theme.colors.switchTint)
.frame(minHeight: 22)
.accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.SignUp.legalAccepted)
}
diff --git a/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentPrompt.swift b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentPrompt.swift
new file mode 100644
index 000000000..35efea390
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentPrompt.swift
@@ -0,0 +1,53 @@
+//
+// TrustedDeviceEnrollmentPrompt.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+import Foundation
+
+extension TransferFlowResult {
+ func shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: String,
+ nativeSettings: Clerk.Environment.AuthConfig.NativeSettings,
+ promptStore: TrustedDeviceEnrollmentPromptStore
+ ) -> Bool {
+ switch self {
+ case .signIn:
+ nativeSettings.trustedDevicePromptAfterSignInEnabled &&
+ !promptStore.hasSeenPrompt(userID: userID)
+ case .signUp:
+ nativeSettings.trustedDevicePromptAfterSignUpEnabled
+ }
+ }
+}
+
+struct TrustedDeviceEnrollmentPromptStore {
+ private let userDefaults: UserDefaults
+
+ init(userDefaults: UserDefaults = .standard) {
+ self.userDefaults = userDefaults
+ }
+
+ func hasSeenPrompt(userID: String) -> Bool {
+ userDefaults.bool(forKey: storageKey(userID: userID))
+ }
+
+ func markPromptSeen(userID: String) {
+ userDefaults.set(true, forKey: storageKey(userID: userID))
+ }
+
+ func clearPromptSeen(userID: String) {
+ userDefaults.removeObject(forKey: storageKey(userID: userID))
+ }
+
+ private func storageKey(userID: String) -> String {
+ "\(Self.storageKeyPrefix).\(userID)"
+ }
+
+ private static let storageKeyPrefix = "clerk_trusted_device_enrollment_prompt_seen"
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentStrings.swift b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentStrings.swift
new file mode 100644
index 000000000..2439afdd7
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentStrings.swift
@@ -0,0 +1,52 @@
+//
+// TrustedDeviceEnrollmentStrings.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+import Foundation
+import SwiftUI
+
+enum TrustedDeviceEnrollmentStrings {
+ @MainActor
+ static func applicationName(for clerk: Clerk) -> String? {
+ guard let applicationName = clerk.environment?.displayConfig.applicationName,
+ !applicationName.isEmptyTrimmed
+ else {
+ return nil
+ }
+ return applicationName
+ }
+
+ static func subtitle(
+ applicationName: String?,
+ biometryDisplayName: TrustedDeviceBiometryDisplayName
+ ) -> LocalizedStringKey {
+ if let applicationName {
+ return "Enable \(biometryDisplayName.value) for faster access to \(applicationName)"
+ }
+
+ return "Enable \(biometryDisplayName.value) for faster access"
+ }
+
+ static func enrollmentReason(
+ applicationName: String?,
+ biometryDisplayName: TrustedDeviceBiometryDisplayName
+ ) -> String {
+ if let applicationName {
+ return String(
+ localized: "The app \(applicationName) uses \(biometryDisplayName.value) to sign you in.",
+ bundle: .module
+ )
+ }
+
+ return String(
+ localized: "Use \(biometryDisplayName.value) to sign in.",
+ bundle: .module
+ )
+ }
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentView.swift b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentView.swift
new file mode 100644
index 000000000..cc9ab2efe
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceEnrollmentView.swift
@@ -0,0 +1,139 @@
+//
+// TrustedDeviceEnrollmentView.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+import SwiftUI
+
+struct TrustedDeviceEnrollmentView: View {
+ @Environment(Clerk.self) private var clerk
+ @Environment(\.clerkTheme) private var theme
+ @Environment(AuthNavigation.self) private var navigation
+
+ @State private var error: Error?
+
+ private let biometryDisplayName = TrustedDeviceBiometryDisplayName.current()
+
+ private var title: LocalizedStringKey {
+ "Allow \(biometryDisplayName.value)"
+ }
+
+ private var subtitle: LocalizedStringKey {
+ TrustedDeviceEnrollmentStrings.subtitle(
+ applicationName: TrustedDeviceEnrollmentStrings.applicationName(for: clerk),
+ biometryDisplayName: biometryDisplayName
+ )
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 32) {
+ headerSection
+ biometryIcon
+ buttonSection
+ SecuredByClerkView()
+ }
+ .padding(16)
+ }
+ .background(theme.colors.background)
+ .clerkErrorPresenting($error)
+ #if os(iOS)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarBackButtonHidden()
+ .toolbar(.visible, for: .navigationBar)
+ .preGlassSolidNavBar()
+ #endif
+ }
+}
+
+extension TrustedDeviceEnrollmentView {
+ private var headerSection: some View {
+ VStack(spacing: 8) {
+ HeaderView(style: .title, text: title)
+ HeaderView(style: .subtitle, text: subtitle)
+ }
+ }
+
+ @ViewBuilder
+ private var biometryIcon: some View {
+ if let systemImageName = biometryDisplayName.systemImageName {
+ Image(systemName: systemImageName)
+ .symbolRenderingMode(.palette)
+ .foregroundStyle(theme.colors.primary, theme.colors.foreground)
+ .font(.system(size: 64))
+ .frame(maxWidth: .infinity)
+ .frame(height: 175)
+ .accessibilityHidden(true)
+ }
+ }
+
+ private var buttonSection: some View {
+ VStack(spacing: 24) {
+ allowButton
+ notNowButton
+ }
+ }
+
+ private var allowButton: some View {
+ AsyncButton {
+ await enrollTrustedDevice()
+ } label: { isRunning in
+ Text("Allow", bundle: .module)
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning) {
+ SpinnerView(color: theme.colors.primaryForeground)
+ }
+ }
+ .buttonStyle(.primary())
+ }
+
+ private var notNowButton: some View {
+ Button(action: continueAfterEnrollmentPrompt) {
+ Text("Not now", bundle: .module)
+ }
+ .buttonStyle(.primary(config: .init(emphasis: .none, size: .small)))
+ }
+
+ private func enrollTrustedDevice() async {
+ error = nil
+
+ do {
+ try await clerk.trustedDevices.enroll(
+ identifierHint: clerk.user?.trustedDeviceIdentifierHint,
+ reason: enrollmentReason
+ )
+ continueAfterEnrollmentPrompt()
+ } catch {
+ if error.isUserCancelledError {
+ return
+ }
+
+ self.error = error
+ }
+ }
+
+ private var enrollmentReason: String {
+ TrustedDeviceEnrollmentStrings.enrollmentReason(
+ applicationName: TrustedDeviceEnrollmentStrings.applicationName(for: clerk),
+ biometryDisplayName: biometryDisplayName
+ )
+ }
+
+ private func continueAfterEnrollmentPrompt() {
+ guard !navigation.routeToSessionTaskStartIfNeeded(session: clerk.session) else {
+ return
+ }
+
+ navigation.markPostAuthStepsComplete()
+ }
+}
+
+#Preview {
+ TrustedDeviceEnrollmentView()
+ .clerkPreview()
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceSignInButton.swift b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceSignInButton.swift
new file mode 100644
index 000000000..6d5be9548
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/Auth/TrustedDevice/TrustedDeviceSignInButton.swift
@@ -0,0 +1,78 @@
+//
+// TrustedDeviceSignInButton.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+import LocalAuthentication
+import SwiftUI
+
+struct TrustedDeviceSignInButton: View {
+ @Environment(\.clerkTheme) private var theme
+
+ private let biometryDisplayName: TrustedDeviceBiometryDisplayName
+ private let action: () async -> Void
+
+ init(
+ biometryDisplayName: TrustedDeviceBiometryDisplayName = .current(),
+ action: @escaping () async -> Void
+ ) {
+ self.biometryDisplayName = biometryDisplayName
+ self.action = action
+ }
+
+ var body: some View {
+ AsyncButton {
+ await action()
+ } label: { isRunning in
+ label
+ .frame(maxWidth: .infinity)
+ .overlayProgressView(isActive: isRunning)
+ }
+ .buttonStyle(.secondary())
+ .accessibilityIdentifier(ClerkAccessibilityIdentifiers.Auth.Start.trustedDeviceSignInButton)
+ .accessibilityLabel(Text("Continue with \(biometryDisplayName.value)", bundle: .module))
+ }
+
+ private var label: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 12) {
+ if let systemImageName = biometryDisplayName.systemImageName {
+ Image(systemName: systemImageName)
+ .font(.system(size: 21))
+ .frame(width: 21, height: 21)
+ .foregroundStyle(theme.colors.foreground)
+ }
+
+ Text("Continue with \(biometryDisplayName.value)", bundle: .module)
+ .lineLimit(1)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.foreground)
+ }
+
+ Text(biometryDisplayName.value)
+ .lineLimit(1)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.foreground)
+ }
+ }
+}
+
+#Preview {
+ VStack(spacing: 12) {
+ TrustedDeviceSignInButton(
+ biometryDisplayName: .init(biometryType: .faceID),
+ action: {}
+ )
+
+ TrustedDeviceSignInButton(
+ biometryDisplayName: .init(biometryType: .touchID),
+ action: {}
+ )
+ }
+ .padding()
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/Common/TrustedDeviceBiometryDisplayName.swift b/Sources/ClerkKitUI/Components/Common/TrustedDeviceBiometryDisplayName.swift
new file mode 100644
index 000000000..2312103d5
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/Common/TrustedDeviceBiometryDisplayName.swift
@@ -0,0 +1,59 @@
+//
+// TrustedDeviceBiometryDisplayName.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import LocalAuthentication
+
+struct TrustedDeviceBiometryDisplayName: Equatable {
+ let value: String
+ let systemImageName: String?
+ let isSupported: Bool
+
+ init(
+ biometryType: LABiometryType,
+ isSupported: Bool? = nil
+ ) {
+ systemImageName = biometryType.systemImageName
+ self.isSupported = isSupported ?? (biometryType != .none)
+
+ switch biometryType {
+ case .faceID:
+ value = "Face ID"
+ case .touchID:
+ value = "Touch ID"
+ case .opticID:
+ value = "Optic ID"
+ default:
+ value = "biometrics"
+ }
+ }
+
+ static func current() -> Self {
+ let context = LAContext()
+ var error: NSError?
+ let isSupported = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
+ return .init(biometryType: context.biometryType, isSupported: isSupported)
+ }
+}
+
+extension LABiometryType {
+ fileprivate var systemImageName: String? {
+ switch self {
+ case .none:
+ nil
+ case .touchID:
+ "touchid"
+ case .faceID:
+ "faceid"
+ case .opticID:
+ "opticid"
+ @unknown default:
+ nil
+ }
+ }
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift
index 059a10f9b..07823aeba 100644
--- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift
+++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileDeleteAccountConfirmationView.swift
@@ -107,7 +107,9 @@ extension UserProfileDeleteAccountConfirmationView {
guard let user else { return }
do {
+ let deletedUserID = user.id
try await user.delete()
+ forgetTrustedDeviceLocalCredentials(deletedUserID: deletedUserID)
let shouldPresentAccountSwitcher = clerk.auth.sessions.count > 1
let shouldDismissUserProfile = clerk.user == nil && !shouldPresentAccountSwitcher
dismiss()
@@ -120,6 +122,17 @@ extension UserProfileDeleteAccountConfirmationView {
ClerkLogger.error("Failed to delete account", error: error)
}
}
+
+ private func forgetTrustedDeviceLocalCredentials(deletedUserID: String) {
+ do {
+ try clerk.trustedDevices.forgetLocalCredentials(deletedUserID: deletedUserID)
+ } catch {
+ ClerkLogger.error(
+ "Failed to delete trusted-device local credentials after account deletion. This is non-critical.",
+ error: error
+ )
+ }
+ }
}
#Preview {
diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileSectionHeader.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileSectionHeader.swift
index a2fd7142c..47dcfaa1c 100644
--- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileSectionHeader.swift
+++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileSectionHeader.swift
@@ -10,13 +10,22 @@ import SwiftUI
struct UserProfileSectionHeader: View {
@Environment(\.clerkTheme) private var theme
- let text: LocalizedStringKey
+ let text: Text
+
+ init(text: LocalizedStringKey) {
+ self.text = Text(text, bundle: .module)
+ }
+
+ init(verbatim text: String) {
+ self.text = Text(verbatim: text)
+ }
var body: some View {
- Text(text, bundle: .module)
+ text
.font(theme.fonts.caption)
.fontWeight(.medium)
.foregroundStyle(theme.colors.mutedForeground)
+ .textCase(.uppercase)
.frame(minHeight: 16)
.padding(.horizontal, 24)
.padding(.top, 32)
diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileSecurityView.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileSecurityView.swift
index 328033f6b..739e9fd21 100644
--- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileSecurityView.swift
+++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileSecurityView.swift
@@ -14,6 +14,9 @@ struct UserProfileSecurityView: View {
@Environment(UserProfileSheetNavigation.self) private var navigation
@State private var error: Error?
+ @State private var trustedDeviceAvailability: TrustedDeviceAvailability?
+ private let biometryDisplayName = TrustedDeviceBiometryDisplayName.current()
+
private var user: User? {
clerk.user
}
@@ -27,6 +30,51 @@ struct UserProfileSecurityView: View {
return (clerk.sessionsByUserId[user.id] ?? []).contains { $0.latestActivity != nil }
}
+ private var trustedDeviceFeatureIsEnabled: Bool {
+ guard let nativeSettings = environment?.authConfig.nativeSettings else {
+ return false
+ }
+
+ return nativeSettings.apiEnabled &&
+ nativeSettings.trustedDeviceSignInEnabled &&
+ biometryDisplayName.isSupported
+ }
+
+ private struct TrustedDeviceAvailabilityRefreshKey: Hashable {
+ let sessionID: String
+ let userID: String
+ }
+
+ private var trustedDeviceIsEnabled: Bool {
+ if let trustedDeviceAvailability {
+ return trustedDeviceAvailability.isAvailable
+ }
+
+ return localTrustedDeviceAvailability?.isAvailable == true
+ }
+
+ private var localTrustedDeviceAvailability: TrustedDeviceAvailability? {
+ guard trustedDeviceFeatureIsEnabled else {
+ return nil
+ }
+
+ return try? clerk.trustedDevices.currentUserLocalAvailability()
+ }
+
+ private var trustedDeviceAvailabilityRefreshKey: TrustedDeviceAvailabilityRefreshKey? {
+ guard trustedDeviceFeatureIsEnabled,
+ let user,
+ let sessionID = clerk.session?.id
+ else {
+ return nil
+ }
+
+ return TrustedDeviceAvailabilityRefreshKey(
+ sessionID: sessionID,
+ userID: user.id
+ )
+ }
+
var body: some View {
@Bindable var navigation = navigation
@@ -38,6 +86,13 @@ struct UserProfileSecurityView: View {
UserProfilePasswordSection()
}
+ if trustedDeviceFeatureIsEnabled {
+ UserProfileTrustedDeviceSection(
+ isEnabled: trustedDeviceIsEnabled,
+ refreshAvailability: refreshTrustedDeviceAvailability
+ )
+ }
+
if environment?.passkeyIsEnabled == true {
UserProfilePasskeySection()
}
@@ -79,6 +134,10 @@ struct UserProfileSecurityView: View {
.task {
_ = try? await user?.getSessions()
}
+ .task(id: trustedDeviceAvailabilityRefreshKey) {
+ refreshLocalTrustedDeviceAvailability()
+ await refreshTrustedDeviceAvailability()
+ }
.task {
_ = try? await clerk.refreshClient()
}
@@ -91,6 +150,46 @@ struct UserProfileSecurityView: View {
}
}
+extension UserProfileSecurityView {
+ @MainActor
+ private func refreshLocalTrustedDeviceAvailability() {
+ guard trustedDeviceFeatureIsEnabled else {
+ trustedDeviceAvailability = nil
+ return
+ }
+
+ do {
+ trustedDeviceAvailability = try clerk.trustedDevices.currentUserLocalAvailability()
+ } catch {
+ trustedDeviceAvailability = nil
+ ClerkLogger.error("Failed to refresh local trusted-device availability", error: error)
+ }
+ }
+
+ @MainActor
+ @discardableResult
+ private func refreshTrustedDeviceAvailability() async -> TrustedDeviceAvailability? {
+ guard trustedDeviceFeatureIsEnabled else {
+ trustedDeviceAvailability = nil
+ return nil
+ }
+
+ do {
+ let availability = try await clerk.trustedDevices.currentUserAvailability()
+ trustedDeviceAvailability = availability
+ return availability
+ } catch {
+ if error.isCancellationError {
+ return nil
+ } else {
+ trustedDeviceAvailability = nil
+ ClerkLogger.error("Failed to refresh trusted-device availability", error: error)
+ }
+ return nil
+ }
+ }
+}
+
#Preview {
NavigationStack {
UserProfileSecurityView()
diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileTrustedDeviceSection.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileTrustedDeviceSection.swift
new file mode 100644
index 000000000..1c1d67595
--- /dev/null
+++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileTrustedDeviceSection.swift
@@ -0,0 +1,143 @@
+//
+// UserProfileTrustedDeviceSection.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+import SwiftUI
+
+struct UserProfileTrustedDeviceSection: View {
+ @Environment(Clerk.self) private var clerk
+ @Environment(\.clerkTheme) private var theme
+
+ let isEnabled: Bool
+ let refreshAvailability: @MainActor () async -> TrustedDeviceAvailability?
+
+ @State private var optimisticIsEnabled: Bool?
+ @State private var isLoading = false
+ @State private var error: Error?
+
+ private let biometryDisplayName = TrustedDeviceBiometryDisplayName.current()
+
+ private var user: User? {
+ clerk.user
+ }
+
+ private var currentIsEnabled: Bool {
+ optimisticIsEnabled ?? isEnabled
+ }
+
+ private var toggleBinding: Binding {
+ Binding {
+ currentIsEnabled
+ } set: { newValue in
+ guard currentIsEnabled != newValue,
+ !isLoading
+ else {
+ return
+ }
+ isLoading = true
+ optimisticIsEnabled = newValue
+ Task {
+ await setTrustedDeviceSignInEnabled(newValue)
+ }
+ }
+ }
+
+ var body: some View {
+ Section {
+ HStack(spacing: 16) {
+ if let systemImageName = biometryDisplayName.systemImageName {
+ Image(systemName: systemImageName)
+ .font(.system(size: 24))
+ .frame(width: 24, height: 24)
+ .foregroundStyle(theme.colors.mutedForeground)
+ }
+
+ Text("Sign in with \(biometryDisplayName.value)", bundle: .module)
+ .font(theme.fonts.body)
+ .foregroundStyle(theme.colors.foreground)
+ .frame(minHeight: 22)
+
+ Spacer(minLength: 0)
+
+ Toggle("", isOn: toggleBinding)
+ .labelsHidden()
+ .tint(theme.colors.switchTint)
+ .accessibilityLabel(Text("Sign in with \(biometryDisplayName.value)", bundle: .module))
+ .accessibilityIdentifier(ClerkAccessibilityIdentifiers.UserProfile.Security.trustedDeviceToggle)
+ }
+ .padding(.horizontal, 24)
+ .padding(.vertical, 16)
+ .background(theme.colors.background)
+ .disabled(isLoading)
+ .opacity(isLoading ? 0.55 : 1)
+ .overlay(alignment: .bottom) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(theme.colors.border)
+ }
+ } header: {
+ UserProfileSectionHeader(verbatim: biometryDisplayName.value)
+ }
+ .clerkErrorPresenting($error)
+ }
+}
+
+extension UserProfileTrustedDeviceSection {
+ private var enrollmentReason: String {
+ TrustedDeviceEnrollmentStrings.enrollmentReason(
+ applicationName: TrustedDeviceEnrollmentStrings.applicationName(for: clerk),
+ biometryDisplayName: biometryDisplayName
+ )
+ }
+
+ private func setTrustedDeviceSignInEnabled(_ enabled: Bool) async {
+ guard let user else {
+ optimisticIsEnabled = nil
+ isLoading = false
+ return
+ }
+
+ optimisticIsEnabled = enabled
+ defer { isLoading = false }
+
+ do {
+ if enabled {
+ try await clerk.trustedDevices.enroll(
+ identifierHint: user.trustedDeviceIdentifierHint,
+ reason: enrollmentReason
+ )
+ } else {
+ try await clerk.trustedDevices.revokeCurrentDeviceCredential()
+ }
+
+ if await refreshAvailability() != nil {
+ optimisticIsEnabled = nil
+ }
+ } catch {
+ _ = await refreshAvailability()
+ optimisticIsEnabled = nil
+
+ if error.isUserCancelledError {
+ return
+ }
+
+ self.error = error
+ ClerkLogger.error("Failed to update trusted-device sign-in", error: error)
+ }
+ }
+}
+
+#Preview {
+ UserProfileTrustedDeviceSection(
+ isEnabled: true,
+ refreshAvailability: { .available }
+ )
+ .clerkPreview()
+ .environment(\.clerkTheme, .clerk)
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift
index 874ac6826..1cfb2d74d 100644
--- a/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift
+++ b/Sources/ClerkKitUI/Components/UserProfile/UserProfileView.swift
@@ -25,7 +25,7 @@ import SwiftUI
///
/// var body: some View {
/// Group {
-/// if clerk.user != nil {
+/// if clerk.isAuthFlowComplete {
/// UserProfileView(isDismissible: false)
/// } else {
/// AuthView(isDismissible: false)
@@ -211,17 +211,6 @@ public struct UserProfileView: View {
.sheet(isPresented: $sheetNavigation.authViewIsPresented) {
AuthView()
}
- .task {
- for await event in clerk.auth.events {
- switch event {
- case .signInCompleted, .signUpCompleted:
- guard clerk.session?.pendingTasks.isEmpty != false else { break }
- sheetNavigation.authViewIsPresented = false
- default:
- break
- }
- }
- }
.task(id: user) {
await getSessionsOnAllDevices()
}
diff --git a/Sources/ClerkKitUI/Extensions/Error+Ext.swift b/Sources/ClerkKitUI/Extensions/Error+Ext.swift
index 8b7a7afee..c7f181274 100644
--- a/Sources/ClerkKitUI/Extensions/Error+Ext.swift
+++ b/Sources/ClerkKitUI/Extensions/Error+Ext.swift
@@ -6,6 +6,7 @@
#if os(iOS) || os(macOS)
import AuthenticationServices
+import ClerkKit
import Foundation
extension Error {
@@ -16,6 +17,12 @@ extension Error {
return true
}
+ if let trustedDeviceError = self as? TrustedDeviceKeyManagerError,
+ trustedDeviceError == .biometricAuthenticationCanceled
+ {
+ return true
+ }
+
return false
}
diff --git a/Sources/ClerkKitUI/Extensions/TrustedDeviceAvailability+Enrollment.swift b/Sources/ClerkKitUI/Extensions/TrustedDeviceAvailability+Enrollment.swift
new file mode 100644
index 000000000..ab19da38c
--- /dev/null
+++ b/Sources/ClerkKitUI/Extensions/TrustedDeviceAvailability+Enrollment.swift
@@ -0,0 +1,24 @@
+//
+// TrustedDeviceAvailability+Enrollment.swift
+// Clerk
+//
+
+#if os(iOS) || os(macOS)
+
+import ClerkKit
+
+extension TrustedDeviceAvailability {
+ var canPromptForEnrollment: Bool {
+ switch unavailableReason {
+ case .noLocalCredential,
+ .localKeyMissing,
+ .serverCredentialMissing,
+ .serverCredentialRevoked:
+ true
+ default:
+ false
+ }
+ }
+}
+
+#endif
diff --git a/Sources/ClerkKitUI/Extensions/User+Ext.swift b/Sources/ClerkKitUI/Extensions/User+Ext.swift
index 9977d375e..1e125f9fe 100644
--- a/Sources/ClerkKitUI/Extensions/User+Ext.swift
+++ b/Sources/ClerkKitUI/Extensions/User+Ext.swift
@@ -34,6 +34,22 @@ extension User {
return nil
}
+ var trustedDeviceIdentifierHint: String? {
+ if let primaryEmailAddress, !primaryEmailAddress.emailAddress.isEmptyTrimmed {
+ return primaryEmailAddress.emailAddress
+ }
+
+ if let primaryPhoneNumber, !primaryPhoneNumber.phoneNumber.isEmptyTrimmed {
+ return primaryPhoneNumber.phoneNumber
+ }
+
+ if let username, !username.isEmptyTrimmed {
+ return username
+ }
+
+ return nil
+ }
+
@MainActor
var usernameForPasswordKeeper: String {
guard let environment = Clerk.shared.environment else { return "" }
diff --git a/Sources/ClerkKitUI/Resources/Localizable.xcstrings b/Sources/ClerkKitUI/Resources/Localizable.xcstrings
index dad74bb1b..c367a1ec2 100644
--- a/Sources/ClerkKitUI/Resources/Localizable.xcstrings
+++ b/Sources/ClerkKitUI/Resources/Localizable.xcstrings
@@ -2167,6 +2167,206 @@
}
}
},
+ "Allow" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "السماح"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erlauben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Να επιτρέπεται"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Allow"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permitir"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autoriser"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "अनुमति दें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Consenti"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "許可"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "허용"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zezwól"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permitir"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permite"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Разрешить"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "İzin ver"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "允许"
+ }
+ }
+ }
+ },
+ "Allow %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "السماح بـ %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ erlauben"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Να επιτρέπεται το %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Allow %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permitir %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autoriser %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ की अनुमति दें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Consenti %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@を許可"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ 허용"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zezwól na %@"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permitir %@"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Permite %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Разрешить %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ için izin ver"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "允许使用 %@"
+ }
+ }
+ }
+ },
"Allow users to join the organization automatically or request to join based on a verified email domain." : {
"localizations" : {
"ar" : {
@@ -2355,6 +2555,107 @@
}
}
},
+ "Application" : {
+ "extractionState" : "stale",
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "التطبيق"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anwendung"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Εφαρμογή"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicación"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "एप्लिकेशन"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Applicazione"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリケーション"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "애플리케이션"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplikacja"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicativo"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicație"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Приложение"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uygulama"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "应用程序"
+ }
+ }
+ }
+ },
"Are you sure you want to delete this organization?" : {
"comment" : "A confirmation message for deleting an organization.",
"isCommentAutoGenerated" : true,
@@ -3487,190 +3788,190 @@
}
}
},
- "Change password" : {
+ "Change logo" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "تغيير كلمة لمرور"
+ "value" : "تغيير الشعار"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Passwort ändern"
+ "value" : "Logo ändern"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Αλλαγή κωδικού πρόσβασης"
+ "value" : "Αλλαγή λογότυπου"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cambiar la contraseña"
+ "value" : "Cambiar logotipo"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Changer le mot de passe"
+ "value" : "Changer le logo"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "पासवर्ड बदलें"
+ "value" : "लोगो बदलें"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cambia password"
+ "value" : "Cambia logo"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "パスワードを変更"
+ "value" : "ロゴを変更"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "비밀번호 변경"
+ "value" : "로고 변경"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Zmień hasło"
+ "value" : "Zmień logo"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Alterar senha"
+ "value" : "Alterar logotipo"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Schimbă parola"
+ "value" : "Schimbă sigla"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Изменить пароль"
+ "value" : "Изменить логотип"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Parolayı değiştir"
+ "value" : "Logoyu değiştir"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "更改密码"
+ "value" : "更改徽标"
}
}
}
},
- "Change logo" : {
+ "Change password" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "تغيير الشعار"
+ "value" : "تغيير كلمة لمرور"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Logo ändern"
+ "value" : "Passwort ändern"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Αλλαγή λογότυπου"
+ "value" : "Αλλαγή κωδικού πρόσβασης"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cambiar logotipo"
+ "value" : "Cambiar la contraseña"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Changer le logo"
+ "value" : "Changer le mot de passe"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "लोगो बदलें"
+ "value" : "पासवर्ड बदलें"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cambia logo"
+ "value" : "Cambia password"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ロゴを変更"
+ "value" : "パスワードを変更"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "로고 변경"
+ "value" : "비밀번호 변경"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Zmień logo"
+ "value" : "Zmień hasło"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Alterar logotipo"
+ "value" : "Alterar senha"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Schimbă sigla"
+ "value" : "Schimbă parola"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Изменить логотип"
+ "value" : "Изменить пароль"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Logoyu değiştir"
+ "value" : "Parolayı değiştir"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "更改徽标"
+ "value" : "更改密码"
}
}
}
@@ -7910,397 +8211,597 @@
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Адрес электронной почты"
+ "value" : "Адрес электронной почты"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-posta adresi"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电子邮箱地址"
+ }
+ }
+ }
+ },
+ "EMAIL ADDRESSES" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "عناوين البريد الإلكتروني"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-MAIL-ADRESSEN"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ΔΙΕΥΘΥΝΣΕΙΣ EMAIL"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "DIRECCIONES DE CORREO ELECTRÓNICO"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ADRESSES E-MAIL"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ईमेल पते"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "INDIRIZZI EMAIL"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "メールアドレス"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이메일 주소"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ADRESY E-MAIL"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ENDEREÇOS DE E-MAIL"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ADRESE DE EMAIL"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "АДРЕСА ЭЛЕКТРОННОЙ ПОЧТЫ"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-POSTA ADRESLERİ"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "电子邮箱地址"
+ }
+ }
+ }
+ },
+ "Email code to %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "إرسال رمز إلى %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Code per E-Mail an %@ senden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Αποστολή κωδικού στο %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enviar código a %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Envoyer le code à %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ पर कोड भेजें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Invia codice a %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@にコードを送信"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@에 코드 전송"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kod e-mail do %@"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enviar código por e-mail para %@"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cod email către %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Код по электронной почте на %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-posta adresi"
+ "value" : "%@ adresine e-posta kodu gönder"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "电子邮箱地址"
+ "value" : "发送验证码至%@"
}
}
}
},
- "EMAIL ADDRESSES" : {
+ "Email link to %@" : {
+ "comment" : "Text that can be displayed to a user requesting a sign-in using an email link. The argument is the user's email address.",
+ "isCommentAutoGenerated" : true,
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "عناوين البريد الإلكتروني"
+ "value" : "إرسال رابط بالبريد الإلكتروني إلى %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-MAIL-ADRESSEN"
+ "value" : "E-Mail-Link an %@ senden"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ΔΙΕΥΘΥΝΣΕΙΣ EMAIL"
+ "value" : "Στείλτε σύνδεσμο μέσω email στο %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "DIRECCIONES DE CORREO ELECTRÓNICO"
+ "value" : "Enviar enlace por correo a %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ADRESSES E-MAIL"
+ "value" : "Envoyer un lien par e-mail à %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ईमेल पते"
+ "value" : "%@ पर ईमेल लिंक भेजें"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "INDIRIZZI EMAIL"
+ "value" : "Invia link via email a %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "メールアドレス"
+ "value" : "%@にメールリンクを送信"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "이메일 주소"
+ "value" : "%@에 이메일 링크 전송"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ADRESY E-MAIL"
+ "value" : "Wyślij link e-mailem do %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ENDEREÇOS DE E-MAIL"
+ "value" : "Enviar link por e-mail para %@"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ADRESE DE EMAIL"
+ "value" : "Trimite linkul prin e-mail la %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "АДРЕСА ЭЛЕКТРОННОЙ ПОЧТЫ"
+ "value" : "Отправить ссылку по электронной почте на %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-POSTA ADRESLERİ"
+ "value" : "%@ adresine e-posta bağlantısı gönder"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "电子邮箱地址"
+ "value" : "向%@发送邮件链接"
}
}
}
},
- "Email code to %@" : {
+ "Email support" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "إرسال رمز إلى %@"
+ "value" : "الدعم بالبريد الإلكتروني"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Code per E-Mail an %@ senden"
+ "value" : "E-Mail-Support"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Αποστολή κωδικού στο %@"
+ "value" : "Υποστήριξη μέσω email"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enviar código a %@"
+ "value" : "Soporte por correo electrónico"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Envoyer le code à %@"
+ "value" : "Support par e-mail"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@ पर कोड भेजें"
+ "value" : "ईमेल सहायता"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Invia codice a %@"
+ "value" : "Supporto via email"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@にコードを送信"
+ "value" : "メールサポート"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@에 코드 전송"
+ "value" : "이메일 지원"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Kod e-mail do %@"
+ "value" : "Napisz do pomocy"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enviar código por e-mail para %@"
+ "value" : "Suporte por e-mail"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Cod email către %@"
+ "value" : "Trimite email către suport"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Код по электронной почте на %@"
+ "value" : "Написать в поддержку"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@ adresine e-posta kodu gönder"
+ "value" : "E-posta desteği"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "发送验证码至%@"
+ "value" : "电子邮件支持"
}
}
}
},
- "Email link to %@" : {
- "comment" : "Text that can be displayed to a user requesting a sign-in using an email link. The argument is the user's email address.",
- "isCommentAutoGenerated" : true,
+ "Enable %@ for faster access" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "إرسال رابط بالبريد الإلكتروني إلى %@"
+ "value" : "فعّل %@ للوصول بشكل أسرع"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-Mail-Link an %@ senden"
+ "value" : "Aktivieren Sie %@ für schnelleren Zugriff"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Στείλτε σύνδεσμο μέσω email στο %@"
+ "value" : "Ενεργοποιήστε το %@ για ταχύτερη πρόσβαση"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enable %@ for faster access"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enviar enlace por correo a %@"
+ "value" : "Activa %@ para acceder más rápido"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Envoyer un lien par e-mail à %@"
+ "value" : "Activez %@ pour un accès plus rapide"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@ पर ईमेल लिंक भेजें"
+ "value" : "तेज़ पहुंच के लिए %@ सक्षम करें"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Invia link via email a %@"
+ "value" : "Abilita %@ per accedere più rapidamente"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@にメールリンクを送信"
+ "value" : "%@を有効にしてすばやくアクセス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@에 이메일 링크 전송"
+ "value" : "%@을 활성화하여 더 빠르게 접근"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Wyślij link e-mailem do %@"
+ "value" : "Włącz %@, aby szybciej uzyskać dostęp"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enviar link por e-mail para %@"
+ "value" : "Ative %@ para acessar mais rapidamente"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Trimite linkul prin e-mail la %@"
+ "value" : "Activează %@ pentru acces mai rapid"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Отправить ссылку по электронной почте на %@"
+ "value" : "Включите %@ для быстрого доступа"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%@ adresine e-posta bağlantısı gönder"
+ "value" : "Daha hızlı erişim için %@ özelliğini etkinleştirin"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "向%@发送邮件链接"
+ "value" : "启用 %@,更快访问"
}
}
}
},
- "Email support" : {
+ "Enable %@ for faster access to %@" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "الدعم بالبريد الإلكتروني"
+ "value" : "فعّل %@ للوصول بشكل أسرع إلى %@"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-Mail-Support"
+ "value" : "Aktivieren Sie %@ für schnelleren Zugriff auf %@"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Υποστήριξη μέσω email"
+ "value" : "Ενεργοποιήστε το %@ για ταχύτερη πρόσβαση στο %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enable %@ for faster access to %@"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Soporte por correo electrónico"
+ "value" : "Activa %@ para acceder más rápido a %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Support par e-mail"
+ "value" : "Activez %@ pour accéder plus rapidement à %@"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ईमेल सहायता"
+ "value" : "%@ सक्षम करें ताकि %@ तक तेज़ी से पहुंच सकें"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Supporto via email"
+ "value" : "Abilita %@ per accedere più rapidamente a %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "メールサポート"
+ "value" : "%@を有効にして%@にすばやくアクセス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "이메일 지원"
+ "value" : "%@을 활성화하여 %@에 더 빠르게 접근"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Napisz do pomocy"
+ "value" : "Włącz %@, aby szybciej uzyskać dostęp do %@"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Suporte por e-mail"
+ "value" : "Ative %@ para acessar %@ mais rapidamente"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Trimite email către suport"
+ "value" : "Activează %@ pentru acces mai rapid la %@"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Написать в поддержку"
+ "value" : "Включите %@ для быстрого доступа к %@"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-posta desteği"
+ "value" : "%@ özelliğini etkinleştirerek %@ uygulamasına daha hızlı erişin"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "电子邮件支持"
+ "value" : "启用 %@,更快访问 %@"
}
}
}
@@ -13775,6 +14276,100 @@
}
}
},
+ "No email app is available on this device." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "لا يتوفر تطبيق بريد إلكتروني على هذا الجهاز."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Auf diesem Gerät ist keine E-Mail-App verfügbar."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Δεν υπάρχει διαθέσιμη εφαρμογή email σε αυτή τη συσκευή."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No hay ninguna app de correo disponible en este dispositivo."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune app e-mail n’est disponible sur cet appareil."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "इस डिवाइस पर कोई ईमेल ऐप उपलब्ध नहीं है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nessuna app e-mail è disponibile su questo dispositivo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このデバイスではメールアプリを利用できません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 기기에서 사용할 수 있는 이메일 앱이 없습니다."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Na tym urządzeniu nie jest dostępna żadna aplikacja e-mail."
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Não há nenhum app de e-mail disponível neste dispositivo."
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nu există nicio aplicație de e-mail disponibilă pe acest dispozitiv."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "На этом устройстве нет доступного почтового приложения."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bu cihazda kullanılabilir bir e-posta uygulaması yok."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "此设备上没有可用的邮件应用。"
+ }
+ }
+ }
+ },
"No invitations sent" : {
"localizations" : {
"ar" : {
@@ -14247,190 +14842,196 @@
}
}
},
- "No email app is available on this device." : {
+ "No verified domains" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "لا يتوفر تطبيق بريد إلكتروني على هذا الجهاز."
+ "value" : "لا توجد نطاقات موثّقة"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Auf diesem Gerät ist keine E-Mail-App verfügbar."
+ "value" : "Keine verifizierten Domains"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Δεν υπάρχει διαθέσιμη εφαρμογή email σε αυτή τη συσκευή."
+ "value" : "Δεν υπάρχουν επαληθευμένοι τομείς"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No hay ninguna app de correo disponible en este dispositivo."
+ "value" : "No hay dominios verificados"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aucune app e-mail n’est disponible sur cet appareil."
+ "value" : "Aucun domaine vérifié"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "इस डिवाइस पर कोई ईमेल ऐप उपलब्ध नहीं है।"
+ "value" : "कोई सत्यापित डोमेन नहीं"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nessuna app e-mail è disponibile su questo dispositivo."
+ "value" : "Nessun dominio verificato"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "このデバイスではメールアプリを利用できません。"
+ "value" : "確認済みドメインがありません"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "이 기기에서 사용할 수 있는 이메일 앱이 없습니다."
+ "value" : "인증된 도메인이 없어요"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Na tym urządzeniu nie jest dostępna żadna aplikacja e-mail."
+ "value" : "Brak zweryfikowanych domen"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Não há nenhum app de e-mail disponível neste dispositivo."
+ "value" : "Nenhum domínio verificado"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nu există nicio aplicație de e-mail disponibilă pe acest dispozitiv."
+ "value" : "Nu există domenii verificate"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "На этом устройстве нет доступного почтового приложения."
+ "value" : "Нет подтвержденных доменов"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Bu cihazda kullanılabilir bir e-posta uygulaması yok."
+ "value" : "Doğrulanmış alan adı yok"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "此设备上没有可用的邮件应用。"
+ "value" : "没有已验证的域名"
}
}
}
},
- "No verified domains" : {
+ "Not now" : {
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
- "value" : "لا توجد نطاقات موثّقة"
+ "value" : "ليس الآن"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keine verifizierten Domains"
+ "value" : "Nicht jetzt"
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Δεν υπάρχουν επαληθευμένοι τομείς"
+ "value" : "Όχι τώρα"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Not now"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "No hay dominios verificados"
+ "value" : "Ahora no"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aucun domaine vérifié"
+ "value" : "Pas maintenant"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
- "value" : "कोई सत्यापित डोमेन नहीं"
+ "value" : "अभी नहीं"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nessun dominio verificato"
+ "value" : "Non ora"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "確認済みドメインがありません"
+ "value" : "今はしない"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "인증된 도메인이 없어요"
+ "value" : "나중에"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Brak zweryfikowanych domen"
+ "value" : "Nie teraz"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nenhum domínio verificado"
+ "value" : "Agora não"
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nu există domenii verificate"
+ "value" : "Nu acum"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Нет подтвержденных доменов"
+ "value" : "Не сейчас"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Doğrulanmış alan adı yok"
+ "value" : "Şimdi değil"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "没有已验证的域名"
+ "value" : "暂不"
}
}
}
@@ -20192,6 +20793,106 @@
}
}
},
+ "Sign in with %@" : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "تسجيل الدخول باستخدام %@"
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mit %@ anmelden"
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Σύνδεση με %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sign in with %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inicia sesión con %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connectez-vous avec %@"
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ से साइन इन करें"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accedi con %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@でサインイン"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@(으)로 로그인"
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zaloguj się za pomocą %@"
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entre com %@"
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autentifică-te cu %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Войти с %@"
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ile giriş yap"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用%@登录"
+ }
+ }
+ }
+ },
"Sign in with your passkey" : {
"localizations" : {
"ar" : {
@@ -21416,6 +22117,106 @@
}
}
},
+ "The app %@ uses %@ to sign you in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يستخدم تطبيق %@ %@ لتسجيل دخولك."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die App %@ verwendet %@, um Sie anzumelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Η εφαρμογή %@ χρησιμοποιεί %@ για να σας συνδέσει."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The app %@ uses %@ to sign you in."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La aplicación %@ usa %@ para iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’application %@ utilise %@ pour vous connecter."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ऐप आपको साइन इन करने के लिए %@ का उपयोग करता है।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’app %@ usa %@ per farti accedere."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリ%@はサインインに%@を使用します。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ 앱은 %@을 사용하여 로그인합니다."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplikacja %@ używa %@, aby Cię zalogować."
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "O app %@ usa %@ para iniciar sua sessão."
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aplicația %@ folosește %@ pentru a te conecta."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Приложение %@ использует %@ для входа."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ uygulaması oturum açmanız için %@ kullanır."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "应用 %@ 使用 %@ 为你登录。"
+ }
+ }
+ }
+ },
"The domain %@ needs to be verified via email." : {
"localizations" : {
"ar" : {
@@ -23774,6 +24575,106 @@
}
}
},
+ "Use %@ to sign in." : {
+ "localizations" : {
+ "ar" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "استخدم %@ لتسجيل الدخول."
+ }
+ },
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verwenden Sie %@, um sich anzumelden."
+ }
+ },
+ "el" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Χρησιμοποιήστε το %@ για να συνδεθείτε."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use %@ to sign in."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa %@ para iniciar sesión."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisez %@ pour vous connecter."
+ }
+ },
+ "hi" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "साइन इन करने के लिए %@ का उपयोग करें।"
+ }
+ },
+ "it" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa %@ per accedere."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サインインに%@を使用します。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@을 사용하여 로그인합니다."
+ }
+ },
+ "pl" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Użyj %@, aby się zalogować."
+ }
+ },
+ "pt" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use %@ para iniciar sessão."
+ }
+ },
+ "ro" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Folosește %@ pentru a te conecta."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Используйте %@ для входа."
+ }
+ },
+ "tr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oturum açmak için %@ kullanın."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用 %@ 登录。"
+ }
+ }
+ }
+ },
"Use a backup code" : {
"localizations" : {
"ar" : {
diff --git a/Sources/ClerkKitUI/Theme/ClerkColors.swift b/Sources/ClerkKitUI/Theme/ClerkColors.swift
index 3d32222e0..81d1355fb 100644
--- a/Sources/ClerkKitUI/Theme/ClerkColors.swift
+++ b/Sources/ClerkKitUI/Theme/ClerkColors.swift
@@ -16,6 +16,9 @@ extension ClerkTheme {
/// The primary color used throughout the views.
public var primary: Color
+ /// The selected tint color for switch-style toggles.
+ public var switchTint: Color
+
/// The background color for containers.
public var background: Color
@@ -111,6 +114,7 @@ extension ClerkTheme {
/// the `border` base color passed to this initializer.
public init(
primary: Color = Self.defaultPrimaryColor,
+ switchTint: Color? = nil,
background: Color = Self.defaultBackgroundColor,
input: Color = Self.defaultInputColor,
danger: Color = Self.defaultDangerColor,
@@ -129,6 +133,7 @@ extension ClerkTheme {
border: Color = Self.defaultBorderColor
) {
self.primary = primary
+ self.switchTint = switchTint ?? primary
self.background = background
self.input = input
self.danger = danger
diff --git a/Tests/Core/ClerkReconfigureTests.swift b/Tests/Core/ClerkReconfigureTests.swift
index 589fc53cf..8efa86f51 100644
--- a/Tests/Core/ClerkReconfigureTests.swift
+++ b/Tests/Core/ClerkReconfigureTests.swift
@@ -52,6 +52,32 @@ struct ClerkReconfigureTests {
#expect(reconfigured.instanceType == .production)
}
+ @Test
+ func reconfigurePreservesRegisteredAuthFlow() async throws {
+ Clerk.shared.client = nil
+ var registration = Clerk.shared.registerAuthFlow()
+ _ = try #require(registration)
+
+ let reconfigured = try await Clerk.reconfigure(
+ publishableKey: publishableKey(for: "registered-auth-flow.clerk.example.com")
+ )
+ defer { reconfigured.cleanupManagers() }
+
+ var signIn = SignIn.mock
+ signIn.status = .complete
+ signIn.createdSessionId = Client.mock.currentSession?.id
+ reconfigured.applyResponseClient(.mock, completedAuthFlow: .signIn(signIn))
+
+ #expect(reconfigured.isAuthFlowComplete == false)
+
+ reconfigured.markAuthFlowComplete()
+
+ #expect(reconfigured.isAuthFlowComplete)
+
+ registration = nil
+ await Task.yield()
+ }
+
@Test
func invalidReconfigureLeavesCurrentInstanceUntouched() async throws {
let original = Clerk.shared
diff --git a/Tests/Core/ClerkTests.swift b/Tests/Core/ClerkTests.swift
index 1173b601f..fea627c2d 100644
--- a/Tests/Core/ClerkTests.swift
+++ b/Tests/Core/ClerkTests.swift
@@ -2,6 +2,7 @@
import ConcurrencyExtras
import Foundation
import Mocker
+import Observation
import Testing
@MainActor
@@ -96,6 +97,7 @@ struct ClerkTests {
try keychain.set("true", forKey: ClerkKeychainKey.watchSyncDeviceTokenSynced.rawValue)
try keychain.set("test-attest-key-id", forKey: ClerkKeychainKey.attestKeyId.rawValue)
try keychain.set("test-pending-flow", forKey: ClerkKeychainKey.pendingMagicLinkFlow.rawValue)
+ try keychain.set(Data("[]".utf8), forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
// Verify all keys exist before clearing
for key in ClerkKeychainKey.allCases {
@@ -180,6 +182,163 @@ struct ClerkTests {
#expect(try keychain.hasItem(forKey: ClerkKeychainKey.clerkDeviceToken.rawValue) == false)
}
+ @Test
+ func clearAllKeychainItemsPreservesTrustedDeviceMetadataWhenCredentialCleanupFails() throws {
+ let keychain = DataFailingKeychain(failingKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+ try keychain.set("test-client-data", forKey: ClerkKeychainKey.cachedClient.rawValue)
+ try keychain.set(Data("[{}]".utf8), forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+
+ Clerk.clearAllKeychainItems(in: keychain)
+
+ #expect(try keychain.hasItem(forKey: ClerkKeychainKey.cachedClient.rawValue) == false)
+ #expect(try keychain.hasItem(forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue) == true)
+ }
+
+ @Test
+ func clearAllKeychainItemsStrictlyPreservesTrustedDeviceMetadataWhenCredentialCleanupFails() throws {
+ let keychain = DataFailingKeychain(failingKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+ try keychain.set("test-client-data", forKey: ClerkKeychainKey.cachedClient.rawValue)
+ try keychain.set(Data("[{}]".utf8), forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+
+ #expect(throws: ClerkClientError.self) {
+ try Clerk.clearAllKeychainItemsStrictly(in: keychain)
+ }
+ #expect(try keychain.hasItem(forKey: ClerkKeychainKey.cachedClient.rawValue) == false)
+ #expect(try keychain.hasItem(forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue) == true)
+ }
+
+ @Test
+ func configureClearsCurrentAppTrustedDeviceCredentialsWhenInstallMarkerIsMissing() throws {
+ let suiteName = installationMarkerDefaultsSuiteName()
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ let originalDefaults = Clerk.installationMarkerUserDefaults
+ let originalAppIdentifierProvider = Clerk.trustedDeviceAppIdentifierProvider
+ Clerk.installationMarkerUserDefaults = defaults
+ Clerk.trustedDeviceAppIdentifierProvider = { "com.clerk.example" }
+ defer {
+ Clerk.installationMarkerUserDefaults = originalDefaults
+ Clerk.trustedDeviceAppIdentifierProvider = originalAppIdentifierProvider
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let keychain = InMemoryKeychain()
+ let credentialStore = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let otherAppCredential = TrustedDeviceLocalCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let dependencies = MockDependencyContainer(
+ apiClient: Clerk.shared.dependencies.apiClient,
+ keychain: keychain,
+ trustedDeviceKeyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ }),
+ trustedDeviceCredentialStore: credentialStore
+ )
+ try credentialStore.save(.mock)
+ try credentialStore.save(otherAppCredential)
+
+ let clerk = Clerk()
+ clerk.performConfiguration(dependencies: dependencies)
+ defer { clerk.cleanupManagers() }
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try credentialStore.all() == [otherAppCredential])
+ }
+
+ @Test
+ func configureKeepsTrustedDeviceCredentialsWhenInstallMarkerExists() throws {
+ let suiteName = installationMarkerDefaultsSuiteName()
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ let originalDefaults = Clerk.installationMarkerUserDefaults
+ let originalAppIdentifierProvider = Clerk.trustedDeviceAppIdentifierProvider
+ Clerk.installationMarkerUserDefaults = defaults
+ Clerk.trustedDeviceAppIdentifierProvider = { "com.clerk.example" }
+ defer {
+ Clerk.installationMarkerUserDefaults = originalDefaults
+ Clerk.trustedDeviceAppIdentifierProvider = originalAppIdentifierProvider
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let keychain = InMemoryKeychain()
+ let credentialStore = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let dependencies = MockDependencyContainer(
+ apiClient: Clerk.shared.dependencies.apiClient,
+ keychain: keychain,
+ trustedDeviceKeyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ }),
+ trustedDeviceCredentialStore: credentialStore
+ )
+
+ let firstConfigure = Clerk()
+ firstConfigure.performConfiguration(dependencies: dependencies)
+ firstConfigure.cleanupManagers()
+
+ try credentialStore.save(.mock)
+ let secondConfigure = Clerk()
+ secondConfigure.performConfiguration(dependencies: dependencies)
+ defer { secondConfigure.cleanupManagers() }
+
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try credentialStore.all() == [.mock])
+ }
+
+ @Test
+ func configureUsesAppScopedTrustedDeviceInstallationMarkers() throws {
+ let suiteName = installationMarkerDefaultsSuiteName()
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ let originalDefaults = Clerk.installationMarkerUserDefaults
+ let originalAppIdentifierProvider = Clerk.trustedDeviceAppIdentifierProvider
+ Clerk.installationMarkerUserDefaults = defaults
+ Clerk.trustedDeviceAppIdentifierProvider = { "com.clerk.example" }
+ defer {
+ Clerk.installationMarkerUserDefaults = originalDefaults
+ Clerk.trustedDeviceAppIdentifierProvider = originalAppIdentifierProvider
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+
+ let keychain = InMemoryKeychain()
+ let credentialStore = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let otherAppCredential = TrustedDeviceLocalCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let dependencies = MockDependencyContainer(
+ apiClient: Clerk.shared.dependencies.apiClient,
+ keychain: keychain,
+ trustedDeviceKeyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ }),
+ trustedDeviceCredentialStore: credentialStore
+ )
+
+ let firstConfigure = Clerk()
+ firstConfigure.performConfiguration(dependencies: dependencies)
+ firstConfigure.cleanupManagers()
+
+ try credentialStore.save(otherAppCredential)
+ Clerk.trustedDeviceAppIdentifierProvider = { "com.clerk.other" }
+
+ let secondConfigure = Clerk()
+ secondConfigure.performConfiguration(dependencies: dependencies)
+ defer { secondConfigure.cleanupManagers() }
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_other_app"])
+ #expect(try credentialStore.all().isEmpty)
+ }
+
// MARK: - isLoaded Tests
@Test
@@ -242,6 +401,215 @@ struct ClerkTests {
#expect(Clerk.shared.isLoaded == false)
}
+ // MARK: - isAuthFlowComplete Tests
+
+ @Test
+ func isAuthFlowCompleteReturnsFalseWhenSignedOut() {
+ let clerk = Clerk.mockSignedOut
+
+ #expect(clerk.isAuthFlowComplete == false)
+ }
+
+ @Test
+ func isAuthFlowCompleteReturnsFalseWhenSessionIsPending() {
+ var client = Client.mock
+ client.sessions[0].status = .pending
+ client.sessions[0].tasks = [.setupMfa]
+ let clerk = Clerk.mock
+ clerk.client = client
+
+ #expect(clerk.user != nil)
+ #expect(clerk.isAuthFlowComplete == false)
+ }
+
+ @Test
+ func isAuthFlowCompleteReturnsFalseWhenActiveSessionHasNoUser() {
+ var client = Client.mock
+ client.sessions[0].user = nil
+ let clerk = Clerk.mock
+ clerk.client = client
+
+ #expect(clerk.session?.status == .active)
+ #expect(clerk.isAuthFlowComplete == false)
+ }
+
+ @Test
+ func isAuthFlowCompleteReturnsTrueWhenUserHasActiveSession() {
+ let clerk = Clerk.mock
+
+ #expect(clerk.user != nil)
+ #expect(clerk.session?.status == .active)
+ #expect(clerk.isAuthFlowComplete)
+ }
+
+ @Test
+ func registerAuthFlowDoesNotRegisterAnExistingActiveSession() {
+ let clerk = Clerk.mock
+
+ let registration = clerk.registerAuthFlow()
+ clerk.markAuthFlowPending()
+
+ #expect(registration == nil)
+ #expect(clerk.isAuthFlowComplete)
+ }
+
+ @Test
+ func refreshedActiveSessionDoesNotHoldARegisteredAuthFlow() throws {
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+
+ clerk.applyResponseClient(.mock)
+
+ #expect(clerk.isAuthFlowComplete)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func completedAuthenticationDoesNotHoldAnUnregisteredAuthFlow() {
+ let clerk = Clerk.mockSignedOut
+
+ clerk.applyResponseClient(.mock, completedAuthFlow: completedAuthFlow())
+
+ #expect(clerk.isAuthFlowComplete)
+ #expect(clerk.pendingAuthFlowCompletion == nil)
+ }
+
+ @Test
+ func completedAuthenticationHoldsAuthFlowUntilPostAuthCompletes() throws {
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+
+ clerk.applyResponseClient(.mock, completedAuthFlow: completedAuthFlow())
+
+ #expect(clerk.isAuthFlowComplete == false)
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == SignIn.mock.id)
+ #expect(clerk.readyPendingAuthFlowCompletion?.flowId == SignIn.mock.id)
+
+ clerk.consumePendingAuthFlowCompletion()
+ #expect(clerk.pendingAuthFlowCompletion == nil)
+ #expect(clerk.readyPendingAuthFlowCompletion == nil)
+ #expect(clerk.isAuthFlowComplete == false)
+
+ clerk.markAuthFlowComplete()
+ #expect(clerk.isAuthFlowComplete)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func completedAuthenticationWaitsForCreatedSessionToBecomeCurrent() throws {
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+ let completedAuthFlow = completedAuthFlow()
+ var pendingActivationClient = Client.mock
+ pendingActivationClient.sessions[0].status = .unknown("pending_activation")
+ pendingActivationClient.lastActiveSessionId = nil
+
+ clerk.applyResponseClient(
+ pendingActivationClient,
+ completedAuthFlow: completedAuthFlow
+ )
+
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == completedAuthFlow.flowId)
+ #expect(clerk.readyPendingAuthFlowCompletion == nil)
+
+ var differentSession = Client.mock.sessions[0]
+ differentSession.id = "different_session"
+ var differentSessionClient = Client.mock
+ differentSessionClient.sessions.append(differentSession)
+ differentSessionClient.lastActiveSessionId = differentSession.id
+ clerk.applyResponseClient(differentSessionClient)
+
+ #expect(clerk.session?.id == differentSession.id)
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == completedAuthFlow.flowId)
+ #expect(clerk.readyPendingAuthFlowCompletion == nil)
+
+ let didChange = LockIsolated(false)
+ _ = withObservationTracking {
+ clerk.readyPendingAuthFlowCompletion?.flowId
+ } onChange: {
+ didChange.setValue(true)
+ }
+
+ var activatedClient = Client.mock
+ activatedClient.sessions[0].status = .pending
+ activatedClient.sessions[0].tasks = [.setupMfa]
+ clerk.applyResponseClient(activatedClient)
+
+ #expect(didChange.value)
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == completedAuthFlow.flowId)
+ #expect(clerk.readyPendingAuthFlowCompletion?.flowId == completedAuthFlow.flowId)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func restoredPendingSessionHoldsAuthFlowAfterSessionBecomesActive() throws {
+ var pendingClient = Client.mock
+ pendingClient.sessions[0].status = .pending
+ pendingClient.sessions[0].tasks = [.setupMfa]
+ let clerk = Clerk.mock
+ clerk.client = pendingClient
+ let registration = try #require(clerk.registerAuthFlow())
+
+ clerk.applyResponseClient(.mock)
+
+ #expect(clerk.isAuthFlowComplete == false)
+
+ clerk.markAuthFlowComplete()
+ #expect(clerk.isAuthFlowComplete)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func isAuthFlowCompleteIsObservableWhenPendingFlowChanges() throws {
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+ clerk.client = .mock
+ let didChange = LockIsolated(false)
+ let initialValue = withObservationTracking {
+ clerk.isAuthFlowComplete
+ } onChange: {
+ didChange.setValue(true)
+ }
+
+ #expect(initialValue)
+
+ clerk.markAuthFlowPending()
+
+ #expect(didChange.value)
+ #expect(clerk.isAuthFlowComplete == false)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func releasingAuthFlowRegistrationClearsPendingHold() async throws {
+ let clerk = Clerk.mockSignedOut
+ var registration = clerk.registerAuthFlow()
+ _ = try #require(registration)
+ clerk.applyResponseClient(.mock, completedAuthFlow: completedAuthFlow())
+
+ registration = nil
+ await Task.yield()
+
+ #expect(clerk.isAuthFlowComplete)
+ #expect(clerk.pendingAuthFlowCompletion == nil)
+ }
+
+ @Test
+ func delayedRegistrationReleaseDoesNotClearANewerAuthFlow() async throws {
+ let clerk = Clerk.mockSignedOut
+ var previousRegistration = clerk.registerAuthFlow()
+ _ = try #require(previousRegistration)
+
+ previousRegistration = nil
+ let currentRegistration = try #require(clerk.registerAuthFlow())
+ clerk.applyResponseClient(.mock, completedAuthFlow: completedAuthFlow())
+ await Task.yield()
+
+ #expect(clerk.isAuthFlowComplete == false)
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == SignIn.mock.id)
+ withExtendedLifetime(currentRegistration) {}
+ }
+
@Test
func handleReturnsFalseForUnrecognizedURL() async throws {
let url = try #require(URL(string: "https://example.com/not-clerk"))
@@ -452,6 +820,13 @@ struct ClerkTests {
#expect(Clerk.shared.user?.id == User.mock.id)
}
+ private func completedAuthFlow() -> TransferFlowResult {
+ var signIn = SignIn.mock
+ signIn.status = .complete
+ signIn.createdSessionId = Client.mock.currentSession?.id
+ return .signIn(signIn)
+ }
+
private func environment(
showDevmodeWarning: Bool,
type: InstanceEnvironmentType
@@ -462,3 +837,49 @@ struct ClerkTests {
return environment
}
}
+
+private func installationMarkerDefaultsSuiteName() -> String {
+ "com.clerk.tests.installation-marker.\(UUID().uuidString)"
+}
+
+private final class DataFailingKeychain: @unchecked Sendable, KeychainStorage {
+ enum Failure: Error {
+ case data
+ }
+
+ private let lock = NSLock()
+ private let failingKey: String
+ private var items: [String: Data] = [:]
+
+ init(failingKey: String) {
+ self.failingKey = failingKey
+ }
+
+ func set(_ data: Data, forKey key: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ items[key] = data
+ }
+
+ func data(forKey key: String) throws -> Data? {
+ if key == failingKey {
+ throw Failure.data
+ }
+
+ lock.lock()
+ defer { lock.unlock() }
+ return items[key]
+ }
+
+ func deleteItem(forKey key: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ items.removeValue(forKey: key)
+ }
+
+ func hasItem(forKey key: String) throws -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return items[key] != nil
+ }
+}
diff --git a/Tests/Domains/Auth/AuthTests.swift b/Tests/Domains/Auth/AuthTests.swift
index 77577326a..ce8064c4e 100644
--- a/Tests/Domains/Auth/AuthTests.swift
+++ b/Tests/Domains/Auth/AuthTests.swift
@@ -70,6 +70,15 @@ struct AuthTests {
return clerk
}
+ private func enabledTrustedDeviceEnvironment() -> Clerk.Environment {
+ var environment = Clerk.Environment.mock
+ environment.authConfig.nativeSettings = .init(
+ apiEnabled: true,
+ trustedDeviceSignInEnabled: true
+ )
+ return environment
+ }
+
struct SignOutScenario: Codable, Equatable {
let sessionId: String?
}
@@ -330,6 +339,91 @@ struct AuthTests {
#expect(prepareParams.strategy == .passkey)
}
+ @Test
+ func signInWithTrustedDeviceDelegatesToTrustedDevicesSignIn() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+
+ let createParams = LockIsolated(nil)
+ let attemptedSignInId = LockIsolated(nil)
+ let attemptParams = LockIsolated(nil)
+ let signedLocalKeyIds = LockIsolated<[String]>([])
+ let signedReasons = LockIsolated<[String?]>([])
+
+ let challengeSignIn = SignIn(
+ id: "si_trusted_device",
+ status: .needsFirstFactor,
+ firstFactorVerification: .init(
+ status: .unverified,
+ strategy: .trustedDevice,
+ trustedDeviceChallenge: .mock
+ )
+ )
+ let completedSignIn = SignIn(
+ id: challengeSignIn.id,
+ status: .complete,
+ createdSessionId: "sess_trusted_device"
+ )
+ let signInService = MockSignInService(
+ create: { params in
+ createParams.setValue(params)
+ return challengeSignIn
+ },
+ attemptFirstFactor: { signInId, params in
+ attemptedSignInId.setValue(signInId)
+ attemptParams.setValue(params)
+ return completedSignIn
+ }
+ )
+ let keyManager = MockTrustedDeviceKeyManager(sign: { clientData, localKeyId, localizedReason in
+ #expect(clientData == TrustedDeviceChallenge.mock.clientData)
+ signedLocalKeyIds.withValue { $0.append(localKeyId) }
+ signedReasons.withValue { $0.append(localizedReason) }
+ return .init(clientData: clientData, signature: "trusted-device-signature")
+ })
+ let credentialStore = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ try credentialStore.save(.init(
+ id: TrustedDeviceLocalCredential.mock.id,
+ localKeyId: TrustedDeviceLocalCredential.mock.localKeyId,
+ userID: TrustedDeviceLocalCredential.mock.userID,
+ appIdentifier: "com.clerk.example",
+ createdAt: TrustedDeviceLocalCredential.mock.createdAt,
+ updatedAt: TrustedDeviceLocalCredential.mock.updatedAt
+ ))
+
+ let apiClient = createMockAPIClient(baseURL: mockBaseUrl)
+ let auth = Auth(
+ magicLinkStore: MagicLinkStore(keychain: InMemoryKeychain()),
+ magicLinkService: MagicLinkService(apiClient: apiClient),
+ signInService: signInService,
+ signUpService: MockSignUpService(),
+ sessionService: MockSessionService(),
+ trustedDevices: TrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(),
+ signInService: signInService,
+ keyManager: keyManager,
+ credentialStore: credentialStore,
+ appIdentifierProvider: { "com.clerk.example" }
+ ),
+ eventEmitter: EventEmitter(),
+ urlHandlingCoordinator: URLHandlingCoordinator()
+ )
+
+ let signIn = try await auth.signInWithTrustedDevice(reason: "Use Face ID to sign in.")
+
+ #expect(signIn == completedSignIn)
+ #expect(createParams.value?.strategy == .trustedDevice)
+ #expect(createParams.value?.trustedDeviceId == TrustedDeviceLocalCredential.mock.id)
+ #expect(attemptedSignInId.value == challengeSignIn.id)
+ #expect(attemptParams.value?.strategy == .trustedDevice)
+ #expect(attemptParams.value?.trustedDeviceId == TrustedDeviceLocalCredential.mock.id)
+ #expect(attemptParams.value?.clientData == TrustedDeviceChallenge.mock.clientData)
+ #expect(attemptParams.value?.signature == "trusted-device-signature")
+ #expect(attemptParams.value?.algorithm == .es256)
+ #expect(signedLocalKeyIds.value == [TrustedDeviceLocalCredential.mock.localKeyId])
+ #expect(signedReasons.value == ["Use Face ID to sign in."])
+ }
+
@Test
func signInWithTicketUsesSignInServiceCreate() async throws {
let signInParams = LockIsolated(nil)
diff --git a/Tests/Domains/Auth/FactorTests.swift b/Tests/Domains/Auth/FactorTests.swift
index aa46307a7..ed73ff004 100644
--- a/Tests/Domains/Auth/FactorTests.swift
+++ b/Tests/Domains/Auth/FactorTests.swift
@@ -26,4 +26,36 @@ struct FactorTests {
#expect(decoded == factor)
#expect(decoded.default == true)
}
+
+ @Test
+ func trustedDeviceStrategyRoundTripsThroughJSON() throws {
+ let strategy = FactorStrategy.trustedDevice
+
+ let data = try JSONEncoder.clerkEncoder.encode(strategy)
+ let rawValue = try #require(String(data: data, encoding: .utf8))
+
+ #expect(rawValue == "\"trusted_device\"")
+ #expect(try JSONDecoder.clerkDecoder.decode(FactorStrategy.self, from: data) == .trustedDevice)
+ }
+
+ @Test
+ func trustedDeviceFactorRoundTripsThroughJSON() throws {
+ let factor = Factor(
+ strategy: .trustedDevice,
+ trustedDeviceId: "tdc_123",
+ safeIdentifier: "Sean's iPhone"
+ )
+
+ let data = try JSONEncoder.clerkEncoder.encode(factor)
+ let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+
+ #expect(object["strategy"] as? String == "trusted_device")
+ #expect(object["trusted_device_id"] as? String == "tdc_123")
+ #expect(object["safe_identifier"] as? String == "Sean's iPhone")
+
+ let decoded = try JSONDecoder.clerkDecoder.decode(Factor.self, from: data)
+
+ #expect(decoded == factor)
+ #expect(decoded.trustedDeviceId == "tdc_123")
+ }
}
diff --git a/Tests/Domains/Auth/SignIn/SignInServiceTests.swift b/Tests/Domains/Auth/SignIn/SignInServiceTests.swift
index db553e946..957fb5b97 100644
--- a/Tests/Domains/Auth/SignIn/SignInServiceTests.swift
+++ b/Tests/Domains/Auth/SignIn/SignInServiceTests.swift
@@ -147,6 +147,33 @@ struct SignInServiceTests {
#expect(requestHandled.value)
}
+ @Test
+ func createWithTrustedDevice() async throws {
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/client/sign_ins")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .post: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "POST")
+ #expect(request.urlEncodedFormBody?["strategy"] == "trusted_device")
+ #expect(request.urlEncodedFormBody?["trusted_device_id"] == "tdc_123")
+ #expect(request.urlEncodedFormBody?["locale"] != nil)
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ _ = try await Clerk.shared.dependencies.signInService.create(
+ params: .init(strategy: .trustedDevice, trustedDeviceId: "tdc_123")
+ )
+ #expect(requestHandled.value)
+ }
+
@Test
func createWithTicket() async throws {
let requestHandled = LockIsolated(false)
@@ -486,6 +513,43 @@ struct SignInServiceTests {
#expect(requestHandled.value)
}
+ @Test
+ func attemptFirstFactorTrustedDevice() async throws {
+ let signIn = SignIn.mock
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/client/sign_ins/\(signIn.id)/attempt_first_factor")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .post: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "POST")
+ #expect(request.urlEncodedFormBody?["strategy"] == "trusted_device")
+ #expect(request.urlEncodedFormBody?["trusted_device_id"] == "tdc_123")
+ #expect(request.urlEncodedFormBody?["client_data"] == "{\"challenge_id\":\"tdch_123\"}")
+ #expect(request.urlEncodedFormBody?["signature"] == "mock_signature")
+ #expect(request.urlEncodedFormBody?["algorithm"] == "ES256")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ _ = try await Clerk.shared.dependencies.signInService.attemptFirstFactor(
+ signInId: signIn.id,
+ params: .init(
+ strategy: .trustedDevice,
+ trustedDeviceId: "tdc_123",
+ clientData: "{\"challenge_id\":\"tdch_123\"}",
+ signature: "mock_signature",
+ algorithm: .es256
+ )
+ )
+ #expect(requestHandled.value)
+ }
+
#if canImport(AuthenticationServices) && !os(watchOS) && !os(tvOS)
@Test
func attemptFirstFactorIdToken() async throws {
diff --git a/Tests/Domains/Auth/TrustedDevice/TrustedDeviceKeyManagerTests.swift b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceKeyManagerTests.swift
new file mode 100644
index 000000000..91c64bc40
--- /dev/null
+++ b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceKeyManagerTests.swift
@@ -0,0 +1,238 @@
+@testable import ClerkKit
+import Foundation
+#if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+import LocalAuthentication
+#endif
+import Security
+import Testing
+
+struct TrustedDeviceKeyManagerTests {
+ @Test
+ func localKeyDefaultsToBiometryOrDevicePasscodePolicy() {
+ let localKey = TrustedDeviceLocalKey(localKeyId: "tdlk_123", publicKeyJWK: "{}")
+
+ #expect(localKey.policy == .biometryOrDevicePasscode)
+ }
+
+ @Test
+ func privateKeyAttributesUseSecureEnclaveAccessControl() throws {
+ let accessControl = try TrustedDeviceKeyManager.makeAccessControl()
+
+ let attributes = TrustedDeviceKeyManager.makePrivateKeyAttributes(
+ localKeyId: "tdlk_123",
+ accessControl: accessControl
+ )
+
+ #expect(attributes[kSecAttrKeyType as String] as? String == kSecAttrKeyTypeECSECPrimeRandom as String)
+ #expect(attributes[kSecAttrKeySizeInBits as String] as? Int == 256)
+ #expect(attributes[kSecAttrTokenID as String] as? String == kSecAttrTokenIDSecureEnclave as String)
+ #if os(macOS)
+ #expect(attributes[kSecUseDataProtectionKeychain as String] as? Bool == true)
+ #else
+ #expect(attributes[kSecUseDataProtectionKeychain as String] == nil)
+ #endif
+
+ let privateKeyAttributes = try #require(attributes[kSecPrivateKeyAttrs as String] as? [String: Any])
+ #expect(privateKeyAttributes[kSecAttrIsPermanent as String] as? Bool == true)
+ #expect(
+ privateKeyAttributes[kSecAttrApplicationTag as String] as? Data ==
+ Data("dev.clerk.trusted_device.tdlk_123".utf8)
+ )
+ #expect(privateKeyAttributes[kSecAttrAccessControl as String] != nil)
+ }
+
+ @Test
+ func accessControlFlagsMatchTrustedDevicePolicies() {
+ #expect(TrustedDeviceKeyManager.accessControlFlags(for: .biometryCurrentSet) == [
+ .privateKeyUsage,
+ .biometryCurrentSet,
+ ])
+ #expect(TrustedDeviceKeyManager.accessControlFlags(for: .biometryAny) == [
+ .privateKeyUsage,
+ .biometryAny,
+ ])
+ #expect(TrustedDeviceKeyManager.accessControlFlags(for: .biometryOrDevicePasscode) == [
+ .privateKeyUsage,
+ .userPresence,
+ ])
+ }
+
+ #if (os(iOS) || os(macOS)) && canImport(LocalAuthentication)
+ @Test
+ func localAuthenticationPoliciesMatchTrustedDevicePolicies() {
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicy(for: .biometryCurrentSet) ==
+ .deviceOwnerAuthenticationWithBiometrics
+ )
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicy(for: .biometryAny) ==
+ .deviceOwnerAuthenticationWithBiometrics
+ )
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicy(for: .biometryOrDevicePasscode) ==
+ .deviceOwnerAuthentication
+ )
+ }
+
+ @Test
+ func localAuthenticationPoliciesForKeyCreationRequireBiometrics() {
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicyForKeyCreation(for: .biometryCurrentSet) ==
+ .deviceOwnerAuthenticationWithBiometrics
+ )
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicyForKeyCreation(for: .biometryAny) ==
+ .deviceOwnerAuthenticationWithBiometrics
+ )
+ #expect(
+ TrustedDeviceKeyManager.localAuthenticationPolicyForKeyCreation(for: .biometryOrDevicePasscode) ==
+ .deviceOwnerAuthenticationWithBiometrics
+ )
+ }
+ #endif
+
+ @Test
+ func privateKeyQueryUsesStableApplicationTag() {
+ let query = TrustedDeviceKeyManager.privateKeyQuery(localKeyId: "tdlk_123")
+
+ #expect(query[kSecClass as String] as? String == kSecClassKey as String)
+ #expect(query[kSecAttrKeyClass as String] as? String == kSecAttrKeyClassPrivate as String)
+ #expect(query[kSecAttrKeyType as String] as? String == kSecAttrKeyTypeECSECPrimeRandom as String)
+ #expect(query[kSecAttrApplicationTag as String] as? Data == Data("dev.clerk.trusted_device.tdlk_123".utf8))
+ #if os(macOS)
+ #expect(query[kSecUseDataProtectionKeychain as String] as? Bool == true)
+ #else
+ #expect(query[kSecUseDataProtectionKeychain as String] == nil)
+ #endif
+ }
+
+ @Test
+ func publicKeyJWKEncodesP256X963Representation() throws {
+ let x = Data(repeating: 0x01, count: 32)
+ let y = Data(repeating: 0x02, count: 32)
+ let representation = Data([0x04]) + x + y
+
+ let jwk = try TrustedDeviceKeyManager.publicKeyJWK(fromX963Representation: representation)
+ let object = try #require(JSONSerialization.jsonObject(with: Data(jwk.utf8)) as? [String: String])
+
+ #expect(object["kty"] == "EC")
+ #expect(object["crv"] == "P-256")
+ #expect(object["x"] == TrustedDeviceKeyManager.base64URLEncodedString(x))
+ #expect(object["y"] == TrustedDeviceKeyManager.base64URLEncodedString(y))
+ #expect(object["alg"] == "ES256")
+ }
+
+ @Test
+ func publicKeyJWKRejectsInvalidRepresentation() throws {
+ do {
+ _ = try TrustedDeviceKeyManager.publicKeyJWK(fromX963Representation: Data(repeating: 0x01, count: 64))
+ Issue.record("Expected invalid public key error.")
+ } catch let error as TrustedDeviceKeyManagerError {
+ #expect(error == .invalidPublicKey)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func base64URLEncodingOmitsPadding() {
+ #expect(TrustedDeviceKeyManager.base64URLEncodedString(Data([0xFB, 0xFF, 0xEF])) == "-__v")
+ #expect(!TrustedDeviceKeyManager.base64URLEncodedString(Data([0x01])).contains("="))
+ }
+
+ @Test
+ func rawES256SignatureConvertsDEREncodedSignature() throws {
+ let r = Data(repeating: 0x01, count: 32)
+ let s = Data([0x00, 0x80]) + Data(repeating: 0x02, count: 31)
+ let derSignature = Data([0x30, 0x45, 0x02, 0x20]) + r + Data([0x02, 0x21]) + s
+
+ let rawSignature = try TrustedDeviceKeyManager.rawES256Signature(fromDEREncoded: derSignature)
+
+ #expect(rawSignature == r + Data([0x80]) + Data(repeating: 0x02, count: 31))
+ }
+
+ @Test
+ func rawES256SignaturePadsShortDERIntegers() throws {
+ let derSignature = Data([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02])
+
+ let rawSignature = try TrustedDeviceKeyManager.rawES256Signature(fromDEREncoded: derSignature)
+
+ #expect(rawSignature == Data(repeating: 0x00, count: 31) + Data([0x01]) +
+ Data(repeating: 0x00, count: 31) + Data([0x02]))
+ }
+
+ @Test
+ func rawES256SignatureRejectsMalformedDER() throws {
+ do {
+ _ = try TrustedDeviceKeyManager.rawES256Signature(fromDEREncoded: Data([0x30, 0x03, 0x02, 0x01, 0x01]))
+ Issue.record("Expected malformed DER signature error.")
+ } catch let error as TrustedDeviceKeyManagerError {
+ #expect(error == .signingFailed("Security returned an invalid ES256 signature."))
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func privateKeyLookupStatusMapsBiometricErrors() {
+ #expect(
+ TrustedDeviceKeyManager.privateKeyLookupError(for: errSecUserCanceled)
+ as? TrustedDeviceKeyManagerError == .biometricAuthenticationCanceled
+ )
+ #expect(
+ TrustedDeviceKeyManager.privateKeyLookupError(for: errSecAuthFailed)
+ as? TrustedDeviceKeyManagerError == .biometricAuthenticationFailed
+ )
+ #expect(
+ TrustedDeviceKeyManager.privateKeyLookupError(for: errSecInteractionNotAllowed)
+ as? TrustedDeviceKeyManagerError == .biometricAuthenticationUnavailable
+ )
+
+ guard case let .unexpectedStatus(status)? = TrustedDeviceKeyManager.privateKeyLookupError(for: errSecNotAvailable)
+ as? KeychainError
+ else {
+ Issue.record("Expected unknown keychain statuses to preserve their OSStatus.")
+ return
+ }
+
+ #expect(status == errSecNotAvailable)
+ }
+
+ @MainActor
+ @Test
+ func mockKeyManagerSignsClientData() throws {
+ let manager = MockTrustedDeviceKeyManager(sign: { clientData, localKeyId, localizedReason in
+ #expect(clientData == "{\"challenge_id\":\"tdch_123\"}")
+ #expect(localKeyId == "tdlk_123")
+ #expect(localizedReason == "Use biometrics")
+ return TrustedDeviceKeySignature(clientData: clientData, signature: "signature")
+ })
+
+ let signature = try manager.sign(
+ clientData: "{\"challenge_id\":\"tdch_123\"}",
+ localKeyId: "tdlk_123",
+ localizedReason: "Use biometrics"
+ )
+
+ #expect(signature.clientData == "{\"challenge_id\":\"tdch_123\"}")
+ #expect(signature.signature == "signature")
+ #expect(signature.algorithm == .es256)
+ }
+
+ @MainActor
+ @Test
+ func mockKeyManagerSurfacesMissingKey() throws {
+ let manager = MockTrustedDeviceKeyManager(sign: { _, _, _ in
+ throw TrustedDeviceKeyManagerError.keyNotFound
+ })
+
+ do {
+ _ = try manager.sign(clientData: "{}", localKeyId: "tdlk_missing")
+ Issue.record("Expected missing key error.")
+ } catch let error as TrustedDeviceKeyManagerError {
+ #expect(error == .keyNotFound)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+}
diff --git a/Tests/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStoreTests.swift b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStoreTests.swift
new file mode 100644
index 000000000..54b328792
--- /dev/null
+++ b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceLocalCredentialStoreTests.swift
@@ -0,0 +1,606 @@
+@testable import ClerkKit
+import ConcurrencyExtras
+import Foundation
+import Testing
+
+@MainActor
+@Suite(.serialized)
+struct TrustedDeviceLocalCredentialStoreTests {
+ @Test
+ func saveAndLoadCredentialMetadata() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+
+ try store.save(.mock)
+
+ #expect(try store.all() == [.mock])
+ #expect(try store.credential(id: "tdc_123") == .mock)
+ }
+
+ @Test
+ func saveReplacesExistingCredentialMetadata() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let updated = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_new",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+
+ try store.save(.mock)
+ try store.save(updated)
+
+ #expect(try store.all() == [updated])
+ }
+
+ @Test
+ func saveDeletesReplacedLocalKeyAfterOverwritingMetadata() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let updated = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_new",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+
+ try store.save(.mock)
+ try store.save(updated, deleteReplacedLocalKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try store.all() == [updated])
+ }
+
+ @Test
+ func saveKeepsUpdatedMetadataWhenReplacedKeyDeletionFails() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let updated = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_new",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+
+ try store.save(.mock)
+
+ try store.save(updated, deleteReplacedLocalKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ throw TestKeyDeletionError.failed
+ })
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try store.all() == [updated])
+ }
+
+ @Test
+ func saveKeepsExistingMetadataAndKeyWhenReplacementPersistenceFails() throws {
+ let keychain = WriteFailingKeychain(failStartingAtSet: 1)
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let updated = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_new",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+
+ try store.save(.mock)
+
+ #expect(throws: WriteFailingKeychain.Failure.self) {
+ try store.save(updated, deleteReplacedLocalKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ }
+
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try store.all() == [.mock])
+ }
+
+ @Test
+ func deleteCredentialMetadata() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+
+ try store.save(.mock)
+ try store.delete(id: "tdc_123")
+
+ #expect(try store.all().isEmpty)
+ #expect(try store.credential(id: "tdc_123") == nil)
+ }
+
+ @Test
+ func localCredentialCanBeBuiltFromServerCredentialAndLocalKey() {
+ let localKey = TrustedDeviceLocalKey(
+ localKeyId: TrustedDeviceLocalKey.mock.localKeyId,
+ publicKeyJWK: TrustedDeviceLocalKey.mock.publicKeyJWK,
+ policy: .biometryOrDevicePasscode
+ )
+ let credential = TrustedDeviceLocalCredential(
+ trustedDevice: .mock,
+ localKey: localKey,
+ userID: User.mock.id,
+ identifierHint: " Sean@Example.COM "
+ )
+
+ #expect(credential.id == TrustedDevice.mock.id)
+ #expect(credential.localKeyId == TrustedDeviceLocalKey.mock.localKeyId)
+ #expect(credential.userID == User.mock.id)
+ #expect(credential.appIdentifier == TrustedDevice.mock.appIdentifier)
+ #expect(credential.identifierHint == "sean@example.com")
+ #expect(credential.policy == .biometryOrDevicePasscode)
+ }
+
+ @Test
+ func emptyIdentifierHintIsNotPersisted() {
+ let credential = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_mock",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ identifierHint: " ",
+ createdAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+
+ #expect(credential.identifierHint == nil)
+ #expect(credential.matches(identifierHint: nil))
+ #expect(!credential.matches(identifierHint: "sean@example.com"))
+ }
+
+ @Test
+ func credentialMetadataRequiresPolicy() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [{
+ "id": "tdc_123",
+ "localKeyId": "tdlk_mock",
+ "userId": "1",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+
+ #expect(throws: DecodingError.self) {
+ _ = try store.credential(id: "tdc_123")
+ }
+ }
+
+ @Test
+ func credentialMetadataRequiresUserID() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [{
+ "id": "tdc_123",
+ "localKeyId": "tdlk_mock",
+ "appIdentifier": "com.clerk.example",
+ "policy": "biometry_current_set",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+
+ #expect(throws: DecodingError.self) {
+ _ = try store.credential(id: "tdc_123")
+ }
+ }
+
+ @Test
+ func deleteAllLocalCredentialsDeletesKeysAndMetadata() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let other = TrustedDeviceLocalCredential(
+ id: "tdc_456",
+ localKeyId: "tdlk_other",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.save(.mock)
+ try store.save(other)
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock", "tdlk_other"])
+ #expect(try store.all().isEmpty)
+ }
+
+ @Test
+ func deleteAllLocalCredentialsPreservesMetadataForFailedKeyDeletions() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let failedCredential = TrustedDeviceLocalCredential(
+ id: "tdc_failed",
+ localKeyId: "tdlk_failed",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ if localKeyId == failedCredential.localKeyId {
+ throw TestKeyDeletionError.failed
+ }
+ })
+
+ try store.save(.mock)
+ try store.save(failedCredential)
+
+ #expect(throws: TestKeyDeletionError.self) {
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+ }
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock", "tdlk_failed"])
+ #expect(try store.all() == [failedCredential])
+ }
+
+ @Test
+ func deleteAllLocalCredentialsStopsAfterMetadataPersistenceFails() throws {
+ let keychain = WriteFailingKeychain(failStartingAtSet: 2)
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let other = TrustedDeviceLocalCredential(
+ id: "tdc_456",
+ localKeyId: "tdlk_other",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.save(.mock)
+ try store.save(other)
+
+ #expect(throws: WriteFailingKeychain.Failure.self) {
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+ }
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try store.all() == [.mock, other])
+ }
+
+ @Test
+ func deleteLocalCredentialsDeletesOnlyMatchingAppIdentifier() throws {
+ let store = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let otherAppCredential = TrustedDeviceLocalCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSince1970: 1),
+ updatedAt: Date(timeIntervalSince1970: 2)
+ )
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.save(.mock)
+ try store.save(otherAppCredential)
+ try store.deleteLocalCredentials(
+ appIdentifier: "com.clerk.example",
+ keyManager: keyManager
+ )
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try store.all() == [otherAppCredential])
+ }
+
+ @Test
+ func appScopedReadsAndSavesIgnoreMalformedCredentialsForOtherApps() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [{
+ "id": "tdc_other_app",
+ "localKeyId": "tdlk_other_app",
+ "appIdentifier": "com.clerk.other",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+
+ try store.save(.mock)
+
+ #expect(try store.all(appIdentifier: "com.clerk.example") == [.mock])
+ #expect(try store.credential(id: TrustedDeviceLocalCredential.mock.id) == .mock)
+ #expect(throws: DecodingError.self) {
+ _ = try store.all()
+ }
+ }
+
+ @Test
+ func appScopedReadsSkipMalformedCredentialsForCurrentApp() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [
+ {
+ "id": "tdc_malformed",
+ "localKeyId": "tdlk_malformed",
+ "userId": "user_malformed",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ },
+ {
+ "id": "tdc_valid",
+ "localKeyId": "tdlk_valid",
+ "userId": "user_valid",
+ "appIdentifier": "com.clerk.example",
+ "policy": "biometry_or_device_passcode",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }
+ ]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+
+ let credentials = try store.all(appIdentifier: "com.clerk.example")
+
+ #expect(credentials == [
+ TrustedDeviceLocalCredential(
+ id: "tdc_valid",
+ localKeyId: "tdlk_valid",
+ userID: "user_valid",
+ appIdentifier: "com.clerk.example",
+ createdAt: Date(timeIntervalSince1970: 1_234_567_890),
+ updatedAt: Date(timeIntervalSince1970: 1_234_567_890)
+ ),
+ ])
+ #expect(throws: DecodingError.self) {
+ _ = try store.all()
+ }
+ }
+
+ @Test
+ func deleteAllLocalCredentialsDeletesMalformedMetadataAfterDeletingKeys() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [{
+ "id": "tdc_legacy",
+ "localKeyId": "tdlk_legacy",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_legacy"])
+ #expect(try store.all().isEmpty)
+ }
+
+ @Test
+ func deleteAllLocalCredentialsDeletesRecoverableKeysFromPartiallyMalformedMetadata() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [
+ {
+ "id": "tdc_recoverable",
+ "localKeyId": "tdlk_recoverable",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ },
+ {
+ "id": "tdc_without_local_key",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ },
+ {
+ "id": "tdc_other",
+ "localKeyId": "tdlk_other",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }
+ ]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_recoverable", "tdlk_other"])
+ #expect(try store.all().isEmpty)
+ }
+
+ @Test
+ func deleteAllLocalCredentialsPreservesMalformedMetadataForFailedKeyDeletions() throws {
+ let metadata = Data(
+ """
+ [{
+ "id": "tdc_legacy",
+ "localKeyId": "tdlk_legacy",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }]
+ """.utf8
+ )
+ let keychain = InMemoryKeychain()
+ try keychain.set(metadata, forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ throw TestKeyDeletionError.failed
+ })
+
+ #expect(throws: TestKeyDeletionError.self) {
+ try store.deleteAllLocalCredentials(keyManager: keyManager)
+ }
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_legacy"])
+ #expect(try keychain.data(forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue) == metadata)
+ }
+
+ @Test
+ func deleteLocalCredentialsDeletesOnlyMatchingAppIdentifierFromMalformedMetadata() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(
+ Data(
+ """
+ [
+ {
+ "id": "tdc_current_app",
+ "localKeyId": "tdlk_current_app",
+ "appIdentifier": "com.clerk.example",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ },
+ {
+ "id": "tdc_other_app",
+ "localKeyId": "tdlk_other_app",
+ "appIdentifier": "com.clerk.other",
+ "createdAt": 1234567890000,
+ "updatedAt": 1234567890000
+ }
+ ]
+ """.utf8
+ ),
+ forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue
+ )
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+
+ try store.deleteLocalCredentials(
+ appIdentifier: "com.clerk.example",
+ keyManager: keyManager
+ )
+
+ let remainingData = try #require(try keychain.data(forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue))
+ let remainingObject = try JSONSerialization.jsonObject(with: remainingData)
+ let remainingRecords = try #require(remainingObject as? [[String: Any]])
+ let remainingRecord = try #require(remainingRecords.first)
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_current_app"])
+ #expect(try store.all(appIdentifier: "com.clerk.example").isEmpty)
+ #expect(remainingRecords.count == 1)
+ #expect(remainingRecord["localKeyId"] as? String == "tdlk_other_app")
+ #expect(remainingRecord["appIdentifier"] as? String == "com.clerk.other")
+ }
+
+ @Test
+ func corruptCredentialMetadataThrows() throws {
+ let keychain = InMemoryKeychain()
+ try keychain.set(Data("not-json".utf8), forKey: ClerkKeychainKey.trustedDeviceCredentials.rawValue)
+ let store = TrustedDeviceLocalCredentialStore(keychain: keychain)
+
+ do {
+ _ = try store.all()
+ Issue.record("Expected corrupt metadata to throw.")
+ } catch {
+ #expect(error is DecodingError)
+ }
+ }
+}
+
+private enum TestKeyDeletionError: Error {
+ case failed
+}
+
+private final class WriteFailingKeychain: @unchecked Sendable, KeychainStorage {
+ enum Failure: Error {
+ case set
+ }
+
+ private let lock = NSLock()
+ private let failStartingAtSet: Int
+ private var items: [String: Data] = [:]
+ private var setCount = 0
+
+ init(failStartingAtSet: Int) {
+ self.failStartingAtSet = failStartingAtSet
+ }
+
+ func set(_ data: Data, forKey key: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ guard setCount < failStartingAtSet else {
+ throw Failure.set
+ }
+ setCount += 1
+ items[key] = data
+ }
+
+ func data(forKey key: String) throws -> Data? {
+ lock.lock()
+ defer { lock.unlock() }
+ return items[key]
+ }
+
+ func deleteItem(forKey key: String) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ items.removeValue(forKey: key)
+ }
+
+ func hasItem(forKey key: String) throws -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return items[key] != nil
+ }
+}
diff --git a/Tests/Domains/Auth/TrustedDevice/TrustedDeviceServiceTests.swift b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceServiceTests.swift
new file mode 100644
index 000000000..da48b606a
--- /dev/null
+++ b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceServiceTests.swift
@@ -0,0 +1,165 @@
+@testable import ClerkKit
+import ConcurrencyExtras
+import Foundation
+import Mocker
+import Testing
+
+@MainActor
+@Suite(.serialized)
+struct TrustedDeviceServiceTests {
+ init() {
+ configureClerkForTesting()
+ }
+
+ @Test
+ func list() async throws {
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/trusted_devices")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .get: JSONEncoder.clerkEncoder.encode(ClientResponse<[TrustedDevice]>(response: [.mock], client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "GET")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ let trustedDevices = try await Clerk.shared.dependencies.trustedDeviceService.list()
+
+ #expect(requestHandled.value)
+ #expect(trustedDevices == [.mock])
+ }
+
+ @Test
+ func prepareEnrollment() async throws {
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/trusted_devices/prepare")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .post: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "POST")
+ #expect(request.urlEncodedFormBody?["platform"] == "ios")
+ #expect(request.urlEncodedFormBody?["app_identifier"] == "com.clerk.example")
+ #expect(request.urlEncodedFormBody?["name"] == "Sean's iPhone")
+ #expect(request.urlEncodedFormBody?["algorithm"] == "ES256")
+ #expect(request.urlEncodedFormBody?["public_key_jwk"] == "{\"kty\":\"EC\"}")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ _ = try await Clerk.shared.dependencies.trustedDeviceService.prepareEnrollment(
+ params: .init(
+ appIdentifier: "com.clerk.example",
+ name: "Sean's iPhone",
+ publicKeyJWK: "{\"kty\":\"EC\"}"
+ )
+ )
+
+ #expect(requestHandled.value)
+ }
+
+ @Test
+ func attemptEnrollment() async throws {
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/trusted_devices/attempt")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .post: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "POST")
+ #expect(request.urlEncodedFormBody?["platform"] == "ios")
+ #expect(request.urlEncodedFormBody?["app_identifier"] == "com.clerk.example")
+ #expect(request.urlEncodedFormBody?["name"] == "Sean's iPhone")
+ #expect(request.urlEncodedFormBody?["algorithm"] == "ES256")
+ #expect(request.urlEncodedFormBody?["public_key_jwk"] == "{\"kty\":\"EC\"}")
+ #expect(request.urlEncodedFormBody?["client_data"] == "{\"challenge_id\":\"tdch_123\"}")
+ #expect(request.urlEncodedFormBody?["signature"] == "mock_signature")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ _ = try await Clerk.shared.dependencies.trustedDeviceService.attemptEnrollment(
+ params: .init(
+ appIdentifier: "com.clerk.example",
+ name: "Sean's iPhone",
+ publicKeyJWK: "{\"kty\":\"EC\"}",
+ clientData: "{\"challenge_id\":\"tdch_123\"}",
+ signature: "mock_signature"
+ )
+ )
+
+ #expect(requestHandled.value)
+ }
+
+ @Test
+ func validateSignInCredential() async throws {
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/client/trusted_devices/validate")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .post: JSONEncoder.clerkEncoder.encode(
+ ClientResponse(
+ response: .init(valid: true),
+ client: .mock
+ )
+ ),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "POST")
+ #expect(request.urlEncodedFormBody?["trusted_device_id"] == "tdc_123")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ let validation = try await Clerk.shared.dependencies.trustedDeviceService.validateSignInCredential(
+ trustedDeviceId: "tdc_123"
+ )
+
+ #expect(requestHandled.value)
+ #expect(validation == .init(valid: true))
+ }
+
+ @Test
+ func revoke() async throws {
+ let trustedDevice = TrustedDevice.mock
+ let requestHandled = LockIsolated(false)
+ let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/trusted_devices/\(trustedDevice.id)")!
+
+ var mock = try Mock(
+ url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200,
+ data: [
+ .delete: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)),
+ ]
+ )
+
+ mock.onRequestHandler = OnRequestHandler { @Sendable request in
+ #expect(request.httpMethod == "DELETE")
+ requestHandled.setValue(true)
+ }
+ mock.register()
+
+ _ = try await Clerk.shared.dependencies.trustedDeviceService.revoke(trustedDeviceId: trustedDevice.id)
+
+ #expect(requestHandled.value)
+ }
+}
diff --git a/Tests/Domains/Auth/TrustedDevice/TrustedDeviceTests.swift b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceTests.swift
new file mode 100644
index 000000000..f88eb4c1d
--- /dev/null
+++ b/Tests/Domains/Auth/TrustedDevice/TrustedDeviceTests.swift
@@ -0,0 +1,88 @@
+@testable import ClerkKit
+import Foundation
+import Testing
+
+struct TrustedDeviceTests {
+ private let decoder = JSONDecoder.clerkDecoder
+ private let encoder = JSONEncoder.clerkEncoder
+
+ @Test
+ func trustedDeviceDecodesBackendShape() throws {
+ let data = Data(
+ """
+ {
+ "id": "tdc_123",
+ "object": "trusted_device",
+ "platform": "ios",
+ "app_identifier": "com.clerk.example",
+ "name": "Sean's iPhone",
+ "algorithm": "ES256",
+ "status": "active",
+ "created_at": 1710000000000,
+ "updated_at": 1710000001000,
+ "last_used_at": 1710000002000,
+ "revoked_at": null
+ }
+ """.utf8
+ )
+
+ let trustedDevice = try decoder.decode(TrustedDevice.self, from: data)
+
+ #expect(trustedDevice.id == "tdc_123")
+ #expect(trustedDevice.object == "trusted_device")
+ #expect(trustedDevice.platform == .iOS)
+ #expect(trustedDevice.appIdentifier == "com.clerk.example")
+ #expect(trustedDevice.name == "Sean's iPhone")
+ #expect(trustedDevice.algorithm == .es256)
+ #expect(trustedDevice.status == .active)
+ #expect(trustedDevice.createdAt == Date(timeIntervalSince1970: 1_710_000_000))
+ #expect(trustedDevice.updatedAt == Date(timeIntervalSince1970: 1_710_000_001))
+ #expect(trustedDevice.lastUsedAt == Date(timeIntervalSince1970: 1_710_000_002))
+ #expect(trustedDevice.revokedAt == nil)
+ }
+
+ @Test
+ func trustedDeviceChallengeDecodesBackendShape() throws {
+ let data = Data(
+ """
+ {
+ "object": "trusted_device_challenge",
+ "challenge": "challenge",
+ "challenge_id": "tdch_123",
+ "trusted_device_id": "tdc_123",
+ "client_data": "{\\"challenge_id\\":\\"tdch_123\\"}",
+ "expires_at": 1710000000000,
+ "algorithm": "ES256"
+ }
+ """.utf8
+ )
+
+ let challenge = try decoder.decode(TrustedDeviceChallenge.self, from: data)
+
+ #expect(challenge.object == "trusted_device_challenge")
+ #expect(challenge.challenge == "challenge")
+ #expect(challenge.challengeId == "tdch_123")
+ #expect(challenge.trustedDeviceId == "tdc_123")
+ #expect(challenge.clientData == "{\"challenge_id\":\"tdch_123\"}")
+ #expect(challenge.expiresAt == Date(timeIntervalSince1970: 1_710_000_000))
+ #expect(challenge.algorithm == .es256)
+ }
+
+ @Test
+ func prepareEnrollmentParamsEncodeBackendKeys() throws {
+ let params = TrustedDevice.PrepareEnrollmentParams(
+ appIdentifier: "com.clerk.example",
+ name: "Sean's iPhone",
+ publicKeyJWK: "{\"kty\":\"EC\"}"
+ )
+
+ let data = try encoder.encode(params)
+ let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+
+ #expect(object["platform"] as? String == "ios")
+ #expect(object["app_identifier"] as? String == "com.clerk.example")
+ #expect(object["name"] as? String == "Sean's iPhone")
+ #expect(object["algorithm"] as? String == "ES256")
+ #expect(object["public_key_jwk"] as? String == "{\"kty\":\"EC\"}")
+ }
+}
diff --git a/Tests/Domains/Auth/TrustedDevice/TrustedDevicesTests.swift b/Tests/Domains/Auth/TrustedDevice/TrustedDevicesTests.swift
new file mode 100644
index 000000000..28db90eba
--- /dev/null
+++ b/Tests/Domains/Auth/TrustedDevice/TrustedDevicesTests.swift
@@ -0,0 +1,1378 @@
+@testable import ClerkKit
+import ConcurrencyExtras
+import Foundation
+import Testing
+
+@MainActor
+@Suite(.serialized)
+struct TrustedDevicesTests {
+ init() {
+ configureClerkForTesting()
+ }
+
+ @Test
+ func listUsesTrustedDeviceService() async throws {
+ let called = LockIsolated(false)
+ let service = MockTrustedDeviceService(list: {
+ called.setValue(true)
+ return [.mock]
+ })
+
+ Clerk.shared.dependencies = MockDependencyContainer(
+ apiClient: createMockAPIClient(),
+ trustedDeviceService: service
+ )
+
+ let trustedDevices = try await Clerk.shared.trustedDevices.list()
+
+ #expect(called.value == true)
+ #expect(trustedDevices == [.mock])
+ }
+
+ @Test
+ func revokeUsesTrustedDeviceService() async throws {
+ let capturedTrustedDeviceId = LockIsolated(nil)
+ let service = MockTrustedDeviceService(revoke: { trustedDeviceId in
+ capturedTrustedDeviceId.setValue(trustedDeviceId)
+ return .mock
+ })
+
+ Clerk.shared.dependencies = MockDependencyContainer(
+ apiClient: createMockAPIClient(),
+ trustedDeviceService: service
+ )
+
+ let trustedDevice = try await Clerk.shared.trustedDevices.revoke(id: "tdc_123")
+
+ #expect(capturedTrustedDeviceId.value == "tdc_123")
+ #expect(trustedDevice == .mock)
+ }
+
+ @Test
+ func availabilityReturnsAvailableLocalCredentialWithoutActiveSession() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = try makeTrustedDevicesWithLocalCredential()
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == true)
+ #expect(availability.unavailableReason == nil)
+ }
+
+ @Test
+ func availabilityReturnsAvailableWithMultipleLocalCredentials() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = makeTrustedDevices()
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_old",
+ localKeyId: "tdlk_old",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_new",
+ localKeyId: "tdlk_new",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == true)
+ }
+
+ @Test
+ func availabilityReconcilesServerCredentialWhenSessionIsActive() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(list: { [.mock] })
+ )
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == true)
+ }
+
+ @Test
+ func localAvailabilityDoesNotReconcileServerCredentialWhenSessionIsActive() throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ Issue.record("Local availability should not fetch trusted devices.")
+ return []
+ })
+ )
+
+ let availability = try setup.trustedDevices.localAvailability()
+
+ #expect(availability.isAvailable == true)
+ #expect(availability.unavailableReason == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ }
+
+ @Test
+ func validateLocalCredentialIfPossibleReturnsValidForServerCredential() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let capturedTrustedDeviceId = LockIsolated(nil)
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(validateSignInCredential: { trustedDeviceId in
+ capturedTrustedDeviceId.setValue(trustedDeviceId)
+ return .init(valid: true)
+ })
+ )
+
+ let result = await setup.trustedDevices.validateLocalCredentialIfPossible()
+
+ #expect(result == .valid)
+ #expect(capturedTrustedDeviceId.value == "tdc_123")
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ }
+
+ @Test
+ func validateLocalCredentialIfPossibleDeletesMissingServerCredential() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(validateSignInCredential: { _ in
+ throw missingTrustedDeviceCredentialError()
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ let result = await setup.trustedDevices.validateLocalCredentialIfPossible()
+
+ #expect(result == .invalid(.serverCredentialMissing))
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") == nil)
+ }
+
+ @Test
+ func validateLocalCredentialIfPossibleSkipsMissingNewestCredential() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let validatedTrustedDeviceIds = LockIsolated<[String]>([])
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(validateSignInCredential: { trustedDeviceId in
+ validatedTrustedDeviceIds.withValue { $0.append(trustedDeviceId) }
+ if trustedDeviceId == "tdc_new" {
+ throw missingTrustedDeviceCredentialError()
+ }
+ return .init(valid: true)
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_old",
+ localKeyId: "tdlk_old",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_new",
+ localKeyId: "tdlk_new",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ let result = await setup.trustedDevices.validateLocalCredentialIfPossible()
+
+ #expect(result == .valid)
+ #expect(validatedTrustedDeviceIds.value == ["tdc_new", "tdc_old"])
+ #expect(deletedLocalKeyIds.value == ["tdlk_new"])
+ #expect(try setup.credentialStore.credential(id: "tdc_new") == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_old") != nil)
+ }
+
+ @Test
+ func validateLocalCredentialIfPossibleKeepsCredentialForTransientError() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(validateSignInCredential: { _ in
+ throw URLError(.timedOut)
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ let result = await setup.trustedDevices.validateLocalCredentialIfPossible()
+
+ #expect(result == .inconclusive)
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ }
+
+ @Test
+ func validateLocalCredentialIfPossibleIsInconclusiveWithoutCachedClient() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = nil
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(validateSignInCredential: { _ in
+ Issue.record("Validation should not run without a cached client.")
+ return .init(valid: true)
+ })
+ )
+
+ let result = await setup.trustedDevices.validateLocalCredentialIfPossible()
+
+ #expect(result == .inconclusive)
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ }
+
+ @Test
+ func availabilitySkipsStaleNewerCredentialWhenSignedIn() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ [trustedDevice(id: "tdc_old", createdAt: Date(timeIntervalSinceReferenceDate: 10))]
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_old",
+ localKeyId: "tdlk_old",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_new",
+ localKeyId: "tdlk_new",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == true)
+ #expect(deletedLocalKeyIds.value == ["tdlk_new"])
+ #expect(try setup.credentialStore.credential(id: "tdc_new") == nil)
+ }
+
+ @Test
+ func availabilityDoesNotReconcileServerCredentialWhenSessionIsExpired() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = Client(
+ id: "client_expired",
+ sessions: [.mockExpired],
+ lastActiveSessionId: Session.mockExpired.id,
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ Issue.record("Expired sessions should not trigger authenticated trusted-device list.")
+ return []
+ })
+ )
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == true)
+ }
+
+ @Test
+ func availabilityReturnsFeatureDisabledWhenNativeSettingIsOff() async throws {
+ Clerk.shared.environment = .mock
+ Clerk.shared.client = .mockSignedOut
+ let setup = try makeTrustedDevicesWithLocalCredential()
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .nativeAPIDisabled)
+ }
+
+ @Test
+ func availabilityDeletesMetadataWhenLocalKeyIsMissing() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ keyManager: MockTrustedDeviceKeyManager(hasKey: { _ in false })
+ )
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .localKeyMissing)
+ #expect(try setup.credentialStore.all().isEmpty)
+ }
+
+ @Test
+ func availabilityDeletesMetadataWhenServerCredentialIsMissing() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(list: { [] }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .serverCredentialMissing)
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.all().isEmpty)
+ }
+
+ @Test
+ func availabilityIgnoresCredentialFromDifferentAppIdentifierBeforeCheckingKeys() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let checkedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(keyManager: MockTrustedDeviceKeyManager(hasKey: { localKeyId in
+ checkedLocalKeyIds.withValue { $0.append(localKeyId) }
+ return false
+ }))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .noLocalCredential)
+ #expect(checkedLocalKeyIds.value.isEmpty)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_app") != nil)
+ }
+
+ @Test
+ func signInUsesCurrentAppCredentialWhenSharedKeychainContainsNewerCredential() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let capturedCreateParams = LockIsolated(nil)
+ let setup = makeTrustedDevices(signInService: MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_current_app",
+ localKeyId: "tdlk_current_app",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ _ = try await setup.trustedDevices.signIn()
+
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_current_app")
+ #expect(try setup.credentialStore.credential(id: "tdc_other_app") != nil)
+ }
+
+ @Test
+ func availabilityIgnoresCredentialOwnedByDifferentUserWhenSignedIn() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let listWasCalled = LockIsolated(false)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ listWasCalled.setValue(true)
+ return []
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_user",
+ localKeyId: "tdlk_other_user",
+ userID: User.mock2.id,
+ identifierHint: "sam@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let availability = try await setup.trustedDevices.availability(identifierHint: "sam@example.com")
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .noLocalCredential)
+ #expect(listWasCalled.value == false)
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_user") != nil)
+ }
+
+ @Test
+ func availabilitySkipsNewestCredentialOwnedByDifferentUserWhenIdentifierHintIsNil() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let listWasCalled = LockIsolated(false)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ listWasCalled.setValue(true)
+ return [trustedDevice(id: "tdc_active_user", createdAt: Date(timeIntervalSinceReferenceDate: 10))]
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_active_user",
+ localKeyId: "tdlk_active_user",
+ userID: User.mock.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_user",
+ localKeyId: "tdlk_other_user",
+ userID: User.mock2.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable)
+ #expect(listWasCalled.value)
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try setup.credentialStore.credential(id: "tdc_active_user") != nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_user") != nil)
+ }
+
+ @Test
+ func availabilityUsesUserIDWhenIdentifierHintChanged() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ [trustedDevice(id: "tdc_current_user", createdAt: Date(timeIntervalSinceReferenceDate: 10))]
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_current_user",
+ localKeyId: "tdlk_current_user",
+ userID: User.mock.id,
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let availability = try await setup.trustedDevices.currentUserAvailability()
+
+ #expect(availability.isAvailable)
+ #expect(try setup.credentialStore.credential(id: "tdc_current_user") != nil)
+ }
+
+ @Test
+ func localAvailabilityUsesUserIDWhenIdentifierHintChanged() throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let setup = makeTrustedDevices()
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_current_user",
+ localKeyId: "tdlk_current_user",
+ userID: User.mock.id,
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let availability = try setup.trustedDevices.currentUserLocalAvailability()
+
+ #expect(availability.isAvailable)
+ }
+
+ @Test
+ func enrollCreatesKeyPreparesChallengeAttemptsAndPersistsMetadata() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let preparedParams = LockIsolated(nil)
+ let attemptedParams = LockIsolated(nil)
+ let trustedDeviceService = MockTrustedDeviceService(
+ prepareEnrollment: { params in
+ preparedParams.setValue(params)
+ return .mock
+ },
+ attemptEnrollment: { params in
+ attemptedParams.setValue(params)
+ return .mock
+ }
+ )
+ let keyManager = MockTrustedDeviceKeyManager(
+ createKeyWithPolicy: { policy in
+ #expect(policy == .biometryOrDevicePasscode)
+ return .init(
+ localKeyId: TrustedDeviceLocalKey.mock.localKeyId,
+ publicKeyJWK: TrustedDeviceLocalKey.mock.publicKeyJWK,
+ policy: policy
+ )
+ },
+ sign: { clientData, localKeyId, localizedReason in
+ #expect(clientData == TrustedDeviceChallenge.mock.clientData)
+ #expect(localKeyId == TrustedDeviceLocalKey.mock.localKeyId)
+ #expect(localizedReason == "Set up Face ID for future sign-ins.")
+ return .init(clientData: clientData, signature: "enrollment_signature")
+ }
+ )
+ let setup = makeTrustedDevices(
+ trustedDeviceService: trustedDeviceService,
+ keyManager: keyManager
+ )
+
+ let trustedDevice = try await setup.trustedDevices.enroll(
+ deviceName: "Sean's iPhone",
+ identifierHint: " Sean@Example.COM ",
+ reason: "Set up Face ID for future sign-ins.",
+ policy: .biometryOrDevicePasscode
+ )
+ let localCredential = try #require(try setup.credentialStore.credential(id: "tdc_123"))
+
+ #expect(trustedDevice == .mock)
+ #expect(preparedParams.value?.appIdentifier == "com.clerk.example")
+ #expect(preparedParams.value?.name == "Sean's iPhone")
+ #expect(preparedParams.value?.publicKeyJWK == TrustedDeviceLocalKey.mock.publicKeyJWK)
+ #expect(attemptedParams.value?.signature == "enrollment_signature")
+ #expect(localCredential.localKeyId == TrustedDeviceLocalKey.mock.localKeyId)
+ #expect(localCredential.userID == User.mock.id)
+ #expect(localCredential.identifierHint == "sean@example.com")
+ #expect(localCredential.policy == .biometryOrDevicePasscode)
+ }
+
+ @Test
+ func enrollReplacesOtherCurrentAppCredentialsAcrossUsersAfterSuccessfulEnrollment() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let revokedTrustedDeviceIds = LockIsolated<[String]>([])
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { trustedDeviceId in
+ revokedTrustedDeviceIds.withValue { $0.append(trustedDeviceId) }
+ return trustedDevice(id: trustedDeviceId, createdAt: Date(timeIntervalSinceReferenceDate: 10))
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_current_user",
+ localKeyId: "tdlk_current_user",
+ userID: User.mock.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_user",
+ localKeyId: "tdlk_other_user",
+ userID: User.mock2.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ appIdentifier: "com.clerk.other",
+ createdAt: Date(timeIntervalSinceReferenceDate: 30)
+ ))
+
+ _ = try await setup.trustedDevices.enroll()
+
+ #expect(revokedTrustedDeviceIds.value.isEmpty)
+ #expect(deletedLocalKeyIds.value == ["tdlk_current_user", "tdlk_other_user"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_current_user") == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_user") == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_app") != nil)
+ }
+
+ @Test
+ func enrollKeepsExistingCredentialsWhenEnrollmentFails() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(
+ prepareEnrollment: { _ in .mock },
+ attemptEnrollment: { _ in throw ClerkClientError(message: "Attempt failed") }
+ ),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_existing",
+ localKeyId: "tdlk_existing",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ do {
+ _ = try await setup.trustedDevices.enroll()
+ Issue.record("Expected enrollment to fail.")
+ } catch {
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_existing") != nil)
+ }
+ }
+
+ @Test
+ func enrollDoesNotCallBackendRevokeForReplacedLocalCredentials() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { _ in
+ throw ClerkClientError(message: "Revoke failed")
+ }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_existing",
+ localKeyId: "tdlk_existing",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ _ = try await setup.trustedDevices.enroll()
+
+ #expect(deletedLocalKeyIds.value == ["tdlk_existing"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_existing") == nil)
+ }
+
+ @Test
+ func enrollAllowsPendingSession() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ var pendingSession = Session.mock
+ pendingSession.id = "sess_pending_tasks"
+ pendingSession.status = .pending
+ pendingSession.tasks = [.setupMfa]
+ Clerk.shared.client = Client(
+ id: "client_pending_tasks",
+ sessions: [pendingSession],
+ lastActiveSessionId: pendingSession.id,
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ let prepareWasCalled = LockIsolated(false)
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(
+ prepareEnrollment: { _ in
+ prepareWasCalled.setValue(true)
+ return .mock
+ }
+ )
+ )
+
+ _ = try await setup.trustedDevices.enroll()
+
+ #expect(prepareWasCalled.value)
+ }
+
+ @Test
+ func enrollDefaultsToBiometryOrDevicePasscodePolicy() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let createdKeyPolicies = LockIsolated<[TrustedDevicePolicy]>([])
+ let setup = makeTrustedDevices(
+ keyManager: MockTrustedDeviceKeyManager(
+ createKeyWithPolicy: { policy in
+ createdKeyPolicies.withValue { $0.append(policy) }
+ return .init(
+ localKeyId: TrustedDeviceLocalKey.mock.localKeyId,
+ publicKeyJWK: TrustedDeviceLocalKey.mock.publicKeyJWK,
+ policy: policy
+ )
+ }
+ )
+ )
+
+ _ = try await setup.trustedDevices.enroll()
+ let localCredential = try #require(try setup.credentialStore.credential(id: TrustedDevice.mock.id))
+
+ #expect(createdKeyPolicies.value == [.biometryOrDevicePasscode])
+ #expect(localCredential.policy == .biometryOrDevicePasscode)
+ }
+
+ @Test
+ func enrollDeletesGeneratedKeyWhenAttemptFails() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let trustedDeviceService = MockTrustedDeviceService(
+ prepareEnrollment: { _ in .mock },
+ attemptEnrollment: { _ in throw ClerkClientError(message: "Attempt failed") }
+ )
+ let keyManager = MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ let setup = makeTrustedDevices(
+ trustedDeviceService: trustedDeviceService,
+ keyManager: keyManager
+ )
+
+ do {
+ _ = try await setup.trustedDevices.enroll()
+ Issue.record("Expected enrollment to fail.")
+ } catch {
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.all().isEmpty)
+ }
+ }
+
+ @Test
+ func enrollRequiresActiveOrPendingSession() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = makeTrustedDevices()
+
+ do {
+ _ = try await setup.trustedDevices.enroll()
+ Issue.record("Expected enrollment to require an active or pending session.")
+ } catch let error as ClerkClientError {
+ #expect(error.message?.contains("active or pending Clerk session") == true)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func revokeDeletesLocalCredentialAfterServerRevoke() async throws {
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { _ in .mock }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ let trustedDevice = try await setup.trustedDevices.revoke(id: "tdc_123")
+
+ #expect(trustedDevice == .mock)
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") == nil)
+ }
+
+ @Test
+ func revokeReturnsServerCredentialWhenLocalCleanupFails() async throws {
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { _ in .mock }),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ throw TestKeyDeletionError.failed
+ })
+ )
+
+ let trustedDevice = try await setup.trustedDevices.revoke(id: "tdc_123")
+
+ #expect(trustedDevice == .mock)
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") != nil)
+ }
+
+ @Test
+ func revokeCurrentDeviceCredentialUsesUserIDWhenIdentifierHintChanged() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let revokedTrustedDeviceIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(
+ list: {
+ [
+ trustedDevice(id: "tdc_current_user", createdAt: Date(timeIntervalSinceReferenceDate: 10)),
+ trustedDevice(id: "tdc_other_user", createdAt: Date(timeIntervalSinceReferenceDate: 20)),
+ ]
+ },
+ revoke: { trustedDeviceId in
+ revokedTrustedDeviceIds.withValue { $0.append(trustedDeviceId) }
+ return trustedDevice(id: trustedDeviceId, createdAt: Date(timeIntervalSinceReferenceDate: 10))
+ }
+ )
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_current_user",
+ localKeyId: "tdlk_current_user",
+ userID: User.mock.id,
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_user",
+ localKeyId: "tdlk_other_user",
+ userID: User.mock2.id,
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ let revokedTrustedDevice = try await setup.trustedDevices.revokeCurrentDeviceCredential()
+
+ #expect(revokedTrustedDevice?.id == "tdc_current_user")
+ #expect(revokedTrustedDeviceIds.value == ["tdc_current_user"])
+ #expect(try setup.credentialStore.credential(id: "tdc_current_user") == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_user") != nil)
+ }
+
+ @Test
+ func revokeCurrentDeviceCredentialReturnsNilWhenNoLocalCredentialIsAvailable() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let revokeWasCalled = LockIsolated(false)
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { _ in
+ revokeWasCalled.setValue(true)
+ return .mock
+ })
+ )
+
+ let trustedDevice = try await setup.trustedDevices.revokeCurrentDeviceCredential()
+
+ #expect(trustedDevice == nil)
+ #expect(revokeWasCalled.value == false)
+ }
+
+ @Test
+ func revokeCurrentDeviceCredentialAllowsPendingSession() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ var pendingSession = Session.mock
+ pendingSession.id = "sess_pending_tasks"
+ pendingSession.status = .pending
+ pendingSession.tasks = [.setupMfa]
+ Clerk.shared.client = Client(
+ id: "client_pending_tasks",
+ sessions: [pendingSession],
+ lastActiveSessionId: pendingSession.id,
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ let revokedTrustedDeviceIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: MockTrustedDeviceService(revoke: { trustedDeviceId in
+ revokedTrustedDeviceIds.withValue { $0.append(trustedDeviceId) }
+ return .mock
+ })
+ )
+
+ _ = try await setup.trustedDevices.revokeCurrentDeviceCredential()
+
+ #expect(revokedTrustedDeviceIds.value == ["tdc_123"])
+ }
+
+ @Test
+ func revokeCurrentDeviceCredentialRequiresActiveOrPendingSession() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = try makeTrustedDevicesWithLocalCredential()
+
+ do {
+ _ = try await setup.trustedDevices.revokeCurrentDeviceCredential()
+ Issue.record("Expected revoking a current-device credential to require an active or pending session.")
+ } catch let error as ClerkClientError {
+ #expect(error.message?.contains("active or pending Clerk session") == true)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func forgetLocalCredentialsDeletesDeletedUserIDAfterCurrentUserIsCleared() throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ }))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_deleted",
+ localKeyId: "tdlk_deleted",
+ userID: User.mock.id,
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_app",
+ localKeyId: "tdlk_other_app",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.other",
+ identifierHint: "old@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let deletedCount = try setup.trustedDevices.forgetLocalCredentials(deletedUserID: User.mock.id)
+
+ #expect(deletedCount == 1)
+ #expect(deletedLocalKeyIds.value == ["tdlk_deleted"])
+ #expect(try setup.credentialStore.credential(id: "tdc_deleted") == nil)
+ #expect(try setup.credentialStore.credential(id: "tdc_other_app") != nil)
+ }
+
+ @Test
+ func availabilityUsesStoredCredentialPolicy() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let checkedPolicies = LockIsolated<[TrustedDevicePolicy]>([])
+ let keyManager = MockTrustedDeviceKeyManager(
+ isSupportedForPolicy: { policy in
+ checkedPolicies.withValue { $0.append(policy) }
+ return policy == .biometryOrDevicePasscode
+ }
+ )
+ let localCredential = TrustedDeviceLocalCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_mock",
+ userID: User.mock.id,
+ appIdentifier: "com.clerk.example",
+ policy: .biometryOrDevicePasscode,
+ createdAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890),
+ updatedAt: Date(timeIntervalSinceReferenceDate: 1_234_567_890)
+ )
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ keyManager: keyManager,
+ localCredential: localCredential
+ )
+
+ let availability = try await setup.trustedDevices.availability()
+
+ #expect(availability.isAvailable)
+ #expect(checkedPolicies.value == [.biometryOrDevicePasscode])
+ }
+
+ @Test
+ func availabilityReturnsNoLocalCredentialWhenIdentifierHintDoesNotMatch() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let setup = try makeTrustedDevicesWithLocalCredential(localCredential: localCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_mock",
+ identifierHint: "sean@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+
+ let availability = try await setup.trustedDevices.availability(identifierHint: "sam@example.com")
+
+ #expect(availability.isAvailable == false)
+ #expect(availability.unavailableReason == .noLocalCredential)
+ }
+
+ @Test
+ func signInUsesCreateChallengeAndAttemptsFirstFactor() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let capturedCreateParams = LockIsolated(nil)
+ let capturedAttempt = LockIsolated<(String, SignIn.AttemptFirstFactorParams)?>(nil)
+ let signInService = MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { signInId, params in
+ capturedAttempt.setValue((signInId, params))
+ return .mockTrustedDeviceComplete
+ }
+ )
+ let keyManager = MockTrustedDeviceKeyManager(sign: { clientData, localKeyId, localizedReason in
+ #expect(clientData == trustedDeviceChallengeClientData)
+ #expect(localKeyId == "tdlk_mock")
+ #expect(localizedReason == "Sign in with Face ID.")
+ return .init(clientData: clientData, signature: "sign_in_signature")
+ })
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ signInService: signInService,
+ keyManager: keyManager
+ )
+
+ let signIn = try await setup.trustedDevices.signIn(reason: "Sign in with Face ID.")
+
+ #expect(signIn == .mockTrustedDeviceComplete)
+ #expect(capturedCreateParams.value?.strategy == .trustedDevice)
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_123")
+ #expect(capturedAttempt.value?.0 == "si_trusted_device")
+ #expect(capturedAttempt.value?.1.strategy == .trustedDevice)
+ #expect(capturedAttempt.value?.1.trustedDeviceId == "tdc_123")
+ #expect(capturedAttempt.value?.1.clientData == trustedDeviceChallengeClientData)
+ #expect(capturedAttempt.value?.1.signature == "sign_in_signature")
+ #expect(capturedAttempt.value?.1.algorithm == .es256)
+ }
+
+ @Test
+ func signInUsesNewestLocalCredentialWhenNoIdIsProvided() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let capturedCreateParams = LockIsolated(nil)
+ let setup = makeTrustedDevices(signInService: MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_old",
+ localKeyId: "tdlk_old",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_new",
+ localKeyId: "tdlk_new",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ _ = try await setup.trustedDevices.signIn()
+
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_new")
+ }
+
+ @Test
+ func signInSkipsCredentialOwnedByDifferentUserWhenSessionIsActive() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let listWasCalled = LockIsolated(false)
+ let capturedCreateParams = LockIsolated(nil)
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ listWasCalled.setValue(true)
+ return [trustedDevice(id: "tdc_active_user", createdAt: Date(timeIntervalSinceReferenceDate: 10))]
+ }),
+ signInService: MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ )
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_active_user",
+ localKeyId: "tdlk_active_user",
+ userID: User.mock.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_other_user",
+ localKeyId: "tdlk_other_user",
+ userID: User.mock2.id,
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ _ = try await setup.trustedDevices.signIn()
+
+ #expect(listWasCalled.value)
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_active_user")
+ }
+
+ @Test
+ func signInUsesIdentifierHintToSelectMatchingLocalCredential() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let capturedCreateParams = LockIsolated(nil)
+ let setup = makeTrustedDevices(signInService: MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_sean",
+ localKeyId: "tdlk_sean",
+ identifierHint: "sean@example.com",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_sam",
+ localKeyId: "tdlk_sam",
+ identifierHint: "+15551234567",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ _ = try await setup.trustedDevices.signIn(identifierHint: " SEAN@example.com ")
+
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_sean")
+ }
+
+ @Test
+ func signInDeletesLocalCredentialWhenCreateReportsTrustedDeviceMissing() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let attemptWasCalled = LockIsolated(false)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ signInService: MockSignInService(
+ create: { _ in throw missingTrustedDeviceCredentialError(code: "trusted_device_not_registered") },
+ attemptFirstFactor: { _, _ in
+ attemptWasCalled.setValue(true)
+ return .mockTrustedDeviceComplete
+ }
+ ),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ do {
+ _ = try await setup.trustedDevices.signIn()
+ Issue.record("Expected trusted-device sign-in to fail.")
+ } catch let error as ClerkClientError {
+ #expect(error.message == "This device is no longer trusted. Sign in another way to enroll it again.")
+ #expect(attemptWasCalled.value == false)
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") == nil)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func signInDeletesLocalCredentialWhenAttemptReportsTrustedDeviceMissing() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ signInService: MockSignInService(
+ create: { _ in .mockTrustedDeviceChallenge },
+ attemptFirstFactor: { _, _ in throw missingTrustedDeviceCredentialError() }
+ ),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ do {
+ _ = try await setup.trustedDevices.signIn()
+ Issue.record("Expected trusted-device sign-in to fail.")
+ } catch let error as ClerkClientError {
+ #expect(error.message == "This device is no longer trusted. Sign in another way to enroll it again.")
+ #expect(deletedLocalKeyIds.value == ["tdlk_mock"])
+ #expect(try setup.credentialStore.credential(id: "tdc_123") == nil)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func signInKeepsLocalCredentialWhenCreateFailsForUnrelatedAPIError() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = try makeTrustedDevicesWithLocalCredential(
+ signInService: MockSignInService(
+ create: { _ in throw missingTrustedDeviceCredentialError(paramName: "identifier") }
+ ),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+
+ do {
+ _ = try await setup.trustedDevices.signIn()
+ Issue.record("Expected trusted-device sign-in to fail.")
+ } catch let error as ClerkAPIError {
+ #expect(error.code == "form_resource_not_found")
+ #expect(deletedLocalKeyIds.value.isEmpty)
+ #expect(try setup.credentialStore.credential(id: "tdc_123") == .mock)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+
+ @Test
+ func signInSkipsStaleNewerCredentialWhenSignedIn() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mock
+ let capturedCreateParams = LockIsolated(nil)
+ let deletedLocalKeyIds = LockIsolated<[String]>([])
+ let setup = makeTrustedDevices(
+ trustedDeviceService: MockTrustedDeviceService(list: {
+ [trustedDevice(id: "tdc_123", createdAt: Date(timeIntervalSinceReferenceDate: 10))]
+ }),
+ signInService: MockSignInService(
+ create: { params in
+ capturedCreateParams.setValue(params)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ ),
+ keyManager: MockTrustedDeviceKeyManager(deleteKey: { localKeyId in
+ deletedLocalKeyIds.withValue { $0.append(localKeyId) }
+ })
+ )
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_123",
+ localKeyId: "tdlk_old",
+ createdAt: Date(timeIntervalSinceReferenceDate: 10)
+ ))
+ try setup.credentialStore.save(localCredential(
+ id: "tdc_new",
+ localKeyId: "tdlk_new",
+ createdAt: Date(timeIntervalSinceReferenceDate: 20)
+ ))
+
+ _ = try await setup.trustedDevices.signIn()
+
+ #expect(capturedCreateParams.value?.trustedDeviceId == "tdc_123")
+ #expect(deletedLocalKeyIds.value == ["tdlk_new"])
+ #expect(try setup.credentialStore.credential(id: "tdc_new") == nil)
+ }
+
+ @Test
+ func signInRequiresCreateToReturnTrustedDeviceChallenge() async throws {
+ Clerk.shared.environment = enabledTrustedDeviceEnvironment()
+ Clerk.shared.client = .mockSignedOut
+ let prepareWasCalled = LockIsolated(false)
+ let signInService = MockSignInService(
+ create: { _ in SignIn(id: "si_missing_challenge", status: .needsIdentifier) },
+ prepareFirstFactor: { _, _ in
+ prepareWasCalled.setValue(true)
+ return .mockTrustedDeviceChallenge
+ },
+ attemptFirstFactor: { _, _ in .mockTrustedDeviceComplete }
+ )
+ let setup = try makeTrustedDevicesWithLocalCredential(signInService: signInService)
+
+ do {
+ _ = try await setup.trustedDevices.signIn(id: "tdc_123")
+ Issue.record("Expected sign-in to fail when create does not return a trusted-device challenge.")
+ } catch let error as ClerkClientError {
+ #expect(error.message == "Trusted-device sign-in did not return a challenge.")
+ #expect(prepareWasCalled.value == false)
+ } catch {
+ Issue.record("Wrong error type: \(error)")
+ }
+ }
+}
+
+private let trustedDeviceChallengeClientData = "{\"challenge_id\":\"tdch_123\"}"
+private let trustedDeviceChallenge = TrustedDeviceChallenge(
+ challenge: "challenge",
+ challengeId: "tdch_123",
+ trustedDeviceId: "tdc_123",
+ clientData: trustedDeviceChallengeClientData,
+ expiresAt: Date(timeIntervalSince1970: 1_710_000_000),
+ algorithm: .es256
+)
+
+private func enabledTrustedDeviceEnvironment() -> Clerk.Environment {
+ var environment = Clerk.Environment.mock
+ environment.authConfig.nativeSettings = .init(
+ apiEnabled: true,
+ trustedDeviceSignInEnabled: true
+ )
+ return environment
+}
+
+private func localCredential(
+ id: String,
+ localKeyId: String,
+ userID: String = User.mock.id,
+ appIdentifier: String = "com.clerk.example",
+ identifierHint: String? = nil,
+ createdAt: Date
+) -> TrustedDeviceLocalCredential {
+ TrustedDeviceLocalCredential(
+ id: id,
+ localKeyId: localKeyId,
+ userID: userID,
+ appIdentifier: appIdentifier,
+ identifierHint: identifierHint,
+ createdAt: createdAt,
+ updatedAt: createdAt
+ )
+}
+
+private func trustedDevice(
+ id: String,
+ createdAt: Date
+) -> TrustedDevice {
+ TrustedDevice(
+ id: id,
+ platform: .iOS,
+ appIdentifier: "com.clerk.example",
+ name: nil,
+ algorithm: .es256,
+ status: .active,
+ createdAt: createdAt,
+ updatedAt: createdAt
+ )
+}
+
+private func missingTrustedDeviceCredentialError(
+ code: String = "form_resource_not_found",
+ paramName: String = "trusted_device_id"
+) -> ClerkAPIError {
+ ClerkAPIError(
+ code: code,
+ message: "is missing",
+ longMessage: "The resource associated with the supplied trusted_device_id was not found.",
+ meta: ["param_name": .string(paramName)],
+ clerkTraceId: "trace_123"
+ )
+}
+
+private enum TestKeyDeletionError: Error {
+ case failed
+}
+
+@MainActor
+private func makeTrustedDevicesWithLocalCredential(
+ trustedDeviceService: TrustedDeviceServiceProtocol = MockTrustedDeviceService(),
+ signInService: SignInServiceProtocol = MockSignInService(),
+ keyManager: MockTrustedDeviceKeyManager = MockTrustedDeviceKeyManager(),
+ localCredential: TrustedDeviceLocalCredential = .mock
+) throws -> (
+ trustedDevices: TrustedDevices,
+ credentialStore: TrustedDeviceLocalCredentialStore
+) {
+ let setup = makeTrustedDevices(
+ trustedDeviceService: trustedDeviceService,
+ signInService: signInService,
+ keyManager: keyManager
+ )
+ try setup.credentialStore.save(localCredential)
+ return setup
+}
+
+@MainActor
+private func makeTrustedDevices(
+ trustedDeviceService: TrustedDeviceServiceProtocol = MockTrustedDeviceService(),
+ signInService: SignInServiceProtocol = MockSignInService(),
+ keyManager: MockTrustedDeviceKeyManager = MockTrustedDeviceKeyManager()
+) -> (
+ trustedDevices: TrustedDevices,
+ credentialStore: TrustedDeviceLocalCredentialStore
+) {
+ let credentialStore = TrustedDeviceLocalCredentialStore(keychain: InMemoryKeychain())
+ let trustedDevices = TrustedDevices(
+ trustedDeviceService: trustedDeviceService,
+ signInService: signInService,
+ keyManager: keyManager,
+ credentialStore: credentialStore,
+ appIdentifierProvider: { "com.clerk.example" }
+ )
+ return (trustedDevices, credentialStore)
+}
+
+extension SignIn {
+ static var mockTrustedDeviceChallenge: SignIn {
+ SignIn(
+ id: "si_trusted_device",
+ status: .needsIdentifier,
+ firstFactorVerification: .init(
+ status: .unverified,
+ strategy: .trustedDevice,
+ trustedDeviceChallenge: trustedDeviceChallenge
+ )
+ )
+ }
+
+ static var mockTrustedDeviceComplete: SignIn {
+ SignIn(
+ id: "si_trusted_device",
+ status: .complete,
+ createdSessionId: "sess_123"
+ )
+ }
+}
diff --git a/Tests/Domains/Environment/AuthConfigDecodingTests.swift b/Tests/Domains/Environment/AuthConfigDecodingTests.swift
new file mode 100644
index 000000000..9d79b7463
--- /dev/null
+++ b/Tests/Domains/Environment/AuthConfigDecodingTests.swift
@@ -0,0 +1,72 @@
+@testable import ClerkKit
+import Foundation
+import Testing
+
+struct AuthConfigDecodingTests {
+ private let decoder = JSONDecoder.clerkDecoder
+ private let encoder = JSONEncoder.clerkEncoder
+
+ @Test
+ func nativeSettingsDecodeTrustedDeviceSignInEnabled() throws {
+ let data = Data(
+ """
+ {
+ "single_session_mode": false,
+ "native_settings": {
+ "api_enabled": true,
+ "trusted_device_sign_in_enabled": true,
+ "trusted_device_enrollment_prompt_after_sign_in_enabled": true,
+ "trusted_device_enrollment_prompt_after_sign_up_enabled": true
+ }
+ }
+ """.utf8
+ )
+
+ let authConfig = try decoder.decode(Clerk.Environment.AuthConfig.self, from: data)
+
+ #expect(authConfig.singleSessionMode == false)
+ #expect(authConfig.nativeSettings.apiEnabled == true)
+ #expect(authConfig.nativeSettings.trustedDeviceSignInEnabled == true)
+ #expect(authConfig.nativeSettings.trustedDevicePromptAfterSignInEnabled == true)
+ #expect(authConfig.nativeSettings.trustedDevicePromptAfterSignUpEnabled == true)
+ }
+
+ @Test
+ func nativeSettingsDefaultWhenMissing() throws {
+ let data = Data(
+ """
+ {
+ "single_session_mode": true
+ }
+ """.utf8
+ )
+
+ let authConfig = try decoder.decode(Clerk.Environment.AuthConfig.self, from: data)
+
+ #expect(authConfig.singleSessionMode == true)
+ #expect(authConfig.nativeSettings == .default)
+ }
+
+ @Test
+ func nativeSettingsEncodeBackendKeys() throws {
+ let authConfig = Clerk.Environment.AuthConfig(
+ singleSessionMode: false,
+ nativeSettings: .init(
+ apiEnabled: true,
+ trustedDeviceSignInEnabled: true,
+ trustedDevicePromptAfterSignInEnabled: true,
+ trustedDevicePromptAfterSignUpEnabled: true
+ )
+ )
+
+ let data = try encoder.encode(authConfig)
+ let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ let nativeSettings = try #require(object["native_settings"] as? [String: Any])
+
+ #expect(object["single_session_mode"] as? Bool == false)
+ #expect(nativeSettings["api_enabled"] as? Bool == true)
+ #expect(nativeSettings["trusted_device_sign_in_enabled"] as? Bool == true)
+ #expect(nativeSettings["trusted_device_enrollment_prompt_after_sign_in_enabled"] as? Bool == true)
+ #expect(nativeSettings["trusted_device_enrollment_prompt_after_sign_up_enabled"] as? Bool == true)
+ }
+}
diff --git a/Tests/Integration/IntegrationTestHelpers.swift b/Tests/Integration/IntegrationTestHelpers.swift
index edf6d3ce9..e626ce57c 100644
--- a/Tests/Integration/IntegrationTestHelpers.swift
+++ b/Tests/Integration/IntegrationTestHelpers.swift
@@ -95,6 +95,7 @@ func configureClerkForIntegrationTesting(keyName: String) throws -> Bool {
sessionService: SessionService(apiClient: apiClient),
magicLinkService: MagicLinkService(apiClient: apiClient),
passkeyService: PasskeyService(apiClient: apiClient),
+ trustedDeviceService: TrustedDeviceService(apiClient: apiClient),
organizationService: OrganizationService(apiClient: apiClient),
environmentService: EnvironmentService(apiClient: apiClient),
emailAddressService: EmailAddressService(apiClient: apiClient),
diff --git a/Tests/Middleware/ClerkClientSyncResponseMiddlewareTests.swift b/Tests/Middleware/ClerkClientSyncResponseMiddlewareTests.swift
index c64d81344..f99323f04 100644
--- a/Tests/Middleware/ClerkClientSyncResponseMiddlewareTests.swift
+++ b/Tests/Middleware/ClerkClientSyncResponseMiddlewareTests.swift
@@ -91,6 +91,60 @@ struct ClerkClientSyncResponseMiddlewareTests {
#expect(clerk.client?.currentSession?.lastActiveOrganizationId == "org_test456")
}
+ @Test
+ func validateHoldsRegisteredAuthFlowBeforeApplyingCompletedSignInClient() async throws {
+ configureClerkForTesting()
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+ let middleware = ClerkClientSyncResponseMiddleware(runtimeScope: .current(clerkProvider: { clerk }))
+ let data = try JSONEncoder.clerkEncoder.encode(ClientEnvelope(
+ response: SignInResponsePayload(
+ object: "sign_in_attempt",
+ id: SignIn.mock.id,
+ status: SignIn.Status.complete.rawValue,
+ createdSessionId: Client.mock.currentSession?.id
+ ),
+ client: Client.mock
+ ))
+ let url = try #require(URL(string: "https://example.com/v1/client/sign_ins/sign_in_123/attempt_first_factor"))
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+
+ try await middleware.validate(response, data: data, for: URLRequest(url: url))
+
+ #expect(clerk.session?.status == .active)
+ #expect(clerk.isAuthFlowComplete == false)
+ #expect(clerk.pendingAuthFlowCompletion?.flowId == SignIn.mock.id)
+ withExtendedLifetime(registration) {}
+ }
+
+ @Test
+ func validateDoesNotHoldRegisteredAuthFlowForRefreshedActiveClient() async throws {
+ configureClerkForTesting()
+ let clerk = Clerk.mockSignedOut
+ let registration = try #require(clerk.registerAuthFlow())
+ let middleware = ClerkClientSyncResponseMiddleware(runtimeScope: .current(clerkProvider: { clerk }))
+ let data = try JSONEncoder.clerkEncoder.encode(ClientOnlyEnvelope(response: Client.mock, client: nil))
+ let url = try #require(URL(string: "https://example.com/v1/client"))
+ let response = try #require(HTTPURLResponse(
+ url: url,
+ statusCode: 200,
+ httpVersion: nil,
+ headerFields: nil
+ ))
+
+ try await middleware.validate(response, data: data, for: URLRequest(url: url))
+
+ #expect(clerk.session?.status == .active)
+ #expect(clerk.isAuthFlowComplete)
+ #expect(clerk.pendingAuthFlowCompletion == nil)
+ withExtendedLifetime(registration) {}
+ }
+
@Test
func validateIgnoresClientResponseFromStaleDeviceTokenGeneration() async throws {
configureClerkForTesting()
@@ -177,6 +231,13 @@ private struct ClientEnvelope: Codable {
let client: Client?
}
+private struct SignInResponsePayload: Codable {
+ let object: String
+ let id: String
+ let status: String
+ let createdSessionId: String?
+}
+
private struct ClientOnlyEnvelope: Codable {
let response: Client
let client: Client?
diff --git a/Tests/Networking/ClerkAPIClientTests.swift b/Tests/Networking/ClerkAPIClientTests.swift
index f7d3dc6e7..eb61574c2 100644
--- a/Tests/Networking/ClerkAPIClientTests.swift
+++ b/Tests/Networking/ClerkAPIClientTests.swift
@@ -27,7 +27,7 @@ struct ClerkAPIClientTests {
#expect(request.allHTTPHeaderFields?["Content-Type"] == "application/x-www-form-urlencoded")
#expect(request.allHTTPHeaderFields?["clerk-api-version"] == Clerk.apiVersion)
#expect(request.allHTTPHeaderFields?["x-ios-sdk-version"] == Clerk.sdkVersion)
- #expect(request.allHTTPHeaderFields?["x-mobile"] == "1")
+ #expect(request.allHTTPHeaderFields?["x-mobile"] == DependencyContainer.mobileHeaderValue)
requestHandled.setValue(true)
}
mock.register()
@@ -41,6 +41,15 @@ struct ClerkAPIClientTests {
#expect(requestHandled.value)
}
+ @Test
+ func mobileHeaderValueMatchesPlatform() {
+ #if os(macOS) || targetEnvironment(macCatalyst)
+ #expect(DependencyContainer.mobileHeaderValue == "0")
+ #else
+ #expect(DependencyContainer.mobileHeaderValue == "1")
+ #endif
+ }
+
@Test
func isNativeQueryParameter() async throws {
let requestHandled = LockIsolated(false)
diff --git a/Tests/Storage/ClerkKeychainKeyTests.swift b/Tests/Storage/ClerkKeychainKeyTests.swift
index 9ca0db138..50d942c39 100644
--- a/Tests/Storage/ClerkKeychainKeyTests.swift
+++ b/Tests/Storage/ClerkKeychainKeyTests.swift
@@ -15,7 +15,7 @@ struct ClerkKeychainKeyTests {
@Test
func allCasesContainsExpectedKeys() {
let allCases = ClerkKeychainKey.allCases
- #expect(allCases.count == 16)
+ #expect(allCases.count == 17)
// Verify all expected keys are present
#expect(allCases.contains(.cachedClient))
@@ -34,6 +34,7 @@ struct ClerkKeychainKeyTests {
#expect(allCases.contains(.watchSyncDeviceTokenSynced))
#expect(allCases.contains(.attestKeyId))
#expect(allCases.contains(.pendingMagicLinkFlow))
+ #expect(allCases.contains(.trustedDeviceCredentials))
}
@Test
@@ -54,5 +55,6 @@ struct ClerkKeychainKeyTests {
#expect(ClerkKeychainKey.watchSyncDeviceTokenSynced.rawValue == "clerkDeviceTokenSynced")
#expect(ClerkKeychainKey.attestKeyId.rawValue == "AttestKeyId")
#expect(ClerkKeychainKey.pendingMagicLinkFlow.rawValue == "pendingMagicLinkFlow")
+ #expect(ClerkKeychainKey.trustedDeviceCredentials.rawValue == "trustedDeviceCredentials")
}
}
diff --git a/Tests/TestSupport/TestHelpers.swift b/Tests/TestSupport/TestHelpers.swift
index caa8af86a..9cdaa1917 100644
--- a/Tests/TestSupport/TestHelpers.swift
+++ b/Tests/TestSupport/TestHelpers.swift
@@ -41,6 +41,7 @@ func setupMockAPIClient() {
sessionService: SessionService(apiClient: mockAPIClient),
magicLinkService: MagicLinkService(apiClient: mockAPIClient),
passkeyService: PasskeyService(apiClient: mockAPIClient),
+ trustedDeviceService: TrustedDeviceService(apiClient: mockAPIClient),
organizationService: OrganizationService(apiClient: mockAPIClient),
environmentService: EnvironmentService(apiClient: mockAPIClient),
emailAddressService: EmailAddressService(apiClient: mockAPIClient),
@@ -65,7 +66,7 @@ func createMockAPIClient(
"Content-Type": "application/x-www-form-urlencoded",
"clerk-api-version": Clerk.apiVersion,
"x-ios-sdk-version": Clerk.sdkVersion,
- "x-mobile": "1",
+ "x-mobile": DependencyContainer.mobileHeaderValue,
]
}
}
diff --git a/Tests/UI/AuthNavigationTests.swift b/Tests/UI/AuthNavigationTests.swift
index aa835c22d..5a04d4cc3 100644
--- a/Tests/UI/AuthNavigationTests.swift
+++ b/Tests/UI/AuthNavigationTests.swift
@@ -12,18 +12,44 @@ struct AuthNavigationTests {
navigation.handleSessionTaskCompletion(session: session)
#expect(navigation.path == [.sessionTaskStart(task: .setupMfa)])
- #expect(navigation.allTasksComplete == false)
+ #expect(navigation.postAuthStepsComplete == false)
}
@Test
- func handleSessionTaskCompletionMarksAllTasksCompleteWhenSessionHasNoPendingTasks() {
+ func handleSessionTaskCompletionMarksPostAuthStepsCompleteWhenSessionHasNoPendingTasks() {
let navigation = AuthNavigation()
let session = session(pendingTasks: [])
navigation.handleSessionTaskCompletion(session: session)
#expect(navigation.path.isEmpty)
- #expect(navigation.allTasksComplete)
+ #expect(navigation.postAuthStepsComplete)
+ }
+
+ @Test
+ func routeToTrustedDeviceEnrollmentAppendsToAuthPathAndMarksOfferShown() {
+ let navigation = AuthNavigation()
+ navigation.path = [.signUpCompleteProfile]
+
+ navigation.routeToTrustedDeviceEnrollment()
+
+ #expect(navigation.path == [.signUpCompleteProfile, .trustedDeviceEnrollment])
+ #expect(navigation.hasTrustedDeviceEnrollmentInPath)
+ #expect(navigation.trustedDeviceEnrollmentWasOffered)
+ }
+
+ @Test
+ func resetForNewAuthFlowClearsPathAndPostAuthFlags() {
+ let navigation = AuthNavigation()
+ navigation.path = [.trustedDeviceEnrollment]
+ navigation.routeToTrustedDeviceEnrollment()
+ navigation.markPostAuthStepsComplete()
+
+ navigation.resetForNewAuthFlow()
+
+ #expect(navigation.path.isEmpty)
+ #expect(navigation.postAuthStepsComplete == false)
+ #expect(navigation.trustedDeviceEnrollmentWasOffered == false)
}
@Test
@@ -58,7 +84,7 @@ struct AuthNavigationTests {
navigation.handleSessionTaskCompletion(session: session)
#expect(navigation.path == [.sessionTaskStart(task: .chooseOrganization)])
- #expect(navigation.allTasksComplete == false)
+ #expect(navigation.postAuthStepsComplete == false)
}
@Test
diff --git a/Tests/UI/AuthStartViewTrustedDeviceTests.swift b/Tests/UI/AuthStartViewTrustedDeviceTests.swift
new file mode 100644
index 000000000..c9e88fc91
--- /dev/null
+++ b/Tests/UI/AuthStartViewTrustedDeviceTests.swift
@@ -0,0 +1,63 @@
+#if os(iOS) || os(macOS)
+
+@testable import ClerkKitUI
+import Testing
+
+struct AuthStartViewTrustedDeviceTests {
+ @Test
+ func trustedDeviceAvailabilityRefreshStateReflectsFeatureAndSession() {
+ #expect(
+ AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: false,
+ activeSessionID: nil,
+ clientID: nil
+ ) == .disabled
+ )
+ #expect(
+ AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: false,
+ activeSessionID: "sess_123",
+ clientID: "client_123"
+ ) == .disabled
+ )
+ #expect(
+ AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: true,
+ activeSessionID: nil,
+ clientID: nil
+ ) == .signedOut(clientID: nil)
+ )
+ #expect(
+ AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: true,
+ activeSessionID: nil,
+ clientID: "client_123"
+ ) == .signedOut(clientID: "client_123")
+ )
+ #expect(
+ AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: true,
+ activeSessionID: "sess_123",
+ clientID: "client_123"
+ ) == .disabled
+ )
+ }
+
+ @Test
+ func signedOutClientChangesTrustedDeviceAvailabilityRefreshTaskIdentity() {
+ let missingClientTaskID = AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: true,
+ activeSessionID: nil,
+ clientID: nil
+ )
+ let restoredClientTaskID = AuthStartTrustedDeviceRefreshState.state(
+ trustedDeviceFeatureIsEnabled: true,
+ activeSessionID: nil,
+ clientID: "client_123"
+ )
+
+ #expect(missingClientTaskID != restoredClientTaskID)
+ }
+}
+
+#endif
diff --git a/Tests/UI/LastUsedAuthTests.swift b/Tests/UI/LastUsedAuthTests.swift
new file mode 100644
index 000000000..8c3bc70b8
--- /dev/null
+++ b/Tests/UI/LastUsedAuthTests.swift
@@ -0,0 +1,55 @@
+#if os(iOS) || os(macOS)
+
+@testable import ClerkKit
+@testable import ClerkKitUI
+import Testing
+
+@MainActor
+@Suite(.serialized)
+struct LastUsedAuthTests {
+ @Test
+ func trustedDeviceStrategyShowsTrustedDeviceBadgeWhenAnotherMethodIsVisible() {
+ configureTrustedDeviceLastAuth()
+ defer { Clerk.shared.client = .mock }
+ var environment = Clerk.Environment.mock
+ environment.userSettings.social = [:]
+ for key in environment.userSettings.attributes.keys where key != "email_address" {
+ environment.userSettings.attributes[key]?.usedForFirstFactor = false
+ }
+
+ let lastUsedAuth = LastUsedAuth(
+ environment: environment,
+ trustedDeviceSignInIsVisible: true
+ )
+
+ #expect(lastUsedAuth == .trustedDevice)
+ #expect(lastUsedAuth?.showsTrustedDeviceBadge == true)
+ }
+
+ @Test
+ func trustedDeviceStrategyDoesNotShowBadgeWhenOnlyTrustedDeviceIsVisible() {
+ configureTrustedDeviceLastAuth()
+ defer { Clerk.shared.client = .mock }
+ var environment = Clerk.Environment.mock
+ environment.userSettings.social = [:]
+ for key in environment.userSettings.attributes.keys {
+ environment.userSettings.attributes[key]?.usedForFirstFactor = false
+ }
+
+ let lastUsedAuth = LastUsedAuth(
+ environment: environment,
+ trustedDeviceSignInIsVisible: true
+ )
+
+ #expect(lastUsedAuth == nil)
+ }
+
+ private func configureTrustedDeviceLastAuth() {
+ Clerk.configure(publishableKey: "pk_test_bW9jay5jbGVyay5hY2NvdW50cy5kZXYk")
+ var client = Client.mock
+ client.lastAuthenticationStrategy = .trustedDevice
+ Clerk.shared.client = client
+ }
+}
+
+#endif
diff --git a/Tests/UI/TrustedDeviceEnrollmentPromptTests.swift b/Tests/UI/TrustedDeviceEnrollmentPromptTests.swift
new file mode 100644
index 000000000..ad1e21b0e
--- /dev/null
+++ b/Tests/UI/TrustedDeviceEnrollmentPromptTests.swift
@@ -0,0 +1,131 @@
+#if os(iOS) || os(macOS)
+
+@testable import ClerkKit
+@testable import ClerkKitUI
+import Foundation
+import Testing
+
+struct TrustedDeviceEnrollmentPromptTests {
+ @Test
+ func signInPromptIsSuppressedAfterItHasBeenSeen() throws {
+ let (store, suiteName) = try makePromptStore()
+ defer { removePromptStoreSuite(named: suiteName) }
+ let result = completedSignInResult()
+
+ #expect(result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: "user_123",
+ nativeSettings: nativeSettings(promptAfterSignIn: true),
+ promptStore: store
+ ))
+
+ store.markPromptSeen(userID: "user_123")
+
+ #expect(result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: "user_123",
+ nativeSettings: nativeSettings(promptAfterSignIn: true),
+ promptStore: store
+ ) == false)
+ }
+
+ @Test
+ func signUpPromptIgnoresSeenState() throws {
+ let (store, suiteName) = try makePromptStore()
+ defer { removePromptStoreSuite(named: suiteName) }
+ let result = completedSignUpResult()
+
+ store.markPromptSeen(userID: "user_123")
+
+ #expect(result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: "user_123",
+ nativeSettings: nativeSettings(promptAfterSignUp: true),
+ promptStore: store
+ ))
+ }
+
+ @Test
+ func signInPromptIsSuppressedWhenEnvironmentSettingIsDisabled() throws {
+ let (store, suiteName) = try makePromptStore()
+ defer { removePromptStoreSuite(named: suiteName) }
+ let result = completedSignInResult()
+
+ #expect(result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: "user_123",
+ nativeSettings: nativeSettings(promptAfterSignIn: false),
+ promptStore: store
+ ) == false)
+ }
+
+ @Test
+ func signUpPromptIsSuppressedWhenEnvironmentSettingIsDisabled() throws {
+ let (store, suiteName) = try makePromptStore()
+ defer { removePromptStoreSuite(named: suiteName) }
+ let result = completedSignUpResult()
+
+ #expect(result.shouldOfferTrustedDeviceEnrollmentPrompt(
+ userID: "user_123",
+ nativeSettings: nativeSettings(promptAfterSignUp: false),
+ promptStore: store
+ ) == false)
+ }
+
+ @Test
+ func seenStateIsScopedByUser() throws {
+ let (store, suiteName) = try makePromptStore()
+ defer { removePromptStoreSuite(named: suiteName) }
+
+ store.markPromptSeen(userID: "user_123")
+
+ #expect(store.hasSeenPrompt(userID: "user_123"))
+ #expect(store.hasSeenPrompt(userID: "user_456") == false)
+ }
+
+ private func makePromptStore() throws -> (TrustedDeviceEnrollmentPromptStore, String) {
+ let suiteName = "com.clerk.tests.trusted-device-enrollment-prompt.\(UUID().uuidString)"
+ let userDefaults = try #require(UserDefaults(suiteName: suiteName))
+ userDefaults.removePersistentDomain(forName: suiteName)
+ return (TrustedDeviceEnrollmentPromptStore(userDefaults: userDefaults), suiteName)
+ }
+
+ private func removePromptStoreSuite(named suiteName: String) {
+ UserDefaults.standard.removePersistentDomain(forName: suiteName)
+ UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
+ }
+
+ private func nativeSettings(
+ promptAfterSignIn: Bool = false,
+ promptAfterSignUp: Bool = false
+ ) -> Clerk.Environment.AuthConfig.NativeSettings {
+ .init(
+ apiEnabled: true,
+ trustedDeviceSignInEnabled: true,
+ trustedDevicePromptAfterSignInEnabled: promptAfterSignIn,
+ trustedDevicePromptAfterSignUpEnabled: promptAfterSignUp
+ )
+ }
+
+ private func completedSignInResult() -> TransferFlowResult {
+ .signIn(SignIn(
+ id: "sign_in_123",
+ status: .complete,
+ createdSessionId: "sess_123"
+ ))
+ }
+
+ private func completedSignUpResult() -> TransferFlowResult {
+ .signUp(SignUp(
+ id: "sign_up_123",
+ status: .complete,
+ requiredFields: [],
+ optionalFields: [],
+ missingFields: [],
+ unverifiedFields: [],
+ verifications: [:],
+ passwordEnabled: false,
+ createdSessionId: "sess_123",
+ createdUserId: "user_123",
+ abandonAt: .distantFuture
+ ))
+ }
+}
+
+#endif
diff --git a/Tests/UI/TrustedDeviceSignInButtonTests.swift b/Tests/UI/TrustedDeviceSignInButtonTests.swift
new file mode 100644
index 000000000..38876d85a
--- /dev/null
+++ b/Tests/UI/TrustedDeviceSignInButtonTests.swift
@@ -0,0 +1,50 @@
+#if os(iOS) || os(macOS)
+
+@testable import ClerkKit
+@testable import ClerkKitUI
+import LocalAuthentication
+import Testing
+
+@MainActor
+struct TrustedDeviceSignInButtonTests {
+ @Test
+ func biometryDisplayNamesUsePlatformTerms() {
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .faceID).value == "Face ID")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .touchID).value == "Touch ID")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .opticID).value == "Optic ID")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .none).value == "biometrics")
+ }
+
+ @Test
+ func biometryDisplayNamesUseMatchingSystemImages() {
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .faceID).systemImageName == "faceid")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .touchID).systemImageName == "touchid")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .opticID).systemImageName == "opticid")
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .none).systemImageName == nil)
+ }
+
+ @Test
+ func biometryDisplayNamesTrackSupport() {
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .faceID).isSupported)
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .none).isSupported == false)
+ #expect(TrustedDeviceBiometryDisplayName(biometryType: .faceID, isSupported: false).isSupported == false)
+ }
+
+ @Test
+ func trustedDeviceCancellationIsTreatedAsUserCancellation() {
+ #expect(TrustedDeviceKeyManagerError.biometricAuthenticationCanceled.isUserCancelledError)
+ #expect(!TrustedDeviceKeyManagerError.biometricAuthenticationFailed.isUserCancelledError)
+ }
+
+ @Test
+ func enrollmentReasonUsesRequestedPromptCopy() {
+ let reason = TrustedDeviceEnrollmentStrings.enrollmentReason(
+ applicationName: "My Application",
+ biometryDisplayName: .init(biometryType: .faceID)
+ )
+
+ #expect(reason == "The app My Application uses Face ID to sign you in.")
+ }
+}
+
+#endif