Repository navigation
feat(mobile): passkeys for Browser page sites from the phone #17762
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
Open
ntindle
wants to merge
3
commits into
pingdotgg:main
Choose a base branch
from
ntindle:t3/mobile-browser-passkeys
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,965
−10
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "platforms": ["apple"], | ||
| "apple": { "modules": ["T3PasskeysModule"] } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| Pod::Spec.new do |s| | ||
| s.name = 'T3Passkeys' | ||
| s.version = '1.0.0' | ||
| s.summary = 'Passkeys for T3 Code server browser pages.' | ||
| s.description = 'Answers a server browser page\'s WebAuthn requests with the system passkey sheet.' | ||
| s.author = 'T3 Tools' | ||
| s.homepage = 'https://t3tools.com' | ||
| s.platforms = { | ||
| :ios => '18.0', | ||
| } | ||
| s.source = { :path => '.' } | ||
| s.static_framework = true | ||
|
|
||
| s.dependency 'ExpoModulesCore' | ||
| s.frameworks = 'AuthenticationServices' | ||
| s.pod_target_xcconfig = { | ||
| 'DEFINES_MODULE' => 'YES', | ||
| } | ||
| s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' | ||
| end |
294 changes: 294 additions & 0 deletions
294
apps/mobile/modules/t3-passkeys/ios/T3PasskeysModule.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,294 @@ | ||
| import AuthenticationServices | ||
| import ExpoModulesCore | ||
| import UIKit | ||
|
|
||
| /// Answers a server browser page's WebAuthn request with the system passkey | ||
| /// sheet, for the page's own origin. AuthenticationServices serves any site only | ||
| /// to apps that hold Apple's browser entitlement, so builds without it report | ||
| /// unavailable and are never asked. | ||
| public final class T3PasskeysModule: Module { | ||
| private var ceremony: T3PasskeyCeremony? | ||
|
|
||
| public func definition() -> ModuleDefinition { | ||
| Name("T3Passkeys") | ||
|
|
||
| Constants { | ||
| // app.config.ts sets this only in builds signed with the entitlement. | ||
| ["available": Bundle.main.object(forInfoDictionaryKey: "T3BrowserPasskeys") as? Bool == true] | ||
| } | ||
|
|
||
| // Both run on the main queue, where AuthenticationServices presents its sheet. | ||
| AsyncFunction("perform") { (id: String, kind: String, origin: String, options: String, promise: Promise) in | ||
| MainActor.assumeIsolated { | ||
| // One sheet at a time: a newer request replaces one still unanswered. | ||
| self.ceremony?.cancel() | ||
| // The ceremony drops this completion once it runs, which ends the cycle back to the module. | ||
| let ceremony = T3PasskeyCeremony(id: id, kind: kind, origin: origin, options: options) { result in | ||
| if self.ceremony?.id == id { self.ceremony = nil } | ||
| promise.resolve(result) | ||
| } | ||
| self.ceremony = ceremony | ||
| ceremony.start() | ||
| } | ||
| }.runOnQueue(.main) | ||
|
|
||
| AsyncFunction("cancel") { (id: String) in | ||
| MainActor.assumeIsolated { | ||
| if self.ceremony?.id == id { self.ceremony?.cancel() } | ||
| } | ||
| }.runOnQueue(.main) | ||
|
|
||
| OnDestroy { | ||
| let ceremony = self.ceremony | ||
| DispatchQueue.main.async { ceremony?.cancel() } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// One WebAuthn ceremony. The server has checked the origin and RP ID; the | ||
| /// result goes back as a `PreviewStreamPasskeyResult` in JSON. | ||
| final class T3PasskeyCeremony: NSObject, ASAuthorizationControllerDelegate, | ||
| ASAuthorizationControllerPresentationContextProviding | ||
| { | ||
| let id: String | ||
| private let kind: String | ||
| private let origin: String | ||
| private let options: String | ||
| private var completion: ((String) -> Void)? | ||
| private var controller: ASAuthorizationController? | ||
|
|
||
| private static let notAllowed: [String: Any] = ["success": false, "error": "NotAllowedError"] | ||
| private static let transports: [String: ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport] = [ | ||
| "usb": .usb, "nfc": .nfc, "ble": .bluetooth, | ||
| ] | ||
|
|
||
| init(id: String, kind: String, origin: String, options: String, completion: @escaping (String) -> Void) { | ||
| self.id = id | ||
| self.kind = kind | ||
| self.origin = origin | ||
| self.options = options | ||
| self.completion = completion | ||
| } | ||
|
|
||
| func start() { | ||
| guard let requests = makeRequests(), !requests.isEmpty else { | ||
| finish(["success": false, "error": "TypeError"]) | ||
| return | ||
| } | ||
| let controller = ASAuthorizationController(authorizationRequests: requests) | ||
| controller.delegate = self | ||
| controller.presentationContextProvider = self | ||
| self.controller = controller | ||
| controller.performRequests() | ||
| } | ||
|
|
||
| func cancel() { | ||
| controller?.cancel() | ||
| finish(Self.notAllowed) | ||
| } | ||
|
|
||
| // Passkeys on the device and security keys are offered together, as Safari does, | ||
| // unless the page asked for one kind of authenticator. | ||
| private func makeRequests() -> [ASAuthorizationRequest]? { | ||
| let json = Data(options.utf8) | ||
| if kind == "create" { | ||
| guard | ||
| let options = try? JSONDecoder().decode(CreationOptions.self, from: json), | ||
| let challenge = Data(base64URL: options.challenge), | ||
| let userID = Data(base64URL: options.user.id) | ||
| else { return nil } | ||
| let clientData = ASPublicKeyCredentialClientData(challenge: challenge, origin: origin) | ||
| let selection = options.authenticatorSelection | ||
| let verification = ASAuthorizationPublicKeyCredentialUserVerificationPreference( | ||
| rawValue: selection?.userVerification ?? "preferred") | ||
| let attestation = ASAuthorizationPublicKeyCredentialAttestationKind(rawValue: options.attestation ?? "none") | ||
| let excluded = (options.excludeCredentials ?? []).compactMap { descriptor in | ||
| Data(base64URL: descriptor.id).map { (descriptor, $0) } | ||
| } | ||
| var requests: [ASAuthorizationRequest] = [] | ||
| if selection?.authenticatorAttachment != "cross-platform" { | ||
| let request = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) | ||
| .createCredentialRegistrationRequest(clientData: clientData, name: options.user.name, userID: userID) | ||
| request.displayName = options.user.displayName | ||
| request.userVerificationPreference = verification | ||
| request.attestationPreference = attestation | ||
| request.excludedCredentials = excluded.map { | ||
| ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0.1) | ||
| } | ||
| requests.append(request) | ||
| } | ||
| if selection?.authenticatorAttachment != "platform" { | ||
| let request = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) | ||
| .createCredentialRegistrationRequest( | ||
| clientData: clientData, | ||
| displayName: options.user.displayName ?? options.user.name, | ||
| name: options.user.name, | ||
| userID: userID | ||
| ) | ||
| request.credentialParameters = [ASAuthorizationPublicKeyCredentialParameters(algorithm: .ES256)] | ||
| request.excludedCredentials = excluded.map { securityKey($0.0, id: $0.1) } | ||
| request.residentKeyPreference = ASAuthorizationPublicKeyCredentialResidentKeyPreference( | ||
| rawValue: selection?.residentKey ?? (selection?.requireResidentKey == true ? "required" : "discouraged")) | ||
| request.userVerificationPreference = verification | ||
| request.attestationPreference = attestation | ||
| requests.append(request) | ||
| } | ||
| return requests | ||
| } | ||
| guard | ||
| let options = try? JSONDecoder().decode(RequestOptions.self, from: json), | ||
| let challenge = Data(base64URL: options.challenge) | ||
| else { return nil } | ||
| let clientData = ASPublicKeyCredentialClientData(challenge: challenge, origin: origin) | ||
| let verification = ASAuthorizationPublicKeyCredentialUserVerificationPreference( | ||
| rawValue: options.userVerification ?? "preferred") | ||
| let allowed = (options.allowCredentials ?? []).compactMap { descriptor in | ||
| Data(base64URL: descriptor.id).map { (descriptor, $0) } | ||
| } | ||
| let platform = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) | ||
| .createCredentialAssertionRequest(clientData: clientData) | ||
| platform.allowedCredentials = allowed.map { ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0.1) } | ||
| platform.userVerificationPreference = verification | ||
| let key = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) | ||
| .createCredentialAssertionRequest(clientData: clientData) | ||
| key.allowedCredentials = allowed.map { securityKey($0.0, id: $0.1) } | ||
| key.userVerificationPreference = verification | ||
| return [platform, key] | ||
| } | ||
|
|
||
| private func securityKey(_ descriptor: Descriptor, id: Data) -> ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor { | ||
| let transports = (descriptor.transports ?? []).compactMap { Self.transports[$0] } | ||
| return ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor( | ||
| credentialID: id, | ||
| transports: transports.isEmpty ? ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported : transports | ||
| ) | ||
| } | ||
|
|
||
| func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { | ||
| switch authorization.credential { | ||
| case let registration as ASAuthorizationPlatformPublicKeyCredentialRegistration: | ||
| guard let attestation = registration.rawAttestationObject else { return finish(Self.notAllowed) } | ||
| finish(succeeded([ | ||
| "id": registration.credentialID.base64URL, | ||
| "clientDataJSON": registration.rawClientDataJSON.base64URL, | ||
| "attestationObject": attestation.base64URL, | ||
| "authenticatorAttachment": Self.attachment(registration.attachment), | ||
| "transports": ["hybrid", "internal"], | ||
| ])) | ||
| case let registration as ASAuthorizationSecurityKeyPublicKeyCredentialRegistration: | ||
| guard let attestation = registration.rawAttestationObject else { return finish(Self.notAllowed) } | ||
| finish(succeeded([ | ||
| "id": registration.credentialID.base64URL, | ||
| "clientDataJSON": registration.rawClientDataJSON.base64URL, | ||
| "attestationObject": attestation.base64URL, | ||
| "authenticatorAttachment": "cross-platform", | ||
| "transports": registration.transports.map(\.rawValue), | ||
| ])) | ||
| case let assertion as ASAuthorizationPlatformPublicKeyCredentialAssertion: | ||
| finish(succeeded([ | ||
| "id": assertion.credentialID.base64URL, | ||
| "clientDataJSON": assertion.rawClientDataJSON.base64URL, | ||
| "authenticatorData": assertion.rawAuthenticatorData.base64URL, | ||
| "signature": assertion.signature.base64URL, | ||
| "userHandle": assertion.userID.base64URL, | ||
| "authenticatorAttachment": Self.attachment(assertion.attachment), | ||
| ])) | ||
| case let assertion as ASAuthorizationSecurityKeyPublicKeyCredentialAssertion: | ||
| finish(succeeded([ | ||
| "id": assertion.credentialID.base64URL, | ||
| "clientDataJSON": assertion.rawClientDataJSON.base64URL, | ||
| "authenticatorData": assertion.rawAuthenticatorData.base64URL, | ||
| "signature": assertion.signature.base64URL, | ||
| "userHandle": assertion.userID.base64URL, | ||
| "authenticatorAttachment": "cross-platform", | ||
| ])) | ||
| default: | ||
| finish(Self.notAllowed) | ||
| } | ||
| } | ||
|
|
||
| func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { | ||
| // WebAuthn reports a passkey this site already has as InvalidStateError; anything else stays opaque. | ||
| let excluded = (error as? ASAuthorizationError)?.code == .matchedExcludedCredential | ||
| finish(excluded ? ["success": false, "error": "InvalidStateError"] : Self.notAllowed) | ||
| } | ||
|
|
||
| func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { | ||
| UIApplication.shared.connectedScenes | ||
| .compactMap { $0 as? UIWindowScene } | ||
| .flatMap(\.windows) | ||
| .first(where: \.isKeyWindow) ?? ASPresentationAnchor() | ||
| } | ||
|
|
||
| private func succeeded(_ credential: [String: Any]) -> [String: Any] { | ||
| ["success": true, "credential": credential] | ||
| } | ||
|
|
||
| private static func attachment(_ attachment: ASAuthorizationPublicKeyCredentialAttachment) -> String { | ||
| attachment == .crossPlatform ? "cross-platform" : "platform" | ||
| } | ||
|
|
||
| private func finish(_ result: [String: Any]) { | ||
| guard let completion else { return } | ||
| self.completion = nil | ||
| controller = nil | ||
| let data = (try? JSONSerialization.data(withJSONObject: result)) | ||
| ?? Data(#"{"success":false,"error":"NotAllowedError"}"#.utf8) | ||
| completion(String(decoding: data, as: UTF8.self)) | ||
| } | ||
| } | ||
|
|
||
| private struct Descriptor: Decodable { | ||
| let id: String | ||
| let transports: [String]? | ||
| } | ||
|
|
||
| /// The members of `PublicKeyCredentialCreationOptionsJSON` the system sheet uses. | ||
| private struct CreationOptions: Decodable { | ||
| struct RelyingParty: Decodable { | ||
| let id: String | ||
| } | ||
|
|
||
| struct User: Decodable { | ||
| let id: String | ||
| let name: String | ||
| let displayName: String? | ||
| } | ||
|
|
||
| struct Selection: Decodable { | ||
| let authenticatorAttachment: String? | ||
| let residentKey: String? | ||
| let requireResidentKey: Bool? | ||
| let userVerification: String? | ||
| } | ||
|
|
||
| let rp: RelyingParty | ||
| let user: User | ||
| let challenge: String | ||
| let excludeCredentials: [Descriptor]? | ||
| let authenticatorSelection: Selection? | ||
| let attestation: String? | ||
| } | ||
|
|
||
| /// The members of `PublicKeyCredentialRequestOptionsJSON` the system sheet uses. | ||
| private struct RequestOptions: Decodable { | ||
| let rpId: String | ||
| let challenge: String | ||
| let allowCredentials: [Descriptor]? | ||
| let userVerification: String? | ||
| } | ||
|
|
||
| private extension Data { | ||
| init?(base64URL: String) { | ||
| var base64 = base64URL.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") | ||
| base64 += String(repeating: "=", count: (4 - base64.count % 4) % 4) | ||
| self.init(base64Encoded: base64) | ||
| } | ||
|
|
||
| var base64URL: String { | ||
| base64EncodedString() | ||
| .replacingOccurrences(of: "+", with: "-") | ||
| .replacingOccurrences(of: "/", with: "_") | ||
| .replacingOccurrences(of: "=", with: "") | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: pingdotgg/t3code
Length of output: 4721
🏁 Script executed:
Repository: pingdotgg/t3code
Length of output: 11796
🌐 Web query:
Apple AuthenticationServices ASAuthorizationSecurityKeyPublicKeyCredentialAssertion userID nullable non-discoverable credential official documentation💡 Result:
🌐 Web query:
W3C WebAuthn specification assertion response userHandle optional non-discoverable credential💡 Result:
Omit
userHandlewhenassertion.userIDis nil.A non-discoverable credential can return no user handle when
allowCredentialsis non-empty. The security-key branch force-unwrapsassertion.userIDwhile building the result, which can crash beforefinishruns. The server accepts an omitteduserHandle.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents