diff --git a/Sources/SnapAuth/API.swift b/Sources/SnapAuth/API.swift index a58e96f..b27ffa2 100644 --- a/Sources/SnapAuth/API.swift +++ b/Sources/SnapAuth/API.swift @@ -1,4 +1,5 @@ import Foundation +import AuthenticationServices /// Wrapper that matches the API wire format /// @@ -16,9 +17,11 @@ struct SAWrappedResponse: Decodable where T: Decodable { struct SACreateRegisterOptionsRequest: Encodable { let user: AuthenticatingUser? + let upgrade: Bool } struct SACreateRegisterOptionsResponse: Decodable { let publicKey: PublicKeyOptions + let mediation: CredentialMediationRequirement struct PublicKeyOptions: Decodable { let rp: RPInfo @@ -94,7 +97,7 @@ enum Transport: String, Encodable { struct SACreateAuthOptionsResponse: Decodable { let publicKey: PublicKeyOptions - // mediation + let mediation: CredentialMediationRequirement struct PublicKeyOptions: Decodable { @@ -133,3 +136,23 @@ struct SAProcessAuthResponse: Decodable { let token: String let expiresAt: Date } + +/// https://www.w3.org/TR/credential-management-1/#mediation-requirements +enum CredentialMediationRequirement: String, Decodable { + /// Default behavior: present requests in foreground if needed. + case optional = "optional" + /// Used to indicate operation should be done in the background. + case conditional = "conditional" + /// Fail if operation cannot be performed without user involvement. Unused; only present for future-proofing. + case silent = "silent" + /// Fail if operation cannot be performed with user involvement. Unused; only present for future-proofing. + case required = "required" + + @available(iOS 18, visionOS 2.0, macOS 15.0, *) + var requestStyle: ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest.RequestStyle { + if (self == .conditional) { + return .conditional + } + return .standard + } +} diff --git a/Sources/SnapAuth/SnapAuth+BuildRequests.swift b/Sources/SnapAuth/SnapAuth+BuildRequests.swift index 0a07065..9b26b08 100644 --- a/Sources/SnapAuth/SnapAuth+BuildRequests.swift +++ b/Sources/SnapAuth/SnapAuth+BuildRequests.swift @@ -14,12 +14,30 @@ extension SnapAuth { if authenticators.contains(.passkey) { let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: options.publicKey.rp.id) - let request = provider.createCredentialRegistrationRequest( - challenge: challenge, - name: username, - userID: options.publicKey.user.id.data) - requests.append(request) + // This is a little clumsy: the conditional request API wasn't added + // to tvOS at all, and a simple if #available can't block an entire + // platform. + var request: ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest? = nil + #if (os(iOS) || os(macOS) || os(visionOS)) + if #available(iOS 18, macOS 15, visionOS 2, *) { + request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: username, + userID: options.publicKey.user.id.data, + requestStyle: options.mediation.requestStyle) + } + // TODO: if conditional and unsupported platform, short-circuit to ensure there's no false-positive modals + #endif + if request == nil { + // tvOS and previous other platforms + request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: username, + userID: options.publicKey.user.id.data) + } + + requests.append(request!) } #if HARDWARE_KEY_SUPPORT diff --git a/Sources/SnapAuth/SnapAuth+Upgrades.swift b/Sources/SnapAuth/SnapAuth+Upgrades.swift new file mode 100644 index 0000000..2de3b4c --- /dev/null +++ b/Sources/SnapAuth/SnapAuth+Upgrades.swift @@ -0,0 +1,27 @@ +extension SnapAuth { + /// Attempts to upgrade an existing account to use passkeys by creating one + /// in the background. + /// + /// This should be called after a user signs in. Errors should not be + /// displayed to the user, though may be logged. + /// + /// - Parameters: + /// - username: The username of the user, such as an email address or handle + /// - displayName: The proper name of the user. If omitted, name will be used. + public func upgradeToPasskey( + username: String, + displayName: String? = nil + ) async -> SnapAuthResult { + if !SAAvailability.passkeyUpgrades { + return .failure(.unsupportedOnPlatform) + } + + return await startRegister( + username: username, + anchor: .default, + displayName: displayName, + authenticators: [.passkey], + upgrade: true + ) + } +} diff --git a/Sources/SnapAuth/SnapAuth.swift b/Sources/SnapAuth/SnapAuth.swift index 1eefbda..be9f103 100644 --- a/Sources/SnapAuth/SnapAuth.swift +++ b/Sources/SnapAuth/SnapAuth.swift @@ -106,7 +106,8 @@ public class SnapAuth: NSObject { // NSObject for ASAuthorizationControllerDeleg username: username, anchor: .default, displayName: displayName, - authenticators: authenticators) + authenticators: authenticators, + upgrade: false) } // TODO: Only make this public if needed? @@ -114,12 +115,13 @@ public class SnapAuth: NSObject { // NSObject for ASAuthorizationControllerDeleg username: String, anchor: ASPresentationAnchor, displayName: String? = nil, - authenticators: Set = Authenticator.all + authenticators: Set = Authenticator.all, + upgrade: Bool ) async -> SnapAuthResult { reset() self.anchor = anchor - let body = SACreateRegisterOptionsRequest(user: nil) + let body = SACreateRegisterOptionsRequest(user: nil, upgrade: upgrade) let response = await api.makeRequest( path: "/attestation/options", body: body,