From f845505d68e648bf3e6dd8068f2abb8133c7c304 Mon Sep 17 00:00:00 2001 From: ryanbas21 Date: Mon, 24 Apr 2023 13:29:34 -0600 Subject: [PATCH] feat(javascript-sdk): register-device-name make it so `register` accepts a parameter for registering a device name --- .../autoscript.ts | 113 ++++++++++++++++++ .../index.html | 36 ++++++ e2e/autoscript-apps/src/index.html | 2 + e2e/autoscript-apps/webpack.config.js | 1 + e2e/mock-api/package.json | 5 +- e2e/mock-api/src/app/app.certs.js | 4 +- package-lock.json | 19 +++ packages/javascript-sdk/project.json | 2 +- .../javascript-sdk/src/fr-webauthn/index.ts | 65 +++++++--- 9 files changed, 229 insertions(+), 18 deletions(-) create mode 100644 e2e/autoscript-apps/src/authn-webauthn-device-registration/autoscript.ts create mode 100644 e2e/autoscript-apps/src/authn-webauthn-device-registration/index.html diff --git a/e2e/autoscript-apps/src/authn-webauthn-device-registration/autoscript.ts b/e2e/autoscript-apps/src/authn-webauthn-device-registration/autoscript.ts new file mode 100644 index 000000000..cca713724 --- /dev/null +++ b/e2e/autoscript-apps/src/authn-webauthn-device-registration/autoscript.ts @@ -0,0 +1,113 @@ +/* + * @forgerock/javascript-sdk + * + * autoscript.ts + * + * Copyright (c) 2020 ForgeRock. All rights reserved. + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +// @ts-nocheck +import * as forgerock from '@forgerock/javascript-sdk'; +import { delay as rxDelay, map, mergeMap } from 'rxjs/operators'; +import { fromEvent } from 'rxjs'; + +function autoscript() { + const delay = 0; + + const url = new URL(window.location.href); + const amUrl = url.searchParams.get('amUrl') || 'https://auth.example.com:9443/am'; + const realmPath = url.searchParams.get('realmPath') || 'root'; + const un = url.searchParams.get('un') || 'sdkuser'; + const pw = url.searchParams.get('pw') || 'password'; + const tree = url.searchParams.get('tree') || 'PasswordlessWebAuthn'; + console.log('Configure the SDK'); + forgerock.Config.set({ + realmPath, + tree, + serverConfig: { + baseUrl: amUrl, + }, + }); + + try { + forgerock.SessionManager.logout(); + } catch (err) { + // Do nothing + } + /*** + * Test Device Registration (Not Automated) + * must create cert because you cannot have a TLS error with webauthn to test manually + */ + /*** + * Test Device Registration (Not Automated) + * must create cert because you cannot have a TLS error with webauthn to test manually + */ + console.log('Click the device registration button!'); + const deviceRegistration = document.querySelector('.device-registration'); + fromEvent(deviceRegistration, 'click') + .pipe( + mergeMap(() => { + console.log('Initiate first step with `undefined`'); + return forgerock.FRAuth.next(); + }), + rxDelay(delay), + mergeMap((step) => { + console.log('Set username on auth tree callback'); + console.log(step); + step.getCallbackOfType('NameCallback').setName(un); + return forgerock.FRAuth.next(step); + }), + rxDelay(delay), + mergeMap((step) => { + console.log('Set password on auth tree callback'); + step.getCallbackOfType('PasswordCallback').setPassword(pw); + return forgerock.FRAuth.next(step); + }), + mergeMap(async (step) => { + const webAuthnStep = forgerock.FRWebAuthn.getWebAuthnStepType(step); + if (webAuthnStep === 2) { + console.log('WebAuthn step is registration'); + } else { + throw new Error('WebAuthn step is incorrectly identified'); + } + console.log('Handle WebAuthn Registration'); + try { + step = await forgerock.FRWebAuthn.register<'mydevice'>(step, 'mydevice'); + // ensure the step here has the 'mydevice' name at the end of the value. (outcome) + const deviceValue = step.getCallbacksOfType('HiddenValueCallback')[0].getInputValue(); + if (!deviceValue.includes('mydevice')) { + throw new Error('Device name is not correct'); + } + } catch (err) { + console.log(err); + } + return forgerock.FRAuth.next(step); + }), + map((step) => { + console.log('step', step); + if (step.payload.status === 401) { + throw new Error('Auth_Error'); + } else if (step.payload.tokenId) { + console.log('Basic login successful.'); + document.body.innerHTML = '

Login successful

'; + return step; + } else { + throw new Error('Something went wrong.'); + } + }), + ) + .subscribe({ + error: (err) => { + console.log(`Error: ${err.message}`); + document.body.innerHTML = `

${err.message}

`; + }, + complete: () => { + console.log('Test script complete'); + document.body.innerHTML = `

Test script complete

`; + }, + }); +} + +autoscript(); +export default autoscript; diff --git a/e2e/autoscript-apps/src/authn-webauthn-device-registration/index.html b/e2e/autoscript-apps/src/authn-webauthn-device-registration/index.html new file mode 100644 index 000000000..2fa12c7dc --- /dev/null +++ b/e2e/autoscript-apps/src/authn-webauthn-device-registration/index.html @@ -0,0 +1,36 @@ + + + + E2E Test | ForgeRock JavaScript SDK + + + + + + + + + + + + + + + + diff --git a/e2e/autoscript-apps/src/index.html b/e2e/autoscript-apps/src/index.html index 3e01ce48a..756eee366 100644 --- a/e2e/autoscript-apps/src/index.html +++ b/e2e/autoscript-apps/src/index.html @@ -35,6 +35,8 @@ AuthN: Social Login AM
AuthN: Social Login IDM
AuthN: WebAuthn
+ AuthN: WebAuthN Device Registration
AuthZ: Token
AuthZ: Tree-based with Basic Login
AuthZ: Tree-based with OAuth
diff --git a/e2e/autoscript-apps/webpack.config.js b/e2e/autoscript-apps/webpack.config.js index 7adab80bc..20e7ea973 100644 --- a/e2e/autoscript-apps/webpack.config.js +++ b/e2e/autoscript-apps/webpack.config.js @@ -25,6 +25,7 @@ const pages = [ 'authn-social-login-am', 'authn-social-login-idm', 'authn-webauthn', + 'authn-webauthn-device-registration', 'authz-token', 'authz-tree-basic', 'authz-txn-basic', diff --git a/e2e/mock-api/package.json b/e2e/mock-api/package.json index ab44394dd..99b685310 100644 --- a/e2e/mock-api/package.json +++ b/e2e/mock-api/package.json @@ -17,5 +17,8 @@ }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "devDependencies": { + "@types/superagent": "^4.1.16" + } } diff --git a/e2e/mock-api/src/app/app.certs.js b/e2e/mock-api/src/app/app.certs.js index 24e101c56..f799c88d8 100644 --- a/e2e/mock-api/src/app/app.certs.js +++ b/e2e/mock-api/src/app/app.certs.js @@ -15,5 +15,7 @@ const __dirname = path.dirname(__filename); const cert = readFileSync(path.resolve(__dirname, '../../../node_modules/lws/ssl/lws-cert.pem')); const key = readFileSync(path.resolve(__dirname, '../../../node_modules/lws/ssl/private-key.pem')); - +// for local testing +// const cert = readFileSync(path.resolve(__dirname, '../../../cert.pem')); +// const key = readFileSync(path.resolve(__dirname, '../../../key.pem')); export { cert, key }; diff --git a/package-lock.json b/package-lock.json index b76080612..b7ab613c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -113,6 +113,9 @@ "local-web-server": "5.1.1", "superagent": "7.1.1", "uuid": "8.3.2" + }, + "devDependencies": { + "@types/superagent": "^4.1.16" } }, "e2e/mock-api/node_modules/mime": { @@ -11672,6 +11675,12 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.37.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.37.0.tgz", @@ -11984,6 +11993,16 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/superagent": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.16.tgz", + "integrity": "sha512-tLfnlJf6A5mB6ddqF159GqcDizfzbMUB1/DeT59/wBNqzRTNNKsaw79A/1TZ84X+f/EwWH8FeuSkjlCLyqS/zQ==", + "dev": true, + "dependencies": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", diff --git a/packages/javascript-sdk/project.json b/packages/javascript-sdk/project.json index 31bf2aff0..30899a4a9 100644 --- a/packages/javascript-sdk/project.json +++ b/packages/javascript-sdk/project.json @@ -7,7 +7,7 @@ "build": { "executor": "@nrwl/rollup:rollup", "options": { - "compiler": "babel", + "compiler": "tsc", "entryFile": "packages/javascript-sdk/src/index.ts", "outputPath": "dist/packages/javascript-sdk", "assets": [ diff --git a/packages/javascript-sdk/src/fr-webauthn/index.ts b/packages/javascript-sdk/src/fr-webauthn/index.ts index 9c90f399b..427da3a16 100644 --- a/packages/javascript-sdk/src/fr-webauthn/index.ts +++ b/packages/javascript-sdk/src/fr-webauthn/index.ts @@ -20,6 +20,7 @@ import { parseRelyingPartyId, } from './helpers'; import { + AttestationType, RelyingParty, WebAuthnAuthenticationMetadata, WebAuthnCallbacks, @@ -29,6 +30,15 @@ import { import TextOutputCallback from '../fr-auth/callbacks/text-output-callback'; import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser'; +// :::::: +type OutcomeWithName< + ClientId extends string, + Attestation extends AttestationType, + PubKeyCred extends PublicKeyCredential, + Name = '', +> = Name extends infer P extends string + ? `${ClientId}::${Attestation}::${PubKeyCred['id']}${P extends '' ? '' : `::${P}`}` + : never; // JSON-based WebAuthn type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata; // Script-based WebAuthn @@ -92,7 +102,7 @@ abstract class FRWebAuthn { public static async authenticate(step: FRStep): Promise { const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); if (hiddenCallback && (metadataCallback || textOutputCallback)) { - let outcome: string; + let outcome: ReturnType; try { let publicKey: PublicKeyCredentialRequestOptions; @@ -127,17 +137,22 @@ abstract class FRWebAuthn { throw e; } } - /** * Populates the step with the necessary registration outcome. * * @param step The step that contains WebAuthn registration data * @return The populated step */ - public static async register(step: FRStep): Promise { + // Can make this generic const in Typescritp 5.0 > and the name itself will + // be inferred from the type so `typeof deviceName` will not just return string + // but the actual name of the deviceName passed in as a generic. + public static async register( + step: FRStep, + deviceName?: T, + ): Promise { const { hiddenCallback, metadataCallback, textOutputCallback } = this.getCallbacks(step); if (hiddenCallback && (metadataCallback || textOutputCallback)) { - let outcome: string; + let outcome: OutcomeWithName; try { let publicKey: PublicKeyCredentialRequestOptions; @@ -147,10 +162,9 @@ abstract class FRWebAuthn { } else if (textOutputCallback) { publicKey = parseWebAuthnRegisterText(textOutputCallback.getMessage()); } - // TypeScript doesn't like `publicKey` being assigned in conditionals above - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const credential = await this.getRegistrationCredential(publicKey); + const credential = await this.getRegistrationCredential( + publicKey as PublicKeyCredentialCreationOptions, + ); outcome = this.getRegistrationOutcome(credential); } catch (error) { if (!(error instanceof Error)) throw error; @@ -162,8 +176,7 @@ abstract class FRWebAuthn { hiddenCallback.setInputValue(`${WebAuthnOutcome.Error}::${error.name}:${error.message}`); throw error; } - - hiddenCallback.setInputValue(outcome); + hiddenCallback.setInputValue(deviceName ? `${outcome}::${deviceName}` : outcome); return step; } else { const e = new Error('Incorrect callbacks for WebAuthn registration'); @@ -263,7 +276,11 @@ abstract class FRWebAuthn { * @param credential The credential to convert * @return The outcome string */ - public static getAuthenticationOutcome(credential: PublicKeyCredential | null): string { + public static getAuthenticationOutcome( + credential: PublicKeyCredential | null, + ): + | OutcomeWithName + | OutcomeWithName { if (credential === null) { const e = new Error('No credential generated from authentication'); e.name = WebAuthnOutcomeType.UnknownError; @@ -273,7 +290,9 @@ abstract class FRWebAuthn { try { const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); const assertionResponse = credential.response as AuthenticatorAssertionResponse; - const authenticatorData = new Int8Array(assertionResponse.authenticatorData).toString(); + const authenticatorData = new Int8Array( + assertionResponse.authenticatorData, + ).toString() as AttestationType; const signature = new Int8Array(assertionResponse.signature).toString(); // Current native typing for PublicKeyCredential does not include `userHandle` @@ -281,11 +300,23 @@ abstract class FRWebAuthn { // @ts-ignore const userHandle = arrayBufferToString(credential.response.userHandle); - let stringOutput = `${clientDataJSON}::${authenticatorData}::${signature}::${credential.id}`; + let stringOutput = + `${clientDataJSON}::${authenticatorData}::${signature}::${credential.id}` as OutcomeWithName< + string, + AttestationType, + PublicKeyCredential + >; // Check if Username is stored on device if (userHandle) { stringOutput = `${stringOutput}::${userHandle}`; + return stringOutput as OutcomeWithName< + string, + AttestationType, + PublicKeyCredential, + string + >; } + return stringOutput; } catch (error) { const e = new Error('Transforming credential object to string failed'); @@ -321,7 +352,9 @@ abstract class FRWebAuthn { * @param credential The credential to convert * @return The outcome string */ - public static getRegistrationOutcome(credential: PublicKeyCredential | null): string { + public static getRegistrationOutcome( + credential: PublicKeyCredential | null, + ): OutcomeWithName { if (credential === null) { const e = new Error('No credential generated from registration'); e.name = WebAuthnOutcomeType.UnknownError; @@ -331,7 +364,9 @@ abstract class FRWebAuthn { try { const clientDataJSON = arrayBufferToString(credential.response.clientDataJSON); const attestationResponse = credential.response as AuthenticatorAttestationResponse; - const attestationObject = new Int8Array(attestationResponse.attestationObject).toString(); + const attestationObject = new Int8Array( + attestationResponse.attestationObject, + ).toString() as AttestationType.Direct; return `${clientDataJSON}::${attestationObject}::${credential.id}`; } catch (error) { const e = new Error('Transforming credential object to string failed');