Skip to content

Commit

Permalink
Infer authenticator aliases (#1556)
Browse files Browse the repository at this point in the history
* Infer authenticator aliases

This introduces heuristics to give default aliases to authenticators.
The alias prompt is removed from the flows (the user will have to
explicitly rename the device if the alias is not ok).

The UAParser module is lazily loaded to make sure existing users don't
incur the cost of an increased bundle size.

* Clean up
  • Loading branch information
nmattia authored May 5, 2023
1 parent 705ff2e commit 8c2eccc
Show file tree
Hide file tree
Showing 12 changed files with 162 additions and 90 deletions.
7 changes: 0 additions & 7 deletions demos/using-dev-build/specs/auth.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]'
Expand Down
20 changes: 16 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 9 additions & 6 deletions src/frontend/src/flows/addDevice/manage/addFIDODevice.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -39,6 +39,8 @@ export const addFIDODevice = async (
connection: AuthenticatedConnection,
devices: DeviceData[]
): Promise<void> => {
// Kick-off fetching "ua-parser-js";
const uaParser = loadUAParser();
let newDevice: WebAuthnIdentity;
try {
newDevice = await WebAuthnIdentity.create({
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand All @@ -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
<strong class="t-strong">${userNumber}</strong>?`,
});

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(() =>
Expand All @@ -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<DeviceData, "origin"> & { credential_id: [CredentialId] } =
{
Expand Down
5 changes: 4 additions & 1 deletion src/frontend/src/flows/manage/authenticatorsSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ export const authenticatorsSection = ({
</div>
${
warnFewDevices
? html`<p class="warning-message t-paragraph t-lead">
? html`<p
style="max-width: 30rem;"
class="warning-message t-paragraph t-lead"
>
Add a Passkey or recovery method to make your Internet Identity
more secure.
</p>`
Expand Down
107 changes: 101 additions & 6 deletions src/frontend/src/flows/register/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -18,10 +19,8 @@ export const register = async ({
connection: Connection;
}): Promise<LoginFlowResult> => {
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
Expand All @@ -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,
Expand All @@ -57,3 +62,93 @@ export const register = async ({
};
}
};

type AuthenticatorType = ReturnType<
IIWebAuthnIdentity["getAuthenticatorAttachment"]
>;
type PreloadedUAParser = ReturnType<typeof loadUAParser>;

// 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<string> => {
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<typeof UAParser | undefined> => {
try {
return (await import("ua-parser-js")).default;
} catch (e) {
console.error(e);
return undefined;
}
};
2 changes: 1 addition & 1 deletion src/frontend/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions src/frontend/src/test-e2e/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
1 change: 0 additions & 1 deletion src/frontend/src/test-e2e/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export const FLOWS = {
): Promise<string> {
const registerView = new RegisterView(browser);
await registerView.waitForDisplay();
await registerView.enterAlias(deviceName);
await registerView.create();
await registerView.waitForRegisterConfirm();
await registerView.confirmRegisterConfirm();
Expand Down
Loading

0 comments on commit 8c2eccc

Please sign in to comment.