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