Skip to content

Commit cf352c5

Browse files
authored
Rework autofill to also use async/await (#31)
As noted in #29, the initial design of the autofill interactions used a delegate-based system, rather than async/await like the modal APIs. This was based on an incorrect assumption (carried over from web, where it may also be flawed) on how promises resolve. Upon further experimentation, it seems perfectly fine and non-problematic to have a promise that might take minutes to resolve and not have it block other interactions. This (breaking) change adjusts the autofill API to also use async/await instead of delegates. Client code is a lot more straightforward, and the internals become more reliable since a bunch of conditional paths are eliminated. In the same vein, I've also added a new `.unsupportedOnPlatform` error code so that clients can have fewer availability checks, part of #30. The API as a whole is still `#if`'d to iOS+visionOS, but that will get lifted eventually in a separate PR (see #16).
1 parent 18f7e89 commit cf352c5

File tree

6 files changed

+80
-49
lines changed

6 files changed

+80
-49
lines changed

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,53 @@ struct SignInView: View {
108108
}
109109
```
110110

111+
### Autofill-assisted Requests
112+
113+
> [!NOTE]
114+
> Autofill is (at present) only supported on iOS/iPadOS >= 16 and visionOS.
115+
> On other platforms or OS versions, this will immediately return a failure code
116+
> indicating a lack of platform support.
117+
118+
To have the system suggest a passkey when a username field is focused, make the following additions to start the process and handle the result:
119+
120+
1. Add `.textContentType(.username)` to the username `TextField`, if not already set:
121+
122+
```swift
123+
TextField("Username", text: $userName)
124+
.textContentType(.username) // <-- Add this
125+
```
126+
127+
2. Run the autofill API when the view is presented:
128+
129+
```swift
130+
// ...
131+
var body: some View {
132+
VStack {
133+
// ...
134+
}
135+
.onAppear(perform: autofill) // <-- Add this
136+
}
137+
138+
// And this
139+
func autofill() {
140+
Task {
141+
let autofillResult = await snapAuth.handleAutofill()
142+
guard case .success(let auth) = autofillResult else {
143+
// Autofill failed, this is common and generally safe to ignore
144+
return
145+
}
146+
// Send auth.token to your backend to sign in the user, as above
147+
}
148+
}
149+
```
150+
111151
## Known issues
112152

113153
In our testing, the sign in dialog in tvOS doesn't open, at least in the simulator.
114154

115155
Even with the Apple-documented configuration, the AutoFill API does not reliably provide passkey suggestions.
156+
There appears to be a display issue inside the SwiftUI and UIKit internals causing the suggestion bar to not render consistently.
157+
We have filed a Feedback with Apple, but this is outside of our control.
116158

117159
## Useful resources
118160

Sources/SnapAuth/Errors.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ public enum SnapAuthError: Error {
55
/// A new request is starting, so the one in flight was canceled
66
case newRequestStarting
77

8+
/// This needs APIs that are not supported on the current platform
9+
case unsupportedOnPlatform
10+
811
// MARK: Internal errors, which could represent SnapAuth bugs
912

1013
/// The SDK received a response from SnapAuth, but it arrived in an

Sources/SnapAuth/SnapAuth+ASACD.swift

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,7 @@ extension SnapAuth: ASAuthorizationControllerDelegate {
5454
/// Sends the error to the appropriate delegate method and resets the internal state back to idle
5555
private func sendError(_ error: SnapAuthError) {
5656
// One or the other should eb set, but not both
57-
assert(
58-
(continuation != nil && autoFillDelegate == nil)
59-
|| (continuation == nil && autoFillDelegate != nil)
60-
)
61-
autoFillDelegate = nil
57+
assert(continuation != nil)
6258
continuation?.resume(returning: .failure(error))
6359
continuation = nil
6460
}
@@ -161,13 +157,6 @@ extension SnapAuth: ASAuthorizationControllerDelegate {
161157
token: authResponse.token,
162158
expiresAt: authResponse.expiresAt)
163159

164-
// Short-term BC hack
165-
if autoFillDelegate != nil {
166-
autoFillDelegate!.snapAuth(didAutoFillWithResult: .success(rewrapped))
167-
autoFillDelegate = nil
168-
return
169-
}
170-
171160
assert(continuation != nil)
172161
continuation?.resume(returning: .success(rewrapped))
173162
continuation = nil

Sources/SnapAuth/SnapAuth+AutoFill.swift

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,53 +10,58 @@ import AuthenticationServices
1010
extension SnapAuth {
1111

1212
/// Starts the AutoFill process using a default ASPresentationAnchor
13-
@available(iOS 16.0, *)
14-
public func handleAutoFill(delegate: SnapAuthAutoFillDelegate) {
15-
handleAutoFill(delegate: delegate, anchor: .default)
13+
///
14+
/// This uses APIs that are unavailable prior to iOS 16, and will
15+
/// immediately return an .unsupportedOnPlatform error code on devices where
16+
/// it cannot run.
17+
public func handleAutoFill() async -> SnapAuthResult {
18+
await handleAutoFill(anchor: .default)
1619
}
1720

1821
/// Use the specified anchor.
1922
/// This may be exposed publiy if needed, but the intent/goal is the default is (almost) always correct
20-
@available(iOS 16.0, *)
2123
internal func handleAutoFill(
22-
delegate: SnapAuthAutoFillDelegate,
2324
anchor: ASPresentationAnchor
24-
) {
25+
) async -> SnapAuthResult {
2526
self.anchor = anchor
2627

27-
handleAutoFill(delegate: delegate, presentationContextProvider: self)
28+
return await handleAutoFill(presentationContextProvider: self)
2829
}
2930

3031
/// Use the specified presentationContextProvider.
3132
/// Like with handleAutoFill(anchor:) this could get publicly exposed later but is for the "file a bug" case
32-
@available(iOS 16.0, *)
3333
internal func handleAutoFill(
34-
delegate: SnapAuthAutoFillDelegate,
3534
presentationContextProvider: ASAuthorizationControllerPresentationContextProviding
36-
) {
35+
) async -> SnapAuthResult {
3736
reset()
38-
autoFillDelegate = delegate
39-
Task {
40-
let response = await api.makeRequest(
41-
path: "/assertion/options",
42-
body: [:] as [String:String],
43-
type: SACreateAuthOptionsResponse.self)
37+
// TODO: filter other unsupported platforms (do this better than the top-level ifdef)
38+
guard #available(iOS 16, *) else {
39+
return .failure(.unsupportedOnPlatform)
40+
}
41+
42+
let response = await self.api.makeRequest(
43+
path: "/assertion/options",
44+
body: [:] as [String:String],
45+
type: SACreateAuthOptionsResponse.self)
4446

45-
guard case let .success(options) = response else {
46-
// TODO: decide how to handle AutoFill errors
47-
return
48-
}
47+
guard case let .success(options) = response else {
48+
// TODO: decide how to handle AutoFill errors
49+
return .failure(response.getError()!)
50+
}
4951

50-
// AutoFill always only uses passkeys, so this is not configurable
51-
let authRequests = buildAuthRequests(
52-
from: options,
53-
authenticators: [.passkey])
52+
// AutoFill always only uses passkeys, so this is not configurable
53+
let authRequests = self.buildAuthRequests(
54+
from: options,
55+
authenticators: [.passkey])
5456

55-
let controller = ASAuthorizationController(authorizationRequests: authRequests)
56-
authController = controller
57-
controller.delegate = self
58-
controller.presentationContextProvider = presentationContextProvider
59-
logger.debug("AF perform")
57+
let controller = ASAuthorizationController(authorizationRequests: authRequests)
58+
authController = controller
59+
controller.delegate = self
60+
controller.presentationContextProvider = presentationContextProvider
61+
logger.debug("AF perform")
62+
return await withCheckedContinuation { continuation in
63+
assert(self.continuation == nil)
64+
self.continuation = continuation // as! CheckedContinuation<SnapAuthResult, Never>
6065
controller.performAutoFillAssistedRequests()
6166
}
6267
}

Sources/SnapAuth/SnapAuth.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import os
99
@available(macOS 12.0, iOS 15.0, tvOS 16.0, *)
1010
public class SnapAuth: NSObject { // NSObject for ASAuthorizationControllerDelegate
1111

12-
/// The delegate that SnapAuth informs about the success or failure of an AutoFill operation.
13-
internal var autoFillDelegate: SnapAuthAutoFillDelegate?
14-
1512
internal let api: SnapAuthClient
1613

1714
internal let logger: Logger

Sources/SnapAuth/SnapAuthDelegate.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import Foundation
22

3-
/// An interface for providing information about the outcome of a SnapAuth AutoFill request
4-
public protocol SnapAuthAutoFillDelegate {
5-
func snapAuth(didAutoFillWithResult result: SnapAuthResult)
6-
}
7-
83
public struct SnapAuthTokenInfo {
94
/// The registration or authentication token.
105
///

0 commit comments

Comments
 (0)