diff --git a/app/javascript/app/README.md b/app/javascript/app/README.md deleted file mode 100644 index 3e03ac81308..00000000000 --- a/app/javascript/app/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# App Scripts - -This folder contains legacy JavaScript which hasn't yet been migrated to the [`packages`-based](../packages) implementation standard. It exists from a time when all JavaScript was shipped in a single, monolithic application bundle. To better support scalability, it is now recommended to split JavaScript to smaller [`packs`](../packs) which are loaded in service of individual features, components, or pages. - -Avoid adding new code to this folder. Whenever possible, updates to existing files should attempt to migrate those files to a `package` implementation. diff --git a/app/javascript/packages/webauthn/enroll-webauthn-device.ts b/app/javascript/packages/webauthn/enroll-webauthn-device.ts index 9e5f02b418b..024f266b18c 100644 --- a/app/javascript/packages/webauthn/enroll-webauthn-device.ts +++ b/app/javascript/packages/webauthn/enroll-webauthn-device.ts @@ -1,6 +1,6 @@ import { arrayBufferToBase64 } from './converters'; -interface EnnrollOptions { +interface EnrollOptions { user: PublicKeyCredentialUserEntity; challenge: BufferSource; @@ -25,7 +25,7 @@ async function enrollWebauthnDevice({ challenge, excludeCredentials, authenticatorAttachment, -}: EnnrollOptions): Promise { +}: EnrollOptions): Promise { const credential = (await navigator.credentials.create({ publicKey: { challenge, diff --git a/app/javascript/packages/webauthn/index.ts b/app/javascript/packages/webauthn/index.ts index 76be832918d..203216c740c 100644 --- a/app/javascript/packages/webauthn/index.ts +++ b/app/javascript/packages/webauthn/index.ts @@ -1,4 +1,5 @@ export { default as isWebauthnSupported } from './is-webauthn-supported'; export { default as enrollWebauthnDevice } from './enroll-webauthn-device'; export { default as extractCredentials } from './extract-credentials'; +export { default as verifyWebauthnDevice } from './verify-webauthn-device'; export * from './converters'; diff --git a/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts b/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts new file mode 100644 index 00000000000..341a57bdfec --- /dev/null +++ b/app/javascript/packages/webauthn/verify-webauthn-device.spec.ts @@ -0,0 +1,90 @@ +import { TextEncoder } from 'util'; +import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers'; +import verifyWebauthnDevice from './verify-webauthn-device'; + +describe('verifyWebauthnDevice', () => { + const sandbox = useSandbox(); + const defineProperty = useDefineProperty(); + + const userChallenge = '[1, 2, 3, 4, 5, 6, 7, 8]'; + const credentialIds = [btoa('credential123'), btoa('credential456')].join(','); + + context('webauthn api resolves credential', () => { + beforeEach(() => { + defineProperty(navigator, 'credentials', { + configurable: true, + value: { + get: sandbox.stub().resolves({ + rawId: Buffer.from('123', 'utf-8'), + response: { + authenticatorData: Buffer.from('auth', 'utf-8'), + clientDataJSON: Buffer.from('json', 'utf-8'), + signature: Buffer.from('sig', 'utf-8'), + }, + }), + }, + }); + }); + + it('resolves to credential', async () => { + const expectedGetOptions = { + publicKey: { + challenge: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), + rpId: 'example.test', + allowCredentials: [ + { + id: new TextEncoder().encode('credential123').buffer, + type: 'public-key', + }, + { + id: new TextEncoder().encode('credential456').buffer, + type: 'public-key', + }, + ], + timeout: 800000, + }, + }; + + const result = await verifyWebauthnDevice({ + userChallenge, + credentialIds, + }); + + expect(navigator.credentials.get).to.have.been.calledWith(expectedGetOptions); + expect(result).to.deep.equal({ + credentialId: btoa('123'), + authenticatorData: btoa('auth'), + clientDataJSON: btoa('json'), + signature: btoa('sig'), + }); + }); + }); + + context('webauthn rejects with an error', () => { + const authError = new Error(); + + beforeEach(() => { + defineProperty(navigator, 'credentials', { + configurable: true, + value: { + get: sandbox.stub().rejects(authError), + }, + }); + }); + + it('forwards errors', async () => { + let didCatch; + try { + await verifyWebauthnDevice({ + userChallenge, + credentialIds, + }); + } catch (error) { + expect(error).to.equal(error); + didCatch = true; + } + + expect(didCatch).to.be.true(); + }); + }); +}); diff --git a/app/javascript/app/webauthn.ts b/app/javascript/packages/webauthn/verify-webauthn-device.ts similarity index 61% rename from app/javascript/app/webauthn.ts rename to app/javascript/packages/webauthn/verify-webauthn-device.ts index 3fbeb52b998..df18445814c 100644 --- a/app/javascript/app/webauthn.ts +++ b/app/javascript/packages/webauthn/verify-webauthn-device.ts @@ -1,4 +1,11 @@ -import { extractCredentials, arrayBufferToBase64 } from '@18f/identity-webauthn'; +import { arrayBufferToBase64 } from './converters'; +import extractCredentials from './extract-credentials'; + +interface VerifyOptions { + userChallenge: string; + + credentialIds: string; +} interface VerifyResult { credentialId: string; @@ -13,29 +20,24 @@ interface VerifyResult { async function verifyWebauthnDevice({ userChallenge, credentialIds, -}: { - userChallenge: string; - credentialIds: string; -}): Promise { - const getOptions = { +}: VerifyOptions): Promise { + const credential = (await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(JSON.parse(userChallenge)), rpId: window.location.hostname, allowCredentials: extractCredentials(credentialIds.split(',').filter(Boolean)), timeout: 800000, }, - }; - - const newCred = (await navigator.credentials.get(getOptions)) as PublicKeyCredential; + })) as PublicKeyCredential; - const response = newCred.response as AuthenticatorAssertionResponse; + const response = credential.response as AuthenticatorAssertionResponse; return { - credentialId: arrayBufferToBase64(newCred.rawId), + credentialId: arrayBufferToBase64(credential.rawId), authenticatorData: arrayBufferToBase64(response.authenticatorData), clientDataJSON: arrayBufferToBase64(response.clientDataJSON), signature: arrayBufferToBase64(response.signature), }; } -export { verifyWebauthnDevice }; +export default verifyWebauthnDevice; diff --git a/app/javascript/packs/webauthn-authenticate.ts b/app/javascript/packs/webauthn-authenticate.ts index f172c77ca01..0932fd27211 100644 --- a/app/javascript/packs/webauthn-authenticate.ts +++ b/app/javascript/packs/webauthn-authenticate.ts @@ -1,5 +1,4 @@ -import { isWebauthnSupported } from '@18f/identity-webauthn'; -import * as WebAuthn from '../app/webauthn'; +import { isWebauthnSupported, verifyWebauthnDevice } from '@18f/identity-webauthn'; function webauthn() { const webauthnInProgressContainer = document.getElementById('webauthn-auth-in-progress')!; @@ -23,7 +22,7 @@ function webauthn() { window.location.href = href; } else { // if platform auth is not supported on device, we should take user to the error screen if theres no additional methods. - WebAuthn.verifyWebauthnDevice({ + verifyWebauthnDevice({ userChallenge: (document.getElementById('user_challenge') as HTMLInputElement).value, credentialIds: (document.getElementById('credential_ids') as HTMLInputElement).value, }) diff --git a/docs/frontend.md b/docs/frontend.md index f10fec8b4c5..bde03ebc4f3 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -346,6 +346,5 @@ The application should support: You can find additional frontend documentation in relevant places throughout the code: - [`app/components/README.md`](../app/components/README.md) -- [`app/javascript/app/README.md`](../app/javascript/app/README.md) - [`app/javascript/packages/README.md`](../app/javascript/packages/README.md) - [`app/javascript/packs/README.md`](../app/javascript/packs/README.md) diff --git a/spec/javascript/app/webauthn_spec.js b/spec/javascript/app/webauthn_spec.js deleted file mode 100644 index 263efe96f57..00000000000 --- a/spec/javascript/app/webauthn_spec.js +++ /dev/null @@ -1,93 +0,0 @@ -import { TextEncoder } from 'util'; -import * as WebAuthn from '../../../app/javascript/app/webauthn'; - -describe('WebAuthn', () => { - let originalNavigator; - let originalCredentials; - beforeEach(() => { - originalNavigator = global.navigator; - originalCredentials = global.navigator.credentials; - global.navigator.credentials = { - get: () => {}, - }; - }); - - afterEach(() => { - global.navigator = originalNavigator; - global.navigator.credentials = originalCredentials; - }); - - describe('verifyWebauthnDevice', () => { - const userChallenge = '[1, 2, 3, 4, 5, 6, 7, 8]'; - const credentialIds = 'Y3JlZGVudGlhbDEyMw==,Y3JlZGVudGlhbDQ1Ng=='; // Base64-encoded 'credential123,credential456' - - it('enrolls a device using the proper get options', (done) => { - const expectedGetOptions = { - publicKey: { - challenge: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), - rpId: 'example.test', - allowCredentials: [ - { - id: new TextEncoder().encode('credential123').buffer, - type: 'public-key', - }, - { - id: new TextEncoder().encode('credential456').buffer, - type: 'public-key', - }, - ], - timeout: 800000, - }, - }; - - const getReturnValue = { - rawId: Buffer.from([214, 109]), // encodes to '123' - response: { - authenticatorData: Buffer.from([97, 117, 116, 104]), // decodes to 'auth' - clientDataJSON: Buffer.from([106, 115, 111, 110]), // decodes to 'json' - signature: Buffer.from([115, 105, 103]), // decodes to 'sig' - }, - }; - - const expectedReturnValue = { - credentialId: '1m0=', // Base64.encode64('123') - authenticatorData: 'YXV0aA==', // Base64.encode64('auth') - clientDataJSON: 'anNvbg==', // Base64.encode64('json') - signature: 'c2ln', // Base64.encode64('sig') - }; - - let getCalled = false; - navigator.credentials.get = (getOptions) => { - getCalled = true; - expect(getOptions).to.deep.equal(expectedGetOptions); - return Promise.resolve(getReturnValue); - }; - - WebAuthn.verifyWebauthnDevice({ - userChallenge, - credentialIds, - }) - .then((result) => { - expect(getCalled).to.eq(true); - expect(result).to.deep.equal(expectedReturnValue); - }) - .then(() => done()) - .catch(done); - }); - - it('forwards errors from the webauthn api', (done) => { - const dummyError = new Error('dummy error'); - navigator.credentials.get = () => Promise.reject(dummyError); - - WebAuthn.verifyWebauthnDevice({ - userChallenge, - credentialIds, - }) - .catch((error) => { - expect(error).to.equal(dummyError); - done(); - }) - .catch(done); - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index ef74bbd2346..bd84c43c54c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,6 @@ }, "include": [ "app/components", - "app/javascript/app", "app/javascript/packages", "app/javascript/packs", "spec/javascript/spec_helper.d.ts",