diff options
author | Matthew Miller <matthew@millerti.me> | 2020-05-22 10:34:40 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-22 10:34:40 -0700 |
commit | 27d2104bfd297ac6e6e6ffe17ad12ad9dcd1bd3d (patch) | |
tree | c6c005074f78ef079d1195acbf03f6abd31bc562 /packages/browser/src | |
parent | d9074ec54935aa2155151d2dd9dea0974f33da29 (diff) | |
parent | 1038e1a9bb04a1b638248ff6cf7d57c21304a668 (diff) |
Merge pull request #1 from MasterKale/feature/lerna
feature/monorepo
Diffstat (limited to 'packages/browser/src')
-rw-r--r-- | packages/browser/src/helpers/__mocks__/supportsWebauthn.ts | 2 | ||||
-rw-r--r-- | packages/browser/src/helpers/supportsWebauthn.test.ts | 15 | ||||
-rw-r--r-- | packages/browser/src/helpers/supportsWebauthn.ts | 9 | ||||
-rw-r--r-- | packages/browser/src/helpers/toBase64String.ts | 9 | ||||
-rw-r--r-- | packages/browser/src/helpers/toUint8Array.ts | 7 | ||||
-rw-r--r-- | packages/browser/src/index.test.ts | 13 | ||||
-rw-r--r-- | packages/browser/src/index.ts | 9 | ||||
-rw-r--r-- | packages/browser/src/methods/startAssertion.test.ts | 111 | ||||
-rw-r--r-- | packages/browser/src/methods/startAssertion.ts | 54 | ||||
-rw-r--r-- | packages/browser/src/methods/startAttestation.test.ts | 112 | ||||
-rw-r--r-- | packages/browser/src/methods/startAttestation.ts | 47 | ||||
-rw-r--r-- | packages/browser/src/setupTests.ts | 16 |
12 files changed, 404 insertions, 0 deletions
diff --git a/packages/browser/src/helpers/__mocks__/supportsWebauthn.ts b/packages/browser/src/helpers/__mocks__/supportsWebauthn.ts new file mode 100644 index 0000000..b6b47d4 --- /dev/null +++ b/packages/browser/src/helpers/__mocks__/supportsWebauthn.ts @@ -0,0 +1,2 @@ +// We just need a simple mock so we can control whether this returns `true` or `false` +export default jest.fn(); diff --git a/packages/browser/src/helpers/supportsWebauthn.test.ts b/packages/browser/src/helpers/supportsWebauthn.test.ts new file mode 100644 index 0000000..078c27d --- /dev/null +++ b/packages/browser/src/helpers/supportsWebauthn.test.ts @@ -0,0 +1,15 @@ +import supportsWebauthn from './supportsWebauthn'; + +beforeEach(() => { + // @ts-ignore 2741 + window.PublicKeyCredential = jest.fn().mockReturnValue(() => {}); +}); + +test('should return true when browser supports WebAuthn', () => { + expect(supportsWebauthn()).toBe(true); +}); + +test('should return false when browser does not support WebAuthn', () => { + delete window.PublicKeyCredential; + expect(supportsWebauthn()).toBe(false); +}); diff --git a/packages/browser/src/helpers/supportsWebauthn.ts b/packages/browser/src/helpers/supportsWebauthn.ts new file mode 100644 index 0000000..f566942 --- /dev/null +++ b/packages/browser/src/helpers/supportsWebauthn.ts @@ -0,0 +1,9 @@ +/** + * Determine if the browser is capable of Webauthn + */ +export default function supportsWebauthn(): boolean { + return ( + window.PublicKeyCredential !== undefined + && typeof window.PublicKeyCredential === 'function' + ); +} diff --git a/packages/browser/src/helpers/toBase64String.ts b/packages/browser/src/helpers/toBase64String.ts new file mode 100644 index 0000000..8234002 --- /dev/null +++ b/packages/browser/src/helpers/toBase64String.ts @@ -0,0 +1,9 @@ +import base64js from 'base64-js'; + +export default function toBase64String(buffer: ArrayBuffer): string { + // TODO: Make sure converting buffer to Uint8Array() is correct + return base64js.fromByteArray(new Uint8Array(buffer)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} diff --git a/packages/browser/src/helpers/toUint8Array.ts b/packages/browser/src/helpers/toUint8Array.ts new file mode 100644 index 0000000..a807a88 --- /dev/null +++ b/packages/browser/src/helpers/toUint8Array.ts @@ -0,0 +1,7 @@ +/** + * A helper method to convert a string sent from the server to a Uint8Array the authenticator will + * expect. + */ +export default function toUint8Array(value: string): Uint8Array { + return Uint8Array.from(value, c => c.charCodeAt(0)); +} diff --git a/packages/browser/src/index.test.ts b/packages/browser/src/index.test.ts new file mode 100644 index 0000000..0d132ba --- /dev/null +++ b/packages/browser/src/index.test.ts @@ -0,0 +1,13 @@ +import * as index from './index'; + +test('should export method `startAttestation`', () => { + expect(index.startAttestation).toBeDefined(); +}); + +test('should export method `startAssertion`', () => { + expect(index.startAssertion).toBeDefined(); +}); + +test('should export method `supportsWebauthn`', () => { + expect(index.supportsWebauthn).toBeDefined(); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 0000000..38ce91b --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1,9 @@ +import startAttestation from './methods/startAttestation'; +import startAssertion from './methods/startAssertion'; +import supportsWebauthn from './helpers/supportsWebauthn'; + +export { + startAttestation, + startAssertion, + supportsWebauthn, +}; diff --git a/packages/browser/src/methods/startAssertion.test.ts b/packages/browser/src/methods/startAssertion.test.ts new file mode 100644 index 0000000..b069f60 --- /dev/null +++ b/packages/browser/src/methods/startAssertion.test.ts @@ -0,0 +1,111 @@ +import base64js from 'base64-js'; + +import { AssertionCredential, PublicKeyCredentialRequestOptionsJSON } from '@webauthntine/typescript-types'; + +import toUint8Array from '../helpers/toUint8Array'; +import supportsWebauthn from '../helpers/supportsWebauthn'; + +import startAssertion from './startAssertion'; + +jest.mock('../helpers/supportsWebauthn'); + +const mockNavigatorGet = (window.navigator.credentials.get as jest.Mock); +const mockSupportsWebauthn = (supportsWebauthn as jest.Mock); + +const mockAttestationObject = 'mockAsse'; +const mockClientDataJSON = 'mockClie'; +const mockSignature = 'mockSign'; +const mockUserHandle = 'mockUser'; + +const goodOpts1: PublicKeyCredentialRequestOptionsJSON = { + publicKey: { + challenge: 'fizz', + allowCredentials: [{ + id: 'credId', + type: 'public-key', + transports: ['nfc'], + }], + timeout: 1, + }, +}; + +beforeEach(() => { + mockNavigatorGet.mockReset(); + mockSupportsWebauthn.mockReset(); +}); + +test('should convert options before passing to navigator.credentials.get(...)', async (done) => { + mockSupportsWebauthn.mockReturnValue(true); + + // Stub out a response so the method won't throw + mockNavigatorGet.mockImplementation((): Promise<any> => { + return new Promise((resolve) => { + resolve({ response: {} }); + }); + }); + + await startAssertion(goodOpts1); + + const argsPublicKey = mockNavigatorGet.mock.calls[0][0].publicKey; + + expect(argsPublicKey.challenge).toEqual(toUint8Array(goodOpts1.publicKey.challenge)); + expect(argsPublicKey.allowCredentials[0].id).toEqual( + toUint8Array(goodOpts1.publicKey.allowCredentials[0].id), + ); + + done(); +}); + +test('should return base64-encoded response values', async (done) => { + mockSupportsWebauthn.mockReturnValue(true); + + mockNavigatorGet.mockImplementation((): Promise<AssertionCredential> => { + return new Promise((resolve) => { + resolve({ + id: 'foobar', + rawId: toUint8Array('foobar'), + response: { + clientDataJSON: base64js.toByteArray(mockClientDataJSON), + authenticatorData: base64js.toByteArray(mockClientDataJSON), + signature: base64js.toByteArray(mockSignature), + userHandle: base64js.toByteArray(mockUserHandle), + }, + getClientExtensionResults: () => ({}), + type: 'webauthn.get', + }); + }); + }); + + const response = await startAssertion(goodOpts1); + + expect(response).toEqual({ + base64AuthenticatorData: mockClientDataJSON, + base64ClientDataJSON: mockClientDataJSON, + base64Signature: mockSignature, + base64UserHandle: 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) => { + mockSupportsWebauthn.mockReturnValue(true); + + mockNavigatorGet.mockImplementation((): Promise<null> => { + return new Promise((resolve) => { + resolve(null); + }); + }); + + await expect(startAssertion(goodOpts1)).rejects.toThrow('Assertion was not completed'); + + done(); +}); diff --git a/packages/browser/src/methods/startAssertion.ts b/packages/browser/src/methods/startAssertion.ts new file mode 100644 index 0000000..603c6fb --- /dev/null +++ b/packages/browser/src/methods/startAssertion.ts @@ -0,0 +1,54 @@ +import { + PublicKeyCredentialRequestOptionsJSON, + AuthenticatorAssertionResponseJSON, + AssertionCredential, +} from '@webauthntine/typescript-types'; + +import toUint8Array from '../helpers/toUint8Array'; +import toBase64String from '../helpers/toBase64String'; +import supportsWebauthn from '../helpers/supportsWebauthn'; + +/** + * Begin authenticator "login" via WebAuthn assertion + * + * @param requestOptionsJSON Output from @webauthntine/server's generateAssertionOptions(...) + */ +export default async function startAssertion( + requestOptionsJSON: PublicKeyCredentialRequestOptionsJSON +): Promise<AuthenticatorAssertionResponseJSON> { + if (!supportsWebauthn()) { + throw new Error('WebAuthn is not supported in this browser'); + } + + // We need to convert some values to Uint8Arrays before passing the credentials to the navigator + const publicKey: PublicKeyCredentialRequestOptions = { + ...requestOptionsJSON.publicKey, + challenge: toUint8Array(requestOptionsJSON.publicKey.challenge), + allowCredentials: requestOptionsJSON.publicKey.allowCredentials.map((cred) => ({ + ...cred, + id: toUint8Array(cred.id), + })) + }; + + // Wait for the user to complete assertion + const credential = await navigator.credentials.get({ publicKey }); + + if (!credential) { + throw new Error('Assertion was not completed'); + } + + const { response } = (credential as AssertionCredential); + + let base64UserHandle = undefined; + if (response.userHandle) { + base64UserHandle = toBase64String(response.userHandle); + } + + // Convert values to base64 to make it easier to send back to the server + return { + base64AuthenticatorData: toBase64String(response.authenticatorData), + base64ClientDataJSON: toBase64String(response.clientDataJSON), + base64Signature: toBase64String(response.signature), + base64UserHandle, + }; +} diff --git a/packages/browser/src/methods/startAttestation.test.ts b/packages/browser/src/methods/startAttestation.test.ts new file mode 100644 index 0000000..0efec48 --- /dev/null +++ b/packages/browser/src/methods/startAttestation.test.ts @@ -0,0 +1,112 @@ +import base64js from 'base64-js'; + +import { AttestationCredential, PublicKeyCredentialCreationOptionsJSON } from '@webauthntine/typescript-types'; + +import toUint8Array from '../helpers/toUint8Array'; +import supportsWebauthn from '../helpers/supportsWebauthn'; + +import startAttestation from './startAttestation'; + +jest.mock('../helpers/supportsWebauthn'); + +const mockNavigatorCreate = (window.navigator.credentials.create as jest.Mock); +const mockSupportsWebauthn = (supportsWebauthn as jest.Mock); + +const mockAttestationObject = 'mockAtte'; +const mockClientDataJSON = 'mockClie'; + +const goodOpts1: PublicKeyCredentialCreationOptionsJSON = { + publicKey: { + challenge: 'fizz', + attestation: 'direct', + pubKeyCredParams: [{ + alg: -7, + type: "public-key", + }], + rp: { + id: '1234', + name: 'webauthntine', + }, + user: { + id: '5678', + displayName: 'username', + name: 'username', + }, + timeout: 1, + }, +}; + +beforeEach(() => { + mockNavigatorCreate.mockReset(); + mockSupportsWebauthn.mockReset(); +}); + +test('should convert options before passing to navigator.credentials.create(...)', async (done) => { + mockSupportsWebauthn.mockReturnValue(true); + + // Stub out a response so the method won't throw + mockNavigatorCreate.mockImplementation((): Promise<any> => { + return new Promise((resolve) => { + resolve({ response: {} }); + }); + }); + + await startAttestation(goodOpts1); + + const argsPublicKey = mockNavigatorCreate.mock.calls[0][0].publicKey; + + expect(argsPublicKey.challenge).toEqual(toUint8Array(goodOpts1.publicKey.challenge)); + expect(argsPublicKey.user.id).toEqual(toUint8Array(goodOpts1.publicKey.user.id)); + + done(); +}); + +test('should return base64-encoded response values', async (done) => { + mockSupportsWebauthn.mockReturnValue(true); + + mockNavigatorCreate.mockImplementation((): Promise<AttestationCredential> => { + return new Promise((resolve) => { + resolve({ + id: 'foobar', + rawId: toUint8Array('foobar'), + response: { + attestationObject: base64js.toByteArray(mockAttestationObject), + clientDataJSON: base64js.toByteArray(mockClientDataJSON), + }, + getClientExtensionResults: () => ({}), + type: 'webauthn.create', + }); + }); + }); + + const response = await startAttestation(goodOpts1); + + expect(response).toEqual({ + base64AttestationObject: mockAttestationObject, + base64ClientDataJSON: mockClientDataJSON, + }); + + done(); +}) + +test('should throw error if WebAuthn isn\'t supported', async (done) => { + mockSupportsWebauthn.mockReturnValue(false); + + await expect(startAttestation(goodOpts1)).rejects.toThrow('WebAuthn is not supported in this browser'); + + done(); +}); + +test('should throw error if attestation is cancelled for some reason', async (done) => { + mockSupportsWebauthn.mockReturnValue(true); + + mockNavigatorCreate.mockImplementation((): Promise<null> => { + return new Promise((resolve) => { + resolve(null); + }); + }); + + await expect(startAttestation(goodOpts1)).rejects.toThrow('Attestation was not completed'); + + done(); +}); diff --git a/packages/browser/src/methods/startAttestation.ts b/packages/browser/src/methods/startAttestation.ts new file mode 100644 index 0000000..1a4b13d --- /dev/null +++ b/packages/browser/src/methods/startAttestation.ts @@ -0,0 +1,47 @@ +import { + PublicKeyCredentialCreationOptionsJSON, + AuthenticatorAttestationResponseJSON, + AttestationCredential, +} from '@webauthntine/typescript-types'; + +import toUint8Array from '../helpers/toUint8Array'; +import toBase64String from '../helpers/toBase64String'; +import supportsWebauthn from '../helpers/supportsWebauthn'; + +/** + * Begin authenticator "registration" via WebAuthn attestation + * + * @param creationOptionsJSON Output from @webauthntine/server's generateAttestationOptions(...) + */ +export default async function startAttestation( + creationOptionsJSON: PublicKeyCredentialCreationOptionsJSON +): Promise<AuthenticatorAttestationResponseJSON> { + if (!supportsWebauthn()) { + throw new Error('WebAuthn is not supported in this browser'); + } + + // We need to convert some values to Uint8Arrays before passing the credentials to the navigator + const publicKey: PublicKeyCredentialCreationOptions = { + ...creationOptionsJSON.publicKey, + challenge: toUint8Array(creationOptionsJSON.publicKey.challenge), + user: { + ...creationOptionsJSON.publicKey.user, + id: toUint8Array(creationOptionsJSON.publicKey.user.id), + }, + }; + + // Wait for the user to complete attestation + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + throw new Error('Attestation was not completed'); + } + + const { response } = (credential as AttestationCredential); + + // Convert values to base64 to make it easier to send back to the server + return { + base64AttestationObject: toBase64String(response.attestationObject), + base64ClientDataJSON: toBase64String(response.clientDataJSON), + }; +} diff --git a/packages/browser/src/setupTests.ts b/packages/browser/src/setupTests.ts new file mode 100644 index 0000000..019ba42 --- /dev/null +++ b/packages/browser/src/setupTests.ts @@ -0,0 +1,16 @@ +// Silence some console output +// jest.spyOn(console, 'log').mockImplementation(); +// jest.spyOn(console, 'debug').mockImplementation(); +// jest.spyOn(console, 'error').mockImplementation(); + +/** + * JSDom doesn't seem to support `credentials`, so let's define them here so we can mock their + * implementations in specific tests. + */ +// @ts-ignore 2540 +window.navigator.credentials = { + // attestation + create: jest.fn(), + // assertion + get: jest.fn(), +}; |