diff --git a/demos/using-dev-build/specs/auth.e2e.ts b/demos/using-dev-build/specs/auth.e2e.ts index b510c27791..1fa8f14c26 100644 --- a/demos/using-dev-build/specs/auth.e2e.ts +++ b/demos/using-dev-build/specs/auth.e2e.ts @@ -7,13 +7,6 @@ describe("authentication", () => { // Click on "Create an Internet Identity Anchor" await browser.$("#registerButton").click(); - // Set the name of the device and submit - const registerAlias = await browser.$("#pickAliasInput"); - await registerAlias.waitForExist(); - await registerAlias.setValue("My Device"); - - await browser.$('button[type="submit"]').click(); - // Construct Identity (no-op) const constructIdentity = await browser.$( '[data-action="construct-identity"]' diff --git a/package-lock.json b/package-lock.json index 498f036c4b..3f0bb5d189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,15 @@ "lit-html": "^2.7.2", "process": "^0.11.10", "qr-creator": "^1.0.0", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "ua-parser-js": "^1.0.35" }, "devDependencies": { "@testing-library/jest-dom": "~5", "@trust/webcrypto": "^0.9.2", "@types/jest": "~29", "@types/selenium-standalone": "^7.0.1", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "5.45.1", "@typescript-eslint/parser": "5.45.1", "@wdio/globals": "^8.6.9", @@ -1982,6 +1984,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "node_modules/@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -8945,7 +8953,6 @@ "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11077,6 +11084,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.3.tgz", "integrity": "sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==" }, + "@types/ua-parser-js": { + "version": "0.7.36", + "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", + "integrity": "sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==", + "dev": true + }, "@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -16279,8 +16292,7 @@ "ua-parser-js": { "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", - "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", - "dev": true + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==" }, "unbzip2-stream": { "version": "1.4.3", diff --git a/package.json b/package.json index 379ba66c1d..f2320fbaba 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@trust/webcrypto": "^0.9.2", "@types/jest": "~29", "@types/selenium-standalone": "^7.0.1", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "5.45.1", "@typescript-eslint/parser": "5.45.1", "@wdio/globals": "^8.6.9", @@ -58,7 +59,8 @@ "lit-html": "^2.7.2", "process": "^0.11.10", "qr-creator": "^1.0.0", - "stream-browserify": "^3.0.0" + "stream-browserify": "^3.0.0", + "ua-parser-js": "^1.0.35" }, "engines": { "npm": ">=9.0.0 <10.0.0", diff --git a/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts b/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts index 647cb0b8cd..11245366db 100644 --- a/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts +++ b/src/frontend/src/flows/addDevice/manage/addFIDODevice.ts @@ -1,7 +1,7 @@ import { DeviceData } from "$generated/internet_identity_types"; -import { promptDeviceAlias } from "$src/components/alias"; import { displayError } from "$src/components/displayError"; import { withLoader } from "$src/components/loader"; +import { inferAlias, loadUAParser } from "$src/flows/register"; import { authenticatorAttachmentToKeyType } from "$src/utils/authenticatorAttachment"; import { AuthenticatedConnection, @@ -39,6 +39,8 @@ export const addFIDODevice = async ( connection: AuthenticatedConnection, devices: DeviceData[] ): Promise => { + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); let newDevice: WebAuthnIdentity; try { newDevice = await WebAuthnIdentity.create({ @@ -56,11 +58,12 @@ export const addFIDODevice = async ( } return; } - const deviceName = await promptDeviceAlias({ title: "Add a Trusted Device" }); - if (deviceName === null) { - // user clicked "cancel", so we return - return; - } + + const deviceName = await inferAlias({ + authenticatorType: newDevice.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); try { await withLoader(() => connection.add( diff --git a/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts b/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts index f4d7098c27..58ab5bbac9 100644 --- a/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts +++ b/src/frontend/src/flows/addDevice/welcomeView/registerTentativeDevice.ts @@ -3,9 +3,9 @@ import { CredentialId, DeviceData, } from "$generated/internet_identity_types"; -import { promptDeviceAlias } from "$src/components/alias"; import { displayError } from "$src/components/displayError"; import { withLoader } from "$src/components/loader"; +import { inferAlias, loadUAParser } from "$src/flows/register"; import { authenticatorAttachmentToKeyType } from "$src/utils/authenticatorAttachment"; import { Connection, creationOptions } from "$src/utils/iiConnection"; import { setAnchorUsed } from "$src/utils/userNumber"; @@ -17,7 +17,6 @@ import { isDuplicateDeviceError, } from "$src/utils/webAuthnErrorUtils"; import { WebAuthnIdentity } from "@dfinity/identity"; -import { html } from "lit-html"; import { deviceRegistrationDisabledInfo } from "./deviceRegistrationModeDisabled"; import { showVerificationCode } from "./showVerificationCode"; @@ -29,17 +28,8 @@ export const registerTentativeDevice = async ( userNumber: bigint, connection: Connection ): Promise<{ alias: string }> => { - // First, we need an alias for the device to (tentatively) add - const alias = await promptDeviceAlias({ - title: "Add a Trusted Device", - message: html` What device do you want to add to Internet Identity - ${userNumber}?`, - }); - - if (alias === null) { - // TODO L2-309: do this without reload - return window.location.reload() as never; - } + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); // Then, we create local WebAuthn credentials for the device const result = await withLoader(() => @@ -66,6 +56,12 @@ export const registerTentativeDevice = async ( return window.location.reload() as never; } + const alias = await inferAlias({ + authenticatorType: result.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); + // Finally, we submit it to the canister const device: Omit & { credential_id: [CredentialId] } = { diff --git a/src/frontend/src/flows/manage/authenticatorsSection.ts b/src/frontend/src/flows/manage/authenticatorsSection.ts index c79cac6f2d..e698330aa9 100644 --- a/src/frontend/src/flows/manage/authenticatorsSection.ts +++ b/src/frontend/src/flows/manage/authenticatorsSection.ts @@ -67,7 +67,10 @@ export const authenticatorsSection = ({ ${ warnFewDevices - ? html`

+ ? html`

Add a Passkey or recovery method to make your Internet Identity more secure.

` diff --git a/src/frontend/src/flows/register/index.ts b/src/frontend/src/flows/register/index.ts index 311f7caf33..cb9d98ed6c 100644 --- a/src/frontend/src/flows/register/index.ts +++ b/src/frontend/src/flows/register/index.ts @@ -1,12 +1,13 @@ -import { promptDeviceAlias } from "$src/components/alias"; import { apiResultToLoginFlowResult, cancel, LoginFlowResult, } from "$src/utils/flowResult"; -import { Connection } from "$src/utils/iiConnection"; +import { Connection, IIWebAuthnIdentity } from "$src/utils/iiConnection"; import { setAnchorUsed } from "$src/utils/userNumber"; import { unknownToString } from "$src/utils/utils"; +import { nonNullish } from "@dfinity/utils"; +import type { UAParser } from "ua-parser-js"; import { promptCaptcha } from "./captcha"; import { displayUserNumber } from "./finish"; import { savePasskey } from "./passkey"; @@ -18,10 +19,8 @@ export const register = async ({ connection: Connection; }): Promise => { try { - const alias = await promptDeviceAlias({ title: "Register this device" }); - if (alias === null) { - return cancel; - } + // Kick-off fetching "ua-parser-js"; + const uaParser = loadUAParser(); // Kick-off the challenge request early, so that we might already // have a captcha to show once we get to the CAPTCHA screen @@ -31,6 +30,12 @@ export const register = async ({ return cancel; } + const alias = await inferAlias({ + authenticatorType: identity.getAuthenticatorAttachment(), + userAgent: navigator.userAgent, + uaParser, + }); + const captchaResult = await promptCaptcha({ connection, challenge: preloadedChallenge, @@ -57,3 +62,93 @@ export const register = async ({ }; } }; + +type AuthenticatorType = ReturnType< + IIWebAuthnIdentity["getAuthenticatorAttachment"] +>; +type PreloadedUAParser = ReturnType; + +// Logic for inferring a passkey alias based on the authenticator type & user agent +export const inferAlias = async ({ + authenticatorType, + userAgent, + uaParser: uaParser_, +}: { + authenticatorType: AuthenticatorType; + userAgent: typeof navigator.userAgent; + uaParser: PreloadedUAParser; +}): Promise => { + const UNNAMED = "Unnamed Passkey"; + const FIDO = "FIDO Passkey"; + const ICLOUD = "iCloud Passkey"; + + // If the authenticator is cross platform, then it's FIDO + if (authenticatorType === "cross-platform") { + return FIDO; + } + + // Otherwise, make sure the UA parser module is loaded, because + // everything from here will use UA heuristics + const UAParser = await uaParser_; + if (UAParser === undefined) { + return UNNAMED; + } + const uaParser = new UAParser(userAgent); + + if ( + authenticatorType === "platform" && + uaParser.getEngine().name === "WebKit" + ) { + // Safari, including Chrome, FireFox etc on iOS/iPadOs + const version = uaParser.getBrowser().version; + + if (nonNullish(version) && Number(version) >= 16.2) { + // Safari 16.2 enforce usage of iCloud passkeys + return ICLOUD; + } else { + // If the Safari version is older, then we just give the device (since + // each apple device like iPhone, iPad, etc has its own OS, there is no + // need to duplicate the info with the OS) + const device = uaParser.getDevice(); + if (nonNullish(device) && nonNullish(device.model)) { + return device.model; + } + } + } + + if ( + authenticatorType !== "platform" && + uaParser.getEngine().name === "Gecko" && + uaParser.getOS().name === "Mac OS" + ) { + // FireFox on Mac OS does not support TouchID, so if it's not a "platform" authenticator it's some sort + // of FIDO device, even if no authenticator type was provided + return FIDO; + } + + const os = uaParser.getOS().name; + + // As a last resort, we try to show something like "Chrome on Linux" or just "Chrome" or just "Linux" + const browser = uaParser.getBrowser().name; + const browserOn = [ + ...(nonNullish(browser) ? [browser] : []), + ...(nonNullish(os) ? [os] : []), + ]; + authenticatorType satisfies undefined | "platform"; + if (browserOn.length !== 0) { + return browserOn.join(" on "); + } + + // If all else fails, the device is unnamed + return UNNAMED; +}; + +// Dynamically load the user agent parser module +export const loadUAParser = async (): Promise => { + try { + return (await import("ua-parser-js")).default; + } catch (e) { + console.error(e); + return undefined; + } +}; diff --git a/src/frontend/src/styles/main.css b/src/frontend/src/styles/main.css index b4625195c2..edfc1d7da8 100644 --- a/src/frontend/src/styles/main.css +++ b/src/frontend/src/styles/main.css @@ -621,7 +621,7 @@ a:hover, .l-container { position: relative; font-size: 1.6rem; - min-width: 30rem; + min-width: 40rem; max-width: 40rem; /* centers the container and adds a bit of space to make sure the footer does not stick to it */ margin: 0 auto 2rem; diff --git a/src/frontend/src/test-e2e/constants.ts b/src/frontend/src/test-e2e/constants.ts index e8876d9cc7..9d1cf2c98a 100644 --- a/src/frontend/src/test-e2e/constants.ts +++ b/src/frontend/src/test-e2e/constants.ts @@ -13,6 +13,5 @@ export const REPLICA_URL = "https://icp-api.io"; export const II_URL = process.env.II_URL ?? "https://identity.internetcomputer.org"; -export const DEVICE_NAME1 = "Virtual WebAuthn device"; -export const DEVICE_NAME2 = "Other WebAuthn device"; +export const DEVICE_NAME1 = "FIDO Passkey"; export const RECOVERY_PHRASE_NAME = "Recovery Phrase"; diff --git a/src/frontend/src/test-e2e/flows.ts b/src/frontend/src/test-e2e/flows.ts index fee5be1e41..4dbe2ff984 100644 --- a/src/frontend/src/test-e2e/flows.ts +++ b/src/frontend/src/test-e2e/flows.ts @@ -14,7 +14,6 @@ export const FLOWS = { ): Promise { const registerView = new RegisterView(browser); await registerView.waitForDisplay(); - await registerView.enterAlias(deviceName); await registerView.create(); await registerView.waitForRegisterConfirm(); await registerView.confirmRegisterConfirm(); diff --git a/src/frontend/src/test-e2e/register.test.ts b/src/frontend/src/test-e2e/register.test.ts index 7002ded57b..133a127540 100644 --- a/src/frontend/src/test-e2e/register.test.ts +++ b/src/frontend/src/test-e2e/register.test.ts @@ -11,10 +11,8 @@ import { waitToClose, } from "./util"; import { - AddDeviceAliasView, AddDeviceSuccessView, AddIdentityAnchorView, - AddRemoteDeviceAliasView, AddRemoteDeviceInstructionsView, AddRemoteDeviceVerificationCodeView, AuthenticateView, @@ -30,7 +28,7 @@ import { // Read canister ids from the corresponding dfx files. // This assumes that they have been successfully dfx-deployed import { readFileSync } from "fs"; -import { II_URL, REPLICA_URL } from "./constants"; +import { DEVICE_NAME1, II_URL, REPLICA_URL } from "./constants"; export const test_app_canister_ids = JSON.parse( readFileSync("./demos/test-app/.dfx/local/canister_ids.json", "utf-8") ); @@ -39,9 +37,6 @@ const TEST_APP_CANISTER_ID = test_app_canister_ids.test_app.local; const TEST_APP_CANONICAL_URL = `https://${TEST_APP_CANISTER_ID}.ic0.app`; const TEST_APP_NICE_URL = "https://nice-name.com"; -const DEVICE_NAME1 = "Virtual WebAuthn device"; -const DEVICE_NAME2 = "Other WebAuthn device"; - test("Register new identity and login with it", async () => { await runInBrowser(async (browser: WebdriverIO.Browser) => { await browser.url(II_URL); @@ -84,11 +79,6 @@ test("Register new identity and add additional device", async () => { ); await addRemoteDeviceInstructionsView.addFIDODevice(); - const addDeviceAliasView = new AddDeviceAliasView(browser); - await addDeviceAliasView.waitForDisplay(); - await addDeviceAliasView.addAdditionalDevice(DEVICE_NAME2); - await addDeviceAliasView.continue(); - await browser.pause(10_000); // success page @@ -97,8 +87,9 @@ test("Register new identity and add additional device", async () => { await addDeviceSuccessView.continue(); // home - await mainView.waitForDeviceDisplay(DEVICE_NAME1); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + await mainView.waitForDisplay(); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); await mainView.logout(); await FLOWS.login(userNumber, DEVICE_NAME1, browser); @@ -125,10 +116,6 @@ test("Register new identity and add additional remote device", async () => { await runInBrowser(async (browser2: WebdriverIO.Browser) => { await addVirtualAuthenticator(browser2); await browser2.url(addDeviceLink); - const addRemoteDeviceView = new AddRemoteDeviceAliasView(browser2); - await addRemoteDeviceView.waitForDisplay(); - await addRemoteDeviceView.selectAlias(DEVICE_NAME2); - await addRemoteDeviceView.continue(); const verificationCodeView = new AddRemoteDeviceVerificationCodeView( browser2 @@ -151,8 +138,8 @@ test("Register new identity and add additional remote device", async () => { await addDeviceSuccessView.continue(); await mainView.waitForDisplay(); - await mainView.waitForDeviceDisplay(DEVICE_NAME1); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); // Verify success on Browser 2 // browser 2 again @@ -168,8 +155,8 @@ test("Register new identity and add additional remote device", async () => { // main page signed-in const mainView2 = new MainView(browser2); - await mainView2.waitForDeviceDisplay(DEVICE_NAME1); - await mainView2.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); }); }); }, 300_000); @@ -194,10 +181,6 @@ test("Register new identity and add additional remote device starting on new dev const addIdentityAnchorView2 = new AddIdentityAnchorView(browser2); await addIdentityAnchorView2.waitForDisplay(); await addIdentityAnchorView2.continue(userNumber); - const addRemoteDeviceView = new AddRemoteDeviceAliasView(browser2); - await addRemoteDeviceView.waitForDisplay(); - await addRemoteDeviceView.selectAlias(DEVICE_NAME2); - await addRemoteDeviceView.continue(); const notInRegistrationModeView = new NotInRegistrationModeView(browser2); await notInRegistrationModeView.waitForDisplay(); @@ -233,7 +216,8 @@ test("Register new identity and add additional remote device starting on new dev }); await mainView.waitForDisplay(); - await mainView.waitForDeviceDisplay(DEVICE_NAME2); + // Expect a second device with the default name + await mainView.waitForDeviceCount(DEVICE_NAME1, 2); }); }, 300_000); diff --git a/src/frontend/src/test-e2e/views.ts b/src/frontend/src/test-e2e/views.ts index 4125596fb5..88c6b68864 100644 --- a/src/frontend/src/test-e2e/views.ts +++ b/src/frontend/src/test-e2e/views.ts @@ -59,16 +59,11 @@ export class RenameView extends View { export class RegisterView extends View { async waitForDisplay(): Promise { await this.browser - .$("#pickAliasInput") + .$('[data-action="construct-identity"') .waitForDisplayed({ timeout: 10_000 }); } - async enterAlias(alias: string): Promise { - await this.browser.$("#pickAliasInput").setValue(alias); - } - async create(): Promise { - await this.browser.$("#pickAliasSubmit").click(); await this.browser.$('[data-action="construct-identity"').click(); } @@ -219,6 +214,13 @@ export class MainView extends View { .waitForDisplayed({ timeout: 10_000 }); } + async waitForDeviceCount(deviceName: string, count: number): Promise { + const elems = await this.browser.$$(`//li[@data-device="${deviceName}"]`); + if (elems.length !== count) { + throw Error("Bad number of elements"); + } + } + async waitForDeviceDisplay(deviceName: string): Promise { await this.browser .$(`//li[@data-device="${deviceName}"]`) @@ -365,22 +367,6 @@ export class MainView extends View { } } -export class AddDeviceAliasView extends View { - async waitForDisplay(): Promise { - await this.browser - .$("#pickAliasSubmit") - .waitForDisplayed({ timeout: 3_000 }); - } - - async addAdditionalDevice(alias: string): Promise { - await this.browser.$("#pickAliasInput").setValue(alias); - } - - async continue(): Promise { - await this.browser.$("#pickAliasSubmit").click(); - } -} - export class AddRemoteDeviceAliasView extends View { async waitForDisplay(): Promise { await this.browser