From 100ea77af46317d815b7bf4f695144187414d5b8 Mon Sep 17 00:00:00 2001 From: Jarrett Helton Date: Sat, 21 Aug 2021 17:10:21 -0400 Subject: renaming assertion -> authentication --- packages/browser/src/index.test.ts | 8 +- packages/browser/src/index.ts | 4 +- .../browser/src/methods/startAssertion.test.ts | 244 --------------------- packages/browser/src/methods/startAssertion.ts | 66 ------ .../src/methods/startAuthentication.test.ts | 219 ++++++++++++++++++ .../browser/src/methods/startAuthentication.ts | 66 ++++++ 6 files changed, 291 insertions(+), 316 deletions(-) delete mode 100644 packages/browser/src/methods/startAssertion.test.ts delete mode 100644 packages/browser/src/methods/startAssertion.ts create mode 100644 packages/browser/src/methods/startAuthentication.test.ts create mode 100644 packages/browser/src/methods/startAuthentication.ts (limited to 'packages/browser/src') diff --git a/packages/browser/src/index.test.ts b/packages/browser/src/index.test.ts index 0d132ba..ffd3b2b 100644 --- a/packages/browser/src/index.test.ts +++ b/packages/browser/src/index.test.ts @@ -1,11 +1,11 @@ import * as index from './index'; -test('should export method `startAttestation`', () => { - expect(index.startAttestation).toBeDefined(); +test('should export method `startRegistration`', () => { + expect(index.startRegistration).toBeDefined(); }); -test('should export method `startAssertion`', () => { - expect(index.startAssertion).toBeDefined(); +test('should export method `startAuthentication`', () => { + expect(index.startAuthentication).toBeDefined(); }); test('should export method `supportsWebauthn`', () => { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 4f42044..520af9a 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -3,7 +3,7 @@ * @module @simplewebauthn/browser */ import startRegistration from './methods/startRegistration'; -import startAssertion from './methods/startAssertion'; +import startAuthentication from './methods/startAuthentication'; import supportsWebauthn from './helpers/supportsWebauthn'; -export { startRegistration, startAssertion, supportsWebauthn }; +export { startRegistration, startAuthentication, supportsWebauthn }; diff --git a/packages/browser/src/methods/startAssertion.test.ts b/packages/browser/src/methods/startAssertion.test.ts deleted file mode 100644 index f005a26..0000000 --- a/packages/browser/src/methods/startAssertion.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { - AssertionCredential, - PublicKeyCredentialRequestOptionsJSON, - AuthenticationExtensionsClientInputs, - AuthenticationExtensionsClientOutputs, -} from '@simplewebauthn/typescript-types'; - -import supportsWebauthn from '../helpers/supportsWebauthn'; -import utf8StringToBuffer from '../helpers/utf8StringToBuffer'; -import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; - -import startAssertion from './startAssertion'; - -jest.mock('../helpers/supportsWebauthn'); - -const mockNavigatorGet = window.navigator.credentials.get as jest.Mock; -const mockSupportsWebauthn = supportsWebauthn as jest.Mock; - -const mockAuthenticatorData = 'mockAuthenticatorData'; -const mockClientDataJSON = 'mockClientDataJSON'; -const mockSignature = 'mockSignature'; -const mockUserHandle = 'mockUserHandle'; - -// With ASCII challenge -const goodOpts1: PublicKeyCredentialRequestOptionsJSON = { - challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), - allowCredentials: [ - { - id: 'C0VGlvYFratUdAV1iCw-ULpUW8E-exHPXQChBfyVeJZCMfjMFcwDmOFgoMUz39LoMtCJUBW8WPlLkGT6q8qTCg', - type: 'public-key', - transports: ['nfc'], - }, - ], - timeout: 1, -}; - -// With UTF-8 challenge -const goodOpts2UTF8: PublicKeyCredentialRequestOptionsJSON = { - challenge: bufferToBase64URLString(utf8StringToBuffer('やれやれだぜ')), - allowCredentials: [], - timeout: 1, -}; - -beforeEach(() => { - // Stub out a response so the method won't throw - mockNavigatorGet.mockImplementation( - (): Promise => { - return new Promise(resolve => { - resolve({ - response: {}, - getClientExtensionResults: () => ({}), - }); - }); - }, - ); - - mockSupportsWebauthn.mockReturnValue(true); -}); - -afterEach(() => { - mockNavigatorGet.mockReset(); - mockSupportsWebauthn.mockReset(); -}); - -test('should convert options before passing to navigator.credentials.get(...)', async done => { - await startAssertion(goodOpts1); - - const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; - const credId = argsPublicKey.allowCredentials[0].id; - - expect(new Uint8Array(argsPublicKey.challenge)).toEqual(new Uint8Array([102, 105, 122, 122])); - // Make sure the credential ID is an ArrayBuffer with a length of 64 - expect(credId instanceof ArrayBuffer).toEqual(true); - expect(credId.byteLength).toEqual(64); - - done(); -}); - -test('should support optional allowCredential', async () => { - await startAssertion({ - challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), - timeout: 1, - }); - - expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); -}); - -test('should convert allow allowCredential to undefined when empty', async () => { - await startAssertion({ - challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), - timeout: 1, - allowCredentials: [], - }); - expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); -}); - -test('should return base64url-encoded response values', async done => { - mockNavigatorGet.mockImplementation( - (): Promise => { - return new Promise(resolve => { - resolve({ - id: 'foobar', - rawId: Buffer.from('foobar', 'ascii'), - response: { - authenticatorData: Buffer.from(mockAuthenticatorData, 'ascii'), - clientDataJSON: Buffer.from(mockClientDataJSON, 'ascii'), - signature: Buffer.from(mockSignature, 'ascii'), - userHandle: Buffer.from(mockUserHandle, 'ascii'), - }, - getClientExtensionResults: () => ({}), - type: 'webauthn.get', - }); - }); - }, - ); - - const response = await startAssertion(goodOpts1); - - expect(response.rawId).toEqual('Zm9vYmFy'); - expect(response.response.authenticatorData).toEqual('bW9ja0F1dGhlbnRpY2F0b3JEYXRh'); - expect(response.response.clientDataJSON).toEqual('bW9ja0NsaWVudERhdGFKU09O'); - expect(response.response.signature).toEqual('bW9ja1NpZ25hdHVyZQ'); - expect(response.response.userHandle).toEqual('mockUserHandle'); - - done(); -}); - -test("should throw error if WebAuthn isn't supported", async done => { - mockSupportsWebauthn.mockReturnValue(false); - - await expect(startAssertion(goodOpts1)).rejects.toThrow( - 'WebAuthn is not supported in this browser', - ); - - done(); -}); - -test('should throw error if assertion is cancelled for some reason', async done => { - mockNavigatorGet.mockImplementation( - (): Promise => { - return new Promise(resolve => { - resolve(null); - }); - }, - ); - - await expect(startAssertion(goodOpts1)).rejects.toThrow('Assertion was not completed'); - - done(); -}); - -test('should handle UTF-8 challenges', async done => { - await startAssertion(goodOpts2UTF8); - - const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; - - expect(new Uint8Array(argsPublicKey.challenge)).toEqual( - new Uint8Array([ - 227, - 130, - 132, - 227, - 130, - 140, - 227, - 130, - 132, - 227, - 130, - 140, - 227, - 129, - 160, - 227, - 129, - 156, - ]), - ); - - done(); -}); - -test('should send extensions to authenticator if present in options', async done => { - const extensions: AuthenticationExtensionsClientInputs = { - credProps: true, - appid: 'appidHere', - uvm: true, - appidExclude: 'appidExcludeHere', - }; - const optsWithExts: PublicKeyCredentialRequestOptionsJSON = { - ...goodOpts1, - extensions, - }; - await startAssertion(optsWithExts); - - const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; - - expect(argsExtensions).toEqual(extensions); - - done(); -}); - -test('should not set any extensions if not present in options', async done => { - await startAssertion(goodOpts1); - - const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; - - expect(argsExtensions).toEqual(undefined); - - done(); -}); - -test('should include extension results', async done => { - const extResults: AuthenticationExtensionsClientOutputs = { - appid: true, - credProps: { - rk: true, - }, - }; - - // Mock extension return values from authenticator - mockNavigatorGet.mockImplementation( - (): Promise => { - return new Promise(resolve => { - resolve({ response: {}, getClientExtensionResults: () => extResults }); - }); - }, - ); - - // Extensions aren't present in this object, but it doesn't matter since we're faking the response - const response = await startAssertion(goodOpts1); - - expect(response.clientExtensionResults).toEqual(extResults); - - done(); -}); - -test('should include extension results when no extensions specified', async done => { - const response = await startAssertion(goodOpts1); - - expect(response.clientExtensionResults).toEqual({}); - - done(); -}); diff --git a/packages/browser/src/methods/startAssertion.ts b/packages/browser/src/methods/startAssertion.ts deleted file mode 100644 index 786d55b..0000000 --- a/packages/browser/src/methods/startAssertion.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - PublicKeyCredentialRequestOptionsJSON, - AssertionCredential, - AssertionCredentialJSON, -} from '@simplewebauthn/typescript-types'; - -import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; -import base64URLStringToBuffer from '../helpers/base64URLStringToBuffer'; -import bufferToUTF8String from '../helpers/bufferToUTF8String'; -import supportsWebauthn from '../helpers/supportsWebauthn'; -import toPublicKeyCredentialDescriptor from '../helpers/toPublicKeyCredentialDescriptor'; - -/** - * Begin authenticator "login" via WebAuthn assertion - * - * @param requestOptionsJSON Output from @simplewebauthn/server's generateAssertionOptions(...) - */ -export default async function startAssertion( - requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON, -): Promise { - if (!supportsWebauthn()) { - throw new Error('WebAuthn is not supported in this browser'); - } - - // We need to avoid passing empty array to avoid blocking retrieval - // of public key - let allowCredentials; - if (requestOptionsJSON.allowCredentials?.length !== 0) { - allowCredentials = requestOptionsJSON.allowCredentials?.map(toPublicKeyCredentialDescriptor); - } - - // We need to convert some values to Uint8Arrays before passing the credentials to the navigator - const publicKey: PublicKeyCredentialRequestOptions = { - ...requestOptionsJSON, - challenge: base64URLStringToBuffer(requestOptionsJSON.challenge), - allowCredentials, - }; - - // Wait for the user to complete assertion - const credential = (await navigator.credentials.get({ publicKey })) as AssertionCredential; - - if (!credential) { - throw new Error('Assertion was not completed'); - } - - const { id, rawId, response, type } = credential; - - let userHandle = undefined; - if (response.userHandle) { - userHandle = bufferToUTF8String(response.userHandle); - } - - // Convert values to base64 to make it easier to send back to the server - return { - id, - rawId: bufferToBase64URLString(rawId), - response: { - authenticatorData: bufferToBase64URLString(response.authenticatorData), - clientDataJSON: bufferToBase64URLString(response.clientDataJSON), - signature: bufferToBase64URLString(response.signature), - userHandle, - }, - type, - clientExtensionResults: credential.getClientExtensionResults(), - }; -} diff --git a/packages/browser/src/methods/startAuthentication.test.ts b/packages/browser/src/methods/startAuthentication.test.ts new file mode 100644 index 0000000..96b140c --- /dev/null +++ b/packages/browser/src/methods/startAuthentication.test.ts @@ -0,0 +1,219 @@ +import { + AuthenticationCredential, + PublicKeyCredentialRequestOptionsJSON, + AuthenticationExtensionsClientInputs, + AuthenticationExtensionsClientOutputs, +} from '@simplewebauthn/typescript-types'; + +import supportsWebauthn from '../helpers/supportsWebauthn'; +import utf8StringToBuffer from '../helpers/utf8StringToBuffer'; +import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; + +import startAuthentication from './startAuthentication'; + +jest.mock('../helpers/supportsWebauthn'); + +const mockNavigatorGet = window.navigator.credentials.get as jest.Mock; +const mockSupportsWebauthn = supportsWebauthn as jest.Mock; + +const mockAuthenticatorData = 'mockAuthenticatorData'; +const mockClientDataJSON = 'mockClientDataJSON'; +const mockSignature = 'mockSignature'; +const mockUserHandle = 'mockUserHandle'; + +// With ASCII challenge +const goodOpts1: PublicKeyCredentialRequestOptionsJSON = { + challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), + allowCredentials: [ + { + id: 'C0VGlvYFratUdAV1iCw-ULpUW8E-exHPXQChBfyVeJZCMfjMFcwDmOFgoMUz39LoMtCJUBW8WPlLkGT6q8qTCg', + type: 'public-key', + transports: ['nfc'], + }, + ], + timeout: 1, +}; + +// With UTF-8 challenge +const goodOpts2UTF8: PublicKeyCredentialRequestOptionsJSON = { + challenge: bufferToBase64URLString(utf8StringToBuffer('やれやれだぜ')), + allowCredentials: [], + timeout: 1, +}; + +beforeEach(() => { + // Stub out a response so the method won't throw + mockNavigatorGet.mockImplementation((): Promise => { + return new Promise(resolve => { + resolve({ + response: {}, + getClientExtensionResults: () => ({}), + }); + }); + }); + + mockSupportsWebauthn.mockReturnValue(true); +}); + +afterEach(() => { + mockNavigatorGet.mockReset(); + mockSupportsWebauthn.mockReset(); +}); + +test('should convert options before passing to navigator.credentials.get(...)', async done => { + await startAuthentication(goodOpts1); + + const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; + const credId = argsPublicKey.allowCredentials[0].id; + + expect(new Uint8Array(argsPublicKey.challenge)).toEqual(new Uint8Array([102, 105, 122, 122])); + // Make sure the credential ID is an ArrayBuffer with a length of 64 + expect(credId instanceof ArrayBuffer).toEqual(true); + expect(credId.byteLength).toEqual(64); + + done(); +}); + +test('should support optional allowCredential', async () => { + await startAuthentication({ + challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), + timeout: 1, + }); + + expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); +}); + +test('should convert allow allowCredential to undefined when empty', async () => { + await startAuthentication({ + challenge: bufferToBase64URLString(utf8StringToBuffer('fizz')), + timeout: 1, + allowCredentials: [], + }); + expect(mockNavigatorGet.mock.calls[0][0].allowCredentials).toEqual(undefined); +}); + +test('should return base64url-encoded response values', async done => { + mockNavigatorGet.mockImplementation((): Promise => { + return new Promise(resolve => { + resolve({ + id: 'foobar', + rawId: Buffer.from('foobar', 'ascii'), + response: { + authenticatorData: Buffer.from(mockAuthenticatorData, 'ascii'), + clientDataJSON: Buffer.from(mockClientDataJSON, 'ascii'), + signature: Buffer.from(mockSignature, 'ascii'), + userHandle: Buffer.from(mockUserHandle, 'ascii'), + }, + getClientExtensionResults: () => ({}), + type: 'webauthn.get', + }); + }); + }); + + const response = await startAuthentication(goodOpts1); + + expect(response.rawId).toEqual('Zm9vYmFy'); + expect(response.response.authenticatorData).toEqual('bW9ja0F1dGhlbnRpY2F0b3JEYXRh'); + expect(response.response.clientDataJSON).toEqual('bW9ja0NsaWVudERhdGFKU09O'); + expect(response.response.signature).toEqual('bW9ja1NpZ25hdHVyZQ'); + expect(response.response.userHandle).toEqual('mockUserHandle'); + + done(); +}); + +test("should throw error if WebAuthn isn't supported", async done => { + mockSupportsWebauthn.mockReturnValue(false); + + await expect(startAuthentication(goodOpts1)).rejects.toThrow( + 'WebAuthn is not supported in this browser', + ); + + done(); +}); + +test('should throw error if assertion is cancelled for some reason', async done => { + mockNavigatorGet.mockImplementation((): Promise => { + return new Promise(resolve => { + resolve(null); + }); + }); + + await expect(startAuthentication(goodOpts1)).rejects.toThrow('Assertion was not completed'); + + done(); +}); + +test('should handle UTF-8 challenges', async done => { + await startAuthentication(goodOpts2UTF8); + + const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; + + expect(new Uint8Array(argsPublicKey.challenge)).toEqual( + new Uint8Array([ + 227, 130, 132, 227, 130, 140, 227, 130, 132, 227, 130, 140, 227, 129, 160, 227, 129, 156, + ]), + ); + + done(); +}); + +test('should send extensions to authenticator if present in options', async done => { + const extensions: AuthenticationExtensionsClientInputs = { + credProps: true, + appid: 'appidHere', + uvm: true, + appidExclude: 'appidExcludeHere', + }; + const optsWithExts: PublicKeyCredentialRequestOptionsJSON = { + ...goodOpts1, + extensions, + }; + await startAuthentication(optsWithExts); + + const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; + + expect(argsExtensions).toEqual(extensions); + + done(); +}); + +test('should not set any extensions if not present in options', async done => { + await startAuthentication(goodOpts1); + + const argsExtensions = mockNavigatorGet.mock.calls[0][0].publicKey.extensions; + + expect(argsExtensions).toEqual(undefined); + + done(); +}); + +test('should include extension results', async done => { + const extResults: AuthenticationExtensionsClientOutputs = { + appid: true, + credProps: { + rk: true, + }, + }; + + // Mock extension return values from authenticator + mockNavigatorGet.mockImplementation((): Promise => { + return new Promise(resolve => { + resolve({ response: {}, getClientExtensionResults: () => extResults }); + }); + }); + + // Extensions aren't present in this object, but it doesn't matter since we're faking the response + const response = await startAuthentication(goodOpts1); + + expect(response.clientExtensionResults).toEqual(extResults); + + done(); +}); + +test('should include extension results when no extensions specified', async done => { + const response = await startAuthentication(goodOpts1); + + expect(response.clientExtensionResults).toEqual({}); + + done(); +}); diff --git a/packages/browser/src/methods/startAuthentication.ts b/packages/browser/src/methods/startAuthentication.ts new file mode 100644 index 0000000..1764ff1 --- /dev/null +++ b/packages/browser/src/methods/startAuthentication.ts @@ -0,0 +1,66 @@ +import { + PublicKeyCredentialRequestOptionsJSON, + AuthenticationCredential, + AuthenticationCredentialJSON, +} from '@simplewebauthn/typescript-types'; + +import bufferToBase64URLString from '../helpers/bufferToBase64URLString'; +import base64URLStringToBuffer from '../helpers/base64URLStringToBuffer'; +import bufferToUTF8String from '../helpers/bufferToUTF8String'; +import supportsWebauthn from '../helpers/supportsWebauthn'; +import toPublicKeyCredentialDescriptor from '../helpers/toPublicKeyCredentialDescriptor'; + +/** + * Begin authenticator "login" via WebAuthn assertion + * + * @param requestOptionsJSON Output from @simplewebauthn/server's generateAssertionOptions(...) + */ +export default async function startAssertion( + requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON, +): Promise { + if (!supportsWebauthn()) { + throw new Error('WebAuthn is not supported in this browser'); + } + + // We need to avoid passing empty array to avoid blocking retrieval + // of public key + let allowCredentials; + if (requestOptionsJSON.allowCredentials?.length !== 0) { + allowCredentials = requestOptionsJSON.allowCredentials?.map(toPublicKeyCredentialDescriptor); + } + + // We need to convert some values to Uint8Arrays before passing the credentials to the navigator + const publicKey: PublicKeyCredentialRequestOptions = { + ...requestOptionsJSON, + challenge: base64URLStringToBuffer(requestOptionsJSON.challenge), + allowCredentials, + }; + + // Wait for the user to complete assertion + const credential = (await navigator.credentials.get({ publicKey })) as AuthenticationCredential; + + if (!credential) { + throw new Error('Assertion was not completed'); + } + + const { id, rawId, response, type } = credential; + + let userHandle = undefined; + if (response.userHandle) { + userHandle = bufferToUTF8String(response.userHandle); + } + + // Convert values to base64 to make it easier to send back to the server + return { + id, + rawId: bufferToBase64URLString(rawId), + response: { + authenticatorData: bufferToBase64URLString(response.authenticatorData), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + signature: bufferToBase64URLString(response.signature), + userHandle, + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + }; +} -- cgit v1.2.3