Skip to content

Automatic passkey upgrade support #42

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

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
25 changes: 24 additions & 1 deletion Sources/SnapAuth/API.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import AuthenticationServices

/// Wrapper that matches the API wire format
///
Expand All @@ -16,9 +17,11 @@ struct SAWrappedResponse<T>: 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
Expand Down Expand Up @@ -94,7 +97,7 @@ enum Transport: String, Encodable {

struct SACreateAuthOptionsResponse: Decodable {
let publicKey: PublicKeyOptions
// mediation
let mediation: CredentialMediationRequirement

struct PublicKeyOptions: Decodable {

Expand Down Expand Up @@ -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
}
}
28 changes: 23 additions & 5 deletions Sources/SnapAuth/SnapAuth+BuildRequests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions Sources/SnapAuth/SnapAuth+Upgrades.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
8 changes: 5 additions & 3 deletions Sources/SnapAuth/SnapAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,20 +106,22 @@ 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?
internal func startRegister(
username: String,
anchor: ASPresentationAnchor,
displayName: String? = nil,
authenticators: Set<Authenticator> = Authenticator.all
authenticators: Set<Authenticator> = 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,
Expand Down
Loading