Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passkeys: UI interaction feedback and errors #1604

Draft
wants to merge 27 commits into
base: feature/passkeys
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
58212cc
Added basic setup to use ASAuthentication on the login form
charliescheer Jun 5, 2024
a07083e
Added request for passkey challenge to account remote
charliescheer Jun 11, 2024
b059b0a
Added ability to register passkeys on server
charliescheer Jun 12, 2024
6b6e228
fixed a few linting errors
charliescheer Jun 12, 2024
5363f4d
Added button for login with passkeys
charliescheer Jun 12, 2024
112fcc1
Added auth verification with passkeys
charliescheer Jun 12, 2024
273a145
put auth challenge parsing back to using string
charliescheer Jun 12, 2024
3dd793e
Add WebauthnResponse struct, fix encoding of base64 from auth challen…
roundhill Jun 13, 2024
ffaa2a9
Added parsing for user object when logging in with passkeys
charliescheer Jun 13, 2024
dbf0884
Refactored passkey registration into a single class
charliescheer Jun 13, 2024
3271ea0
Moved passkey authenticator to app delegate
charliescheer Jun 13, 2024
162dad6
Migrated passkey auth to the authenticator
charliescheer Jun 13, 2024
b0dd3c1
Moved passkey auth data objects to their own files
charliescheer Jun 13, 2024
9f20166
Moved passkey registration data objects to their own files
charliescheer Jun 13, 2024
ebe4cc7
Reorder PasskeyAuthenticator for readability
charliescheer Jun 13, 2024
a50a0cc
Refactored parsing a verification json on passkey auth
charliescheer Jun 13, 2024
c670805
Moved decodeURLsafebase64 to a data extension file
charliescheer Jun 13, 2024
ec0ad95
Cleaned up some messy code with passkeys
charliescheer Jun 13, 2024
860971d
Fixed parsing authentication verified value on passkey auth
charliescheer Jun 13, 2024
00401db
Refactored getting to the passkey authentication to fix the ui tests
charliescheer Jun 14, 2024
7e524e0
Updated release notes PR1599 added passkey authentication
charliescheer Jun 14, 2024
4c72593
Added feedback and errors for challenge registration of passkeys
charliescheer Jun 14, 2024
f67dcd1
Added additional interaction/error handling for passkey registration
charliescheer Jun 14, 2024
71887f1
Handle invalid email on passkey auth
charliescheer Jun 14, 2024
a1cbeb2
Added auth ui locking and release when using passkeys
charliescheer Jun 14, 2024
fdd994f
Removed async from auth delegate methods
charliescheer Jun 14, 2024
b0c9b8c
Refactored handling passkey auth success and failure
charliescheer Jun 14, 2024
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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- Extended support for iOS Shortcuts
- Fixed issue where notes selection was lost when app backgrounded
- Fixed an issue can activate the faceid switch without a passcode
- Added passkey authentication support

4.51
-----
Expand Down
46 changes: 43 additions & 3 deletions Simplenote.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

90 changes: 90 additions & 0 deletions Simplenote/AccountRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,94 @@ class AccountRemote: Remote {

return request
}

// MARK: - Passkeys
//
private func passkeyCredentialCreationRequest(withEmail email: String, password: String) -> URLRequest {
let params = [
"email": email.lowercased(),
"password": password,
"webauthn": "true"
] as [String: Any]

let boundary = "Boundary-\(UUID().uuidString)"
var request = URLRequest(url: SimplenoteConstants.passkeyCredentialCreationURL)
request.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

request.httpMethod = RemoteConstants.Method.POST
request.httpBody = body(with: boundary, parameters: params)

return request
}

private func body(with boundary: String, parameters: [String: Any]) -> Data {
var body = Data()

for param in parameters {
let paramName = param.key
body += Data("--\(boundary)\r\n".utf8)
body += Data("Content-Disposition:form-data; name=\"\(paramName)\"".utf8)
let paramValue = param.value as! String
body += Data("\r\n\r\n\(paramValue)\r\n".utf8)
}

body += Data("--\(boundary)--\r\n".utf8)

return body
}

func requestChallengeResponseToCreatePasskey(forEmail email: String, password: String) async throws -> Data? {
let request = passkeyCredentialCreationRequest(withEmail: email, password: password)

return try await performDataTask(with: request)
}

private func passkeyCredentialRegistration(withData data: Data) -> URLRequest {
var urlRequest = URLRequest(url: SimplenoteConstants.passkeyRegistrationURL)
urlRequest.httpMethod = RemoteConstants.Method.POST
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

urlRequest.httpBody = data

return urlRequest
}

func registerCredential(with data: Data) async throws {
let request = passkeyCredentialRegistration(withData: data)
try await _ = performDataTask(with: request)
}

private func passkeyAuthChallengeRequest(forEmail email: String) -> URLRequest {
var urlRequest = URLRequest(url: SimplenoteConstants.passkeyAuthChallengeURL)
urlRequest.httpMethod = RemoteConstants.Method.POST
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")

let body = [
"email": email.lowercased()
]
let json = try? JSONEncoder().encode(body)

urlRequest.httpBody = json

return urlRequest
}

func passkeyAuthChallenge(for email: String) async throws -> Data? {
let request = passkeyAuthChallengeRequest(forEmail: email)
return try await performDataTask(with: request)
}

private func verifyPassKeyRequest(with data: Data) -> URLRequest {
var urlRequest = URLRequest(url: SimplenoteConstants.verifyPasskeyAuthChallengeURL)
urlRequest.httpMethod = RemoteConstants.Method.POST
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = data

return urlRequest
}

func verifyPasskeyLogin(with data: Data) async throws -> Data? {
let request = verifyPassKeyRequest(with: data)
return try await performDataTask(with: request)
}
}
70 changes: 65 additions & 5 deletions Simplenote/Classes/SPAuthViewController.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import UIKit
import SafariServices
import AuthenticationServices

// MARK: - SPAuthViewController
//
Expand Down Expand Up @@ -293,7 +294,7 @@ private extension SPAuthViewController {
return
}

performSimperiumAuthentication()
performSimperiumAuthentication(username: email, password: password)
}

@IBAction func performSignUp() {
Expand All @@ -318,6 +319,24 @@ private extension SPAuthViewController {
}
}

@objc func passkeyAuthAction() {
guard ensureWarningsAreOnScreenWhenNeeded() else {
return
}

Task {
lockdownInterface()

let passkeyAuthenticator = SPAppDelegate.shared().passkeyAuthenticator
passkeyAuthenticator.delegate = self
do {
try await passkeyAuthenticator.attemptPasskeyAuth(for: email, in: self)
} catch {
failed(error)
}
}
}

@IBAction func presentPasswordReset() {
controller.presentPasswordReset(from: self, username: email)
}
Expand Down Expand Up @@ -357,10 +376,10 @@ private extension SPAuthViewController {
}
}

func performSimperiumAuthentication() {
func performSimperiumAuthentication(username: String, password: String) {
lockdownInterface()

controller.loginWithCredentials(username: email, password: password) { error in
controller.loginWithCredentials(username: username, password: password) { error in
if let error = error {
self.handleError(error: error)
} else {
Expand Down Expand Up @@ -616,6 +635,30 @@ extension SPAuthViewController: SPTextInputViewDelegate {
}
}

// MARK: - Passkeys
extension SPAuthViewController: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
view.window!
}
}

extension SPAuthViewController: PasskeyDelegate {
func succeed() {
unlockInterface()
}

func failed(_ error: any Error) {
unlockInterface()
presentPasskeyAuthError(error)
}

private func presentPasskeyAuthError(_ error: any Error) {
let alert = UIAlertController(title: AuthenticationStrings.passkeyAuthFailureTitle, message: error.localizedDescription, preferredStyle: .alert)
alert.addCancelActionWithTitle(AuthenticationStrings.unverifiedCancelText)
present(alert, animated: true)
}
}

// MARK: - AuthenticationMode: Signup / Login
//
struct AuthenticationMode {
Expand All @@ -627,6 +670,7 @@ struct AuthenticationMode {
let secondaryActionText: String?
let secondaryActionAttributedText: NSAttributedString?
let isPasswordHidden: Bool
let isLogin: Bool
}

// MARK: - Default Operation Modes
Expand All @@ -643,7 +687,8 @@ extension AuthenticationMode {
secondaryActionSelector: #selector(SPAuthViewController.presentPasswordReset),
secondaryActionText: AuthenticationStrings.loginSecondaryAction,
secondaryActionAttributedText: nil,
isPasswordHidden: false)
isPasswordHidden: false,
isLogin: true)
}

/// Signup Operation Mode: Contains all of the strings + delegate wirings, so that the AuthUI handles user account creation scenarios.
Expand All @@ -656,7 +701,20 @@ extension AuthenticationMode {
secondaryActionSelector: #selector(SPAuthViewController.presentTermsOfService),
secondaryActionText: nil,
secondaryActionAttributedText: AuthenticationStrings.signupSecondaryAttributedAction,
isPasswordHidden: true)
isPasswordHidden: true,
isLogin: false)
}

static var loginWithPasskeys: AuthenticationMode {
return .init(title: AuthenticationStrings.loginTitle,
validationStyle: .legacy,
primaryActionSelector: #selector(SPAuthViewController.passkeyAuthAction),
primaryActionText: AuthenticationStrings.passkeyActionButton,
secondaryActionSelector: #selector(SPAuthViewController.presentPasswordReset),
secondaryActionText: AuthenticationStrings.loginSecondaryAction,
secondaryActionAttributedText: nil,
isPasswordHidden: true,
isLogin: true)
}
}

Expand All @@ -666,6 +724,7 @@ private enum AuthenticationStrings {
static let loginTitle = NSLocalizedString("Log In", comment: "LogIn Interface Title")
static let loginPrimaryAction = NSLocalizedString("Log In", comment: "LogIn Action")
static let loginSecondaryAction = NSLocalizedString("Forgotten password?", comment: "Password Reset Action")
static let passkeyActionButton = NSLocalizedString("Log In With Passkeys", comment: "Login with Passkey action")
static let signupTitle = NSLocalizedString("Sign Up", comment: "SignUp Interface Title")
static let signupPrimaryAction = NSLocalizedString("Sign Up", comment: "SignUp Action")
static let signupSecondaryActionPrefix = NSLocalizedString("By creating an account you agree to our", comment: "Terms of Service Legend *PREFIX*: printed in dark color")
Expand All @@ -683,6 +742,7 @@ private enum AuthenticationStrings {
static let unverifiedErrorMessage = NSLocalizedString("There was an preparing your verification email, please try again later", comment: "Request error alert message")
static let verificationSentTitle = NSLocalizedString("Check your Email", comment: "Vefification sent alert title")
static let verificationSentTemplate = NSLocalizedString("We’ve sent a verification email to %1$@. Please check your inbox and follow the instructions.", comment: "Confirmation that an email has been sent")
static let passkeyAuthFailureTitle = NSLocalizedString("Passkey Authentication Failed", comment: "Title for passkey authentication failure")
}

// MARK: - PasswordInsecure Alert Strings
Expand Down
39 changes: 31 additions & 8 deletions Simplenote/Classes/SPAuthViewController.xib
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14854.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="ipad9_7" orientation="landscape" layout="fullscreen" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14806.4"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
Expand All @@ -28,7 +29,7 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="CJS-5O-pxK">
<rect key="frame" x="332" y="40" width="360" height="252"/>
<rect key="frame" x="332" y="60" width="360" height="252"/>
<subviews>
<view contentMode="center" translatesAutoresizingMaskIntoConstraints="NO" id="hW6-K1-OyV" customClass="SPTextInputView" customModule="Simplenote" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="360" height="44"/>
Expand All @@ -43,7 +44,7 @@
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="#Invalid email#" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="n7T-Gj-plH" customClass="SPLabel" customModule="Simplenote" customModuleProvider="target">
<rect key="frame" x="0.0" y="52" width="102.5" height="18"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="systemPinkColor" red="1" green="0.17647058823529413" blue="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="textColor" systemColor="systemPinkColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="center" translatesAutoresizingMaskIntoConstraints="NO" id="pdF-nK-gXE" customClass="SPTextInputView" customModule="Simplenote" customModuleProvider="target">
Expand All @@ -59,13 +60,13 @@
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="#Invalid password#" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UyC-If-pXj" customClass="SPLabel" customModule="Simplenote" customModuleProvider="target">
<rect key="frame" x="0.0" y="130" width="132" height="18"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="textColor" systemColor="systemRedColor" red="1" green="0.23137254901960785" blue="0.18823529411764706" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="textColor" systemColor="systemRedColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="CGr-O3-MBQ" userLabel="Primary Action View">
<rect key="frame" x="0.0" y="156" width="360" height="44"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="q3p-Ji-dOm" customClass="SPSquaredButton" customModule="Simplenote" customModuleProvider="target">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="q3p-Ji-dOm" customClass="SPSquaredButton" customModule="Simplenote" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="360" height="44"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleHeadline"/>
<state key="normal" title="#Primary#"/>
Expand All @@ -74,7 +75,7 @@
<rect key="frame" x="322" y="12" width="20" height="20"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="q3p-Ji-dOm" secondAttribute="bottom" id="42P-Dw-g8A"/>
<constraint firstAttribute="trailing" secondItem="o3M-2A-urw" secondAttribute="trailing" constant="18" id="5zP-Ho-8zY"/>
Expand Down Expand Up @@ -107,15 +108,37 @@
</constraints>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="CJS-5O-pxK" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" constant="40" id="Cyh-nE-ql7"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="CJS-5O-pxK" secondAttribute="trailing" priority="999" constant="24" id="Gnk-2R-FJy"/>
<constraint firstItem="CJS-5O-pxK" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="H6h-v9-Uwb"/>
<constraint firstItem="CJS-5O-pxK" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" priority="999" constant="24" id="Odu-ev-HSV"/>
</constraints>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<point key="canvasLocation" x="137.68115942028987" y="152.67857142857142"/>
</view>
</objects>
<designables>
<designable name="UyC-If-pXj">
<size key="intrinsicContentSize" width="132" height="18"/>
</designable>
<designable name="n7T-Gj-plH">
<size key="intrinsicContentSize" width="102.5" height="18"/>
</designable>
<designable name="q3p-Ji-dOm">
<size key="intrinsicContentSize" width="84" height="33"/>
</designable>
</designables>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemPinkColor">
<color red="1" green="0.1764705882" blue="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemRedColor">
<color red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>
8 changes: 7 additions & 1 deletion Simplenote/Classes/SPOnboardingViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,18 @@ private extension SPOnboardingViewController {
let sheetController = SPSheetController()

sheetController.setTitleForButton0(title: OnboardingStrings.loginWithEmailText)
sheetController.setTitleForButton1(title: OnboardingStrings.loginWithWpcomText)
sheetController.setTitleForButton1(title: OnboardingStrings.loginWithPasskeysText)
sheetController.setTitleForButton2(title: OnboardingStrings.loginWithWpcomText)

sheetController.onClickButton0 = { [weak self] in
self?.presentAuthenticationInterface(mode: .login)
}

sheetController.onClickButton1 = { [weak self] in
self?.presentAuthenticationInterface(mode: .loginWithPasskeys)
}

sheetController.onClickButton2 = { [weak self] in
self?.presentWordpressSSO()
}

Expand Down Expand Up @@ -224,6 +229,7 @@ private struct OnboardingStrings {
static let headerText = NSLocalizedString("The simplest way to keep notes.", comment: "Onboarding Header Text")
static let loginWithEmailText = NSLocalizedString("Log in with email", comment: "Presents the regular Email signin flow")
static let loginWithWpcomText = NSLocalizedString("Log in with WordPress.com", comment: "Allows the user to SignIn using their WPCOM Account")
static let loginWithPasskeysText = NSLocalizedString("Log in with Passkeys", comment: "Allows the user to SignIn using Passkeys")
}

private struct SignInError {
Expand Down
Loading