From d9b644dd413ed5013e5b8d5bc5dcdb152f0ef19f Mon Sep 17 00:00:00 2001 From: Wyatt Mufson Date: Sun, 25 Aug 2024 21:07:53 +0900 Subject: [PATCH] Add domain support + implementation fixes (#11) * fix README * Add init function to set domain * README update * improve implementation of passkey creation and getting in obj-c * Add changeset * example fix --- .changeset/curvy-needles-whisper.md | 5 ++ README.md | 7 ++- src/demo/electron-demo.ts | 2 + src/index.ts | 73 +++++++++++++++++++++++++---- src/lib/passkey.mm | 54 +++++++++++++++++++-- 5 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 .changeset/curvy-needles-whisper.md diff --git a/.changeset/curvy-needles-whisper.md b/.changeset/curvy-needles-whisper.md new file mode 100644 index 0000000..a398145 --- /dev/null +++ b/.changeset/curvy-needles-whisper.md @@ -0,0 +1,5 @@ +--- +'electron-passkey': minor +--- + +A lot of cleanup and refactoring of the implementation diff --git a/README.md b/README.md index e0c32be..27808c8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ navigator.credentials.get = function (options) { ```js import Passkey from 'electron-passkey'; +Passkey.getInstance().init('domain.com'); + ipcMain.handle('webauthn-create', (event, options) => { return Passkey.getInstance().handlePasskeyCreate(options); }); @@ -40,7 +42,7 @@ ipcMain.handle('webauthn-get', (event, options) => { ![AssociatedDomains](images/AssociatedDomains.png "Associated Domains") 3) You may need to create a provisioning profile for macOS development on your device and/or for distribution 4) Create a webserver to serve an AASA file [as specificed in the docs](https://developer.apple.com/documentation/xcode/supporting-associated-domains#Add-the-associated-domain-file-to-your-website) -5) Test it with the [yURL validator](https://branch.io/resources/aasa-validator/) and/or [branch.io validator](https://branch.io/resources/aasa-validator/) +5) Test it with the [yURL validator](https://yurl.chayev.com) and/or [branch.io validator](https://branch.io/resources/aasa-validator/) 6) Add the following to your entitlements plist ``` com.apple.application-identifier @@ -52,7 +54,8 @@ ipcMain.handle('webauthn-get', (event, options) => { ``` 7) Check to see if your AASA is being cached by the Apple CDN at `https://app-site-association.cdn-apple.com/a/v1/DOMAIN` -8) Build your electron application and sign it +8) Make sure to call `Passkey.getInstance().init()` and pass in your domain +9) Build your electron application and sign it ### Deployments diff --git a/src/demo/electron-demo.ts b/src/demo/electron-demo.ts index 1609aa2..8d2b2c3 100644 --- a/src/demo/electron-demo.ts +++ b/src/demo/electron-demo.ts @@ -8,6 +8,8 @@ import './ipcHandlers'; // https://github.com/electron/electron/issues/25153 // app.disableHardwareAcceleration(); +Passkey.getInstance().init('google.com'); + let window: BrowserWindow; function createWindow() { diff --git a/src/index.ts b/src/index.ts index f81a717..2b69521 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,10 +50,13 @@ interface PasskeyOptions { signal?: AbortSignal; } +interface PasskeyHandler { + HandlePasskeyCreate(options: string): Promise; + HandlePasskeyGet(options: string): Promise; +} + interface PasskeyInterface { - HandlePasskeyCreate: (options: string) => Promise; - HandlePasskeyGet: (options: string) => Promise; - PasskeyHandler: any; + PasskeyHandler: new () => PasskeyHandler; } const lib: PasskeyInterface = require('node-gyp-build')(join(__dirname, '..')); @@ -68,14 +71,26 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { return btoa(binary); } +function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i += 1) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + class Passkey { // eslint-disable-next-line private static instance: Passkey; - private handler: any; + private handler: PasskeyHandler; private platform = os.platform(); + private domain: string = ''; + private constructor() { this.handler = new lib.PasskeyHandler(); // Create an instance of PasskeyHandler } @@ -88,7 +103,13 @@ class Passkey { return Passkey.instance; } - handlePasskeyCreate(options: PasskeyOptions): Promise { + init(domain: string): void { + this.domain = domain; + } + + async handlePasskeyCreate( + options: PasskeyOptions, + ): Promise { if (this.platform !== 'darwin') { throw new Error( `electron-passkey is meant for macOS only and should NOT be run on ${this.platform}`, @@ -97,22 +118,58 @@ class Passkey { options.publicKey.challenge = arrayBufferToBase64( options.publicKey.challenge as ArrayBuffer, ); + (options.publicKey as PublicKeyCredentialCreationOptions).rp.id = + this.domain; (options.publicKey as PublicKeyCredentialCreationOptions).user.id = arrayBufferToBase64( (options.publicKey as PublicKeyCredentialCreationOptions).user .id as ArrayBuffer, ); - return this.handler.HandlePasskeyCreate(JSON.stringify(options)); + const rawString = await this.handler.HandlePasskeyCreate( + JSON.stringify(options), + ); + let raw; + try { + raw = JSON.parse(rawString); + } catch (e: any) { + throw new Error(`Failed to parse JSON response: ${e.message}`); + } + + try { + raw.rawId = base64ToArrayBuffer(raw.rawId); + } catch (e: any) { + throw new Error(`Failed to convert rawId from base64: ${e.message}`); + } + return raw; } - handlePasskeyGet(options: PasskeyOptions): Promise { + async handlePasskeyGet( + options: PasskeyOptions, + ): Promise { if (this.platform !== 'darwin') { throw new Error( `electron-passkey is meant for macOS only and should NOT be run on ${this.platform}`, ); } - return this.handler.HandlePasskeyGet(JSON.stringify(options)); + (options.publicKey as PublicKeyCredentialRequestOptions).rpId = this.domain; + + const rawString = await this.handler.HandlePasskeyGet( + JSON.stringify(options), + ); + let raw; + try { + raw = JSON.parse(rawString); + } catch (e: any) { + throw new Error(`Failed to parse JSON response: ${e.message}`); + } + + try { + raw.rawId = base64ToArrayBuffer(raw.rawId); + } catch (e: any) { + throw new Error(`Failed to convert rawId from base64: ${e.message}`); + } + return raw; } static getPackageName(): string { diff --git a/src/lib/passkey.mm b/src/lib/passkey.mm index 2634ddf..ff34ee4 100644 --- a/src/lib/passkey.mm +++ b/src/lib/passkey.mm @@ -144,13 +144,57 @@ - (void)authorizationController:(ASAuthorizationController *)controller didCompl NSString *credentialId = [credential.credentialID base64EncodedStringWithOptions:0]; NSDictionary *responseDict = @{ - @"clientDataJSON": [clientDataJSON base64EncodedStringWithOptions:0], - @"attestationObject": [attestationObject base64EncodedStringWithOptions:0], - @"credentialId": credentialId + @"clientDataJSON": [credential.rawClientDataJSON base64EncodedStringWithOptions:0], + @"attestationObject": [credential.rawAttestationObject base64EncodedStringWithOptions:0] }; + // Assemble the PublicKeyCredential object structure + NSDictionary *publicKeyCredentialDict = @{ + @"id": [credential.credentialID base64EncodedStringWithOptions:0], // id is the base64-encoded credential ID + @"type": @"public-key", // Fixed value for PublicKeyCredential + @"rawId": [credential.credentialID base64EncodedStringWithOptions:0], // rawId is the raw NSData representing the credential ID + @"response": responseDict, // The response object + @"clientExtensionResults": @{}, // An empty dictionary, as no extensions are used in this example + @"transports": @[] // Transports are not directly available in ASAuthorizationPlatformPublicKeyCredentialRegistration + }; + + NSError *error; + NSData *responseData = [NSJSONSerialization dataWithJSONObject:publicKeyCredentialDict options:0 error:&error]; + if (error) { + NSLog(@"[authorizationController didCompleteWithAuthorization]: Failed to serialize response: %@", error.localizedDescription); + if (self.completionHandler) { + self.completionHandler(nil, error.localizedDescription); + } + } else { + NSString *resultMessage = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding]; + if (self.completionHandler) { + self.completionHandler(resultMessage, nil); + } + } + } else if ([authorization.credential isKindOfClass:[ASAuthorizationPlatformPublicKeyCredentialAssertion class]]) { + ASAuthorizationPlatformPublicKeyCredentialAssertion *credential = (ASAuthorizationPlatformPublicKeyCredentialAssertion *)authorization.credential; + + // Create the "response" dictionary, simulating the AuthenticatorAssertionResponse + NSDictionary *responseDict = @{ + @"clientDataJSON": [credential.rawClientDataJSON base64EncodedStringWithOptions:0], + @"authenticatorData": [credential.rawAuthenticatorData base64EncodedStringWithOptions:0], + @"signature": [credential.signature base64EncodedStringWithOptions:0], + @"userHandle": credential.userID ? [credential.userID base64EncodedStringWithOptions:0] : [NSNull null] + }; + + // Assemble the PublicKeyCredential object structure + NSDictionary *publicKeyCredentialDict = @{ + @"id": [credential.credentialID base64EncodedStringWithOptions:0], // id is the base64-encoded credential ID + @"type": @"public-key", // Fixed value for PublicKeyCredential + @"rawId": [credential.credentialID base64EncodedStringWithOptions:0], // rawId is the base64-encoded credential ID + @"response": responseDict, // The response object + @"clientExtensionResults": @{}, // An empty dictionary, as no extensions are used in this example + @"transports": @[] // Transports are not directly available in ASAuthorizationPlatformPublicKeyCredentialAssertion + }; + + // Serialize the PublicKeyCredential object into JSON NSError *error; - NSData *responseData = [NSJSONSerialization dataWithJSONObject:responseDict options:0 error:&error]; + NSData *responseData = [NSJSONSerialization dataWithJSONObject:publicKeyCredentialDict options:0 error:&error]; if (error) { NSLog(@"[authorizationController didCompleteWithAuthorization]: Failed to serialize response: %@", error.localizedDescription); if (self.completionHandler) { @@ -282,6 +326,8 @@ - (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthoriz deferred.Resolve(Napi::String::New(env, std::string([resultMessage UTF8String]))); } }]; + + return deferred.Promise(); } private: