Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
99c3b77
Add trusted device sign-in support
seanperez29 Jun 9, 2026
a1fb6e7
Add trusted device policy and identifier hints
seanperez29 Jun 10, 2026
c56bd1d
Add trusted device enrollment management
seanperez29 Jun 30, 2026
0adaf00
Use local trusted-device availability in UI
seanperez29 Jun 30, 2026
ccc9ce2
Use local trusted device availability fallback
seanperez29 Jun 30, 2026
2b1305e
Limit trusted device auth to native iOS
seanperez29 Jun 30, 2026
42e1f95
Persist trusted device prompt state
seanperez29 Jul 1, 2026
1a3eb01
Add configurable switch tint color
seanperez29 Jul 1, 2026
7d0e34b
Harden trusted device sign-in flows
seanperez29 Jul 1, 2026
aa99f7c
Scope trusted device credentials by user
seanperez29 Jul 1, 2026
72b15b6
Default trusted devices to passcode fallback
seanperez29 Jul 1, 2026
6596050
Use user ID for current trusted devices
seanperez29 Jul 1, 2026
e0079b0
Scope trusted device credentials by app
seanperez29 Jul 1, 2026
d7292e3
Test preserving other users trusted devices
seanperez29 Jul 1, 2026
a72b156
Handle malformed trusted device metadata
seanperez29 Jul 2, 2026
a4ff8c1
Scope trusted devices by app and skip Catalyst
seanperez29 Jul 2, 2026
44354a8
Support macOS trusted devices
seanperez29 Jul 2, 2026
c75093d
Honor trusted-device auth configuration
seanperez29 Jul 3, 2026
b36d26c
Expose trusted device marker key helper
seanperez29 Jul 3, 2026
ebcbd7a
Remove legacy trusted device marker migration
seanperez29 Jul 3, 2026
2ff2b15
Add trusted device validation
seanperez29 Jul 3, 2026
ff66c99
Move trusted device auth components into folder
seanperez29 Jul 4, 2026
7fbcf68
Clarify post-auth completion flow
seanperez29 Jul 6, 2026
35d7695
Rename trusted device enroll name parameter
seanperez29 Jul 6, 2026
fe4e462
Handle unregistered trusted device errors
seanperez29 Jul 6, 2026
3c21c08
Avoid trusted-device navigation after cancellation
seanperez29 Jul 7, 2026
95778ed
Update Clerk SDK
seanperez29 Jul 14, 2026
a076d3f
Convert trusted device ES256 signatures to raw
seanperez29 Jul 20, 2026
aec1721
Harden trusted device credential cleanup
seanperez29 Jul 20, 2026
1635b0e
Harden trusted device credential cleanup
seanperez29 Jul 20, 2026
47c358e
Assert post-auth steps remain incomplete
seanperez29 Jul 20, 2026
ed76ea6
Fix trusted device flow edge cases
seanperez29 Jul 20, 2026
1026c9d
Harden trusted device validation handling
seanperez29 Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Examples/Quickstart/Quickstart/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
</array>
</dict>
</array>
<key>NSFaceIDUsageDescription</key>
<string>Quickstart uses Face ID to sign you in.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Atlantis uses Bonjour Service to discover the Proxyman app on your local network.</string>
<key>NSBonjourServices</key>
Expand All @@ -23,4 +25,3 @@
</array>
</dict>
</plist>

23 changes: 23 additions & 0 deletions Sources/ClerkKit/Core/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthEvent>
private let urlHandlingCoordinator: URLHandlingCoordinator

Expand All @@ -28,6 +29,7 @@ public struct Auth {
signInService: SignInServiceProtocol,
signUpService: SignUpServiceProtocol,
sessionService: SessionServiceProtocol,
trustedDevices: TrustedDevices,
eventEmitter: EventEmitter<AuthEvent>,
urlHandlingCoordinator: URLHandlingCoordinator
) {
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
///
Expand Down
21 changes: 21 additions & 0 deletions Sources/ClerkKit/Core/AuthFlowRegistration.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
58 changes: 58 additions & 0 deletions Sources/ClerkKit/Core/Clerk+Installation.swift
Original file line number Diff line number Diff line change
@@ -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: ".")
}
}
102 changes: 101 additions & 1 deletion Sources/ClerkKit/Core/Clerk.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -221,6 +263,7 @@ public final class Clerk {
signInService: dependencies.signInService,
signUpService: dependencies.signUpService,
sessionService: dependencies.sessionService,
trustedDevices: trustedDevices,
eventEmitter: authEventEmitter,
urlHandlingCoordinator: urlHandlingCoordinator
)
Expand All @@ -230,13 +273,63 @@ 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.
public var organizations: Organizations {
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
Expand Down Expand Up @@ -290,6 +383,7 @@ extension Clerk {
taskCoordinator = TaskCoordinator()

self.dependencies = dependencies
reconcileTrustedDeviceCredentialsForCurrentInstallation()

// Set up session polling and lifecycle management
sessionPollingManager = SessionPollingManager(
Expand Down Expand Up @@ -583,6 +677,8 @@ extension Clerk {
await SessionTokenFetcher.shared.reset()
await SessionTokensCache.shared.clear()

pendingAuthFlowCompletion = nil
isAuthFlowPending = false
client = nil
environment = nil
sessionsByUserId = [:]
Expand Down Expand Up @@ -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(
Expand All @@ -797,6 +894,9 @@ extension Clerk {
if let serverDate {
lastClientServerFetchDate = serverDate
}
if let completedAuthFlow {
holdAuthFlowCompletion(completedAuthFlow)
}
client = incoming
}

Expand Down
Loading